diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 229d81d..3ca5d60 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -27,6 +27,7 @@ import type { NutritionReport, ProviderWarning, RenderOptions, + ReplayReport, TokenleakOutput, ProviderData, } from '@tokenleak/core'; @@ -61,6 +62,8 @@ import { startLiveServer, startWrappedLiveServer, startReplayLiveServer, + type ReplayHeatmapEntry, + type ReplayLiveDataProvider, colorize256, bold256, dim, @@ -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, @@ -1963,19 +1967,21 @@ function parseExplainArgs(argv: string[]): { date: string; cliArgs: Record } { 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 = {}; + const cliArgs: Record = { dateExplicit }; let index = argv[0]?.startsWith('-') ? 0 : 1; while (index < argv.length) { @@ -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; @@ -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}"`); } @@ -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(); + 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): 'json' | 'terminal' { if (typeof cliArgs['format'] === 'string') { const format = cliArgs['format']; @@ -2351,7 +2408,11 @@ function resolveReplayFormat(cliArgs: Record): 'json' | 'termin async function runReplay(date: string, cliArgs: Record): Promise { 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 && @@ -2376,11 +2437,34 @@ async function runReplay(date: string, cliArgs: Record): 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`, @@ -2389,7 +2473,48 @@ async function runReplay(date: string, cliArgs: Record): 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((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 { diff --git a/packages/cli/src/receipts.ts b/packages/cli/src/receipts.ts index 2406305..89ef3e3 100644 --- a/packages/cli/src/receipts.ts +++ b/packages/cli/src/receipts.ts @@ -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', diff --git a/packages/cli/src/replay-cast.test.ts b/packages/cli/src/replay-cast.test.ts new file mode 100644 index 0000000..02bb08b --- /dev/null +++ b/packages/cli/src/replay-cast.test.ts @@ -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'); + }); +}); diff --git a/packages/cli/src/replay-cast.ts b/packages/cli/src/replay-cast.ts new file mode 100644 index 0000000..18634aa --- /dev/null +++ b/packages/cli/src/replay-cast.ts @@ -0,0 +1,282 @@ +import type { ReplayReport, UsageEvent } from '@tokenleak/core'; + +/** + * Asciinema cast (v2) generator for replays. Renders the replay as a series + * of plain-text frames timed to match the requested speed; opening the file + * in `asciinema play` produces a cinematic playback of the day. + * + * Output is the canonical asciinema-rec format: + * line 1: a single JSON header ({"version": 2, ...}) + * line N: a JSON array per frame: [t_seconds, "o", ""] + * + * One frame per real event is emitted (rather than one per fixed wall-time + * tick) so that bursty days produce dense scrubs and idle stretches yield + * long natural pauses — exactly matching the lived experience of the day. + */ + +export const CAST_DEFAULT_WIDTH = 100; +export const CAST_DEFAULT_HEIGHT = 32; +export const CAST_DEFAULT_SPEED = 240; + +const HEATMAP_BLOCKS = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; +const HEATMAP_SLOTS = 48; + +export interface BuildReplayCastOptions { + speed?: number; + width?: number; + height?: number; + /** Override clock for header timestamp (used by tests). */ + nowSeconds?: number; +} + +export interface ReplayCastFrame { + /** Offset in seconds from cast start. */ + t: number; + /** Output bytes (terminal data). */ + data: string; +} + +export function buildReplayCast(report: ReplayReport, options: BuildReplayCastOptions = {}): string { + const speed = options.speed ?? CAST_DEFAULT_SPEED; + const width = options.width ?? CAST_DEFAULT_WIDTH; + const height = options.height ?? CAST_DEFAULT_HEIGHT; + const nowSeconds = Math.floor(options.nowSeconds ?? Date.now() / 1000); + + const header = { + version: 2, + width, + height, + timestamp: nowSeconds, + title: `tokenleak replay ${report.date}`, + env: { TERM: 'xterm-256color', SHELL: '/bin/bash' }, + }; + + const lines: string[] = [JSON.stringify(header)]; + + const frames = computeReplayCastFrames(report, { speed, width }); + for (const frame of frames) { + lines.push(JSON.stringify([Number(frame.t.toFixed(3)), 'o', frame.data])); + } + return lines.join('\n') + '\n'; +} + +export function computeReplayCastFrames( + report: ReplayReport, + opts: { speed: number; width: number }, +): ReplayCastFrame[] { + const events = report.events; + if (events.length === 0) { + return [ + { + t: 0, + data: '\x1b[2J\x1b[H' + renderEmptyFrame(report, opts.width), + }, + ]; + } + + const dayStart = Date.parse(events[0].timestamp); + const totalCost = events.reduce((s, e) => s + e.cost, 0); + const totalTokens = events.reduce((s, e) => s + e.totalTokens, 0); + + const frames: ReplayCastFrame[] = []; + let cumCost = 0; + let cumTokens = 0; + let cumInput = 0; + let cumCacheR = 0; + const modelMix = new Map(); + + for (let i = 0; i < events.length; i++) { + const e = events[i]; + cumCost += e.cost; + cumTokens += e.totalTokens; + cumInput += e.inputTokens; + cumCacheR += e.cacheReadTokens; + modelMix.set(e.model, (modelMix.get(e.model) ?? 0) + e.totalTokens); + + const t = (Date.parse(e.timestamp) - dayStart) / 1000 / opts.speed; + const frame = renderPlaybackFrame(report, { + cursorIndex: i, + totalCost, + totalTokens, + cumCost, + cumTokens, + cumInput, + cumCacheR, + modelMix, + width: opts.width, + speed: opts.speed, + }); + frames.push({ t, data: '\x1b[2J\x1b[H' + frame }); + } + return frames; +} + +interface FrameContext { + cursorIndex: number; + totalCost: number; + totalTokens: number; + cumCost: number; + cumTokens: number; + cumInput: number; + cumCacheR: number; + modelMix: Map; + width: number; + speed: number; +} + +function renderPlaybackFrame(report: ReplayReport, ctx: FrameContext): string { + const cursorEvent = report.events[ctx.cursorIndex]; + const lines: string[] = []; + + const title = `tokenleak replay · ${report.date}`; + const right = `[event ${ctx.cursorIndex + 1}/${report.events.length} · ${ctx.speed}×]`; + lines.push(padBetween(title, right, ctx.width)); + lines.push(''); + + // Stats block + const cacheRate = ctx.cumInput + ctx.cumCacheR > 0 + ? ctx.cumCacheR / (ctx.cumInput + ctx.cumCacheR) + : 0; + const costLine = `cost: ${formatCost(ctx.cumCost)} / ${formatCost(ctx.totalCost)} tokens: ${formatTokens(ctx.cumTokens)} / ${formatTokens(ctx.totalTokens)} cache ${formatPercent(cacheRate)}`; + lines.push(costLine); + const cacheRateOnEvent = cursorEvent.inputTokens + cursorEvent.cacheReadTokens > 0 + ? cursorEvent.cacheReadTokens / (cursorEvent.inputTokens + cursorEvent.cacheReadTokens) + : 0; + const eventLine = `clock: ${formatTimeSeconds(cursorEvent.timestamp)} event: ${cursorEvent.model} · ${formatTokens(cursorEvent.totalTokens)} tok · cache ${formatPercent(cacheRateOnEvent)} · ${formatCost(cursorEvent.cost)}`; + lines.push(eventLine); + lines.push(''); + + // Activity bar with playhead + const activity = renderActivityWithPlayhead(report, cursorEvent); + lines.push('activity:'); + lines.push(' ' + activity.bar); + lines.push(' ' + activity.playhead); + lines.push(' ' + activity.axis); + lines.push(''); + + // Active block + const activeBlock = findActiveBlock(report, cursorEvent); + if (activeBlock) { + const cursorTs = Date.parse(cursorEvent.timestamp); + const eventsBefore = activeBlock.events.filter((e) => Date.parse(e.timestamp) <= cursorTs).length; + lines.push(`active block: ${activeBlock.label.toLowerCase()} · ${formatTime(activeBlock.start)} → ${formatTime(activeBlock.end)} (${eventsBefore}/${activeBlock.eventCount} events)`); + lines.push(` ${activeBlock.dominantModel} · cache hit-rate trend ${formatTrend(activeBlock.cacheHitRateTrend)} · block cost ${formatCost(activeBlock.cost)}`); + } else { + lines.push('active block: idle · between blocks'); + } + lines.push(''); + + // Events near cursor + lines.push('events near cursor:'); + const start = Math.max(0, ctx.cursorIndex - 2); + const end = Math.min(report.events.length, ctx.cursorIndex + 3); + for (let i = start; i < end; i++) { + const e = report.events[i]; + const marker = i === ctx.cursorIndex ? '▶' : ' '; + const cr = e.inputTokens + e.cacheReadTokens > 0 ? e.cacheReadTokens / (e.inputTokens + e.cacheReadTokens) : 0; + lines.push(` ${marker} ${formatTimeSeconds(e.timestamp).padEnd(9)} ${truncate(e.model, 22).padEnd(22)} ${formatTokens(e.totalTokens).padStart(8)} tok cache ${formatPercent(cr).padStart(5)} ${formatCost(e.cost).padStart(8)}`); + } + lines.push(''); + + // Model mix bar chart + lines.push('model mix (cumulative):'); + const totalMix = Array.from(ctx.modelMix.values()).reduce((s, v) => s + v, 0) || 1; + const sortedMix = Array.from(ctx.modelMix.entries()).sort((a, b) => b[1] - a[1]).slice(0, 5); + const barColumnWidth = 24; + for (const [model, tokens] of sortedMix) { + const pct = tokens / totalMix; + const filled = Math.max(1, Math.round(pct * barColumnWidth)); + const bar = '█'.repeat(filled) + ' '.repeat(barColumnWidth - filled); + lines.push(` ${truncate(model, 18).padEnd(18)} ${bar} ${(pct * 100).toFixed(0).padStart(3)}%`); + } + + return lines.map((l) => l.padEnd(ctx.width).slice(0, ctx.width)).join('\r\n'); +} + +function renderActivityWithPlayhead(report: ReplayReport, cursorEvent: UsageEvent) { + const slotTokens = new Array(HEATMAP_SLOTS).fill(0); + for (const e of report.events) { + const d = new Date(e.timestamp); + const slot = Math.min(d.getHours() * 2 + Math.floor(d.getMinutes() / 30), HEATMAP_SLOTS - 1); + slotTokens[slot] += e.totalTokens; + } + const max = Math.max(...slotTokens); + let bar = ''; + for (let i = 0; i < HEATMAP_SLOTS; i++) { + const level = max > 0 ? Math.round((slotTokens[i] / max) * (HEATMAP_BLOCKS.length - 1)) : 0; + bar += HEATMAP_BLOCKS[level]; + } + const cursorD = new Date(cursorEvent.timestamp); + const cursorSlot = Math.min(cursorD.getHours() * 2 + Math.floor(cursorD.getMinutes() / 30), HEATMAP_SLOTS - 1); + const playhead = ' '.repeat(cursorSlot) + '▼'; + const firstTime = formatTime(report.events[0].timestamp); + const lastTime = formatTime(report.events[report.events.length - 1].timestamp); + const axis = `${firstTime}${' '.repeat(Math.max(1, HEATMAP_SLOTS - firstTime.length - lastTime.length))}${lastTime}`; + return { bar, playhead, axis }; +} + +function findActiveBlock(report: ReplayReport, cursorEvent: UsageEvent) { + const ts = Date.parse(cursorEvent.timestamp); + for (const b of report.flowBlocks) { + if (ts >= Date.parse(b.start) && ts <= Date.parse(b.end)) return b; + } + return null; +} + +function renderEmptyFrame(report: ReplayReport, width: number): string { + const header = `tokenleak replay · ${report.date}`; + return [ + header.padEnd(width).slice(0, width), + ''.padEnd(width), + `(no events on ${report.date})`.padEnd(width).slice(0, width), + ].join('\r\n'); +} + +// ── Formatting helpers (local copies; kept simple to avoid TUI dep here) ──── + +function formatCost(n: number): string { + return `$${n.toFixed(2)}`; +} + +function formatTokens(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; + return `${Math.round(n)}`; +} + +function formatPercent(n: number): string { + return `${Math.round(n * 100)}%`; +} + +function formatTime(iso: string): string { + const d = new Date(iso); + return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; +} + +function formatTimeSeconds(iso: string): string { + const d = new Date(iso); + return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`; +} + +function formatTrend(trend: number[]): string { + if (trend.length === 0) return '—'; + if (trend.length === 1) return formatPercent(trend[0]); + const first = formatPercent(trend[0]); + const last = formatPercent(trend[trend.length - 1]); + const direction = trend[trend.length - 1] > trend[0] + 0.05 ? '↑' : trend[trend.length - 1] < trend[0] - 0.05 ? '↓' : '→'; + return `${first} ${direction} ${last}`; +} + +function truncate(s: string, max: number): string { + if (s.length <= max) return s; + if (max <= 1) return ''; + return s.slice(0, max - 1) + '…'; +} + +function padBetween(left: string, right: string, width: number): string { + if (left.length + right.length + 1 >= width) { + return (left + ' ' + right).padEnd(width).slice(0, width); + } + const pad = width - left.length - right.length; + return left + ' '.repeat(pad) + right; +} diff --git a/packages/cli/src/replay-cli.test.ts b/packages/cli/src/replay-cli.test.ts index 00cc503..10448b6 100644 --- a/packages/cli/src/replay-cli.test.ts +++ b/packages/cli/src/replay-cli.test.ts @@ -6,7 +6,12 @@ describe('parseReplayArgs', () => { it('defaults date to today when no positional argument is given', () => { const { date, cliArgs } = parseReplayArgs([]); expect(date).toMatch(/^\d{4}-\d{2}-\d{2}$/); - expect(cliArgs).toEqual({}); + expect(cliArgs).toEqual({ dateExplicit: false }); + }); + + it('flags explicit positional dates so the interactive view can pin to them', () => { + const { cliArgs } = parseReplayArgs(['2026-04-22']); + expect(cliArgs['dateExplicit']).toBe(true); }); it('parses a YYYY-MM-DD positional date', () => { @@ -40,6 +45,29 @@ describe('parseReplayArgs', () => { expect(() => parseReplayArgs(['--bogus'])).toThrow(TokenleakError); }); + describe('--record / --cast / --speed', () => { + it('parses --record and --cast as the same flag', () => { + expect(parseReplayArgs(['--record', 'day.cast']).cliArgs['record']).toBe('day.cast'); + expect(parseReplayArgs(['--cast', 'day.cast']).cliArgs['record']).toBe('day.cast'); + }); + + it('--record requires an output path', () => { + expect(() => parseReplayArgs(['--record'])).toThrow(TokenleakError); + }); + + it('parses --speed as a positive number', () => { + expect(parseReplayArgs(['--speed', '600']).cliArgs['speed']).toBe(600); + expect(parseReplayArgs(['--speed', '0.5']).cliArgs['speed']).toBe(0.5); + }); + + it('rejects non-positive or out-of-range speeds', () => { + expect(() => parseReplayArgs(['--speed', '0'])).toThrow(TokenleakError); + expect(() => parseReplayArgs(['--speed', '-1'])).toThrow(TokenleakError); + expect(() => parseReplayArgs(['--speed', 'abc'])).toThrow(TokenleakError); + expect(() => parseReplayArgs(['--speed', '20000'])).toThrow(TokenleakError); + }); + }); + describe('--port validation', () => { it('rejects non-numeric values instead of silently defaulting', () => { expect(() => parseReplayArgs(['--port', 'abc'])).toThrow(TokenleakError); diff --git a/packages/cli/src/replay.ts b/packages/cli/src/replay.ts index aef61df..affab07 100644 --- a/packages/cli/src/replay.ts +++ b/packages/cli/src/replay.ts @@ -200,8 +200,11 @@ export function buildReplayHelpText(): string { ' --all-providers Ignore provider filters and use every available provider', ' --no-color Accepted for parity with terminal output', ' -i, --interactive Open the replay in a browser scrub UI on http://localhost:3567', + ' --no-heatmap Disable the 90-day heatmap day-navigation strip in --interactive', ' --port Override the starting port for --interactive (default 3567)', ' --open When combined with --interactive, auto-open the browser', + ' --record Render the day as an asciinema v2 cast file (alias --cast)', + ' --speed Playback speed multiplier for --record (default 240)', ' --help Show replay help', '', 'Examples:', @@ -211,6 +214,8 @@ export function buildReplayHelpText(): string { ' tokenleak replay --provider claude --output replay.json', ' tokenleak replay 2026-03-10 --interactive', ' tokenleak replay --interactive --open', + ' tokenleak replay 2026-03-10 --record day.cast', + ' tokenleak replay 2026-03-10 --record day.cast --speed 600', '', ].join('\n'); } diff --git a/packages/mcp/src/tools/get-receipt-lines.ts b/packages/mcp/src/tools/get-receipt-lines.ts index 1c45300..946de1d 100644 --- a/packages/mcp/src/tools/get-receipt-lines.ts +++ b/packages/mcp/src/tools/get-receipt-lines.ts @@ -47,7 +47,7 @@ export async function handleGetReceiptLines( warnings, note: events.length > 0 && receipt.lines.length === 0 - ? 'No events carried captured prompts. Prompt capture currently only works for Claude Code logs.' + ? 'No events carried captured prompts. Prompt capture currently works for Claude Code and Codex logs when prompt text is present.' : undefined, }, null, diff --git a/packages/mcp/src/tools/index.ts b/packages/mcp/src/tools/index.ts index 24380c0..baafa63 100644 --- a/packages/mcp/src/tools/index.ts +++ b/packages/mcp/src/tools/index.ts @@ -90,7 +90,7 @@ export function registerTools(server: McpServer, registry: ProviderRegistry): vo server.tool( 'get_receipt_lines', - 'Generate an itemized receipt of AI coding spend by prompt behavior. Clusters repeated prompts into line items with categories (debugging, styling, refactoring, etc.) and aggregates cost per cluster. Prompt capture currently requires Claude Code logs.', + 'Generate an itemized receipt of AI coding spend by prompt behavior. Clusters repeated prompts into line items with categories (debugging, styling, refactoring, etc.) and aggregates cost per cluster. Prompt capture currently uses Claude Code and Codex logs when prompt text is present.', { days: z.number().optional().describe('Number of days to look back (default: 30)'), since: z.string().optional().describe('Start date in YYYY-MM-DD format'), diff --git a/packages/registry/src/__fixtures__/codex-current/sessions/2026/03/12/session-current.jsonl b/packages/registry/src/__fixtures__/codex-current/sessions/2026/03/12/session-current.jsonl index 0d2a3c8..a52618a 100644 --- a/packages/registry/src/__fixtures__/codex-current/sessions/2026/03/12/session-current.jsonl +++ b/packages/registry/src/__fixtures__/codex-current/sessions/2026/03/12/session-current.jsonl @@ -1,4 +1,6 @@ {"timestamp":"2026-03-12T10:00:00Z","type":"session_meta","payload":{"id":"sess-current","model_provider":"openai"}} {"timestamp":"2026-03-12T10:00:01Z","type":"turn_context","payload":{"turn_id":"turn-current","model":"gpt-5.4"}} +{"timestamp":"2026-03-12T10:02:00Z","type":"event_msg","payload":{"type":"user_message","message":"implement replay prompt capture for Codex","images":[],"local_images":[],"text_elements":[]}} {"timestamp":"2026-03-12T10:05:00Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":1000,"cached_input_tokens":200,"output_tokens":150,"total_tokens":1150},"total_token_usage":{"input_tokens":1000,"cached_input_tokens":200,"output_tokens":150,"total_tokens":1150}}}} +{"timestamp":"2026-03-12T10:12:00Z","type":"event_msg","payload":{"type":"user_message","message":"show me the latest replay token delta","images":[],"local_images":[],"text_elements":[]}} {"timestamp":"2026-03-12T10:15:00Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":2500,"cached_input_tokens":700,"output_tokens":380,"total_tokens":2880}}}} diff --git a/packages/registry/src/providers/codex.test.ts b/packages/registry/src/providers/codex.test.ts index 816933a..41ce901 100644 --- a/packages/registry/src/providers/codex.test.ts +++ b/packages/registry/src/providers/codex.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect } from 'bun:test'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { DateRange } from '@tokenleak/core'; import { CodexProvider } from './codex'; @@ -97,6 +99,10 @@ describe('CodexProvider', () => { expect(data.daily[0]!.totalTokens).toBe(2880); expect(data.totalTokens).toBe(2880); expect(data.totalCost).toBeCloseTo(0.010375, 8); + expect(data.events?.map((event) => event.prompt)).toEqual([ + 'implement replay prompt capture for Codex', + 'show me the latest replay token delta', + ]); expect(data.costCompleteness).toMatchObject({ status: 'complete', totalTokens: 2880, @@ -109,6 +115,47 @@ describe('CodexProvider', () => { ); }); + it('truncates captured prompts before attaching them to usage events', async () => { + const sessionsDir = mkdtempSync(join(tmpdir(), 'tokenleak-codex-')); + try { + const dayDir = join(sessionsDir, '2026', '03', '12'); + mkdirSync(dayDir, { recursive: true }); + const longPrompt = 'x'.repeat(2_500); + const records = [ + { timestamp: '2026-03-12T10:00:01Z', type: 'turn_context', payload: { model: 'gpt-5.4' } }, + { + timestamp: '2026-03-12T10:02:00Z', + type: 'event_msg', + payload: { type: 'user_message', message: longPrompt, images: [], local_images: [], text_elements: [] }, + }, + { + timestamp: '2026-03-12T10:05:00Z', + type: 'event_msg', + payload: { + type: 'token_count', + info: { + last_token_usage: { + input_tokens: 1000, + cached_input_tokens: 200, + output_tokens: 150, + total_tokens: 1150, + }, + }, + }, + }, + ]; + writeFileSync(join(dayDir, 'session-long.jsonl'), records.map((r) => JSON.stringify(r)).join('\n')); + + const provider = new CodexProvider(sessionsDir); + const data = await provider.load(CURRENT_RANGE); + + expect(data.events).toHaveLength(1); + expect(data.events?.[0]?.prompt).toBe('x'.repeat(2_000)); + } finally { + rmSync(sessionsDir, { recursive: true, force: true }); + } + }); + // -- load: empty directory ---------------------------------------------- it('returns empty data when directory has no JSONL files', async () => { diff --git a/packages/registry/src/providers/codex.ts b/packages/registry/src/providers/codex.ts index 0b29a4b..d4cda9b 100644 --- a/packages/registry/src/providers/codex.ts +++ b/packages/registry/src/providers/codex.ts @@ -54,6 +54,7 @@ interface CodexUsageRecord { outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number; + prompt?: string; sessionId?: string; projectId?: string; } @@ -66,8 +67,11 @@ interface SessionContext { outputTokens: number; cachedInputTokens: number; } | null; + lastUserPrompt?: string; } +const MAX_PROMPT_CHARS = 2_000; + /** * Narrows an unknown parsed JSONL record to a CodexResponseEvent, * returning `null` if the record doesn't match the expected shape. @@ -222,6 +226,70 @@ function inferProjectIdFromContext(record: unknown): string | null { return typeof cwd === 'string' && cwd.trim() ? cwd.trim() : null; } +function extractTextElementText(value: unknown): string | null { + if (typeof value === 'string') { + return value; + } + if (typeof value !== 'object' || value === null) { + return null; + } + + const obj = value as Record; + for (const key of ['text', 'content', 'message'] as const) { + if (typeof obj[key] === 'string') { + return obj[key]; + } + } + + return null; +} + +function normalizePromptText(text: string): string | null { + const trimmed = text.replace(/\s+$/g, '').trimStart(); + if (trimmed.length === 0) { + return null; + } + return trimmed.length > MAX_PROMPT_CHARS ? trimmed.slice(0, MAX_PROMPT_CHARS) : trimmed; +} + +function extractUserPrompt(record: unknown): string | null { + if (typeof record !== 'object' || record === null) { + return null; + } + + const obj = record as Record; + if (obj['type'] !== 'event_msg') { + return null; + } + + const payload = obj['payload']; + if (typeof payload !== 'object' || payload === null) { + return null; + } + + const eventPayload = payload as Record; + if (eventPayload['type'] !== 'user_message') { + return null; + } + + if (typeof eventPayload['message'] === 'string' && eventPayload['message'].trim().length > 0) { + return normalizePromptText(eventPayload['message']); + } + + const parts: string[] = []; + const textElements = eventPayload['text_elements']; + if (Array.isArray(textElements)) { + for (const element of textElements) { + const text = extractTextElementText(element); + if (text) { + parts.push(text); + } + } + } + + return normalizePromptText(parts.join('\n\n')); +} + function parseTokenCountUsage(record: unknown, context: SessionContext): CodexUsageRecord | null { if (typeof record !== 'object' || record === null) { return null; @@ -314,6 +382,7 @@ function parseTokenCountUsage(record: unknown, context: SessionContext): CodexUs outputTokens: usage.outputTokens, cacheReadTokens, cacheWriteTokens: 0, + prompt: context.lastUserPrompt, }; } @@ -332,6 +401,12 @@ function parseUsageRecord(record: unknown, context: SessionContext): CodexUsageR return null; } + const userPrompt = extractUserPrompt(record); + if (userPrompt) { + context.lastUserPrompt = userPrompt; + return null; + } + const tokenCountUsage = parseTokenCountUsage(record, context); if (tokenCountUsage) { return tokenCountUsage; @@ -355,6 +430,7 @@ function parseUsageRecord(record: unknown, context: SessionContext): CodexUsageR outputTokens: legacyEvent.usage.output_tokens, cacheReadTokens: 0, cacheWriteTokens: 0, + prompt: context.lastUserPrompt, }; } @@ -396,6 +472,7 @@ export class CodexProvider implements IProvider { model: 'gpt-5', projectId: undefined, previousTotals: null, + lastUserPrompt: undefined, }; const relativeFile = relative(this.sessionsDir, file).split(sep).join('/'); const projectDir = relative(this.sessionsDir, dirname(file)).split(sep).join('/'); @@ -445,6 +522,7 @@ export class CodexProvider implements IProvider { unpricedTokens: cost.unpricedTokens, sessionId: usage.sessionId, projectId: usage.projectId, + prompt: usage.prompt, }); } } catch { diff --git a/packages/renderers/src/index.ts b/packages/renderers/src/index.ts index fa15ae0..a61cfe9 100644 --- a/packages/renderers/src/index.ts +++ b/packages/renderers/src/index.ts @@ -9,7 +9,11 @@ export type { LiveServerOptions } from './live/live-server'; export { startWrappedLiveServer } from './live/wrapped-live-server'; export type { WrappedLiveServerOptions } from './live/wrapped-live-server'; export { startReplayLiveServer } from './live/replay-live-server'; -export type { ReplayLiveServerOptions } from './live/replay-live-server'; +export type { + ReplayLiveServerOptions, + ReplayLiveDataProvider, + ReplayHeatmapEntry, +} from './live/replay-live-server'; export { generateReplayLiveHtml } from './live/replay-live-template'; export { renderTabBar, diff --git a/packages/renderers/src/live/__tests__/replay-live-server.test.ts b/packages/renderers/src/live/__tests__/replay-live-server.test.ts index 19a35c3..812f9a9 100644 --- a/packages/renderers/src/live/__tests__/replay-live-server.test.ts +++ b/packages/renderers/src/live/__tests__/replay-live-server.test.ts @@ -105,12 +105,92 @@ describe('generateReplayLiveHtml', () => { expect(html).toContain('id="blockCard"'); }); + it('renders the prompt-detail card so users can see what was asked', () => { + const html = generateReplayLiveHtml(makeReport()); + expect(html).toContain('id="promptCard"'); + expect(html).toContain('id="promptMeta"'); + expect(html).toContain('id="promptBody"'); + expect(html).toContain('// prompt sent to model'); + expect(html).toContain("this provider doesn't capture prompt text for this event"); + expect(html).not.toContain('only Claude Code stores prompts'); + }); + + it('embeds prompt text from events into window.__REPLAY__', () => { + const baseEvent = makeEvent({ + timestamp: '2026-04-26T09:00:00.000Z', + model: 'claude-sonnet-4', + }); + const events: UsageEvent[] = [ + { ...baseEvent, prompt: 'refactor the date-loader to support streaming inputs' }, + ]; + const report: ReplayReport = { + date: '2026-04-26', + events, + flowBlocks: [makeBlock({ blockIndex: 0, start: events[0].timestamp, end: events[0].timestamp, events })], + tokenVelocity: [], + summary: { + totalSessions: 1, + totalEvents: 1, + flowTimeMs: 0, + thinkTimeMs: 0, + flowThinkRatio: 0, + peakMinute: null, + }, + }; + const html = generateReplayLiveHtml(report); + expect(html).toContain('refactor the date-loader to support streaming inputs'); + }); + it('shows the date in the page title and header', () => { const html = generateReplayLiveHtml(makeReport()); expect(html).toContain('tokenleak replay · 2026-04-26'); expect(html).toContain('April 26, 2026'); }); + it('computes heatmap stats from the rendered 91-day window only', () => { + const html = generateReplayLiveHtml(makeReport(), { + initialDate: '2099-04-01', + heatmap: [ + { date: '2098-01-01', tokens: 1_000_000, cost: 100, events: 1 }, + { date: '2099-04-01', tokens: 1_000, cost: 2, events: 1 }, + ], + }); + + expect(html).toContain('1 active days'); + expect(html).toContain('1.0K tok'); + expect(html).toContain('$2.00'); + expect(html).not.toContain('1.0M tok'); + expect(html).not.toContain('$102.00'); + }); + + it('anchors the heatmap to old replay data instead of wall-clock today', () => { + const html = generateReplayLiveHtml(makeReport(), { + initialDate: '2000-01-01', + heatmap: [ + { date: '2000-01-01', tokens: 1_000, cost: 1, events: 1 }, + ], + }); + + expect(html).toContain('href="/?date=2000-01-01"'); + }); + + it('uses enough columns when the heatmap starts mid-week', () => { + const html = generateReplayLiveHtml(makeReport(), { + initialDate: '2099-04-01', + heatmap: [ + { date: '2099-04-01', tokens: 1_000, cost: 1, events: 1 }, + ], + }); + + expect(html).toContain('grid-template-columns:repeat(14, 14px)'); + }); + + it('does not render an empty heatmap section', () => { + const html = generateReplayLiveHtml(makeReport(), { heatmap: [] }); + + expect(html).not.toContain('
{ const empty: ReplayReport = { date: '2026-04-26', @@ -207,3 +287,169 @@ describe('startReplayLiveServer', () => { expect(b.port).toBeGreaterThan(a.port); }); }); + +describe('startReplayLiveServer (multi-day mode)', () => { + const cleanups: Array<() => void> = []; + + afterEach(() => { + while (cleanups.length > 0) { + const stop = cleanups.pop(); + try { stop?.(); } catch { /* noop */ } + } + }); + + function makeProvider() { + const initial = makeReport(); + const otherDate = '2026-04-21'; + const otherEvents = initial.events.map((e) => ({ + ...e, + date: otherDate, + timestamp: e.timestamp.replace('2026-04-26', otherDate), + })); + const otherReport: ReplayReport = { + ...initial, + date: otherDate, + events: otherEvents, + flowBlocks: initial.flowBlocks.map((b, blockIndex) => ({ + ...b, + blockIndex, + start: b.start.replace('2026-04-26', otherDate), + end: b.end.replace('2026-04-26', otherDate), + events: b.events.map((e) => ({ + ...e, + date: otherDate, + timestamp: e.timestamp.replace('2026-04-26', otherDate), + })), + })), + tokenVelocity: initial.tokenVelocity.map((v) => ({ + ...v, + minute: v.minute.replace('2026-04-26', otherDate), + })), + summary: { + ...initial.summary, + peakMinute: initial.summary.peakMinute + ? { + ...initial.summary.peakMinute, + minute: initial.summary.peakMinute.minute.replace('2026-04-26', otherDate), + } + : null, + }, + }; + return { + heatmap: [ + { date: '2026-04-21', tokens: 7_200, cost: 0.04, events: 4 }, + { date: '2026-04-26', tokens: 7_200, cost: 0.04, events: 4 }, + ], + initialDate: '2026-04-26', + initialReport: initial, + otherReport, + }; + } + + it('renders the heatmap on GET /', async () => { + const p = makeProvider(); + const { port, stop } = await startReplayLiveServer({ + heatmap: p.heatmap, + initialDate: p.initialDate, + initialReport: p.initialReport, + getReport: async (d) => (d === p.otherReport.date ? p.otherReport : null), + }, { port: 0 }); + cleanups.push(stop); + const res = await fetch(`http://localhost:${port}/`); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain('class="heatmap-grid"'); + expect(html).toContain('hm-cell--active'); + expect(html).toContain('href="/?date=2026-04-21"'); + }); + + it('serves a different day for /?date=YYYY-MM-DD via getReport', async () => { + const p = makeProvider(); + let lookedUp: string | null = null; + const { port, stop } = await startReplayLiveServer({ + heatmap: p.heatmap, + initialDate: p.initialDate, + initialReport: p.initialReport, + getReport: async (d) => { + lookedUp = d; + return d === p.otherReport.date ? p.otherReport : null; + }, + }, { port: 0 }); + cleanups.push(stop); + const res = await fetch(`http://localhost:${port}/?date=2026-04-21`); + expect(res.status).toBe(200); + expect(lookedUp).toBe('2026-04-21'); + const html = await res.text(); + expect(html).toContain('window.__REPLAY__'); + expect(html).toContain('"date":"2026-04-21"'); + }); + + it('falls back to an empty report (not 404) for an unknown day on GET /', async () => { + const p = makeProvider(); + const { port, stop } = await startReplayLiveServer({ + heatmap: p.heatmap, + initialDate: p.initialDate, + initialReport: p.initialReport, + getReport: async () => null, + }, { port: 0 }); + cleanups.push(stop); + const res = await fetch(`http://localhost:${port}/?date=2025-01-01`); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain('nothing happened on 2025-01-01'); + }); + + it('GET /api/replay?date=X returns 200 + JSON for a known day', async () => { + const p = makeProvider(); + const { port, stop } = await startReplayLiveServer({ + heatmap: p.heatmap, + initialDate: p.initialDate, + initialReport: p.initialReport, + getReport: async (d) => (d === p.otherReport.date ? p.otherReport : null), + }, { port: 0 }); + cleanups.push(stop); + const res = await fetch(`http://localhost:${port}/api/replay?date=2026-04-21`); + expect(res.status).toBe(200); + expect(res.headers.get('content-type') ?? '').toContain('application/json'); + const body = await res.json() as ReplayReport; + expect(body.date).toBe('2026-04-21'); + }); + + it('GET /api/replay?date=invalid returns 400', async () => { + const p = makeProvider(); + const { port, stop } = await startReplayLiveServer({ + heatmap: p.heatmap, + initialDate: p.initialDate, + initialReport: p.initialReport, + getReport: async () => null, + }, { port: 0 }); + cleanups.push(stop); + const res = await fetch(`http://localhost:${port}/api/replay?date=foo`); + expect(res.status).toBe(400); + }); + + it('GET /api/replay?date=missing returns 404', async () => { + const p = makeProvider(); + const { port, stop } = await startReplayLiveServer({ + heatmap: p.heatmap, + initialDate: p.initialDate, + initialReport: p.initialReport, + getReport: async () => null, + }, { port: 0 }); + cleanups.push(stop); + const res = await fetch(`http://localhost:${port}/api/replay?date=2024-12-31`); + expect(res.status).toBe(404); + }); + + it('single-day mode (passing a ReplayReport) does NOT render the heatmap', async () => { + const { port, stop } = await startReplayLiveServer(makeReport(), { port: 0 }); + cleanups.push(stop); + const res = await fetch(`http://localhost:${port}/`); + const html = await res.text(); + // Structural element: only emitted when the heatmap section is rendered. + // (The CSS class names live in the inlined stylesheet either way, so we + // can't grep on `hm-cell--active` to detect single- vs multi-day mode.) + expect(html).not.toContain('
Promise | ReplayReport | null; +} + +export interface ReplayHeatmapEntry { + date: string; // YYYY-MM-DD + tokens: number; + cost: number; + events: number; +} + +function isProvider(arg: ReplayReport | ReplayLiveDataProvider): arg is ReplayLiveDataProvider { + return (arg as ReplayLiveDataProvider).getReport !== undefined; +} + +const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/; + +function makeEmptyReport(date: string): ReplayReport { + return { + date, + events: [], + flowBlocks: [], + tokenVelocity: [], + summary: { + totalSessions: 0, + totalEvents: 0, + flowTimeMs: 0, + thinkTimeMs: 0, + flowThinkRatio: 0, + peakMinute: null, + }, + }; } function tryServe( - html: string, + buildHandler: () => (req: Request) => Response | Promise, port: number, ): { server: ReturnType; error: null } | { server: null; error: unknown } { try { - const server = Bun.serve({ - port, - fetch(_req: Request): Response { - return new Response(html, { - headers: { 'Content-Type': 'text/html; charset=utf-8' }, - }); - }, - }); + const server = Bun.serve({ port, fetch: buildHandler() }); return { server, error: null }; } catch (err: unknown) { return { server: null, error: err }; @@ -39,24 +85,83 @@ function isAddrInUse(err: unknown): boolean { /** * Start a local HTTP server that renders the interactive replay UI. * Finds a free port starting from the given port (default 3567). + * + * Single-day mode: pass a ReplayReport. Server has one route (`/`) and the + * page renders without a heatmap. + * + * Multi-day mode: pass a ReplayLiveDataProvider. Server adds + * `GET /api/replay?date=YYYY-MM-DD` returning JSON; the page renders a + * GitHub-style heatmap above the cost odometer for in-page date switching. */ export async function startReplayLiveServer( - report: ReplayReport, + arg: ReplayReport | ReplayLiveDataProvider, options: ReplayLiveServerOptions = {}, ): Promise<{ port: number; stop: () => void }> { - const html = generateReplayLiveHtml(report); const startPort = options.port ?? 3567; const maxAttempts = 20; - let port = startPort; + const buildHandler = () => { + if (isProvider(arg)) { + // Multi-day mode: serve `/` with the day-specific report embedded (read + // from `?date=` query, falling back to initialDate). Heatmap clicks just + // navigate to `/?date=YYYY-MM-DD` — a full reload, but cheap on local + // data and avoids needing a JS-side rebuild path. The /api/replay + // endpoint stays around for future programmatic access. + return async (req: Request): Promise => { + const url = new URL(req.url); + if (url.pathname === '/') { + const requested = url.searchParams.get('date'); + let date = arg.initialDate; + let report: ReplayReport | null = arg.initialReport; + if (requested && ISO_DATE.test(requested) && requested !== arg.initialDate) { + const fresh = await arg.getReport(requested); + if (fresh) { + date = requested; + report = fresh; + } else { + report = makeEmptyReport(requested); + date = requested; + } + } + const html = generateReplayLiveHtml(report, { + heatmap: arg.heatmap, + initialDate: date, + }); + return new Response(html, { + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }); + } + if (url.pathname === '/api/replay') { + const date = url.searchParams.get('date') ?? ''; + if (!ISO_DATE.test(date)) { + return Response.json({ error: 'invalid date — expected YYYY-MM-DD' }, { status: 400 }); + } + const report = await arg.getReport(date); + if (!report) { + return Response.json({ error: 'no events for that date' }, { status: 404 }); + } + return Response.json(report); + } + return new Response('not found', { status: 404 }); + }; + } + const html = generateReplayLiveHtml(arg); + return (_req: Request): Response => + new Response(html, { + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }); + }; + for (let attempt = 0; attempt < maxAttempts; attempt++) { - const result = tryServe(html, port); + const result = tryServe(buildHandler, port); if (result.server) { const actualPort = result.server.port ?? port; - process.stderr.write( - `Replay live at http://localhost:${String(actualPort)}\n`, - ); + if (!options.silent) { + process.stderr.write( + `Replay live at http://localhost:${String(actualPort)}\n`, + ); + } return { port: actualPort, stop: () => result.server.stop(true) }; } diff --git a/packages/renderers/src/live/replay-live-template.ts b/packages/renderers/src/live/replay-live-template.ts index 08be70e..88d4563 100644 --- a/packages/renderers/src/live/replay-live-template.ts +++ b/packages/renderers/src/live/replay-live-template.ts @@ -1,4 +1,12 @@ import type { ReplayReport } from '@tokenleak/core'; +import type { ReplayHeatmapEntry } from './replay-live-server'; + +export interface ReplayLiveHtmlOptions { + /** Optional heatmap to render above the cost odometer for date navigation. */ + heatmap?: ReplayHeatmapEntry[]; + /** Date to mark as active in the heatmap. Defaults to the report's date. */ + initialDate?: string; +} function esc(s: string): string { return s @@ -26,13 +34,144 @@ function formatDateLong(dateStr: string): string { return `${months[d.getUTCMonth()]} ${d.getUTCDate()}, ${d.getUTCFullYear()}`; } +function formatHeatmapDate(dateStr: string): string { + const d = new Date(dateStr + 'T00:00:00Z'); + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + return `${months[d.getUTCMonth()]} ${d.getUTCDate()}`; +} + +function formatHeatmapTokens(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M tok`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K tok`; + return `${Math.round(n)} tok`; +} + +function formatHeatmapCost(n: number): string { + return `$${n.toFixed(2)}`; +} + +const HEATMAP_DAYS = 91; // 13 weeks × 7 days + +/** + * Render the GitHub-style heatmap above the cost odometer for in-page date + * navigation. Each cell is an `` so clicks just navigate to `/?date=X` + * — no JS required. + * + * Layout: 7 rows (Sun-Sat) × 13-14 cols (weeks), latest day on the right. + */ +function renderHeatmapSection(entries: ReplayHeatmapEntry[], activeDate: string): string { + const byDate = new Map(); + for (const e of entries) byDate.set(e.date, e); + + // Determine the day window from the replay data, not wall-clock today. + const latestEntryDate = entries.reduce((acc, e) => (e.date > acc ? e.date : acc), activeDate); + const end = new Date(latestEntryDate + 'T00:00:00Z'); + const start = new Date(end.getTime() - (HEATMAP_DAYS - 1) * 86_400_000); + + // Walk forward from start; bucket into calendar weeks (column = week index). + const startWeekday = start.getUTCDay(); // 0 = Sun + const cells: Array<{ date: string; tokens: number; cost: number; events: number; weekday: number; col: number }> = []; + for (let i = 0; i < HEATMAP_DAYS; i++) { + const d = new Date(start.getTime() + i * 86_400_000); + const dateStr = d.toISOString().slice(0, 10); + const weekday = d.getUTCDay(); // 0 = Sun + const col = Math.floor((i + startWeekday) / 7); + const e = byDate.get(dateStr); + cells.push({ + date: dateStr, + tokens: e?.tokens ?? 0, + cost: e?.cost ?? 0, + events: e?.events ?? 0, + weekday, + col, + }); + } + + const visibleEntries = cells.filter((c) => c.tokens > 0); + const maxTokens = visibleEntries.reduce((acc, e) => Math.max(acc, e.tokens), 0); + const totalTokens = visibleEntries.reduce((acc, e) => acc + e.tokens, 0); + const totalCost = visibleEntries.reduce((acc, e) => acc + e.cost, 0); + const activeDays = visibleEntries.length; + const columnCount = Math.max(13, (cells[cells.length - 1]?.col ?? 12) + 1); + const columnStyle = `grid-template-columns:repeat(${columnCount}, 14px)`; + + const cellHtml = cells + .map((c) => { + const intensity = maxTokens > 0 && c.tokens > 0 + ? Math.max(0.18, Math.log(1 + c.tokens) / Math.log(1 + maxTokens)) + : 0; + const isActive = c.date === activeDate; + const klass = ['hm-cell']; + if (isActive) klass.push('hm-cell--active'); + if (c.tokens === 0) klass.push('hm-cell--empty'); + const tooltip = c.tokens > 0 + ? `${formatHeatmapDate(c.date)} · ${formatHeatmapTokens(c.tokens)} · ${formatHeatmapCost(c.cost)}` + : `${formatHeatmapDate(c.date)} · no events`; + const style = c.tokens > 0 + ? `--hm-alpha:${intensity.toFixed(3)};grid-column:${c.col + 1};grid-row:${c.weekday + 1};` + : `grid-column:${c.col + 1};grid-row:${c.weekday + 1};`; + // Empty days render as a non-link — clicking them would land on + // "0 flow blocks" with nothing to scrub, which is just confusing. + if (c.tokens === 0) { + return ``; + } + return ``; + }) + .join(''); + + // Month labels along the top: emit one label per week-column whose first + // day is the start of a new month (or the very first column). + const monthLabels: string[] = []; + const seenMonths = new Set(); + for (let week = 0; week < columnCount; week++) { + const firstDay = cells.find((c) => c.col === week); + if (!firstDay) continue; + const d = new Date(firstDay.date + 'T00:00:00Z'); + const monthKey = `${d.getUTCFullYear()}-${d.getUTCMonth()}`; + if (!seenMonths.has(monthKey)) { + seenMonths.add(monthKey); + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + monthLabels.push(`${months[d.getUTCMonth()]}`); + } + } + + return ` +
+
+
// last 90 days · click any day to replay
+
+ ${activeDays} active days + ${formatHeatmapTokens(totalTokens)} + ${formatHeatmapCost(totalCost)} +
+
+
+
+ + Mon + + Wed + + Fri + +
+
+
${monthLabels.join('')}
+
${cellHtml}
+
+
+
`; +} + const FONTS_HREF = 'https://fonts.googleapis.com/css2?family=Geist+Mono:wght@400;500;600&family=Geist:wght@400;500;600&display=swap'; -export function generateReplayLiveHtml(report: ReplayReport): string { +export function generateReplayLiveHtml(report: ReplayReport, options: ReplayLiveHtmlOptions = {}): string { const safeReport = JSON.stringify(report); const dateLong = formatDateLong(report.date); const isEmpty = report.events.length === 0; + const heatmap = options.heatmap && options.heatmap.length > 0 ? options.heatmap : null; + const activeDate = options.initialDate ?? report.date; const styles = ` :root { @@ -434,6 +573,130 @@ header.bar .meta strong { } .summary-pill strong { color: var(--text); font-weight: 500; } +/* Prompt detail panel — populated when an event row is clicked. */ +.prompt-card { margin-top: 18px; } +.prompt-meta { + font-size: 11.5px; + color: var(--muted); + margin-bottom: 10px; +} +.prompt-meta strong { color: var(--text); font-weight: 500; } +.prompt-meta.empty { color: var(--dim); margin-bottom: 0; } +.prompt-body { + font-size: 12.5px; + line-height: 1.55; + color: var(--text); + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 6px; + padding: 12px 14px; + max-height: 240px; + overflow-y: auto; + white-space: pre-wrap; + word-break: break-word; + margin: 0; +} +.prompt-body.empty { + color: var(--dim); + font-style: italic; +} +.event-row { cursor: pointer; } +.event-row.selected { + border-left-color: var(--warn); + background: rgba(253, 224, 71, 0.06); +} +.event-row.selected.current { + border-left-color: var(--accent); + background: rgba(16, 185, 129, 0.08); +} + +/* GitHub-style heatmap for in-page date navigation */ +.heatmap-section { + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface); + padding: 16px 18px; +} +.heatmap-head { + display: flex; + align-items: baseline; + justify-content: space-between; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 12px; +} +.heatmap-stats { + display: flex; + gap: 16px; + font-size: 11.5px; + color: var(--muted); +} +.heatmap-stats strong { color: var(--text); font-weight: 500; margin-right: 4px; } +.heatmap-wrap { + display: flex; + align-items: stretch; + gap: 8px; + overflow-x: auto; + padding-bottom: 4px; +} +.heatmap-dow { + display: grid; + grid-template-rows: repeat(7, 14px); + gap: 3px; + font-size: 9px; + color: var(--dim); + padding-top: 18px; /* line up with cells, accounting for the months row above */ + flex: 0 0 auto; +} +.heatmap-dow span { line-height: 14px; } +.heatmap-body { + flex: 0 0 auto; +} +.heatmap-months { + display: grid; + grid-template-columns: repeat(13, 14px); + gap: 3px; + font-size: 9px; + color: var(--dim); + height: 14px; + margin-bottom: 4px; +} +.heatmap-months span { grid-row: 1; white-space: nowrap; } +.heatmap-grid { + display: grid; + grid-template-rows: repeat(7, 14px); + grid-template-columns: repeat(13, 14px); + gap: 3px; +} +.hm-cell { + display: block; + width: 14px; + height: 14px; + border-radius: 3px; + background: rgba(16, 185, 129, calc(var(--hm-alpha, 0.18) * 0.9)); + border: 1px solid transparent; + cursor: pointer; + transition: transform 100ms ease, border-color 100ms ease, box-shadow 100ms ease; + text-decoration: none; +} +.hm-cell:hover { + border-color: var(--accent); + transform: scale(1.2); +} +.hm-cell--empty { + background: rgba(255, 255, 255, 0.03); + border-color: var(--border); + cursor: default; +} +.hm-cell--empty:hover { + border-color: var(--border); + transform: none; +} +.hm-cell--active { + border-color: var(--text) !important; + box-shadow: 0 0 0 1px var(--bg), 0 0 0 2px var(--accent); +} + /* Help footer */ .help { margin-top: 24px; @@ -547,6 +810,12 @@ header.bar .meta strong { +
+
// prompt sent to model
+
click any event in the list above to see what was asked
+ +
+
spaceplay / pause step ±1 min @@ -611,6 +880,12 @@ header.bar .meta strong { let lastFrameTs = 0; let rafId = 0; let prevCost = 0; + // selectedEventIndex tracks which row's prompt is shown in the prompt + // card. Until the user clicks a row, it follows the playhead — so during + // playback the prompt panel updates live. Once they click, it freezes + // there until the next click. + let selectedEventIndex = -1; + let manualSelection = false; // ── DOM refs ─────────────────────────────────────────────────────── const $ = function (id) { return document.getElementById(id); }; @@ -634,14 +909,16 @@ header.bar .meta strong { const blockCost = $('blockCost'); const blockCache = $('blockCache'); const blockSwitches = $('blockSwitches'); + const promptMeta = $('promptMeta'); + const promptBody = $('promptBody'); // ── Static SVG render ────────────────────────────────────────────── const TL_W = 1000; const TL_H = 180; const HIST_TOP = 12; - const HIST_HEIGHT = 120; - const RIBBON_TOP = 144; - const RIBBON_HEIGHT = 18; + const HIST_HEIGHT = 110; + const RIBBON_TOP = 138; + const RIBBON_HEIGHT = 26; function timeToX(ts) { return ((ts - dayStart) / dayDuration) * TL_W; @@ -668,13 +945,22 @@ header.bar .meta strong { parts.push(''); }); - // Flow block ribbon. + // Flow block ribbon. Min width bumped to 10 SVG units (~12px on screen) + // so short bursts (Quick Lookups, 30s–3min Deep Flows) stay visible + // instead of collapsing into invisible 2px slivers. Centered around + // the block's true midpoint so visual position remains accurate. flowBlocks.forEach(function (b, i) { - const x = timeToX(b.startTs); - const w = Math.max(2, timeToX(b.endTs) - x); + const xStart = timeToX(b.startTs); + const xEnd = timeToX(b.endTs); + const trueWidth = Math.max(0, xEnd - xStart); + const minWidth = 10; + const w = Math.max(minWidth, trueWidth); + const x = trueWidth < minWidth + ? Math.max(0, xStart + trueWidth / 2 - minWidth / 2) + : xStart; const colorMap = { 'Deep Flow': ACCENT, 'Quick Lookup': WARN, 'Moderate Session': MUTED }; const fill = colorMap[b.label] || MUTED; - parts.push(''); + parts.push(''); }); // Playhead (drawn last, on top). @@ -826,9 +1112,16 @@ header.bar .meta strong { for (let i = 0; i < events.length; i++) { if (events[i].ts <= currentTimeMs) cur = i; else break; } + // Auto-follow the playhead until the user manually picks a row. + if (!manualSelection) { + selectedEventIndex = cur; + } const start = Math.max(0, cur - EV_WINDOW); const end = Math.min(events.length, (cur === -1 ? 0 : cur) + EV_WINDOW + 1); - const key = start + ':' + end + ':' + cur; + // Selection has to be part of the cache key, otherwise clicking a row + // already inside the rendered window wouldn't repaint and the + // .selected class wouldn't move. + const key = start + ':' + end + ':' + cur + ':' + selectedEventIndex; if (key === lastWindowKey) return; lastWindowKey = key; @@ -836,9 +1129,12 @@ header.bar .meta strong { for (let i = start; i < end; i++) { const e = events[i]; const future = i > cur; - const cls = 'event-row' + (future ? ' future' : '') + (i === cur ? ' current' : ''); + const cls = 'event-row' + + (future ? ' future' : '') + + (i === cur ? ' current' : '') + + (i === selectedEventIndex ? ' selected' : ''); parts.push( - '
' + + '
' + '' + fmtClock(e.ts).slice(0, 5) + '' + '' + escHtml(e.model) + '' + '' + fmtTokens(e.totalTokens) + '' + @@ -854,6 +1150,50 @@ header.bar .meta strong { } } + // Click delegation: clicking an event row pins the prompt panel to that + // event. Also pauses playback so the user can read. + eventList.addEventListener('click', function (ev) { + let target = ev.target; + while (target && target !== eventList && !(target.getAttribute && target.getAttribute('data-event-index'))) { + target = target.parentNode; + } + if (!target || target === eventList) return; + const idx = parseInt(target.getAttribute('data-event-index'), 10); + if (isNaN(idx) || idx < 0 || idx >= events.length) return; + pause(); + manualSelection = true; + selectedEventIndex = idx; + // Force a repaint so the .selected class moves. + lastWindowKey = ''; + renderEventList(); + renderPromptPanel(); + }); + + function renderPromptPanel() { + if (selectedEventIndex < 0 || selectedEventIndex >= events.length) { + promptMeta.className = 'prompt-meta mono empty'; + promptMeta.textContent = 'click any event in the list above to see what was asked'; + promptBody.hidden = true; + promptBody.textContent = ''; + return; + } + const e = events[selectedEventIndex]; + promptMeta.className = 'prompt-meta mono'; + promptMeta.innerHTML = + 'asked at ' + escHtml(fmtClock(e.ts)) + '' + + ' · ' + escHtml(e.model) + '' + + ' · ' + escHtml(fmtTokens(e.totalTokens)) + ' tok' + + ' · ' + escHtml(fmtCost(e.cost)) + ''; + promptBody.hidden = false; + if (typeof e.prompt === 'string' && e.prompt.length > 0) { + promptBody.classList.remove('empty'); + promptBody.textContent = e.prompt; + } else { + promptBody.classList.add('empty'); + promptBody.textContent = "this provider doesn't capture prompt text for this event"; + } + } + function escHtml(s) { return String(s).replace(/&/g, '&').replace(//g, '>'); } @@ -937,6 +1277,7 @@ header.bar .meta strong { renderActiveBlock(); renderEventList(); renderMix(cum); + renderPromptPanel(); } function setTime(t) { @@ -1093,6 +1434,7 @@ header.bar .meta strong { ${report.flowBlocks.length} flow blocks
+ ${heatmap ? renderHeatmapSection(heatmap, activeDate) : ''} ${isEmpty ? emptyBody : mainBody}
diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 93d48c6..017674c 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -14,7 +14,7 @@ import { saveCursorCredentials, validateCursorSession, } from '@tokenleak/registry'; -import { computeAchievements } from '@tokenleak/renderers'; +import { computeAchievements, startReplayLiveServer } from '@tokenleak/renderers'; import { copyTextToClipboard } from './lib/clipboard.js'; import { COLORS, BOLD } from './lib/theme.js'; import { @@ -50,7 +50,34 @@ import { createComparePanel } from './panels/compare.js'; import { createExportPanel } from './panels/export.js'; import { createWrappedPanel } from './panels/wrapped.js'; import { createHelpPanel } from './panels/help.js'; -import { createReplayPanel, REPLAY_MAX_CONTENT_WIDTH, REPLAY_VISIBLE_BLOCKS } from './panels/replay.js'; +import { + createReplayPanel, + REPLAY_MAX_CONTENT_WIDTH, + REPLAY_VISIBLE_BLOCKS, + REPLAY_VISIBLE_BLOCKS_PLAYBACK, +} from './panels/replay.js'; +import type { ReplayPlaybackView } from './panels/replay.js'; +import { + REPLAY_PLAYBACK_TICK_MS, + computePlaybackSummary, + enterReplayPlayback, + exitReplayPlayback, + jumpReplayCursorToBlockBoundary, + jumpReplayCursorToInteresting, + setReplayPlaybackSpeed, + selectReplayCursorEvent, + stepReplayCursor, + tickReplayPlayback, + toggleReplayPlayback, +} from './lib/replay-playback.js'; +import { + buildReplayLiveDataProvider, + keepReplaySelectionVisible, + moveReplayOverviewSelection, + resetReplayDataInteraction, + resetReplayPanelInteraction, +} from './lib/replay-interaction.js'; +import type { ReplayPlaybackSpeed } from './lib/state.js'; import { createNutritionPanel, NUTRITION_VISIBLE_ROWS } from './panels/nutrition.js'; import { createReceiptsPanel, RECEIPTS_MAX_CONTENT_WIDTH, RECEIPTS_VISIBLE_ROWS } from './panels/receipts.js'; import { buildCursorBanner, createCursorSetupPanel, isEscapeKeySequence } from './panels/cursor-setup.js'; @@ -307,6 +334,8 @@ function buildContent(state: AppState, renderer: CliRenderer) { state.replayExpandedBlockIndex, state.replayScrollOffset, getPanelContentWidth(renderer, REPLAY_MAX_CONTENT_WIDTH), + undefined, + null, ); } if (!state.replayDate) { @@ -329,6 +358,7 @@ function buildContent(state: AppState, renderer: CliRenderer) { toggleReplayBlock(state, blockIndex); render(state, renderer); }, + buildReplayPlaybackView(state), ); case 'nutrition': if (!hasWindowData) { @@ -488,9 +518,7 @@ function applyLoadedData( state.nutritionScrollOffset = 0; state.compareScrollOffset = 0; state.wrappedScrollOffset = 0; - state.replayScrollOffset = 0; - state.replaySelectedBlockIndex = 0; - state.replayExpandedBlockIndex = null; + resetReplayDataState(state); state.replayDate = null; state.explainDate = null; state.receiptsScrollOffset = 0; @@ -626,12 +654,269 @@ function keepSelectedItemVisible(selectedIndex: number, scrollOffset: number, vi return scrollOffset; } -function resetReplayInteraction(state: AppState): void { - state.replayScrollOffset = 0; - state.replaySelectedBlockIndex = 0; - state.replayExpandedBlockIndex = null; +let replayPlaybackTimer: ReturnType | null = null; + +function startReplayPlaybackTimer(): void { + if (replayPlaybackTimer !== null) return; + replayPlaybackTimer = setInterval(() => { + if (!currentState.replayPlaybackActive) { + stopReplayPlaybackTimer(); + return; + } + const advanced = tickReplayPlayback(currentState); + keepReplaySelectionVisible(currentState, REPLAY_VISIBLE_BLOCKS_PLAYBACK); + if (!advanced) { + stopReplayPlaybackTimer(); + } + render(currentState, currentRenderer); + }, REPLAY_PLAYBACK_TICK_MS); +} + +function resetReplayPanelState(state: AppState): void { + resetReplayPanelInteraction(state, stopReplayPlaybackTimer); +} + +function resetReplayDataState(state: AppState): void { + resetReplayDataInteraction(state, stopReplayPlaybackTimer, stopReplayLiveServer); +} + +function stopReplayPlaybackTimer(): void { + if (replayPlaybackTimer !== null) { + clearInterval(replayPlaybackTimer); + replayPlaybackTimer = null; + } +} + +/** + * Replay playback / step-mode keyboard dispatch. Returns true when the + * sequence was consumed. Activates only on the replay view. + * + * Mode entry/exit is bound to `s` so the rest of the TUI's keymap is + * unaffected. Once in playback mode we intercept playback-specific keys + * (n/p step, N/P block, i/I interesting, space play/pause, 1/2/3 speed, + * Home/End jump, Esc exit) and let everything else (q, view switches, + * date shift via h/l, j/k block selection) fall through to the regular + * handlers downstream. + */ +function handleReplayPlaybackInput( + sequence: string, + state: AppState, + renderer: CliRenderer, +): boolean { + if (state.selectedView !== 'replay') return false; + const events = state.cachedReplayReport?.events; + + // Toggle entry from overview mode. + if (state.replayCursorEventIndex === null) { + if (sequence === 's' && events && events.length > 0) { + enterReplayPlayback(state); + keepReplaySelectionVisible(state, REPLAY_VISIBLE_BLOCKS_PLAYBACK); + render(state, renderer); + return true; + } + return false; + } + + // We're in playback mode from here on. + if (!events || events.length === 0) { + exitReplayPlayback(state); + stopReplayPlaybackTimer(); + render(state, renderer); + return true; + } + + // Exit + if (sequence === 's' || isEscapeKeySequence(sequence)) { + exitReplayPlayback(state); + stopReplayPlaybackTimer(); + render(state, renderer); + return true; + } + + // Step controls + if (sequence === 'n' || sequence === '\x1b[C') { + pauseReplayPlaybackIfRunning(state); + stepReplayCursor(state, 1); + keepReplaySelectionVisible(state, REPLAY_VISIBLE_BLOCKS_PLAYBACK); + render(state, renderer); + return true; + } + if (sequence === 'p' || sequence === '\x1b[D') { + pauseReplayPlaybackIfRunning(state); + stepReplayCursor(state, -1); + keepReplaySelectionVisible(state, REPLAY_VISIBLE_BLOCKS_PLAYBACK); + render(state, renderer); + return true; + } + if (sequence === 'N') { + pauseReplayPlaybackIfRunning(state); + jumpReplayCursorToBlockBoundary(state, 1); + keepReplaySelectionVisible(state, REPLAY_VISIBLE_BLOCKS_PLAYBACK); + render(state, renderer); + return true; + } + if (sequence === 'P') { + pauseReplayPlaybackIfRunning(state); + jumpReplayCursorToBlockBoundary(state, -1); + keepReplaySelectionVisible(state, REPLAY_VISIBLE_BLOCKS_PLAYBACK); + render(state, renderer); + return true; + } + if (sequence === 'i') { + pauseReplayPlaybackIfRunning(state); + jumpReplayCursorToInteresting(state, 1); + keepReplaySelectionVisible(state, REPLAY_VISIBLE_BLOCKS_PLAYBACK); + render(state, renderer); + return true; + } + if (sequence === 'I') { + pauseReplayPlaybackIfRunning(state); + jumpReplayCursorToInteresting(state, -1); + keepReplaySelectionVisible(state, REPLAY_VISIBLE_BLOCKS_PLAYBACK); + render(state, renderer); + return true; + } + if (sequence === '\x1b[H' || sequence === '\x1bOH') { + pauseReplayPlaybackIfRunning(state); + selectReplayCursorEvent(state, 0); + keepReplaySelectionVisible(state, REPLAY_VISIBLE_BLOCKS_PLAYBACK); + render(state, renderer); + return true; + } + if (sequence === '\x1b[F' || sequence === '\x1bOF') { + pauseReplayPlaybackIfRunning(state); + selectReplayCursorEvent(state, events.length - 1); + keepReplaySelectionVisible(state, REPLAY_VISIBLE_BLOCKS_PLAYBACK); + render(state, renderer); + return true; + } + + // Play / pause + if (sequence === ' ') { + const active = toggleReplayPlayback(state); + if (active) { + // If we're at the end, restart from the beginning. + if (state.replayCursorEventIndex! >= events.length - 1) { + selectReplayCursorEvent(state, 0); + keepReplaySelectionVisible(state, REPLAY_VISIBLE_BLOCKS_PLAYBACK); + } + startReplayPlaybackTimer(); + } else { + stopReplayPlaybackTimer(); + } + render(state, renderer); + return true; + } + + // Speed selection + if (sequence === '1' || sequence === '2' || sequence === '3') { + const speedMap: Record = { '1': 60, '2': 240, '3': 600 }; + setReplayPlaybackSpeed(state, speedMap[sequence]); + render(state, renderer); + return true; + } + + // Pass through everything else (q, j/k, h/l, view switches, etc.) + return false; +} + +function pauseReplayPlaybackIfRunning(state: AppState): void { + if (state.replayPlaybackActive) { + state.replayPlaybackActive = false; + stopReplayPlaybackTimer(); + } +} + +// ── In-process replay live server (launched via global `o` keypress) ── + +let replayLiveServerStop: (() => void) | null = null; + +const OS_OPEN_COMMANDS: Record = { + darwin: 'open', + linux: 'xdg-open', + win32: 'start', +}; + +function openUrlInBrowser(url: string): void { + const cmd = OS_OPEN_COMMANDS[process.platform]; + if (!cmd) return; + const args = process.platform === 'win32' ? ['cmd', '/c', 'start', '', url] : [cmd, url]; + try { + Bun.spawn(args, { stdout: 'ignore', stderr: 'ignore' }); + } catch { + // best-effort; the in-TUI banner still shows the URL the user can open manually + } } +async function launchReplayBrowser(state: AppState, renderer: CliRenderer): Promise { + // Idempotent — second press just re-opens the browser to the existing server. + if (state.replayLiveServerPort !== null) { + openUrlInBrowser(`http://localhost:${state.replayLiveServerPort}/`); + return; + } + if (!state.data) return; // still booting; nothing to render yet + const scoped = getScopedWindowData(state); + const providers = scoped?.scopedProviders ?? state.data.providers; + const replayLiveData = buildReplayLiveDataProvider(providers, state.replayDate, getTodayLocal()); + state.replayDate = replayLiveData.initialDate; + state.cachedReplayReport = replayLiveData.initialReport; + try { + // silent: true suppresses the server's stderr "Replay live at..." line, + // which would otherwise corrupt the full-screen TUI render and make + // the terminal look frozen until the user hits another key. + const { port, stop } = await startReplayLiveServer(replayLiveData, { silent: true }); + state.replayLiveServerPort = port; + replayLiveServerStop = stop; + render(state, renderer); + openUrlInBrowser(`http://localhost:${port}/`); + } catch { + // best-effort. The TUI render path doesn't have an error toast yet — + // failing silently is safer than dumping to stderr (would corrupt + // the screen). + } +} + +/** + * Global [o] dispatcher — fires from any view, not just Replay. Lets the + * footer CTA's keybind work everywhere in the TUI. Returns true when the + * keypress was consumed. + */ +function handleGlobalReplayBrowserKey( + sequence: string, + state: AppState, + renderer: CliRenderer, +): boolean { + if (sequence !== 'o') return false; + void launchReplayBrowser(state, renderer); + return true; +} + +function stopReplayLiveServer(state: AppState): void { + if (replayLiveServerStop !== null) { + try { replayLiveServerStop(); } catch { /* noop */ } + replayLiveServerStop = null; + } + state.replayLiveServerPort = null; +} + +function buildReplayPlaybackView(state: AppState): ReplayPlaybackView | null { + const report = state.cachedReplayReport; + if (!report || state.replayCursorEventIndex === null || report.events.length === 0) { + return null; + } + const summary = computePlaybackSummary(report, state.replayCursorEventIndex); + if (!summary) return null; + const totalDayCost = report.events.reduce((s, e) => s + e.cost, 0); + return { + cursorIndex: summary.cursorIndex, + active: state.replayPlaybackActive, + speed: state.replayPlaybackSpeed, + summary, + totalDayCost, + }; +} + + function resetReceiptsInteraction(state: AppState): void { state.receiptsScrollOffset = 0; state.receiptsSelectedLineIndex = 0; @@ -645,6 +930,7 @@ function getReceiptLineCount(state: AppState): number { } function toggleReplayBlock(state: AppState, blockIndex: number = state.replaySelectedBlockIndex): void { + if (state.replayCursorEventIndex !== null) return; const itemCount = state.cachedReplayReport?.flowBlocks.length ?? 0; if (itemCount <= 0) return; const selected = clampItemIndex(blockIndex, itemCount); @@ -671,15 +957,7 @@ function toggleReceiptLine(state: AppState, lineIndex: number = state.receiptsSe } function moveReplaySelection(state: AppState, direction: number): void { - const itemCount = state.cachedReplayReport?.flowBlocks.length ?? 0; - if (itemCount <= 0) return; - const selected = clampItemIndex(state.replaySelectedBlockIndex + direction, itemCount); - state.replaySelectedBlockIndex = selected; - state.replayScrollOffset = keepSelectedItemVisible( - selected, - state.replayScrollOffset, - REPLAY_VISIBLE_BLOCKS, - ); + moveReplayOverviewSelection(state, direction, REPLAY_VISIBLE_BLOCKS); } function moveReceiptSelection(state: AppState, direction: number): void { @@ -703,7 +981,7 @@ function handleViewSwitch(mode: ViewMode): void { currentState.nutritionScrollOffset = 0; currentState.compareScrollOffset = 0; currentState.wrappedScrollOffset = 0; - resetReplayInteraction(currentState); + resetReplayPanelState(currentState); resetReceiptsInteraction(currentState); currentState.receiptsSortMode = 'cost'; currentState.receiptsCategoryFilter = null; @@ -768,7 +1046,7 @@ function invalidateWindowCaches(state: AppState): void { state.receiptsCategoryFilter = null; state.explainDate = null; // re-derive from new window's peak day state.replayDate = null; - resetReplayInteraction(state); + resetReplayDataState(state); clearViewTaskState(state); } @@ -783,7 +1061,7 @@ function invalidateAllCaches(state: AppState): void { state.cachedWasteReport = null; state.cachedNutritionReport = null; state.cachedReceipt = null; - resetReplayInteraction(state); + resetReplayDataState(state); resetReceiptsInteraction(state); state.nutritionSignalsLoading = false; state.nutritionSignalsLoadedKeys.clear(); @@ -797,7 +1075,7 @@ function shiftReplayDate(state: AppState, direction: number): void { d.setUTCDate(d.getUTCDate() + direction); state.replayDate = d.toISOString().slice(0, 10); state.cachedReplayReport = null; - resetReplayInteraction(state); + resetReplayDataState(state); } /** Navigate explain date forward or backward by one day */ @@ -1089,6 +1367,16 @@ export async function main(): Promise { return true; } + // Global [o] interactive replay launcher — must run before any + // view-specific handlers so the footer CTA works on every view. + if (handleGlobalReplayBrowserKey(sequence, state, renderer)) { + return true; + } + + if (handleReplayPlaybackInput(sequence, state, renderer)) { + return true; + } + // Help toggle: ? key if (sequence === '?') { state.showHelp = !state.showHelp; @@ -1287,8 +1575,9 @@ export async function main(): Promise { return true; } - // o: cycle sort mode (receipts view) - if (sequence === 'o' && state.selectedView === 'receipts') { + // S: cycle sort mode (receipts view). Moved off [o] because the + // global interactive-replay launcher now owns that key. + if (sequence === 'S' && state.selectedView === 'receipts') { const order: Array<'cost' | 'qty' | 'alpha'> = ['cost', 'qty', 'alpha']; const nextIndex = (order.indexOf(state.receiptsSortMode) + 1) % order.length; state.receiptsSortMode = order[nextIndex]!; @@ -1329,6 +1618,7 @@ export async function main(): Promise { // q: quit if (sequence === 'q') { + stopReplayLiveServer(state); renderer.destroy(); process.exit(0); } diff --git a/packages/tui/src/lib/replay-interaction.test.ts b/packages/tui/src/lib/replay-interaction.test.ts new file mode 100644 index 0000000..a5b4f56 --- /dev/null +++ b/packages/tui/src/lib/replay-interaction.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, test } from 'bun:test'; +import type { + FlowBlock, + ProviderColors, + ProviderData, + ReplayReport, + UsageEvent, +} from '@tokenleak/core'; +import { createInitialState } from './state'; +import { + buildReplayLiveDataProvider, + moveReplayOverviewSelection, + resetReplayDataInteraction, + resetReplayPanelInteraction, +} from './replay-interaction'; + +const COLORS: ProviderColors = { + primary: '#111111', + secondary: '#222222', + gradient: ['#111111', '#222222'], +}; + +function event(timestamp: string, overrides: Partial = {}): UsageEvent { + return { + provider: 'codex', + timestamp, + date: timestamp.slice(0, 10), + model: 'gpt-5.4', + inputTokens: 100, + outputTokens: 50, + cacheReadTokens: 20, + cacheWriteTokens: 5, + totalTokens: 175, + cost: 0.01, + ...overrides, + }; +} + +function provider(events: UsageEvent[]): ProviderData { + return { + provider: 'codex', + displayName: 'Codex', + colors: COLORS, + daily: [], + totalTokens: events.reduce((sum, e) => sum + e.totalTokens, 0), + totalCost: events.reduce((sum, e) => sum + e.cost, 0), + events, + }; +} + +function block(blockIndex: number, start: string, end: string): FlowBlock { + const events = [event(start)]; + return { + blockIndex, + label: 'Quick Lookup', + start, + end, + durationMs: Date.parse(end) - Date.parse(start), + eventCount: events.length, + inputTokens: 100, + outputTokens: 50, + cacheReadTokens: 20, + cacheWriteTokens: 5, + totalTokens: 175, + cost: 0.01, + dominantModel: 'gpt-5.4', + events, + modelSwitches: 0, + cacheHitRateTrend: [0.2], + }; +} + +describe('replay interaction resets', () => { + test('panel reset clears TUI-only state while preserving the browser server', () => { + const state = createInitialState(); + state.replayScrollOffset = 4; + state.replaySelectedBlockIndex = 3; + state.replayExpandedBlockIndex = 2; + state.replayCursorEventIndex = 1; + state.replayPlaybackActive = true; + state.replayLiveServerPort = 3567; + let stoppedPlayback = 0; + + resetReplayPanelInteraction(state, () => { + stoppedPlayback++; + }); + + expect(state.replayScrollOffset).toBe(0); + expect(state.replaySelectedBlockIndex).toBe(0); + expect(state.replayExpandedBlockIndex).toBeNull(); + expect(state.replayCursorEventIndex).toBeNull(); + expect(state.replayPlaybackActive).toBe(false); + expect(state.replayLiveServerPort).toBe(3567); + expect(stoppedPlayback).toBe(1); + }); + + test('data reset also stops the browser server and clears its port', () => { + const state = createInitialState(); + state.replayLiveServerPort = 3567; + let stoppedServer = 0; + + resetReplayDataInteraction( + state, + () => {}, + () => { + stoppedServer++; + }, + ); + + expect(stoppedServer).toBe(1); + expect(state.replayLiveServerPort).toBeNull(); + }); +}); + +describe('buildReplayLiveDataProvider', () => { + test('builds heatmap navigation and defaults to the latest active day', () => { + const events = [ + event('2026-03-10T09:00:00.000Z', { totalTokens: 100, cost: 0.01 }), + event('2026-03-10T10:00:00.000Z', { totalTokens: 200, cost: 0.02 }), + event('2026-03-11T11:00:00.000Z', { totalTokens: 300, cost: 0.03 }), + ]; + + const liveData = buildReplayLiveDataProvider([provider(events)], null, '2026-03-12'); + + expect(liveData.initialDate).toBe('2026-03-11'); + expect(liveData.initialReport.date).toBe('2026-03-11'); + expect(liveData.initialReport.events).toHaveLength(1); + expect(liveData.heatmap).toEqual([ + { date: '2026-03-10', tokens: 300, cost: 0.03, events: 2 }, + { date: '2026-03-11', tokens: 300, cost: 0.03, events: 1 }, + ]); + const march10Report = liveData.getReport('2026-03-10') as ReplayReport; + expect(march10Report.events).toHaveLength(2); + expect(liveData.getReport('2026-03-12')).toBeNull(); + }); + + test('returns an empty initial report for empty scoped data', () => { + const liveData = buildReplayLiveDataProvider([], null, '2026-03-12'); + + expect(liveData.initialDate).toBe('2026-03-12'); + expect(liveData.heatmap).toEqual([]); + expect(liveData.initialReport.date).toBe('2026-03-12'); + expect(liveData.initialReport.events).toEqual([]); + }); +}); + +describe('moveReplayOverviewSelection', () => { + test('does not desync selected block while playback cursor mode is active', () => { + const state = createInitialState(); + state.cachedReplayReport = { + date: '2026-03-10', + events: [], + flowBlocks: [ + block(0, '2026-03-10T09:00:00.000Z', '2026-03-10T09:00:00.000Z'), + block(1, '2026-03-10T10:00:00.000Z', '2026-03-10T10:00:00.000Z'), + ], + tokenVelocity: [], + summary: { + totalSessions: 0, + totalEvents: 0, + flowTimeMs: 0, + thinkTimeMs: 0, + flowThinkRatio: 0, + peakMinute: null, + }, + }; + state.replaySelectedBlockIndex = 1; + state.replayScrollOffset = 1; + state.replayCursorEventIndex = 0; + + moveReplayOverviewSelection(state, -1, 3); + + expect(state.replaySelectedBlockIndex).toBe(1); + expect(state.replayScrollOffset).toBe(1); + }); + + test('uses the caller-provided visible count when keeping selection visible', () => { + const state = createInitialState(); + state.cachedReplayReport = { + date: '2026-03-10', + events: [], + flowBlocks: Array.from({ length: 6 }, (_, index) => + block(index, `2026-03-10T1${index}:00:00.000Z`, `2026-03-10T1${index}:00:00.000Z`), + ), + tokenVelocity: [], + summary: { + totalSessions: 0, + totalEvents: 0, + flowTimeMs: 0, + thinkTimeMs: 0, + flowThinkRatio: 0, + peakMinute: null, + }, + }; + state.replaySelectedBlockIndex = 3; + state.replayScrollOffset = 0; + + moveReplayOverviewSelection(state, 1, 3); + + expect(state.replaySelectedBlockIndex).toBe(4); + expect(state.replayScrollOffset).toBe(2); + }); + + test('does not adjust scroll offset when visible count is zero', () => { + const state = createInitialState(); + state.cachedReplayReport = { + date: '2026-03-10', + events: [], + flowBlocks: Array.from({ length: 6 }, (_, index) => + block(index, `2026-03-10T1${index}:00:00.000Z`, `2026-03-10T1${index}:00:00.000Z`), + ), + tokenVelocity: [], + summary: { + totalSessions: 0, + totalEvents: 0, + flowTimeMs: 0, + thinkTimeMs: 0, + flowThinkRatio: 0, + peakMinute: null, + }, + }; + state.replaySelectedBlockIndex = 4; + state.replayScrollOffset = 2; + + moveReplayOverviewSelection(state, 1, 0); + + expect(state.replaySelectedBlockIndex).toBe(5); + expect(state.replayScrollOffset).toBe(2); + }); +}); diff --git a/packages/tui/src/lib/replay-interaction.ts b/packages/tui/src/lib/replay-interaction.ts new file mode 100644 index 0000000..95449a7 --- /dev/null +++ b/packages/tui/src/lib/replay-interaction.ts @@ -0,0 +1,127 @@ +import type { ProviderData } from '@tokenleak/core'; +import { buildReplayReport, getTodayLocal } from '@tokenleak/core'; +import type { ReplayHeatmapEntry, ReplayLiveDataProvider } from '@tokenleak/renderers'; +import type { AppState } from './state.js'; +import { exitReplayPlayback } from './replay-playback.js'; + +type StopPlaybackTimer = () => void; +type StopLiveServer = (state: AppState) => void; + +function clampItemIndex(index: number, itemCount: number): number { + if (itemCount <= 0) return 0; + return Math.max(0, Math.min(index, itemCount - 1)); +} + +function keepSelectedItemVisible(selectedIndex: number, scrollOffset: number, visibleCount: number): number { + if (visibleCount <= 0) { + return scrollOffset; + } + if (selectedIndex < scrollOffset) { + return selectedIndex; + } + if (selectedIndex >= scrollOffset + visibleCount) { + return selectedIndex - visibleCount + 1; + } + return scrollOffset; +} + +export function resetReplayPanelInteraction( + state: AppState, + stopPlaybackTimer: StopPlaybackTimer, +): void { + state.replayScrollOffset = 0; + state.replaySelectedBlockIndex = 0; + state.replayExpandedBlockIndex = null; + exitReplayPlayback(state); + stopPlaybackTimer(); +} + +export function resetReplayDataInteraction( + state: AppState, + stopPlaybackTimer: StopPlaybackTimer, + stopLiveServer: StopLiveServer, +): void { + resetReplayPanelInteraction(state, stopPlaybackTimer); + stopLiveServer(state); + state.replayLiveServerPort = null; +} + +export function moveReplayOverviewSelection( + state: AppState, + direction: number, + visibleCount: number, +): void { + if (state.replayCursorEventIndex !== null) return; + const itemCount = state.cachedReplayReport?.flowBlocks.length ?? 0; + if (itemCount <= 0) return; + const selected = clampItemIndex(state.replaySelectedBlockIndex + direction, itemCount); + state.replaySelectedBlockIndex = selected; + state.replayScrollOffset = keepSelectedItemVisible( + selected, + state.replayScrollOffset, + visibleCount, + ); +} + +export function keepReplaySelectionVisible(state: AppState, visibleCount: number): void { + const itemCount = state.cachedReplayReport?.flowBlocks.length ?? 0; + if (itemCount <= 0) return; + const selected = clampItemIndex(state.replaySelectedBlockIndex, itemCount); + state.replaySelectedBlockIndex = selected; + state.replayScrollOffset = keepSelectedItemVisible( + selected, + state.replayScrollOffset, + visibleCount, + ); +} + +export function buildReplayHeatmap(providers: ProviderData[]): ReplayHeatmapEntry[] { + const byDate = new Map(); + for (const provider of providers) { + const events = provider.events ?? []; + for (const event of events) { + const current = byDate.get(event.date) ?? { tokens: 0, cost: 0, events: 0 }; + current.tokens += event.totalTokens; + current.cost += event.cost; + current.events += 1; + byDate.set(event.date, current); + } + } + + return Array.from(byDate.entries()) + .map(([date, values]) => ({ + date, + tokens: values.tokens, + cost: values.cost, + events: values.events, + })) + .sort((a, b) => a.date.localeCompare(b.date)); +} + +function latestActiveDate(heatmap: ReplayHeatmapEntry[]): string | null { + let latest: string | null = null; + for (const entry of heatmap) { + if (entry.events > 0 && (latest === null || entry.date > latest)) { + latest = entry.date; + } + } + return latest; +} + +export function buildReplayLiveDataProvider( + providers: ProviderData[], + replayDate: string | null, + fallbackDate: string = getTodayLocal(), +): ReplayLiveDataProvider { + const heatmap = buildReplayHeatmap(providers); + const initialDate = replayDate ?? latestActiveDate(heatmap) ?? fallbackDate; + return { + heatmap, + initialDate, + initialReport: buildReplayReport(providers, initialDate), + getReport: (date: string) => { + const report = buildReplayReport(providers, date); + return report.events.length > 0 ? report : null; + }, + }; +} diff --git a/packages/tui/src/lib/replay-playback.test.ts b/packages/tui/src/lib/replay-playback.test.ts new file mode 100644 index 0000000..da688ee --- /dev/null +++ b/packages/tui/src/lib/replay-playback.test.ts @@ -0,0 +1,305 @@ +import { describe, expect, it } from 'bun:test'; +import type { ReplayReport, UsageEvent, FlowBlock } from '@tokenleak/core'; +import { createInitialState } from './state'; +import type { AppState } from './state'; +import { + computeInterestingEventIndices, + computePlaybackSummary, + enterReplayPlayback, + eventsPerTick, + exitReplayPlayback, + jumpReplayCursorToBlockBoundary, + jumpReplayCursorToInteresting, + setReplayPlaybackSpeed, + stepReplayCursor, + tickReplayPlayback, + toggleReplayPlayback, + selectReplayCursorEvent, +} from './replay-playback'; + +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 block( + blockIndex: number, + start: string, + end: string, + events: UsageEvent[], + label: FlowBlock['label'] = 'Deep Flow', +): FlowBlock { + const totalTokens = events.reduce((s, e) => s + e.totalTokens, 0); + const cost = events.reduce((s, e) => s + e.cost, 0); + return { + blockIndex, + label, + start, + end, + durationMs: Date.parse(end) - Date.parse(start), + eventCount: events.length, + inputTokens: events.reduce((s, e) => s + e.inputTokens, 0), + outputTokens: events.reduce((s, e) => s + e.outputTokens, 0), + cacheReadTokens: events.reduce((s, e) => s + e.cacheReadTokens, 0), + cacheWriteTokens: events.reduce((s, e) => s + e.cacheWriteTokens, 0), + totalTokens, + cost, + dominantModel: events[0]?.model ?? 'unknown', + events, + modelSwitches: 0, + cacheHitRateTrend: [0.4, 0.7], + }; +} + +function makeReport(): ReplayReport { + const events: UsageEvent[] = [ + ev('2026-04-22T09:30:00.000', 'claude-sonnet-4', 1_000, 0.01), + ev('2026-04-22T09:32:00.000', 'claude-sonnet-4', 2_000, 0.02), + ev('2026-04-22T09:35:00.000', 'gpt-5.4', 4_000, 0.04), + ev('2026-04-22T11:00:00.000', 'claude-haiku-4', 500, 0.005), + ev('2026-04-22T11:01:00.000', 'claude-haiku-4', 600, 0.005), + ev('2026-04-22T14:00:00.000', 'claude-opus-4-7', 100_000, 5.00), + ]; + return { + date: '2026-04-22', + events, + flowBlocks: [ + block(0, '2026-04-22T09:30:00.000', '2026-04-22T09:35:00.000', events.slice(0, 3), 'Deep Flow'), + block(1, '2026-04-22T11:00:00.000', '2026-04-22T11:01:00.000', events.slice(3, 5), 'Quick Lookup'), + block(2, '2026-04-22T14:00:00.000', '2026-04-22T14:00:00.000', events.slice(5, 6), 'Moderate Session'), + ], + tokenVelocity: [ + { minute: '2026-04-22T09:30:00.000', tokensPerMinute: 1_000 }, + { minute: '2026-04-22T14:00:00.000', tokensPerMinute: 100_000 }, + ], + summary: { + totalSessions: 3, + totalEvents: 6, + flowTimeMs: 360_000, + thinkTimeMs: 16_140_000, + flowThinkRatio: 0.022, + peakMinute: { minute: '2026-04-22T14:00:00.000', tokensPerMinute: 100_000 }, + }, + }; +} + +function withReport(): AppState { + const state = createInitialState(); + state.cachedReplayReport = makeReport(); + state.replayDate = state.cachedReplayReport.date; + return state; +} + +describe('eventsPerTick', () => { + it('returns 1 / 4 / 10 for 60 / 240 / 600 speeds', () => { + expect(eventsPerTick(60)).toBe(1); + expect(eventsPerTick(240)).toBe(4); + expect(eventsPerTick(600)).toBe(10); + }); +}); + +describe('enterReplayPlayback / exitReplayPlayback', () => { + it('parks the cursor on event 0 and selects its block', () => { + const state = withReport(); + enterReplayPlayback(state); + expect(state.replayCursorEventIndex).toBe(0); + expect(state.replayPlaybackActive).toBe(false); + expect(state.replaySelectedBlockIndex).toBe(0); + }); + + it('is a no-op when there are no events', () => { + const state = withReport(); + state.cachedReplayReport!.events = []; + enterReplayPlayback(state); + expect(state.replayCursorEventIndex).toBeNull(); + }); + + it('exit clears playback state', () => { + const state = withReport(); + enterReplayPlayback(state); + state.replayPlaybackActive = true; + exitReplayPlayback(state); + expect(state.replayCursorEventIndex).toBeNull(); + expect(state.replayPlaybackActive).toBe(false); + }); +}); + +describe('stepReplayCursor', () => { + it('selectReplayCursorEvent clamps and keeps the selected block synced', () => { + const state = withReport(); + + selectReplayCursorEvent(state, 100); + + expect(state.replayCursorEventIndex).toBe(5); + expect(state.replaySelectedBlockIndex).toBe(2); + + selectReplayCursorEvent(state, -10); + + expect(state.replayCursorEventIndex).toBe(0); + expect(state.replaySelectedBlockIndex).toBe(0); + }); + + it('moves forward and back', () => { + const state = withReport(); + enterReplayPlayback(state); + stepReplayCursor(state, 2); + expect(state.replayCursorEventIndex).toBe(2); + stepReplayCursor(state, -1); + expect(state.replayCursorEventIndex).toBe(1); + }); + + it('clamps at boundaries', () => { + const state = withReport(); + enterReplayPlayback(state); + stepReplayCursor(state, -10); + expect(state.replayCursorEventIndex).toBe(0); + stepReplayCursor(state, 100); + expect(state.replayCursorEventIndex).toBe(state.cachedReplayReport!.events.length - 1); + }); + + it('updates the selected block to the one containing the cursor event', () => { + const state = withReport(); + enterReplayPlayback(state); + stepReplayCursor(state, 3); // event 3 is in block 1 (Quick Lookup) + expect(state.replaySelectedBlockIndex).toBe(1); + stepReplayCursor(state, 2); // event 5 is in block 2 + expect(state.replaySelectedBlockIndex).toBe(2); + }); + + it('is a no-op without an active cursor', () => { + const state = withReport(); + stepReplayCursor(state, 1); + expect(state.replayCursorEventIndex).toBeNull(); + }); +}); + +describe('jumpReplayCursorToBlockBoundary', () => { + it('forward: jumps to the start event of the next block', () => { + const state = withReport(); + enterReplayPlayback(state); + stepReplayCursor(state, 1); // mid-block 0 + jumpReplayCursorToBlockBoundary(state, 1); + // First event of block 1 is event index 3 + expect(state.replayCursorEventIndex).toBe(3); + expect(state.replaySelectedBlockIndex).toBe(1); + }); + + it('backward: snaps to the start event of the current block first, then previous', () => { + const state = withReport(); + enterReplayPlayback(state); + stepReplayCursor(state, 4); // mid-block 1, event index 4 + jumpReplayCursorToBlockBoundary(state, -1); + // First event of block 1 is index 3 + expect(state.replayCursorEventIndex).toBe(3); + // Pressing again should go back to block 0 + jumpReplayCursorToBlockBoundary(state, -1); + expect(state.replayCursorEventIndex).toBe(0); + expect(state.replaySelectedBlockIndex).toBe(0); + }); + + it('forward at the last block clamps to the last event', () => { + const state = withReport(); + enterReplayPlayback(state); + stepReplayCursor(state, 5); // last event + jumpReplayCursorToBlockBoundary(state, 1); + expect(state.replayCursorEventIndex).toBe(5); + }); +}); + +describe('jumpReplayCursorToInteresting', () => { + it('walks through interesting moments forward and wraps at the end', () => { + const state = withReport(); + enterReplayPlayback(state); + const points = computeInterestingEventIndices(state.cachedReplayReport!); + expect(points.length).toBeGreaterThan(0); + + // Cursor starts at 0; first call should land on the next interesting > 0. + const firstInteresting = points.find((p) => p > 0)!; + jumpReplayCursorToInteresting(state, 1); + expect(state.replayCursorEventIndex).toBe(firstInteresting); + + // Walk all the way through; eventually wraps to points[0]. + for (let i = 0; i < points.length + 1; i++) { + jumpReplayCursorToInteresting(state, 1); + } + // After this many forward steps we should have wrapped at least once. + expect(points).toContain(state.replayCursorEventIndex!); + }); + + it('walks backwards', () => { + const state = withReport(); + enterReplayPlayback(state); + stepReplayCursor(state, 5); + jumpReplayCursorToInteresting(state, -1); + expect(state.replayCursorEventIndex).toBeLessThan(5); + }); +}); + +describe('toggleReplayPlayback / tickReplayPlayback / setReplayPlaybackSpeed', () => { + it('toggle returns the new active state', () => { + const state = withReport(); + enterReplayPlayback(state); + expect(toggleReplayPlayback(state)).toBe(true); + expect(state.replayPlaybackActive).toBe(true); + expect(toggleReplayPlayback(state)).toBe(false); + }); + + it('toggle is a no-op outside playback', () => { + const state = withReport(); + expect(toggleReplayPlayback(state)).toBe(false); + }); + + it('tick advances the cursor by speed-derived events', () => { + const state = withReport(); + enterReplayPlayback(state); + state.replayPlaybackActive = true; + setReplayPlaybackSpeed(state, 240); // 4 events/tick + tickReplayPlayback(state); + expect(state.replayCursorEventIndex).toBe(4); + }); + + it('tick stops playback at the end of the day', () => { + const state = withReport(); + enterReplayPlayback(state); + state.replayPlaybackActive = true; + setReplayPlaybackSpeed(state, 600); // 10 events/tick > total 6 + expect(tickReplayPlayback(state)).toBe(false); + expect(state.replayPlaybackActive).toBe(false); + expect(state.replayCursorEventIndex).toBe(state.cachedReplayReport!.events.length - 1); + }); + + it('tick is a no-op when playback is not active', () => { + const state = withReport(); + enterReplayPlayback(state); + state.replayPlaybackActive = false; + expect(tickReplayPlayback(state)).toBe(false); + }); +}); + +describe('computePlaybackSummary', () => { + it('returns null when there are no events', () => { + const state = withReport(); + state.cachedReplayReport!.events = []; + expect(computePlaybackSummary(state.cachedReplayReport!, 0)).toBeNull(); + }); + + it('sums cumulative cost / tokens / mix up to and including the cursor', () => { + const report = makeReport(); + const summary = computePlaybackSummary(report, 2); + expect(summary).not.toBeNull(); + expect(summary!.cumulativeCost).toBeCloseTo(0.07, 5); + expect(summary!.cumulativeTokens).toBe(7_000); + expect(summary!.modelMix.get('claude-sonnet-4')).toBe(3_000); + expect(summary!.modelMix.get('gpt-5.4')).toBe(4_000); + }); +}); diff --git a/packages/tui/src/lib/replay-playback.ts b/packages/tui/src/lib/replay-playback.ts new file mode 100644 index 0000000..96ef574 --- /dev/null +++ b/packages/tui/src/lib/replay-playback.ts @@ -0,0 +1,275 @@ +import type { ReplayReport, UsageEvent } from '@tokenleak/core'; +import type { AppState, ReplayPlaybackSpeed } from './state.js'; + +export const REPLAY_PLAYBACK_SPEEDS: readonly ReplayPlaybackSpeed[] = [60, 240, 600]; +export const REPLAY_PLAYBACK_TICK_MS = 100; + +/** Per-tick event advance count derived from the configured speed. */ +export function eventsPerTick(speed: ReplayPlaybackSpeed): number { + return Math.max(1, Math.round(speed / 60)); +} + +/** Enter step/playback mode by parking the cursor on the first event. */ +export function enterReplayPlayback(state: AppState): void { + const events = state.cachedReplayReport?.events; + if (!events || events.length === 0) return; + selectReplayCursorEvent(state, 0); + state.replayPlaybackActive = false; +} + +/** Exit playback mode entirely. Caller is responsible for clearing any timer. */ +export function exitReplayPlayback(state: AppState): void { + state.replayCursorEventIndex = null; + state.replayPlaybackActive = false; +} + +/** + * Move the cursor by `delta` events. Negative values step backwards. + * Clamps at the day's bounds. No-op when not in playback mode. + */ +export function stepReplayCursor(state: AppState, delta: number): void { + const report = state.cachedReplayReport; + if (!report || state.replayCursorEventIndex === null || report.events.length === 0) return; + const next = clamp(state.replayCursorEventIndex + delta, 0, report.events.length - 1); + selectReplayCursorEvent(state, next); +} + +/** + * Jump to the start of the next/previous flow block boundary. If currently + * inside a block, "previous" lands on this block's first event; "next" jumps + * to the next block's first event. + */ +export function jumpReplayCursorToBlockBoundary(state: AppState, dir: 1 | -1): void { + const report = state.cachedReplayReport; + if (!report || state.replayCursorEventIndex === null) return; + if (report.flowBlocks.length === 0 || report.events.length === 0) return; + const currentBlock = blockIndexForEvent(report, state.replayCursorEventIndex); + const currentEventTs = Date.parse(report.events[state.replayCursorEventIndex].timestamp); + + if (dir === 1) { + // Find the next block whose first event is strictly after the current one. + for (let i = currentBlock + 1; i < report.flowBlocks.length; i++) { + const startTs = Date.parse(report.flowBlocks[i].start); + if (startTs > currentEventTs) { + const idx = firstEventIndexOnOrAfter(report.events, startTs); + if (idx !== null) { + selectReplayCursorEvent(state, idx); + return; + } + } + } + // No next block — jump to last event. + selectReplayCursorEvent(state, report.events.length - 1); + return; + } + + // dir === -1: snap to the start of the current block. If already on it, jump to previous block. + const currentBlockStartTs = Date.parse(report.flowBlocks[currentBlock].start); + if (currentEventTs > currentBlockStartTs) { + const idx = firstEventIndexOnOrAfter(report.events, currentBlockStartTs); + if (idx !== null) { + selectReplayCursorEvent(state, idx); + return; + } + } + for (let i = currentBlock - 1; i >= 0; i--) { + const startTs = Date.parse(report.flowBlocks[i].start); + const idx = firstEventIndexOnOrAfter(report.events, startTs); + if (idx !== null) { + selectReplayCursorEvent(state, idx); + return; + } + } + selectReplayCursorEvent(state, 0); +} + +/** + * Jump to the next/previous "interesting" moment. + * Interesting = peak velocity minute, model switches, flow block starts, + * and individual events whose cost exceeds 2× the day's mean event cost. + * Cycles forward (dir = 1) or backward (dir = -1). + */ +export function jumpReplayCursorToInteresting(state: AppState, dir: 1 | -1): void { + const report = state.cachedReplayReport; + if (!report || state.replayCursorEventIndex === null || report.events.length === 0) return; + const points = computeInterestingEventIndices(report); + if (points.length === 0) return; + const cursor = state.replayCursorEventIndex; + let target: number | null = null; + if (dir === 1) { + for (const idx of points) { + if (idx > cursor) { + target = idx; + break; + } + } + if (target === null) target = points[0]; // wrap + } else { + for (let i = points.length - 1; i >= 0; i--) { + if (points[i] < cursor) { + target = points[i]; + break; + } + } + if (target === null) target = points[points.length - 1]; // wrap + } + selectReplayCursorEvent(state, target); +} + +/** Toggle the play loop. Returns the new active state. */ +export function toggleReplayPlayback(state: AppState): boolean { + if (state.replayCursorEventIndex === null) return false; + state.replayPlaybackActive = !state.replayPlaybackActive; + return state.replayPlaybackActive; +} + +/** Advance the cursor by one tick's worth of events. Returns false when at end-of-day. */ +export function tickReplayPlayback(state: AppState): boolean { + const report = state.cachedReplayReport; + if (!report || state.replayCursorEventIndex === null || !state.replayPlaybackActive) { + return false; + } + const advance = eventsPerTick(state.replayPlaybackSpeed); + const next = clamp(state.replayCursorEventIndex + advance, 0, report.events.length - 1); + selectReplayCursorEvent(state, next); + if (next >= report.events.length - 1) { + state.replayPlaybackActive = false; + return false; + } + return true; +} + +export function setReplayPlaybackSpeed(state: AppState, speed: ReplayPlaybackSpeed): void { + state.replayPlaybackSpeed = speed; +} + +/** Cumulative aggregate up to (and including) the cursor's event. */ +export interface PlaybackSummary { + cursorIndex: number; + cursorEvent: UsageEvent; + cumulativeCost: number; + cumulativeTokens: number; + cumulativeInputTokens: number; + cumulativeOutputTokens: number; + cumulativeCacheReadTokens: number; + cumulativeCacheWriteTokens: number; + cacheHitRate: number; + modelMix: Map; +} + +export function computePlaybackSummary(report: ReplayReport, cursorIndex: number): PlaybackSummary | null { + if (report.events.length === 0) return null; + const idx = clamp(cursorIndex, 0, report.events.length - 1); + const cursorEvent = report.events[idx]; + let cost = 0; + let tokens = 0; + let inputT = 0; + let outputT = 0; + let cacheR = 0; + let cacheW = 0; + const mix = new Map(); + for (let i = 0; i <= idx; i++) { + const e = report.events[i]; + cost += e.cost; + tokens += e.totalTokens; + inputT += e.inputTokens; + outputT += e.outputTokens; + cacheR += e.cacheReadTokens; + cacheW += e.cacheWriteTokens; + mix.set(e.model, (mix.get(e.model) ?? 0) + e.totalTokens); + } + const denom = inputT + cacheR; + return { + cursorIndex: idx, + cursorEvent, + cumulativeCost: cost, + cumulativeTokens: tokens, + cumulativeInputTokens: inputT, + cumulativeOutputTokens: outputT, + cumulativeCacheReadTokens: cacheR, + cumulativeCacheWriteTokens: cacheW, + cacheHitRate: denom > 0 ? cacheR / denom : 0, + modelMix: mix, + }; +} + +// ── Helpers ───────────────────────────────────────────────────────────────── + +export function selectReplayCursorEvent(state: AppState, eventIndex: number): void { + const report = state.cachedReplayReport; + if (!report || report.events.length === 0) return; + const next = clamp(eventIndex, 0, report.events.length - 1); + state.replayCursorEventIndex = next; + state.replaySelectedBlockIndex = blockIndexForEvent(report, next); +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +function blockIndexForEvent(report: ReplayReport, eventIdx: number): number { + const events = report.events; + if (eventIdx < 0 || eventIdx >= events.length) return 0; + const ts = Date.parse(events[eventIdx].timestamp); + for (let i = 0; i < report.flowBlocks.length; i++) { + const b = report.flowBlocks[i]; + if (ts >= Date.parse(b.start) && ts <= Date.parse(b.end)) return i; + } + // Fall back to the most recent block whose start is ≤ this event. + let best = 0; + for (let i = 0; i < report.flowBlocks.length; i++) { + if (Date.parse(report.flowBlocks[i].start) <= ts) best = i; + } + return best; +} + +function firstEventIndexOnOrAfter(events: UsageEvent[], ts: number): number | null { + for (let i = 0; i < events.length; i++) { + if (Date.parse(events[i].timestamp) >= ts) return i; + } + return null; +} + +/** + * Compute "interesting" event indices in chronological order. + * Each kind of moment is added once; the merged set is deduped + sorted. + */ +export function computeInterestingEventIndices(report: ReplayReport): number[] { + if (report.events.length === 0) return []; + const set = new Set(); + + // 1. Flow block starts + for (const b of report.flowBlocks) { + const startTs = Date.parse(b.start); + const idx = firstEventIndexOnOrAfter(report.events, startTs); + if (idx !== null) set.add(idx); + } + + // 2. Peak minute (the one with the highest tokensPerMinute) — find first event in that minute + if (report.summary.peakMinute) { + const peakStart = Date.parse(report.summary.peakMinute.minute); + const peakEnd = peakStart + 60_000; + for (let i = 0; i < report.events.length; i++) { + const ts = Date.parse(report.events[i].timestamp); + if (ts >= peakStart && ts < peakEnd) { + set.add(i); + break; + } + } + } + + // 3. Model switches: the event at which the model differs from the previous event + for (let i = 1; i < report.events.length; i++) { + if (report.events[i].model !== report.events[i - 1].model) set.add(i); + } + + // 4. Outlier events: cost > 2× the day's mean event cost + const totalCost = report.events.reduce((s, e) => s + e.cost, 0); + const meanCost = totalCost / report.events.length; + const threshold = meanCost * 2; + for (let i = 0; i < report.events.length; i++) { + if (report.events[i].cost > threshold) set.add(i); + } + + return Array.from(set).sort((a, b) => a - b); +} diff --git a/packages/tui/src/lib/state.ts b/packages/tui/src/lib/state.ts index f909985..52d97df 100644 --- a/packages/tui/src/lib/state.ts +++ b/packages/tui/src/lib/state.ts @@ -28,6 +28,7 @@ export type ViewMode = export type SortMode = 'cost' | 'tokens'; export type ReceiptsSortMode = 'cost' | 'qty' | 'alpha'; export type CursorSetupField = 'label' | 'token'; +export type ReplayPlaybackSpeed = 60 | 240 | 600; export interface ViewTaskState { pendingKeys: Set; @@ -80,6 +81,12 @@ export interface AppState { replayScrollOffset: number; replaySelectedBlockIndex: number; replayExpandedBlockIndex: number | null; + // replay playback / step mode (null cursor = overview, set = step/playback mode) + replayCursorEventIndex: number | null; + replayPlaybackActive: boolean; + replayPlaybackSpeed: ReplayPlaybackSpeed; + /** Port the in-process replay live server is listening on, or null if not started. */ + replayLiveServerPort: number | null; // receipts view state receiptsScrollOffset: number; @@ -140,6 +147,10 @@ export function createInitialState(): AppState { replayScrollOffset: 0, replaySelectedBlockIndex: 0, replayExpandedBlockIndex: null, + replayCursorEventIndex: null, + replayPlaybackActive: false, + replayPlaybackSpeed: 240, + replayLiveServerPort: null, receiptsScrollOffset: 0, receiptsSelectedLineIndex: 0, receiptsExpandedLineIndex: null, diff --git a/packages/tui/src/panels/help.ts b/packages/tui/src/panels/help.ts index 2597640..640ff69 100644 --- a/packages/tui/src/panels/help.ts +++ b/packages/tui/src/panels/help.ts @@ -52,6 +52,7 @@ export function createHelpPanel() { ...helpSection('ACTIONS', [ ['s', 'Toggle sort mode'], ['r', 'Refresh data'], + ['o', 'Open interactive replay (any view)'], ['c', 'Open Cursor setup'], ['q', 'Quit'], ]), @@ -94,7 +95,7 @@ export function createHelpPanel() { ['j / k', 'Select line item'], ['Enter / Space', 'Toggle selected line item'], ['Click', 'Select and toggle a line item'], - ['o', 'Cycle sort (cost / qty / alpha)'], + ['S', 'Cycle sort (cost / qty / alpha)'], ['f', 'Cycle category filter'], ]), ), diff --git a/packages/tui/src/panels/receipts.ts b/packages/tui/src/panels/receipts.ts index 6307e85..3c4d12e 100644 --- a/packages/tui/src/panels/receipts.ts +++ b/packages/tui/src/panels/receipts.ts @@ -101,11 +101,11 @@ export function createReceiptsPanel( fg: COLORS.dimWhite, }), Text({ - content: 'Prompt capture currently only works for Claude Code logs.', + content: 'Prompt capture currently works for Claude Code and Codex logs when prompt text is present.', fg: COLORS.dimWhite, }), Text({ - content: 'Run Claude Code locally to generate logs with prompt text, then press r to refresh.', + content: 'Run Claude Code or Codex locally to generate logs with prompt text, then press r to refresh.', fg: COLORS.dimWhite, }), ); diff --git a/packages/tui/src/panels/replay.test.ts b/packages/tui/src/panels/replay.test.ts new file mode 100644 index 0000000..22b39d9 --- /dev/null +++ b/packages/tui/src/panels/replay.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from 'bun:test'; +import type { ReplayReport, UsageEvent, FlowBlock } from '@tokenleak/core'; +import { createReplayPanel } from './replay'; +import type { ReplayPlaybackView } from './replay'; +import { computePlaybackSummary } from '../lib/replay-playback'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function collectTextContent(node: unknown): string[] { + if (!isRecord(node)) return []; + const props = node['props']; + const ownContent = + isRecord(props) && typeof props['content'] === 'string' ? [props['content']] : []; + const children = Array.isArray(node['children']) + ? node['children'].flatMap((child) => collectTextContent(child)) + : []; + return [...ownContent, ...children]; +} + +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 block(blockIndex: number, start: string, end: string, events: UsageEvent[]): FlowBlock { + return { + blockIndex, + label: 'Deep Flow', + start, + end, + durationMs: Date.parse(end) - Date.parse(start), + eventCount: events.length, + inputTokens: events.reduce((s, e) => s + e.inputTokens, 0), + outputTokens: events.reduce((s, e) => s + e.outputTokens, 0), + cacheReadTokens: events.reduce((s, e) => s + e.cacheReadTokens, 0), + cacheWriteTokens: events.reduce((s, e) => s + e.cacheWriteTokens, 0), + totalTokens: events.reduce((s, e) => s + e.totalTokens, 0), + cost: events.reduce((s, e) => s + e.cost, 0), + dominantModel: events[0]?.model ?? 'unknown', + events, + modelSwitches: 0, + cacheHitRateTrend: [0.4, 0.6], + }; +} + +function makeReport(): ReplayReport { + const events: UsageEvent[] = [ + ev('2026-04-22T09:30:00.000', 'claude-sonnet-4', 1_000, 0.01), + ev('2026-04-22T09:32:00.000', 'claude-sonnet-4', 2_000, 0.02), + ev('2026-04-22T11:00:00.000', 'gpt-5.4', 3_000, 0.03), + ]; + return { + date: '2026-04-22', + events, + flowBlocks: [ + block(0, '2026-04-22T09:30:00.000', '2026-04-22T09:32:00.000', events.slice(0, 2)), + block(1, '2026-04-22T11:00:00.000', '2026-04-22T11:00:00.000', events.slice(2, 3)), + ], + tokenVelocity: [ + { minute: '2026-04-22T09:30:00.000', tokensPerMinute: 1_000 }, + { minute: '2026-04-22T11:00:00.000', tokensPerMinute: 3_000 }, + ], + summary: { + totalSessions: 2, + totalEvents: 3, + flowTimeMs: 120_000, + thinkTimeMs: 5_280_000, + flowThinkRatio: 0.022, + peakMinute: { minute: '2026-04-22T11:00:00.000', tokensPerMinute: 3_000 }, + }, + }; +} + +function makePlayback(): ReplayPlaybackView { + const r = makeReport(); + const summary = computePlaybackSummary(r, 1)!; + const totalDayCost = r.events.reduce((s, e) => s + e.cost, 0); + return { cursorIndex: 1, active: true, speed: 240, summary, totalDayCost }; +} + +describe('createReplayPanel', () => { + test('overview mode renders pulse chart and day summary', () => { + const panel = createReplayPanel(makeReport(), '2026-04-22', 0, null, 0); + const text = collectTextContent(panel).join('\n'); + expect(text).toContain('Pulse (tok/min)'); + expect(text).toContain('Sessions: 2'); + expect(text).toContain('Flow Blocks (2)'); + expect(text).toContain('[s] enter step/playback'); + expect(text).not.toContain('[n/p] step'); + }); + + test('playback mode SLIMS the panel: no pulse chart, no day summary, fewer blocks', () => { + const panel = createReplayPanel( + makeReport(), + '2026-04-22', + 1, + null, + 0, + undefined, + undefined, + makePlayback(), + ); + const text = collectTextContent(panel).join('\n'); + // The two heavy sections must not appear in playback — they're the + // ones that pushed the panel past the terminal viewport and caused + // opentui's flex layout to compress sibling rows on top of each other. + expect(text).not.toContain('Pulse (tok/min)'); + expect(text).not.toContain('Sessions: 2 | Events:'); + // The events-near-cursor list IS shown. + expect(text).toContain('Events near cursor'); + // Help is a SINGLE row, not two. + expect(text.split('\n').filter((l) => l.includes('[space]')).length).toBe(1); + }); + + test('replay panel no longer carries the per-view "press [o]" banner', () => { + // The "open browser" affordance lives in the global footer status bar + // now, so the panel itself should not duplicate it. + const overview = collectTextContent(createReplayPanel(makeReport(), '2026-04-22', 0, null, 0)).join('\n'); + expect(overview).not.toContain('press [o]'); + expect(overview).not.toContain('browser scrub'); + + const playback = collectTextContent( + createReplayPanel(makeReport(), '2026-04-22', 1, null, 0, undefined, undefined, makePlayback()), + ).join('\n'); + expect(playback).not.toContain('press [o]'); + }); + + test('null-report path renders the date header without a banner', () => { + const panel = createReplayPanel(null, '2026-04-22', 0, null, 0); + const text = collectTextContent(panel).join('\n'); + expect(text).toContain('Replay:'); + expect(text).not.toContain('press [o]'); + }); +}); diff --git a/packages/tui/src/panels/replay.ts b/packages/tui/src/panels/replay.ts index 22d38d5..5e8ed5a 100644 --- a/packages/tui/src/panels/replay.ts +++ b/packages/tui/src/panels/replay.ts @@ -1,21 +1,39 @@ import { Box, Text } from '@opentui/core'; -import type { FlowBlock, ReplayReport, TokenVelocityPoint } from '@tokenleak/core'; +import type { FlowBlock, ReplayReport, TokenVelocityPoint, UsageEvent } from '@tokenleak/core'; import { formatCost, formatTokens, formatPercent, formatShortDate, padRight, padLeft, truncate, wrapText } from '../lib/format.js'; import { COLORS, BOLD } from '../lib/theme.js'; +import type { PlaybackSummary } from '../lib/replay-playback.js'; -const HEATMAP_BLOCKS = [' ', '\u2581', '\u2582', '\u2583', '\u2584', '\u2585', '\u2586', '\u2587', '\u2588']; +const HEATMAP_BLOCKS = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; const HEATMAP_SLOTS = 48; export const REPLAY_MAX_CONTENT_WIDTH = 78; const REPLAY_EVENT_DETAIL_LIMIT = 4; export const REPLAY_VISIBLE_BLOCKS = 8; +/** Tighter list during playback so the events-near-cursor section fits. */ +export const REPLAY_VISIBLE_BLOCKS_PLAYBACK = 3; +const PLAYBACK_EVENT_LIST_BEFORE = 2; +const PLAYBACK_EVENT_LIST_AFTER = 4; type ReplayToggleHandler = (blockIndex: number) => void; +export interface ReplayPlaybackView { + cursorIndex: number; + active: boolean; + speed: number; + summary: PlaybackSummary; + totalDayCost: number; +} + function formatTime(iso: string): string { const date = new Date(iso); return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`; } +function formatTimeSeconds(iso: string): string { + const date = new Date(iso); + return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}`; +} + function formatDuration(ms: number): string { if (ms <= 0) return '0s'; const hours = Math.floor(ms / 3_600_000); @@ -24,7 +42,8 @@ function formatDuration(ms: number): string { return `${minutes}m`; } -function renderActivityBar(report: ReplayReport) { +/** Activity bar with an optional ▼ playhead column. */ +function renderActivityBar(report: ReplayReport, playback: ReplayPlaybackView | null) { if (report.events.length === 0) { return Text({ content: ' (no events)', fg: COLORS.dimWhite }); } @@ -46,11 +65,18 @@ function renderActivityBar(report: ReplayReport) { const firstTime = formatTime(report.events[0].timestamp); const lastTime = formatTime(report.events[report.events.length - 1].timestamp); - return Box( - { flexDirection: 'column', width: '100%', paddingLeft: 1, paddingRight: 1 }, - Text({ content: bar, fg: COLORS.green }), - Text({ content: `${firstTime}${' '.repeat(Math.max(1, HEATMAP_SLOTS - firstTime.length - lastTime.length))}${lastTime}`, fg: COLORS.dimWhite }), - ); + const children: ReturnType[] = []; + if (playback) { + const cursorEvent = playback.summary.cursorEvent; + const date = new Date(cursorEvent.timestamp); + const slot = Math.min(date.getHours() * 2 + Math.floor(date.getMinutes() / 30), HEATMAP_SLOTS - 1); + const playhead = ' '.repeat(slot) + '▼'; + children.push(Text({ content: playhead, fg: COLORS.amber, attributes: BOLD })); + } + children.push(Text({ content: bar, fg: COLORS.green })); + children.push(Text({ content: `${firstTime}${' '.repeat(Math.max(1, HEATMAP_SLOTS - firstTime.length - lastTime.length))}${lastTime}`, fg: COLORS.dimWhite })); + + return Box({ flexDirection: 'column', width: '100%', paddingLeft: 1, paddingRight: 1 }, ...children); } function renderDetailLine(content: string, contentWidth: number, fg: string = COLORS.dimWhite) { @@ -61,17 +87,21 @@ function renderFlowBlockCard( block: FlowBlock, selected: boolean, expanded: boolean, + active: boolean, contentWidth: number, onToggleBlock?: ReplayToggleHandler, ) { - const timeRange = `${formatTime(block.start)}\u2013${formatTime(block.end)}`; + const timeRange = `${formatTime(block.start)}–${formatTime(block.end)}`; const headerText = `${timeRange} ${block.label} | ${block.eventCount} events | ${formatTokens(block.totalTokens)} tok | ${formatCost(block.cost)}`; - const cursor = selected ? '\u25B8' : ' '; - const expandIcon = expanded ? '\u25BC' : '\u25B6'; + const cursor = active ? '▶' : selected ? '▸' : ' '; + const expandIcon = expanded ? '▼' : '▶'; + const headerFg = active + ? COLORS.amber + : block.label === 'Deep Flow' ? COLORS.cyan : block.label === 'Quick Lookup' ? COLORS.dimWhite : COLORS.white; const headerLine = Text({ content: truncate(` ${cursor} ${expandIcon} ${headerText}`, contentWidth), - fg: block.label === 'Deep Flow' ? COLORS.cyan : block.label === 'Quick Lookup' ? COLORS.dimWhite : COLORS.white, + fg: headerFg, attributes: BOLD, }); @@ -116,10 +146,10 @@ function renderFlowBlockCard( const first = (trend[0] * 100).toFixed(0); const last = (trend[trend.length - 1] * 100).toFixed(0); if (first !== last) { - const direction = Number(last) > Number(first) ? '\u2191' : '\u2193'; + const direction = Number(last) > Number(first) ? '↑' : '↓'; children.push( renderDetailLine( - `Cache trend: ${first}% \u2192 ${last}% ${direction}`, + `Cache trend: ${first}% → ${last}% ${direction}`, contentWidth, Number(last) > Number(first) ? COLORS.green : COLORS.red, ), @@ -161,7 +191,7 @@ function renderPulseChart(velocity: TokenVelocityPoint[]) { const tpm = velocity[idx].tokensPerMinute; const normalizedHeight = maxTpm > 0 ? (tpm / maxTpm) * (chartHeight - 1) : 0; const threshold = chartHeight - 1 - row; - line += normalizedHeight >= threshold ? '\u2588' : ' '; + line += normalizedHeight >= threshold ? '█' : ' '; } rows.push(line); } @@ -173,7 +203,7 @@ function renderPulseChart(velocity: TokenVelocityPoint[]) { for (let i = 0; i < rows.length; i++) { const label = i === 0 ? padLeft(formatTokens(maxTpm), 7) : i === rows.length - 1 ? padLeft('0', 7) : ' '; children.push( - Text({ content: `${label} \u2502${rows[i]}`, fg: COLORS.green }), + Text({ content: `${label} │${rows[i]}`, fg: COLORS.green }), ); } @@ -203,6 +233,63 @@ function renderDaySummary(report: ReplayReport, contentWidth: number) { ); } +function renderPlaybackHeader(playback: ReplayPlaybackView, totalEvents: number, contentWidth: number) { + const status = playback.active + ? `▶ playing ${playback.speed}×` + : `⏸ paused @ ${formatTimeSeconds(playback.summary.cursorEvent.timestamp)}`; + const statusColor = playback.active ? COLORS.green : COLORS.amber; + const cost = `${formatCost(playback.summary.cumulativeCost)}/${formatCost(playback.totalDayCost)}`; + const counter = `event ${playback.summary.cursorIndex + 1}/${totalEvents}`; + const cacheBit = `cache ${formatPercent(playback.summary.cacheHitRate)}`; + const left = `${cost} · ${counter} · ${cacheBit}`; + const composed = `${left} ${status}`; + return Box( + { flexDirection: 'column', width: '100%', paddingLeft: 1, paddingRight: 1 }, + Text({ content: truncate(composed, contentWidth), fg: statusColor, attributes: BOLD }), + ); +} + +function renderPlaybackEventList(report: ReplayReport, playback: ReplayPlaybackView, contentWidth: number) { + const start = Math.max(0, playback.cursorIndex - PLAYBACK_EVENT_LIST_BEFORE); + const end = Math.min(report.events.length, playback.cursorIndex + PLAYBACK_EVENT_LIST_AFTER + 1); + const lines: ReturnType[] = [ + Text({ content: ' Events near cursor', fg: COLORS.amber, attributes: BOLD }), + ]; + for (let i = start; i < end; i++) { + const e: UsageEvent = report.events[i]; + const isCursor = i === playback.cursorIndex; + const isFuture = i > playback.cursorIndex; + const cacheRate = (e.inputTokens + e.cacheReadTokens) > 0 + ? e.cacheReadTokens / (e.inputTokens + e.cacheReadTokens) + : 0; + const marker = isCursor ? '▶' : ' '; + const line = ` ${marker} ${padRight(formatTimeSeconds(e.timestamp), 9)} ${padRight(truncate(e.model, 22), 23)} ${padLeft(formatTokens(e.totalTokens), 8)} tok cache ${padLeft(formatPercent(cacheRate), 5)} ${padLeft(formatCost(e.cost), 8)}`; + const fg = isCursor ? COLORS.green : isFuture ? COLORS.dimWhite : COLORS.white; + const attributes = isCursor ? BOLD : undefined; + lines.push(Text({ content: truncate(line, contentWidth), fg, attributes })); + } + return Box({ flexDirection: 'column', width: '100%', paddingLeft: 1, paddingRight: 1 }, ...lines); +} + +function renderPlaybackHelp(contentWidth: number) { + // One row only — opentui flex compresses sibling rows when content overflows + // the parent height. Two-line help collapsed onto each other in a real + // terminal screenshot; keep it lean so the panel never overruns. + const line = ' [n/p] step · [N/P] block · [i] interesting · [space] play · [1/2/3] speed · [s] exit'; + return Box( + { flexDirection: 'column', width: '100%', paddingLeft: 1, paddingRight: 1 }, + Text({ content: truncate(line, contentWidth), fg: COLORS.dimWhite }), + ); +} + +function renderOverviewHelp(contentWidth: number) { + const line = ' [s] enter step/playback'; + return Box( + { flexDirection: 'column', width: '100%', paddingLeft: 1, paddingRight: 1 }, + Text({ content: truncate(line, contentWidth), fg: COLORS.dimWhite }), + ); +} + export function createReplayPanel( report: ReplayReport | null, replayDate: string | null, @@ -211,8 +298,9 @@ export function createReplayPanel( scrollOffset: number, contentWidth: number = REPLAY_MAX_CONTENT_WIDTH, onToggleBlock?: ReplayToggleHandler, + playback: ReplayPlaybackView | null = null, ) { - const dateLabel = replayDate ? formatShortDate(replayDate) : '\u2014'; + const dateLabel = replayDate ? formatShortDate(replayDate) : '—'; if (!report) { return Box( @@ -226,7 +314,7 @@ export function createReplayPanel( paddingRight: 1, }, Text({ - content: ` Replay: ${dateLabel} \u25C4 \u25BA `, + content: ` Replay: ${dateLabel} ◄ ► `, fg: COLORS.amber, attributes: BOLD, }), @@ -235,14 +323,19 @@ export function createReplayPanel( ); } + // Playback mode trims the panel: dropping the pulse chart + day summary + // and shrinking the flow-block list keeps total rows ≲ 22 so opentui's + // flex layout never compresses sibling Text rows on top of each other. + const visibleBlocks = playback ? REPLAY_VISIBLE_BLOCKS_PLAYBACK : REPLAY_VISIBLE_BLOCKS; const totalCost = report.events.reduce((sum, e) => sum + e.cost, 0); - const safeOffset = Math.max(0, Math.min(scrollOffset, Math.max(0, report.flowBlocks.length - REPLAY_VISIBLE_BLOCKS))); + const safeOffset = Math.max(0, Math.min(scrollOffset, Math.max(0, report.flowBlocks.length - visibleBlocks))); const blockCards = report.flowBlocks - .slice(safeOffset, safeOffset + REPLAY_VISIBLE_BLOCKS) + .slice(safeOffset, safeOffset + visibleBlocks) .map((block) => renderFlowBlockCard( block, block.blockIndex === selectedBlockIndex, block.blockIndex === expandedBlockIndex, + playback !== null && block.blockIndex === selectedBlockIndex, contentWidth, onToggleBlock, )); @@ -256,16 +349,14 @@ export function createReplayPanel( scrollIndicators.push(Text({ content: ` ${below} more below`, fg: COLORS.dimWhite })); } - return Box( - { - flexDirection: 'column', - width: '100%', - flexGrow: 1, - borderStyle: 'single', - borderColor: COLORS.dimWhite, - }, + const titleSuffix = playback ? ' [PLAYBACK]' : ''; + const playbackHeader = playback ? renderPlaybackHeader(playback, report.events.length, contentWidth) : null; + const playbackEvents = playback ? renderPlaybackEventList(report, playback, contentWidth) : null; + const help = playback ? renderPlaybackHelp(contentWidth) : renderOverviewHelp(contentWidth); + + const children: ReturnType[] = [ Text({ - content: ` Replay: ${dateLabel} \u25C4 \u25BA `, + content: ` Replay: ${dateLabel} ◄ ►${titleSuffix}`, fg: COLORS.amber, attributes: BOLD, }), @@ -273,9 +364,15 @@ export function createReplayPanel( { flexDirection: 'row', width: '100%', paddingLeft: 1, paddingRight: 1 }, Text({ content: `Total: ${formatCost(totalCost)}`, fg: COLORS.green }), ), - Text({ content: '', fg: COLORS.dimWhite }), - renderActivityBar(report), - Text({ content: '', fg: COLORS.dimWhite }), + ]; + if (playbackHeader) { + children.push(Text({ content: '', fg: COLORS.dimWhite })); + children.push(playbackHeader); + } + children.push(Text({ content: '', fg: COLORS.dimWhite })); + children.push(renderActivityBar(report, playback)); + children.push(Text({ content: '', fg: COLORS.dimWhite })); + children.push( Box( { flexDirection: 'column', width: '100%', paddingLeft: 1, paddingRight: 1 }, Text({ @@ -284,11 +381,30 @@ export function createReplayPanel( attributes: BOLD, }), ), - ...blockCards, - ...scrollIndicators, - Text({ content: '', fg: COLORS.dimWhite }), - renderPulseChart(report.tokenVelocity), - Text({ content: '', fg: COLORS.dimWhite }), - renderDaySummary(report, contentWidth), + ); + children.push(...blockCards); + children.push(...scrollIndicators); + if (playbackEvents) { + children.push(Text({ content: '', fg: COLORS.dimWhite })); + children.push(playbackEvents); + } + if (!playback) { + children.push(Text({ content: '', fg: COLORS.dimWhite })); + children.push(renderPulseChart(report.tokenVelocity)); + children.push(Text({ content: '', fg: COLORS.dimWhite })); + children.push(renderDaySummary(report, contentWidth)); + } + children.push(Text({ content: '', fg: COLORS.dimWhite })); + children.push(help); + + return Box( + { + flexDirection: 'column', + width: '100%', + flexGrow: 1, + borderStyle: 'single', + borderColor: COLORS.dimWhite, + }, + ...children, ); } diff --git a/packages/tui/src/panels/status-bar.test.ts b/packages/tui/src/panels/status-bar.test.ts index 9e029c0..b7a4904 100644 --- a/packages/tui/src/panels/status-bar.test.ts +++ b/packages/tui/src/panels/status-bar.test.ts @@ -44,6 +44,35 @@ describe('buildStatusBar', () => { expect(text).toContain('Refresh failed: provider blew up'); expect(text).toContain('r:retry'); }); + + test('always shows the [o] interactive replay CTA chip on every non-modal view', () => { + const state = createInitialState(); + state.isLoading = false; + + const overviewText = collectTextContent(buildStatusBar(state)).join(''); + expect(overviewText).toContain('[o] interactive replay'); + + state.selectedView = 'wrapped'; + const wrappedText = collectTextContent(buildStatusBar(state)).join(''); + expect(wrappedText).toContain('[o] interactive replay'); + + state.selectedView = 'receipts'; + const receiptsText = collectTextContent(buildStatusBar(state)).join(''); + expect(receiptsText).toContain('[o] interactive replay'); + // Receipts sort moved off [o] — make sure the new key shows up. + expect(receiptsText).toContain('S:sort'); + expect(receiptsText).not.toContain('o:sort'); + }); + + test('CTA chip swaps to the open-status form once the live server is running', () => { + const state = createInitialState(); + state.isLoading = false; + state.replayLiveServerPort = 3567; + + const text = collectTextContent(buildStatusBar(state)).join(''); + expect(text).toContain('replay open :3567'); + expect(text).not.toContain('[o] interactive replay'); + }); }); describe('createHelpPanel', () => { diff --git a/packages/tui/src/panels/status-bar.ts b/packages/tui/src/panels/status-bar.ts index fe721fd..d7eb896 100644 --- a/packages/tui/src/panels/status-bar.ts +++ b/packages/tui/src/panels/status-bar.ts @@ -1,5 +1,5 @@ import { Box, Text } from '@opentui/core'; -import { COLORS } from '../lib/theme.js'; +import { COLORS, BOLD } from '../lib/theme.js'; import type { AppState } from '../lib/state.js'; function formatUpdateTime(): string { @@ -9,6 +9,27 @@ function formatUpdateTime(): string { return `${hours}:${minutes}`; } +/** + * Build the bright-emerald global CTA chip. Always shown in the footer + * (any view) so the interactive browser replay is one keystroke away + * from anywhere in the TUI. When the server is already running, swap + * the text to a status indicator instead. + */ +export function buildReplayCtaChip(state: AppState) { + if (state.replayLiveServerPort !== null) { + return Text({ + content: ` ✓ replay open :${state.replayLiveServerPort} · `, + fg: COLORS.green, + attributes: BOLD, + }); + } + return Text({ + content: ' ▶ [o] interactive replay · ', + fg: COLORS.green, + attributes: BOLD, + }); +} + export function buildStatusBar(state: AppState) { if (state.isLoading) { return Box( @@ -86,7 +107,7 @@ export function buildStatusBar(state: AppState) { } const helpHint = '?:keys'; - const nav = `\u2190\u2192:view tab/\u21E7tab:period`; + const nav = `←→:view tab/⇧tab:period`; const cursorHint = ' c:cursor'; let keys: string; @@ -99,7 +120,7 @@ export function buildStatusBar(state: AppState) { } else if (state.selectedView === 'replay') { keys = `${nav} h/l:date j/k:select enter/space:toggle r:refresh${cursorHint} ${helpHint} q:quit`; } else if (state.selectedView === 'receipts') { - keys = `${nav} j/k:select enter/space:toggle o:sort f:filter r:refresh${cursorHint} ${helpHint} q:quit`; + keys = `${nav} j/k:select enter/space:toggle S:sort f:filter r:refresh${cursorHint} ${helpHint} q:quit`; } else if ( state.selectedView === 'advisor' || state.selectedView === 'focus' || @@ -114,6 +135,9 @@ export function buildStatusBar(state: AppState) { keys = `${nav} r:refresh${cursorHint} ${helpHint} q:quit`; } + // Always-visible CTA on the left, view-specific keymap immediately + // after, timestamp on the right. The bright emerald chip next to + // dim-white hints is the "highlighted at any cost" affordance. return Box( { flexDirection: 'row', @@ -123,7 +147,11 @@ export function buildStatusBar(state: AppState) { paddingRight: 1, height: 1, }, - Text({ content: keys, fg: COLORS.dimWhite }), + Box( + { flexDirection: 'row' }, + buildReplayCtaChip(state), + Text({ content: keys, fg: COLORS.dimWhite }), + ), Text({ content: `Updated ${formatUpdateTime()}`, fg: COLORS.dimWhite,