From c2bc5cb418c8802f3145f70885e624e4406dc9ed Mon Sep 17 00:00:00 2001 From: Deva Annamaraju Date: Mon, 27 Apr 2026 02:26:04 +0530 Subject: [PATCH 01/13] feat(tui-replay): step + VCR playback foundations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the pure-state side of step / play modes for the TUI replay panel: - New `replayCursorEventIndex` / `replayPlaybackActive` / `replayPlaybackSpeed` fields on AppState. Cursor null = overview mode (existing UX). Cursor set = step mode. Active flag is the play/pause toggle on top of step mode. - New `lib/replay-playback.ts` module with deterministic helpers: enter / exit; step ±N events; jump to next/prev block boundary; jump to next/prev "interesting" moment (peak minute, model switches, block starts, outlier-cost events); toggle / tick play loop; set speed (60×/240×/600× → 1/4/10 events per 100ms tick); computePlaybackSummary for cumulative cost/tokens/cache/model-mix. - Replay panel learns a `playback: ReplayPlaybackView | null` parameter: when set, prepends a status line (cumulative cost, event N/M, cache rate, ▶ playing / ⏸ paused), draws a ▼ playhead column on the activity bar, marks the active block in amber, and shows an "events near cursor" list (±2 around the cursor event). - Help footer rotates between overview ("[s] enter step mode") and playback ([n/p step, N/P block, i interesting, space play, …]). This commit ships only the data + render plumbing. The next commit wires the keyboard + the play-loop timer into tui/index.ts. Tests: 25 cases in lib/replay-playback.test.ts covering boundaries, clamping, block tracking, interesting-moment detection, tick stop at end-of-day, no-op safety when cursor is null or report empty. --- packages/tui/src/lib/replay-playback.test.ts | 290 +++++++++++++++++++ packages/tui/src/lib/replay-playback.ts | 276 ++++++++++++++++++ packages/tui/src/lib/state.ts | 8 + packages/tui/src/panels/replay.ts | 179 +++++++++--- 4 files changed, 717 insertions(+), 36 deletions(-) create mode 100644 packages/tui/src/lib/replay-playback.test.ts create mode 100644 packages/tui/src/lib/replay-playback.ts 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..0735be9 --- /dev/null +++ b/packages/tui/src/lib/replay-playback.test.ts @@ -0,0 +1,290 @@ +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, +} 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('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..5558cc6 --- /dev/null +++ b/packages/tui/src/lib/replay-playback.ts @@ -0,0 +1,276 @@ +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; + state.replayCursorEventIndex = 0; + state.replayPlaybackActive = false; + state.replaySelectedBlockIndex = blockIndexForEvent(state.cachedReplayReport!, 0); +} + +/** 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); + state.replayCursorEventIndex = next; + state.replaySelectedBlockIndex = blockIndexForEvent(report, 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) { + state.replayCursorEventIndex = idx; + state.replaySelectedBlockIndex = blockIndexForEvent(report, idx); + return; + } + } + } + // No next block — jump to last event. + state.replayCursorEventIndex = report.events.length - 1; + state.replaySelectedBlockIndex = blockIndexForEvent(report, state.replayCursorEventIndex); + 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) { + state.replayCursorEventIndex = idx; + state.replaySelectedBlockIndex = currentBlock; + 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) { + state.replayCursorEventIndex = idx; + state.replaySelectedBlockIndex = i; + return; + } + } + state.replayCursorEventIndex = 0; + state.replaySelectedBlockIndex = blockIndexForEvent(report, 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 + } + state.replayCursorEventIndex = target; + state.replaySelectedBlockIndex = blockIndexForEvent(report, 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); + state.replayCursorEventIndex = next; + state.replaySelectedBlockIndex = blockIndexForEvent(report, 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 ───────────────────────────────────────────────────────────────── + +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..b171938 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,10 @@ 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; // receipts view state receiptsScrollOffset: number; @@ -140,6 +145,9 @@ export function createInitialState(): AppState { replayScrollOffset: 0, replaySelectedBlockIndex: 0, replayExpandedBlockIndex: null, + replayCursorEventIndex: null, + replayPlaybackActive: false, + replayPlaybackSpeed: 240, receiptsScrollOffset: 0, receiptsSelectedLineIndex: 0, receiptsExpandedLineIndex: null, diff --git a/packages/tui/src/panels/replay.ts b/packages/tui/src/panels/replay.ts index 22d38d5..9e65f0b 100644 --- a/packages/tui/src/panels/replay.ts +++ b/packages/tui/src/panels/replay.ts @@ -1,21 +1,37 @@ 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; +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 +40,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 +63,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 +85,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 +144,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 +189,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 +201,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 +231,62 @@ 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) { + const line1 = ' [n/p] step [N/P] block [i] interesting [home/end] jump'; + const line2 = ' [space] play/pause [1/2/3] speed [s/esc] exit playback'; + return Box( + { flexDirection: 'column', width: '100%', paddingLeft: 1, paddingRight: 1 }, + Text({ content: truncate(line1, contentWidth), fg: COLORS.dimWhite }), + Text({ content: truncate(line2, contentWidth), fg: COLORS.dimWhite }), + ); +} + +function renderOverviewHelp(contentWidth: number) { + const line = ' [s] enter step/playback mode ([n/p] step events, [space] play, [i] jump to interesting)'; + 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 +295,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 +311,7 @@ export function createReplayPanel( paddingRight: 1, }, Text({ - content: ` Replay: ${dateLabel} \u25C4 \u25BA `, + content: ` Replay: ${dateLabel} ◄ ► `, fg: COLORS.amber, attributes: BOLD, }), @@ -243,6 +328,7 @@ export function createReplayPanel( block, block.blockIndex === selectedBlockIndex, block.blockIndex === expandedBlockIndex, + playback !== null && block.blockIndex === selectedBlockIndex, contentWidth, onToggleBlock, )); @@ -256,16 +342,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 +357,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 +374,28 @@ 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); + } + 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, ); } From 4f7e713ac640a5eff6db362f2ba7f2c5ad149223 Mon Sep 17 00:00:00 2001 From: Deva Annamaraju Date: Mon, 27 Apr 2026 02:26:23 +0530 Subject: [PATCH 02/13] feat(tui-replay): wire step / VCR keyboard + timer into the input loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Routes replay-view input through a new handleReplayPlaybackInput dispatcher that activates only on the replay view: - s enter step mode (overview only) - s/Esc exit step mode (in step mode) - n / → next event (pauses if playing) - p / ← previous event - N / P next / previous flow-block boundary - i / I next / previous interesting moment - Home jump to first event - End jump to last event - space play / pause (overrides the existing block-toggle for replay-only) - 1/2/3 set playback speed to 60× / 240× / 600× Other keys (q, j/k for block scroll, h/l for date shift, view switches) fall through to the regular replay handlers, so step mode is purely additive. A module-level setInterval drives the play loop at 100ms ticks; the ticker advances `eventsPerTick(speed)` events per fire and stops itself when it reaches the end of the day OR the user pauses. Switching away from the replay view (or shifting date with h/l) calls `resetReplayInteraction`, which now also exits playback and clears the timer — so we never leave a runaway interval behind. --- packages/tui/src/index.ts | 197 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 93d48c6..f0a174c 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -51,6 +51,20 @@ 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 type { ReplayPlaybackView } from './panels/replay.js'; +import { + REPLAY_PLAYBACK_TICK_MS, + computePlaybackSummary, + enterReplayPlayback, + exitReplayPlayback, + jumpReplayCursorToBlockBoundary, + jumpReplayCursorToInteresting, + setReplayPlaybackSpeed, + stepReplayCursor, + tickReplayPlayback, + toggleReplayPlayback, +} from './lib/replay-playback.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'; @@ -329,6 +343,7 @@ function buildContent(state: AppState, renderer: CliRenderer) { toggleReplayBlock(state, blockIndex); render(state, renderer); }, + buildReplayPlaybackView(state), ); case 'nutrition': if (!hasWindowData) { @@ -630,8 +645,186 @@ function resetReplayInteraction(state: AppState): void { state.replayScrollOffset = 0; state.replaySelectedBlockIndex = 0; state.replayExpandedBlockIndex = null; + exitReplayPlayback(state); + stopReplayPlaybackTimer(); +} + +let replayPlaybackTimer: ReturnType | null = null; + +function startReplayPlaybackTimer(): void { + if (replayPlaybackTimer !== null) return; + replayPlaybackTimer = setInterval(() => { + if (!currentState.replayPlaybackActive) { + stopReplayPlaybackTimer(); + return; + } + const advanced = tickReplayPlayback(currentState); + if (!advanced) { + stopReplayPlaybackTimer(); + } + render(currentState, currentRenderer); + }, REPLAY_PLAYBACK_TICK_MS); +} + +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); + 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); + render(state, renderer); + return true; + } + if (sequence === 'p' || sequence === '\x1b[D') { + pauseReplayPlaybackIfRunning(state); + stepReplayCursor(state, -1); + render(state, renderer); + return true; + } + if (sequence === 'N') { + pauseReplayPlaybackIfRunning(state); + jumpReplayCursorToBlockBoundary(state, 1); + render(state, renderer); + return true; + } + if (sequence === 'P') { + pauseReplayPlaybackIfRunning(state); + jumpReplayCursorToBlockBoundary(state, -1); + render(state, renderer); + return true; + } + if (sequence === 'i') { + pauseReplayPlaybackIfRunning(state); + jumpReplayCursorToInteresting(state, 1); + render(state, renderer); + return true; + } + if (sequence === 'I') { + pauseReplayPlaybackIfRunning(state); + jumpReplayCursorToInteresting(state, -1); + render(state, renderer); + return true; + } + if (sequence === '\x1b[H' || sequence === '\x1bOH') { + pauseReplayPlaybackIfRunning(state); + state.replayCursorEventIndex = 0; + state.replaySelectedBlockIndex = 0; + render(state, renderer); + return true; + } + if (sequence === '\x1b[F' || sequence === '\x1bOF') { + pauseReplayPlaybackIfRunning(state); + state.replayCursorEventIndex = events.length - 1; + state.replaySelectedBlockIndex = state.cachedReplayReport!.flowBlocks.length > 0 + ? state.cachedReplayReport!.flowBlocks.length - 1 + : 0; + 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) { + state.replayCursorEventIndex = 0; + } + 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(); + } } +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; @@ -1089,6 +1282,10 @@ export async function main(): Promise { return true; } + if (handleReplayPlaybackInput(sequence, state, renderer)) { + return true; + } + // Help toggle: ? key if (sequence === '?') { state.showHelp = !state.showHelp; From 5364de2e01981c2d9599719ad8387d361d13e79b Mon Sep 17 00:00:00 2001 From: Deva Annamaraju Date: Mon, 27 Apr 2026 02:26:44 +0530 Subject: [PATCH 03/13] feat(replay): add asciinema cast export via --record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tokenleak replay --record day.cast` (alias --cast) renders the day as an asciinema v2 cast file: open it with `asciinema play` to get a cinematic playback of your AI session — every prompt's cumulative cost, model switches, cache trend, and the active flow block tick by in real time at the chosen speed. Implementation: - New packages/cli/src/replay-cast.ts: pure renderer that emits one frame per real event with a screen-clear-then-redraw payload, timed at (event.ts - dayStart) / speed seconds. Bursty stretches scrub fast, idle stretches give natural pauses. - Per-frame layout (plain text, fits 100x32 by default): title + event counter cost / tokens / cache · clock · current event activity heatmap with a ▼ playhead column active block info events-near-cursor list (±2) cumulative model-mix bar chart - New CLI flags on `replay`: --record / --cast write a v2 cast file --speed playback speed (default 240) - Mutual exclusion: `--record` and `--interactive` cannot be combined; `--format / --output / --width / --port / --open / --speed` are warned-and-ignored when the wrong mode is set. - Empty days emit a single placeholder frame instead of failing. Tests: - packages/cli/src/replay-cast.test.ts: 8 cases — header shape, per-event frame count, frame-tuple JSON shape, speed scaling, screen-clear escape prefix, cumulative cost / model-mix in final frame, empty-day placeholder. - packages/cli/src/replay-cli.test.ts: 7 new cases for --record / --cast / --speed parsing + validation (positive number, ≤10000, required value). Smoke: `tokenleak replay 2026-04-22 --record /tmp/day.cast --speed 240` on a real 211-event day produced a 212-line valid cast that plays cleanly under `asciinema play`. --- packages/cli/src/cli.ts | 55 +++++- packages/cli/src/replay-cast.test.ts | 134 +++++++++++++ packages/cli/src/replay-cast.ts | 282 +++++++++++++++++++++++++++ packages/cli/src/replay-cli.test.ts | 23 +++ packages/cli/src/replay.ts | 4 + 5 files changed, 497 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/replay-cast.test.ts create mode 100644 packages/cli/src/replay-cast.ts diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 229d81d..b91eccb 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -82,6 +82,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, @@ -2078,6 +2079,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}"`); } @@ -2351,7 +2377,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 +2406,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`, 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..66fe375 100644 --- a/packages/cli/src/replay-cli.test.ts +++ b/packages/cli/src/replay-cli.test.ts @@ -40,6 +40,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..c88e284 100644 --- a/packages/cli/src/replay.ts +++ b/packages/cli/src/replay.ts @@ -202,6 +202,8 @@ export function buildReplayHelpText(): string { ' -i, --interactive Open the replay in a browser scrub UI on http://localhost:3567', ' --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 +213,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'); } From da7b1a9acba948973c5dbb9b4061749d1a9ba43e Mon Sep 17 00:00:00 2001 From: Deva Annamaraju Date: Mon, 27 Apr 2026 03:01:58 +0530 Subject: [PATCH 04/13] fix(tui-replay): slim playback layout + always-visible browser banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live testing surfaced two issues with PR #128's TUI playback view: 1. Glyph overlap. The playback overlay added 3 sections (status header, events-near-cursor, 2-line help) on top of an already-30-row panel. When the total exceeded the terminal viewport, opentui's flex layout silently *compressed* sibling rows on top of each other — the "Events near cursor" header collapsed onto the first event row, the "Pulse (tok/min)" label onto the y-axis label, the day-summary onto the peak label, and the 2-line help footer onto a single jumbled row. 2. The browser interactive scrub had no in-TUI affordance — new users never discover it. Fix: - In playback mode, drop the standalone pulse chart (the activity bar already shows the same shape with a ▼ playhead) and the day-summary line (its data lives in the playback status header). Shrink the flow-block list from 8 → 3 rows. Collapse the 2-line help footer to a single compact row. Total panel rows now ≲ 22 — comfortably fits on a standard 24-row terminal. - Always render a prominent "press [b] to open the interactive browser scrub" banner directly under the title, in BOTH overview and playback modes. Once a port is set on state, the banner switches to a one-line "✓ browser open at http://localhost:/" status. The flow-block list, activity bar, status header, events-near-cursor list, and help row remain. The overlap is gone at terminals 80×24+. Tests (packages/tui/src/panels/replay.test.ts, 5 new cases): - overview mode renders the pulse chart + day summary - playback mode does NOT (and the help row collapses to one line) - the "press [b]" banner is visible in both modes - once liveServerPort is set, the banner becomes "browser open" status - null-report path still shows the banner --- packages/tui/src/panels/replay.test.ts | 157 +++++++++++++++++++++++++ packages/tui/src/panels/replay.ts | 65 ++++++++-- 2 files changed, 211 insertions(+), 11 deletions(-) create mode 100644 packages/tui/src/panels/replay.test.ts diff --git a/packages/tui/src/panels/replay.test.ts b/packages/tui/src/panels/replay.test.ts new file mode 100644 index 0000000..3e2e5c9 --- /dev/null +++ b/packages/tui/src/panels/replay.test.ts @@ -0,0 +1,157 @@ +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)'); + }); + + 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('"press [b]" banner is visible in BOTH overview and playback', () => { + const overview = collectTextContent(createReplayPanel(makeReport(), '2026-04-22', 0, null, 0)).join('\n'); + expect(overview).toContain('press [b]'); + + const playback = collectTextContent( + createReplayPanel(makeReport(), '2026-04-22', 1, null, 0, undefined, undefined, makePlayback()), + ).join('\n'); + expect(playback).toContain('press [b]'); + }); + + test('once a server port is set, the banner becomes a "browser open" status', () => { + const panel = createReplayPanel( + makeReport(), + '2026-04-22', + 0, + null, + 0, + undefined, + undefined, + null, + 3567, + ); + const text = collectTextContent(panel).join('\n'); + expect(text).toContain('browser open at http://localhost:3567/'); + expect(text).not.toContain('press [b] to open'); + }); + + test('null-report path still shows the banner', () => { + const panel = createReplayPanel(null, '2026-04-22', 0, null, 0); + const text = collectTextContent(panel).join('\n'); + expect(text).toContain('press [b]'); + }); +}); diff --git a/packages/tui/src/panels/replay.ts b/packages/tui/src/panels/replay.ts index 9e65f0b..4719ffa 100644 --- a/packages/tui/src/panels/replay.ts +++ b/packages/tui/src/panels/replay.ts @@ -9,6 +9,8 @@ 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; @@ -270,23 +272,53 @@ function renderPlaybackEventList(report: ReplayReport, playback: ReplayPlaybackV } function renderPlaybackHelp(contentWidth: number) { - const line1 = ' [n/p] step [N/P] block [i] interesting [home/end] jump'; - const line2 = ' [space] play/pause [1/2/3] speed [s/esc] exit playback'; + // 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(line1, contentWidth), fg: COLORS.dimWhite }), - Text({ content: truncate(line2, contentWidth), fg: COLORS.dimWhite }), + Text({ content: truncate(line, contentWidth), fg: COLORS.dimWhite }), ); } function renderOverviewHelp(contentWidth: number) { - const line = ' [s] enter step/playback mode ([n/p] step events, [space] play, [i] jump to interesting)'; + const line = ' [s] enter step/playback · [n/p] step · [space] play · [i] interesting · [b] open browser'; return Box( { flexDirection: 'column', width: '100%', paddingLeft: 1, paddingRight: 1 }, Text({ content: truncate(line, contentWidth), fg: COLORS.dimWhite }), ); } +/** + * Big "press [b] to open the interactive browser scrub" banner. Always + * shown above the playback header in BOTH overview and playback modes — + * the browser experience is the better one for visual scrubbing and we + * want it discoverable from anywhere on this view. + * + * When `liveServerPort` is set, swaps to a one-line success state. + */ +function renderBrowserBanner(contentWidth: number, liveServerPort: number | null) { + if (liveServerPort !== null) { + const status = ` ✓ browser open at http://localhost:${liveServerPort}/ · press [b] again to re-open`; + return Box( + { flexDirection: 'column', width: '100%', paddingLeft: 1, paddingRight: 1 }, + Text({ content: truncate(status, contentWidth), fg: COLORS.green, attributes: BOLD }), + ); + } + const inner = ' ▶ press [b] to open the interactive browser scrub ⟶'; + const innerWidth = Math.min(contentWidth - 2, 60); + const top = '╭' + '─'.repeat(innerWidth) + '╮'; + const middle = '│' + truncate(inner.padEnd(innerWidth), innerWidth) + '│'; + const bottom = '╰' + '─'.repeat(innerWidth) + '╯'; + return Box( + { flexDirection: 'column', width: '100%', paddingLeft: 1, paddingRight: 1 }, + Text({ content: top, fg: COLORS.green }), + Text({ content: middle, fg: COLORS.green, attributes: BOLD }), + Text({ content: bottom, fg: COLORS.green }), + ); +} + export function createReplayPanel( report: ReplayReport | null, replayDate: string | null, @@ -296,6 +328,7 @@ export function createReplayPanel( contentWidth: number = REPLAY_MAX_CONTENT_WIDTH, onToggleBlock?: ReplayToggleHandler, playback: ReplayPlaybackView | null = null, + liveServerPort: number | null = null, ) { const dateLabel = replayDate ? formatShortDate(replayDate) : '—'; @@ -316,14 +349,20 @@ export function createReplayPanel( attributes: BOLD, }), Text({ content: '', fg: COLORS.dimWhite }), + renderBrowserBanner(contentWidth, liveServerPort), + Text({ content: '', fg: COLORS.dimWhite }), Text({ content: 'No data available for this date', fg: COLORS.dimWhite }), ); } + // 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, @@ -357,6 +396,8 @@ export function createReplayPanel( { flexDirection: 'row', width: '100%', paddingLeft: 1, paddingRight: 1 }, Text({ content: `Total: ${formatCost(totalCost)}`, fg: COLORS.green }), ), + Text({ content: '', fg: COLORS.dimWhite }), + renderBrowserBanner(contentWidth, liveServerPort), ]; if (playbackHeader) { children.push(Text({ content: '', fg: COLORS.dimWhite })); @@ -381,10 +422,12 @@ export function createReplayPanel( children.push(Text({ content: '', fg: COLORS.dimWhite })); children.push(playbackEvents); } - children.push(Text({ content: '', fg: COLORS.dimWhite })); - children.push(renderPulseChart(report.tokenVelocity)); - children.push(Text({ content: '', fg: COLORS.dimWhite })); - children.push(renderDaySummary(report, contentWidth)); + 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); From b895a32624a28cb36c1c863465416b0ab3a53e8a Mon Sep 17 00:00:00 2001 From: Deva Annamaraju Date: Mon, 27 Apr 2026 03:02:14 +0530 Subject: [PATCH 05/13] =?UTF-8?q?feat(tui-replay):=20wire=20[b]=20key=20?= =?UTF-8?q?=E2=86=92=20in-process=20browser=20launch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the second half of the press-[b] affordance: the actual key handler + server lifecycle. - New `replayLiveServerPort: number | null` on AppState. Drives the banner state in the panel. - Press `b` from the replay view (works in both overview and playback modes) → start startReplayLiveServer in-process, store the port, spawn the OS open command (`open` on macOS, `xdg-open` on Linux, `start` on Windows). Idempotent: a second `b` re-opens the browser to the existing server. - Server cleanup is bound to the TUI's lifecycle: * `resetReplayInteraction` (called on view-switch and date-shift) now also stops the server, * the `q` quit handler stops it before destroying the renderer. No orphan process after exit. - The OS-open shim is duplicated from packages/cli/src/sharing/open.ts (~10 lines) to avoid introducing a new shared package just for this. --- packages/tui/src/index.ts | 62 ++++++++++++++++++++++++++++++++++- packages/tui/src/lib/state.ts | 3 ++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index f0a174c..78f551f 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 { @@ -321,6 +321,9 @@ function buildContent(state: AppState, renderer: CliRenderer) { state.replayExpandedBlockIndex, state.replayScrollOffset, getPanelContentWidth(renderer, REPLAY_MAX_CONTENT_WIDTH), + undefined, + null, + state.replayLiveServerPort, ); } if (!state.replayDate) { @@ -344,6 +347,7 @@ function buildContent(state: AppState, renderer: CliRenderer) { render(state, renderer); }, buildReplayPlaybackView(state), + state.replayLiveServerPort, ); case 'nutrition': if (!hasWindowData) { @@ -647,6 +651,7 @@ function resetReplayInteraction(state: AppState): void { state.replayExpandedBlockIndex = null; exitReplayPlayback(state); stopReplayPlaybackTimer(); + stopReplayLiveServer(state); } let replayPlaybackTimer: ReturnType | null = null; @@ -692,6 +697,12 @@ function handleReplayPlaybackInput( if (state.selectedView !== 'replay') return false; const events = state.cachedReplayReport?.events; + // Browser launcher — works from BOTH overview and playback modes. + if (sequence === 'b' && events && events.length > 0) { + void launchReplayBrowser(state, renderer); + return true; + } + // Toggle entry from overview mode. if (state.replayCursorEventIndex === null) { if (sequence === 's' && events && events.length > 0) { @@ -807,6 +818,54 @@ function pauseReplayPlaybackIfRunning(state: AppState): void { } } +// ── In-process replay live server (launched via `b` from the replay view) ── + +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.cachedReplayReport) return; + try { + const { port, stop } = await startReplayLiveServer(state.cachedReplayReport); + state.replayLiveServerPort = port; + replayLiveServerStop = stop; + render(state, renderer); + openUrlInBrowser(`http://localhost:${port}/`); + } catch (err) { + // surface the error in the help row briefly; for now just log to stderr + process.stderr.write(`failed to start replay live server: ${err instanceof Error ? err.message : String(err)}\n`); + } +} + +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) { @@ -1526,6 +1585,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/state.ts b/packages/tui/src/lib/state.ts index b171938..52d97df 100644 --- a/packages/tui/src/lib/state.ts +++ b/packages/tui/src/lib/state.ts @@ -85,6 +85,8 @@ export interface AppState { 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; @@ -148,6 +150,7 @@ export function createInitialState(): AppState { replayCursorEventIndex: null, replayPlaybackActive: false, replayPlaybackSpeed: 240, + replayLiveServerPort: null, receiptsScrollOffset: 0, receiptsSelectedLineIndex: 0, receiptsExpandedLineIndex: null, From a80d104e13a976db842fd0ef243c368f3d8ce056 Mon Sep 17 00:00:00 2001 From: Deva Annamaraju Date: Mon, 27 Apr 2026 03:02:41 +0530 Subject: [PATCH 06/13] feat(replay-live): heatmap day-navigation in the browser scrubber MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The interactive replay page becomes multi-day. A GitHub-style 7×13 heatmap (last 90 days) sits above the cost odometer; click any day to jump to that day's replay without restarting the CLI. Server (packages/renderers/src/live/replay-live-server.ts): - startReplayLiveServer now accepts ReplayReport | ReplayLiveDataProvider. Single-day mode (the existing call shape) is unchanged — no heatmap rendered, no new routes exposed. - Multi-day mode adds: * `?date=YYYY-MM-DD` on GET / → server-side render of that day (no JS-side rebuild needed; full reload, but cheap on local data) * `GET /api/replay?date=YYYY-MM-DD` → JSON ReplayReport (200) or 404 / 400 with structured error * Empty-day fallback: if getReport returns null, the page renders a "nothing happened on YYYY-MM-DD" empty state instead of erroring Template (packages/renderers/src/live/replay-live-template.ts): - New ReplayLiveHtmlOptions param on generateReplayLiveHtml. - Heatmap is rendered server-side as anchor cells — clicks just navigate to /?date=…, no JS required for the date switch. - Layout: 7 rows (Sun–Sat) × 13 cols of 14×14px cells, gap 3px. Active day has a thick emerald outline. Empty days are dim-bordered. Hover shows date + tokens + cost. Month labels along the top. CLI (packages/cli/src/cli.ts): - `tokenleak replay [date] --interactive` becomes multi-day by default: loads the last 90 days once, builds heatmap entries by grouping events per date, passes a ReplayLiveDataProvider whose getReport re-runs buildReplayReport on demand. - New `--no-heatmap` escape hatch reverts to the single-day server. - New `buildReplayHeatmap(providers)` helper. Tests: - packages/renderers/src/live/__tests__/replay-live-server.test.ts +7: * heatmap renders + active cell marked + cell links use /?date=… * /?date=X serves the requested day via getReport * unknown day on GET / falls back to empty-state HTML (not 404) * /api/replay 200 + JSON / 400 invalid / 404 missing * single-day mode does NOT emit the heatmap section - All existing tests stay green. Smoke: `tokenleak replay 2026-04-22 --interactive` shows the heatmap; clicking March 8 navigates the page in-place to that day's data. --- packages/cli/src/cli.ts | 54 ++++- packages/cli/src/replay.ts | 1 + packages/renderers/src/index.ts | 6 +- .../live/__tests__/replay-live-server.test.ts | 137 +++++++++++ .../renderers/src/live/replay-live-server.ts | 123 ++++++++-- .../src/live/replay-live-template.ts | 216 +++++++++++++++++- 6 files changed, 521 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index b91eccb..89da77c 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, @@ -2060,6 +2063,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; @@ -2354,6 +2362,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']; @@ -2442,7 +2471,30 @@ 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); + const initialReport = buildReplayReport(heatmapOutput.providers, date); + serverArg = { + heatmap: heatmapEntries, + initialDate: date, + initialReport, + getReport: (d: string) => buildReplayReport(heatmapOutput.providers, d), + }; + } + + const { port: actualPort, stop } = await startReplayLiveServer( + serverArg, + port !== undefined ? { port } : {}, + ); if (cliArgs['open'] === true) { try { diff --git a/packages/cli/src/replay.ts b/packages/cli/src/replay.ts index c88e284..affab07 100644 --- a/packages/cli/src/replay.ts +++ b/packages/cli/src/replay.ts @@ -200,6 +200,7 @@ 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)', 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..6dc657f 100644 --- a/packages/renderers/src/live/__tests__/replay-live-server.test.ts +++ b/packages/renderers/src/live/__tests__/replay-live-server.test.ts @@ -207,3 +207,140 @@ 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 otherReport: ReplayReport = { + ...initial, + date: otherDate, + events: initial.events.map((e) => ({ ...e, date: otherDate, timestamp: e.timestamp.replace('2026-04-26', otherDate) })), + }; + 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('
`; + }) + .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 < Math.ceil(HEATMAP_DAYS / 7); 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 ?? null; + const activeDate = options.initialDate ?? report.date; const styles = ` :root { @@ -434,6 +565,88 @@ header.bar .meta strong { } .summary-pill strong { color: var(--text); font-weight: 500; } +/* 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); +} +.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; @@ -1093,6 +1306,7 @@ header.bar .meta strong { ${report.flowBlocks.length} flow blocks + ${heatmap ? renderHeatmapSection(heatmap, activeDate) : ''} ${isEmpty ? emptyBody : mainBody} From eb7d1d52713bf961955df1eb298db182ae3a07e3 Mon Sep 17 00:00:00 2001 From: Deva Annamaraju Date: Mon, 27 Apr 2026 13:08:44 +0530 Subject: [PATCH 07/13] fix(replay): visible flow-block ribbon + silent server start from TUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues from live testing: 1. Flow blocks looked invisible. The block ribbon below the velocity histogram had a 2-SVG-unit minimum width clamp; on a 13-hour day that's ~2px on screen, so short blocks (Quick Lookups, 30s–3min Deep Flows) collapsed into invisible slivers and gave the user the impression there were no blocks at all. Bumped min width to 10 SVG units (~12px on screen), centered the clamped block on its true midpoint so visual position remains accurate, and bumped the ribbon's height + fill/stroke opacity so it reads clearly against the histogram. Also reshaped the timeline rows: histogram 12-122, ribbon 138-164 (was 12-132 / 144-162). 2. Pressing [b] in the TUI made the terminal "stuck". Root cause: startReplayLiveServer writes "Replay live at http://..." to stderr on success, which corrupts the full-screen TUI render and leaves the screen looking frozen. Added a `silent` option to ReplayLiveServerOptions; the TUI's launchReplayBrowser passes `{ silent: true }`. The TUI's banner is the user's feedback; no stderr noise needed. Also swallowed the failure path silently for the same reason — the TUI doesn't have an error toast yet, and dumping to stderr would corrupt the screen. No test changes — the existing tests cover both code paths and stay green (TUI 106, renderers 279). --- .../renderers/src/live/replay-live-server.ts | 14 ++++++++--- .../src/live/replay-live-template.ts | 23 +++++++++++++------ packages/tui/src/index.ts | 12 ++++++---- 3 files changed, 35 insertions(+), 14 deletions(-) diff --git a/packages/renderers/src/live/replay-live-server.ts b/packages/renderers/src/live/replay-live-server.ts index aa73806..f1f37d0 100644 --- a/packages/renderers/src/live/replay-live-server.ts +++ b/packages/renderers/src/live/replay-live-server.ts @@ -3,6 +3,12 @@ import { generateReplayLiveHtml } from './replay-live-template'; export interface ReplayLiveServerOptions { port?: number; + /** + * Suppress the "Replay live at http://..." stderr line on successful start. + * Set this when calling from a full-screen TUI process — the stderr write + * corrupts the rendered screen and makes the terminal look frozen. + */ + silent?: boolean; } /** @@ -151,9 +157,11 @@ export async function startReplayLiveServer( 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 0a5e727..91ce546 100644 --- a/packages/renderers/src/live/replay-live-template.ts +++ b/packages/renderers/src/live/replay-live-template.ts @@ -852,9 +852,9 @@ header.bar .meta strong { 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; @@ -881,13 +881,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). diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 78f551f..64be944 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -847,14 +847,18 @@ async function launchReplayBrowser(state: AppState, renderer: CliRenderer): Prom } if (!state.cachedReplayReport) return; try { - const { port, stop } = await startReplayLiveServer(state.cachedReplayReport); + // 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(state.cachedReplayReport, { silent: true }); state.replayLiveServerPort = port; replayLiveServerStop = stop; render(state, renderer); openUrlInBrowser(`http://localhost:${port}/`); - } catch (err) { - // surface the error in the help row briefly; for now just log to stderr - process.stderr.write(`failed to start replay live server: ${err instanceof Error ? err.message : String(err)}\n`); + } 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). } } From edb591fed01cf0cfed4e4ac55cc731be19776753 Mon Sep 17 00:00:00 2001 From: Deva Annamaraju Date: Mon, 27 Apr 2026 19:40:28 +0530 Subject: [PATCH 08/13] fix(replay): rebind browser launch to [o] + smarter heatmap default day MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues from live testing: 1. The [b] key was a silent no-op when the cached single-day report had zero events — events.length > 0 gated the launcher. Switching to [o] (mnemonic: open) and dropping that gate so the key works whenever the user is on the replay view. 2. The interactive browser defaulted to today's date, so a quiet today rendered "0 flow blocks" even when the heatmap had plenty of data one cell to the left. When the date isn't passed explicitly on the CLI, fall back to the most recent day in the heatmap that has events. Also: empty heatmap cells were anchor links and would route the user to days with no data. Render them as instead so a stray click can't land on a 0-block view. --- packages/cli/src/cli.ts | 23 ++++++++++++++++--- packages/cli/src/replay-cli.test.ts | 7 +++++- .../src/live/replay-live-template.ts | 10 ++++++++ packages/tui/src/index.ts | 7 ++++-- packages/tui/src/panels/replay.test.ts | 10 ++++---- packages/tui/src/panels/replay.ts | 8 +++---- 6 files changed, 50 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 89da77c..c46ad6e 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1967,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) { @@ -2482,10 +2484,25 @@ async function runReplay(date: string, cliArgs: Record): Promis const heatmapRange = computeDateRange({ days: 90, until: date }); const heatmapOutput = await loadTokenleakData(available, heatmapRange); const heatmapEntries = buildReplayHeatmap(heatmapOutput.providers); - const initialReport = buildReplayReport(heatmapOutput.providers, date); + + // 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: date, + initialDate, initialReport, getReport: (d: string) => buildReplayReport(heatmapOutput.providers, d), }; diff --git a/packages/cli/src/replay-cli.test.ts b/packages/cli/src/replay-cli.test.ts index 66fe375..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', () => { diff --git a/packages/renderers/src/live/replay-live-template.ts b/packages/renderers/src/live/replay-live-template.ts index 91ce546..ce39c04 100644 --- a/packages/renderers/src/live/replay-live-template.ts +++ b/packages/renderers/src/live/replay-live-template.ts @@ -107,6 +107,11 @@ function renderHeatmapSection(entries: ReplayHeatmapEntry[], activeDate: string) 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(''); @@ -641,6 +646,11 @@ header.bar .meta strong { .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; diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 64be944..fd3f617 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -697,8 +697,11 @@ function handleReplayPlaybackInput( if (state.selectedView !== 'replay') return false; const events = state.cachedReplayReport?.events; - // Browser launcher — works from BOTH overview and playback modes. - if (sequence === 'b' && events && events.length > 0) { + // Browser launcher — works from BOTH overview and playback modes, and + // doesn't require events to exist on the *current* day. The browser + // page is multi-day-aware: it'll pick the latest day with events as + // the initial view, which is more useful than gating on today's data. + if (sequence === 'o') { void launchReplayBrowser(state, renderer); return true; } diff --git a/packages/tui/src/panels/replay.test.ts b/packages/tui/src/panels/replay.test.ts index 3e2e5c9..cd2c444 100644 --- a/packages/tui/src/panels/replay.test.ts +++ b/packages/tui/src/panels/replay.test.ts @@ -122,14 +122,14 @@ describe('createReplayPanel', () => { expect(text.split('\n').filter((l) => l.includes('[space]')).length).toBe(1); }); - test('"press [b]" banner is visible in BOTH overview and playback', () => { + test('"press [o]" banner is visible in BOTH overview and playback', () => { const overview = collectTextContent(createReplayPanel(makeReport(), '2026-04-22', 0, null, 0)).join('\n'); - expect(overview).toContain('press [b]'); + expect(overview).toContain('press [o]'); const playback = collectTextContent( createReplayPanel(makeReport(), '2026-04-22', 1, null, 0, undefined, undefined, makePlayback()), ).join('\n'); - expect(playback).toContain('press [b]'); + expect(playback).toContain('press [o]'); }); test('once a server port is set, the banner becomes a "browser open" status', () => { @@ -146,12 +146,12 @@ describe('createReplayPanel', () => { ); const text = collectTextContent(panel).join('\n'); expect(text).toContain('browser open at http://localhost:3567/'); - expect(text).not.toContain('press [b] to open'); + expect(text).not.toContain('press [o] to open'); }); test('null-report path still shows the banner', () => { const panel = createReplayPanel(null, '2026-04-22', 0, null, 0); const text = collectTextContent(panel).join('\n'); - expect(text).toContain('press [b]'); + expect(text).toContain('press [o]'); }); }); diff --git a/packages/tui/src/panels/replay.ts b/packages/tui/src/panels/replay.ts index 4719ffa..3efd095 100644 --- a/packages/tui/src/panels/replay.ts +++ b/packages/tui/src/panels/replay.ts @@ -283,7 +283,7 @@ function renderPlaybackHelp(contentWidth: number) { } function renderOverviewHelp(contentWidth: number) { - const line = ' [s] enter step/playback · [n/p] step · [space] play · [i] interesting · [b] open browser'; + const line = ' [s] enter step/playback · [n/p] step · [space] play · [i] interesting · [o] open browser'; return Box( { flexDirection: 'column', width: '100%', paddingLeft: 1, paddingRight: 1 }, Text({ content: truncate(line, contentWidth), fg: COLORS.dimWhite }), @@ -291,7 +291,7 @@ function renderOverviewHelp(contentWidth: number) { } /** - * Big "press [b] to open the interactive browser scrub" banner. Always + * Big "press [o] to open the interactive browser scrub" banner. Always * shown above the playback header in BOTH overview and playback modes — * the browser experience is the better one for visual scrubbing and we * want it discoverable from anywhere on this view. @@ -300,13 +300,13 @@ function renderOverviewHelp(contentWidth: number) { */ function renderBrowserBanner(contentWidth: number, liveServerPort: number | null) { if (liveServerPort !== null) { - const status = ` ✓ browser open at http://localhost:${liveServerPort}/ · press [b] again to re-open`; + const status = ` ✓ browser open at http://localhost:${liveServerPort}/ · press [o] again to re-open`; return Box( { flexDirection: 'column', width: '100%', paddingLeft: 1, paddingRight: 1 }, Text({ content: truncate(status, contentWidth), fg: COLORS.green, attributes: BOLD }), ); } - const inner = ' ▶ press [b] to open the interactive browser scrub ⟶'; + const inner = ' ▶ press [o] to open the interactive browser scrub ⟶'; const innerWidth = Math.min(contentWidth - 2, 60); const top = '╭' + '─'.repeat(innerWidth) + '╮'; const middle = '│' + truncate(inner.padEnd(innerWidth), innerWidth) + '│'; From ea3f9b43e4b035eccf3bf264b8ba92763a5caa8f Mon Sep 17 00:00:00 2001 From: Deva Annamaraju Date: Mon, 27 Apr 2026 22:33:49 +0530 Subject: [PATCH 09/13] feat(replay): prompt-detail card in browser + globalize TUI launcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups requested while testing the interactive replay: 1. The browser scrubber showed model/tokens/cost per event but not the prompt that drove it. Add a full-width "// prompt sent to model" card under the existing grid. Click an event row to pin the prompt panel; during playback the panel auto-follows the playhead until the user makes a manual selection. Prompt text comes from UsageEvent.prompt (already populated by Claude Code, truncated to 2,000 chars at parse time). For providers that don't capture prompts (Codex, Cursor, Pi), the panel renders a clear empty state instead of looking broken. 2. The "[o] open browser" affordance was Replay-only. Make it global: move the keypress handler out of handleReplayPlaybackInput into a new global helper called early in addInputHandler, and lazy-load cachedReplayReport via ensureReplayReport when the user presses [o] from a view that hasn't built one yet. The footer status bar now carries a bright-emerald "▶ [o] interactive replay" CTA chip on every non-modal view, swapping to "✓ replay open :PORT" once the server is running. The per-panel banner in replay.ts is removed since the footer covers it everywhere. Receipts sort moves from [o] to [S] to make room for the global launcher; help.ts and the status bar's receipts hint reflect the new binding. --- .../live/__tests__/replay-live-server.test.ts | 34 ++++++ .../src/live/replay-live-template.ts | 112 +++++++++++++++++- packages/tui/src/index.ts | 49 +++++--- packages/tui/src/panels/help.ts | 3 +- packages/tui/src/panels/replay.test.ts | 31 ++--- packages/tui/src/panels/replay.ts | 36 +----- packages/tui/src/panels/status-bar.test.ts | 29 +++++ packages/tui/src/panels/status-bar.ts | 36 +++++- 8 files changed, 251 insertions(+), 79 deletions(-) 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 6dc657f..4bf227a 100644 --- a/packages/renderers/src/live/__tests__/replay-live-server.test.ts +++ b/packages/renderers/src/live/__tests__/replay-live-server.test.ts @@ -105,6 +105,40 @@ 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'); + }); + + 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'); diff --git a/packages/renderers/src/live/replay-live-template.ts b/packages/renderers/src/live/replay-live-template.ts index ce39c04..be1bf78 100644 --- a/packages/renderers/src/live/replay-live-template.ts +++ b/packages/renderers/src/live/replay-live-template.ts @@ -570,6 +570,43 @@ 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); @@ -770,6 +807,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 @@ -834,6 +877,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); }; @@ -857,6 +906,8 @@ 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; @@ -1058,9 +1109,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; @@ -1068,9 +1126,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) + '' + @@ -1086,6 +1147,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 — only Claude Code stores prompts in its session logs"; + } + } + function escHtml(s) { return String(s).replace(/&/g, '&').replace(//g, '>'); } @@ -1169,6 +1274,7 @@ header.bar .meta strong { renderActiveBlock(); renderEventList(); renderMix(cum); + renderPromptPanel(); } function setTime(t) { diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index fd3f617..c72eb92 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -323,7 +323,6 @@ function buildContent(state: AppState, renderer: CliRenderer) { getPanelContentWidth(renderer, REPLAY_MAX_CONTENT_WIDTH), undefined, null, - state.replayLiveServerPort, ); } if (!state.replayDate) { @@ -347,7 +346,6 @@ function buildContent(state: AppState, renderer: CliRenderer) { render(state, renderer); }, buildReplayPlaybackView(state), - state.replayLiveServerPort, ); case 'nutrition': if (!hasWindowData) { @@ -697,15 +695,6 @@ function handleReplayPlaybackInput( if (state.selectedView !== 'replay') return false; const events = state.cachedReplayReport?.events; - // Browser launcher — works from BOTH overview and playback modes, and - // doesn't require events to exist on the *current* day. The browser - // page is multi-day-aware: it'll pick the latest day with events as - // the initial view, which is more useful than gating on today's data. - if (sequence === 'o') { - void launchReplayBrowser(state, renderer); - return true; - } - // Toggle entry from overview mode. if (state.replayCursorEventIndex === null) { if (sequence === 's' && events && events.length > 0) { @@ -821,7 +810,7 @@ function pauseReplayPlaybackIfRunning(state: AppState): void { } } -// ── In-process replay live server (launched via `b` from the replay view) ── +// ── In-process replay live server (launched via global `o` keypress) ── let replayLiveServerStop: (() => void) | null = null; @@ -848,6 +837,16 @@ async function launchReplayBrowser(state: AppState, renderer: CliRenderer): Prom openUrlInBrowser(`http://localhost:${state.replayLiveServerPort}/`); return; } + // Lazy-load the replay report so the launcher works from any view — + // when the user presses [o] from Overview/Wrapped/etc., cachedReplayReport + // is typically null because we only build it when the Replay panel renders. + if (!state.cachedReplayReport) { + if (!state.data) return; // still booting; nothing to render yet + if (!state.replayDate) { + state.replayDate = new Date().toISOString().slice(0, 10); + } + ensureReplayReport(state); + } if (!state.cachedReplayReport) return; try { // silent: true suppresses the server's stderr "Replay live at..." line, @@ -865,6 +864,21 @@ async function launchReplayBrowser(state: AppState, renderer: CliRenderer): Prom } } +/** + * 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 */ } @@ -1348,6 +1362,12 @@ 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; } @@ -1550,8 +1570,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]!; 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/replay.test.ts b/packages/tui/src/panels/replay.test.ts index cd2c444..4383d1a 100644 --- a/packages/tui/src/panels/replay.test.ts +++ b/packages/tui/src/panels/replay.test.ts @@ -122,36 +122,23 @@ describe('createReplayPanel', () => { expect(text.split('\n').filter((l) => l.includes('[space]')).length).toBe(1); }); - test('"press [o]" banner is visible in BOTH overview and playback', () => { + 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).toContain('press [o]'); + 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).toContain('press [o]'); + expect(playback).not.toContain('press [o]'); }); - test('once a server port is set, the banner becomes a "browser open" status', () => { - const panel = createReplayPanel( - makeReport(), - '2026-04-22', - 0, - null, - 0, - undefined, - undefined, - null, - 3567, - ); - const text = collectTextContent(panel).join('\n'); - expect(text).toContain('browser open at http://localhost:3567/'); - expect(text).not.toContain('press [o] to open'); - }); - - test('null-report path still shows the banner', () => { + 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('press [o]'); + 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 3efd095..abaec5a 100644 --- a/packages/tui/src/panels/replay.ts +++ b/packages/tui/src/panels/replay.ts @@ -283,42 +283,13 @@ function renderPlaybackHelp(contentWidth: number) { } function renderOverviewHelp(contentWidth: number) { - const line = ' [s] enter step/playback · [n/p] step · [space] play · [i] interesting · [o] open browser'; + const line = ' [s] enter step/playback · [n/p] step · [space] play · [i] interesting'; return Box( { flexDirection: 'column', width: '100%', paddingLeft: 1, paddingRight: 1 }, Text({ content: truncate(line, contentWidth), fg: COLORS.dimWhite }), ); } -/** - * Big "press [o] to open the interactive browser scrub" banner. Always - * shown above the playback header in BOTH overview and playback modes — - * the browser experience is the better one for visual scrubbing and we - * want it discoverable from anywhere on this view. - * - * When `liveServerPort` is set, swaps to a one-line success state. - */ -function renderBrowserBanner(contentWidth: number, liveServerPort: number | null) { - if (liveServerPort !== null) { - const status = ` ✓ browser open at http://localhost:${liveServerPort}/ · press [o] again to re-open`; - return Box( - { flexDirection: 'column', width: '100%', paddingLeft: 1, paddingRight: 1 }, - Text({ content: truncate(status, contentWidth), fg: COLORS.green, attributes: BOLD }), - ); - } - const inner = ' ▶ press [o] to open the interactive browser scrub ⟶'; - const innerWidth = Math.min(contentWidth - 2, 60); - const top = '╭' + '─'.repeat(innerWidth) + '╮'; - const middle = '│' + truncate(inner.padEnd(innerWidth), innerWidth) + '│'; - const bottom = '╰' + '─'.repeat(innerWidth) + '╯'; - return Box( - { flexDirection: 'column', width: '100%', paddingLeft: 1, paddingRight: 1 }, - Text({ content: top, fg: COLORS.green }), - Text({ content: middle, fg: COLORS.green, attributes: BOLD }), - Text({ content: bottom, fg: COLORS.green }), - ); -} - export function createReplayPanel( report: ReplayReport | null, replayDate: string | null, @@ -328,7 +299,6 @@ export function createReplayPanel( contentWidth: number = REPLAY_MAX_CONTENT_WIDTH, onToggleBlock?: ReplayToggleHandler, playback: ReplayPlaybackView | null = null, - liveServerPort: number | null = null, ) { const dateLabel = replayDate ? formatShortDate(replayDate) : '—'; @@ -349,8 +319,6 @@ export function createReplayPanel( attributes: BOLD, }), Text({ content: '', fg: COLORS.dimWhite }), - renderBrowserBanner(contentWidth, liveServerPort), - Text({ content: '', fg: COLORS.dimWhite }), Text({ content: 'No data available for this date', fg: COLORS.dimWhite }), ); } @@ -396,8 +364,6 @@ export function createReplayPanel( { flexDirection: 'row', width: '100%', paddingLeft: 1, paddingRight: 1 }, Text({ content: `Total: ${formatCost(totalCost)}`, fg: COLORS.green }), ), - Text({ content: '', fg: COLORS.dimWhite }), - renderBrowserBanner(contentWidth, liveServerPort), ]; if (playbackHeader) { children.push(Text({ content: '', fg: COLORS.dimWhite })); 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, From 108a8c0a4b3f74d60832b099febfd5a9fd665201 Mon Sep 17 00:00:00 2001 From: Deva Annamaraju Date: Sat, 16 May 2026 12:05:12 +0530 Subject: [PATCH 10/13] fix(tui): harden replay browser interaction --- packages/tui/src/index.ts | 95 ++++---- .../tui/src/lib/replay-interaction.test.ts | 202 ++++++++++++++++++ packages/tui/src/lib/replay-interaction.ts | 121 +++++++++++ packages/tui/src/lib/replay-playback.test.ts | 15 ++ packages/tui/src/lib/replay-playback.ts | 35 ++- 5 files changed, 405 insertions(+), 63 deletions(-) create mode 100644 packages/tui/src/lib/replay-interaction.test.ts create mode 100644 packages/tui/src/lib/replay-interaction.ts diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index c72eb92..017674c 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -50,7 +50,12 @@ 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, @@ -60,10 +65,18 @@ import { 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'; @@ -505,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; @@ -643,15 +654,6 @@ function keepSelectedItemVisible(selectedIndex: number, scrollOffset: number, vi return scrollOffset; } -function resetReplayInteraction(state: AppState): void { - state.replayScrollOffset = 0; - state.replaySelectedBlockIndex = 0; - state.replayExpandedBlockIndex = null; - exitReplayPlayback(state); - stopReplayPlaybackTimer(); - stopReplayLiveServer(state); -} - let replayPlaybackTimer: ReturnType | null = null; function startReplayPlaybackTimer(): void { @@ -662,6 +664,7 @@ function startReplayPlaybackTimer(): void { return; } const advanced = tickReplayPlayback(currentState); + keepReplaySelectionVisible(currentState, REPLAY_VISIBLE_BLOCKS_PLAYBACK); if (!advanced) { stopReplayPlaybackTimer(); } @@ -669,6 +672,14 @@ function startReplayPlaybackTimer(): void { }, 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); @@ -699,6 +710,7 @@ function handleReplayPlaybackInput( if (state.replayCursorEventIndex === null) { if (sequence === 's' && events && events.length > 0) { enterReplayPlayback(state); + keepReplaySelectionVisible(state, REPLAY_VISIBLE_BLOCKS_PLAYBACK); render(state, renderer); return true; } @@ -725,52 +737,56 @@ function handleReplayPlaybackInput( 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); - state.replayCursorEventIndex = 0; - state.replaySelectedBlockIndex = 0; + selectReplayCursorEvent(state, 0); + keepReplaySelectionVisible(state, REPLAY_VISIBLE_BLOCKS_PLAYBACK); render(state, renderer); return true; } if (sequence === '\x1b[F' || sequence === '\x1bOF') { pauseReplayPlaybackIfRunning(state); - state.replayCursorEventIndex = events.length - 1; - state.replaySelectedBlockIndex = state.cachedReplayReport!.flowBlocks.length > 0 - ? state.cachedReplayReport!.flowBlocks.length - 1 - : 0; + selectReplayCursorEvent(state, events.length - 1); + keepReplaySelectionVisible(state, REPLAY_VISIBLE_BLOCKS_PLAYBACK); render(state, renderer); return true; } @@ -781,7 +797,8 @@ function handleReplayPlaybackInput( if (active) { // If we're at the end, restart from the beginning. if (state.replayCursorEventIndex! >= events.length - 1) { - state.replayCursorEventIndex = 0; + selectReplayCursorEvent(state, 0); + keepReplaySelectionVisible(state, REPLAY_VISIBLE_BLOCKS_PLAYBACK); } startReplayPlaybackTimer(); } else { @@ -837,22 +854,17 @@ async function launchReplayBrowser(state: AppState, renderer: CliRenderer): Prom openUrlInBrowser(`http://localhost:${state.replayLiveServerPort}/`); return; } - // Lazy-load the replay report so the launcher works from any view — - // when the user presses [o] from Overview/Wrapped/etc., cachedReplayReport - // is typically null because we only build it when the Replay panel renders. - if (!state.cachedReplayReport) { - if (!state.data) return; // still booting; nothing to render yet - if (!state.replayDate) { - state.replayDate = new Date().toISOString().slice(0, 10); - } - ensureReplayReport(state); - } - if (!state.cachedReplayReport) 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(state.cachedReplayReport, { silent: true }); + const { port, stop } = await startReplayLiveServer(replayLiveData, { silent: true }); state.replayLiveServerPort = port; replayLiveServerStop = stop; render(state, renderer); @@ -918,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); @@ -944,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 { @@ -976,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; @@ -1041,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); } @@ -1056,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(); @@ -1070,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 */ 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..d8d8b82 --- /dev/null +++ b/packages/tui/src/lib/replay-interaction.test.ts @@ -0,0 +1,202 @@ +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); + }); + + 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); + }); +}); diff --git a/packages/tui/src/lib/replay-interaction.ts b/packages/tui/src/lib/replay-interaction.ts new file mode 100644 index 0000000..4e17add --- /dev/null +++ b/packages/tui/src/lib/replay-interaction.ts @@ -0,0 +1,121 @@ +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 (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) => buildReplayReport(providers, date), + }; +} diff --git a/packages/tui/src/lib/replay-playback.test.ts b/packages/tui/src/lib/replay-playback.test.ts index 0735be9..da688ee 100644 --- a/packages/tui/src/lib/replay-playback.test.ts +++ b/packages/tui/src/lib/replay-playback.test.ts @@ -14,6 +14,7 @@ import { stepReplayCursor, tickReplayPlayback, toggleReplayPlayback, + selectReplayCursorEvent, } from './replay-playback'; function ev(timestamp: string, model: string, totalTokens: number, cost: number): UsageEvent { @@ -134,6 +135,20 @@ describe('enterReplayPlayback / exitReplayPlayback', () => { }); 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); diff --git a/packages/tui/src/lib/replay-playback.ts b/packages/tui/src/lib/replay-playback.ts index 5558cc6..96ef574 100644 --- a/packages/tui/src/lib/replay-playback.ts +++ b/packages/tui/src/lib/replay-playback.ts @@ -13,9 +13,8 @@ export function eventsPerTick(speed: ReplayPlaybackSpeed): number { export function enterReplayPlayback(state: AppState): void { const events = state.cachedReplayReport?.events; if (!events || events.length === 0) return; - state.replayCursorEventIndex = 0; + selectReplayCursorEvent(state, 0); state.replayPlaybackActive = false; - state.replaySelectedBlockIndex = blockIndexForEvent(state.cachedReplayReport!, 0); } /** Exit playback mode entirely. Caller is responsible for clearing any timer. */ @@ -32,8 +31,7 @@ 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); - state.replayCursorEventIndex = next; - state.replaySelectedBlockIndex = blockIndexForEvent(report, next); + selectReplayCursorEvent(state, next); } /** @@ -55,15 +53,13 @@ export function jumpReplayCursorToBlockBoundary(state: AppState, dir: 1 | -1): v if (startTs > currentEventTs) { const idx = firstEventIndexOnOrAfter(report.events, startTs); if (idx !== null) { - state.replayCursorEventIndex = idx; - state.replaySelectedBlockIndex = blockIndexForEvent(report, idx); + selectReplayCursorEvent(state, idx); return; } } } // No next block — jump to last event. - state.replayCursorEventIndex = report.events.length - 1; - state.replaySelectedBlockIndex = blockIndexForEvent(report, state.replayCursorEventIndex); + selectReplayCursorEvent(state, report.events.length - 1); return; } @@ -72,8 +68,7 @@ export function jumpReplayCursorToBlockBoundary(state: AppState, dir: 1 | -1): v if (currentEventTs > currentBlockStartTs) { const idx = firstEventIndexOnOrAfter(report.events, currentBlockStartTs); if (idx !== null) { - state.replayCursorEventIndex = idx; - state.replaySelectedBlockIndex = currentBlock; + selectReplayCursorEvent(state, idx); return; } } @@ -81,13 +76,11 @@ export function jumpReplayCursorToBlockBoundary(state: AppState, dir: 1 | -1): v const startTs = Date.parse(report.flowBlocks[i].start); const idx = firstEventIndexOnOrAfter(report.events, startTs); if (idx !== null) { - state.replayCursorEventIndex = idx; - state.replaySelectedBlockIndex = i; + selectReplayCursorEvent(state, idx); return; } } - state.replayCursorEventIndex = 0; - state.replaySelectedBlockIndex = blockIndexForEvent(report, 0); + selectReplayCursorEvent(state, 0); } /** @@ -120,8 +113,7 @@ export function jumpReplayCursorToInteresting(state: AppState, dir: 1 | -1): voi } if (target === null) target = points[points.length - 1]; // wrap } - state.replayCursorEventIndex = target; - state.replaySelectedBlockIndex = blockIndexForEvent(report, target); + selectReplayCursorEvent(state, target); } /** Toggle the play loop. Returns the new active state. */ @@ -139,8 +131,7 @@ export function tickReplayPlayback(state: AppState): boolean { } const advance = eventsPerTick(state.replayPlaybackSpeed); const next = clamp(state.replayCursorEventIndex + advance, 0, report.events.length - 1); - state.replayCursorEventIndex = next; - state.replaySelectedBlockIndex = blockIndexForEvent(report, next); + selectReplayCursorEvent(state, next); if (next >= report.events.length - 1) { state.replayPlaybackActive = false; return false; @@ -204,6 +195,14 @@ export function computePlaybackSummary(report: ReplayReport, cursorIndex: number // ── 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)); } From 8d6433d915c52062d12d38f87a001dc0c7f0360c Mon Sep 17 00:00:00 2001 From: Deva Annamaraju Date: Sat, 16 May 2026 12:32:07 +0530 Subject: [PATCH 11/13] feat(codex): show prompts in replay --- packages/cli/src/receipts.ts | 2 +- packages/mcp/src/tools/get-receipt-lines.ts | 2 +- packages/mcp/src/tools/index.ts | 2 +- .../sessions/2026/03/12/session-current.jsonl | 2 + packages/registry/src/providers/codex.test.ts | 4 ++ packages/registry/src/providers/codex.ts | 69 +++++++++++++++++++ .../live/__tests__/replay-live-server.test.ts | 2 + .../src/live/replay-live-template.ts | 2 +- packages/tui/src/panels/receipts.ts | 4 +- 9 files changed, 83 insertions(+), 6 deletions(-) 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/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..fbcf7f0 100644 --- a/packages/registry/src/providers/codex.test.ts +++ b/packages/registry/src/providers/codex.test.ts @@ -97,6 +97,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, diff --git a/packages/registry/src/providers/codex.ts b/packages/registry/src/providers/codex.ts index 0b29a4b..48c4078 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,6 +67,7 @@ interface SessionContext { outputTokens: number; cachedInputTokens: number; } | null; + lastUserPrompt?: string; } /** @@ -222,6 +224,63 @@ 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 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 eventPayload['message'].replace(/\s+$/g, '').trimStart(); + } + + 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); + } + } + } + + const prompt = parts.join('\n\n').replace(/\s+$/g, '').trimStart(); + return prompt.length > 0 ? prompt : null; +} + function parseTokenCountUsage(record: unknown, context: SessionContext): CodexUsageRecord | null { if (typeof record !== 'object' || record === null) { return null; @@ -314,6 +373,7 @@ function parseTokenCountUsage(record: unknown, context: SessionContext): CodexUs outputTokens: usage.outputTokens, cacheReadTokens, cacheWriteTokens: 0, + prompt: context.lastUserPrompt, }; } @@ -332,6 +392,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 +421,7 @@ function parseUsageRecord(record: unknown, context: SessionContext): CodexUsageR outputTokens: legacyEvent.usage.output_tokens, cacheReadTokens: 0, cacheWriteTokens: 0, + prompt: context.lastUserPrompt, }; } @@ -396,6 +463,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 +513,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/live/__tests__/replay-live-server.test.ts b/packages/renderers/src/live/__tests__/replay-live-server.test.ts index 4bf227a..2eada72 100644 --- a/packages/renderers/src/live/__tests__/replay-live-server.test.ts +++ b/packages/renderers/src/live/__tests__/replay-live-server.test.ts @@ -111,6 +111,8 @@ describe('generateReplayLiveHtml', () => { 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__', () => { diff --git a/packages/renderers/src/live/replay-live-template.ts b/packages/renderers/src/live/replay-live-template.ts index be1bf78..8eff272 100644 --- a/packages/renderers/src/live/replay-live-template.ts +++ b/packages/renderers/src/live/replay-live-template.ts @@ -1187,7 +1187,7 @@ header.bar .meta strong { promptBody.textContent = e.prompt; } else { promptBody.classList.add('empty'); - promptBody.textContent = "this provider doesn't capture prompt text — only Claude Code stores prompts in its session logs"; + promptBody.textContent = "this provider doesn't capture prompt text for this event"; } } 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, }), ); From bfbf613a429c760473d5fe771a366226baf1f22d Mon Sep 17 00:00:00 2001 From: Deva Annamaraju Date: Sat, 16 May 2026 14:07:29 +0530 Subject: [PATCH 12/13] fix(replay): address prompt and heatmap review --- packages/registry/src/providers/codex.test.ts | 43 +++++++++++++++++++ packages/registry/src/providers/codex.ts | 15 +++++-- .../live/__tests__/replay-live-server.test.ts | 16 +++++++ .../src/live/replay-live-template.ts | 11 ++--- 4 files changed, 77 insertions(+), 8 deletions(-) diff --git a/packages/registry/src/providers/codex.test.ts b/packages/registry/src/providers/codex.test.ts index fbcf7f0..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'; @@ -113,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 48c4078..d4cda9b 100644 --- a/packages/registry/src/providers/codex.ts +++ b/packages/registry/src/providers/codex.ts @@ -70,6 +70,8 @@ interface SessionContext { 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. @@ -242,6 +244,14 @@ function extractTextElementText(value: unknown): string | null { 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; @@ -263,7 +273,7 @@ function extractUserPrompt(record: unknown): string | null { } if (typeof eventPayload['message'] === 'string' && eventPayload['message'].trim().length > 0) { - return eventPayload['message'].replace(/\s+$/g, '').trimStart(); + return normalizePromptText(eventPayload['message']); } const parts: string[] = []; @@ -277,8 +287,7 @@ function extractUserPrompt(record: unknown): string | null { } } - const prompt = parts.join('\n\n').replace(/\s+$/g, '').trimStart(); - return prompt.length > 0 ? prompt : null; + return normalizePromptText(parts.join('\n\n')); } function parseTokenCountUsage(record: unknown, context: SessionContext): CodexUsageRecord | null { 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 2eada72..d160d79 100644 --- a/packages/renderers/src/live/__tests__/replay-live-server.test.ts +++ b/packages/renderers/src/live/__tests__/replay-live-server.test.ts @@ -147,6 +147,22 @@ describe('generateReplayLiveHtml', () => { 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('renders the empty-state body when there are no events', () => { const empty: ReplayReport = { date: '2026-04-26', diff --git a/packages/renderers/src/live/replay-live-template.ts b/packages/renderers/src/live/replay-live-template.ts index 8eff272..61f7090 100644 --- a/packages/renderers/src/live/replay-live-template.ts +++ b/packages/renderers/src/live/replay-live-template.ts @@ -69,11 +69,6 @@ function renderHeatmapSection(entries: ReplayHeatmapEntry[], activeDate: string) const end = new Date(latestEntryDate + 'T00:00:00Z'); const start = new Date(end.getTime() - (HEATMAP_DAYS - 1) * 86_400_000); - const maxTokens = entries.reduce((acc, e) => Math.max(acc, e.tokens), 0); - const totalTokens = entries.reduce((acc, e) => acc + e.tokens, 0); - const totalCost = entries.reduce((acc, e) => acc + e.cost, 0); - const activeDays = entries.filter((e) => e.tokens > 0).length; - // Walk forward from start; bucket into weeks (column = week index). const cells: Array<{ date: string; tokens: number; cost: number; events: number; weekday: number; col: number }> = []; for (let i = 0; i < HEATMAP_DAYS; i++) { @@ -92,6 +87,12 @@ function renderHeatmapSection(entries: ReplayHeatmapEntry[], activeDate: string) }); } + 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 cellHtml = cells .map((c) => { const intensity = maxTokens > 0 && c.tokens > 0 From f1da9786350ef604d5efb9eec5440de7b643a977 Mon Sep 17 00:00:00 2001 From: Deva Annamaraju Date: Sat, 16 May 2026 14:09:40 +0530 Subject: [PATCH 13/13] fix(replay): resolve remaining review feedback --- packages/cli/src/cli.ts | 5 +- .../live/__tests__/replay-live-server.test.ts | 59 ++++++++++++++++++- .../src/live/replay-live-template.ts | 22 +++---- .../tui/src/lib/replay-interaction.test.ts | 28 +++++++++ packages/tui/src/lib/replay-interaction.ts | 8 ++- packages/tui/src/panels/replay.test.ts | 2 + packages/tui/src/panels/replay.ts | 2 +- 7 files changed, 112 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index c46ad6e..3ca5d60 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -2504,7 +2504,10 @@ async function runReplay(date: string, cliArgs: Record): Promis heatmap: heatmapEntries, initialDate, initialReport, - getReport: (d: string) => buildReplayReport(heatmapOutput.providers, d), + getReport: (d: string) => { + const reportForDay = buildReplayReport(heatmapOutput.providers, d); + return reportForDay.events.length > 0 ? reportForDay : null; + }, }; } 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 d160d79..812f9a9 100644 --- a/packages/renderers/src/live/__tests__/replay-live-server.test.ts +++ b/packages/renderers/src/live/__tests__/replay-live-server.test.ts @@ -163,6 +163,34 @@ describe('generateReplayLiveHtml', () => { 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', @@ -273,10 +301,39 @@ describe('startReplayLiveServer (multi-day mode)', () => { 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: initial.events.map((e) => ({ ...e, date: otherDate, timestamp: e.timestamp.replace('2026-04-26', 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: [ diff --git a/packages/renderers/src/live/replay-live-template.ts b/packages/renderers/src/live/replay-live-template.ts index 61f7090..88d4563 100644 --- a/packages/renderers/src/live/replay-live-template.ts +++ b/packages/renderers/src/live/replay-live-template.ts @@ -57,25 +57,25 @@ const HEATMAP_DAYS = 91; // 13 weeks × 7 days * navigation. Each cell is an `` so clicks just navigate to `/?date=X` * — no JS required. * - * Layout: 7 rows (Sun-Sat) × ~13 cols (weeks), latest day on the right. + * 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: anchor on today (or the latest entry, whichever is later). - const todayStr = new Date().toISOString().slice(0, 10); - const latestEntryDate = entries.reduce((acc, e) => (e.date > acc ? e.date : acc), todayStr); + // 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 weeks (column = week index). + // 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 / 7); + const col = Math.floor((i + startWeekday) / 7); const e = byDate.get(dateStr); cells.push({ date: dateStr, @@ -92,6 +92,8 @@ function renderHeatmapSection(entries: ReplayHeatmapEntry[], activeDate: string) 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) => { @@ -121,7 +123,7 @@ function renderHeatmapSection(entries: ReplayHeatmapEntry[], activeDate: string) // 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 < Math.ceil(HEATMAP_DAYS / 7); week++) { + 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'); @@ -154,8 +156,8 @@ function renderHeatmapSection(entries: ReplayHeatmapEntry[], activeDate: string)
-
${monthLabels.join('')}
-
${cellHtml}
+
${monthLabels.join('')}
+
${cellHtml}
`; @@ -168,7 +170,7 @@ export function generateReplayLiveHtml(report: ReplayReport, options: ReplayLive const safeReport = JSON.stringify(report); const dateLong = formatDateLong(report.date); const isEmpty = report.events.length === 0; - const heatmap = options.heatmap ?? null; + const heatmap = options.heatmap && options.heatmap.length > 0 ? options.heatmap : null; const activeDate = options.initialDate ?? report.date; const styles = ` diff --git a/packages/tui/src/lib/replay-interaction.test.ts b/packages/tui/src/lib/replay-interaction.test.ts index d8d8b82..a5b4f56 100644 --- a/packages/tui/src/lib/replay-interaction.test.ts +++ b/packages/tui/src/lib/replay-interaction.test.ts @@ -131,6 +131,7 @@ describe('buildReplayLiveDataProvider', () => { ]); 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', () => { @@ -199,4 +200,31 @@ describe('moveReplayOverviewSelection', () => { 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 index 4e17add..95449a7 100644 --- a/packages/tui/src/lib/replay-interaction.ts +++ b/packages/tui/src/lib/replay-interaction.ts @@ -13,6 +13,9 @@ function clampItemIndex(index: number, itemCount: number): number { } function keepSelectedItemVisible(selectedIndex: number, scrollOffset: number, visibleCount: number): number { + if (visibleCount <= 0) { + return scrollOffset; + } if (selectedIndex < scrollOffset) { return selectedIndex; } @@ -116,6 +119,9 @@ export function buildReplayLiveDataProvider( heatmap, initialDate, initialReport: buildReplayReport(providers, initialDate), - getReport: (date: string) => buildReplayReport(providers, date), + getReport: (date: string) => { + const report = buildReplayReport(providers, date); + return report.events.length > 0 ? report : null; + }, }; } diff --git a/packages/tui/src/panels/replay.test.ts b/packages/tui/src/panels/replay.test.ts index 4383d1a..22b39d9 100644 --- a/packages/tui/src/panels/replay.test.ts +++ b/packages/tui/src/panels/replay.test.ts @@ -97,6 +97,8 @@ describe('createReplayPanel', () => { 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', () => { diff --git a/packages/tui/src/panels/replay.ts b/packages/tui/src/panels/replay.ts index abaec5a..5e8ed5a 100644 --- a/packages/tui/src/panels/replay.ts +++ b/packages/tui/src/panels/replay.ts @@ -283,7 +283,7 @@ function renderPlaybackHelp(contentWidth: number) { } function renderOverviewHelp(contentWidth: number) { - const line = ' [s] enter step/playback · [n/p] step · [space] play · [i] interesting'; + const line = ' [s] enter step/playback'; return Box( { flexDirection: 'column', width: '100%', paddingLeft: 1, paddingRight: 1 }, Text({ content: truncate(line, contentWidth), fg: COLORS.dimWhite }),