mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 02:34:15 -05:00
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:
co-authored by
Gravirei
Claude Opus 4.6
parent
8bce86f841
commit
c2cf603344
+3
-1
@@ -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
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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 +0,0 @@
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
|
||||
@@ -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
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user