From 534a95997757bafd61f9837c739c6417ba783601 Mon Sep 17 00:00:00 2001 From: "dominik.oh" Date: Mon, 19 Jan 2026 02:05:04 +0900 Subject: [PATCH 1/5] Add token speed widgets (InputSpeed, OutputSpeed, TotalSpeed) - Add SpeedMetrics type for tracking token throughput - Implement speed calculation utilities in speed-metrics.ts - Add InputSpeed, OutputSpeed, TotalSpeed widgets - Extend jsonl.ts to extract timing data for speed calculation - Inject speedMetrics into RenderContext for widget access - Add unit tests for speed widgets --- src/ccstatusline.ts | 11 ++ src/types/RenderContext.ts | 2 + src/types/SpeedMetrics.ts | 20 ++++ src/types/TokenMetrics.ts | 1 + src/types/index.ts | 3 +- src/utils/jsonl.ts | 98 ++++++++++++++++ src/utils/speed-metrics.ts | 62 ++++++++++ src/utils/widgets.ts | 3 + src/widgets/InputSpeed.ts | 34 ++++++ src/widgets/OutputSpeed.ts | 34 ++++++ src/widgets/TotalSpeed.ts | 34 ++++++ src/widgets/__tests__/SpeedWidgets.test.ts | 126 +++++++++++++++++++++ src/widgets/index.ts | 5 +- 13 files changed, 431 insertions(+), 2 deletions(-) create mode 100644 src/types/SpeedMetrics.ts create mode 100644 src/utils/speed-metrics.ts create mode 100644 src/widgets/InputSpeed.ts create mode 100644 src/widgets/OutputSpeed.ts create mode 100644 src/widgets/TotalSpeed.ts create mode 100644 src/widgets/__tests__/SpeedWidgets.test.ts diff --git a/src/ccstatusline.ts b/src/ccstatusline.ts index 0bc999d..e102389 100644 --- a/src/ccstatusline.ts +++ b/src/ccstatusline.ts @@ -4,6 +4,7 @@ import chalk from 'chalk'; import { runTUI } from './tui'; import type { BlockMetrics, + SpeedMetrics, TokenMetrics } from './types'; import type { RenderContext } from './types/RenderContext'; @@ -17,6 +18,7 @@ import { import { getBlockMetrics, getSessionDuration, + getSpeedMetrics, getTokenMetrics } from './utils/jsonl'; import { @@ -75,6 +77,9 @@ async function renderMultipleLines(data: StatusJSON) { // Check if block timer is needed const hasBlockTimer = lines.some(line => line.some(item => item.type === 'block-timer')); + // Check if speed metrics widgets are needed + const hasSpeedItems = lines.some(line => line.some(item => ['output-speed', 'input-speed', 'total-speed'].includes(item.type))); + let tokenMetrics: TokenMetrics | null = null; if (hasTokenItems && data.transcript_path) { tokenMetrics = await getTokenMetrics(data.transcript_path); @@ -90,10 +95,16 @@ async function renderMultipleLines(data: StatusJSON) { blockMetrics = getBlockMetrics(); } + let speedMetrics: SpeedMetrics | null = null; + if (hasSpeedItems && data.transcript_path) { + speedMetrics = await getSpeedMetrics(data.transcript_path); + } + // Create render context const context: RenderContext = { data, tokenMetrics, + speedMetrics, sessionDuration, blockMetrics, isPreview: false diff --git a/src/types/RenderContext.ts b/src/types/RenderContext.ts index 9e088c9..5911b6c 100644 --- a/src/types/RenderContext.ts +++ b/src/types/RenderContext.ts @@ -1,11 +1,13 @@ import type { BlockMetrics } from '../types'; +import type { SpeedMetrics } from './SpeedMetrics'; import type { StatusJSON } from './StatusJSON'; import type { TokenMetrics } from './TokenMetrics'; export interface RenderContext { data?: StatusJSON; tokenMetrics?: TokenMetrics | null; + speedMetrics?: SpeedMetrics | null; sessionDuration?: string | null; blockMetrics?: BlockMetrics | null; terminalWidth?: number | null; diff --git a/src/types/SpeedMetrics.ts b/src/types/SpeedMetrics.ts new file mode 100644 index 0000000..525a2fc --- /dev/null +++ b/src/types/SpeedMetrics.ts @@ -0,0 +1,20 @@ +/** + * Speed metrics for calculating token processing rates. + * Provides time-based data needed for speed calculations. + */ +export interface SpeedMetrics { + /** Active processing duration in milliseconds (sum of user request → assistant response times) */ + totalDurationMs: number; + + /** Total input tokens across all requests */ + inputTokens: number; + + /** Total output tokens across all requests */ + outputTokens: number; + + /** Total tokens (input + output) */ + totalTokens: number; + + /** Number of API requests (entries with message.usage, excluding sidechains) */ + requestCount: number; +} \ No newline at end of file diff --git a/src/types/TokenMetrics.ts b/src/types/TokenMetrics.ts index ec3c866..cb63cac 100644 --- a/src/types/TokenMetrics.ts +++ b/src/types/TokenMetrics.ts @@ -10,6 +10,7 @@ export interface TranscriptLine { isSidechain?: boolean; timestamp?: string; isApiErrorMessage?: boolean; + type?: 'user' | 'assistant' | 'system' | 'progress' | 'file-history-snapshot'; } export interface TokenMetrics { diff --git a/src/types/index.ts b/src/types/index.ts index 916ded0..164b3c7 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -17,4 +17,5 @@ export type { RenderContext } from './RenderContext'; export type { PowerlineFontStatus } from './PowerlineFontStatus'; export type { ClaudeSettings } from './ClaudeSettings'; export type { ColorEntry } from './ColorEntry'; -export type { BlockMetrics } from './BlockMetrics'; \ No newline at end of file +export type { BlockMetrics } from './BlockMetrics'; +export type { SpeedMetrics } from './SpeedMetrics'; \ No newline at end of file diff --git a/src/utils/jsonl.ts b/src/utils/jsonl.ts index d00923c..492f646 100644 --- a/src/utils/jsonl.ts +++ b/src/utils/jsonl.ts @@ -5,6 +5,7 @@ import { promisify } from 'util'; import type { BlockMetrics, + SpeedMetrics, TokenMetrics, TranscriptLine } from '../types'; @@ -146,6 +147,103 @@ export async function getTokenMetrics(transcriptPath: string): Promise { + const emptyMetrics: SpeedMetrics = { + totalDurationMs: 0, + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + requestCount: 0 + }; + + try { + if (!fs.existsSync(transcriptPath)) { + return emptyMetrics; + } + + const content = await readFile(transcriptPath, 'utf-8'); + const lines = content.trim().split('\n'); + + let inputTokens = 0; + let outputTokens = 0; + let requestCount = 0; + let activeDurationMs = 0; + + // Track timestamps for calculating processing time + let lastUserTimestamp: Date | null = null; + let lastAssistantTimestamp: Date | null = null; + + for (const line of lines) { + try { + const data = JSON.parse(line) as TranscriptLine; + + // Skip sidechains and API error messages + if (data.isSidechain === true || data.isApiErrorMessage) { + continue; + } + + // Track user message timestamps (request start time) + if (data.type === 'user' && data.timestamp) { + const timestamp = new Date(data.timestamp); + if (!Number.isNaN(timestamp.getTime())) { + // When a new user message comes after assistant responses, + // finalize the previous processing time + if (lastUserTimestamp && lastAssistantTimestamp) { + const processingTime = lastAssistantTimestamp.getTime() - lastUserTimestamp.getTime(); + if (processingTime > 0) { + activeDurationMs += processingTime; + } + } + lastUserTimestamp = timestamp; + lastAssistantTimestamp = null; + } + } + + // Track assistant responses with usage + if (data.type === 'assistant' && data.message?.usage && data.timestamp) { + const responseTimestamp = new Date(data.timestamp); + if (!Number.isNaN(responseTimestamp.getTime())) { + // Update the last assistant timestamp (we want the final one in a sequence) + lastAssistantTimestamp = responseTimestamp; + } + + inputTokens += data.message.usage.input_tokens || 0; + outputTokens += data.message.usage.output_tokens || 0; + requestCount++; + } + } catch { + // Skip invalid JSON lines + } + } + + // Finalize the last user-assistant pair + if (lastUserTimestamp && lastAssistantTimestamp) { + const processingTime = lastAssistantTimestamp.getTime() - lastUserTimestamp.getTime(); + if (processingTime > 0) { + activeDurationMs += processingTime; + } + } + + return { + totalDurationMs: activeDurationMs, + inputTokens, + outputTokens, + totalTokens: inputTokens + outputTokens, + requestCount + }; + } catch { + return emptyMetrics; + } +} + /** * Gets block metrics for the current 5-hour block from JSONL files */ diff --git a/src/utils/speed-metrics.ts b/src/utils/speed-metrics.ts new file mode 100644 index 0000000..7f6cc6a --- /dev/null +++ b/src/utils/speed-metrics.ts @@ -0,0 +1,62 @@ +import type { SpeedMetrics } from '../types/SpeedMetrics'; + +/** + * Calculates output tokens per second from speed metrics. + * + * @param metrics SpeedMetrics containing timing and token data + * @returns Output tokens per second, or null if duration is zero + */ +export function calculateOutputSpeed(metrics: SpeedMetrics): number | null { + if (metrics.totalDurationMs === 0) { + return null; + } + const seconds = metrics.totalDurationMs / 1000; + return metrics.outputTokens / seconds; +} + +/** + * Calculates input tokens per second from speed metrics. + * + * @param metrics SpeedMetrics containing timing and token data + * @returns Input tokens per second, or null if duration is zero + */ +export function calculateInputSpeed(metrics: SpeedMetrics): number | null { + if (metrics.totalDurationMs === 0) { + return null; + } + const seconds = metrics.totalDurationMs / 1000; + return metrics.inputTokens / seconds; +} + +/** + * Calculates total tokens per second from speed metrics. + * + * @param metrics SpeedMetrics containing timing and token data + * @returns Total tokens per second, or null if duration is zero + */ +export function calculateTotalSpeed(metrics: SpeedMetrics): number | null { + if (metrics.totalDurationMs === 0) { + return null; + } + const seconds = metrics.totalDurationMs / 1000; + return metrics.totalTokens / seconds; +} + +/** + * Formats a tokens per second value for display. + * + * @param tokensPerSec Tokens per second value, or null if unavailable + * @returns Formatted string (e.g., "42.5 t/s", "1.2k t/s", or "—" for null) + */ +export function formatSpeed(tokensPerSec: number | null): string { + if (tokensPerSec === null) { + return '—'; + } + + if (tokensPerSec >= 1000) { + const kValue = tokensPerSec / 1000; + return `${kValue.toFixed(1)}k t/s`; + } + + return `${tokensPerSec.toFixed(1)} t/s`; +} \ No newline at end of file diff --git a/src/utils/widgets.ts b/src/utils/widgets.ts index 15a2510..103fa07 100644 --- a/src/utils/widgets.ts +++ b/src/utils/widgets.ts @@ -20,6 +20,9 @@ const widgetRegistry = new Map([ ['context-length', new widgets.ContextLengthWidget()], ['context-percentage', new widgets.ContextPercentageWidget()], ['context-percentage-usable', new widgets.ContextPercentageUsableWidget()], + ['output-speed', new widgets.OutputSpeedWidget()], + ['input-speed', new widgets.InputSpeedWidget()], + ['total-speed', new widgets.TotalSpeedWidget()], ['session-clock', new widgets.SessionClockWidget()], ['session-cost', new widgets.SessionCostWidget()], ['block-timer', new widgets.BlockTimerWidget()], diff --git a/src/widgets/InputSpeed.ts b/src/widgets/InputSpeed.ts new file mode 100644 index 0000000..851d02a --- /dev/null +++ b/src/widgets/InputSpeed.ts @@ -0,0 +1,34 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + calculateInputSpeed, + formatSpeed +} from '../utils/speed-metrics'; + +export class InputSpeedWidget implements Widget { + getDefaultColor(): string { return 'cyan'; } + getDescription(): string { return 'Shows input token processing speed (tokens/sec)'; } + getDisplayName(): string { return 'Input Speed'; } + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + return { displayText: this.getDisplayName() }; + } + + render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + if (context.isPreview) { + return item.rawValue ? '85.2 t/s' : 'In: 85.2 t/s'; + } else if (context.speedMetrics) { + const speed = calculateInputSpeed(context.speedMetrics); + const formatted = formatSpeed(speed); + return item.rawValue ? formatted : `In: ${formatted}`; + } + return null; + } + + supportsRawValue(): boolean { return true; } + supportsColors(item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/OutputSpeed.ts b/src/widgets/OutputSpeed.ts new file mode 100644 index 0000000..9274bd4 --- /dev/null +++ b/src/widgets/OutputSpeed.ts @@ -0,0 +1,34 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + calculateOutputSpeed, + formatSpeed +} from '../utils/speed-metrics'; + +export class OutputSpeedWidget implements Widget { + getDefaultColor(): string { return 'cyan'; } + getDescription(): string { return 'Shows output token generation speed (tokens/sec)'; } + getDisplayName(): string { return 'Output Speed'; } + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + return { displayText: this.getDisplayName() }; + } + + render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + if (context.isPreview) { + return item.rawValue ? '42.5 t/s' : 'Out: 42.5 t/s'; + } else if (context.speedMetrics) { + const speed = calculateOutputSpeed(context.speedMetrics); + const formatted = formatSpeed(speed); + return item.rawValue ? formatted : `Out: ${formatted}`; + } + return null; + } + + supportsRawValue(): boolean { return true; } + supportsColors(item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/TotalSpeed.ts b/src/widgets/TotalSpeed.ts new file mode 100644 index 0000000..26439db --- /dev/null +++ b/src/widgets/TotalSpeed.ts @@ -0,0 +1,34 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + calculateTotalSpeed, + formatSpeed +} from '../utils/speed-metrics'; + +export class TotalSpeedWidget implements Widget { + getDefaultColor(): string { return 'cyan'; } + getDescription(): string { return 'Shows total token processing speed (tokens/sec)'; } + getDisplayName(): string { return 'Total Speed'; } + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + return { displayText: this.getDisplayName() }; + } + + render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + if (context.isPreview) { + return item.rawValue ? '127.7 t/s' : 'Total: 127.7 t/s'; + } else if (context.speedMetrics) { + const speed = calculateTotalSpeed(context.speedMetrics); + const formatted = formatSpeed(speed); + return item.rawValue ? formatted : `Total: ${formatted}`; + } + return null; + } + + supportsRawValue(): boolean { return true; } + supportsColors(item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/__tests__/SpeedWidgets.test.ts b/src/widgets/__tests__/SpeedWidgets.test.ts new file mode 100644 index 0000000..9768a94 --- /dev/null +++ b/src/widgets/__tests__/SpeedWidgets.test.ts @@ -0,0 +1,126 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import type { + RenderContext, + SpeedMetrics, + WidgetItem +} from '../../types'; +import { DEFAULT_SETTINGS } from '../../types/Settings'; +import { InputSpeedWidget } from '../InputSpeed'; +import { OutputSpeedWidget } from '../OutputSpeed'; +import { TotalSpeedWidget } from '../TotalSpeed'; + +function createSpeedMetrics(overrides: Partial = {}): SpeedMetrics { + return { + totalDurationMs: 10000, + inputTokens: 1000, + outputTokens: 500, + totalTokens: 1500, + requestCount: 5, + ...overrides + }; +} + +function createItem(type: string, rawValue = false): WidgetItem { + return { + id: type, + type, + rawValue + }; +} + +describe('OutputSpeedWidget', () => { + const widget = new OutputSpeedWidget(); + + it('should return preview value in preview mode', () => { + const context: RenderContext = { isPreview: true }; + expect(widget.render(createItem('output-speed'), context, DEFAULT_SETTINGS)).toBe('Out: 42.5 t/s'); + }); + + it('should return raw preview value when rawValue is true', () => { + const context: RenderContext = { isPreview: true }; + expect(widget.render(createItem('output-speed', true), context, DEFAULT_SETTINGS)).toBe('42.5 t/s'); + }); + + it('should calculate output speed from speedMetrics', () => { + const context: RenderContext = { speedMetrics: createSpeedMetrics({ outputTokens: 500, totalDurationMs: 10000 }) }; + expect(widget.render(createItem('output-speed'), context, DEFAULT_SETTINGS)).toBe('Out: 50.0 t/s'); + }); + + it('should return raw value when rawValue is true', () => { + const context: RenderContext = { speedMetrics: createSpeedMetrics({ outputTokens: 500, totalDurationMs: 10000 }) }; + expect(widget.render(createItem('output-speed', true), context, DEFAULT_SETTINGS)).toBe('50.0 t/s'); + }); + + it('should return null when speedMetrics is undefined', () => { + const context: RenderContext = {}; + expect(widget.render(createItem('output-speed'), context, DEFAULT_SETTINGS)).toBeNull(); + }); + + it('should use k suffix for high speeds', () => { + const context: RenderContext = { speedMetrics: createSpeedMetrics({ outputTokens: 10000, totalDurationMs: 1000 }) }; + expect(widget.render(createItem('output-speed', true), context, DEFAULT_SETTINGS)).toBe('10.0k t/s'); + }); +}); + +describe('InputSpeedWidget', () => { + const widget = new InputSpeedWidget(); + + it('should return preview value in preview mode', () => { + const context: RenderContext = { isPreview: true }; + expect(widget.render(createItem('input-speed'), context, DEFAULT_SETTINGS)).toBe('In: 85.2 t/s'); + }); + + it('should return raw preview value when rawValue is true', () => { + const context: RenderContext = { isPreview: true }; + expect(widget.render(createItem('input-speed', true), context, DEFAULT_SETTINGS)).toBe('85.2 t/s'); + }); + + it('should calculate input speed from speedMetrics', () => { + const context: RenderContext = { speedMetrics: createSpeedMetrics({ inputTokens: 1000, totalDurationMs: 10000 }) }; + expect(widget.render(createItem('input-speed'), context, DEFAULT_SETTINGS)).toBe('In: 100.0 t/s'); + }); + + it('should return raw value when rawValue is true', () => { + const context: RenderContext = { speedMetrics: createSpeedMetrics({ inputTokens: 1000, totalDurationMs: 10000 }) }; + expect(widget.render(createItem('input-speed', true), context, DEFAULT_SETTINGS)).toBe('100.0 t/s'); + }); + + it('should return null when speedMetrics is undefined', () => { + const context: RenderContext = {}; + expect(widget.render(createItem('input-speed'), context, DEFAULT_SETTINGS)).toBeNull(); + }); +}); + +describe('TotalSpeedWidget', () => { + const widget = new TotalSpeedWidget(); + + it('should return preview value in preview mode', () => { + const context: RenderContext = { isPreview: true }; + expect(widget.render(createItem('total-speed'), context, DEFAULT_SETTINGS)).toBe('Total: 127.7 t/s'); + }); + + it('should return raw preview value when rawValue is true', () => { + const context: RenderContext = { isPreview: true }; + expect(widget.render(createItem('total-speed', true), context, DEFAULT_SETTINGS)).toBe('127.7 t/s'); + }); + + it('should calculate total speed from speedMetrics', () => { + const context: RenderContext = { speedMetrics: createSpeedMetrics({ totalTokens: 1500, totalDurationMs: 10000 }) }; + expect(widget.render(createItem('total-speed'), context, DEFAULT_SETTINGS)).toBe('Total: 150.0 t/s'); + }); + + it('should return raw value when rawValue is true', () => { + const context: RenderContext = { speedMetrics: createSpeedMetrics({ totalTokens: 1500, totalDurationMs: 10000 }) }; + expect(widget.render(createItem('total-speed', true), context, DEFAULT_SETTINGS)).toBe('150.0 t/s'); + }); + + it('should return null when speedMetrics is undefined', () => { + const context: RenderContext = {}; + expect(widget.render(createItem('total-speed'), context, DEFAULT_SETTINGS)).toBeNull(); + }); +}); \ No newline at end of file diff --git a/src/widgets/index.ts b/src/widgets/index.ts index faaa705..9262b02 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -18,4 +18,7 @@ export { CustomTextWidget } from './CustomText'; export { CustomCommandWidget } from './CustomCommand'; export { BlockTimerWidget } from './BlockTimer'; export { CurrentWorkingDirWidget } from './CurrentWorkingDir'; -export { ClaudeSessionIdWidget } from './ClaudeSessionId'; \ No newline at end of file +export { ClaudeSessionIdWidget } from './ClaudeSessionId'; +export { InputSpeedWidget } from './InputSpeed'; +export { OutputSpeedWidget } from './OutputSpeed'; +export { TotalSpeedWidget } from './TotalSpeed'; \ No newline at end of file From 9afd7adad544d94d2f40b1874c0278d25998a86b Mon Sep 17 00:00:00 2001 From: Matthew Breedlove Date: Wed, 4 Mar 2026 19:18:11 -0500 Subject: [PATCH 2/5] Add widget-gated subagent speed aggregation with parallel reads --- src/ccstatusline.ts | 2 +- src/types/SpeedMetrics.ts | 2 +- src/utils/__tests__/jsonl-metrics.test.ts | 321 ++++++++++++++++++++++ src/utils/__tests__/speed-metrics.test.ts | 74 +++++ src/utils/jsonl-metrics.ts | 201 +++++++++++--- 5 files changed, 555 insertions(+), 45 deletions(-) create mode 100644 src/utils/__tests__/speed-metrics.test.ts diff --git a/src/ccstatusline.ts b/src/ccstatusline.ts index 5c7973d..7e30687 100644 --- a/src/ccstatusline.ts +++ b/src/ccstatusline.ts @@ -107,7 +107,7 @@ async function renderMultipleLines(data: StatusJSON) { let speedMetrics: SpeedMetrics | null = null; if (hasSpeedItems && data.transcript_path) { - speedMetrics = await getSpeedMetrics(data.transcript_path); + speedMetrics = await getSpeedMetrics(data.transcript_path, { includeSubagents: true }); } // Create render context diff --git a/src/types/SpeedMetrics.ts b/src/types/SpeedMetrics.ts index 525a2fc..b2e242c 100644 --- a/src/types/SpeedMetrics.ts +++ b/src/types/SpeedMetrics.ts @@ -15,6 +15,6 @@ export interface SpeedMetrics { /** Total tokens (input + output) */ totalTokens: number; - /** Number of API requests (entries with message.usage, excluding sidechains) */ + /** Number of assistant usage entries included in speed aggregation */ requestCount: number; } \ No newline at end of file diff --git a/src/utils/__tests__/jsonl-metrics.test.ts b/src/utils/__tests__/jsonl-metrics.test.ts index b30112a..2b40168 100644 --- a/src/utils/__tests__/jsonl-metrics.test.ts +++ b/src/utils/__tests__/jsonl-metrics.test.ts @@ -257,6 +257,327 @@ describe('jsonl transcript metrics', () => { }); }); + it('does not parse subagent transcripts unless includeSubagents is enabled', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-')); + tempRoots.push(root); + const transcriptPath = path.join(root, 'speed-main.jsonl'); + const subagentsDir = path.join(root, 'subagents'); + const subagentTranscriptPath = path.join(subagentsDir, 'agent-1.jsonl'); + + fs.writeFileSync(transcriptPath, [ + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:00.000Z', + type: 'user' + }), + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:04.000Z', + type: 'assistant', + input: 10, + output: 20 + }) + ].join('\n')); + + fs.mkdirSync(subagentsDir, { recursive: true }); + fs.writeFileSync(subagentTranscriptPath, [ + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:01.000Z', + type: 'user', + isSidechain: true + }), + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:11.000Z', + type: 'assistant', + input: 100, + output: 200, + isSidechain: true + }) + ].join('\n')); + + const metrics = await getSpeedMetrics(transcriptPath); + + expect(metrics).toEqual({ + totalDurationMs: 4000, + inputTokens: 10, + outputTokens: 20, + totalTokens: 30, + requestCount: 1 + }); + }); + + it('aggregates subagent speed metrics with merged active windows when enabled', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-')); + tempRoots.push(root); + const transcriptPath = path.join(root, 'speed-main-with-subagents.jsonl'); + const subagentsDir = path.join(root, 'subagents'); + + fs.writeFileSync(transcriptPath, [ + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:00.000Z', + type: 'user' + }), + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:10.000Z', + type: 'assistant', + input: 50, + output: 100 + }) + ].join('\n')); + + fs.mkdirSync(subagentsDir, { recursive: true }); + fs.writeFileSync(path.join(subagentsDir, 'agent-a.jsonl'), [ + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:05.000Z', + type: 'user', + isSidechain: true + }), + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:15.000Z', + type: 'assistant', + input: 150, + output: 300, + isSidechain: true + }) + ].join('\n')); + fs.writeFileSync(path.join(subagentsDir, 'agent-b.jsonl'), [ + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:20.000Z', + type: 'user', + isSidechain: true + }), + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:25.000Z', + type: 'assistant', + input: 25, + output: 50, + isSidechain: true + }) + ].join('\n')); + + const metrics = await getSpeedMetrics(transcriptPath, { includeSubagents: true }); + + expect(metrics).toEqual({ + totalDurationMs: 20000, + inputTokens: 225, + outputTokens: 450, + totalTokens: 675, + requestCount: 3 + }); + }); + + it('falls back to main transcript metrics when subagent folder cannot be listed', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-')); + tempRoots.push(root); + const transcriptPath = path.join(root, 'speed-main-discovery-failure.jsonl'); + const subagentsPath = path.join(root, 'subagents'); + + fs.writeFileSync(transcriptPath, [ + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:00.000Z', + type: 'user' + }), + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:03.000Z', + type: 'assistant', + input: 30, + output: 60 + }) + ].join('\n')); + + // Create a regular file where the subagents directory is expected. + fs.writeFileSync(subagentsPath, 'not-a-directory'); + + const metrics = await getSpeedMetrics(transcriptPath, { includeSubagents: true }); + + expect(metrics).toEqual({ + totalDurationMs: 3000, + inputTokens: 30, + outputTokens: 60, + totalTokens: 90, + requestCount: 1 + }); + }); + + it('ignores malformed subagent lines without failing', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-')); + tempRoots.push(root); + const transcriptPath = path.join(root, 'speed-main-malformed-subagent.jsonl'); + const subagentsDir = path.join(root, 'subagents'); + + fs.writeFileSync(transcriptPath, [ + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:00.000Z', + type: 'user' + }), + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:02.000Z', + type: 'assistant', + input: 10, + output: 20 + }) + ].join('\n')); + + fs.mkdirSync(subagentsDir, { recursive: true }); + fs.writeFileSync(path.join(subagentsDir, 'agent-malformed.jsonl'), [ + 'not-json', + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:03.000Z', + type: 'user', + isSidechain: true + }), + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:07.000Z', + type: 'assistant', + input: 5, + output: 15, + isSidechain: true + }) + ].join('\n')); + + const metrics = await getSpeedMetrics(transcriptPath, { includeSubagents: true }); + + expect(metrics).toEqual({ + totalDurationMs: 6000, + inputTokens: 15, + outputTokens: 35, + totalTokens: 50, + requestCount: 2 + }); + }); + + it('falls back to main transcript metrics when subagents directory is missing', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-')); + tempRoots.push(root); + const transcriptPath = path.join(root, 'speed-main-no-subagents.jsonl'); + + fs.writeFileSync(transcriptPath, [ + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:00.000Z', + type: 'user' + }), + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:03.000Z', + type: 'assistant', + input: 30, + output: 60 + }) + ].join('\n')); + + const metrics = await getSpeedMetrics(transcriptPath, { includeSubagents: true }); + + expect(metrics).toEqual({ + totalDurationMs: 3000, + inputTokens: 30, + outputTokens: 60, + totalTokens: 90, + requestCount: 1 + }); + }); + + it('ignores unreadable subagent transcript files without failing', async () => { + if (process.platform === 'win32') { + expect(true).toBe(true); + return; + } + + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-')); + tempRoots.push(root); + const transcriptPath = path.join(root, 'speed-main-unreadable-subagent.jsonl'); + const subagentsDir = path.join(root, 'subagents'); + const unreadableSubagentPath = path.join(subagentsDir, 'agent-unreadable.jsonl'); + + fs.writeFileSync(transcriptPath, [ + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:00.000Z', + type: 'user' + }), + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:03.000Z', + type: 'assistant', + input: 30, + output: 60 + }) + ].join('\n')); + + fs.mkdirSync(subagentsDir, { recursive: true }); + fs.writeFileSync(unreadableSubagentPath, [ + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:04.000Z', + type: 'user', + isSidechain: true + }), + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:06.000Z', + type: 'assistant', + input: 100, + output: 200, + isSidechain: true + }) + ].join('\n')); + + fs.chmodSync(unreadableSubagentPath, 0o000); + const metrics = await (async () => { + try { + return await getSpeedMetrics(transcriptPath, { includeSubagents: true }); + } finally { + fs.chmodSync(unreadableSubagentPath, 0o600); + } + })(); + + expect(metrics).toEqual({ + totalDurationMs: 3000, + inputTokens: 30, + outputTokens: 60, + totalTokens: 90, + requestCount: 1 + }); + }); + + it('returns empty speed metrics when transcript path points to an unreadable directory', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-')); + tempRoots.push(root); + const transcriptPath = path.join(root, 'not-a-jsonl-file'); + + fs.mkdirSync(transcriptPath); + + const metrics = await getSpeedMetrics(transcriptPath); + + expect(metrics).toEqual({ + totalDurationMs: 0, + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + requestCount: 0 + }); + }); + + it('counts assistant tokens without timestamps while keeping active duration at zero', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-')); + tempRoots.push(root); + const transcriptPath = path.join(root, 'speed-missing-timestamps.jsonl'); + + fs.writeFileSync(transcriptPath, [ + JSON.stringify({ + type: 'assistant', + message: { + usage: { + input_tokens: 7, + output_tokens: 9 + } + } + }) + ].join('\n')); + + const metrics = await getSpeedMetrics(transcriptPath); + + expect(metrics).toEqual({ + totalDurationMs: 0, + inputTokens: 7, + outputTokens: 9, + totalTokens: 16, + requestCount: 1 + }); + }); + it('returns empty speed metrics when transcript is missing', async () => { const metrics = await getSpeedMetrics('/tmp/ccstatusline-jsonl-speed-missing.jsonl'); expect(metrics).toEqual({ diff --git a/src/utils/__tests__/speed-metrics.test.ts b/src/utils/__tests__/speed-metrics.test.ts new file mode 100644 index 0000000..c6cfa63 --- /dev/null +++ b/src/utils/__tests__/speed-metrics.test.ts @@ -0,0 +1,74 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import type { SpeedMetrics } from '../../types/SpeedMetrics'; +import { + calculateInputSpeed, + calculateOutputSpeed, + calculateTotalSpeed, + formatSpeed +} from '../speed-metrics'; + +function createMetrics(overrides: Partial = {}): SpeedMetrics { + return { + totalDurationMs: 10000, + inputTokens: 1000, + outputTokens: 500, + totalTokens: 1500, + requestCount: 5, + ...overrides + }; +} + +describe('speed metrics calculations', () => { + it('calculateOutputSpeed returns null when duration is zero', () => { + const result = calculateOutputSpeed(createMetrics({ totalDurationMs: 0 })); + expect(result).toBeNull(); + }); + + it('calculateOutputSpeed computes output tokens per second', () => { + const result = calculateOutputSpeed(createMetrics({ outputTokens: 750, totalDurationMs: 15000 })); + expect(result).toBe(50); + }); + + it('calculateInputSpeed returns null when duration is zero', () => { + const result = calculateInputSpeed(createMetrics({ totalDurationMs: 0 })); + expect(result).toBeNull(); + }); + + it('calculateInputSpeed computes input tokens per second', () => { + const result = calculateInputSpeed(createMetrics({ inputTokens: 1200, totalDurationMs: 6000 })); + expect(result).toBe(200); + }); + + it('calculateTotalSpeed returns null when duration is zero', () => { + const result = calculateTotalSpeed(createMetrics({ totalDurationMs: 0 })); + expect(result).toBeNull(); + }); + + it('calculateTotalSpeed computes total tokens per second from totalTokens', () => { + const result = calculateTotalSpeed(createMetrics({ totalTokens: 3000, totalDurationMs: 12000 })); + expect(result).toBe(250); + }); +}); + +describe('formatSpeed', () => { + it('formats null as an em dash placeholder', () => { + expect(formatSpeed(null)).toBe('\u2014'); + }); + + it('formats sub-1000 speeds with one decimal place', () => { + expect(formatSpeed(42.54)).toBe('42.5 t/s'); + }); + + it('formats exact threshold values in k notation', () => { + expect(formatSpeed(1000)).toBe('1.0k t/s'); + }); + + it('formats high speeds in k notation with one decimal place', () => { + expect(formatSpeed(1250)).toBe('1.3k t/s'); + }); +}); \ No newline at end of file diff --git a/src/utils/jsonl-metrics.ts b/src/utils/jsonl-metrics.ts index 8bbf3eb..2c15d57 100644 --- a/src/utils/jsonl-metrics.ts +++ b/src/utils/jsonl-metrics.ts @@ -1,4 +1,5 @@ import * as fs from 'fs'; +import path from 'node:path'; import type { SpeedMetrics, @@ -11,6 +12,20 @@ import { readJsonlLines } from './jsonl-lines'; +interface SpeedMetricsOptions { includeSubagents?: boolean } + +interface SpeedInterval { + startMs: number; + endMs: number; +} + +interface CollectedSpeedMetrics { + inputTokens: number; + outputTokens: number; + requestCount: number; + intervals: SpeedInterval[]; +} + export async function getSessionDuration(transcriptPath: string): Promise { try { if (!fs.existsSync(transcriptPath)) { @@ -141,7 +156,122 @@ function parseTimestamp(value: string | undefined): Date | null { return Number.isNaN(timestamp.getTime()) ? null : timestamp; } -export async function getSpeedMetrics(transcriptPath: string): Promise { +function addInterval(intervals: SpeedInterval[], start: Date | null, end: Date | null) { + if (!start || !end) { + return; + } + + const startMs = start.getTime(); + const endMs = end.getTime(); + if (endMs > startMs) { + intervals.push({ startMs, endMs }); + } +} + +function mergeIntervals(intervals: SpeedInterval[]): SpeedInterval[] { + if (intervals.length === 0) { + return []; + } + + const sorted = intervals + .slice() + .sort((a, b) => a.startMs - b.startMs); + const first = sorted[0]; + if (!first) { + return []; + } + const merged: SpeedInterval[] = [{ ...first }]; + + for (let i = 1; i < sorted.length; i++) { + const current = sorted[i]; + const last = merged[merged.length - 1]; + if (!current || !last) { + continue; + } + + if (current.startMs <= last.endMs) { + last.endMs = Math.max(last.endMs, current.endMs); + } else { + merged.push({ ...current }); + } + } + + return merged; +} + +function getIntervalsDurationMs(intervals: SpeedInterval[]): number { + return intervals.reduce((total, interval) => total + (interval.endMs - interval.startMs), 0); +} + +function collectSpeedMetricsFromLines(lines: string[], ignoreSidechain: boolean): CollectedSpeedMetrics { + let inputTokens = 0; + let outputTokens = 0; + let requestCount = 0; + const intervals: SpeedInterval[] = []; + + let lastUserTimestamp: Date | null = null; + let lastAssistantTimestamp: Date | null = null; + + for (const line of lines) { + const data = parseJsonlLine(line) as TranscriptLine | null; + if (!data || data.isApiErrorMessage) { + continue; + } + + if (ignoreSidechain && data.isSidechain === true) { + continue; + } + + const entryTimestamp = parseTimestamp(data.timestamp); + + if (data.type === 'user' && entryTimestamp) { + addInterval(intervals, lastUserTimestamp, lastAssistantTimestamp); + lastUserTimestamp = entryTimestamp; + lastAssistantTimestamp = null; + continue; + } + + if (data.type === 'assistant' && data.message?.usage) { + inputTokens += data.message.usage.input_tokens || 0; + outputTokens += data.message.usage.output_tokens || 0; + requestCount++; + + if (entryTimestamp) { + lastAssistantTimestamp = entryTimestamp; + } + } + } + + addInterval(intervals, lastUserTimestamp, lastAssistantTimestamp); + + return { + inputTokens, + outputTokens, + requestCount, + intervals + }; +} + +function getSubagentTranscriptPaths(transcriptPath: string): string[] { + const subagentsDir = path.join(path.dirname(transcriptPath), 'subagents'); + if (!fs.existsSync(subagentsDir)) { + return []; + } + + try { + const dirEntries = fs.readdirSync(subagentsDir, { withFileTypes: true }); + return dirEntries + .filter(entry => entry.isFile() && entry.name.endsWith('.jsonl')) + .map(entry => path.join(subagentsDir, entry.name)); + } catch { + return []; + } +} + +export async function getSpeedMetrics( + transcriptPath: string, + options: SpeedMetricsOptions = {} +): Promise { const emptyMetrics: SpeedMetrics = { totalDurationMs: 0, inputTokens: 0, @@ -155,55 +285,40 @@ export async function getSpeedMetrics(transcriptPath: string): Promise 0) { - activeDurationMs += processingTime; - } + const mainLines = await readJsonlLines(transcriptPath); + const mainMetrics = collectSpeedMetricsFromLines(mainLines, true); + + let inputTokens = mainMetrics.inputTokens; + let outputTokens = mainMetrics.outputTokens; + let requestCount = mainMetrics.requestCount; + const allIntervals: SpeedInterval[] = [...mainMetrics.intervals]; + + if (options.includeSubagents === true) { + const subagentPaths = getSubagentTranscriptPaths(transcriptPath); + const subagentMetricsResults = await Promise.all(subagentPaths.map(async (subagentPath) => { + try { + const subagentLines = await readJsonlLines(subagentPath); + return collectSpeedMetricsFromLines(subagentLines, false); + } catch { + return null; } + })); - lastUserTimestamp = entryTimestamp; - lastAssistantTimestamp = null; - continue; - } - - if (data.type === 'assistant' && data.message?.usage) { - inputTokens += data.message.usage.input_tokens || 0; - outputTokens += data.message.usage.output_tokens || 0; - requestCount++; - - if (entryTimestamp) { - lastAssistantTimestamp = entryTimestamp; + for (const subagentMetrics of subagentMetricsResults) { + if (!subagentMetrics) { + continue; } - } - } - if (lastUserTimestamp && lastAssistantTimestamp) { - const processingTime = lastAssistantTimestamp.getTime() - lastUserTimestamp.getTime(); - if (processingTime > 0) { - activeDurationMs += processingTime; + inputTokens += subagentMetrics.inputTokens; + outputTokens += subagentMetrics.outputTokens; + requestCount += subagentMetrics.requestCount; + allIntervals.push(...subagentMetrics.intervals); } } + const mergedIntervals = mergeIntervals(allIntervals); + const activeDurationMs = getIntervalsDurationMs(mergedIntervals); + return { totalDurationMs: activeDurationMs, inputTokens, From 2ea5971a26e7d062f61760e61996f46abe0fdf60 Mon Sep 17 00:00:00 2001 From: Matthew Breedlove Date: Wed, 4 Mar 2026 19:32:09 -0500 Subject: [PATCH 3/5] Fix subagent speed parsing for session-directory transcript layout --- src/utils/__tests__/jsonl-metrics.test.ts | 139 ++++++++++++++++++++++ src/utils/jsonl-metrics.ts | 95 +++++++++++++-- 2 files changed, 223 insertions(+), 11 deletions(-) diff --git a/src/utils/__tests__/jsonl-metrics.test.ts b/src/utils/__tests__/jsonl-metrics.test.ts index 2b40168..4cfe57e 100644 --- a/src/utils/__tests__/jsonl-metrics.test.ts +++ b/src/utils/__tests__/jsonl-metrics.test.ts @@ -274,6 +274,10 @@ describe('jsonl transcript metrics', () => { type: 'assistant', input: 10, output: 20 + }), + JSON.stringify({ + type: 'progress', + data: { agentId: '1' } }) ].join('\n')); @@ -320,6 +324,14 @@ describe('jsonl transcript metrics', () => { type: 'assistant', input: 50, output: 100 + }), + JSON.stringify({ + type: 'progress', + data: { agentId: 'a' } + }), + JSON.stringify({ + type: 'progress', + data: { agentId: 'b' } }) ].join('\n')); @@ -364,6 +376,121 @@ describe('jsonl transcript metrics', () => { }); }); + it('includes only referenced subagent transcripts from the parent transcript', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-')); + tempRoots.push(root); + const transcriptPath = path.join(root, 'speed-main-referenced-subagents.jsonl'); + const subagentsDir = path.join(root, 'subagents'); + + fs.writeFileSync(transcriptPath, [ + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:00.000Z', + type: 'user' + }), + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:05.000Z', + type: 'assistant', + input: 20, + output: 30 + }), + JSON.stringify({ + type: 'progress', + data: { agentId: 'referenced-agent' } + }) + ].join('\n')); + + fs.mkdirSync(subagentsDir, { recursive: true }); + fs.writeFileSync(path.join(subagentsDir, 'agent-referenced-agent.jsonl'), [ + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:06.000Z', + type: 'user', + isSidechain: true + }), + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:08.000Z', + type: 'assistant', + input: 10, + output: 20, + isSidechain: true + }) + ].join('\n')); + fs.writeFileSync(path.join(subagentsDir, 'agent-unrelated-agent.jsonl'), [ + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:06.000Z', + type: 'user', + isSidechain: true + }), + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:18.000Z', + type: 'assistant', + input: 500, + output: 900, + isSidechain: true + }) + ].join('\n')); + + const metrics = await getSpeedMetrics(transcriptPath, { includeSubagents: true }); + + expect(metrics).toEqual({ + totalDurationMs: 7000, + inputTokens: 30, + outputTokens: 50, + totalTokens: 80, + requestCount: 2 + }); + }); + + it('finds subagents in session-directory layout used by Claude transcripts', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-')); + tempRoots.push(root); + const sessionId = 'session-123'; + const transcriptPath = path.join(root, `${sessionId}.jsonl`); + const subagentsDir = path.join(root, sessionId, 'subagents'); + + fs.writeFileSync(transcriptPath, [ + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:00.000Z', + type: 'user' + }), + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:04.000Z', + type: 'assistant', + input: 10, + output: 20 + }), + JSON.stringify({ + type: 'progress', + data: { agentId: 'layout-agent' } + }) + ].join('\n')); + + fs.mkdirSync(subagentsDir, { recursive: true }); + fs.writeFileSync(path.join(subagentsDir, 'agent-layout-agent.jsonl'), [ + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:05.000Z', + type: 'user', + isSidechain: true + }), + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:08.000Z', + type: 'assistant', + input: 15, + output: 25, + isSidechain: true + }) + ].join('\n')); + + const metrics = await getSpeedMetrics(transcriptPath, { includeSubagents: true }); + + expect(metrics).toEqual({ + totalDurationMs: 7000, + inputTokens: 25, + outputTokens: 45, + totalTokens: 70, + requestCount: 2 + }); + }); + it('falls back to main transcript metrics when subagent folder cannot be listed', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-')); tempRoots.push(root); @@ -380,6 +507,10 @@ describe('jsonl transcript metrics', () => { type: 'assistant', input: 30, output: 60 + }), + JSON.stringify({ + type: 'progress', + data: { agentId: 'unreadable' } }) ].join('\n')); @@ -413,6 +544,10 @@ describe('jsonl transcript metrics', () => { type: 'assistant', input: 10, output: 20 + }), + JSON.stringify({ + type: 'progress', + data: { agentId: 'malformed' } }) ].join('\n')); @@ -459,6 +594,10 @@ describe('jsonl transcript metrics', () => { type: 'assistant', input: 30, output: 60 + }), + JSON.stringify({ + type: 'progress', + data: { agentId: 'unreadable' } }) ].join('\n')); diff --git a/src/utils/jsonl-metrics.ts b/src/utils/jsonl-metrics.ts index 2c15d57..c100a51 100644 --- a/src/utils/jsonl-metrics.ts +++ b/src/utils/jsonl-metrics.ts @@ -26,6 +26,43 @@ interface CollectedSpeedMetrics { intervals: SpeedInterval[]; } +function collectAgentIds(value: unknown, agentIds: Set) { + if (!value || typeof value !== 'object') { + return; + } + + if (Array.isArray(value)) { + for (const item of value) { + collectAgentIds(item, agentIds); + } + return; + } + + for (const [key, nestedValue] of Object.entries(value)) { + if (key === 'agentId' && typeof nestedValue === 'string' && nestedValue.trim() !== '') { + agentIds.add(nestedValue); + continue; + } + + collectAgentIds(nestedValue, agentIds); + } +} + +function getReferencedSubagentIds(lines: string[]): Set { + const agentIds = new Set(); + + for (const line of lines) { + const data = parseJsonlLine(line); + if (!data) { + continue; + } + + collectAgentIds(data, agentIds); + } + + return agentIds; +} + export async function getSessionDuration(transcriptPath: string): Promise { try { if (!fs.existsSync(transcriptPath)) { @@ -252,20 +289,55 @@ function collectSpeedMetricsFromLines(lines: string[], ignoreSidechain: boolean) }; } -function getSubagentTranscriptPaths(transcriptPath: string): string[] { - const subagentsDir = path.join(path.dirname(transcriptPath), 'subagents'); - if (!fs.existsSync(subagentsDir)) { +function getSubagentTranscriptPaths(transcriptPath: string, referencedAgentIds: Set): string[] { + if (referencedAgentIds.size === 0) { return []; } - try { - const dirEntries = fs.readdirSync(subagentsDir, { withFileTypes: true }); - return dirEntries - .filter(entry => entry.isFile() && entry.name.endsWith('.jsonl')) - .map(entry => path.join(subagentsDir, entry.name)); - } catch { - return []; + const transcriptDir = path.dirname(transcriptPath); + const transcriptStem = path.parse(transcriptPath).name; + const candidateDirs = [ + path.join(transcriptDir, 'subagents'), + path.join(transcriptDir, transcriptStem, 'subagents') + ]; + const seenPaths = new Set(); + const matchedPaths: string[] = []; + + for (const subagentsDir of candidateDirs) { + if (!fs.existsSync(subagentsDir)) { + continue; + } + + try { + const dirEntries = fs.readdirSync(subagentsDir, { withFileTypes: true }); + for (const entry of dirEntries) { + if (!entry.isFile()) { + continue; + } + + const match = /^agent-(.+)\.jsonl$/.exec(entry.name); + if (!match?.[1]) { + continue; + } + + if (!referencedAgentIds.has(match[1])) { + continue; + } + + const fullPath = path.join(subagentsDir, entry.name); + if (seenPaths.has(fullPath)) { + continue; + } + + seenPaths.add(fullPath); + matchedPaths.push(fullPath); + } + } catch { + continue; + } } + + return matchedPaths; } export async function getSpeedMetrics( @@ -287,6 +359,7 @@ export async function getSpeedMetrics( const mainLines = await readJsonlLines(transcriptPath); const mainMetrics = collectSpeedMetricsFromLines(mainLines, true); + const referencedSubagentIds = getReferencedSubagentIds(mainLines); let inputTokens = mainMetrics.inputTokens; let outputTokens = mainMetrics.outputTokens; @@ -294,7 +367,7 @@ export async function getSpeedMetrics( const allIntervals: SpeedInterval[] = [...mainMetrics.intervals]; if (options.includeSubagents === true) { - const subagentPaths = getSubagentTranscriptPaths(transcriptPath); + const subagentPaths = getSubagentTranscriptPaths(transcriptPath, referencedSubagentIds); const subagentMetricsResults = await Promise.all(subagentPaths.map(async (subagentPath) => { try { const subagentLines = await readJsonlLines(subagentPath); From cee7b509314f3f62a840def5c3a477b133961981 Mon Sep 17 00:00:00 2001 From: Matthew Breedlove Date: Wed, 4 Mar 2026 19:58:24 -0500 Subject: [PATCH 4/5] Consolidate speed windows into core speed widgets --- src/ccstatusline.ts | 29 ++- src/types/RenderContext.ts | 1 + src/utils/__tests__/jsonl-metrics.test.ts | 156 ++++++++++++ src/utils/__tests__/speed-window.test.ts | 55 +++++ src/utils/jsonl-metrics.ts | 262 ++++++++++++++++----- src/utils/jsonl.ts | 1 + src/utils/speed-window.ts | 49 ++++ src/widgets/InputSpeed.ts | 39 +-- src/widgets/OutputSpeed.ts | 39 +-- src/widgets/TotalSpeed.ts | 39 +-- src/widgets/__tests__/SpeedWidgets.test.ts | 109 +++++---- src/widgets/shared/speed-widget.tsx | 199 ++++++++++++++++ 12 files changed, 814 insertions(+), 164 deletions(-) create mode 100644 src/utils/__tests__/speed-window.test.ts create mode 100644 src/utils/speed-window.ts create mode 100644 src/widgets/shared/speed-widget.tsx diff --git a/src/ccstatusline.ts b/src/ccstatusline.ts index 7e30687..0dac483 100644 --- a/src/ccstatusline.ts +++ b/src/ccstatusline.ts @@ -18,7 +18,7 @@ import { } from './utils/config'; import { getSessionDuration, - getSpeedMetrics, + getSpeedMetricsCollection, getTokenMetrics } from './utils/jsonl'; import { @@ -27,6 +27,10 @@ import { renderStatusLine } from './utils/renderer'; import { advanceGlobalSeparatorIndex } from './utils/separator-index'; +import { + getWidgetSpeedWindowSeconds, + isWidgetSpeedWindowEnabled +} from './utils/speed-window'; import { prefetchUsageDataIfNeeded } from './utils/usage-prefetch'; function hasSessionDurationInStatusJson(data: StatusJSON): boolean { @@ -91,8 +95,17 @@ async function renderMultipleLines(data: StatusJSON) { // Check if session clock is needed const hasSessionClock = lines.some(line => line.some(item => item.type === 'session-clock')); - // Check if speed metrics widgets are needed - const hasSpeedItems = lines.some(line => line.some(item => ['output-speed', 'input-speed', 'total-speed'].includes(item.type))); + const speedWidgetTypes = new Set(['output-speed', 'input-speed', 'total-speed']); + const hasSpeedItems = lines.some(line => line.some(item => speedWidgetTypes.has(item.type))); + const requestedSpeedWindows = new Set(); + for (const line of lines) { + for (const item of line) { + if (speedWidgetTypes.has(item.type) && isWidgetSpeedWindowEnabled(item)) { + requestedSpeedWindows.add(getWidgetSpeedWindowSeconds(item)); + } + } + } + let tokenMetrics: TokenMetrics | null = null; if (data.transcript_path) { tokenMetrics = await getTokenMetrics(data.transcript_path); @@ -106,8 +119,15 @@ async function renderMultipleLines(data: StatusJSON) { const usageData = await prefetchUsageDataIfNeeded(lines); let speedMetrics: SpeedMetrics | null = null; + let windowedSpeedMetrics: Record | null = null; if (hasSpeedItems && data.transcript_path) { - speedMetrics = await getSpeedMetrics(data.transcript_path, { includeSubagents: true }); + const speedMetricsCollection = await getSpeedMetricsCollection(data.transcript_path, { + includeSubagents: true, + windowSeconds: Array.from(requestedSpeedWindows) + }); + + speedMetrics = speedMetricsCollection.sessionAverage; + windowedSpeedMetrics = speedMetricsCollection.windowed; } // Create render context @@ -115,6 +135,7 @@ async function renderMultipleLines(data: StatusJSON) { data, tokenMetrics, speedMetrics, + windowedSpeedMetrics, usageData, sessionDuration, isPreview: false diff --git a/src/types/RenderContext.ts b/src/types/RenderContext.ts index 5183254..c2f3b49 100644 --- a/src/types/RenderContext.ts +++ b/src/types/RenderContext.ts @@ -20,6 +20,7 @@ export interface RenderContext { data?: StatusJSON; tokenMetrics?: TokenMetrics | null; speedMetrics?: SpeedMetrics | null; + windowedSpeedMetrics?: Record | null; usageData?: RenderUsageData | null; sessionDuration?: string | null; blockMetrics?: BlockMetrics | null; diff --git a/src/utils/__tests__/jsonl-metrics.test.ts b/src/utils/__tests__/jsonl-metrics.test.ts index 4cfe57e..92d9f63 100644 --- a/src/utils/__tests__/jsonl-metrics.test.ts +++ b/src/utils/__tests__/jsonl-metrics.test.ts @@ -11,6 +11,7 @@ import { import { getSessionDuration, getSpeedMetrics, + getSpeedMetricsCollection, getTokenMetrics } from '../jsonl'; @@ -214,6 +215,108 @@ describe('jsonl transcript metrics', () => { }); }); + it('calculates windowed speed metrics from recent requests only', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-')); + tempRoots.push(root); + const transcriptPath = path.join(root, 'speed-window.jsonl'); + + fs.writeFileSync(transcriptPath, [ + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:00.000Z', + type: 'user' + }), + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:10.000Z', + type: 'assistant', + input: 100, + output: 50 + }), + makeTranscriptLine({ + timestamp: '2026-01-01T10:01:00.000Z', + type: 'user' + }), + makeTranscriptLine({ + timestamp: '2026-01-01T10:01:10.000Z', + type: 'assistant', + input: 200, + output: 100 + }), + makeTranscriptLine({ + timestamp: '2026-01-01T10:02:00.000Z', + type: 'user' + }), + makeTranscriptLine({ + timestamp: '2026-01-01T10:02:10.000Z', + type: 'assistant', + input: 300, + output: 150 + }) + ].join('\n')); + + const metrics = await getSpeedMetrics(transcriptPath, { windowSeconds: 70 }); + + expect(metrics).toEqual({ + totalDurationMs: 20000, + inputTokens: 500, + outputTokens: 250, + totalTokens: 750, + requestCount: 2 + }); + }); + + it('returns session and windowed speed metrics in one collection call', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-')); + tempRoots.push(root); + const transcriptPath = path.join(root, 'speed-window-collection.jsonl'); + + fs.writeFileSync(transcriptPath, [ + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:00.000Z', + type: 'user' + }), + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:10.000Z', + type: 'assistant', + input: 100, + output: 50 + }), + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:40.000Z', + type: 'user' + }), + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:50.000Z', + type: 'assistant', + input: 200, + output: 100 + }) + ].join('\n')); + + const metricsCollection = await getSpeedMetricsCollection(transcriptPath, { windowSeconds: [30, 90] }); + + expect(metricsCollection.sessionAverage).toEqual({ + totalDurationMs: 20000, + inputTokens: 300, + outputTokens: 150, + totalTokens: 450, + requestCount: 2 + }); + expect(metricsCollection.windowed['30']).toEqual({ + totalDurationMs: 10000, + inputTokens: 200, + outputTokens: 100, + totalTokens: 300, + requestCount: 1 + }); + expect(metricsCollection.windowed['90']).toEqual({ + totalDurationMs: 20000, + inputTokens: 300, + outputTokens: 150, + totalTokens: 450, + requestCount: 2 + }); + }); + it('ignores sidechain and API error entries in speed metrics', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-')); tempRoots.push(root); @@ -376,6 +479,59 @@ describe('jsonl transcript metrics', () => { }); }); + it('applies window filtering to aggregated subagent speed metrics', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-')); + tempRoots.push(root); + const transcriptPath = path.join(root, 'speed-main-subagent-windowed.jsonl'); + const subagentsDir = path.join(root, 'subagents'); + + fs.writeFileSync(transcriptPath, [ + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:00.000Z', + type: 'user' + }), + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:04.000Z', + type: 'assistant', + input: 10, + output: 20 + }), + JSON.stringify({ + type: 'progress', + data: { agentId: 'a' } + }) + ].join('\n')); + + fs.mkdirSync(subagentsDir, { recursive: true }); + fs.writeFileSync(path.join(subagentsDir, 'agent-a.jsonl'), [ + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:05.000Z', + type: 'user', + isSidechain: true + }), + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:10.000Z', + type: 'assistant', + input: 30, + output: 60, + isSidechain: true + }) + ].join('\n')); + + const metrics = await getSpeedMetrics(transcriptPath, { + includeSubagents: true, + windowSeconds: 4 + }); + + expect(metrics).toEqual({ + totalDurationMs: 4000, + inputTokens: 30, + outputTokens: 60, + totalTokens: 90, + requestCount: 1 + }); + }); + it('includes only referenced subagent transcripts from the parent transcript', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-')); tempRoots.push(root); diff --git a/src/utils/__tests__/speed-window.test.ts b/src/utils/__tests__/speed-window.test.ts new file mode 100644 index 0000000..ba5d202 --- /dev/null +++ b/src/utils/__tests__/speed-window.test.ts @@ -0,0 +1,55 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import type { WidgetItem } from '../../types/Widget'; +import { + DEFAULT_SPEED_WINDOW_SECONDS, + clampSpeedWindowSeconds, + getWidgetSpeedWindowSeconds, + isWidgetSpeedWindowEnabled, + withWidgetSpeedWindowSeconds +} from '../speed-window'; + +function createWidget(metadata?: Record): WidgetItem { + return { + id: 'speed-widget', + type: 'total-speed', + metadata + }; +} + +describe('speed-window helpers', () => { + it('clamps values to the supported range', () => { + expect(clampSpeedWindowSeconds(-1)).toBe(0); + expect(clampSpeedWindowSeconds(0)).toBe(0); + expect(clampSpeedWindowSeconds(90)).toBe(90); + expect(clampSpeedWindowSeconds(300)).toBe(120); + }); + + it('returns default window seconds when metadata is missing or invalid', () => { + expect(getWidgetSpeedWindowSeconds(createWidget())).toBe(DEFAULT_SPEED_WINDOW_SECONDS); + expect(getWidgetSpeedWindowSeconds(createWidget({ windowSeconds: 'abc' }))).toBe(DEFAULT_SPEED_WINDOW_SECONDS); + }); + + it('parses and clamps widget metadata window seconds', () => { + expect(getWidgetSpeedWindowSeconds(createWidget({ windowSeconds: '45' }))).toBe(45); + expect(getWidgetSpeedWindowSeconds(createWidget({ windowSeconds: '999' }))).toBe(120); + }); + + it('stores clamped window seconds in metadata while preserving existing keys', () => { + const updated = withWidgetSpeedWindowSeconds(createWidget({ keep: 'true' }), -3); + expect(updated.metadata).toEqual({ + keep: 'true', + windowSeconds: '0' + }); + }); + + it('treats zero as disabled and positive values as enabled', () => { + expect(isWidgetSpeedWindowEnabled(createWidget())).toBe(false); + expect(isWidgetSpeedWindowEnabled(createWidget({ windowSeconds: '0' }))).toBe(false); + expect(isWidgetSpeedWindowEnabled(createWidget({ windowSeconds: '30' }))).toBe(true); + }); +}); \ No newline at end of file diff --git a/src/utils/jsonl-metrics.ts b/src/utils/jsonl-metrics.ts index c100a51..aacdefb 100644 --- a/src/utils/jsonl-metrics.ts +++ b/src/utils/jsonl-metrics.ts @@ -12,18 +12,36 @@ import { readJsonlLines } from './jsonl-lines'; -interface SpeedMetricsOptions { includeSubagents?: boolean } +export interface SpeedMetricsOptions { + includeSubagents?: boolean; + windowSeconds?: number; +} + +interface SpeedMetricsCollectionOptions { + includeSubagents?: boolean; + windowSeconds?: number[]; +} + +export interface SpeedMetricsCollection { + sessionAverage: SpeedMetrics; + windowed: Record; +} interface SpeedInterval { startMs: number; endMs: number; } -interface CollectedSpeedMetrics { +interface SpeedRequest { inputTokens: number; outputTokens: number; - requestCount: number; - intervals: SpeedInterval[]; + assistantTimestampMs: number | null; + interval: SpeedInterval | null; +} + +interface CollectedSpeedMetrics { + requests: SpeedRequest[]; + latestTimestampMs: number | null; } function collectAgentIds(value: unknown, agentIds: Set) { @@ -193,18 +211,6 @@ function parseTimestamp(value: string | undefined): Date | null { return Number.isNaN(timestamp.getTime()) ? null : timestamp; } -function addInterval(intervals: SpeedInterval[], start: Date | null, end: Date | null) { - if (!start || !end) { - return; - } - - const startMs = start.getTime(); - const endMs = end.getTime(); - if (endMs > startMs) { - intervals.push({ startMs, endMs }); - } -} - function mergeIntervals(intervals: SpeedInterval[]): SpeedInterval[] { if (intervals.length === 0) { return []; @@ -240,14 +246,30 @@ function getIntervalsDurationMs(intervals: SpeedInterval[]): number { return intervals.reduce((total, interval) => total + (interval.endMs - interval.startMs), 0); } +function createEmptySpeedMetrics(): SpeedMetrics { + return { + totalDurationMs: 0, + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + requestCount: 0 + }; +} + +function normalizeWindowSeconds(value: number | undefined): number | null { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return null; + } + + const normalized = Math.trunc(value); + return normalized > 0 ? normalized : null; +} + function collectSpeedMetricsFromLines(lines: string[], ignoreSidechain: boolean): CollectedSpeedMetrics { - let inputTokens = 0; - let outputTokens = 0; - let requestCount = 0; - const intervals: SpeedInterval[] = []; + const requests: SpeedRequest[] = []; let lastUserTimestamp: Date | null = null; - let lastAssistantTimestamp: Date | null = null; + let latestTimestampMs: number | null = null; for (const line of lines) { const data = parseJsonlLine(line) as TranscriptLine | null; @@ -260,35 +282,133 @@ function collectSpeedMetricsFromLines(lines: string[], ignoreSidechain: boolean) } const entryTimestamp = parseTimestamp(data.timestamp); + if (entryTimestamp) { + const entryTimestampMs = entryTimestamp.getTime(); + if (latestTimestampMs === null || entryTimestampMs > latestTimestampMs) { + latestTimestampMs = entryTimestampMs; + } + } if (data.type === 'user' && entryTimestamp) { - addInterval(intervals, lastUserTimestamp, lastAssistantTimestamp); lastUserTimestamp = entryTimestamp; - lastAssistantTimestamp = null; continue; } if (data.type === 'assistant' && data.message?.usage) { - inputTokens += data.message.usage.input_tokens || 0; - outputTokens += data.message.usage.output_tokens || 0; - requestCount++; - - if (entryTimestamp) { - lastAssistantTimestamp = entryTimestamp; + const inputTokens = data.message.usage.input_tokens || 0; + const outputTokens = data.message.usage.output_tokens || 0; + let interval: SpeedInterval | null = null; + if (entryTimestamp && lastUserTimestamp) { + const startMs = lastUserTimestamp.getTime(); + const endMs = entryTimestamp.getTime(); + if (endMs > startMs) { + interval = { startMs, endMs }; + } } + + requests.push({ + inputTokens, + outputTokens, + assistantTimestampMs: entryTimestamp ? entryTimestamp.getTime() : null, + interval + }); + } + } + + return { + requests, + latestTimestampMs + }; +} + +function mergeCollectedSpeedMetrics(parts: CollectedSpeedMetrics[]): CollectedSpeedMetrics { + const requests: SpeedRequest[] = []; + let latestTimestampMs: number | null = null; + + for (const part of parts) { + requests.push(...part.requests); + + if (part.latestTimestampMs !== null && (latestTimestampMs === null || part.latestTimestampMs > latestTimestampMs)) { + latestTimestampMs = part.latestTimestampMs; } } - addInterval(intervals, lastUserTimestamp, lastAssistantTimestamp); + return { + requests, + latestTimestampMs + }; +} + +function buildSpeedMetrics( + collected: CollectedSpeedMetrics, + windowSeconds?: number +): SpeedMetrics { + const normalizedWindowSeconds = normalizeWindowSeconds(windowSeconds); + if (normalizedWindowSeconds !== null && collected.latestTimestampMs === null) { + return createEmptySpeedMetrics(); + } + + const windowEndMs = normalizedWindowSeconds !== null && collected.latestTimestampMs !== null + ? collected.latestTimestampMs + : null; + const windowStartMs = normalizedWindowSeconds !== null && windowEndMs !== null + ? windowEndMs - (normalizedWindowSeconds * 1000) + : null; + + const selectedRequests = normalizedWindowSeconds !== null && windowStartMs !== null && windowEndMs !== null + ? collected.requests.filter(request => request.assistantTimestampMs !== null + && request.assistantTimestampMs >= windowStartMs + && request.assistantTimestampMs <= windowEndMs + ) + : collected.requests; + + let inputTokens = 0; + let outputTokens = 0; + const intervals: SpeedInterval[] = []; + + for (const request of selectedRequests) { + inputTokens += request.inputTokens; + outputTokens += request.outputTokens; + + if (!request.interval) { + continue; + } + + if (windowStartMs === null || windowEndMs === null) { + intervals.push(request.interval); + continue; + } + + const clippedStartMs = Math.max(request.interval.startMs, windowStartMs); + const clippedEndMs = Math.min(request.interval.endMs, windowEndMs); + if (clippedEndMs > clippedStartMs) { + intervals.push({ + startMs: clippedStartMs, + endMs: clippedEndMs + }); + } + } + + const mergedIntervals = mergeIntervals(intervals); + const totalDurationMs = getIntervalsDurationMs(mergedIntervals); return { + totalDurationMs, inputTokens, outputTokens, - requestCount, - intervals + totalTokens: inputTokens + outputTokens, + requestCount: selectedRequests.length }; } +function buildEmptyWindowedMetrics(windowSeconds: number[]): Record { + const windowed: Record = {}; + for (const window of windowSeconds) { + windowed[window.toString()] = createEmptySpeedMetrics(); + } + return windowed; +} + function getSubagentTranscriptPaths(transcriptPath: string, referencedAgentIds: Set): string[] { if (referencedAgentIds.size === 0) { return []; @@ -340,33 +460,34 @@ function getSubagentTranscriptPaths(transcriptPath: string, referencedAgentIds: return matchedPaths; } -export async function getSpeedMetrics( +export async function getSpeedMetricsCollection( transcriptPath: string, - options: SpeedMetricsOptions = {} -): Promise { - const emptyMetrics: SpeedMetrics = { - totalDurationMs: 0, - inputTokens: 0, - outputTokens: 0, - totalTokens: 0, - requestCount: 0 - }; + options: SpeedMetricsCollectionOptions = {} +): Promise { + const normalizedWindows = Array.from( + new Set( + (options.windowSeconds ?? []) + .map(window => normalizeWindowSeconds(window)) + .filter((window): window is number => window !== null) + ) + ); + const emptyWindowedMetrics = buildEmptyWindowedMetrics(normalizedWindows); try { if (!fs.existsSync(transcriptPath)) { - return emptyMetrics; + return { + sessionAverage: createEmptySpeedMetrics(), + windowed: emptyWindowedMetrics + }; } const mainLines = await readJsonlLines(transcriptPath); - const mainMetrics = collectSpeedMetricsFromLines(mainLines, true); - const referencedSubagentIds = getReferencedSubagentIds(mainLines); - - let inputTokens = mainMetrics.inputTokens; - let outputTokens = mainMetrics.outputTokens; - let requestCount = mainMetrics.requestCount; - const allIntervals: SpeedInterval[] = [...mainMetrics.intervals]; + const allCollected: CollectedSpeedMetrics[] = [ + collectSpeedMetricsFromLines(mainLines, true) + ]; if (options.includeSubagents === true) { + const referencedSubagentIds = getReferencedSubagentIds(mainLines); const subagentPaths = getSubagentTranscriptPaths(transcriptPath, referencedSubagentIds); const subagentMetricsResults = await Promise.all(subagentPaths.map(async (subagentPath) => { try { @@ -382,24 +503,41 @@ export async function getSpeedMetrics( continue; } - inputTokens += subagentMetrics.inputTokens; - outputTokens += subagentMetrics.outputTokens; - requestCount += subagentMetrics.requestCount; - allIntervals.push(...subagentMetrics.intervals); + allCollected.push(subagentMetrics); } } - const mergedIntervals = mergeIntervals(allIntervals); - const activeDurationMs = getIntervalsDurationMs(mergedIntervals); + const combined = mergeCollectedSpeedMetrics(allCollected); + const windowed: Record = {}; + for (const window of normalizedWindows) { + windowed[window.toString()] = buildSpeedMetrics(combined, window); + } return { - totalDurationMs: activeDurationMs, - inputTokens, - outputTokens, - totalTokens: inputTokens + outputTokens, - requestCount + sessionAverage: buildSpeedMetrics(combined), + windowed }; } catch { - return emptyMetrics; + return { + sessionAverage: createEmptySpeedMetrics(), + windowed: emptyWindowedMetrics + }; } +} + +export async function getSpeedMetrics( + transcriptPath: string, + options: SpeedMetricsOptions = {} +): Promise { + const requestedWindow = normalizeWindowSeconds(options.windowSeconds); + const metricsCollection = await getSpeedMetricsCollection(transcriptPath, { + includeSubagents: options.includeSubagents, + windowSeconds: requestedWindow ? [requestedWindow] : [] + }); + + if (requestedWindow === null) { + return metricsCollection.sessionAverage; + } + + return metricsCollection.windowed[requestedWindow.toString()] ?? createEmptySpeedMetrics(); } \ No newline at end of file diff --git a/src/utils/jsonl.ts b/src/utils/jsonl.ts index 360d9f9..9605f64 100644 --- a/src/utils/jsonl.ts +++ b/src/utils/jsonl.ts @@ -8,5 +8,6 @@ export { getBlockMetrics } from './jsonl-blocks'; export { getSessionDuration, getSpeedMetrics, + getSpeedMetricsCollection, getTokenMetrics } from './jsonl-metrics'; \ No newline at end of file diff --git a/src/utils/speed-window.ts b/src/utils/speed-window.ts new file mode 100644 index 0000000..31693ec --- /dev/null +++ b/src/utils/speed-window.ts @@ -0,0 +1,49 @@ +import type { WidgetItem } from '../types/Widget'; + +export const SPEED_WINDOW_METADATA_KEY = 'windowSeconds'; +export const DEFAULT_SPEED_WINDOW_SECONDS = 0; +export const MIN_SPEED_WINDOW_SECONDS = 0; +export const MAX_SPEED_WINDOW_SECONDS = 120; + +export function clampSpeedWindowSeconds(value: number): number { + if (!Number.isFinite(value)) { + return DEFAULT_SPEED_WINDOW_SECONDS; + } + + const normalized = Math.trunc(value); + if (normalized < MIN_SPEED_WINDOW_SECONDS) { + return MIN_SPEED_WINDOW_SECONDS; + } + if (normalized > MAX_SPEED_WINDOW_SECONDS) { + return MAX_SPEED_WINDOW_SECONDS; + } + return normalized; +} + +export function getWidgetSpeedWindowSeconds(item: WidgetItem): number { + const metadataValue = item.metadata?.[SPEED_WINDOW_METADATA_KEY]; + if (!metadataValue) { + return DEFAULT_SPEED_WINDOW_SECONDS; + } + + const parsed = Number.parseInt(metadataValue, 10); + if (!Number.isFinite(parsed)) { + return DEFAULT_SPEED_WINDOW_SECONDS; + } + + return clampSpeedWindowSeconds(parsed); +} + +export function isWidgetSpeedWindowEnabled(item: WidgetItem): boolean { + return getWidgetSpeedWindowSeconds(item) > 0; +} + +export function withWidgetSpeedWindowSeconds(item: WidgetItem, seconds: number): WidgetItem { + return { + ...item, + metadata: { + ...(item.metadata ?? {}), + [SPEED_WINDOW_METADATA_KEY]: clampSpeedWindowSeconds(seconds).toString() + } + }; +} \ No newline at end of file diff --git a/src/widgets/InputSpeed.ts b/src/widgets/InputSpeed.ts index a6309aa..4e3a3d9 100644 --- a/src/widgets/InputSpeed.ts +++ b/src/widgets/InputSpeed.ts @@ -1,37 +1,42 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { + CustomKeybind, Widget, WidgetEditorDisplay, + WidgetEditorProps, WidgetItem } from '../types/Widget'; -import { - calculateInputSpeed, - formatSpeed -} from '../utils/speed-metrics'; -import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; +import { + getSpeedWidgetCustomKeybinds, + getSpeedWidgetDescription, + getSpeedWidgetDisplayName, + getSpeedWidgetEditorDisplay, + renderSpeedWidgetEditor, + renderSpeedWidgetValue +} from './shared/speed-widget'; export class InputSpeedWidget implements Widget { getDefaultColor(): string { return 'cyan'; } - getDescription(): string { return 'Shows input token processing speed (tokens/sec)'; } - getDisplayName(): string { return 'Input Speed'; } + getDescription(): string { return getSpeedWidgetDescription('input'); } + getDisplayName(): string { return getSpeedWidgetDisplayName('input'); } getCategory(): string { return 'Token Speed'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - return { displayText: this.getDisplayName() }; + return getSpeedWidgetEditorDisplay('input', item); } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { - if (context.isPreview) { - return formatRawOrLabeledValue(item, 'In: ', '85.2 t/s'); - } + void settings; + return renderSpeedWidgetValue('input', item, context); + } + + getCustomKeybinds(): CustomKeybind[] { + return getSpeedWidgetCustomKeybinds(); + } - if (context.speedMetrics) { - const speed = calculateInputSpeed(context.speedMetrics); - const formatted = formatSpeed(speed); - return formatRawOrLabeledValue(item, 'In: ', formatted); - } - return null; + renderEditor(props: WidgetEditorProps) { + return renderSpeedWidgetEditor(props); } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/OutputSpeed.ts b/src/widgets/OutputSpeed.ts index 8bddfb3..5ffa0aa 100644 --- a/src/widgets/OutputSpeed.ts +++ b/src/widgets/OutputSpeed.ts @@ -1,37 +1,42 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { + CustomKeybind, Widget, WidgetEditorDisplay, + WidgetEditorProps, WidgetItem } from '../types/Widget'; -import { - calculateOutputSpeed, - formatSpeed -} from '../utils/speed-metrics'; -import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; +import { + getSpeedWidgetCustomKeybinds, + getSpeedWidgetDescription, + getSpeedWidgetDisplayName, + getSpeedWidgetEditorDisplay, + renderSpeedWidgetEditor, + renderSpeedWidgetValue +} from './shared/speed-widget'; export class OutputSpeedWidget implements Widget { getDefaultColor(): string { return 'cyan'; } - getDescription(): string { return 'Shows output token generation speed (tokens/sec)'; } - getDisplayName(): string { return 'Output Speed'; } + getDescription(): string { return getSpeedWidgetDescription('output'); } + getDisplayName(): string { return getSpeedWidgetDisplayName('output'); } getCategory(): string { return 'Token Speed'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - return { displayText: this.getDisplayName() }; + return getSpeedWidgetEditorDisplay('output', item); } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { - if (context.isPreview) { - return formatRawOrLabeledValue(item, 'Out: ', '42.5 t/s'); - } + void settings; + return renderSpeedWidgetValue('output', item, context); + } + + getCustomKeybinds(): CustomKeybind[] { + return getSpeedWidgetCustomKeybinds(); + } - if (context.speedMetrics) { - const speed = calculateOutputSpeed(context.speedMetrics); - const formatted = formatSpeed(speed); - return formatRawOrLabeledValue(item, 'Out: ', formatted); - } - return null; + renderEditor(props: WidgetEditorProps) { + return renderSpeedWidgetEditor(props); } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/TotalSpeed.ts b/src/widgets/TotalSpeed.ts index 39a0f38..256b6cc 100644 --- a/src/widgets/TotalSpeed.ts +++ b/src/widgets/TotalSpeed.ts @@ -1,37 +1,42 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { + CustomKeybind, Widget, WidgetEditorDisplay, + WidgetEditorProps, WidgetItem } from '../types/Widget'; -import { - calculateTotalSpeed, - formatSpeed -} from '../utils/speed-metrics'; -import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; +import { + getSpeedWidgetCustomKeybinds, + getSpeedWidgetDescription, + getSpeedWidgetDisplayName, + getSpeedWidgetEditorDisplay, + renderSpeedWidgetEditor, + renderSpeedWidgetValue +} from './shared/speed-widget'; export class TotalSpeedWidget implements Widget { getDefaultColor(): string { return 'cyan'; } - getDescription(): string { return 'Shows total token processing speed (tokens/sec)'; } - getDisplayName(): string { return 'Total Speed'; } + getDescription(): string { return getSpeedWidgetDescription('total'); } + getDisplayName(): string { return getSpeedWidgetDisplayName('total'); } getCategory(): string { return 'Token Speed'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - return { displayText: this.getDisplayName() }; + return getSpeedWidgetEditorDisplay('total', item); } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { - if (context.isPreview) { - return formatRawOrLabeledValue(item, 'Total: ', '127.7 t/s'); - } + void settings; + return renderSpeedWidgetValue('total', item, context); + } + + getCustomKeybinds(): CustomKeybind[] { + return getSpeedWidgetCustomKeybinds(); + } - if (context.speedMetrics) { - const speed = calculateTotalSpeed(context.speedMetrics); - const formatted = formatSpeed(speed); - return formatRawOrLabeledValue(item, 'Total: ', formatted); - } - return null; + renderEditor(props: WidgetEditorProps) { + return renderSpeedWidgetEditor(props); } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/__tests__/SpeedWidgets.test.ts b/src/widgets/__tests__/SpeedWidgets.test.ts index a07ac2c..8533eba 100644 --- a/src/widgets/__tests__/SpeedWidgets.test.ts +++ b/src/widgets/__tests__/SpeedWidgets.test.ts @@ -25,11 +25,18 @@ function createSpeedMetrics(overrides: Partial = {}): SpeedMetrics }; } -function createItem(type: string, rawValue = false): WidgetItem { +function createItem( + type: string, + options: { + rawValue?: boolean; + metadata?: Record; + } = {} +): WidgetItem { return { id: type, type, - rawValue + rawValue: options.rawValue, + metadata: options.metadata }; } @@ -40,34 +47,42 @@ describe('OutputSpeedWidget', () => { expect(widget.getCategory()).toBe('Token Speed'); }); - it('should return preview value in preview mode', () => { + it('should describe session-average behavior and window override', () => { + expect(widget.getDescription()).toContain('session-average'); + expect(widget.getDescription()).toContain('0-120'); + }); + + it('should expose a window editor keybind', () => { + expect(widget.getCustomKeybinds()).toEqual([ + { key: 'w', label: '(w)indow', action: 'edit-window' } + ]); + }); + + it('should show session average as the default editor modifier', () => { + expect(widget.getEditorDisplay(createItem('output-speed')).modifierText).toBe('(session avg)'); + }); + + it('should render session preview value by default', () => { const context: RenderContext = { isPreview: true }; expect(widget.render(createItem('output-speed'), context, DEFAULT_SETTINGS)).toBe('Out: 42.5 t/s'); }); - it('should return raw preview value when rawValue is true', () => { + it('should render window preview when window metadata is enabled', () => { const context: RenderContext = { isPreview: true }; - expect(widget.render(createItem('output-speed', true), context, DEFAULT_SETTINGS)).toBe('42.5 t/s'); + const item = createItem('output-speed', { metadata: { windowSeconds: '45' } }); + expect(widget.render(item, context, DEFAULT_SETTINGS)).toBe('Out: 26.8 t/s'); }); - it('should calculate output speed from speedMetrics', () => { + it('should calculate output speed from session speedMetrics when window is disabled', () => { const context: RenderContext = { speedMetrics: createSpeedMetrics({ outputTokens: 500, totalDurationMs: 10000 }) }; expect(widget.render(createItem('output-speed'), context, DEFAULT_SETTINGS)).toBe('Out: 50.0 t/s'); }); - it('should return raw value when rawValue is true', () => { - const context: RenderContext = { speedMetrics: createSpeedMetrics({ outputTokens: 500, totalDurationMs: 10000 }) }; - expect(widget.render(createItem('output-speed', true), context, DEFAULT_SETTINGS)).toBe('50.0 t/s'); - }); - - it('should return null when speedMetrics is undefined', () => { - const context: RenderContext = {}; - expect(widget.render(createItem('output-speed'), context, DEFAULT_SETTINGS)).toBeNull(); - }); + it('should calculate output speed from windowed metrics when window is enabled', () => { + const context: RenderContext = { windowedSpeedMetrics: { 90: createSpeedMetrics({ outputTokens: 720, totalDurationMs: 6000 }) } }; + const item = createItem('output-speed', { metadata: { windowSeconds: '90' } }); - it('should use k suffix for high speeds', () => { - const context: RenderContext = { speedMetrics: createSpeedMetrics({ outputTokens: 10000, totalDurationMs: 1000 }) }; - expect(widget.render(createItem('output-speed', true), context, DEFAULT_SETTINGS)).toBe('10.0k t/s'); + expect(widget.render(item, context, DEFAULT_SETTINGS)).toBe('Out: 120.0 t/s'); }); }); @@ -78,29 +93,29 @@ describe('InputSpeedWidget', () => { expect(widget.getCategory()).toBe('Token Speed'); }); - it('should return preview value in preview mode', () => { - const context: RenderContext = { isPreview: true }; - expect(widget.render(createItem('input-speed'), context, DEFAULT_SETTINGS)).toBe('In: 85.2 t/s'); + it('should show configured window modifier in editor display', () => { + const item = createItem('input-speed', { metadata: { windowSeconds: '75' } }); + expect(widget.getEditorDisplay(item).modifierText).toBe('(75s window)'); }); - it('should return raw preview value when rawValue is true', () => { + it('should render session preview by default and raw variant', () => { const context: RenderContext = { isPreview: true }; - expect(widget.render(createItem('input-speed', true), context, DEFAULT_SETTINGS)).toBe('85.2 t/s'); + expect(widget.render(createItem('input-speed'), context, DEFAULT_SETTINGS)).toBe('In: 85.2 t/s'); + expect(widget.render(createItem('input-speed', { rawValue: true }), context, DEFAULT_SETTINGS)).toBe('85.2 t/s'); }); - it('should calculate input speed from speedMetrics', () => { - const context: RenderContext = { speedMetrics: createSpeedMetrics({ inputTokens: 1000, totalDurationMs: 10000 }) }; - expect(widget.render(createItem('input-speed'), context, DEFAULT_SETTINGS)).toBe('In: 100.0 t/s'); - }); + it('should use windowed speed metrics for render output when configured', () => { + const context: RenderContext = { windowedSpeedMetrics: { 45: createSpeedMetrics({ inputTokens: 450, totalDurationMs: 3000 }) } }; + const item = createItem('input-speed', { metadata: { windowSeconds: '45' } }); - it('should return raw value when rawValue is true', () => { - const context: RenderContext = { speedMetrics: createSpeedMetrics({ inputTokens: 1000, totalDurationMs: 10000 }) }; - expect(widget.render(createItem('input-speed', true), context, DEFAULT_SETTINGS)).toBe('100.0 t/s'); + expect(widget.render(item, context, DEFAULT_SETTINGS)).toBe('In: 150.0 t/s'); }); - it('should return null when speedMetrics is undefined', () => { - const context: RenderContext = {}; - expect(widget.render(createItem('input-speed'), context, DEFAULT_SETTINGS)).toBeNull(); + it('should treat 0 as disabled window and use session metrics', () => { + const context: RenderContext = { speedMetrics: createSpeedMetrics({ inputTokens: 200, totalDurationMs: 2000 }) }; + const item = createItem('input-speed', { metadata: { windowSeconds: '0' } }); + + expect(widget.render(item, context, DEFAULT_SETTINGS)).toBe('In: 100.0 t/s'); }); }); @@ -111,28 +126,28 @@ describe('TotalSpeedWidget', () => { expect(widget.getCategory()).toBe('Token Speed'); }); - it('should return preview value in preview mode', () => { - const context: RenderContext = { isPreview: true }; - expect(widget.render(createItem('total-speed'), context, DEFAULT_SETTINGS)).toBe('Total: 127.7 t/s'); + it('should clamp invalid editor metadata to supported range', () => { + const item = createItem('total-speed', { metadata: { windowSeconds: '999' } }); + expect(widget.getEditorDisplay(item).modifierText).toBe('(120s window)'); }); - it('should return raw preview value when rawValue is true', () => { + it('should render preview values', () => { const context: RenderContext = { isPreview: true }; - expect(widget.render(createItem('total-speed', true), context, DEFAULT_SETTINGS)).toBe('127.7 t/s'); + expect(widget.render(createItem('total-speed'), context, DEFAULT_SETTINGS)).toBe('Total: 127.7 t/s'); + expect(widget.render(createItem('total-speed', { rawValue: true }), context, DEFAULT_SETTINGS)).toBe('127.7 t/s'); }); - it('should calculate total speed from speedMetrics', () => { - const context: RenderContext = { speedMetrics: createSpeedMetrics({ totalTokens: 1500, totalDurationMs: 10000 }) }; - expect(widget.render(createItem('total-speed'), context, DEFAULT_SETTINGS)).toBe('Total: 150.0 t/s'); - }); + it('should compute total speed from selected window metrics', () => { + const context: RenderContext = { windowedSpeedMetrics: { 30: createSpeedMetrics({ totalTokens: 300, totalDurationMs: 2000 }) } }; + const item = createItem('total-speed', { metadata: { windowSeconds: '30' } }); - it('should return raw value when rawValue is true', () => { - const context: RenderContext = { speedMetrics: createSpeedMetrics({ totalTokens: 1500, totalDurationMs: 10000 }) }; - expect(widget.render(createItem('total-speed', true), context, DEFAULT_SETTINGS)).toBe('150.0 t/s'); + expect(widget.render(item, context, DEFAULT_SETTINGS)).toBe('Total: 150.0 t/s'); }); - it('should return null when speedMetrics is undefined', () => { + it('should return null when windowed metrics are missing for enabled window', () => { const context: RenderContext = {}; - expect(widget.render(createItem('total-speed'), context, DEFAULT_SETTINGS)).toBeNull(); + const item = createItem('total-speed', { metadata: { windowSeconds: '15' } }); + + expect(widget.render(item, context, DEFAULT_SETTINGS)).toBeNull(); }); }); \ No newline at end of file diff --git a/src/widgets/shared/speed-widget.tsx b/src/widgets/shared/speed-widget.tsx new file mode 100644 index 0000000..24e6001 --- /dev/null +++ b/src/widgets/shared/speed-widget.tsx @@ -0,0 +1,199 @@ +import { + Box, + Text, + useInput +} from 'ink'; +import React, { useState } from 'react'; + +import type { RenderContext } from '../../types/RenderContext'; +import type { SpeedMetrics } from '../../types/SpeedMetrics'; +import type { + CustomKeybind, + WidgetEditorDisplay, + WidgetEditorProps, + WidgetItem +} from '../../types/Widget'; +import { shouldInsertInput } from '../../utils/input-guards'; +import { + calculateInputSpeed, + calculateOutputSpeed, + calculateTotalSpeed, + formatSpeed +} from '../../utils/speed-metrics'; +import { + DEFAULT_SPEED_WINDOW_SECONDS, + MAX_SPEED_WINDOW_SECONDS, + MIN_SPEED_WINDOW_SECONDS, + getWidgetSpeedWindowSeconds, + isWidgetSpeedWindowEnabled, + withWidgetSpeedWindowSeconds +} from '../../utils/speed-window'; + +import { makeModifierText } from './editor-display'; +import { formatRawOrLabeledValue } from './raw-or-labeled'; + +export type SpeedWidgetKind = 'input' | 'output' | 'total'; + +const WINDOW_EDITOR_ACTION = 'edit-window'; + +interface SpeedWidgetKindConfig { + label: string; + displayName: string; + description: string; + sessionPreview: string; + windowedPreview: string; +} + +const SPEED_WIDGET_CONFIG: Record = { + input: { + label: 'In: ', + displayName: 'Input Speed', + description: 'Shows session-average input token speed (tokens/sec). Optional window: 0-120 seconds (0 = full-session average).', + sessionPreview: '85.2 t/s', + windowedPreview: '31.5 t/s' + }, + output: { + label: 'Out: ', + displayName: 'Output Speed', + description: 'Shows session-average output token speed (tokens/sec). Optional window: 0-120 seconds (0 = full-session average).', + sessionPreview: '42.5 t/s', + windowedPreview: '26.8 t/s' + }, + total: { + label: 'Total: ', + displayName: 'Total Speed', + description: 'Shows session-average total token speed (tokens/sec). Optional window: 0-120 seconds (0 = full-session average).', + sessionPreview: '127.7 t/s', + windowedPreview: '58.3 t/s' + } +}; + +function getSpeedMetricsForWidget(item: WidgetItem, context: RenderContext): SpeedMetrics | null { + if (!isWidgetSpeedWindowEnabled(item)) { + return context.speedMetrics ?? null; + } + + const windowSeconds = getWidgetSpeedWindowSeconds(item); + return context.windowedSpeedMetrics?.[windowSeconds.toString()] ?? null; +} + +function calculateSpeed(kind: SpeedWidgetKind, metrics: SpeedMetrics): number | null { + if (kind === 'input') { + return calculateInputSpeed(metrics); + } + if (kind === 'output') { + return calculateOutputSpeed(metrics); + } + return calculateTotalSpeed(metrics); +} + +export function getSpeedWidgetDisplayName(kind: SpeedWidgetKind): string { + return SPEED_WIDGET_CONFIG[kind].displayName; +} + +export function getSpeedWidgetDescription(kind: SpeedWidgetKind): string { + return SPEED_WIDGET_CONFIG[kind].description; +} + +export function getSpeedWidgetEditorDisplay(kind: SpeedWidgetKind, item: WidgetItem): WidgetEditorDisplay { + const windowSeconds = getWidgetSpeedWindowSeconds(item); + const modifiers = windowSeconds > 0 + ? [`${windowSeconds}s window`] + : ['session avg']; + + return { + displayText: getSpeedWidgetDisplayName(kind), + modifierText: makeModifierText(modifiers) + }; +} + +export function renderSpeedWidgetValue( + kind: SpeedWidgetKind, + item: WidgetItem, + context: RenderContext +): string | null { + const config = SPEED_WIDGET_CONFIG[kind]; + const previewValue = isWidgetSpeedWindowEnabled(item) + ? config.windowedPreview + : config.sessionPreview; + + if (context.isPreview) { + return formatRawOrLabeledValue(item, config.label, previewValue); + } + + const metrics = getSpeedMetricsForWidget(item, context); + if (!metrics) { + return null; + } + + const speed = calculateSpeed(kind, metrics); + return formatRawOrLabeledValue(item, config.label, formatSpeed(speed)); +} + +export function getSpeedWidgetCustomKeybinds(): CustomKeybind[] { + return [{ + key: 'w', + label: '(w)indow', + action: WINDOW_EDITOR_ACTION + }]; +} + +export function renderSpeedWidgetEditor(props: WidgetEditorProps): React.ReactElement { + return ; +} + +const SpeedWindowEditor: React.FC = ({ widget, onComplete, onCancel, action }) => { + const [windowInput, setWindowInput] = useState(getWidgetSpeedWindowSeconds(widget).toString()); + + useInput((input, key) => { + if (action !== WINDOW_EDITOR_ACTION) { + return; + } + + if (key.return) { + const parsedWindow = Number.parseInt(windowInput, 10); + const nextWindow = Number.isFinite(parsedWindow) + ? parsedWindow + : DEFAULT_SPEED_WINDOW_SECONDS; + + onComplete(withWidgetSpeedWindowSeconds(widget, nextWindow)); + return; + } + + if (key.escape) { + onCancel(); + return; + } + + if (key.backspace) { + setWindowInput(windowInput.slice(0, -1)); + return; + } + + if (shouldInsertInput(input, key) && /\d/.test(input)) { + setWindowInput(windowInput + input); + } + }); + + if (action !== WINDOW_EDITOR_ACTION) { + return Unknown editor mode; + } + + return ( + + + + Enter window in seconds ( + {MIN_SPEED_WINDOW_SECONDS} + - + {MAX_SPEED_WINDOW_SECONDS} + ): + {' '} + + {windowInput} + {' '} + + 0 disables window mode and averages the full session. Press Enter to save, ESC to cancel. + + ); +}; \ No newline at end of file From 6bbb1868034b3dcdb7d8ce4af7f58e926f3cafff Mon Sep 17 00:00:00 2001 From: Matthew Breedlove Date: Wed, 4 Mar 2026 20:03:18 -0500 Subject: [PATCH 5/5] Bump to v2.2.0 and document new speed widgets --- README.md | 8 ++++++++ package.json | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6617bdd..12036d7 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,14 @@ ## 🆕 Recent Updates +### v2.2.0 - New Token Speed widgets with optional windows + +- **🚀 New Token Speed widgets** - Added three widgets: **Input Speed**, **Output Speed**, and **Total Speed**. + - Each speed widget supports a configurable window of `0-120` seconds in the widget editor (`w` key). + - `0` disables window mode and uses a full-session average speed. + - `1-120` calculates recent speed over the selected rolling window. +- **🤝 Better subagent-aware speed reporting** - Token speed calculations continue to include referenced subagent activity so displayed speeds better reflect actual concurrent work. + ### v2.1.0 - v2.1.10 - Usage widgets, links, new git insertions / deletions widgets, and reliability fixes - **🧩 New Usage widgets (v2.1.0)** - Added **Session Usage**, **Weekly Usage**, **Block Reset Timer**, and **Context Bar** widgets. diff --git a/package.json b/package.json index 329e2c5..4e08db6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ccstatusline", - "version": "2.1.10", + "version": "2.2.0", "description": "A customizable status line formatter for Claude Code CLI", "module": "src/ccstatusline.ts", "type": "module",