Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions src/ccstatusline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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),
Expand Down
27 changes: 27 additions & 0 deletions src/utils/__tests__/jsonl-metrics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ async function getSessionDuration(transcriptPath: string): Promise<string | null

async function getTokenMetrics(transcriptPath: string): Promise<TokenMetrics> {
const analysis = await getTranscriptAnalysis(transcriptPath);
if (!analysis.tokenMetrics) {
throw new Error('token metrics are collected by default');
}

return analysis.tokenMetrics;
}

Expand Down Expand Up @@ -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);
Expand Down
171 changes: 171 additions & 0 deletions src/utils/__tests__/transcript-requirements.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, StatusJSON> = {
'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);
}
});
});
13 changes: 9 additions & 4 deletions src/utils/jsonl-metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -45,15 +47,15 @@ export interface TranscriptAnalysisOptions {
}

export interface TranscriptAnalysis {
tokenMetrics: TokenMetrics;
tokenMetrics: TokenMetrics | null;
sessionDuration: string | null;
speedMetricsCollection: SpeedMetricsCollection | null;
compactionData: CompactionData | null;
thinkingEffort: ResolvedThinkingEffort | undefined;
sessionName: string | null;
}

interface TranscriptScanOptions extends TranscriptAnalysisOptions { includeTokenMetrics?: boolean }
type TranscriptScanOptions = TranscriptAnalysisOptions;

interface TranscriptScanResult {
tokenMetrics: TokenMetrics | null;
Expand Down Expand Up @@ -666,13 +668,16 @@ export async function getTranscriptAnalysis(
transcriptPath: string,
options: TranscriptAnalysisOptions = {}
): Promise<TranscriptAnalysis> {
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,
Expand Down
68 changes: 68 additions & 0 deletions src/utils/transcript-requirements.ts
Original file line number Diff line number Diff line change
@@ -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<string>([
'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<string>([
'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<Settings, 'flexMode'>,
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;
}
}));
}
Loading