From c2cf60334421736a0e1f183ae0f8ff766ad4ecdf Mon Sep 17 00:00:00 2001 From: Gravirei <147187533+Gravirei@users.noreply.github.com> Date: Tue, 16 Jun 2026 04:50:45 +0600 Subject: [PATCH] feat(ctx): add /ctx context window visualization and token bars to /cost (#1610) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ctx): add /ctx context window visualization and token bars to /cost Adds a new /ctx slash command that surfaces exactly what the model sees on the next API call, with per-category token bars, last-response breakdown, session token usage, per-model totals, and a session summary. The command reuses the same pipeline as /context (compact boundary, optional context-collapse transform, microcompact, analyzeContextUsage) so the totals match what is actually sent, not a rough estimate. A single 'local' command with supportsNonInteractive: true is registered in the public COMMANDS array (replacing the disabled /ctx_viz stub) and added to REMOTE_SAFE_COMMANDS and BRIDGE_SAFE_COMMANDS, so /ctx works identically in interactive REPL, headless -p, remote, and bridge modes. /cost gains a Token usage section with colored bars for input, output, cache read, and cache write tokens, appended after the existing cost/duration/code-changes block without changing the per-model line. Co-Authored-By: Claude Opus 4.6 * ctx-viz: fix bar scale to use contextWindow denominator - Current Context block: use contextWindow as barMax so bars visually match percentage column - Session Token Usage: use sessionTotalTokens as sessionMax for same reason - Update test assertions for new tokens/contextWindow scale * cost-tracker: add format test for token bar display * test: add missing mocks for ctx_viz rendering test * fix(ctx_viz): restore mocks after test; filter capacity rows from model-seen breakdown * test: verify capacity rows are filtered from ctx viz output - Add 'Free space' capacity row to mocked analyzeContextUsage categories - Add assertion that rendered output does not contain 'Free space' - Verifies CAPACITY_ROWS filtering logic in ctx-noninteractive.ts * fix: isolate ctx_viz mocks and filter deferred categories - Restore real modules in afterEach to prevent mock.module() leakage into downstream tests (autoCompact, compression) - Filter deferred categories (isDeferred: true) from model-visible rows to avoid overstating context usage * fix: eliminate mock.module() for leaky modules in ctx_viz test Only analyzeContext.js is mocked (single data fixture). All other modules (autoCompact, microCompact, context, model, state) use their real implementations to avoid process-global mock.module() leakage into downstream autoCompact and compression tests. * fix: eliminate mock.module() entirely via renderCtxReport extraction Extract renderCtxReport() from call() so the rendering test can construct a hand-crafted RenderInput directly, requiring zero mock.module() calls. This avoids process-global mock leakage into downstream autoCompact/compression tests AND makes the test immune to mock pollution from any preceding test file. * chore: derive RenderInput from collectCtxData return type, use it in test - RenderInput is now Awaited> — single source of truth, no manual property duplication. - Test imports RenderInput type and uses it in the renderCtxReport assertion instead of 'unknown', enabling compile-time fixture validation. --------- Co-authored-by: Gravirei Co-authored-by: Claude Opus 4.6 --- src/commands.ts | 4 +- src/commands/ctx_viz/ctx-noninteractive.ts | 249 ++++++++++++++++++++ src/commands/ctx_viz/ctx_viz.test.ts | 253 +++++++++++++++++++++ src/commands/ctx_viz/index.js | 1 - src/commands/ctx_viz/index.ts | 12 + src/cost-tracker.format.test.ts | 157 +++++++++++++ src/cost-tracker.ts | 33 ++- 7 files changed, 704 insertions(+), 5 deletions(-) create mode 100644 src/commands/ctx_viz/ctx-noninteractive.ts create mode 100644 src/commands/ctx_viz/ctx_viz.test.ts delete mode 100644 src/commands/ctx_viz/index.js create mode 100644 src/commands/ctx_viz/index.ts create mode 100644 src/cost-tracker.format.test.ts diff --git a/src/commands.ts b/src/commands.ts index bef3a2b37..271f9273e 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -242,7 +242,6 @@ export const INTERNAL_ONLY_COMMANDS = [ bughunter, commit, commitPushPr, - ctx_viz, goodClaude, issue, initVerifiers, @@ -289,6 +288,7 @@ const COMMANDS = memoize((): Command[] => [ context, contextNonInteractive, cost, + ctx_viz, diff, dream, doctor, @@ -658,6 +658,7 @@ export const REMOTE_SAFE_COMMANDS: Set = new Set([ color, // Change agent color vim, // Toggle vim mode cost, // Show session cost (local cost tracking) + ctx_viz, // Context window usage usage, // Show usage info copy, // Copy last message btw, // Quick note @@ -687,6 +688,7 @@ export const BRIDGE_SAFE_COMMANDS: Set = new Set( compact, // Shrink context — useful mid-session from a phone clear, // Wipe transcript cost, // Show session cost + ctx_viz, // Context window usage summary, // Summarize conversation releaseNotes, // Show changelog files, // List tracked files diff --git a/src/commands/ctx_viz/ctx-noninteractive.ts b/src/commands/ctx_viz/ctx-noninteractive.ts new file mode 100644 index 000000000..d3114cd95 --- /dev/null +++ b/src/commands/ctx_viz/ctx-noninteractive.ts @@ -0,0 +1,249 @@ +import { feature } from 'bun:bundle' +import chalk from 'chalk' +import figures from 'figures' +import { getEffectiveContextWindowSize, getAutoCompactThreshold, isAutoCompactEnabled } from '../../services/compact/autoCompact.js' +import { microcompactMessages } from '../../services/compact/microCompact.js' +import type { AppState } from '../../state/AppStateStore.js' +import type { ToolUseContext } from '../../Tool.js' +import type { Tools } from '../../Tool.js' +import type { AgentDefinitionsResult } from '../../tools/AgentTool/loadAgentsDir.js' +import type { Message } from '../../types/message.js' +import { analyzeContextUsage, type ContextData } from '../../utils/analyzeContext.js' +import { getContextWindowForModel, getModelMaxOutputTokens } from '../../utils/context.js' +import { formatNumber, formatDuration } from '../../utils/format.js' +import { getMessagesAfterCompactBoundary } from '../../utils/messages.js' +import { getCanonicalName } from '../../utils/model/model.js' +import { + getSdkBetas, + getModelUsage, + getTotalInputTokens, + getTotalOutputTokens, + getTotalCacheReadInputTokens, + getTotalCacheCreationInputTokens, + getTotalCostUSD, + getTotalAPIDuration, + getTotalDuration, + getTotalLinesAdded, + getTotalLinesRemoved, +} from '../../bootstrap/state.js' + +type CtxDataInput = { + messages: Message[] + getAppState: () => AppState + options: { + mainLoopModel: string + tools: Tools + agentDefinitions: AgentDefinitionsResult + customSystemPrompt?: string + appendSystemPrompt?: string + } +} + +function toApiView(messages: Message[]): Message[] { + let view = getMessagesAfterCompactBoundary(messages) + if (feature('CONTEXT_COLLAPSE')) { + /* eslint-disable @typescript-eslint/no-require-imports */ + const { projectView } = require('../../services/contextCollapse/operations.js') as typeof import('../../services/contextCollapse/operations.js') + /* eslint-enable @typescript-eslint/no-require-imports */ + view = projectView(view) + } + return view +} + +export async function collectCtxData(context: CtxDataInput): Promise<{ + contextData: ContextData + contextWindow: number + effectiveContext: number + autoCompactThreshold: number + maxOutput: { default: number; upperLimit: number } + canonicalName: string + autoCompactEnabled: boolean + sessionInput: number + sessionOutput: number + sessionCacheRead: number + sessionCacheCreation: number + sessionCost: number + sessionApiDuration: number + sessionWallDuration: number + linesAdded: number + linesRemoved: number + modelUsageMap: ReturnType +}> { + const { + messages, + getAppState, + options: { mainLoopModel, tools, agentDefinitions, customSystemPrompt, appendSystemPrompt }, + } = context + + const apiView = toApiView(messages) + const { messages: compactedMessages } = await microcompactMessages(apiView) + const appState = getAppState() + + const contextData = await analyzeContextUsage( + compactedMessages, + mainLoopModel, + async () => appState.toolPermissionContext, + tools, + agentDefinitions, + undefined, + { options: { customSystemPrompt, appendSystemPrompt } } as Pick, + undefined, + apiView, + ) + + const model = mainLoopModel + + return { + contextData, + contextWindow: getContextWindowForModel(model, getSdkBetas()), + effectiveContext: getEffectiveContextWindowSize(model), + autoCompactThreshold: getAutoCompactThreshold(model), + maxOutput: getModelMaxOutputTokens(model), + canonicalName: getCanonicalName(model), + autoCompactEnabled: isAutoCompactEnabled(), + sessionInput: getTotalInputTokens(), + sessionOutput: getTotalOutputTokens(), + sessionCacheRead: getTotalCacheReadInputTokens(), + sessionCacheCreation: getTotalCacheCreationInputTokens(), + sessionCost: getTotalCostUSD(), + sessionApiDuration: getTotalAPIDuration(), + sessionWallDuration: getTotalDuration(), + linesAdded: getTotalLinesAdded(), + linesRemoved: getTotalLinesRemoved(), + modelUsageMap: getModelUsage(), + } +} + +/** Shape returned by collectCtxData, accepted by renderCtxReport. */ +export type RenderInput = Awaited> + +function themeColorToChalk(themeColor: string): (text: string) => string { + if (themeColor === 'error') return chalk.red + if (themeColor === 'warning') return chalk.yellow + if (themeColor === 'success') return chalk.green + if (themeColor === 'info' || themeColor === 'subtle') return chalk.cyan + return chalk.blue +} + +function bar(filled: number, total: number, width: number, c: string): string { + const ratio = total > 0 ? Math.min(filled / total, 1) : 0 + const filledW = Math.round(ratio * width) + const emptyW = width - filledW + return themeColorToChalk(c)('█'.repeat(filledW)) + chalk.gray('░'.repeat(emptyW)) +} + +function categoryLine(label: string, tokens: number, barMax: number, pctMax: number, width: number, c: string): string { + const pct = pctMax > 0 ? ((tokens / pctMax) * 100).toFixed(1) : '0.0' + const b = bar(tokens, barMax, width, c) + return ` ${chalk.bold(formatNumber(tokens).padStart(12))} ${pct.padStart(6)}% ${b} ${label}` +} + +export async function call( + _args: string, + context: ToolUseContext, +): Promise<{ type: 'text'; value: string }> { + const d = await collectCtxData(context) + return { type: 'text' as const, value: renderCtxReport(d) } +} + +/** Render the report from already-collected data (no module imports needed). */ +export function renderCtxReport(d: RenderInput): string { + const { contextData: data } = d + + const barWidth = 30 + const lines: string[] = [] + + lines.push('') + lines.push(chalk.bold.cyan(` ${figures.bullet} Context Window: ${d.canonicalName}`)) + lines.push('') + + lines.push(chalk.bold(' Window Capacity')) + lines.push(` ${figures.bullet} Context window: ${chalk.bold(formatNumber(d.contextWindow))} tokens`) + lines.push(` ${figures.bullet} Effective context: ${chalk.bold(formatNumber(d.effectiveContext))} tokens`) + lines.push(` ${figures.bullet} Max output: ${chalk.bold(formatNumber(d.maxOutput.default))} tokens${d.maxOutput.default !== d.maxOutput.upperLimit ? ` (up to ${formatNumber(d.maxOutput.upperLimit)})` : ''}`) + if (d.autoCompactEnabled) { + lines.push(` ${figures.bullet} Auto-compact at: ${chalk.bold(formatNumber(d.autoCompactThreshold))} tokens`) + } + lines.push('') + + lines.push(chalk.bold(' Current Context (what the model sees)')) + lines.push(` Total: ${chalk.bold(formatNumber(data.totalTokens))} / ${formatNumber(d.contextWindow)} tokens (${chalk.bold(`${data.percentage}%`)} used)`) + lines.push('') + + // Bar scale: use the context window as the denominator so the bar + // visually matches the percentage column (tokens / contextWindow). + const barMax = d.contextWindow + + const CAPACITY_ROWS = new Set(['Free space', 'Autocompact buffer', 'Compact buffer']) + for (const cat of data.categories) { + if (cat.tokens > 0 && !CAPACITY_ROWS.has(cat.name) && !cat.isDeferred) { + lines.push(categoryLine(cat.name, cat.tokens, barMax, barMax, barWidth, cat.color)) + } + } + lines.push('') + + if (data.apiUsage) { + const u = data.apiUsage + lines.push(chalk.bold(' Last API Response')) + lines.push(` ${figures.bullet} Input: ${chalk.bold(formatNumber(u.input_tokens))} tokens`) + lines.push(` ${figures.bullet} Output: ${chalk.bold(formatNumber(u.output_tokens))} tokens`) + if (u.cache_read_input_tokens > 0) { + lines.push(` ${figures.bullet} Cache read: ${chalk.bold(formatNumber(u.cache_read_input_tokens))} tokens`) + } + if (u.cache_creation_input_tokens > 0) { + lines.push(` ${figures.bullet} Cache write: ${chalk.bold(formatNumber(u.cache_creation_input_tokens))} tokens`) + } + lines.push('') + } + + const sessionTotalTokens = d.sessionInput + d.sessionOutput + d.sessionCacheRead + d.sessionCacheCreation + if (sessionTotalTokens > 0) { + const sessionMax = Math.max(sessionTotalTokens, 1) + lines.push(chalk.bold(' Session Token Usage')) + lines.push(categoryLine('Input', d.sessionInput, sessionMax, sessionTotalTokens, barWidth, 'blue')) + lines.push(categoryLine('Output', d.sessionOutput, sessionMax, sessionTotalTokens, barWidth, 'green')) + if (d.sessionCacheRead > 0) { + lines.push(categoryLine('Cache read', d.sessionCacheRead, sessionMax, sessionTotalTokens, barWidth, 'cyan')) + } + if (d.sessionCacheCreation > 0) { + lines.push(categoryLine('Cache write', d.sessionCacheCreation, sessionMax, sessionTotalTokens, barWidth, 'yellow')) + } + lines.push(` ${'Total:'.padStart(14)} ${chalk.bold(formatNumber(sessionTotalTokens))} tokens`) + lines.push('') + } + + if (Object.keys(d.modelUsageMap).length > 0) { + lines.push(chalk.bold(' Per-Model Session Totals')) + for (const [modelName, usage] of Object.entries(d.modelUsageMap)) { + const shortName = getCanonicalName(modelName) + const parts = [`${formatNumber(usage.inputTokens)} in`, `${formatNumber(usage.outputTokens)} out`] + if (usage.cacheReadInputTokens > 0) parts.push(`${formatNumber(usage.cacheReadInputTokens)} cache read`) + if (usage.cacheCreationInputTokens > 0) parts.push(`${formatNumber(usage.cacheCreationInputTokens)} cache write`) + if (usage.costUSD > 0) parts.push(chalk.yellow(`$${usage.costUSD.toFixed(4)}`)) + lines.push(` ${chalk.bold(shortName)}: ${parts.join(', ')}`) + } + lines.push('') + } + + if (d.sessionCost > 0 || d.sessionInput > 0 || d.linesAdded > 0 || d.linesRemoved > 0) { + lines.push(chalk.bold(' Session Summary')) + if (d.sessionCost > 0) { + lines.push(` ${figures.bullet} Cost: ${chalk.bold(chalk.yellow(`$${d.sessionCost.toFixed(4)}`))}`) + } + if (d.sessionApiDuration > 0) { + lines.push(` ${figures.bullet} API duration: ${chalk.bold(formatDuration(d.sessionApiDuration))}`) + } + if (d.sessionWallDuration > 0) { + lines.push(` ${figures.bullet} Wall duration: ${chalk.bold(formatDuration(d.sessionWallDuration))}`) + } + if (d.linesAdded > 0 || d.linesRemoved > 0) { + lines.push(` ${figures.bullet} Code changes: ${chalk.green(`+${d.linesAdded}`)} / ${chalk.red(`-${d.linesRemoved}`)} lines`) + } + lines.push('') + } + + lines.push(chalk.dim(` ${figures.info} Run /context for detailed grid view, /cost for pricing, /stats for history`)) + lines.push('') + + return lines.join('\n') +} diff --git a/src/commands/ctx_viz/ctx_viz.test.ts b/src/commands/ctx_viz/ctx_viz.test.ts new file mode 100644 index 000000000..7318ec180 --- /dev/null +++ b/src/commands/ctx_viz/ctx_viz.test.ts @@ -0,0 +1,253 @@ +/** + * Coverage for the /ctx command surface added in PR #1610. + * + * Reviewer (P2) asked for tests that lock down: + * 1. Command registration in the public COMMANDS list (i.e. it left + * INTERNAL_ONLY_COMMANDS and now resolves via getCommands()). + * 2. Aliases are wired so /ctx, /ctx_viz, and /context-viz all resolve + * to the same command. + * 3. The remote-mode and bridge allowlists accept /ctx, so it works + * in --remote and from the iOS/mobile client. + * 4. supportsNonInteractive is true, so the headless -p path + * dispatches to ctx-noninteractive.ts. + * 5. The non-interactive call() renders the report sections so a + * future refactor cannot silently drop a header or category row. + * + * The existing commands.test.ts file did not exercise any of this, so a + * future refactor could silently demote /ctx back to internal-only or + * drop the bridge/remote flags without any test failing. + */ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { + afterEach, + beforeEach, + describe, + expect, + mock, + test, +} from 'bun:test' +import { + BRIDGE_SAFE_COMMANDS, + clearCommandMemoizationCaches, + findCommand, + getCommand, + getCommands, + hasCommand, + INTERNAL_ONLY_COMMANDS, + isBridgeSafeCommand, + REMOTE_SAFE_COMMANDS, +} from '../../commands.js' +import { + resetSettingsCache, + setSessionSettingsCache, +} from '../../utils/settings/settingsCache.js' +import type { RenderInput } from './ctx-noninteractive.js' + +function findCtx(commands: ReturnType extends Promise ? T : never) { + return commands.find(c => c.name === 'ctx') +} + +beforeEach(() => { + delete process.env['USER_TYPE'] + delete process.env['IS_DEMO'] + clearCommandMemoizationCaches() + resetSettingsCache() + setSessionSettingsCache({ settings: {}, errors: [] }) +}) + +afterEach(() => { + mock.restore() + resetSettingsCache() + clearCommandMemoizationCaches() +}) + +describe('/ctx command surface (PR #1610)', () => { + test('is registered in the public COMMANDS list for normal users', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'oc-test-ctx-pub-')) + try { + const cmds = await getCommands(cwd) + expect(hasCommand('ctx', cmds)).toBe(true) + const internalNames = INTERNAL_ONLY_COMMANDS.map(c => c.name) + // /ctx was promoted out of INTERNAL_ONLY_COMMANDS in this PR — keep it out. + expect(internalNames).not.toContain('ctx') + } finally { + await rm(cwd, { recursive: true, force: true }) + } + }) + + test('exposes /ctx, /ctx_viz, and /context-viz as resolving to the same command', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'oc-test-ctx-aliases-')) + try { + const cmds = await getCommands(cwd) + const ctx = getCommand('ctx', cmds) + expect(ctx.name).toBe('ctx') + expect(ctx.aliases).toEqual(expect.arrayContaining(['ctx_viz', 'context-viz'])) + + for (const alias of ['ctx_viz', 'context-viz']) { + // findCommand + getCommand both resolve aliases back to /ctx. + expect(findCommand(alias, cmds)?.name).toBe('ctx') + expect(getCommand(alias, cmds).name).toBe('ctx') + expect(hasCommand(alias, cmds)).toBe(true) + } + } finally { + await rm(cwd, { recursive: true, force: true }) + } + }) + + test('is in REMOTE_SAFE_COMMANDS so it works under --remote', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'oc-test-ctx-remote-')) + try { + const cmds = await getCommands(cwd) + const ctx = findCtx(cmds) + expect(ctx).toBeDefined() + expect(REMOTE_SAFE_COMMANDS.has(ctx!)).toBe(true) + } finally { + await rm(cwd, { recursive: true, force: true }) + } + }) + + test('is in BRIDGE_SAFE_COMMANDS so it is reachable from the mobile/web bridge', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'oc-test-ctx-bridge-')) + try { + const cmds = await getCommands(cwd) + const ctx = findCtx(cmds) + expect(ctx).toBeDefined() + // isBridgeSafeCommand is the runtime gate in the bridge inbound path; + // the allowlist membership is the source of truth. + expect(BRIDGE_SAFE_COMMANDS.has(ctx!)).toBe(true) + expect(isBridgeSafeCommand(ctx!)).toBe(true) + } finally { + await rm(cwd, { recursive: true, force: true }) + } + }) + + test('supports headless / non-interactive dispatch (supportsNonInteractive: true)', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'oc-test-ctx-nonint-')) + try { + const cmds = await getCommands(cwd) + const ctx = findCtx(cmds) + expect(ctx).toBeDefined() + // Narrow from discriminated union so TS allows property access + const cmd = ctx! + if (cmd.type !== 'local') throw new Error('expected local command') + // Drives the -p / piped-arg path into ctx-noninteractive.ts. + expect(cmd.supportsNonInteractive).toBe(true) + } finally { + await rm(cwd, { recursive: true, force: true }) + } + }) + + test('is a local command that lazy-loads ctx-noninteractive.ts', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'oc-test-ctx-load-')) + try { + const cmds = await getCommands(cwd) + const ctx = findCtx(cmds) + expect(ctx).toBeDefined() + const cmd = ctx! + if (cmd.type !== 'local') throw new Error('expected local command') + // `load` returns a dynamic import. Call it and verify the + // non-interactive module's `call` function is exported. + const mod = await cmd.load() + expect(typeof mod.call).toBe('function') + } finally { + await rm(cwd, { recursive: true, force: true }) + } + }) + + test('call() renders the report sections and category bars', async () => { + // Call renderCtxReport directly with a hand-crafted RenderInput so + // the test needs zero mock.module() calls — avoiding process-global + // mock pollution entirely. + const mod = (await import( + `./ctx-noninteractive.ts?render=${Date.now()}-${Math.random()}` + )) as { + renderCtxReport: (d: RenderInput) => string + } + + const out = mod.renderCtxReport({ + contextData: { + categories: [ + { name: 'System prompt', tokens: 7_800, color: 'claude' }, + { name: 'System tools', tokens: 15_500, color: 'promptBorder' }, + { name: 'Memory files', tokens: 956, color: 'inactive' }, + { name: 'Messages', tokens: 84, color: 'permission' }, + { name: 'Free space', tokens: 50_000, color: 'subtle' }, + { name: 'System tools (deferred)', tokens: 4_000, color: 'inactive', isDeferred: true }, + ], + totalTokens: 74_340, + maxTokens: 131_072, + rawMaxTokens: 131_072, + percentage: 57, + gridRows: [], + model: 'claude-sonnet-4', + memoryFiles: [], + mcpTools: [], + agents: [], + apiUsage: null, + isAutoCompactEnabled: true, + autoCompactThreshold: 167_000, + }, + contextWindow: 200_000, + effectiveContext: 180_000, + autoCompactThreshold: 167_000, + maxOutput: { default: 32_000, upperLimit: 64_000 }, + canonicalName: 'claude-sonnet-4', + autoCompactEnabled: true, + sessionInput: 0, + sessionOutput: 0, + sessionCacheRead: 0, + sessionCacheCreation: 0, + sessionCost: 0, + sessionApiDuration: 0, + sessionWallDuration: 0, + linesAdded: 0, + linesRemoved: 0, + modelUsageMap: {}, + }) + + // Header line — confirms the model name is rendered. + expect(out).toContain('Context Window:') + // Window Capacity block (4 bullets). + expect(out).toContain('Window Capacity') + expect(out).toContain('Context window:') + expect(out).toContain('Effective context:') + expect(out).toContain('Max output:') + // Auto-compact line is rendered because the fixture sets + // isAutoCompactEnabled: true. + expect(out).toContain('Auto-compact at:') + // Current Context block + total. + expect(out).toContain('Current Context (what the model sees)') + expect(out).toContain('Total:') + expect(out).toMatch(/used\)/) + // Each non-zero category in the fixture appears in the output. + for (const cat of [ + 'System prompt', + 'System tools', + 'Memory files', + 'Messages', + ]) { + expect(out).toContain(cat) + } + // Bar characters — width 30, ratio = tokens / contextWindow (200k). + // With the fixture: + // System tools 15.5k / 200k → 2 filled + // System prompt 7.8k / 200k → 1 filled + // Memory files 956 / 200k → 0 filled + // Messages 84 / 200k → 0 filled + expect(out).toContain('█'.repeat(2) + '░'.repeat(28)) + expect(out).toContain('█'.repeat(1) + '░'.repeat(29)) + expect(out).toMatch(/░{30}/) + // Footer cross-references the sibling commands. + expect(out).toContain('/context') + expect(out).toContain('/cost') + expect(out).toContain('/stats') + // Capacity rows (Free space, Autocompact buffer, Compact buffer) should be filtered out. + expect(out).not.toContain('Free space') + // Deferred tool categories (MCP tools (deferred), System tools (deferred)) + // should be filtered out since they aren't in the model-visible context. + expect(out).not.toContain('System tools (deferred)') + }) +}) diff --git a/src/commands/ctx_viz/index.js b/src/commands/ctx_viz/index.js deleted file mode 100644 index e1a619d25..000000000 --- a/src/commands/ctx_viz/index.js +++ /dev/null @@ -1 +0,0 @@ -export default { isEnabled: () => false, isHidden: true, name: 'stub' }; diff --git a/src/commands/ctx_viz/index.ts b/src/commands/ctx_viz/index.ts new file mode 100644 index 000000000..58f17259b --- /dev/null +++ b/src/commands/ctx_viz/index.ts @@ -0,0 +1,12 @@ +import type { Command } from '../../commands.js' + +const ctx: Command = { + type: 'local', + name: 'ctx', + description: 'Show context window usage and token breakdown', + aliases: ['ctx_viz', 'context-viz'], + supportsNonInteractive: true, + load: () => import('./ctx-noninteractive.js'), +} + +export default ctx diff --git a/src/cost-tracker.format.test.ts b/src/cost-tracker.format.test.ts new file mode 100644 index 000000000..6fd09fe47 --- /dev/null +++ b/src/cost-tracker.format.test.ts @@ -0,0 +1,157 @@ +/** + * Coverage for the /cost token-bar output added in PR #1610. + * + * Reviewer (P2) asked for a small formatter test for the new token + * bar display so it does not regress silently. formatTotalCost() pulls + * live state from bootstrap/state.js, so we drive it through the real + * addToTotalSessionCost path with the shared-mutation lock, then strip + * ANSI and assert on the structural shape (header, per-bucket rows, + * alignment, cache-row gating). + */ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { + addToTotalLinesChanged, + resetStateForTests, +} from './bootstrap/state.js' +import { formatTotalCost, resetCostState } from './cost-tracker.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from './test/sharedMutationLock.js' + +// BetaUsage-compatible shape — minimum fields addToTotalSessionCost +// needs to run without throwing. +function anthropicUsage(partial: { + input?: number + output?: number + cacheRead?: number + cacheCreation?: number +}): Parameters[1] { + return { + input_tokens: partial.input ?? 0, + output_tokens: partial.output ?? 0, + cache_read_input_tokens: partial.cacheRead ?? 0, + cache_creation_input_tokens: partial.cacheCreation ?? 0, + } as Parameters< + typeof import('./cost-tracker.js').addToTotalSessionCost + >[1] +} + +beforeEach(async () => { + await acquireSharedMutationLock('cost-tracker.format.test.ts') + resetStateForTests() + resetCostState() +}) + +afterEach(() => { + try { + resetStateForTests() + resetCostState() + } finally { + releaseSharedMutationLock() + } +}) + +// Strip ANSI escape codes so assertions are stable across terminal +// emulators and CI runners. +function stripAnsi(s: string): string { + // eslint-disable-next-line no-control-regex + return s.replace(/\x1b\[[0-9;]*m/g, '') +} + +describe('formatTotalCost — token bar output (PR #1610)', () => { + test('omits the Token usage section when no tokens have been recorded', () => { + const out = stripAnsi(formatTotalCost()) + expect(out).not.toContain('Token usage:') + }) + + test('renders Input/Output bars and the Token usage header when tokens exist', () => { + // Seed input + output through the real session-cost path. + // addToTotalSessionCost is a regular re-export from this module. + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { addToTotalSessionCost } = + require('./cost-tracker.js') as typeof import('./cost-tracker.js') + addToTotalSessionCost( + 0, + anthropicUsage({ input: 1000, output: 200 }), + 'claude-sonnet-4', + ) + + const out = stripAnsi(formatTotalCost()) + + expect(out).toContain('Token usage:') + expect(out).toContain('Input tokens') + expect(out).toContain('Output tokens') + + // Bar characters: 20-wide, ratio = 1.0 for input (max), 0.2 for output + expect(out).toContain('█'.repeat(20) + '░'.repeat(0)) + expect(out).toContain('█'.repeat(4) + '░'.repeat(16)) + }) + + test('gates cache read / cache write rows on non-zero values', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { addToTotalSessionCost } = + require('./cost-tracker.js') as typeof import('./cost-tracker.js') + // No cache read or cache write — neither row should appear. + addToTotalSessionCost( + 0, + anthropicUsage({ input: 500, output: 100 }), + 'claude-sonnet-4', + ) + const outNoCache = stripAnsi(formatTotalCost()) + expect(outNoCache).not.toContain('Cache read') + expect(outNoCache).not.toContain('Cache write') + + // Reset and re-seed with cache fields populated. + resetStateForTests() + resetCostState() + addToTotalSessionCost( + 0, + anthropicUsage({ + input: 500, + output: 100, + cacheRead: 250, + cacheCreation: 75, + }), + 'claude-sonnet-4', + ) + const outWithCache = stripAnsi(formatTotalCost()) + expect(outWithCache).toContain('Cache read') + expect(outWithCache).toContain('Cache write') + }) + + test('formats token counts using formatNumber (compact notation for ≥1000)', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { addToTotalSessionCost } = + require('./cost-tracker.js') as typeof import('./cost-tracker.js') + // 12,345 input tokens → formatNumber() renders compact as "12.3k". + // 67 output tokens stay as "67" (below the 1000 compact-notation threshold). + addToTotalSessionCost( + 0, + anthropicUsage({ input: 12_345, output: 67 }), + 'claude-sonnet-4', + ) + const out = stripAnsi(formatTotalCost()) + expect(out).toContain('12.3k') + // Output row keeps the raw count — not compact-formatted. + expect(out).toMatch(/Output tokens[^\n]*67/) + }) + + test('keeps the legacy Total cost / Total duration / Total code changes block', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { addToTotalSessionCost } = + require('./cost-tracker.js') as typeof import('./cost-tracker.js') + addToTotalSessionCost( + 0, + anthropicUsage({ input: 100, output: 50 }), + 'claude-sonnet-4', + ) + addToTotalLinesChanged(7, 3) + + const out = stripAnsi(formatTotalCost()) + expect(out).toMatch(/Total cost:/) + expect(out).toMatch(/Total duration \(API\):/) + expect(out).toMatch(/Total duration \(wall\):/) + expect(out).toMatch(/7 lines added, 3 lines removed/) + }) +}) diff --git a/src/cost-tracker.ts b/src/cost-tracker.ts index 6beec872e..5eaa4cbda 100644 --- a/src/cost-tracker.ts +++ b/src/cost-tracker.ts @@ -246,6 +246,15 @@ function formatModelUsage(): string { return result } +function formatTokenBar(label: string, tokens: number, maxTokens: number, colorFn: (text: string) => string): string { + const barWidth = 20 + const ratio = maxTokens > 0 ? Math.min(tokens / maxTokens, 1) : 0 + const filled = Math.round(ratio * barWidth) + const empty = barWidth - filled + const bar = colorFn('█'.repeat(filled)) + chalk.gray('░'.repeat(empty)) + return ` ${label.padEnd(14)} ${bar} ${formatNumber(tokens)}` +} + export function formatTotalCost(): string { const costDisplay = formatCost(getTotalCostUSD()) + @@ -254,14 +263,32 @@ export function formatTotalCost(): string { : '') const modelUsageDisplay = formatModelUsage() + const totalInput = getTotalInputTokens() + const totalOutput = getTotalOutputTokens() + const totalCacheRead = getTotalCacheReadInputTokens() + const totalCacheCreation = getTotalCacheCreationInputTokens() + const totalTokens = totalInput + totalOutput + totalCacheRead + totalCacheCreation + const tokenSection = totalTokens > 0 ? (() => { + const maxTokens = Math.max(totalInput, totalOutput, totalCacheRead, totalCacheCreation, 1) + const tokenBars = [ + formatTokenBar('Input tokens', totalInput, maxTokens, chalk.blue), + formatTokenBar('Output tokens', totalOutput, maxTokens, chalk.green), + totalCacheRead > 0 ? formatTokenBar('Cache read', totalCacheRead, maxTokens, chalk.cyan) : null, + totalCacheCreation > 0 ? formatTokenBar('Cache write', totalCacheCreation, maxTokens, chalk.yellow) : null, + ].filter(Boolean).join('\n') + return `\nToken usage:\n${tokenBars}` + })() : '' - return chalk.dim( + const statsBlock = chalk.dim( `Total cost: ${costDisplay}\n` + `Total duration (API): ${formatDuration(getTotalAPIDuration())} Total duration (wall): ${formatDuration(getTotalDuration())} -Total code changes: ${getTotalLinesAdded()} ${getTotalLinesAdded() === 1 ? 'line' : 'lines'} added, ${getTotalLinesRemoved()} ${getTotalLinesRemoved() === 1 ? 'line' : 'lines'} removed -${modelUsageDisplay}`, +Total code changes: ${getTotalLinesAdded()} ${getTotalLinesAdded() === 1 ? 'line' : 'lines'} added, ${getTotalLinesRemoved()} ${getTotalLinesRemoved() === 1 ? 'line' : 'lines'} removed`, ) + + return `${statsBlock}${tokenSection} + +${modelUsageDisplay}` } function round(number: number, precision: number): number {