diff --git a/packages/cli/src/ui/commands/clearCommand.test.ts b/packages/cli/src/ui/commands/clearCommand.test.ts index e94c974fb..1617a2f75 100644 --- a/packages/cli/src/ui/commands/clearCommand.test.ts +++ b/packages/cli/src/ui/commands/clearCommand.test.ts @@ -40,6 +40,7 @@ describe('clearCommand', () => { resetChat: mockResetChat, }) as unknown as GeminiClient, startNewSession: mockStartNewSession, + getToolRegistry: () => undefined, }, }, session: { diff --git a/packages/cli/src/ui/commands/clearCommand.ts b/packages/cli/src/ui/commands/clearCommand.ts index dd774934b..4f3530861 100644 --- a/packages/cli/src/ui/commands/clearCommand.ts +++ b/packages/cli/src/ui/commands/clearCommand.ts @@ -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); } diff --git a/packages/cli/src/ui/commands/contextCommand.ts b/packages/cli/src/ui/commands/contextCommand.ts index e4df88029..b4b7f4f04 100644 --- a/packages/cli/src/ui/commands/contextCommand.ts +++ b/packages/cli/src/ui/commands/contextCommand.ts @@ -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 = + 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( `\n\n${skill.name}\n\n\n${skill.description} (${skill.level})\n\n\n${skill.level}\n\n`, - ), - })); - // 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()); diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index d53d233e0..6b2fb7cba 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -193,6 +193,7 @@ const HistoryItemDisplayComponent: React.FC = ({ memoryFiles={itemForDisplay.memoryFiles} skills={itemForDisplay.skills} isEstimated={itemForDisplay.isEstimated} + showDetails={itemForDisplay.showDetails} /> )} {itemForDisplay.type === 'insight_progress' && ( diff --git a/packages/cli/src/ui/components/views/ContextUsage.tsx b/packages/cli/src/ui/components/views/ContextUsage.tsx index 753f40890..f6bed1d26 100644 --- a/packages/cli/src/ui/components/views/ContextUsage.tsx +++ b/packages/cli/src/ui/components/views/ContextUsage.tsx @@ -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 = ({ memoryFiles, skills, isEstimated, + showDetails = false, }) => { const percentage = contextWindowSize > 0 ? (totalTokens / contextWindowSize) * 100 : 0; @@ -164,7 +167,13 @@ export const ContextUsage: React.FC = ({ 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 ( = ({ /> )} - {/* Built-in tools detail */} - {sortedBuiltinTools.length > 0 && ( - - - {t('Built-in tools')} - - {sortedBuiltinTools.map((tool) => ( - - ))} - - )} + {showDetails ? ( + <> + {/* Built-in tools detail */} + {sortedBuiltinTools.length > 0 && ( + + + {t('Built-in tools')} + + {sortedBuiltinTools.map((tool) => ( + + ))} + + )} - {/* MCP Tools detail */} - {sortedMcpTools.length > 0 && ( - - - {t('MCP tools')} - - {sortedMcpTools.map((tool) => ( - - ))} - - )} + {/* MCP Tools detail */} + {sortedMcpTools.length > 0 && ( + + + {t('MCP tools')} + + {sortedMcpTools.map((tool) => ( + + ))} + + )} - {/* Memory files detail */} - {sortedMemoryFiles.length > 0 && ( - - - {t('Memory files')} - - {sortedMemoryFiles.map((file) => ( - - ))} - - )} + {/* Memory files detail */} + {sortedMemoryFiles.length > 0 && ( + + + {t('Memory files')} + + {sortedMemoryFiles.map((file) => ( + + ))} + + )} - {/* Skills detail */} - {sortedSkills.length > 0 && ( - - - {t('Skills')} + {/* Skills detail */} + {sortedSkills.length > 0 && ( + + + {t('Skills')} + + {sortedSkills.map((skill) => ( + + + {'\u2514'} + + + {truncateName(skill.name, DETAIL_NAME_MAX_LEN)} + + {skill.loaded && ( + {t('active')} + )} + + + + {formatTokens(skill.tokens)} {t('tokens')} + + + + {skill.loaded && + skill.bodyTokens != null && + skill.bodyTokens > 0 && ( + + {' \u2514'} + + + {t('body loaded')} + + + + + +{formatTokens(skill.bodyTokens)} {t('tokens')} + + + + )} + + ))} + + )} + + ) : ( + + + {t('Run /context detail for per-item breakdown.')} - {sortedSkills.map((skill) => ( - - ))} )} diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 21b354c75..7d75f8bca 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -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 & { diff --git a/packages/core/src/tools/skill.ts b/packages/core/src/tools/skill.ts index 68ec7dd55..b97f52c27 100644 --- a/packages/core/src/tools/skill.ts +++ b/packages/core/src/tools/skill.ts @@ -20,6 +20,15 @@ export interface SkillParams { skill: string; } +/** + * Builds the LLM-facing content string when a skill body is injected. + * Shared between SkillToolInvocation (runtime) and /context (estimation) + * so that token estimates stay in sync with actual usage. + */ +export function buildSkillLlmContent(baseDir: string, body: string): string { + return `Base directory for this skill: ${baseDir}\nImportant: ALWAYS resolve absolute paths from this base directory when working with skills.\n\n${body}\n`; +} + /** * Skill tool that enables the model to access skill definitions. * The tool dynamically loads available skills and includes them in its description @@ -30,6 +39,7 @@ export class SkillTool extends BaseDeclarativeTool { private skillManager: SkillManager; private availableSkills: SkillConfig[] = []; + private loadedSkillNames: Set = new Set(); constructor(private readonly config: Config) { // Initialize with a basic schema first @@ -176,12 +186,34 @@ ${skillDescriptions} } protected createInvocation(params: SkillParams) { - return new SkillToolInvocation(this.config, this.skillManager, params); + return new SkillToolInvocation( + this.config, + this.skillManager, + params, + (name: string) => this.loadedSkillNames.add(name), + ); } getAvailableSkillNames(): string[] { return this.availableSkills.map((skill) => skill.name); } + + /** + * Returns the set of skill names that have been successfully loaded + * (invoked) during the current session. Used by /context to attribute + * loaded skill body tokens separately from the tool-definition cost. + */ + getLoadedSkillNames(): ReadonlySet { + return this.loadedSkillNames; + } + + /** + * Clears the loaded-skills tracking. Should be called when the session + * is reset (e.g. /clear) so that stale body-token data is not shown. + */ + clearLoadedSkills(): void { + this.loadedSkillNames.clear(); + } } class SkillToolInvocation extends BaseToolInvocation { @@ -189,6 +221,7 @@ class SkillToolInvocation extends BaseToolInvocation { private readonly config: Config, private readonly skillManager: SkillManager, params: SkillParams, + private readonly onSkillLoaded: (name: string) => void, ) { super(params); } @@ -245,11 +278,10 @@ class SkillToolInvocation extends BaseToolInvocation { this.config, new SkillLaunchEvent(this.params.skill, true), ); + this.onSkillLoaded(this.params.skill); const baseDir = path.dirname(skill.filePath); - - // Build markdown content for LLM (show base dir, then body) - const llmContent = `Base directory for this skill: ${baseDir}\nImportant: ALWAYS resolve absolute paths from this base directory when working with skills.\n\n${skill.body}\n`; + const llmContent = buildSkillLlmContent(baseDir, skill.body); return { llmContent: [{ text: llmContent }],