mirror of
https://github.com/tiennm99/ccstatusline.git
synced 2026-10-13 08:15:18 +00:00
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:
1 parent
591cb60459
commit
7ca953400c
22 files changed
+1962
-3
No files matched your search
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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';
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -7,5 +7,7 @@ export {
|
||||
export { getBlockMetrics } from './jsonl-blocks';
|
||||
export {
|
||||
getSessionDuration,
|
||||
getSpeedMetrics,
|
||||
getSpeedMetricsCollection,
|
||||
getTokenMetrics
|
||||
} from './jsonl-metrics';
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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() },
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user