From c03662d2b25dd5a61788a92fc1a67b7a7de3926f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 12:18:25 +0000 Subject: [PATCH 1/5] feat(metrics): add includeSubagents option to getTokenMetrics Sums referenced subagent transcript files into token totals, reusing the existing subagent discovery (getReferencedSubagentIds/getSubagentTranscriptPaths) that already backs speed metrics. Default behavior is byte-identical; a double-count guard drops inline sidechain rows from the main pass only when separate subagent files are present. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/utils/__tests__/jsonl-metrics.test.ts | 111 ++++++++++++++ src/utils/jsonl-metrics.ts | 171 ++++++++++++++-------- 2 files changed, 225 insertions(+), 57 deletions(-) diff --git a/src/utils/__tests__/jsonl-metrics.test.ts b/src/utils/__tests__/jsonl-metrics.test.ts index cdd7af5c..a6d53b38 100644 --- a/src/utils/__tests__/jsonl-metrics.test.ts +++ b/src/utils/__tests__/jsonl-metrics.test.ts @@ -373,6 +373,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); diff --git a/src/utils/jsonl-metrics.ts b/src/utils/jsonl-metrics.ts index 5ba0db99..58325ebd 100644 --- a/src/utils/jsonl-metrics.ts +++ b/src/utils/jsonl-metrics.ts @@ -148,7 +148,93 @@ export async function getSessionDuration(transcriptPath: string): Promise { +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 { try { // Use Node.js-compatible file reading if (!fs.existsSync(transcriptPath)) { @@ -156,72 +242,43 @@ export async function getTokenMetrics(transcriptPath: string): Promise 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; - } + })); - inputTokens += usage.input_tokens || 0; - outputTokens += usage.output_tokens || 0; - cachedTokens += usage.cache_read_input_tokens ?? 0; - cachedTokens += usage.cache_creation_input_tokens ?? 0; - - // Track the most recent entry with isSidechain: false (or undefined, which defaults to main chain) - // Also skip API error messages (synthetic messages with 0 tokens) - if (data.isSidechain !== true && data.timestamp && !data.isApiErrorMessage) { - const entryTime = new Date(data.timestamp); - if (!mostRecentTimestamp || entryTime > mostRecentTimestamp) { - mostRecentTimestamp = entryTime; - mostRecentMainChainEntry = data; + for (const sum of subagentSums) { + if (!sum) { + continue; } + inputTokens += sum.inputTokens; + outputTokens += sum.outputTokens; + cachedTokens += sum.cachedTokens; } } - // Calculate context length from the most recent main chain message - if (mostRecentMainChainEntry?.message?.usage) { - const usage = mostRecentMainChainEntry.message.usage; - contextLength = (usage.input_tokens || 0) - + (usage.cache_read_input_tokens ?? 0) - + (usage.cache_creation_input_tokens ?? 0); - } - const totalTokens = inputTokens + outputTokens + cachedTokens; return { inputTokens, outputTokens, cachedTokens, totalTokens, contextLength }; From c652f42a79ade74d6819f930a989872b1ae71bb2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 12:18:33 +0000 Subject: [PATCH 2/5] feat(widgets): add token-subagents metadata helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit includeSubagents per-widget flag, the Σ marker constant, and a tokenMetricsForWidget selector (main-only vs session-inclusive metrics). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/utils/__tests__/token-subagents.test.ts | 45 +++++++++++++++++++++ src/utils/token-subagents.ts | 37 +++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 src/utils/__tests__/token-subagents.test.ts create mode 100644 src/utils/token-subagents.ts diff --git a/src/utils/__tests__/token-subagents.test.ts b/src/utils/__tests__/token-subagents.test.ts new file mode 100644 index 00000000..ff96d5b8 --- /dev/null +++ b/src/utils/__tests__/token-subagents.test.ts @@ -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): 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('Σ '); + }); +}); diff --git a/src/utils/token-subagents.ts b/src/utils/token-subagents.ts new file mode 100644 index 00000000..9009986e --- /dev/null +++ b/src/utils/token-subagents.ts @@ -0,0 +1,37 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { TokenMetrics } from '../types/TokenMetrics'; +import type { WidgetItem } from '../types/Widget'; + +export const SUBAGENTS_METADATA_KEY = 'includeSubagents'; +export const SUBAGENTS_MARKER = 'Σ '; + +export function isWidgetSubagentsEnabled(item: WidgetItem): boolean { + return item.metadata?.[SUBAGENTS_METADATA_KEY] === 'true'; +} + +export function withWidgetSubagentsEnabled(item: WidgetItem, on: boolean): WidgetItem { + if (on) { + return { + ...item, + metadata: { + ...(item.metadata ?? {}), + [SUBAGENTS_METADATA_KEY]: 'true' + } + }; + } + + const { [SUBAGENTS_METADATA_KEY]: _removed, ...restMetadata } = item.metadata ?? {}; + void _removed; + return { + ...item, + metadata: Object.keys(restMetadata).length > 0 ? restMetadata : undefined + }; +} + +// Selects the subagent-inclusive metrics when the widget opts in, otherwise the +// main-only metrics. Returns null when the needed metrics are unavailable. +export function tokenMetricsForWidget(item: WidgetItem, context: RenderContext): TokenMetrics | null { + return isWidgetSubagentsEnabled(item) + ? (context.sessionTokenMetrics ?? null) + : (context.tokenMetrics ?? null); +} From 0727a671b27b7d5124e83d1b203b7ef713017c02 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 12:18:39 +0000 Subject: [PATCH 3/5] feat(pipeline): compute sessionTokenMetrics on demand Adds RenderContext.sessionTokenMetrics and computes it once (includeSubagents) only when a session-total widget or a subagents-enabled token widget is present. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/ccstatusline.ts | 10 ++++++++++ src/types/RenderContext.ts | 1 + 2 files changed, 11 insertions(+) diff --git a/src/ccstatusline.ts b/src/ccstatusline.ts index a6890a5d..0dcf7ff9 100644 --- a/src/ccstatusline.ts +++ b/src/ccstatusline.ts @@ -41,6 +41,7 @@ import { getWidgetSpeedWindowSeconds, isWidgetSpeedWindowEnabled } from './utils/speed-window'; +import { isWidgetSubagentsEnabled } from './utils/token-subagents'; import { prefetchUsageDataIfNeeded } from './utils/usage-prefetch'; function hasSessionDurationInStatusJson(data: StatusJSON): boolean { @@ -116,9 +117,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; @@ -169,6 +178,7 @@ async function renderMultipleLines(data: StatusJSON) { const context: RenderContext = { data, tokenMetrics, + sessionTokenMetrics, speedMetrics, windowedSpeedMetrics, usageData, diff --git a/src/types/RenderContext.ts b/src/types/RenderContext.ts index 2ca3c276..0558961f 100644 --- a/src/types/RenderContext.ts +++ b/src/types/RenderContext.ts @@ -28,6 +28,7 @@ export interface CompactionData { count: number } export interface RenderContext { data?: StatusJSON; tokenMetrics?: TokenMetrics | null; + sessionTokenMetrics?: TokenMetrics | null; speedMetrics?: SpeedMetrics | null; windowedSpeedMetrics?: Record | null; usageData?: RenderUsageData | null; From 57cbf97b4c9101c64b280e3df7081e280864c079 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 12:18:50 +0000 Subject: [PATCH 4/5] feat(widgets): add (s)ubagents toggle to token widgets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tokens Input/Output/Cached/Total gain a (s)ubagents keybind that includes sub-agent usage, a [+sub] editor modifier, and a Σ render marker. Input/Output bypass the main-only stdin context_window payload when the toggle is on. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/widgets/TokensCached.ts | 32 +++++- src/widgets/TokensInput.ts | 41 +++++-- src/widgets/TokensOutput.ts | 41 +++++-- src/widgets/TokensTotal.ts | 32 +++++- .../__tests__/token-widgets-subagents.test.ts | 100 ++++++++++++++++++ 5 files changed, 224 insertions(+), 22 deletions(-) create mode 100644 src/widgets/__tests__/token-widgets-subagents.test.ts diff --git a/src/widgets/TokensCached.ts b/src/widgets/TokensCached.ts index c1fd8bfb..cd146eb7 100644 --- a/src/widgets/TokensCached.ts +++ b/src/widgets/TokensCached.ts @@ -1,11 +1,18 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { + CustomKeybind, Widget, WidgetEditorDisplay, WidgetItem } from '../types/Widget'; import { formatTokens } from '../utils/renderer'; +import { + SUBAGENTS_MARKER, + isWidgetSubagentsEnabled, + tokenMetricsForWidget, + withWidgetSubagentsEnabled +} from '../utils/token-subagents'; import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; @@ -15,20 +22,37 @@ export class TokensCachedWidget implements Widget { getDisplayName(): string { return 'Tokens Cached'; } getCategory(): string { return 'Tokens'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - return { displayText: this.getDisplayName() }; + return isWidgetSubagentsEnabled(item) + ? { displayText: this.getDisplayName(), modifierText: '[+sub]' } + : { displayText: this.getDisplayName() }; } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + const subagents = isWidgetSubagentsEnabled(item); + const label = subagents ? `${SUBAGENTS_MARKER}Cached: ` : 'Cached: '; + if (context.isPreview) { - return formatRawOrLabeledValue(item, 'Cached: ', '12k'); + return formatRawOrLabeledValue(item, label, '12k'); } - if (context.tokenMetrics) { - return formatRawOrLabeledValue(item, 'Cached: ', formatTokens(context.tokenMetrics.cachedTokens)); + const metrics = tokenMetricsForWidget(item, context); + if (metrics) { + return formatRawOrLabeledValue(item, label, formatTokens(metrics.cachedTokens)); } return null; } + getCustomKeybinds(): CustomKeybind[] { + return [{ key: 's', label: '(s)ubagents', action: 'toggle-subagents' }]; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + if (action !== 'toggle-subagents') { + return null; + } + return withWidgetSubagentsEnabled(item, !isWidgetSubagentsEnabled(item)); + } + supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } } diff --git a/src/widgets/TokensInput.ts b/src/widgets/TokensInput.ts index 15815e09..2eff067f 100644 --- a/src/widgets/TokensInput.ts +++ b/src/widgets/TokensInput.ts @@ -1,12 +1,19 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { + CustomKeybind, Widget, WidgetEditorDisplay, WidgetItem } from '../types/Widget'; import { getContextWindowInputTotalTokens } from '../utils/context-window'; import { formatTokens } from '../utils/renderer'; +import { + SUBAGENTS_MARKER, + isWidgetSubagentsEnabled, + tokenMetricsForWidget, + withWidgetSubagentsEnabled +} from '../utils/token-subagents'; import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; @@ -16,25 +23,45 @@ export class TokensInputWidget implements Widget { getDisplayName(): string { return 'Tokens Input'; } getCategory(): string { return 'Tokens'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - return { displayText: this.getDisplayName() }; + return isWidgetSubagentsEnabled(item) + ? { displayText: this.getDisplayName(), modifierText: '[+sub]' } + : { displayText: this.getDisplayName() }; } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + const subagents = isWidgetSubagentsEnabled(item); + const label = subagents ? `${SUBAGENTS_MARKER}In: ` : 'In: '; + if (context.isPreview) { - return formatRawOrLabeledValue(item, 'In: ', '15.2k'); + return formatRawOrLabeledValue(item, label, '15.2k'); } - const inputTotalTokens = getContextWindowInputTotalTokens(context.data); - if (inputTotalTokens !== null) { - return formatRawOrLabeledValue(item, 'In: ', formatTokens(inputTotalTokens)); + // The stdin context_window payload is main-only; skip it when subagents are on. + if (!subagents) { + const inputTotalTokens = getContextWindowInputTotalTokens(context.data); + if (inputTotalTokens !== null) { + return formatRawOrLabeledValue(item, label, formatTokens(inputTotalTokens)); + } } - if (context.tokenMetrics) { - return formatRawOrLabeledValue(item, 'In: ', formatTokens(context.tokenMetrics.inputTokens)); + const metrics = tokenMetricsForWidget(item, context); + if (metrics) { + return formatRawOrLabeledValue(item, label, formatTokens(metrics.inputTokens)); } return null; } + getCustomKeybinds(): CustomKeybind[] { + return [{ key: 's', label: '(s)ubagents', action: 'toggle-subagents' }]; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + if (action !== 'toggle-subagents') { + return null; + } + return withWidgetSubagentsEnabled(item, !isWidgetSubagentsEnabled(item)); + } + supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } } diff --git a/src/widgets/TokensOutput.ts b/src/widgets/TokensOutput.ts index 19c70854..6a92cf05 100644 --- a/src/widgets/TokensOutput.ts +++ b/src/widgets/TokensOutput.ts @@ -1,12 +1,19 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { + CustomKeybind, Widget, WidgetEditorDisplay, WidgetItem } from '../types/Widget'; import { getContextWindowOutputTotalTokens } from '../utils/context-window'; import { formatTokens } from '../utils/renderer'; +import { + SUBAGENTS_MARKER, + isWidgetSubagentsEnabled, + tokenMetricsForWidget, + withWidgetSubagentsEnabled +} from '../utils/token-subagents'; import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; @@ -16,25 +23,45 @@ export class TokensOutputWidget implements Widget { getDisplayName(): string { return 'Tokens Output'; } getCategory(): string { return 'Tokens'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - return { displayText: this.getDisplayName() }; + return isWidgetSubagentsEnabled(item) + ? { displayText: this.getDisplayName(), modifierText: '[+sub]' } + : { displayText: this.getDisplayName() }; } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + const subagents = isWidgetSubagentsEnabled(item); + const label = subagents ? `${SUBAGENTS_MARKER}Out: ` : 'Out: '; + if (context.isPreview) { - return formatRawOrLabeledValue(item, 'Out: ', '3.4k'); + return formatRawOrLabeledValue(item, label, '3.4k'); } - const outputTotalTokens = getContextWindowOutputTotalTokens(context.data); - if (outputTotalTokens !== null) { - return formatRawOrLabeledValue(item, 'Out: ', formatTokens(outputTotalTokens)); + // The stdin context_window payload is main-only; skip it when subagents are on. + if (!subagents) { + const outputTotalTokens = getContextWindowOutputTotalTokens(context.data); + if (outputTotalTokens !== null) { + return formatRawOrLabeledValue(item, label, formatTokens(outputTotalTokens)); + } } - if (context.tokenMetrics) { - return formatRawOrLabeledValue(item, 'Out: ', formatTokens(context.tokenMetrics.outputTokens)); + const metrics = tokenMetricsForWidget(item, context); + if (metrics) { + return formatRawOrLabeledValue(item, label, formatTokens(metrics.outputTokens)); } return null; } + getCustomKeybinds(): CustomKeybind[] { + return [{ key: 's', label: '(s)ubagents', action: 'toggle-subagents' }]; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + if (action !== 'toggle-subagents') { + return null; + } + return withWidgetSubagentsEnabled(item, !isWidgetSubagentsEnabled(item)); + } + supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } } diff --git a/src/widgets/TokensTotal.ts b/src/widgets/TokensTotal.ts index 5f6e57bd..e781f1ba 100644 --- a/src/widgets/TokensTotal.ts +++ b/src/widgets/TokensTotal.ts @@ -1,11 +1,18 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { + CustomKeybind, Widget, WidgetEditorDisplay, WidgetItem } from '../types/Widget'; import { formatTokens } from '../utils/renderer'; +import { + SUBAGENTS_MARKER, + isWidgetSubagentsEnabled, + tokenMetricsForWidget, + withWidgetSubagentsEnabled +} from '../utils/token-subagents'; import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; @@ -15,20 +22,37 @@ export class TokensTotalWidget implements Widget { getDisplayName(): string { return 'Tokens Total'; } getCategory(): string { return 'Tokens'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - return { displayText: this.getDisplayName() }; + return isWidgetSubagentsEnabled(item) + ? { displayText: this.getDisplayName(), modifierText: '[+sub]' } + : { displayText: this.getDisplayName() }; } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + const subagents = isWidgetSubagentsEnabled(item); + const label = subagents ? `${SUBAGENTS_MARKER}Total: ` : 'Total: '; + if (context.isPreview) { - return formatRawOrLabeledValue(item, 'Total: ', '30.6k'); + return formatRawOrLabeledValue(item, label, '30.6k'); } - if (context.tokenMetrics) { - return formatRawOrLabeledValue(item, 'Total: ', formatTokens(context.tokenMetrics.totalTokens)); + const metrics = tokenMetricsForWidget(item, context); + if (metrics) { + return formatRawOrLabeledValue(item, label, formatTokens(metrics.totalTokens)); } return null; } + getCustomKeybinds(): CustomKeybind[] { + return [{ key: 's', label: '(s)ubagents', action: 'toggle-subagents' }]; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + if (action !== 'toggle-subagents') { + return null; + } + return withWidgetSubagentsEnabled(item, !isWidgetSubagentsEnabled(item)); + } + supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } } diff --git a/src/widgets/__tests__/token-widgets-subagents.test.ts b/src/widgets/__tests__/token-widgets-subagents.test.ts new file mode 100644 index 00000000..743cc0de --- /dev/null +++ b/src/widgets/__tests__/token-widgets-subagents.test.ts @@ -0,0 +1,100 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import type { RenderContext } from '../../types/RenderContext'; +import type { Settings } from '../../types/Settings'; +import type { WidgetItem } from '../../types/Widget'; +// Statically load renderer first so the eager widget registry initializes before +// any widget module is dynamically imported (avoids a circular-import init order). +import '../../utils/renderer'; + +async function loadWidgets() { + const [{ TokensInputWidget }, { TokensTotalWidget }] = await Promise.all([ + import('../TokensInput'), + import('../TokensTotal') + ]); + return { TokensInputWidget, TokensTotalWidget }; +} + +const settings = {} as Settings; + +function item(metadata?: Record, rawValue?: boolean): WidgetItem { + return { id: '1', type: 'tokens-input', metadata, rawValue }; +} + +const baseMetrics = { + inputTokens: 90000, outputTokens: 40000, cachedTokens: 22000, + totalTokens: 152000, contextLength: 50000 +}; +const sessionMetrics = { + inputTokens: 150000, outputTokens: 60000, cachedTokens: 30000, + totalTokens: 240000, contextLength: 50000 +}; + +function ctx(over: Partial = {}): RenderContext { + return { + tokenMetrics: baseMetrics, + sessionTokenMetrics: sessionMetrics, + ...over + }; +} + +describe('token widgets — subagents toggle', () => { + it('TokensInput uses main metrics and no marker when disabled', async () => { + const { TokensInputWidget } = await loadWidgets(); + const w = new TokensInputWidget(); + expect(w.render(item(), ctx(), settings)).toBe('In: 90.0k'); + }); + + it('TokensInput uses session metrics and Σ marker when enabled', async () => { + const { TokensInputWidget } = await loadWidgets(); + const w = new TokensInputWidget(); + const out = w.render(item({ includeSubagents: 'true' }), ctx(), settings); + expect(out).toBe('Σ In: 150.0k'); + }); + + it('TokensInput omits the marker in raw mode but still uses session metrics', async () => { + const { TokensInputWidget } = await loadWidgets(); + const w = new TokensInputWidget(); + const out = w.render(item({ includeSubagents: 'true' }, true), ctx(), settings); + expect(out).toBe('150.0k'); + }); + + it('TokensInput ignores the stdin context_window total when subagents are on', async () => { + const { TokensInputWidget } = await loadWidgets(); + const w = new TokensInputWidget(); + const out = w.render( + item({ includeSubagents: 'true' }), + ctx({ data: { context_window: { total_input_tokens: 12345 } } }), + settings + ); + // Must come from sessionTokenMetrics (150.0k), not the 12345 payload value. + expect(out).toBe('Σ In: 150.0k'); + }); + + it('TokensTotal switches to session total with marker when enabled', async () => { + const { TokensTotalWidget } = await loadWidgets(); + const w = new TokensTotalWidget(); + expect(w.render(item(), ctx(), settings)).toBe('Total: 152.0k'); + expect(w.render(item({ includeSubagents: 'true' }), ctx(), settings)).toBe('Σ Total: 240.0k'); + }); + + it('toggles the flag through handleEditorAction', async () => { + const { TokensTotalWidget } = await loadWidgets(); + const w = new TokensTotalWidget(); + const on = w.handleEditorAction('toggle-subagents', item()); + expect(on?.metadata?.includeSubagents).toBe('true'); + const off = w.handleEditorAction('toggle-subagents', on ?? item()); + expect(off?.metadata?.includeSubagents).toBeUndefined(); + }); + + it('reports the [+sub] modifier in the editor display when enabled', async () => { + const { TokensTotalWidget } = await loadWidgets(); + const w = new TokensTotalWidget(); + expect(w.getEditorDisplay(item()).modifierText).toBeUndefined(); + expect(w.getEditorDisplay(item({ includeSubagents: 'true' })).modifierText).toBe('[+sub]'); + }); +}); From ac0a57330d2e50a70a1d76c365dd0b12c648f520 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 12:19:01 +0000 Subject: [PATCH 5/5] feat(widgets): add Session Total Tokens widget Dedicated tokens-session-total widget: session grand total including sub-agents, with an optional (b)reakdown of input/output/cache. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/utils/widget-manifest.ts | 1 + src/widgets/SessionTotalTokens.ts | 84 +++++++++++++++++++ .../__tests__/session-total-tokens.test.ts | 74 ++++++++++++++++ src/widgets/index.ts | 1 + 4 files changed, 160 insertions(+) create mode 100644 src/widgets/SessionTotalTokens.ts create mode 100644 src/widgets/__tests__/session-total-tokens.test.ts diff --git a/src/utils/widget-manifest.ts b/src/utils/widget-manifest.ts index 6800b52e..3c52108d 100644 --- a/src/utils/widget-manifest.ts +++ b/src/utils/widget-manifest.ts @@ -57,6 +57,7 @@ export const WIDGET_MANIFEST: WidgetManifestEntry[] = [ { type: 'tokens-output', create: () => new widgets.TokensOutputWidget() }, { type: 'tokens-cached', create: () => new widgets.TokensCachedWidget() }, { type: 'tokens-total', create: () => new widgets.TokensTotalWidget() }, + { type: 'tokens-session-total', create: () => new widgets.SessionTotalTokensWidget() }, { type: 'input-speed', create: () => new widgets.InputSpeedWidget() }, { type: 'output-speed', create: () => new widgets.OutputSpeedWidget() }, { type: 'total-speed', create: () => new widgets.TotalSpeedWidget() }, diff --git a/src/widgets/SessionTotalTokens.ts b/src/widgets/SessionTotalTokens.ts new file mode 100644 index 00000000..fb20785f --- /dev/null +++ b/src/widgets/SessionTotalTokens.ts @@ -0,0 +1,84 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { formatTokens } from '../utils/renderer'; +import { SUBAGENTS_MARKER } from '../utils/token-subagents'; + +import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; + +const BREAKDOWN_METADATA_KEY = 'breakdown'; + +function isBreakdownEnabled(item: WidgetItem): boolean { + return item.metadata?.[BREAKDOWN_METADATA_KEY] === 'true'; +} + +export class SessionTotalTokensWidget implements Widget { + getDefaultColor(): string { return 'cyan'; } + getDescription(): string { return 'Shows total session tokens (input + output + cache) including sub-agents'; } + getDisplayName(): string { return 'Session Total Tokens'; } + getCategory(): string { return 'Tokens'; } + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + return isBreakdownEnabled(item) + ? { displayText: this.getDisplayName(), modifierText: '[breakdown]' } + : { displayText: this.getDisplayName() }; + } + + render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + if (context.isPreview) { + const preview = formatRawOrLabeledValue(item, `${SUBAGENTS_MARKER}Total: `, '152k'); + return isBreakdownEnabled(item) && !item.rawValue + ? `${preview} (in 90k/out 40k/cache 22k)` + : preview; + } + + const metrics = context.sessionTokenMetrics; + if (!metrics) { + return null; + } + + const base = formatRawOrLabeledValue(item, `${SUBAGENTS_MARKER}Total: `, formatTokens(metrics.totalTokens)); + if (isBreakdownEnabled(item) && !item.rawValue) { + return `${base} (in ${formatTokens(metrics.inputTokens)}/out ${formatTokens(metrics.outputTokens)}/cache ${formatTokens(metrics.cachedTokens)})`; + } + return base; + } + + getCustomKeybinds(): CustomKeybind[] { + return [{ key: 'b', label: '(b)reakdown', action: 'toggle-breakdown' }]; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + if (action !== 'toggle-breakdown') { + return null; + } + + if (isBreakdownEnabled(item)) { + const { [BREAKDOWN_METADATA_KEY]: _removed, ...restMetadata } = item.metadata ?? {}; + void _removed; + return { + ...item, + metadata: Object.keys(restMetadata).length > 0 ? restMetadata : undefined + }; + } + + return { + ...item, + metadata: { + ...(item.metadata ?? {}), + [BREAKDOWN_METADATA_KEY]: 'true' + } + }; + } + + getNumericValue(context: RenderContext): number | null { + return context.sessionTokenMetrics?.totalTokens ?? null; + } + + supportsRawValue(): boolean { return true; } + supportsColors(item: WidgetItem): boolean { return true; } +} diff --git a/src/widgets/__tests__/session-total-tokens.test.ts b/src/widgets/__tests__/session-total-tokens.test.ts new file mode 100644 index 00000000..05548b2d --- /dev/null +++ b/src/widgets/__tests__/session-total-tokens.test.ts @@ -0,0 +1,74 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import type { RenderContext } from '../../types/RenderContext'; +import type { Settings } from '../../types/Settings'; +import type { WidgetItem } from '../../types/Widget'; +// Statically load renderer first so the eager widget registry initializes before +// the widget module is dynamically imported (avoids a circular-import init order). +import '../../utils/renderer'; + +async function loadWidget() { + const { SessionTotalTokensWidget } = await import('../SessionTotalTokens'); + return SessionTotalTokensWidget; +} + +const settings = {} as Settings; +const sessionMetrics = { + inputTokens: 90000, outputTokens: 40000, cachedTokens: 22000, + totalTokens: 152000, contextLength: 50000 +}; + +function item(metadata?: Record, rawValue?: boolean): WidgetItem { + return { id: '1', type: 'tokens-session-total', metadata, rawValue }; +} + +function ctx(over: Partial = {}): RenderContext { + return { sessionTokenMetrics: sessionMetrics, ...over }; +} + +describe('SessionTotalTokens widget', () => { + it('renders the session grand total with the Σ marker', async () => { + const SessionTotalTokensWidget = await loadWidget(); + const w = new SessionTotalTokensWidget(); + expect(w.render(item(), ctx(), settings)).toBe('Σ Total: 152.0k'); + }); + + it('renders raw value without marker or label', async () => { + const SessionTotalTokensWidget = await loadWidget(); + const w = new SessionTotalTokensWidget(); + expect(w.render(item(undefined, true), ctx(), settings)).toBe('152.0k'); + }); + + it('appends a breakdown when enabled', async () => { + const SessionTotalTokensWidget = await loadWidget(); + const w = new SessionTotalTokensWidget(); + const out = w.render(item({ breakdown: 'true' }), ctx(), settings); + expect(out).toBe('Σ Total: 152.0k (in 90.0k/out 40.0k/cache 22.0k)'); + }); + + it('returns null when session metrics are unavailable', async () => { + const SessionTotalTokensWidget = await loadWidget(); + const w = new SessionTotalTokensWidget(); + expect(w.render(item(), ctx({ sessionTokenMetrics: null }), settings)).toBeNull(); + }); + + it('toggles the breakdown flag through handleEditorAction', async () => { + const SessionTotalTokensWidget = await loadWidget(); + const w = new SessionTotalTokensWidget(); + const on = w.handleEditorAction('toggle-breakdown', item()); + expect(on?.metadata?.breakdown).toBe('true'); + const off = w.handleEditorAction('toggle-breakdown', on ?? item()); + expect(off?.metadata?.breakdown).toBeUndefined(); + }); + + it('exposes the total as its numeric value', async () => { + const SessionTotalTokensWidget = await loadWidget(); + const w = new SessionTotalTokensWidget(); + expect(w.getNumericValue(ctx())).toBe(152000); + expect(w.getNumericValue(ctx({ sessionTokenMetrics: null }))).toBeNull(); + }); +}); diff --git a/src/widgets/index.ts b/src/widgets/index.ts index 047369e1..c4d6debb 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -29,6 +29,7 @@ export { TokensInputWidget } from './TokensInput'; export { TokensOutputWidget } from './TokensOutput'; export { TokensCachedWidget } from './TokensCached'; export { TokensTotalWidget } from './TokensTotal'; +export { SessionTotalTokensWidget } from './SessionTotalTokens'; export { ContextLengthWidget } from './ContextLength'; export { ContextWindowWidget } from './ContextWindow'; export { ContextPercentageWidget } from './ContextPercentage';