Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 128 additions & 3 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type {
NutritionReport,
ProviderWarning,
RenderOptions,
ReplayReport,
TokenleakOutput,
ProviderData,
} from '@tokenleak/core';
Expand Down Expand Up @@ -61,6 +62,8 @@ import {
startLiveServer,
startWrappedLiveServer,
startReplayLiveServer,
type ReplayHeatmapEntry,
type ReplayLiveDataProvider,
colorize256,
bold256,
dim,
Expand All @@ -82,6 +85,7 @@ import {
import { TokenleakError, handleError } from './errors.js';
import { buildExplainHelpText, renderExplainTerminal } from './explain.js';
import { buildReplayHelpText, renderReplayTerminal } from './replay.js';
import { CAST_DEFAULT_SPEED, buildReplayCast } from './replay-cast.js';
import {
buildReceiptsHelpText,
collectEventsForReceipt,
Expand Down Expand Up @@ -1963,19 +1967,21 @@ function parseExplainArgs(argv: string[]): { date: string; cliArgs: Record<strin

export function parseReplayArgs(argv: string[]): { date: string; cliArgs: Record<string, unknown> } {
let date: string | null = null;
let dateExplicit = false;

if (argv.length > 0 && !argv[0]!.startsWith('-')) {
date = argv[0]!;
if (!isValidDateArgument(date)) {
throw new TokenleakError('tokenleak replay date must be in YYYY-MM-DD format');
}
dateExplicit = true;
}

if (date === null) {
date = getTodayLocal();
}

const cliArgs: Record<string, unknown> = {};
const cliArgs: Record<string, unknown> = { dateExplicit };
let index = argv[0]?.startsWith('-') ? 0 : 1;

while (index < argv.length) {
Expand Down Expand Up @@ -2059,6 +2065,11 @@ export function parseReplayArgs(argv: string[]): { date: string; cliArgs: Record
cliArgs['interactive'] = true;
index += 1;
break;
case '--noHeatmap':
case '--no-heatmap':
cliArgs['noHeatmap'] = true;
index += 1;
break;
case '--open':
cliArgs['open'] = true;
index += 1;
Expand All @@ -2078,6 +2089,31 @@ export function parseReplayArgs(argv: string[]): { date: string; cliArgs: Record
index += 2;
break;
}
case '--record':
case '--cast': {
const raw = argv[index + 1];
if (raw === undefined) {
throw new TokenleakError(`${arg} requires an output path`);
}
cliArgs['record'] = raw;
index += 2;
break;
}
case '--speed': {
const raw = argv[index + 1];
if (raw === undefined) {
throw new TokenleakError(`${arg} requires a value`);
}
const speed = Number(raw);
if (!Number.isFinite(speed) || speed <= 0 || speed > 10_000) {
throw new TokenleakError(
`--speed must be a positive number ≤ 10000 (got "${raw}")`,
);
}
cliArgs['speed'] = speed;
index += 2;
break;
}
default:
throw new TokenleakError(`Unknown replay flag "${arg}"`);
}
Expand Down Expand Up @@ -2328,6 +2364,27 @@ function runCommonsInspect(file: string): void {
process.stdout.write(`${renderCommonsInspect(report)}\n`);
}

/**
* Group all loaded provider events by date to produce the heatmap entries
* that drive the in-browser day-navigation strip.
*/
function buildReplayHeatmap(providers: ProviderData[]): ReplayHeatmapEntry[] {
const byDate = new Map<string, { tokens: number; cost: number; events: number }>();
for (const provider of providers) {
const events = provider.events ?? [];
for (const e of events) {
const cur = byDate.get(e.date) ?? { tokens: 0, cost: 0, events: 0 };
cur.tokens += e.totalTokens;
cur.cost += e.cost;
cur.events += 1;
byDate.set(e.date, cur);
}
}
return Array.from(byDate.entries())
.map(([date, v]) => ({ date, tokens: v.tokens, cost: v.cost, events: v.events }))
.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
}

function resolveReplayFormat(cliArgs: Record<string, unknown>): 'json' | 'terminal' {
if (typeof cliArgs['format'] === 'string') {
const format = cliArgs['format'];
Expand All @@ -2351,7 +2408,11 @@ function resolveReplayFormat(cliArgs: Record<string, unknown>): 'json' | 'termin
async function runReplay(date: string, cliArgs: Record<string, unknown>): Promise<void> {
const config = resolveConfig(cliArgs);
const interactive = cliArgs['interactive'] === true;
const format = interactive ? 'terminal' : resolveReplayFormat(cliArgs);
const recordPath = typeof cliArgs['record'] === 'string' ? cliArgs['record'] : null;
if (interactive && recordPath !== null) {
throw new TokenleakError('--interactive and --record are mutually exclusive');
}
const format = interactive || recordPath ? 'terminal' : resolveReplayFormat(cliArgs);

if (
config.allProviders &&
Expand All @@ -2376,11 +2437,34 @@ async function runReplay(date: string, cliArgs: Record<string, unknown>): Promis
emitProviderWarnings(replayOutput.providers, 'Warning');
const report = buildReplayReport(replayOutput.providers, date);

if (recordPath !== null) {
const ignored: string[] = [];
if (cliArgs['format']) ignored.push('--format');
if (cliArgs['output']) ignored.push('--output');
if (cliArgs['width']) ignored.push('--width');
if (cliArgs['port']) ignored.push('--port');
if (cliArgs['open']) ignored.push('--open');
if (ignored.length > 0) {
process.stderr.write(
`Warning: ${ignored.join(', ')} ignored when --record is set.\n`,
);
}
const speed = typeof cliArgs['speed'] === 'number' ? cliArgs['speed'] : CAST_DEFAULT_SPEED;
const cast = buildReplayCast(report, { speed });
writeFileSync(recordPath, cast);
const eventCount = report.events.length;
process.stderr.write(
`Wrote asciinema cast to ${recordPath} (${eventCount} frame${eventCount === 1 ? '' : 's'} at ${speed}× — play with: asciinema play ${recordPath})\n`,
);
return;
}

if (interactive) {
const ignored: string[] = [];
if (cliArgs['format']) ignored.push('--format');
if (cliArgs['output']) ignored.push('--output');
if (cliArgs['width']) ignored.push('--width');
if (cliArgs['speed']) ignored.push('--speed');
if (ignored.length > 0) {
process.stderr.write(
`Warning: ${ignored.join(', ')} ignored when --interactive is set.\n`,
Expand All @@ -2389,7 +2473,48 @@ async function runReplay(date: string, cliArgs: Record<string, unknown>): Promis

const rawPort = cliArgs['port'];
const port = typeof rawPort === 'number' && Number.isFinite(rawPort) ? rawPort : undefined;
const { port: actualPort, stop } = await startReplayLiveServer(report, port !== undefined ? { port } : {});
const noHeatmap = cliArgs['noHeatmap'] === true;

let serverArg: ReplayReport | ReplayLiveDataProvider = report;
if (!noHeatmap) {
// Load the last 90 days once. buildReplayReport filters by date, so
// navigating between days in the browser just calls it again with a
// different date — no re-load.
process.stderr.write('Loading 90 days of data for heatmap navigation...\n');
const heatmapRange = computeDateRange({ days: 90, until: date });
const heatmapOutput = await loadTokenleakData(available, heatmapRange);
const heatmapEntries = buildReplayHeatmap(heatmapOutput.providers);

// If the user didn't pass an explicit date, default the initial view
// to the most recent day with events instead of "today" — otherwise
// a quiet today renders 0 flow blocks even though plenty of usable
// data sits one cell to the left in the heatmap.
const dateExplicit = cliArgs['dateExplicit'] === true;
let initialDate = date;
if (!dateExplicit && heatmapEntries.length > 0) {
const latestActive = heatmapEntries
.filter((e) => e.events > 0)
.reduce<string | null>((acc, e) => (acc === null || e.date > acc ? e.date : acc), null);
if (latestActive !== null) {
initialDate = latestActive;
}
}
const initialReport = buildReplayReport(heatmapOutput.providers, initialDate);
serverArg = {
heatmap: heatmapEntries,
initialDate,
initialReport,
getReport: (d: string) => {
const reportForDay = buildReplayReport(heatmapOutput.providers, d);
return reportForDay.events.length > 0 ? reportForDay : null;
},
};
}

const { port: actualPort, stop } = await startReplayLiveServer(
serverArg,
port !== undefined ? { port } : {},
);

if (cliArgs['open'] === true) {
try {
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/receipts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ export function buildReceiptsHelpText(): string {
' --no-color Accepted for parity with terminal output',
' --help Show receipts help',
'',
'Note: prompt capture currently only works for Claude Code logs.',
'Note: prompt capture currently works for Claude Code and Codex logs when prompt text is present.',
'',
'Examples:',
' tokenleak receipts',
Expand Down
134 changes: 134 additions & 0 deletions packages/cli/src/replay-cast.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { describe, expect, it } from 'bun:test';
import type { ReplayReport, UsageEvent } from '@tokenleak/core';
import { CAST_DEFAULT_SPEED, buildReplayCast, computeReplayCastFrames } from './replay-cast';

function ev(timestamp: string, model: string, totalTokens: number, cost: number): UsageEvent {
return {
provider: 'codex',
timestamp,
date: timestamp.slice(0, 10),
model,
inputTokens: Math.round(totalTokens * 0.7),
outputTokens: Math.round(totalTokens * 0.2),
cacheReadTokens: Math.round(totalTokens * 0.08),
cacheWriteTokens: Math.round(totalTokens * 0.02),
totalTokens,
cost,
};
}

function makeReport(events: UsageEvent[] = []): ReplayReport {
const list = events.length > 0
? events
: [
ev('2026-04-22T09:30:00.000', 'claude-sonnet-4', 1_000, 0.01),
ev('2026-04-22T09:32:00.000', 'gpt-5.4', 2_000, 0.02),
ev('2026-04-22T14:00:00.000', 'claude-opus-4-7', 50_000, 2.5),
];
return {
date: '2026-04-22',
events: list,
flowBlocks: [
{
blockIndex: 0,
label: 'Deep Flow',
start: list[0].timestamp,
end: list[list.length - 1].timestamp,
durationMs: Date.parse(list[list.length - 1].timestamp) - Date.parse(list[0].timestamp),
eventCount: list.length,
inputTokens: list.reduce((s, e) => s + e.inputTokens, 0),
outputTokens: list.reduce((s, e) => s + e.outputTokens, 0),
cacheReadTokens: list.reduce((s, e) => s + e.cacheReadTokens, 0),
cacheWriteTokens: list.reduce((s, e) => s + e.cacheWriteTokens, 0),
totalTokens: list.reduce((s, e) => s + e.totalTokens, 0),
cost: list.reduce((s, e) => s + e.cost, 0),
dominantModel: list[0].model,
events: list,
modelSwitches: 0,
cacheHitRateTrend: [0.5, 0.7],
},
],
tokenVelocity: list.map((e) => ({ minute: e.timestamp, tokensPerMinute: e.totalTokens })),
summary: {
totalSessions: 1,
totalEvents: list.length,
flowTimeMs: Date.parse(list[list.length - 1].timestamp) - Date.parse(list[0].timestamp),
thinkTimeMs: 0,
flowThinkRatio: 1,
peakMinute: { minute: list[0].timestamp, tokensPerMinute: list[0].totalTokens },
},
};
}

describe('buildReplayCast', () => {
it('emits a v2 header on line 1', () => {
const cast = buildReplayCast(makeReport(), { nowSeconds: 1_700_000_000 });
const firstLine = cast.split('\n')[0];
const header = JSON.parse(firstLine);
expect(header.version).toBe(2);
expect(header.timestamp).toBe(1_700_000_000);
expect(header.title).toContain('2026-04-22');
expect(typeof header.width).toBe('number');
expect(typeof header.height).toBe('number');
});

it('emits one frame per event', () => {
const cast = buildReplayCast(makeReport());
const frameLines = cast.trim().split('\n').slice(1);
expect(frameLines.length).toBe(3);
});

it('frames are valid JSON arrays of [t, "o", data]', () => {
const cast = buildReplayCast(makeReport());
const frameLines = cast.trim().split('\n').slice(1);
for (const line of frameLines) {
const parsed = JSON.parse(line);
expect(Array.isArray(parsed)).toBe(true);
expect(parsed.length).toBe(3);
expect(typeof parsed[0]).toBe('number');
expect(parsed[1]).toBe('o');
expect(typeof parsed[2]).toBe('string');
}
});

it('frame timing scales with --speed (faster speed = earlier frame timestamps)', () => {
const slow = computeReplayCastFrames(makeReport(), { speed: 60, width: 100 });
const fast = computeReplayCastFrames(makeReport(), { speed: 600, width: 100 });
// Last frame at 600× should be 10× earlier than at 60×
expect(fast[fast.length - 1].t).toBeCloseTo(slow[slow.length - 1].t / 10, 5);
});

it('default speed is the documented constant', () => {
expect(CAST_DEFAULT_SPEED).toBe(240);
});

it('frames begin with a screen clear escape so each frame replaces the prior', () => {
const frames = computeReplayCastFrames(makeReport(), { speed: 240, width: 100 });
for (const frame of frames) {
expect(frame.data.startsWith('\x1b[2J\x1b[H')).toBe(true);
}
});

it('frames render the cumulative cost, the cursor event, and a model-mix bar', () => {
const frames = computeReplayCastFrames(makeReport(), { speed: 240, width: 100 });
const finalFrame = frames[frames.length - 1].data;
expect(finalFrame).toContain('cost:');
expect(finalFrame).toContain('event:');
expect(finalFrame).toContain('claude-opus-4-7');
expect(finalFrame).toContain('model mix');
// Total cost should appear in the final frame's stats line.
expect(finalFrame).toContain('$2.53');
});

it('handles empty days with a single placeholder frame', () => {
const empty = makeReport([]);
empty.events = [];
empty.flowBlocks = [];
empty.tokenVelocity = [];
empty.summary.totalEvents = 0;
const cast = buildReplayCast(empty);
const frameLines = cast.trim().split('\n').slice(1);
expect(frameLines.length).toBe(1);
expect(frameLines[0]).toContain('no events');
});
});
Loading
Loading