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 @@ -46,6 +46,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 @@ -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
119 changes: 119 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,125 @@ 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)
cacheReadTokens: 25, // 20 + 5
cacheCreationTokens: 15, // 10 + 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
cacheReadTokens: 25, // main 20 + file 5
cacheCreationTokens: 15, // main 10 + file 5
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,
cacheReadTokens: 25, // 20 + 5
cacheCreationTokens: 15, // 10 + 5
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,
cacheReadTokens: 0,
cacheCreationTokens: 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('Σ ');
});
});
Loading