feat(ctx): add /ctx context window visualization and token bars to /cost (#1610)

* 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 <noreply@anthropic.com>

* 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<ReturnType<typeof collectCtxData>> — 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 <gravirei@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Gravirei
2026-06-16 06:50:45 +08:00
committed by GitHub
co-authored by Gravirei Claude Opus 4.6
parent 8bce86f841
commit c2cf603344
7 changed files with 704 additions and 5 deletions
+3 -1
View File
@@ -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<Command> = 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<Command> = 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
+249
View File
@@ -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<typeof getModelUsage>
}> {
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<ToolUseContext, 'options'>,
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<ReturnType<typeof collectCtxData>>
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')
}
+253
View File
@@ -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<typeof getCommands> extends Promise<infer T> ? 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)')
})
})
-1
View File
@@ -1 +0,0 @@
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
+12
View File
@@ -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
+157
View File
@@ -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<typeof import('./cost-tracker.js').addToTotalSessionCost>[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/)
})
})
+30 -3
View File
@@ -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 {