Skip to content
7 changes: 7 additions & 0 deletions src/ccstatusline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
getPackageVersion,
getTerminalWidth
} from './utils/terminal';
import { isWidgetSubagentsEnabled } from './utils/token-subagents';
import { prefetchUsageDataIfNeeded } from './utils/usage-prefetch';

function hasSessionDurationInStatusJson(data: StatusJSON): boolean {
Expand Down Expand Up @@ -108,6 +109,9 @@ async function renderMultipleLines(data: StatusJSON) {
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 needsTranscriptThinkingEffort = hasThinkingEffortWidget
Expand All @@ -126,6 +130,7 @@ async function renderMultipleLines(data: StatusJSON) {
includeSessionDuration: hasSessionClock && !hasSessionDurationInStatusJson(data),
includeSpeedMetrics: hasSpeedItems,
includeSubagents: true,
includeSubagentTokens: needsSessionTokens,
speedWindowSeconds: Array.from(requestedSpeedWindows),
includeCompactionStats: hasCompactionWidget,
includeThinkingEffort: needsTranscriptThinkingEffort,
Expand All @@ -139,6 +144,7 @@ async function renderMultipleLines(data: StatusJSON) {
]);

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 @@ -156,6 +162,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 @@ -45,6 +45,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
125 changes: 125 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 @@ -786,6 +795,122 @@ 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('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