feat: Add token speed widgets (InputSpeed, OutputSpeed, TotalSpeed) (#141)

* 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

* Add widget-gated subagent speed aggregation with parallel reads

* Fix subagent speed parsing for session-directory transcript layout

* Consolidate speed windows into core speed widgets

* Bump to v2.2.0 and document new speed widgets

---------

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
This commit is contained in:
olion500andMatthew Breedlove authored and GitHub committed 2026-03-04 20:03:36 -05:00
1 parent 591cb60459
commit 7ca953400c
22 files changed
+1962 -3

No files matched your search

+8
View File
@@ -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.
+1 -1
View File
@@ -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",
+34 -1
View File
@@ -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<number>();
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<string, SpeedMetrics> | 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
+3
View File
@@ -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<string, SpeedMetrics> | null;
usageData?: RenderUsageData | null;
sessionDuration?: string | null;
blockMetrics?: BlockMetrics | null;
+20
View File
@@ -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;
}
+1
View File
@@ -10,6 +10,7 @@ export interface TranscriptLine {
isSidechain?: boolean;
timestamp?: string;
isApiErrorMessage?: boolean;
type?: 'user' | 'assistant' | 'system' | 'progress' | 'file-history-snapshot';
}
export interface TokenMetrics {
+2 -1
View File
@@ -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';
export type { BlockMetrics } from './BlockMetrics';
export type { SpeedMetrics } from './SpeedMetrics';
+740
View File
@@ -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
});
});
});
+74
View File
@@ -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> = {}): 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');
});
});
+55
View File
@@ -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<string, string>): 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);
});
});
+10
View File
@@ -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');
+411
View File
@@ -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<string, SpeedMetrics>;
}
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<string>) {
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<string> {
const agentIds = new Set<string>();
for (const line of lines) {
const data = parseJsonlLine(line);
if (!data) {
continue;
}
collectAgentIds(data, agentIds);
}
return agentIds;
}
export async function getSessionDuration(transcriptPath: string): Promise<string | null> {
try {
if (!fs.existsSync(transcriptPath)) {
@@ -129,4 +200,344 @@ export async function getTokenMetrics(transcriptPath: string): Promise<TokenMetr
} catch {
return { inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0, contextLength: 0 };
}
}
function parseTimestamp(value: string | undefined): Date | null {
if (!value) {
return null;
}
const timestamp = new Date(value);
return Number.isNaN(timestamp.getTime()) ? null : timestamp;
}
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 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<string, SpeedMetrics> {
const windowed: Record<string, SpeedMetrics> = {};
for (const window of windowSeconds) {
windowed[window.toString()] = createEmptySpeedMetrics();
}
return windowed;
}
function getSubagentTranscriptPaths(transcriptPath: string, referencedAgentIds: Set<string>): 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<string>();
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<SpeedMetricsCollection> {
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<string, SpeedMetrics> = {};
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<SpeedMetrics> {
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();
}
+2
View File
@@ -7,5 +7,7 @@ export {
export { getBlockMetrics } from './jsonl-blocks';
export {
getSessionDuration,
getSpeedMetrics,
getSpeedMetricsCollection,
getTokenMetrics
} from './jsonl-metrics';
+62
View File
@@ -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`;
}
+49
View File
@@ -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()
}
};
}
+3
View File
@@ -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() },
+44
View File
@@ -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; }
}
+44
View File
@@ -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; }
}
+44
View File
@@ -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; }
}
+153
View File
@@ -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> = {}): SpeedMetrics {
return {
totalDurationMs: 10000,
inputTokens: 1000,
outputTokens: 500,
totalTokens: 1500,
requestCount: 5,
...overrides
};
}
function createItem(
type: string,
options: {
rawValue?: boolean;
metadata?: Record<string, string>;
} = {}
): 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();
});
});
+3
View File
@@ -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';
+199
View File
@@ -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<SpeedWidgetKind, SpeedWidgetKindConfig> = {
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 <SpeedWindowEditor {...props} />;
}
const SpeedWindowEditor: React.FC<WidgetEditorProps> = ({ 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 <Text>Unknown editor mode</Text>;
}
return (
<Box flexDirection='column'>
<Box>
<Text>
Enter window in seconds (
{MIN_SPEED_WINDOW_SECONDS}
-
{MAX_SPEED_WINDOW_SECONDS}
):
{' '}
</Text>
<Text>{windowInput}</Text>
<Text backgroundColor='gray' color='black'>{' '}</Text>
</Box>
<Text dimColor>0 disables window mode and averages the full session. Press Enter to save, ESC to cancel.</Text>
</Box>
);
};