diff --git a/src/ccstatusline.ts b/src/ccstatusline.ts index d16d0bb58..7b3910ee8 100644 --- a/src/ccstatusline.ts +++ b/src/ccstatusline.ts @@ -40,6 +40,7 @@ import { getTerminalWidth } from './utils/terminal'; import { sanitizeTerminalText } from './utils/terminal-sanitize'; +import { needsTranscriptTokenMetrics } from './utils/transcript-requirements'; import { prefetchUsageDataIfNeeded } from './utils/usage-prefetch'; import { ensureWindowsUtf8CodePage } from './utils/windows-code-page'; @@ -109,9 +110,20 @@ async function renderMultipleLines(data: StatusJSON) { } } - const transcriptAnalysisPromise = data.transcript_path + // Reading the transcript costs a full parse of a file that grows all + // session, so only scan it when a configured widget will read the result. + const includeTokenMetrics = needsTranscriptTokenMetrics(lines, settings, data); + const includeSessionDuration = hasSessionClock && !hasSessionDurationInStatusJson(data); + const needsTranscriptAnalysis = includeTokenMetrics + || includeSessionDuration + || hasSpeedItems + || hasCompactionWidget + || needsTranscriptThinkingEffort + || hasSessionNameWidget; + const transcriptAnalysisPromise = data.transcript_path && needsTranscriptAnalysis ? getTranscriptAnalysis(data.transcript_path, { - includeSessionDuration: hasSessionClock && !hasSessionDurationInStatusJson(data), + includeTokenMetrics, + includeSessionDuration, includeSpeedMetrics: hasSpeedItems, includeSubagents: true, speedWindowSeconds: Array.from(requestedSpeedWindows), diff --git a/src/utils/__tests__/jsonl-metrics.test.ts b/src/utils/__tests__/jsonl-metrics.test.ts index 1a40bec8d..199751378 100644 --- a/src/utils/__tests__/jsonl-metrics.test.ts +++ b/src/utils/__tests__/jsonl-metrics.test.ts @@ -26,6 +26,10 @@ async function getSessionDuration(transcriptPath: string): Promise { const analysis = await getTranscriptAnalysis(transcriptPath); + if (!analysis.tokenMetrics) { + throw new Error('token metrics are collected by default'); + } + return analysis.tokenMetrics; } @@ -148,6 +152,29 @@ describe('jsonl transcript metrics', () => { expect(metrics.contextLength).toBe(live.contextLengthTokens); }); + it('leaves token metrics out when the caller does not need them', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-metrics-')); + tempRoots.push(root); + const transcriptPath = path.join(root, 'no-token-metrics.jsonl'); + fs.writeFileSync(transcriptPath, [ + JSON.stringify({ + timestamp: '2026-01-01T10:00:00.000Z', + message: { stop_reason: 'end_turn', usage: { input_tokens: 10, output_tokens: 20 } } + }), + JSON.stringify({ timestamp: '2026-01-01T10:05:00.000Z' }) + ].join('\n')); + + const skipped = await getTranscriptAnalysis(transcriptPath, { + includeTokenMetrics: false, + includeSessionDuration: true + }); + const missing = await getTranscriptAnalysis(path.join(root, 'missing.jsonl'), { includeTokenMetrics: false }); + + expect(skipped.tokenMetrics).toBeNull(); + expect(skipped.sessionDuration).toBe('5m'); + expect(missing.tokenMetrics).toBeNull(); + }); + it('formats session duration as <1m for sub-minute transcripts', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-metrics-')); tempRoots.push(root); diff --git a/src/utils/__tests__/transcript-requirements.test.ts b/src/utils/__tests__/transcript-requirements.test.ts new file mode 100644 index 000000000..edab8c0ef --- /dev/null +++ b/src/utils/__tests__/transcript-requirements.test.ts @@ -0,0 +1,171 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import type { + RenderContext, + TokenMetrics +} from '../../types'; +import { DEFAULT_SETTINGS } from '../../types/Settings'; +import type { StatusJSON } from '../../types/StatusJSON'; +import type { WidgetItem } from '../../types/Widget'; +import { calculateContextPercentage } from '../context-percentage'; +import { needsTranscriptTokenMetrics } from '../transcript-requirements'; +import { WIDGET_MANIFEST } from '../widget-manifest'; + +const MODEL = { id: 'claude-sonnet-4-5-20250929', display_name: 'Sonnet 4.5' }; + +const PAYLOADS: Record = { + 'no context_window': { model: MODEL }, + 'full context_window': { + model: MODEL, + context_window: { + context_window_size: 200000, + total_input_tokens: 1000, + total_output_tokens: 500, + current_usage: { + input_tokens: 10, + output_tokens: 20, + cache_creation_input_tokens: 300, + cache_read_input_tokens: 4000 + }, + used_percentage: 12.5, + remaining_percentage: 87.5 + } + }, + 'context_window without size': { + model: MODEL, + context_window: { current_usage: 42000 } + }, + 'context_window with percentage only': { + model: MODEL, + context_window: { used_percentage: 30 } + }, + 'context_window with size and percentage only': { + model: MODEL, + context_window: { context_window_size: 200000, used_percentage: 30 } + } +}; + +const SENTINEL_METRICS: TokenMetrics = { + inputTokens: 111111, + outputTokens: 222222, + cachedTokens: 333333, + cacheReadTokens: 300000, + cacheCreationTokens: 33333, + totalTokens: 666666, + contextLength: 77777 +}; + +// Widgets that shell out, read the clock, or read files and usage caches; none +// of them read tokenMetrics, and rendering them here would be slow and flaky. +const EXTERNAL_STATE_PREFIXES = [ + 'git-', + 'jj-', + 'worktree-', + 'custom-', + 'claude-', + 'block-', + 'cache-timer', + 'current-working-dir', + 'extra-usage-', + 'weekly-', + 'fable-', + 'reset-timer', + 'session-usage', + 'free-memory', + 'skills', + 'terminal-width' +]; + +function renderWithMetrics(item: WidgetItem, data: StatusJSON, tokenMetrics: TokenMetrics | null): string | null { + const widget = WIDGET_MANIFEST.find(entry => entry.type === item.type)?.create(); + const context: RenderContext = { data, tokenMetrics }; + return widget?.render(item, context, DEFAULT_SETTINGS) ?? null; +} + +describe('needsTranscriptTokenMetrics', () => { + const itemVariants = (type: string): WidgetItem[] => [ + { id: '1', type }, + { id: '2', type, metadata: { cacheScopeSession: 'true' } } + ]; + const renderableTypes = WIDGET_MANIFEST + .map(entry => entry.type) + .filter(type => !EXTERNAL_STATE_PREFIXES.some(prefix => type.startsWith(prefix))); + + for (const [payloadName, data] of Object.entries(PAYLOADS)) { + it(`covers every widget whose output depends on transcript metrics (${payloadName})`, () => { + for (const type of renderableTypes) { + for (const item of itemVariants(type)) { + if (needsTranscriptTokenMetrics([[item]], DEFAULT_SETTINGS, data)) { + continue; + } + + expect( + renderWithMetrics(item, data, SENTINEL_METRICS), + `${type} ${JSON.stringify(item.metadata ?? {})}` + ).toBe(renderWithMetrics(item, data, null)); + } + } + }, 30000); + + it(`covers the full-until-compact flex width (${payloadName})`, () => { + const settings = { ...DEFAULT_SETTINGS, flexMode: 'full-until-compact' as const }; + if (needsTranscriptTokenMetrics([[{ id: '1', type: 'model' }]], settings, data)) { + return; + } + + expect(calculateContextPercentage({ data, tokenMetrics: SENTINEL_METRICS })) + .toBe(calculateContextPercentage({ data, tokenMetrics: null })); + }); + } + + it('skips the scan when the status JSON supplies the context fields', () => { + const data = PAYLOADS['full context_window'] ?? {}; + const lines: WidgetItem[][] = [[ + { id: '1', type: 'model' }, + { id: '2', type: 'context-length' }, + { id: '3', type: 'context-percentage' }, + { id: '4', type: 'context-percentage-usable' }, + { id: '5', type: 'context-bar' }, + { id: '6', type: 'cache-hit-rate' } + ]]; + + expect(needsTranscriptTokenMetrics(lines, DEFAULT_SETTINGS, data)).toBe(false); + expect(needsTranscriptTokenMetrics(lines, { flexMode: 'full-until-compact' }, data)).toBe(false); + }); + + it('scans when a context widget has to fall back to the transcript', () => { + const data = PAYLOADS['no context_window'] ?? {}; + + for (const type of ['context-length', 'context-percentage', 'context-percentage-usable', 'context-bar']) { + expect(needsTranscriptTokenMetrics([[{ id: '1', type }]], DEFAULT_SETTINGS, data), type).toBe(true); + } + expect(needsTranscriptTokenMetrics([[{ id: '1', type: 'model' }]], { flexMode: 'full-until-compact' }, data)).toBe(true); + }); + + it('scans for context-bar when only the window size is missing', () => { + const data = PAYLOADS['context_window without size'] ?? {}; + + expect(needsTranscriptTokenMetrics([[{ id: '1', type: 'context-length' }]], DEFAULT_SETTINGS, data)).toBe(false); + expect(needsTranscriptTokenMetrics([[{ id: '1', type: 'context-bar' }]], DEFAULT_SETTINGS, data)).toBe(true); + }); + + it('always scans for token widgets and session-scoped cache widgets', () => { + const data = PAYLOADS['full context_window'] ?? {}; + + for (const type of ['tokens-input', 'tokens-output', 'tokens-cached', 'tokens-total']) { + expect(needsTranscriptTokenMetrics([[{ id: '1', type }]], DEFAULT_SETTINGS, data), type).toBe(true); + } + for (const type of ['cache-hit-rate', 'cache-read', 'cache-write']) { + expect(needsTranscriptTokenMetrics([[{ id: '1', type }]], DEFAULT_SETTINGS, data), type).toBe(false); + expect(needsTranscriptTokenMetrics( + [[{ id: '1', type, metadata: { cacheScopeSession: 'true' } }]], + DEFAULT_SETTINGS, + data + ), type).toBe(true); + } + }); +}); diff --git a/src/utils/jsonl-metrics.ts b/src/utils/jsonl-metrics.ts index a82f79299..55e7c5c29 100644 --- a/src/utils/jsonl-metrics.ts +++ b/src/utils/jsonl-metrics.ts @@ -35,6 +35,8 @@ export interface SpeedMetricsCollection { } export interface TranscriptAnalysisOptions { + /** Defaults to true; when false, `tokenMetrics` is null. */ + includeTokenMetrics?: boolean; includeSessionDuration?: boolean; includeSpeedMetrics?: boolean; includeSubagents?: boolean; @@ -45,7 +47,7 @@ export interface TranscriptAnalysisOptions { } export interface TranscriptAnalysis { - tokenMetrics: TokenMetrics; + tokenMetrics: TokenMetrics | null; sessionDuration: string | null; speedMetricsCollection: SpeedMetricsCollection | null; compactionData: CompactionData | null; @@ -53,7 +55,7 @@ export interface TranscriptAnalysis { sessionName: string | null; } -interface TranscriptScanOptions extends TranscriptAnalysisOptions { includeTokenMetrics?: boolean } +type TranscriptScanOptions = TranscriptAnalysisOptions; interface TranscriptScanResult { tokenMetrics: TokenMetrics | null; @@ -666,13 +668,16 @@ export async function getTranscriptAnalysis( transcriptPath: string, options: TranscriptAnalysisOptions = {} ): Promise { + const includeTokenMetrics = options.includeTokenMetrics ?? true; const result = await scanTranscript(transcriptPath, { ...options, - includeTokenMetrics: true + includeTokenMetrics }); return { - tokenMetrics: result.tokenMetrics ?? createEmptyTokenMetrics(), + tokenMetrics: includeTokenMetrics + ? (result.tokenMetrics ?? createEmptyTokenMetrics()) + : null, sessionDuration: result.sessionDuration, speedMetricsCollection: result.speedMetricsCollection, compactionData: result.compactionData, diff --git a/src/utils/transcript-requirements.ts b/src/utils/transcript-requirements.ts new file mode 100644 index 000000000..89fdbfbc2 --- /dev/null +++ b/src/utils/transcript-requirements.ts @@ -0,0 +1,68 @@ +import type { Settings } from '../types/Settings'; +import type { StatusJSON } from '../types/StatusJSON'; +import type { WidgetItem } from '../types/Widget'; +import { isCacheSessionScope } from '../widgets/shared/cache-scope'; + +import { getContextWindowMetrics } from './context-window'; +import { resolveLegacyWidgetType } from './widgets'; + +// Widgets that read transcript token totals whenever they render: the token +// widgets prefer them over the status JSON totals, or have no other source. +const ALWAYS_TOKEN_METRIC_WIDGETS = new Set([ + 'tokens-input', + 'tokens-output', + 'tokens-cached', + 'tokens-total' +]); + +// Cache widgets read transcript totals only in their session scope. +const SESSION_SCOPE_CACHE_WIDGETS = new Set([ + 'cache-hit-rate', + 'cache-read', + 'cache-write' +]); + +/** + * Whether rendering this configuration can read `RenderContext.tokenMetrics`. + * Context widgets only fall back to transcript metrics when the status JSON + * lacks the matching `context_window` field, so the full transcript scan can be + * skipped when Claude Code already supplies it. Keep in sync with the + * `context.tokenMetrics` reads in the widgets and in `calculateMaxWidth`. + */ +export function needsTranscriptTokenMetrics( + lines: WidgetItem[][], + settings: Pick, + data: StatusJSON +): boolean { + const contextWindow = getContextWindowMetrics(data); + const missingUsedPercentage = contextWindow.usedPercentage === null; + const missingContextLength = contextWindow.contextLengthTokens === null; + + if (settings.flexMode === 'full-until-compact' && missingUsedPercentage) { + return true; + } + + return lines.some(line => line.some((item) => { + const type = resolveLegacyWidgetType(item.type); + if (ALWAYS_TOKEN_METRIC_WIDGETS.has(type)) { + return true; + } + if (SESSION_SCOPE_CACHE_WIDGETS.has(type)) { + return isCacheSessionScope(item); + } + + switch (type) { + case 'context-length': + case 'context-percentage-usable': + return missingContextLength; + case 'context-percentage': + return missingUsedPercentage; + case 'context-bar': + // The bar also uses the presence of transcript metrics to pick a + // default window size when context_window_size is missing. + return missingContextLength || contextWindow.windowSize === null; + default: + return false; + } + })); +}