From 2a1b7b0293923a12a2589a6989590fa4b93866ce Mon Sep 17 00:00:00 2001 From: Ani Aggarwal Date: Sat, 7 Mar 2026 14:55:32 -0800 Subject: [PATCH 1/2] fix: use transcript model to correct stale statusline model display Read the model from per-session transcript data instead of relying on the global shared state model, which can be contaminated across sessions (anthropics/claude-code#19570, closes #190). When the last transcript entry is a user message (API call in progress), returns null to avoid showing a stale model. Self-corrects once the response arrives. --- src/ccstatusline.ts | 20 +++++ src/types/TokenMetrics.ts | 3 +- .../__tests__/context-percentage.test.ts | 27 ++++--- src/utils/__tests__/jsonl-metrics.test.ts | 79 ++++++++++++++++++- src/utils/jsonl-metrics.ts | 24 +++++- src/widgets/__tests__/ContextBar.test.ts | 9 ++- .../__tests__/ContextPercentage.test.ts | 6 +- .../__tests__/ContextPercentageUsable.test.ts | 9 ++- src/widgets/__tests__/TokensWidgets.test.ts | 9 ++- 9 files changed, 159 insertions(+), 27 deletions(-) diff --git a/src/ccstatusline.ts b/src/ccstatusline.ts index 6fe1c222..02251403 100644 --- a/src/ccstatusline.ts +++ b/src/ccstatusline.ts @@ -38,6 +38,14 @@ import { } from './utils/speed-window'; import { prefetchUsageDataIfNeeded } from './utils/usage-prefetch'; +function formatModelDisplayName(modelId: string): string { + return modelId + .replace(/^claude-/, '') + .replace(/-(\d{8})$/, '') + .replace(/-/g, ' ') + .replace(/\b\w/g, c => c.toUpperCase()); +} + function hasSessionDurationInStatusJson(data: StatusJSON): boolean { const durationMs = data.cost?.total_duration_ms; return typeof durationMs === 'number' && Number.isFinite(durationMs) && durationMs >= 0; @@ -114,6 +122,18 @@ async function renderMultipleLines(data: StatusJSON) { let tokenMetrics: TokenMetrics | null = null; if (data.transcript_path) { tokenMetrics = await getTokenMetrics(data.transcript_path); + + if (tokenMetrics.model) { + const stdinModelId = typeof data.model === 'string' + ? data.model + : data.model?.id; + if (stdinModelId !== tokenMetrics.model) { + data.model = { + id: tokenMetrics.model, + display_name: formatModelDisplayName(tokenMetrics.model) + }; + } + } } let sessionDuration: string | null = null; diff --git a/src/types/TokenMetrics.ts b/src/types/TokenMetrics.ts index cb63cac3..eb97d9a1 100644 --- a/src/types/TokenMetrics.ts +++ b/src/types/TokenMetrics.ts @@ -6,7 +6,7 @@ export interface TokenUsage { } export interface TranscriptLine { - message?: { usage?: TokenUsage }; + message?: { usage?: TokenUsage; model?: string }; isSidechain?: boolean; timestamp?: string; isApiErrorMessage?: boolean; @@ -19,4 +19,5 @@ export interface TokenMetrics { cachedTokens: number; totalTokens: number; contextLength: number; + model: string | null; } \ No newline at end of file diff --git a/src/utils/__tests__/context-percentage.test.ts b/src/utils/__tests__/context-percentage.test.ts index 8f85c784..f789698f 100644 --- a/src/utils/__tests__/context-percentage.test.ts +++ b/src/utils/__tests__/context-percentage.test.ts @@ -23,7 +23,8 @@ describe('calculateContextPercentage', () => { outputTokens: 0, cachedTokens: 0, totalTokens: 0, - contextLength: 100000 + contextLength: 100000, + model: null } }; @@ -61,7 +62,8 @@ describe('calculateContextPercentage', () => { outputTokens: 0, cachedTokens: 0, totalTokens: 0, - contextLength: 42000 + contextLength: 42000, + model: null } }; @@ -79,7 +81,8 @@ describe('calculateContextPercentage', () => { outputTokens: 0, cachedTokens: 0, totalTokens: 0, - contextLength: 42000 + contextLength: 42000, + model: null } }; @@ -95,7 +98,8 @@ describe('calculateContextPercentage', () => { outputTokens: 0, cachedTokens: 0, totalTokens: 0, - contextLength: 2000000 + contextLength: 2000000, + model: null } }; @@ -111,7 +115,8 @@ describe('calculateContextPercentage', () => { outputTokens: 0, cachedTokens: 0, totalTokens: 0, - contextLength: 42000 + contextLength: 42000, + model: null } }; @@ -127,7 +132,8 @@ describe('calculateContextPercentage', () => { outputTokens: 0, cachedTokens: 0, totalTokens: 0, - contextLength: 42000 + contextLength: 42000, + model: null } }; @@ -148,7 +154,8 @@ describe('calculateContextPercentage', () => { outputTokens: 0, cachedTokens: 0, totalTokens: 0, - contextLength: 42000 + contextLength: 42000, + model: null } }; @@ -166,7 +173,8 @@ describe('calculateContextPercentage', () => { outputTokens: 0, cachedTokens: 0, totalTokens: 0, - contextLength: 42000 + contextLength: 42000, + model: null } }; @@ -188,7 +196,8 @@ describe('calculateContextPercentage', () => { outputTokens: 0, cachedTokens: 0, totalTokens: 0, - contextLength: 42000 + contextLength: 42000, + model: null } }; diff --git a/src/utils/__tests__/jsonl-metrics.test.ts b/src/utils/__tests__/jsonl-metrics.test.ts index 92d9f63f..84702f95 100644 --- a/src/utils/__tests__/jsonl-metrics.test.ts +++ b/src/utils/__tests__/jsonl-metrics.test.ts @@ -46,6 +46,7 @@ function makeTranscriptLine(params: { output?: number; isSidechain?: boolean; isApiErrorMessage?: boolean; + model?: string; }): string { return JSON.stringify({ timestamp: params.timestamp, @@ -57,7 +58,8 @@ function makeTranscriptLine(params: { usage: { input_tokens: params.input ?? 0, output_tokens: params.output ?? 0 - } + }, + model: params.model } : undefined }); @@ -155,7 +157,8 @@ describe('jsonl transcript metrics', () => { outputTokens: 141, cachedTokens: 92, totalTokens: 2032, - contextLength: 250 + contextLength: 250, + model: null }); }); @@ -166,10 +169,80 @@ describe('jsonl transcript metrics', () => { outputTokens: 0, cachedTokens: 0, totalTokens: 0, - contextLength: 0 + contextLength: 0, + model: null }); }); + it('returns the model from the most recent assistant entry', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-metrics-')); + tempRoots.push(root); + const transcriptPath = path.join(root, 'model.jsonl'); + fs.writeFileSync(transcriptPath, [ + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:00.000Z', + type: 'user' + }), + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:05.000Z', + type: 'assistant', + input: 100, + output: 50, + model: 'claude-sonnet-4-5-20250929' + }) + ].join('\n')); + + const metrics = await getTokenMetrics(transcriptPath); + expect(metrics.model).toBe('claude-sonnet-4-5-20250929'); + }); + + it('returns null model when the last entry is a user message (API in progress)', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-metrics-')); + tempRoots.push(root); + const transcriptPath = path.join(root, 'model-pending.jsonl'); + fs.writeFileSync(transcriptPath, [ + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:00.000Z', + type: 'user' + }), + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:05.000Z', + type: 'assistant', + input: 100, + output: 50, + model: 'claude-sonnet-4-5-20250929' + }), + makeTranscriptLine({ + timestamp: '2026-01-01T10:01:00.000Z', + type: 'user' + }) + ].join('\n')); + + const metrics = await getTokenMetrics(transcriptPath); + expect(metrics.model).toBeNull(); + }); + + it('returns null model when transcript has no model field', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-metrics-')); + tempRoots.push(root); + const transcriptPath = path.join(root, 'no-model.jsonl'); + fs.writeFileSync(transcriptPath, [ + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:00.000Z', + type: 'user' + }), + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:05.000Z', + type: 'assistant', + input: 100, + output: 50 + }) + ].join('\n')); + + const metrics = await getTokenMetrics(transcriptPath); + expect(metrics.model).toBeNull(); + }); + it('calculates speed metrics from user-to-assistant processing windows', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-')); tempRoots.push(root); diff --git a/src/utils/jsonl-metrics.ts b/src/utils/jsonl-metrics.ts index aacdefb3..85b577ea 100644 --- a/src/utils/jsonl-metrics.ts +++ b/src/utils/jsonl-metrics.ts @@ -152,7 +152,7 @@ export async function getTokenMetrics(transcriptPath: string): Promise= 0; i--) { + const last = parseJsonlLine(lines[i]!) as TranscriptLine | null; + if (last?.type === 'assistant' || last?.type === 'user') { + lastEntryIsUser = last.type === 'user'; + break; + } + } + if (!lastEntryIsUser) { + model = mostRecentMainChainEntry.message.model; + } + } + const totalTokens = inputTokens + outputTokens + cachedTokens; - return { inputTokens, outputTokens, cachedTokens, totalTokens, contextLength }; + return { inputTokens, outputTokens, cachedTokens, totalTokens, contextLength, model }; } catch { - return { inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0, contextLength: 0 }; + return { inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0, contextLength: 0, model: null }; } } diff --git a/src/widgets/__tests__/ContextBar.test.ts b/src/widgets/__tests__/ContextBar.test.ts index 7f277f0f..045b1750 100644 --- a/src/widgets/__tests__/ContextBar.test.ts +++ b/src/widgets/__tests__/ContextBar.test.ts @@ -49,7 +49,8 @@ describe('ContextBarWidget', () => { outputTokens: 0, cachedTokens: 0, totalTokens: 0, - contextLength: 50000 + contextLength: 50000, + model: null } }; const widget = new ContextBarWidget(); @@ -65,7 +66,8 @@ describe('ContextBarWidget', () => { outputTokens: 0, cachedTokens: 0, totalTokens: 0, - contextLength: 50000 + contextLength: 50000, + model: null } }; const widget = new ContextBarWidget(); @@ -81,7 +83,8 @@ describe('ContextBarWidget', () => { outputTokens: 0, cachedTokens: 0, totalTokens: 0, - contextLength: 50000 + contextLength: 50000, + model: null } }; const widget = new ContextBarWidget(); diff --git a/src/widgets/__tests__/ContextPercentage.test.ts b/src/widgets/__tests__/ContextPercentage.test.ts index beba7d8d..2a9444a0 100644 --- a/src/widgets/__tests__/ContextPercentage.test.ts +++ b/src/widgets/__tests__/ContextPercentage.test.ts @@ -20,7 +20,8 @@ function render(modelId: string | undefined, contextLength: number, rawValue = f outputTokens: 0, cachedTokens: 0, totalTokens: 0, - contextLength + contextLength, + model: null } }; const item: WidgetItem = { @@ -72,7 +73,8 @@ describe('ContextPercentageWidget', () => { outputTokens: 0, cachedTokens: 0, totalTokens: 0, - contextLength: 100000 + contextLength: 100000, + model: null } }; diff --git a/src/widgets/__tests__/ContextPercentageUsable.test.ts b/src/widgets/__tests__/ContextPercentageUsable.test.ts index 98945af4..ed424fe1 100644 --- a/src/widgets/__tests__/ContextPercentageUsable.test.ts +++ b/src/widgets/__tests__/ContextPercentageUsable.test.ts @@ -20,7 +20,8 @@ function render(modelId: string | undefined, contextLength: number, rawValue = f outputTokens: 0, cachedTokens: 0, totalTokens: 0, - contextLength + contextLength, + model: null } }; const item: WidgetItem = { @@ -76,7 +77,8 @@ describe('ContextPercentageUsableWidget', () => { outputTokens: 0, cachedTokens: 0, totalTokens: 0, - contextLength: 200000 + contextLength: 200000, + model: null } }; @@ -99,7 +101,8 @@ describe('ContextPercentageUsableWidget', () => { outputTokens: 0, cachedTokens: 0, totalTokens: 0, - contextLength: 42000 + contextLength: 42000, + model: null } }; diff --git a/src/widgets/__tests__/TokensWidgets.test.ts b/src/widgets/__tests__/TokensWidgets.test.ts index 29b9fb5a..b7fe7c38 100644 --- a/src/widgets/__tests__/TokensWidgets.test.ts +++ b/src/widgets/__tests__/TokensWidgets.test.ts @@ -57,7 +57,8 @@ describe('Token widgets', () => { outputTokens: 9999, cachedTokens: 9999, totalTokens: 9999, - contextLength: 9999 + contextLength: 9999, + model: null } }; @@ -75,7 +76,8 @@ describe('Token widgets', () => { outputTokens: 3400, cachedTokens: 560, totalTokens: 5160, - contextLength: 0 + contextLength: 0, + model: null } }; @@ -105,7 +107,8 @@ describe('Token widgets', () => { outputTokens: 3400, cachedTokens: 560, totalTokens: 5160, - contextLength: 20000 + contextLength: 20000, + model: null } }; From 889772a319eef123b8ba4bb8c6946621a7432495 Mon Sep 17 00:00:00 2001 From: Ani Aggarwal Date: Sat, 7 Mar 2026 15:10:27 -0800 Subject: [PATCH 2/2] fix: replace non-null assertion with guard to satisfy eslint --- src/utils/jsonl-metrics.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/utils/jsonl-metrics.ts b/src/utils/jsonl-metrics.ts index 85b577ea..cca96b06 100644 --- a/src/utils/jsonl-metrics.ts +++ b/src/utils/jsonl-metrics.ts @@ -201,7 +201,11 @@ export async function getTokenMetrics(transcriptPath: string): Promise= 0; i--) { - const last = parseJsonlLine(lines[i]!) as TranscriptLine | null; + const line = lines[i]; + if (!line) { + continue; + } + const last = parseJsonlLine(line) as TranscriptLine | null; if (last?.type === 'assistant' || last?.type === 'user') { lastEntryIsUser = last.type === 'user'; break;