From f78b295667df498394406132d3c7d3ba13f6525a Mon Sep 17 00:00:00 2001 From: elhoim Date: Wed, 30 Sep 2026 17:19:46 +0000 Subject: [PATCH] perf(render): skip the transcript scan when no widget reads it Every render parsed the whole session transcript, even when no configured widget uses anything from it. Token metrics are the usual reason for the scan, but the context widgets (context-length, context-percentage, context-percentage-usable, context-bar) and the full-until-compact flex mode only fall back to them when the status JSON lacks the matching context_window field, and the cache widgets only read them in session scope. The render now asks for token metrics only in those cases and skips the scan entirely when nothing else (session clock, speed, compaction, thinking effort, session name) needs it. getTranscriptAnalysis gains an includeTokenMetrics option (default true, tokenMetrics is null when it is false). A property test renders every data-only widget with and without transcript metrics for several payload shapes and fails if the output differs while the gate says the scan can be skipped. Measured (node dist, 20 interleaved passes, load1 ~57 on 6 cores), CPU median per render: default config 1575 -> 1270 ms on a 5 MB transcript (-19%), 2837 -> 1222 ms on 50 MB (-57%); a config with token widgets is unchanged. Output is byte-identical on 90 config/payload pairs, including payloads without context_window, without context_window_size, and with a missing transcript. Co-Authored-By: Claude Opus 5.5 --- src/ccstatusline.ts | 16 +- src/utils/__tests__/jsonl-metrics.test.ts | 27 +++ .../__tests__/transcript-requirements.test.ts | 171 ++++++++++++++++++ src/utils/jsonl-metrics.ts | 13 +- src/utils/transcript-requirements.ts | 68 +++++++ 5 files changed, 289 insertions(+), 6 deletions(-) create mode 100644 src/utils/__tests__/transcript-requirements.test.ts create mode 100644 src/utils/transcript-requirements.ts diff --git a/src/ccstatusline.ts b/src/ccstatusline.ts index 1da963f8f..0e5303f45 100644 --- a/src/ccstatusline.ts +++ b/src/ccstatusline.ts @@ -39,6 +39,7 @@ import { getPackageVersion, getTerminalWidth } from './utils/terminal'; +import { needsTranscriptTokenMetrics } from './utils/transcript-requirements'; import { prefetchUsageDataIfNeeded } from './utils/usage-prefetch'; function hasSessionDurationInStatusJson(data: StatusJSON): boolean { @@ -120,9 +121,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 348ea432a..d7f2f3791 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 88f4c47d8..ed20e979c 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; + } + })); +}