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
10 changes: 10 additions & 0 deletions src/ccstatusline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
getWidgetSpeedWindowSeconds,
isWidgetSpeedWindowEnabled
} from './utils/speed-window';
import { isWidgetSubagentsEnabled } from './utils/token-subagents';
import {
getPackageVersion,
getTerminalWidth
Expand Down Expand Up @@ -122,9 +123,17 @@ async function renderMultipleLines(data: StatusJSON) {
}
}

const subagentTokenWidgetTypes = new Set(['tokens-input', 'tokens-output', 'tokens-cached', 'tokens-total']);
const needsSessionTokens = lines.some(line => line.some(item => item.type === 'tokens-session-total'
|| (subagentTokenWidgetTypes.has(item.type) && isWidgetSubagentsEnabled(item))));

let tokenMetrics: TokenMetrics | null = null;
let sessionTokenMetrics: TokenMetrics | null = null;
if (data.transcript_path) {
tokenMetrics = await getTokenMetrics(data.transcript_path);
if (needsSessionTokens) {
sessionTokenMetrics = await getTokenMetrics(data.transcript_path, { includeSubagents: true });
}
}

let sessionDuration: string | null = null;
Expand Down Expand Up @@ -161,6 +170,7 @@ async function renderMultipleLines(data: StatusJSON) {
const context: RenderContext = {
data,
tokenMetrics,
sessionTokenMetrics,
speedMetrics,
windowedSpeedMetrics,
usageData,
Expand Down
1 change: 1 addition & 0 deletions src/types/RenderContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export interface CompactionData {
export interface RenderContext {
data?: StatusJSON;
tokenMetrics?: TokenMetrics | null;
sessionTokenMetrics?: TokenMetrics | null;
speedMetrics?: SpeedMetrics | null;
windowedSpeedMetrics?: Record<string, SpeedMetrics> | null;
usageData?: RenderUsageData | null;
Expand Down
111 changes: 111 additions & 0 deletions src/utils/__tests__/jsonl-metrics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,117 @@ describe('jsonl transcript metrics', () => {
});
});

it('excludes subagents by default (back-compat) and counts them when enabled', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-token-sub-'));
tempRoots.push(root);
const transcriptPath = path.join(root, 'main.jsonl');
const subagentsDir = path.join(root, 'subagents');

fs.writeFileSync(transcriptPath, [
makeUsageLine({
timestamp: '2026-01-01T10:00:00.000Z',
input: 100, output: 50, cacheRead: 20, cacheCreate: 10
}),
// inline sidechain entry that ALSO lives in the separate file below
makeUsageLine({
timestamp: '2026-01-01T10:05:00.000Z',
input: 500, output: 60, cacheRead: 5, cacheCreate: 5,
isSidechain: true
}),
JSON.stringify({ type: 'progress', data: { agentId: 'x' } })
].join('\n'));

fs.mkdirSync(subagentsDir, { recursive: true });
fs.writeFileSync(path.join(subagentsDir, 'agent-x.jsonl'), [
makeUsageLine({
timestamp: '2026-01-01T10:05:00.000Z',
input: 500, output: 60, cacheRead: 5, cacheCreate: 5,
isSidechain: true
})
].join('\n'));

// Default: inline sidechain counted, separate file NOT read.
const mainOnly = await getTokenMetrics(transcriptPath);
expect(mainOnly).toEqual({
inputTokens: 600, // 100 + 500
outputTokens: 110, // 50 + 60
cachedTokens: 40, // (20+10) + (5+5)
totalTokens: 750,
contextLength: 130 // latest main-chain non-sidechain: 100 + 20 + 10
});

// Included: inline sidechain dropped from main (separate file present), file added once.
const withSubs = await getTokenMetrics(transcriptPath, { includeSubagents: true });
expect(withSubs).toEqual({
inputTokens: 600, // main 100 (sidechain dropped) + file 500
outputTokens: 110, // main 50 + file 60
cachedTokens: 40, // main 30 + file 10
totalTokens: 750,
contextLength: 130 // unchanged — main-chain concept
});
});

it('counts inline sidechain entries when included but no separate files exist', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-token-sub-'));
tempRoots.push(root);
const transcriptPath = path.join(root, 'main-no-files.jsonl');

fs.writeFileSync(transcriptPath, [
makeUsageLine({
timestamp: '2026-01-01T10:00:00.000Z',
input: 100, output: 50, cacheRead: 20, cacheCreate: 10
}),
makeUsageLine({
timestamp: '2026-01-01T10:05:00.000Z',
input: 500, output: 60, cacheRead: 5, cacheCreate: 5,
isSidechain: true
})
].join('\n'));

// No subagents dir → skipMainSidechain=false → inline sidechain still counted.
const withSubs = await getTokenMetrics(transcriptPath, { includeSubagents: true });
expect(withSubs).toEqual({
inputTokens: 600,
outputTokens: 110,
cachedTokens: 40,
totalTokens: 750,
contextLength: 130
});
});

it('sums multiple referenced subagent token files and ignores unreferenced ones', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-token-sub-'));
tempRoots.push(root);
const transcriptPath = path.join(root, 'main-multi.jsonl');
const subagentsDir = path.join(root, 'subagents');

fs.writeFileSync(transcriptPath, [
makeUsageLine({
timestamp: '2026-01-01T10:00:00.000Z',
input: 10, output: 5, cacheRead: 0, cacheCreate: 0
}),
JSON.stringify({ type: 'progress', data: { agentId: 'a' } }),
JSON.stringify({ type: 'progress', data: { agentId: 'b' } })
].join('\n'));

fs.mkdirSync(subagentsDir, { recursive: true });
fs.writeFileSync(path.join(subagentsDir, 'agent-a.jsonl'),
makeUsageLine({ timestamp: '2026-01-01T10:01:00.000Z', input: 100, output: 200, cacheRead: 0, cacheCreate: 0 }));
fs.writeFileSync(path.join(subagentsDir, 'agent-b.jsonl'),
makeUsageLine({ timestamp: '2026-01-01T10:02:00.000Z', input: 30, output: 40, cacheRead: 0, cacheCreate: 0 }));
fs.writeFileSync(path.join(subagentsDir, 'agent-unreferenced.jsonl'),
makeUsageLine({ timestamp: '2026-01-01T10:03:00.000Z', input: 9999, output: 9999, cacheRead: 0, cacheCreate: 0 }));

const withSubs = await getTokenMetrics(transcriptPath, { includeSubagents: true });
expect(withSubs).toEqual({
inputTokens: 140, // 10 + 100 + 30
outputTokens: 245, // 5 + 200 + 40
cachedTokens: 0,
totalTokens: 385,
contextLength: 10 // 10 + 0 + 0
});
});

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);
Expand Down
45 changes: 45 additions & 0 deletions src/utils/__tests__/token-subagents.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import {
describe,
expect,
it
} from 'vitest';

import type { WidgetItem } from '../../types/Widget';
import {
SUBAGENTS_MARKER,
isWidgetSubagentsEnabled,
withWidgetSubagentsEnabled
} from '../token-subagents';

function makeItem(metadata?: Record<string, string>): WidgetItem {
return { id: '1', type: 'tokens-input', metadata };
}

describe('token-subagents helper', () => {
it('defaults to disabled', () => {
expect(isWidgetSubagentsEnabled(makeItem())).toBe(false);
expect(isWidgetSubagentsEnabled(makeItem({}))).toBe(false);
});

it('reads the includeSubagents flag', () => {
expect(isWidgetSubagentsEnabled(makeItem({ includeSubagents: 'true' }))).toBe(true);
expect(isWidgetSubagentsEnabled(makeItem({ includeSubagents: 'false' }))).toBe(false);
});

it('enables and clears the flag immutably', () => {
const base = makeItem({ color: 'red' });

const enabled = withWidgetSubagentsEnabled(base, true);
expect(enabled).not.toBe(base);
expect(isWidgetSubagentsEnabled(enabled)).toBe(true);
expect(enabled.metadata?.color).toBe('red');

const disabled = withWidgetSubagentsEnabled(enabled, false);
expect(isWidgetSubagentsEnabled(disabled)).toBe(false);
expect(disabled.metadata?.includeSubagents).toBeUndefined();
});

it('exposes the sigma marker', () => {
expect(SUBAGENTS_MARKER).toBe('Σ ');
});
});
147 changes: 114 additions & 33 deletions src/utils/jsonl-metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,57 +148,135 @@ export async function getSessionDuration(transcriptPath: string): Promise<string
}
}

export async function getTokenMetrics(transcriptPath: string): Promise<TokenMetrics> {
interface TokenUsageSum {
inputTokens: number;
outputTokens: number;
cachedTokens: number;
}

export interface TokenMetricsOptions { includeSubagents?: boolean }

// Claude Code writes multiple JSONL entries per API call during streaming:
// intermediate entries have stop_reason: null, and the final entry has a string
// value like "end_turn" or "tool_use". Return finalized entries plus the latest
// unfinished one so live updates do not overcount duplicate partial rows. If the
// transcript format has no stop_reason field at all, count all entries.
function getFinalizedUsageEntries(lines: string[]): TranscriptLine[] {
const parsedEntries: TranscriptLine[] = [];
let hasStopReasonField = false;

for (const line of lines) {
const data = parseJsonlLine(line) as TranscriptLine | null;
if (data?.message?.usage) {
parsedEntries.push(data);
if (Object.hasOwn(data.message, 'stop_reason')) {
hasStopReasonField = true;
}
}
}

return hasStopReasonField
? parsedEntries.filter((data, index) => {
const stopReason = data.message?.stop_reason;
return Boolean(stopReason) || (stopReason === null && index === parsedEntries.length - 1);
})
: parsedEntries;
}

function sumUsage(entries: TranscriptLine[], skipSidechain: boolean): TokenUsageSum {
let inputTokens = 0;
let outputTokens = 0;
let cachedTokens = 0;

for (const data of entries) {
if (skipSidechain && data.isSidechain === true) {
continue;
}
const usage = data.message?.usage;
if (!usage) {
continue;
}
inputTokens += usage.input_tokens || 0;
outputTokens += usage.output_tokens || 0;
cachedTokens += usage.cache_read_input_tokens ?? 0;
cachedTokens += usage.cache_creation_input_tokens ?? 0;
}

return { inputTokens, outputTokens, cachedTokens };
}

// Context length is the most recent main-chain (non-sidechain, non-error)
// finalized entry's context size. Sub-agents do not share the main context window.
function computeContextLength(entries: TranscriptLine[]): number {
let mostRecentMainChainEntry: TranscriptLine | null = null;
let mostRecentTimestamp: Date | null = null;

for (const data of entries) {
if (data.isSidechain !== true && data.timestamp && !data.isApiErrorMessage) {
const entryTime = new Date(data.timestamp);
if (!mostRecentTimestamp || entryTime > mostRecentTimestamp) {
mostRecentTimestamp = entryTime;
mostRecentMainChainEntry = data;
}
}
}

if (mostRecentMainChainEntry?.message?.usage) {
const usage = mostRecentMainChainEntry.message.usage;
return (usage.input_tokens || 0)
+ (usage.cache_read_input_tokens ?? 0)
+ (usage.cache_creation_input_tokens ?? 0);
}

return 0;
}

export async function getTokenMetrics(
transcriptPath: string,
options: TokenMetricsOptions = {}
): Promise<TokenMetrics> {
try {
// Use Node.js-compatible file reading
if (!fs.existsSync(transcriptPath)) {
return { inputTokens: 0, outputTokens: 0, cachedTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0, totalTokens: 0, contextLength: 0 };
}

const lines = await readJsonlLines(transcriptPath);
const mainEntries = getFinalizedUsageEntries(lines);
const contextLength = computeContextLength(mainEntries);

const subagentPaths = options.includeSubagents === true
? getSubagentTranscriptPaths(transcriptPath, getReferencedSubagentIds(lines))
: [];
let inputTokens = 0;
let outputTokens = 0;
let cacheReadTokens = 0;
let cacheCreationTokens = 0;
let contextLength = 0;

// Parse each line and sum up token usage for totals.
// Claude Code writes multiple JSONL entries per API call during streaming:
// intermediate entries have stop_reason: null, and the final entry has a
// string value like "end_turn" or "tool_use". For streaming-aware
// transcripts, count finalized entries plus the latest unfinished entry so
// live updates do not overcount duplicate partial rows. If the transcript
// format has no stop_reason field at all, fall back to counting all entries.
let mostRecentMainChainEntry: TranscriptLine | null = null;
let mostRecentTimestamp: Date | null = null;
// When separate subagent files exist, inline sidechain rows are represented
// there — drop them from the main pass to avoid double counting. When no
// separate files exist (older format), keep counting inline sidechain rows.
const skipMainSidechain = subagentPaths.length > 0;
const mainSum = sumUsage(mainEntries, skipMainSidechain);

const parsedEntries: TranscriptLine[] = [];
let hasStopReasonField = false;
let inputTokens = mainSum.inputTokens;
let outputTokens = mainSum.outputTokens;
let cachedTokens = mainSum.cachedTokens;

for (const line of lines) {
const data = parseJsonlLine(line) as TranscriptLine | null;
if (data?.message?.usage) {
parsedEntries.push(data);
if (Object.hasOwn(data.message, 'stop_reason')) {
hasStopReasonField = true;
if (subagentPaths.length > 0) {
const subagentSums = await Promise.all(subagentPaths.map(async (subagentPath) => {
try {
const subagentLines = await readJsonlLines(subagentPath);
return sumUsage(getFinalizedUsageEntries(subagentLines), false);
} catch {
return null;
}
}
}

const entriesToCount = hasStopReasonField
? parsedEntries.filter((data, index) => {
const stopReason = data.message?.stop_reason;
return Boolean(stopReason) || (stopReason === null && index === parsedEntries.length - 1);
})
: parsedEntries;

for (const data of entriesToCount) {
const usage = data.message?.usage;
if (!usage) {
continue;
}
}));

for (const sum of subagentSums) {
if (!sum) {
continue;
inputTokens += usage.input_tokens || 0;
outputTokens += usage.output_tokens || 0;
cacheReadTokens += usage.cache_read_input_tokens ?? 0;
Expand All @@ -212,6 +290,9 @@ export async function getTokenMetrics(transcriptPath: string): Promise<TokenMetr
mostRecentTimestamp = entryTime;
mostRecentMainChainEntry = data;
}
inputTokens += sum.inputTokens;
outputTokens += sum.outputTokens;
cachedTokens += sum.cachedTokens;
}
}

Expand Down
Loading