mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-01 05:00:46 +00:00
feat(cli): add detail mode to /context and track loaded skill bodies
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
parent
b629de35cf
commit
4de9688543
7 changed files with 246 additions and 78 deletions
|
|
@ -40,6 +40,7 @@ describe('clearCommand', () => {
|
|||
resetChat: mockResetChat,
|
||||
}) as unknown as GeminiClient,
|
||||
startNewSession: mockStartNewSession,
|
||||
getToolRegistry: () => undefined,
|
||||
},
|
||||
},
|
||||
session: {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,11 @@
|
|||
import type { SlashCommand } from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { uiTelemetryService } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
uiTelemetryService,
|
||||
ToolNames,
|
||||
SkillTool,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
export const clearCommand: SlashCommand = {
|
||||
name: 'clear',
|
||||
|
|
@ -25,6 +29,15 @@ export const clearCommand: SlashCommand = {
|
|||
// Reset UI telemetry metrics for the new session
|
||||
uiTelemetryService.reset();
|
||||
|
||||
// Clear loaded-skills tracking so /context doesn't show stale data
|
||||
const skillTool = config
|
||||
.getToolRegistry()
|
||||
?.getAllTools()
|
||||
.find((tool) => tool.name === ToolNames.SKILL);
|
||||
if (skillTool instanceof SkillTool) {
|
||||
skillTool.clearLoadedSkills();
|
||||
}
|
||||
|
||||
if (newSessionId && context.session.startNewSession) {
|
||||
context.session.startNewSession(newSessionId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ import {
|
|||
getCoreSystemPrompt,
|
||||
DEFAULT_TOKEN_LIMIT,
|
||||
ToolNames,
|
||||
SkillTool,
|
||||
buildSkillLlmContent,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
|
|
@ -88,10 +90,15 @@ function parseMemoryFiles(memoryContent: string): ContextMemoryDetail[] {
|
|||
export const contextCommand: SlashCommand = {
|
||||
name: 'context',
|
||||
get description() {
|
||||
return t('Show context window usage breakdown.');
|
||||
return t(
|
||||
'Show context window usage breakdown. Use "/context detail" for per-item breakdown.',
|
||||
);
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context: CommandContext) => {
|
||||
action: async (context: CommandContext, args?: string) => {
|
||||
const showDetails =
|
||||
args?.trim().toLowerCase() === 'detail' ||
|
||||
args?.trim().toLowerCase() === '-d';
|
||||
const { config } = context.services;
|
||||
if (!config) {
|
||||
context.ui.addItem(
|
||||
|
|
@ -153,30 +160,51 @@ export const contextCommand: SlashCommand = {
|
|||
const memoryFilesTokens = memoryFiles.reduce((sum, f) => sum + f.tokens, 0);
|
||||
|
||||
// 5. Skills (progressive disclosure)
|
||||
// The SkillTool's description embeds all skill name+description listings
|
||||
// plus ~600 chars of instruction text. This is the "always in context"
|
||||
// cost. The full SKILL.md body is only loaded on-demand when the model
|
||||
// invokes the skill tool (and that cost appears in Messages).
|
||||
//
|
||||
// To get an accurate total, we read the SkillTool's actual schema from
|
||||
// the registry rather than reconstructing from a template.
|
||||
// Two cost components:
|
||||
// a) Tool definition: SkillTool's description embeds all skill
|
||||
// name+description listings plus instruction text — always in context.
|
||||
// b) Loaded bodies: When the model invokes a skill, the full SKILL.md
|
||||
// body is injected into the conversation as a tool result. We track
|
||||
// which skills have been loaded and attribute their body tokens here
|
||||
// so the "Skills" category accurately reflects the total cost.
|
||||
const skillTool = allTools.find((tool) => tool.name === ToolNames.SKILL);
|
||||
const skillToolTotalTokens = skillTool
|
||||
const skillToolDefinitionTokens = skillTool
|
||||
? estimateTokens(JSON.stringify(skillTool.schema))
|
||||
: 0;
|
||||
|
||||
// Per-skill breakdown for detail display (proportional to description length)
|
||||
// Determine which skills have been loaded in this session
|
||||
const loadedSkillNames: ReadonlySet<string> =
|
||||
skillTool instanceof SkillTool
|
||||
? skillTool.getLoadedSkillNames()
|
||||
: new Set();
|
||||
|
||||
// Per-skill breakdown: listing cost + body cost for loaded skills
|
||||
const skillManager = config.getSkillManager();
|
||||
const skillConfigs = skillManager ? await skillManager.listSkills() : [];
|
||||
const skills: ContextSkillDetail[] = skillConfigs.map((skill) => ({
|
||||
name: skill.name,
|
||||
tokens: estimateTokens(
|
||||
let loadedBodiesTokens = 0;
|
||||
const skills: ContextSkillDetail[] = skillConfigs.map((skill) => {
|
||||
const listingTokens = estimateTokens(
|
||||
`<skill>\n<name>\n${skill.name}\n</name>\n<description>\n${skill.description} (${skill.level})\n</description>\n<location>\n${skill.level}\n</location>\n</skill>`,
|
||||
),
|
||||
}));
|
||||
// Use the SkillTool's actual schema tokens as the total, not the sum of
|
||||
// individual estimates (which would miss the instruction wrapper text).
|
||||
const skillsTokens = skillToolTotalTokens;
|
||||
);
|
||||
const isLoaded = loadedSkillNames.has(skill.name);
|
||||
let bodyTokens: number | undefined;
|
||||
if (isLoaded && skill.body) {
|
||||
const baseDir = skill.filePath
|
||||
? skill.filePath.replace(/\/[^/]+$/, '')
|
||||
: '';
|
||||
bodyTokens = estimateTokens(buildSkillLlmContent(baseDir, skill.body));
|
||||
loadedBodiesTokens += bodyTokens;
|
||||
}
|
||||
return {
|
||||
name: skill.name,
|
||||
tokens: listingTokens,
|
||||
loaded: isLoaded,
|
||||
bodyTokens,
|
||||
};
|
||||
});
|
||||
|
||||
// Total skills cost = tool definition + loaded bodies
|
||||
const skillsTokens = skillToolDefinitionTokens + loadedBodiesTokens;
|
||||
|
||||
// 6. Autocompact buffer
|
||||
const compressionThreshold =
|
||||
|
|
@ -187,8 +215,14 @@ export const contextCommand: SlashCommand = {
|
|||
? Math.round((1 - compressionThreshold) * contextWindowSize)
|
||||
: 0;
|
||||
|
||||
// 7. Calculate raw overhead (allToolsTokens already includes skills)
|
||||
const rawOverhead = systemPromptTokens + allToolsTokens + memoryFilesTokens;
|
||||
// 7. Calculate raw overhead
|
||||
// allToolsTokens includes the skill tool definition; loadedBodiesTokens
|
||||
// covers the on-demand skill bodies now attributed to Skills.
|
||||
const rawOverhead =
|
||||
systemPromptTokens +
|
||||
allToolsTokens +
|
||||
memoryFilesTokens +
|
||||
loadedBodiesTokens;
|
||||
|
||||
// 8. Determine total tokens and build breakdown
|
||||
const isEstimated = apiTotalTokens === 0;
|
||||
|
|
@ -219,14 +253,15 @@ export const contextCommand: SlashCommand = {
|
|||
// once real API data arrives.
|
||||
totalTokens = 0;
|
||||
displaySystemPrompt = systemPromptTokens;
|
||||
// builtinTools category = allTools - skills - mcpTools
|
||||
// Skills = tool definition + loaded bodies
|
||||
displaySkills = skillsTokens;
|
||||
// builtinTools = allTools minus skills-definition minus mcpTools
|
||||
displayBuiltinTools = Math.max(
|
||||
0,
|
||||
allToolsTokens - skillsTokens - mcpToolsTotalTokens,
|
||||
allToolsTokens - skillToolDefinitionTokens - mcpToolsTotalTokens,
|
||||
);
|
||||
displayMcpTools = mcpToolsTotalTokens;
|
||||
displayMemoryFiles = memoryFilesTokens;
|
||||
displaySkills = skillsTokens;
|
||||
messagesTokens = 0;
|
||||
// Free space accounts for the estimated overhead
|
||||
freeSpace = Math.max(
|
||||
|
|
@ -249,16 +284,24 @@ export const contextCommand: SlashCommand = {
|
|||
displaySystemPrompt = Math.round(systemPromptTokens * overheadScale);
|
||||
const scaledAllTools = Math.round(allToolsTokens * overheadScale);
|
||||
displayMemoryFiles = Math.round(memoryFilesTokens * overheadScale);
|
||||
// Skills = tool definition + loaded bodies (scaled together)
|
||||
displaySkills = Math.round(skillsTokens * overheadScale);
|
||||
const scaledMcpTotal = Math.round(mcpToolsTotalTokens * overheadScale);
|
||||
displayMcpTools = scaledMcpTotal;
|
||||
// builtinTools = allTools minus skill-definition minus mcpTools
|
||||
const scaledSkillDefinition = Math.round(
|
||||
skillToolDefinitionTokens * overheadScale,
|
||||
);
|
||||
displayBuiltinTools = Math.max(
|
||||
0,
|
||||
scaledAllTools - displaySkills - scaledMcpTotal,
|
||||
scaledAllTools - scaledSkillDefinition - scaledMcpTotal,
|
||||
);
|
||||
|
||||
const scaledOverhead =
|
||||
displaySystemPrompt + scaledAllTools + displayMemoryFiles;
|
||||
displaySystemPrompt +
|
||||
scaledAllTools +
|
||||
displayMemoryFiles +
|
||||
Math.round(loadedBodiesTokens * overheadScale);
|
||||
messagesTokens = Math.max(0, totalTokens - scaledOverhead);
|
||||
|
||||
freeSpace = Math.max(
|
||||
|
|
@ -278,7 +321,16 @@ export const contextCommand: SlashCommand = {
|
|||
detailBuiltinTools = scaleDetail(builtinTools);
|
||||
detailMcpTools = scaleDetail(mcpTools);
|
||||
detailMemoryFiles = scaleDetail(memoryFiles);
|
||||
detailSkills = scaleDetail(skills);
|
||||
detailSkills =
|
||||
overheadScale < 1
|
||||
? skills.map((item) => ({
|
||||
...item,
|
||||
tokens: Math.round(item.tokens * overheadScale),
|
||||
bodyTokens: item.bodyTokens
|
||||
? Math.round(item.bodyTokens * overheadScale)
|
||||
: undefined,
|
||||
}))
|
||||
: skills;
|
||||
}
|
||||
|
||||
const breakdown: ContextCategoryBreakdown = {
|
||||
|
|
@ -303,6 +355,7 @@ export const contextCommand: SlashCommand = {
|
|||
memoryFiles: detailMemoryFiles,
|
||||
skills: detailSkills,
|
||||
isEstimated,
|
||||
showDetails,
|
||||
};
|
||||
|
||||
context.ui.addItem(contextUsageItem, Date.now());
|
||||
|
|
|
|||
|
|
@ -193,6 +193,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
|
|||
memoryFiles={itemForDisplay.memoryFiles}
|
||||
skills={itemForDisplay.skills}
|
||||
isEstimated={itemForDisplay.isEstimated}
|
||||
showDetails={itemForDisplay.showDetails}
|
||||
/>
|
||||
)}
|
||||
{itemForDisplay.type === 'insight_progress' && (
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ interface ContextUsageProps {
|
|||
skills: ContextSkillDetail[];
|
||||
/** True when totalTokens is estimated (no API call yet) */
|
||||
isEstimated?: boolean;
|
||||
/** When true, show per-item detail breakdowns. Default: false (compact). */
|
||||
showDetails?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -152,6 +154,7 @@ export const ContextUsage: React.FC<ContextUsageProps> = ({
|
|||
memoryFiles,
|
||||
skills,
|
||||
isEstimated,
|
||||
showDetails = false,
|
||||
}) => {
|
||||
const percentage =
|
||||
contextWindowSize > 0 ? (totalTokens / contextWindowSize) * 100 : 0;
|
||||
|
|
@ -164,7 +167,13 @@ export const ContextUsage: React.FC<ContextUsageProps> = ({
|
|||
const sortedMemoryFiles = [...memoryFiles].sort(
|
||||
(a, b) => b.tokens - a.tokens,
|
||||
);
|
||||
const sortedSkills = [...skills].sort((a, b) => b.tokens - a.tokens);
|
||||
// Sort skills: loaded first, then by total token cost descending
|
||||
const sortedSkills = [...skills].sort((a, b) => {
|
||||
if (a.loaded !== b.loaded) return a.loaded ? -1 : 1;
|
||||
const aTotal = a.tokens + (a.bodyTokens ?? 0);
|
||||
const bTotal = b.tokens + (b.bodyTokens ?? 0);
|
||||
return bTotal - aTotal;
|
||||
});
|
||||
|
||||
return (
|
||||
<Box
|
||||
|
|
@ -307,55 +316,107 @@ export const ContextUsage: React.FC<ContextUsageProps> = ({
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* Built-in tools detail */}
|
||||
{sortedBuiltinTools.length > 0 && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{t('Built-in tools')}
|
||||
</Text>
|
||||
{sortedBuiltinTools.map((tool) => (
|
||||
<DetailRow key={tool.name} name={tool.name} tokens={tool.tokens} />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
{showDetails ? (
|
||||
<>
|
||||
{/* Built-in tools detail */}
|
||||
{sortedBuiltinTools.length > 0 && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{t('Built-in tools')}
|
||||
</Text>
|
||||
{sortedBuiltinTools.map((tool) => (
|
||||
<DetailRow
|
||||
key={tool.name}
|
||||
name={tool.name}
|
||||
tokens={tool.tokens}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* MCP Tools detail */}
|
||||
{sortedMcpTools.length > 0 && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{t('MCP tools')}
|
||||
</Text>
|
||||
{sortedMcpTools.map((tool) => (
|
||||
<DetailRow key={tool.name} name={tool.name} tokens={tool.tokens} />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
{/* MCP Tools detail */}
|
||||
{sortedMcpTools.length > 0 && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{t('MCP tools')}
|
||||
</Text>
|
||||
{sortedMcpTools.map((tool) => (
|
||||
<DetailRow
|
||||
key={tool.name}
|
||||
name={tool.name}
|
||||
tokens={tool.tokens}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Memory files detail */}
|
||||
{sortedMemoryFiles.length > 0 && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{t('Memory files')}
|
||||
</Text>
|
||||
{sortedMemoryFiles.map((file) => (
|
||||
<DetailRow key={file.path} name={file.path} tokens={file.tokens} />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
{/* Memory files detail */}
|
||||
{sortedMemoryFiles.length > 0 && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{t('Memory files')}
|
||||
</Text>
|
||||
{sortedMemoryFiles.map((file) => (
|
||||
<DetailRow
|
||||
key={file.path}
|
||||
name={file.path}
|
||||
tokens={file.tokens}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Skills detail */}
|
||||
{sortedSkills.length > 0 && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{t('Skills')}
|
||||
{/* Skills detail */}
|
||||
{sortedSkills.length > 0 && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{t('Skills')}
|
||||
</Text>
|
||||
{sortedSkills.map((skill) => (
|
||||
<Box key={skill.name} flexDirection="column">
|
||||
<Box width={CONTENT_WIDTH} paddingLeft={2}>
|
||||
<Text color={theme.text.secondary}>{'\u2514'} </Text>
|
||||
<Box width={32}>
|
||||
<Text color={theme.text.link}>
|
||||
{truncateName(skill.name, DETAIL_NAME_MAX_LEN)}
|
||||
</Text>
|
||||
{skill.loaded && (
|
||||
<Text color={theme.status.success}> {t('active')}</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box flexGrow={1} justifyContent="flex-end">
|
||||
<Text color={theme.text.secondary}>
|
||||
{formatTokens(skill.tokens)} {t('tokens')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
{skill.loaded &&
|
||||
skill.bodyTokens != null &&
|
||||
skill.bodyTokens > 0 && (
|
||||
<Box width={CONTENT_WIDTH} paddingLeft={4}>
|
||||
<Text color={theme.text.secondary}>{' \u2514'} </Text>
|
||||
<Box width={30}>
|
||||
<Text color={theme.text.secondary} italic>
|
||||
{t('body loaded')}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} justifyContent="flex-end">
|
||||
<Text color={theme.status.success}>
|
||||
+{formatTokens(skill.bodyTokens)} {t('tokens')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary} italic>
|
||||
{t('Run /context detail for per-item breakdown.')}
|
||||
</Text>
|
||||
{sortedSkills.map((skill) => (
|
||||
<DetailRow
|
||||
key={skill.name}
|
||||
name={skill.name}
|
||||
tokens={skill.tokens}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -282,7 +282,12 @@ export interface ContextMemoryDetail {
|
|||
|
||||
export interface ContextSkillDetail {
|
||||
name: string;
|
||||
/** Token cost of the skill listing (name+description) in the tool definition */
|
||||
tokens: number;
|
||||
/** Whether this skill has been invoked and its full body loaded into context */
|
||||
loaded?: boolean;
|
||||
/** Token cost of the loaded SKILL.md body (only set when loaded is true) */
|
||||
bodyTokens?: number;
|
||||
}
|
||||
|
||||
export type HistoryItemContextUsage = HistoryItemBase & {
|
||||
|
|
@ -297,6 +302,8 @@ export type HistoryItemContextUsage = HistoryItemBase & {
|
|||
skills: ContextSkillDetail[];
|
||||
/** True when totalTokens is estimated (no API call yet) rather than from API response */
|
||||
isEstimated?: boolean;
|
||||
/** When true, show per-item detail sections (tools, memory, skills). Default: false (compact). */
|
||||
showDetails?: boolean;
|
||||
};
|
||||
|
||||
export type HistoryItemInsightProgress = HistoryItemBase & {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue