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
7 changes: 7 additions & 0 deletions src/ccstatusline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
getTerminalWidth
} from './utils/terminal';
import { sanitizeTerminalText } from './utils/terminal-sanitize';
import { isWidgetSubagentsEnabled } from './utils/token-subagents';
import { fetchAccountEmail } from './utils/usage';
import { prefetchUsageDataIfNeeded } from './utils/usage-prefetch';
import { ensureWindowsUtf8CodePage } from './utils/windows-code-page';
Expand Down Expand Up @@ -123,6 +124,9 @@ async function renderMultipleLines(data: StatusJSON, rawData: Record<string, unk
const speedWidgetTypes = new Set(['output-speed', 'input-speed', 'total-speed']);
const hasSpeedItems = lines.some(line => line.some(item => speedWidgetTypes.has(item.type)));
const hasCompactionWidget = lines.some(line => line.some(item => item.type === 'compaction-counter'));
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))));
const hasThinkingEffortWidget = lines.some(line => line.some(item => item.type === 'thinking-effort'));
const hasSessionNameWidget = lines.some(line => line.some(item => item.type === 'session-name'));
const hasAccountEmailWidget = lines.some(line => line.some(item => item.type === 'claude-account-email'));
Expand All @@ -142,6 +146,7 @@ async function renderMultipleLines(data: StatusJSON, rawData: Record<string, unk
includeSessionDuration: hasSessionClock && !hasSessionDurationInStatusJson(data),
includeSpeedMetrics: hasSpeedItems,
includeSubagents: true,
includeSubagentTokens: needsSessionTokens,
speedWindowSeconds: Array.from(requestedSpeedWindows),
includeCompactionStats: hasCompactionWidget,
includeThinkingEffort: needsTranscriptThinkingEffort,
Expand All @@ -157,6 +162,7 @@ async function renderMultipleLines(data: StatusJSON, rawData: Record<string, unk
const sessionForecast = computeSessionForecastIfNeeded(lines, usageData, getDefaultSessionUsageHistoryDeps(data));

const tokenMetrics = transcriptAnalysis?.tokenMetrics ?? null;
const sessionTokenMetrics = transcriptAnalysis?.sessionTokenMetrics ?? null;
const sessionDuration = transcriptAnalysis?.sessionDuration ?? null;
const speedMetrics = transcriptAnalysis?.speedMetricsCollection?.sessionAverage ?? null;
const windowedSpeedMetrics = transcriptAnalysis?.speedMetricsCollection?.windowed ?? null;
Expand All @@ -175,6 +181,7 @@ async function renderMultipleLines(data: StatusJSON, rawData: Record<string, unk
data,
rawData,
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 @@ -55,6 +55,7 @@ export interface RenderContext {
// number strings into numbers.
rawData?: Record<string, unknown>;
tokenMetrics?: TokenMetrics | null;
sessionTokenMetrics?: TokenMetrics | null;
speedMetrics?: SpeedMetrics | null;
windowedSpeedMetrics?: Record<string, SpeedMetrics> | null;
usageData?: RenderUsageData | null;
Expand Down
176 changes: 176 additions & 0 deletions src/utils/__tests__/jsonl-metrics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,15 @@ async function getTokenMetrics(transcriptPath: string): Promise<TokenMetrics> {
return analysis.tokenMetrics;
}

async function getSessionTokenMetrics(transcriptPath: string): Promise<TokenMetrics> {
const analysis = await getTranscriptAnalysis(transcriptPath, { includeSubagentTokens: true });
if (!analysis.sessionTokenMetrics) {
throw new Error('subagent-inclusive token metrics were requested but not collected');
}

return analysis.sessionTokenMetrics;
}

async function getSpeedMetricsCollection(
transcriptPath: string,
options: { includeSubagents?: boolean; windowSeconds?: number[] } = {}
Expand Down Expand Up @@ -1143,6 +1152,173 @@ describe('jsonl transcript metrics', () => {
});
});

it('leaves the main token metrics alone and counts sub-agents only in the session totals', 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
})}\n`);

// Unchanged behavior: the main metrics count the inline rows and never
// read the separate file.
const mainOnly = await getTokenMetrics(transcriptPath);
expect(mainOnly).toEqual({
inputTokens: 600,
outputTokens: 110,
cachedTokens: 40,
cacheReadTokens: 25,
cacheCreationTokens: 15,
totalTokens: 750,
contextLength: 130
});

// Session totals: the inline sidechain rows are dropped because the
// file represents them, and the file is added once.
const sessionMetrics = await getSessionTokenMetrics(transcriptPath);
expect(sessionMetrics).toEqual({
inputTokens: 600,
outputTokens: 110,
cachedTokens: 40,
cacheReadTokens: 25,
cacheCreationTokens: 15,
totalTokens: 750,
contextLength: 130
});
});

it('drops a repeated inline sidechain message exactly once when its sub-agent file exists', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-token-sub-'));
tempRoots.push(root);
const transcriptPath = path.join(root, 'main-repeated.jsonl');
const subagentsDir = path.join(root, 'subagents');

fs.writeFileSync(transcriptPath, [
makeUsageLine({
timestamp: '2026-01-01T10:00:00.000Z',
input: 100, output: 50,
messageId: 'main-1'
}),
// The same sidechain message logged twice as it streams. It is counted
// once, with the larger output, so exactly that must be taken back out.
makeUsageLine({
timestamp: '2026-01-01T10:05:00.000Z',
input: 500, output: 10,
isSidechain: true,
messageId: 'side-1'
}),
makeUsageLine({
timestamp: '2026-01-01T10:05:01.000Z',
input: 500, output: 60,
isSidechain: true,
messageId: 'side-1'
}),
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:01.000Z',
input: 500, output: 60,
isSidechain: true,
messageId: 'side-1'
})}\n`);

const expected = {
inputTokens: 600,
outputTokens: 110,
cachedTokens: 0,
cacheReadTokens: 0,
cacheCreationTokens: 0,
totalTokens: 710,
contextLength: 100
};

expect(await getTokenMetrics(transcriptPath)).toEqual(expected);
expect(await getSessionTokenMetrics(transcriptPath)).toEqual(expected);
});

it('keeps inline sidechain entries in the session totals when no sub-agent 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'));

// Older transcript format: nothing to subtract, so the inline rows stand.
const sessionMetrics = await getSessionTokenMetrics(transcriptPath);
expect(sessionMetrics).toEqual({
inputTokens: 600,
outputTokens: 110,
cachedTokens: 40,
cacheReadTokens: 25,
cacheCreationTokens: 15,
totalTokens: 750,
contextLength: 130
});
});

it('sums every referenced sub-agent transcript 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 }),
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 })}\n`);
fs.writeFileSync(path.join(subagentsDir, 'agent-b.jsonl'),
`${makeUsageLine({ timestamp: '2026-01-01T10:02:00.000Z', input: 30, output: 40 })}\n`);
fs.writeFileSync(path.join(subagentsDir, 'agent-unreferenced.jsonl'),
`${makeUsageLine({ timestamp: '2026-01-01T10:03:00.000Z', input: 9999, output: 9999 })}\n`);

const sessionMetrics = await getSessionTokenMetrics(transcriptPath);
expect(sessionMetrics).toEqual({
inputTokens: 140,
outputTokens: 245,
cachedTokens: 0,
cacheReadTokens: 0,
cacheCreationTokens: 0,
totalTokens: 385,
contextLength: 10
});
});

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('Σ ');
});
});
Loading
Loading