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:
pomelo-nwu 2026-03-15 14:39:33 +08:00
parent b629de35cf
commit 4de9688543
7 changed files with 246 additions and 78 deletions

View file

@ -40,6 +40,7 @@ describe('clearCommand', () => {
resetChat: mockResetChat,
}) as unknown as GeminiClient,
startNewSession: mockStartNewSession,
getToolRegistry: () => undefined,
},
},
session: {

View file

@ -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);
}

View file

@ -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());

View file

@ -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' && (

View file

@ -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>

View file

@ -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 & {