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", diff --git a/src/ccstatusline.ts b/src/ccstatusline.ts index 1312634..0dac483 100644 --- a/src/ccstatusline.ts +++ b/src/ccstatusline.ts @@ -2,7 +2,10 @@ import chalk from 'chalk'; import { runTUI } from './tui'; -import type { TokenMetrics } from './types'; +import type { + SpeedMetrics, + TokenMetrics +} from './types'; import type { RenderContext } from './types/RenderContext'; import type { StatusJSON } from './types/StatusJSON'; import { StatusJSONSchema } from './types/StatusJSON'; @@ -15,6 +18,7 @@ import { } from './utils/config'; import { getSessionDuration, + getSpeedMetricsCollection, getTokenMetrics } from './utils/jsonl'; import { @@ -23,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 { @@ -87,6 +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')); + 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); @@ -99,10 +118,24 @@ 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) { + const speedMetricsCollection = await getSpeedMetricsCollection(data.transcript_path, { + includeSubagents: true, + windowSeconds: Array.from(requestedSpeedWindows) + }); + + speedMetrics = speedMetricsCollection.sessionAverage; + windowedSpeedMetrics = speedMetricsCollection.windowed; + } + // Create render context const context: RenderContext = { data, tokenMetrics, + speedMetrics, + windowedSpeedMetrics, usageData, sessionDuration, isPreview: false diff --git a/src/types/RenderContext.ts b/src/types/RenderContext.ts index dde9f97..c2f3b49 100644 --- a/src/types/RenderContext.ts +++ b/src/types/RenderContext.ts @@ -1,5 +1,6 @@ import type { BlockMetrics } from '../types'; +import type { SpeedMetrics } from './SpeedMetrics'; import type { StatusJSON } from './StatusJSON'; import type { TokenMetrics } from './TokenMetrics'; @@ -18,6 +19,8 @@ export interface RenderUsageData { 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/types/SpeedMetrics.ts b/src/types/SpeedMetrics.ts new file mode 100644 index 0000000..b2e242c --- /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 assistant usage entries included in speed aggregation */ + 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/__tests__/jsonl-metrics.test.ts b/src/utils/__tests__/jsonl-metrics.test.ts index c997008..92d9f63 100644 --- a/src/utils/__tests__/jsonl-metrics.test.ts +++ b/src/utils/__tests__/jsonl-metrics.test.ts @@ -10,6 +10,8 @@ import { import { getSessionDuration, + getSpeedMetrics, + getSpeedMetricsCollection, getTokenMetrics } from '../jsonl'; @@ -37,6 +39,30 @@ function makeUsageLine(params: { }); } +function makeTranscriptLine(params: { + timestamp: string; + type: 'user' | 'assistant'; + input?: number; + output?: number; + isSidechain?: boolean; + isApiErrorMessage?: boolean; +}): string { + return JSON.stringify({ + timestamp: params.timestamp, + type: params.type, + isSidechain: params.isSidechain, + isApiErrorMessage: params.isApiErrorMessage, + message: typeof params.input === 'number' || typeof params.output === 'number' + ? { + usage: { + input_tokens: params.input ?? 0, + output_tokens: params.output ?? 0 + } + } + : undefined + }); +} + describe('jsonl transcript metrics', () => { const tempRoots: string[] = []; @@ -143,4 +169,718 @@ describe('jsonl transcript metrics', () => { contextLength: 0 }); }); + + it('calculates speed metrics from user-to-assistant processing windows', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-speed-')); + tempRoots.push(root); + const transcriptPath = path.join(root, 'speed.jsonl'); + + fs.writeFileSync(transcriptPath, [ + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:00.000Z', + type: 'user' + }), + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:05.000Z', + type: 'assistant', + input: 200, + output: 100 + }), + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:08.000Z', + type: 'assistant', + input: 100, + output: 50 + }), + makeTranscriptLine({ + timestamp: '2026-01-01T10:01:00.000Z', + type: 'user' + }), + makeTranscriptLine({ + timestamp: '2026-01-01T10:01:04.000Z', + type: 'assistant', + input: 300, + output: 150 + }) + ].join('\n')); + + const metrics = await getSpeedMetrics(transcriptPath); + + expect(metrics).toEqual({ + totalDurationMs: 12000, + inputTokens: 600, + outputTokens: 300, + totalTokens: 900, + requestCount: 3 + }); + }); + + 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); + const transcriptPath = path.join(root, 'speed-filtering.jsonl'); + + fs.writeFileSync(transcriptPath, [ + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:00.000Z', + type: 'user' + }), + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:01.000Z', + type: 'assistant', + input: 999, + output: 999, + isSidechain: true + }), + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:02.000Z', + type: 'assistant', + input: 500, + output: 500, + isApiErrorMessage: true + }), + makeTranscriptLine({ + timestamp: '2026-01-01T10:00:03.000Z', + type: 'assistant', + input: 100, + output: 50 + }) + ].join('\n')); + + const metrics = await getSpeedMetrics(transcriptPath); + + expect(metrics).toEqual({ + totalDurationMs: 3000, + inputTokens: 100, + outputTokens: 50, + totalTokens: 150, + requestCount: 1 + }); + }); + + 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 + }), + JSON.stringify({ + type: 'progress', + data: { agentId: '1' } + }) + ].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 + }), + JSON.stringify({ + type: 'progress', + data: { agentId: 'a' } + }), + JSON.stringify({ + type: 'progress', + data: { agentId: 'b' } + }) + ].join('\n')); + + fs.mkdirSync(subagentsDir, { recursive: true }); + fs.writeFileSync(path.join(subagentsDir, 'agent-a.jsonl'), [ + 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('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); + 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); + 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 + }), + JSON.stringify({ + type: 'progress', + data: { agentId: 'unreadable' } + }) + ].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 + }), + JSON.stringify({ + type: 'progress', + data: { agentId: 'malformed' } + }) + ].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 + }), + JSON.stringify({ + type: 'progress', + data: { agentId: 'unreadable' } + }) + ].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({ + totalDurationMs: 0, + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + requestCount: 0 + }); + }); }); \ No newline at end of file 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/__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/__tests__/widgets.test.ts b/src/utils/__tests__/widgets.test.ts index 8cd965e..3be756b 100644 --- a/src/utils/__tests__/widgets.test.ts +++ b/src/utils/__tests__/widgets.test.ts @@ -33,6 +33,9 @@ describe('widget catalog', () => { const link = catalog.find(entry => entry.type === 'link'); const gitInsertions = catalog.find(entry => entry.type === 'git-insertions'); const gitDeletions = catalog.find(entry => entry.type === 'git-deletions'); + const inputSpeed = catalog.find(entry => entry.type === 'input-speed'); + const outputSpeed = catalog.find(entry => entry.type === 'output-speed'); + const totalSpeed = catalog.find(entry => entry.type === 'total-speed'); const resetTimer = catalog.find(entry => entry.type === 'reset-timer'); const weeklyResetTimer = catalog.find(entry => entry.type === 'weekly-reset-timer'); @@ -46,6 +49,12 @@ describe('widget catalog', () => { expect(gitInsertions?.category).toBe('Git'); expect(gitDeletions?.displayName).toBe('Git Deletions'); expect(gitDeletions?.category).toBe('Git'); + expect(inputSpeed?.displayName).toBe('Input Speed'); + expect(inputSpeed?.category).toBe('Token Speed'); + expect(outputSpeed?.displayName).toBe('Output Speed'); + expect(outputSpeed?.category).toBe('Token Speed'); + expect(totalSpeed?.displayName).toBe('Total Speed'); + expect(totalSpeed?.category).toBe('Token Speed'); expect(resetTimer?.displayName).toBe('Block Reset Timer'); expect(resetTimer?.category).toBe('Usage'); expect(weeklyResetTimer?.displayName).toBe('Weekly Reset Timer'); @@ -84,6 +93,7 @@ describe('widget catalog', () => { expect(categories).toContain('Git'); expect(categories).toContain('Context'); expect(categories).toContain('Tokens'); + expect(categories).toContain('Token Speed'); expect(categories).toContain('Session'); expect(categories).toContain('Usage'); expect(categories).toContain('Environment'); diff --git a/src/utils/jsonl-metrics.ts b/src/utils/jsonl-metrics.ts index 90425dc..aacdefb 100644 --- a/src/utils/jsonl-metrics.ts +++ b/src/utils/jsonl-metrics.ts @@ -1,6 +1,8 @@ import * as fs from 'fs'; +import path from 'node:path'; import type { + SpeedMetrics, TokenMetrics, TranscriptLine } from '../types'; @@ -10,6 +12,75 @@ import { readJsonlLines } from './jsonl-lines'; +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 SpeedRequest { + inputTokens: number; + outputTokens: number; + assistantTimestampMs: number | null; + interval: SpeedInterval | null; +} + +interface CollectedSpeedMetrics { + requests: SpeedRequest[]; + latestTimestampMs: number | null; +} + +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)) { @@ -129,4 +200,344 @@ export async function getTokenMetrics(transcriptPath: string): Promise 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 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 { + const requests: SpeedRequest[] = []; + + let lastUserTimestamp: Date | null = null; + let latestTimestampMs: number | 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 (entryTimestamp) { + const entryTimestampMs = entryTimestamp.getTime(); + if (latestTimestampMs === null || entryTimestampMs > latestTimestampMs) { + latestTimestampMs = entryTimestampMs; + } + } + + if (data.type === 'user' && entryTimestamp) { + lastUserTimestamp = entryTimestamp; + continue; + } + + if (data.type === 'assistant' && data.message?.usage) { + 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; + } + } + + 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, + 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 []; + } + + 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 getSpeedMetricsCollection( + transcriptPath: string, + 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 { + sessionAverage: createEmptySpeedMetrics(), + windowed: emptyWindowedMetrics + }; + } + + const mainLines = await readJsonlLines(transcriptPath); + 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 { + const subagentLines = await readJsonlLines(subagentPath); + return collectSpeedMetricsFromLines(subagentLines, false); + } catch { + return null; + } + })); + + for (const subagentMetrics of subagentMetricsResults) { + if (!subagentMetrics) { + continue; + } + + allCollected.push(subagentMetrics); + } + } + + const combined = mergeCollectedSpeedMetrics(allCollected); + const windowed: Record = {}; + for (const window of normalizedWindows) { + windowed[window.toString()] = buildSpeedMetrics(combined, window); + } + + return { + sessionAverage: buildSpeedMetrics(combined), + windowed + }; + } catch { + 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 39a04c8..9605f64 100644 --- a/src/utils/jsonl.ts +++ b/src/utils/jsonl.ts @@ -7,5 +7,7 @@ export { 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-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/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/utils/widget-manifest.ts b/src/utils/widget-manifest.ts index 96062d8..4345300 100644 --- a/src/utils/widget-manifest.ts +++ b/src/utils/widget-manifest.ts @@ -30,6 +30,9 @@ export const WIDGET_MANIFEST: WidgetManifestEntry[] = [ { type: 'tokens-output', create: () => new widgets.TokensOutputWidget() }, { type: 'tokens-cached', create: () => new widgets.TokensCachedWidget() }, { type: 'tokens-total', create: () => new widgets.TokensTotalWidget() }, + { type: 'input-speed', create: () => new widgets.InputSpeedWidget() }, + { type: 'output-speed', create: () => new widgets.OutputSpeedWidget() }, + { type: 'total-speed', create: () => new widgets.TotalSpeedWidget() }, { type: 'context-length', create: () => new widgets.ContextLengthWidget() }, { type: 'context-percentage', create: () => new widgets.ContextPercentageWidget() }, { type: 'context-percentage-usable', create: () => new widgets.ContextPercentageUsableWidget() }, diff --git a/src/widgets/InputSpeed.ts b/src/widgets/InputSpeed.ts new file mode 100644 index 0000000..4e3a3d9 --- /dev/null +++ b/src/widgets/InputSpeed.ts @@ -0,0 +1,44 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetEditorProps, + WidgetItem +} from '../types/Widget'; + +import { + getSpeedWidgetCustomKeybinds, + getSpeedWidgetDescription, + getSpeedWidgetDisplayName, + getSpeedWidgetEditorDisplay, + renderSpeedWidgetEditor, + renderSpeedWidgetValue +} from './shared/speed-widget'; + +export class InputSpeedWidget implements Widget { + getDefaultColor(): string { return 'cyan'; } + getDescription(): string { return getSpeedWidgetDescription('input'); } + getDisplayName(): string { return getSpeedWidgetDisplayName('input'); } + getCategory(): string { return 'Token Speed'; } + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + return getSpeedWidgetEditorDisplay('input', item); + } + + render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + void settings; + return renderSpeedWidgetValue('input', item, context); + } + + getCustomKeybinds(): CustomKeybind[] { + return getSpeedWidgetCustomKeybinds(); + } + + renderEditor(props: WidgetEditorProps) { + return renderSpeedWidgetEditor(props); + } + + 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..5ffa0aa --- /dev/null +++ b/src/widgets/OutputSpeed.ts @@ -0,0 +1,44 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetEditorProps, + WidgetItem +} from '../types/Widget'; + +import { + getSpeedWidgetCustomKeybinds, + getSpeedWidgetDescription, + getSpeedWidgetDisplayName, + getSpeedWidgetEditorDisplay, + renderSpeedWidgetEditor, + renderSpeedWidgetValue +} from './shared/speed-widget'; + +export class OutputSpeedWidget implements Widget { + getDefaultColor(): string { return 'cyan'; } + getDescription(): string { return getSpeedWidgetDescription('output'); } + getDisplayName(): string { return getSpeedWidgetDisplayName('output'); } + getCategory(): string { return 'Token Speed'; } + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + return getSpeedWidgetEditorDisplay('output', item); + } + + render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + void settings; + return renderSpeedWidgetValue('output', item, context); + } + + getCustomKeybinds(): CustomKeybind[] { + return getSpeedWidgetCustomKeybinds(); + } + + renderEditor(props: WidgetEditorProps) { + return renderSpeedWidgetEditor(props); + } + + 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..256b6cc --- /dev/null +++ b/src/widgets/TotalSpeed.ts @@ -0,0 +1,44 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetEditorProps, + WidgetItem +} from '../types/Widget'; + +import { + getSpeedWidgetCustomKeybinds, + getSpeedWidgetDescription, + getSpeedWidgetDisplayName, + getSpeedWidgetEditorDisplay, + renderSpeedWidgetEditor, + renderSpeedWidgetValue +} from './shared/speed-widget'; + +export class TotalSpeedWidget implements Widget { + getDefaultColor(): string { return 'cyan'; } + getDescription(): string { return getSpeedWidgetDescription('total'); } + getDisplayName(): string { return getSpeedWidgetDisplayName('total'); } + getCategory(): string { return 'Token Speed'; } + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + return getSpeedWidgetEditorDisplay('total', item); + } + + render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + void settings; + return renderSpeedWidgetValue('total', item, context); + } + + getCustomKeybinds(): CustomKeybind[] { + return getSpeedWidgetCustomKeybinds(); + } + + renderEditor(props: WidgetEditorProps) { + return renderSpeedWidgetEditor(props); + } + + 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..8533eba --- /dev/null +++ b/src/widgets/__tests__/SpeedWidgets.test.ts @@ -0,0 +1,153 @@ +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, + options: { + rawValue?: boolean; + metadata?: Record; + } = {} +): WidgetItem { + return { + id: type, + type, + rawValue: options.rawValue, + metadata: options.metadata + }; +} + +describe('OutputSpeedWidget', () => { + const widget = new OutputSpeedWidget(); + + it('should report Token Speed category', () => { + expect(widget.getCategory()).toBe('Token Speed'); + }); + + 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 render window preview when window metadata is enabled', () => { + const context: RenderContext = { isPreview: true }; + 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 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 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' } }); + + expect(widget.render(item, context, DEFAULT_SETTINGS)).toBe('Out: 120.0 t/s'); + }); +}); + +describe('InputSpeedWidget', () => { + const widget = new InputSpeedWidget(); + + it('should report Token Speed category', () => { + expect(widget.getCategory()).toBe('Token Speed'); + }); + + 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 render session preview by default and raw variant', () => { + const context: RenderContext = { isPreview: true }; + 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 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' } }); + + expect(widget.render(item, context, DEFAULT_SETTINGS)).toBe('In: 150.0 t/s'); + }); + + 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'); + }); +}); + +describe('TotalSpeedWidget', () => { + const widget = new TotalSpeedWidget(); + + it('should report Token Speed category', () => { + expect(widget.getCategory()).toBe('Token Speed'); + }); + + 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 render preview values', () => { + const context: RenderContext = { isPreview: true }; + 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 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' } }); + + expect(widget.render(item, context, DEFAULT_SETTINGS)).toBe('Total: 150.0 t/s'); + }); + + it('should return null when windowed metrics are missing for enabled window', () => { + const context: RenderContext = {}; + 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/index.ts b/src/widgets/index.ts index e0738ba..c6f8b8a 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -22,6 +22,9 @@ export { CustomCommandWidget } from './CustomCommand'; export { BlockTimerWidget } from './BlockTimer'; export { CurrentWorkingDirWidget } from './CurrentWorkingDir'; export { ClaudeSessionIdWidget } from './ClaudeSessionId'; +export { InputSpeedWidget } from './InputSpeed'; +export { OutputSpeedWidget } from './OutputSpeed'; +export { TotalSpeedWidget } from './TotalSpeed'; export { FreeMemoryWidget } from './FreeMemory'; export { SessionNameWidget } from './SessionName'; export { SessionUsageWidget } from './SessionUsage'; 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