From de461b458ead07b04d52e3d8a973f53762fc7abf Mon Sep 17 00:00:00 2001 From: Deva Annamaraju Date: Sun, 26 Apr 2026 23:11:12 +0530 Subject: [PATCH 1/2] fix tui accordion interactions --- packages/tui/src/index.ts | 174 +++++++++++++++++----- packages/tui/src/lib/format.ts | 47 ++++++ packages/tui/src/lib/state.ts | 8 +- packages/tui/src/panels/accordion.test.ts | 164 ++++++++++++++++++++ packages/tui/src/panels/help.ts | 11 +- packages/tui/src/panels/receipts.ts | 131 +++++++++------- packages/tui/src/panels/replay.ts | 100 ++++++++++--- packages/tui/src/panels/status-bar.ts | 4 +- 8 files changed, 522 insertions(+), 117 deletions(-) create mode 100644 packages/tui/src/panels/accordion.test.ts diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index c31a820..1b36b5c 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -50,9 +50,9 @@ 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 } from './panels/replay.js'; +import { createReplayPanel, REPLAY_VISIBLE_BLOCKS } from './panels/replay.js'; import { createNutritionPanel, NUTRITION_VISIBLE_ROWS } from './panels/nutrition.js'; -import { createReceiptsPanel } from './panels/receipts.js'; +import { createReceiptsPanel, RECEIPTS_VISIBLE_ROWS } from './panels/receipts.js'; import { buildCursorBanner, createCursorSetupPanel, isEscapeKeySequence } from './panels/cursor-setup.js'; const CURSOR_SETUP_LABEL_INPUT_ID = 'cursor-setup-label-input'; @@ -303,7 +303,8 @@ function buildContent(state: AppState, renderer: CliRenderer) { return createReplayPanel( null, state.replayDate, - state.replayExpandedBlocks, + state.replaySelectedBlockIndex, + state.replayExpandedBlockIndex, state.replayScrollOffset, ); } @@ -319,8 +320,13 @@ function buildContent(state: AppState, renderer: CliRenderer) { return createReplayPanel( state.cachedReplayReport, state.replayDate, - state.replayExpandedBlocks, + state.replaySelectedBlockIndex, + state.replayExpandedBlockIndex, state.replayScrollOffset, + (blockIndex) => { + toggleReplayBlock(state, blockIndex); + render(state, renderer); + }, ); case 'nutrition': if (!hasWindowData) { @@ -384,7 +390,10 @@ function buildContent(state: AppState, renderer: CliRenderer) { ensureReceipt(state); }); } - return createReceiptsPanel(state, state.cachedReceipt); + return createReceiptsPanel(state, state.cachedReceipt, (lineIndex) => { + toggleReceiptLine(state, lineIndex); + render(state, renderer); + }); default: return Box({ flexDirection: 'column', width: '100%', flexGrow: 1 }); } @@ -473,10 +482,12 @@ function applyLoadedData( state.compareScrollOffset = 0; state.wrappedScrollOffset = 0; state.replayScrollOffset = 0; - state.replayExpandedBlocks = new Set(); + state.replaySelectedBlockIndex = 0; + state.replayExpandedBlockIndex = null; state.replayDate = null; state.explainDate = null; state.receiptsScrollOffset = 0; + state.receiptsSelectedLineIndex = 0; state.receiptsExpandedLineIndex = null; state.receiptsSortMode = 'cost'; state.receiptsCategoryFilter = null; @@ -589,6 +600,89 @@ function tryOpenCursorSetup(state: AppState, renderer: CliRenderer): boolean { let currentState: AppState; let currentRenderer: CliRenderer; +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; +} + +function resetReplayInteraction(state: AppState): void { + state.replayScrollOffset = 0; + state.replaySelectedBlockIndex = 0; + state.replayExpandedBlockIndex = null; +} + +function resetReceiptsInteraction(state: AppState): void { + state.receiptsScrollOffset = 0; + state.receiptsSelectedLineIndex = 0; + state.receiptsExpandedLineIndex = null; +} + +function getReceiptLineCount(state: AppState): number { + const receipt = state.cachedReceipt; + if (!receipt) return 0; + return deriveReceiptLines(receipt, state.receiptsSortMode, state.receiptsCategoryFilter).length; +} + +function toggleReplayBlock(state: AppState, blockIndex: number = state.replaySelectedBlockIndex): void { + const itemCount = state.cachedReplayReport?.flowBlocks.length ?? 0; + if (itemCount <= 0) return; + const selected = clampItemIndex(blockIndex, itemCount); + state.replaySelectedBlockIndex = selected; + state.replayScrollOffset = keepSelectedItemVisible( + selected, + state.replayScrollOffset, + REPLAY_VISIBLE_BLOCKS, + ); + state.replayExpandedBlockIndex = state.replayExpandedBlockIndex === selected ? null : selected; +} + +function toggleReceiptLine(state: AppState, lineIndex: number = state.receiptsSelectedLineIndex): void { + const itemCount = getReceiptLineCount(state); + if (itemCount <= 0) return; + const selected = clampItemIndex(lineIndex, itemCount); + state.receiptsSelectedLineIndex = selected; + state.receiptsScrollOffset = keepSelectedItemVisible( + selected, + state.receiptsScrollOffset, + RECEIPTS_VISIBLE_ROWS, + ); + state.receiptsExpandedLineIndex = state.receiptsExpandedLineIndex === selected ? null : selected; +} + +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, + ); +} + +function moveReceiptSelection(state: AppState, direction: number): void { + const itemCount = getReceiptLineCount(state); + if (itemCount <= 0) return; + const selected = clampItemIndex(state.receiptsSelectedLineIndex + direction, itemCount); + state.receiptsSelectedLineIndex = selected; + state.receiptsScrollOffset = keepSelectedItemVisible( + selected, + state.receiptsScrollOffset, + RECEIPTS_VISIBLE_ROWS, + ); +} + function handleViewSwitch(mode: ViewMode): void { if (currentState.selectedView !== mode) { currentState.selectedView = mode; @@ -598,12 +692,10 @@ function handleViewSwitch(mode: ViewMode): void { currentState.nutritionScrollOffset = 0; currentState.compareScrollOffset = 0; currentState.wrappedScrollOffset = 0; - currentState.replayScrollOffset = 0; - currentState.receiptsScrollOffset = 0; - currentState.receiptsExpandedLineIndex = null; + resetReplayInteraction(currentState); + resetReceiptsInteraction(currentState); currentState.receiptsSortMode = 'cost'; currentState.receiptsCategoryFilter = null; - currentState.replayExpandedBlocks = new Set(); currentState.viewTasks.activeLabel = null; // Reset matrix page when switching to matrix if (mode === 'matrix') { @@ -660,12 +752,12 @@ function invalidateWindowCaches(state: AppState): void { state.cachedWasteReport = null; state.cachedNutritionReport = null; state.cachedReceipt = null; - state.receiptsScrollOffset = 0; - state.receiptsExpandedLineIndex = null; + resetReceiptsInteraction(state); state.receiptsSortMode = 'cost'; state.receiptsCategoryFilter = null; state.explainDate = null; // re-derive from new window's peak day state.replayDate = null; + resetReplayInteraction(state); clearViewTaskState(state); } @@ -680,6 +772,8 @@ function invalidateAllCaches(state: AppState): void { state.cachedWasteReport = null; state.cachedNutritionReport = null; state.cachedReceipt = null; + resetReplayInteraction(state); + resetReceiptsInteraction(state); state.nutritionSignalsLoading = false; state.nutritionSignalsLoadedKeys.clear(); clearViewTaskState(state); @@ -692,8 +786,7 @@ function shiftReplayDate(state: AppState, direction: number): void { d.setUTCDate(d.getUTCDate() + direction); state.replayDate = d.toISOString().slice(0, 10); state.cachedReplayReport = null; - state.replayScrollOffset = 0; - state.replayExpandedBlocks = new Set(); + resetReplayInteraction(state); } /** Navigate explain date forward or backward by one day */ @@ -762,10 +855,7 @@ function getScrollableItemCount(state: AppState): number { case 'nutrition': return Math.min(state.cachedNutritionReport?.repos.length ?? 0, 30); case 'receipts': { - const receipt = state.cachedReceipt; - if (!receipt) return 0; - return deriveReceiptLines(receipt, state.receiptsSortMode, state.receiptsCategoryFilter) - .length; + return getReceiptLineCount(state); } default: return 0; @@ -783,11 +873,11 @@ function getVisibleCount(view: ViewMode): number { case 'wrapped': return 20; case 'replay': - return 15; + return REPLAY_VISIBLE_BLOCKS; case 'nutrition': return NUTRITION_VISIBLE_ROWS; case 'receipts': - return 12; + return RECEIPTS_VISIBLE_ROWS; default: return 10; } @@ -1094,6 +1184,16 @@ export async function main(): Promise { } return true; } + if (state.selectedView === 'replay') { + moveReplaySelection(state, 1); + render(state, renderer); + return true; + } + if (state.selectedView === 'receipts') { + moveReceiptSelection(state, 1); + render(state, renderer); + return true; + } if (SCROLLABLE_VIEWS.has(state.selectedView)) { const itemCount = getScrollableItemCount(state); const visibleCount = getVisibleCount(state.selectedView); @@ -1117,6 +1217,16 @@ export async function main(): Promise { } return true; } + if (state.selectedView === 'replay') { + moveReplaySelection(state, -1); + render(state, renderer); + return true; + } + if (state.selectedView === 'receipts') { + moveReceiptSelection(state, -1); + render(state, renderer); + return true; + } if (SCROLLABLE_VIEWS.has(state.selectedView)) { const current = getScrollOffset(state); if (current > 0) { @@ -1152,22 +1262,16 @@ export async function main(): Promise { return true; } - // Enter: expand/collapse flow blocks (replay view) - if (sequence === '\r' && state.selectedView === 'replay') { - const blockIndex = state.replayScrollOffset; - if (state.replayExpandedBlocks.has(blockIndex)) { - state.replayExpandedBlocks.delete(blockIndex); - } else { - state.replayExpandedBlocks.add(blockIndex); - } + // Enter/Space: expand/collapse the selected flow block (replay view) + if ((sequence === '\r' || sequence === ' ') && state.selectedView === 'replay') { + toggleReplayBlock(state); render(state, renderer); return true; } - // Enter: expand/collapse sample prompts for the top visible line (receipts view) - if (sequence === '\r' && state.selectedView === 'receipts') { - const target = state.receiptsScrollOffset; - state.receiptsExpandedLineIndex = state.receiptsExpandedLineIndex === target ? null : target; + // Enter/Space: expand/collapse sample prompts for the selected receipt line + if ((sequence === '\r' || sequence === ' ') && state.selectedView === 'receipts') { + toggleReceiptLine(state); render(state, renderer); return true; } @@ -1177,8 +1281,7 @@ export async function main(): Promise { const order: Array<'cost' | 'qty' | 'alpha'> = ['cost', 'qty', 'alpha']; const nextIndex = (order.indexOf(state.receiptsSortMode) + 1) % order.length; state.receiptsSortMode = order[nextIndex]!; - state.receiptsScrollOffset = 0; - state.receiptsExpandedLineIndex = null; + resetReceiptsInteraction(state); render(state, renderer); return true; } @@ -1194,8 +1297,7 @@ export async function main(): Promise { const currentIndex = current === null ? -1 : categories.indexOf(current); const nextIndex = currentIndex + 1; state.receiptsCategoryFilter = nextIndex >= categories.length ? null : categories[nextIndex]!; - state.receiptsScrollOffset = 0; - state.receiptsExpandedLineIndex = null; + resetReceiptsInteraction(state); render(state, renderer); return true; } diff --git a/packages/tui/src/lib/format.ts b/packages/tui/src/lib/format.ts index 3bd339a..73e1a20 100644 --- a/packages/tui/src/lib/format.ts +++ b/packages/tui/src/lib/format.ts @@ -65,6 +65,53 @@ export function truncate(s: string, maxLen: number): string { return s.slice(0, maxLen - 1) + '\u2026'; } +export function cleanInlineText(value: string): string { + return value.replace(/\s+/g, ' ').trim(); +} + +export function wrapText(value: string, width: number, maxLines: number): string[] { + const safeWidth = Math.max(1, width); + const safeMaxLines = Math.max(1, maxLines); + const words = cleanInlineText(value).split(' ').filter(Boolean); + const lines: string[] = []; + let current = ''; + + for (const word of words) { + const next = current ? `${current} ${word}` : word; + if (next.length <= safeWidth) { + current = next; + continue; + } + + if (current) { + lines.push(current); + } else { + lines.push(truncate(word, safeWidth)); + current = ''; + if (lines.length >= safeMaxLines) { + break; + } + continue; + } + + if (lines.length >= safeMaxLines) { + break; + } + current = word.length > safeWidth ? truncate(word, safeWidth) : word; + } + + if (current && lines.length < safeMaxLines) { + lines.push(current); + } + + const fullText = words.join(' '); + if (lines.length === safeMaxLines && fullText.length > lines.join(' ').length) { + lines[safeMaxLines - 1] = truncate(lines[safeMaxLines - 1] ?? '', safeWidth); + } + + return lines.length > 0 ? lines : ['']; +} + /** Build a simple ASCII bar chart segment */ export function asciiBar(ratio: number, width: number): string { const clamped = Math.max(0, Math.min(1, ratio)); diff --git a/packages/tui/src/lib/state.ts b/packages/tui/src/lib/state.ts index 3671f46..f909985 100644 --- a/packages/tui/src/lib/state.ts +++ b/packages/tui/src/lib/state.ts @@ -78,10 +78,12 @@ export interface AppState { // replay view state replayDate: string | null; replayScrollOffset: number; - replayExpandedBlocks: Set; + replaySelectedBlockIndex: number; + replayExpandedBlockIndex: number | null; // receipts view state receiptsScrollOffset: number; + receiptsSelectedLineIndex: number; receiptsExpandedLineIndex: number | null; receiptsSortMode: ReceiptsSortMode; receiptsCategoryFilter: ReceiptCategory | null; @@ -136,8 +138,10 @@ export function createInitialState(): AppState { cursorSetupStatusOverride: null, replayDate: null, replayScrollOffset: 0, - replayExpandedBlocks: new Set(), + replaySelectedBlockIndex: 0, + replayExpandedBlockIndex: null, receiptsScrollOffset: 0, + receiptsSelectedLineIndex: 0, receiptsExpandedLineIndex: null, receiptsSortMode: 'cost', receiptsCategoryFilter: null, diff --git a/packages/tui/src/panels/accordion.test.ts b/packages/tui/src/panels/accordion.test.ts new file mode 100644 index 0000000..558e601 --- /dev/null +++ b/packages/tui/src/panels/accordion.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, test } from 'bun:test'; +import type { FlowBlock, Receipt, ReplayReport, UsageEvent } from '@tokenleak/core'; +import { createReplayPanel } from './replay.js'; +import { createReceiptsPanel } from './receipts.js'; + +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 makeEvent(index: number, model: string): UsageEvent { + return { + provider: 'claude-code', + timestamp: `2026-03-10T09:0${index}:00.000Z`, + date: '2026-03-10', + model, + inputTokens: 1000 + index, + outputTokens: 500 + index, + cacheReadTokens: 250 + index, + cacheWriteTokens: 50, + totalTokens: 1800 + index, + cost: 0.12 + index / 100, + sessionId: 'session-1', + }; +} + +function makeBlock(blockIndex: number, eventCount: number, model: string): FlowBlock { + const events = Array.from({ length: eventCount }, (_, index) => makeEvent(index, model)); + return { + blockIndex, + label: blockIndex === 0 ? 'Deep Flow' : 'Quick Lookup', + start: events[0]!.timestamp, + end: events[events.length - 1]!.timestamp, + durationMs: 60_000 * eventCount, + eventCount, + inputTokens: 5000, + outputTokens: 2500, + cacheReadTokens: 1200, + cacheWriteTokens: 250, + totalTokens: 8950, + cost: 1.42, + dominantModel: model, + events, + modelSwitches: 1, + cacheHitRateTrend: [0.1, 0.5], + }; +} + +function makeReplayReport(): ReplayReport { + const model = 'claude-super-long-model-name-that-needs-truncation'; + const flowBlocks = [makeBlock(0, 5, model), makeBlock(1, 1, 'short-model')]; + const events = flowBlocks.flatMap((block) => block.events); + return { + date: '2026-03-10', + events, + flowBlocks, + tokenVelocity: [{ minute: '2026-03-10T09:00:00.000Z', tokensPerMinute: 5000 }], + summary: { + totalSessions: 1, + totalEvents: events.length, + flowTimeMs: 300_000, + thinkTimeMs: 60_000, + flowThinkRatio: 0.83, + peakMinute: { minute: '2026-03-10T09:00:00.000Z', tokensPerMinute: 5000 }, + }, + }; +} + +function makeReceipt(): Receipt { + return { + lines: [ + { + description: 'A very long debugging prompt cluster description that must not collide with the cost column', + category: 'debugging', + quantity: 4, + totalCost: 3.25, + totalTokens: 12_000, + samplePrompts: [ + 'Please investigate this failing test with a very long prompt that needs wrapping inside the receipt details instead of overflowing the table.', + 'Another representative debugging prompt with enough text to exercise the wrapped sample prompt rendering.', + ], + }, + { + description: 'Short refactor request', + category: 'refactoring', + quantity: 1, + totalCost: 0.5, + totalTokens: 1000, + samplePrompts: [], + }, + ], + summary: { + dateRange: { since: '2026-03-10', until: '2026-03-10' }, + accountedPrompts: 5, + unlabeledEvents: 0, + subtotal: 3.75, + serviceFees: 0, + total: 3.75, + }, + }; +} + +describe('Replay accordion panel', () => { + test('renders a selected collapsed block without expanded details', () => { + const lines = collectTextContent(createReplayPanel(makeReplayReport(), '2026-03-10', 0, null, 0)); + + expect(lines.some((line) => line.includes('▸ ▶ 09:00'))).toBe(true); + expect(lines.some((line) => line.includes('Model:'))).toBe(false); + }); + + test('renders expanded details in bounded text lines', () => { + const lines = collectTextContent(createReplayPanel(makeReplayReport(), '2026-03-10', 0, 0, 0)); + + expect(lines.some((line) => line.includes('▸ ▼ 09:00'))).toBe(true); + expect(lines.some((line) => line.includes('Model: claude-super-long-model-name'))).toBe(true); + expect(lines.some((line) => line.includes('+1 more events'))).toBe(true); + expect(lines.every((line) => line.length <= 78)).toBe(true); + }); +}); + +describe('Receipts accordion panel', () => { + test('keeps selection independent from expansion', () => { + const state = { + receiptsScrollOffset: 0, + receiptsSelectedLineIndex: 1, + receiptsExpandedLineIndex: 0, + receiptsSortMode: 'cost' as const, + receiptsCategoryFilter: null, + }; + const lines = collectTextContent(createReceiptsPanel(state, makeReceipt())); + + expect(lines.some((line) => line.includes('▼') && line.includes('1.'))).toBe(true); + expect(lines.some((line) => line.includes('▸ ▶') && line.includes('2.'))).toBe(true); + }); + + test('wraps expanded sample prompts into bounded detail lines', () => { + const state = { + receiptsScrollOffset: 0, + receiptsSelectedLineIndex: 0, + receiptsExpandedLineIndex: 0, + receiptsSortMode: 'cost' as const, + receiptsCategoryFilter: null, + }; + const lines = collectTextContent(createReceiptsPanel(state, makeReceipt())); + + expect(lines.some((line) => line.includes('▸ ▼') && line.includes('1.'))).toBe(true); + expect(lines.some((line) => line.includes('└ Please investigate this failing test'))).toBe(true); + expect(lines.every((line) => line.length <= 78)).toBe(true); + }); +}); diff --git a/packages/tui/src/panels/help.ts b/packages/tui/src/panels/help.ts index 4390d7a..2597640 100644 --- a/packages/tui/src/panels/help.ts +++ b/packages/tui/src/panels/help.ts @@ -84,9 +84,16 @@ export function createHelpPanel() { ['h', 'Previous day'], ['l', 'Next day'], ]), + ...helpSection('REPLAY VIEW', [ + ['h / l', 'Previous / next day'], + ['j / k', 'Select flow block'], + ['Enter / Space', 'Toggle selected flow block'], + ['Click', 'Select and toggle a flow block'], + ]), ...helpSection('RECEIPTS VIEW', [ - ['j / k', 'Scroll line items'], - ['enter', 'Expand top line into sample prompts'], + ['j / k', 'Select line item'], + ['Enter / Space', 'Toggle selected line item'], + ['Click', 'Select and toggle a line item'], ['o', 'Cycle sort (cost / qty / alpha)'], ['f', 'Cycle category filter'], ]), diff --git a/packages/tui/src/panels/receipts.ts b/packages/tui/src/panels/receipts.ts index 0c3b26d..54be913 100644 --- a/packages/tui/src/panels/receipts.ts +++ b/packages/tui/src/panels/receipts.ts @@ -5,7 +5,7 @@ import { type ReceiptCategory, type ReceiptLine, } from '@tokenleak/core'; -import { formatCost, padRight, padLeft, truncate } from '../lib/format.js'; +import { formatCost, padRight, padLeft, truncate, wrapText } from '../lib/format.js'; import { deriveReceiptLines } from '../lib/data.js'; import { COLORS, BOLD } from '../lib/theme.js'; import type { ReceiptsSortMode } from '../lib/state.js'; @@ -16,51 +16,70 @@ const SORT_LABELS: Record = { alpha: 'alpha', }; -const VISIBLE_ROWS = 12; +export const RECEIPTS_VISIBLE_ROWS = 8; +const CONTENT_WIDTH = 78; +const SAMPLE_LIMIT = 3; +const SAMPLE_LINE_LIMIT = 2; -function renderLine(line: ReceiptLine, rank: number, descColWidth: number, isExpanded: boolean) { +type ReceiptToggleHandler = (lineIndex: number) => void; + +function renderLine( + line: ReceiptLine, + lineIndex: number, + rank: number, + descColWidth: number, + isSelected: boolean, + isExpanded: boolean, + onToggleLine?: ReceiptToggleHandler, +) { const rankStr = padLeft(`${rank}.`, 3); const category = CATEGORY_LABELS_SHORT[line.category] ?? line.category.toUpperCase(); const qty = `${line.quantity}×`; const cost = formatCost(line.totalCost); const desc = truncate(line.description, descColWidth); - // Arrow indicator on the expanded line so the cursor position is obvious. - const pointer = isExpanded ? '▸' : ' '; + const pointer = isSelected ? '▸' : ' '; + const expandIcon = isExpanded ? '▼' : '▶'; + const content = truncate( + ` ${pointer} ${expandIcon} ${rankStr} ${padRight(category, 9)} ${padLeft(qty, 5)} ${padRight(desc, descColWidth)} ${padLeft(cost, 10)}`, + CONTENT_WIDTH, + ); return Box( - { flexDirection: 'row', width: '100%', paddingLeft: 1, paddingRight: 1 }, + { + flexDirection: 'row', + width: '100%', + paddingLeft: 1, + paddingRight: 1, + onMouseDown: onToggleLine ? () => onToggleLine(lineIndex) : undefined, + }, Text({ - content: `${pointer}${rankStr} `, - fg: isExpanded ? COLORS.amber : COLORS.dimWhite, - attributes: isExpanded ? BOLD : undefined, + content, + fg: isSelected ? COLORS.amber : COLORS.white, + attributes: isSelected || isExpanded ? BOLD : undefined, }), - Text({ content: padRight(category, 9), fg: COLORS.amber, attributes: BOLD }), - Text({ content: padLeft(qty, 5), fg: COLORS.cyan }), - Text({ content: ` ${padRight(desc, descColWidth)}`, fg: COLORS.white }), - Text({ content: padLeft(cost, 10), fg: COLORS.green }), ); } -function renderSamplePrompt(prompt: string, descColWidth: number) { - const indent = 6; // align under the description column - const width = Math.max(10, descColWidth - 2); - const text = truncate(prompt, width); - return Box( - { flexDirection: 'row', width: '100%', paddingLeft: 1, paddingRight: 1 }, - Text({ content: ' '.repeat(indent), fg: COLORS.dimWhite }), - Text({ content: '└ ', fg: COLORS.dimWhite }), - Text({ content: text, fg: COLORS.dimWhite }), +function renderDetailLine(value: string, fg: string = COLORS.dimWhite) { + return Text({ content: truncate(` ${value}`, CONTENT_WIDTH), fg }); +} + +function renderSamplePrompt(prompt: string) { + return wrapText(prompt, CONTENT_WIDTH - 10, SAMPLE_LINE_LIMIT).map((line, index) => + renderDetailLine(`${index === 0 ? '└ ' : ' '}${line}`), ); } export function createReceiptsPanel( state: { receiptsScrollOffset: number; + receiptsSelectedLineIndex: number; receiptsExpandedLineIndex: number | null; receiptsSortMode: ReceiptsSortMode; receiptsCategoryFilter: ReceiptCategory | null; }, receipt: Receipt | null, + onToggleLine?: ReceiptToggleHandler, ) { if (!receipt || receipt.lines.length === 0) { return Box( @@ -95,11 +114,12 @@ export function createReceiptsPanel( state.receiptsSortMode, state.receiptsCategoryFilter, ); - const offset = state.receiptsScrollOffset; - const visible = derivedLines.slice(offset, offset + VISIBLE_ROWS); + const maxOffset = Math.max(0, derivedLines.length - RECEIPTS_VISIBLE_ROWS); + const offset = Math.max(0, Math.min(state.receiptsScrollOffset, maxOffset)); + const visible = derivedLines.slice(offset, offset + RECEIPTS_VISIBLE_ROWS); const DESC_MIN = 20; - const DESC_MAX = 52; + const DESC_MAX = 38; const longest = Math.max(DESC_MIN, ...visible.map((l) => l.description.length)); const descColWidth = Math.min(DESC_MAX, longest); @@ -146,11 +166,13 @@ export function createReceiptsPanel( const columnHeader = Box( { flexDirection: 'row', width: '100%', paddingLeft: 1, paddingRight: 1 }, - Text({ content: padRight('', 4), fg: COLORS.dimWhite }), - Text({ content: padRight('Bucket', 9), fg: COLORS.dimWhite }), - Text({ content: padLeft('Qty', 5), fg: COLORS.dimWhite }), - Text({ content: ` ${padRight('Description', descColWidth)}`, fg: COLORS.dimWhite }), - Text({ content: padLeft('Cost', 10), fg: COLORS.dimWhite }), + Text({ + content: truncate( + ` ${padRight('#', 3)} ${padRight('Bucket', 9)} ${padLeft('Qty', 5)} ${padRight('Description', descColWidth)} ${padLeft('Cost', 10)}`, + CONTENT_WIDTH, + ), + fg: COLORS.dimWhite, + }), ); const summary = Box( @@ -185,18 +207,19 @@ export function createReceiptsPanel( attributes: BOLD, }), ), - Text({ - content: `All categories · Subtotal ${formatCost(receipt.summary.subtotal)} · Service fees ${formatCost(receipt.summary.serviceFees)} · Total ${formatCost(receipt.summary.total)}`, - fg: COLORS.dimWhite, - }), + ...wrapText( + `All categories · Subtotal ${formatCost(receipt.summary.subtotal)} · Service fees ${formatCost(receipt.summary.serviceFees)} · Total ${formatCost(receipt.summary.total)}`, + CONTENT_WIDTH - 2, + 2, + ).map((line) => Text({ content: line, fg: COLORS.dimWhite })), ) : Box( - { flexDirection: 'row', width: '100%', paddingLeft: 1, paddingRight: 1 }, - Text({ - content: `Subtotal ${formatCost(receipt.summary.subtotal)} · Service fees ${formatCost(receipt.summary.serviceFees)} · `, - fg: COLORS.dimWhite, - }), - Text({ content: `Total ${formatCost(receipt.summary.total)}`, fg: COLORS.amber, attributes: BOLD }), + { flexDirection: 'column', width: '100%', paddingLeft: 1, paddingRight: 1 }, + ...wrapText( + `Subtotal ${formatCost(receipt.summary.subtotal)} · Service fees ${formatCost(receipt.summary.serviceFees)} · Total ${formatCost(receipt.summary.total)}`, + CONTENT_WIDTH - 2, + 2, + ).map((line) => Text({ content: line, fg: COLORS.dimWhite })), ); const expandedIndex = state.receiptsExpandedLineIndex; @@ -204,21 +227,27 @@ export function createReceiptsPanel( for (let i = 0; i < visible.length; i++) { const absoluteIndex = offset + i; const isExpanded = expandedIndex === absoluteIndex; - rows.push(renderLine(visible[i]!, absoluteIndex + 1, descColWidth, isExpanded)); + const isSelected = state.receiptsSelectedLineIndex === absoluteIndex; + rows.push(renderLine( + visible[i]!, + absoluteIndex, + absoluteIndex + 1, + descColWidth, + isSelected, + isExpanded, + onToggleLine, + )); if (isExpanded) { const samples = visible[i]!.samplePrompts; if (samples.length === 0) { - rows.push( - Box( - { flexDirection: 'row', width: '100%', paddingLeft: 1, paddingRight: 1 }, - Text({ content: ' ', fg: COLORS.dimWhite }), - Text({ content: '└ ', fg: COLORS.dimWhite }), - Text({ content: 'No sample prompts available for this line.', fg: COLORS.dimWhite }), - ), - ); + rows.push(renderDetailLine('└ No sample prompts available for this line.')); } else { - for (const sample of samples) { - rows.push(renderSamplePrompt(sample, descColWidth)); + const visibleSamples = samples.slice(0, SAMPLE_LIMIT); + for (const sample of visibleSamples) { + rows.push(...renderSamplePrompt(sample)); + } + if (samples.length > visibleSamples.length) { + rows.push(renderDetailLine(`+${samples.length - visibleSamples.length} more samples`)); } } } diff --git a/packages/tui/src/panels/replay.ts b/packages/tui/src/panels/replay.ts index 4070c27..1072f72 100644 --- a/packages/tui/src/panels/replay.ts +++ b/packages/tui/src/panels/replay.ts @@ -1,10 +1,15 @@ import { Box, Text } from '@opentui/core'; import type { FlowBlock, ReplayReport, TokenVelocityPoint } from '@tokenleak/core'; -import { formatCost, formatTokens, formatPercent, formatShortDate, padRight, padLeft, truncate } from '../lib/format.js'; +import { formatCost, formatTokens, formatPercent, formatShortDate, padRight, padLeft, truncate, wrapText } from '../lib/format.js'; import { COLORS, BOLD } from '../lib/theme.js'; const HEATMAP_BLOCKS = [' ', '\u2581', '\u2582', '\u2583', '\u2584', '\u2585', '\u2586', '\u2587', '\u2588']; const HEATMAP_SLOTS = 48; +const CONTENT_WIDTH = 78; +const REPLAY_EVENT_DETAIL_LIMIT = 4; +export const REPLAY_VISIBLE_BLOCKS = 8; + +type ReplayToggleHandler = (blockIndex: number) => void; function formatTime(iso: string): string { const date = new Date(iso); @@ -48,39 +53,60 @@ function renderActivityBar(report: ReplayReport) { ); } -function renderFlowBlockCard(block: FlowBlock, expanded: boolean) { +function renderDetailLine(content: string, fg: string = COLORS.dimWhite) { + return Text({ content: truncate(` ${content}`, CONTENT_WIDTH), fg }); +} + +function renderFlowBlockCard( + block: FlowBlock, + selected: boolean, + expanded: boolean, + onToggleBlock?: ReplayToggleHandler, +) { const timeRange = `${formatTime(block.start)}\u2013${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 headerLine = Text({ - content: ` ${expandIcon} ${headerText}`, + content: truncate(` ${cursor} ${expandIcon} ${headerText}`, CONTENT_WIDTH), fg: block.label === 'Deep Flow' ? COLORS.cyan : block.label === 'Quick Lookup' ? COLORS.dimWhite : COLORS.white, attributes: BOLD, }); if (!expanded) { - return Box({ flexDirection: 'column', width: '100%' }, headerLine); + return Box( + { + flexDirection: 'column', + width: '100%', + onMouseDown: onToggleBlock ? () => onToggleBlock(block.blockIndex) : undefined, + }, + headerLine, + ); } const children = [headerLine]; - children.push( - Text({ - content: ` Model: ${block.dominantModel}${block.modelSwitches > 0 ? ` (${block.modelSwitches} switch${block.modelSwitches === 1 ? '' : 'es'})` : ''}`, - fg: COLORS.white, - }), - ); + const switchText = block.modelSwitches > 0 + ? `, ${block.modelSwitches} switch${block.modelSwitches === 1 ? '' : 'es'}` + : ''; + children.push(renderDetailLine(`Model: ${block.dominantModel}${switchText}`, COLORS.white)); + children.push(renderDetailLine( + `Input ${formatTokens(block.inputTokens)} | Output ${formatTokens(block.outputTokens)} | Cache read ${formatTokens(block.cacheReadTokens)} | Cache write ${formatTokens(block.cacheWriteTokens)}`, + )); - for (const event of block.events) { + const events = block.events.slice(0, REPLAY_EVENT_DETAIL_LIMIT); + for (const event of events) { const time = formatTime(event.timestamp); const cacheRate = (event.inputTokens + event.cacheReadTokens) > 0 ? event.cacheReadTokens / (event.inputTokens + event.cacheReadTokens) : 0; - const line = ` ${padRight(time, 7)} ${padRight(truncate(event.model, 18), 19)} ${padLeft(formatTokens(event.totalTokens), 8)} cache:${formatPercent(cacheRate).padStart(4)} ${formatCost(event.cost).padStart(8)}`; - children.push( - Text({ content: line, fg: COLORS.white }), - ); + const line = `${padRight(time, 6)} ${padRight(truncate(event.model, 20), 21)} ${padLeft(formatTokens(event.totalTokens), 8)} tok cache ${padLeft(formatPercent(cacheRate), 6)} ${padLeft(formatCost(event.cost), 8)}`; + children.push(renderDetailLine(line, COLORS.white)); + } + const hiddenEventCount = block.events.length - events.length; + if (hiddenEventCount > 0) { + children.push(renderDetailLine(`+${hiddenEventCount} more events`, COLORS.dimWhite)); } const trend = block.cacheHitRateTrend; @@ -90,17 +116,24 @@ function renderFlowBlockCard(block: FlowBlock, expanded: boolean) { if (first !== last) { const direction = Number(last) > Number(first) ? '\u2191' : '\u2193'; children.push( - Text({ - content: ` Cache: ${first}% \u2192 ${last}% ${direction}`, - fg: Number(last) > Number(first) ? COLORS.green : COLORS.red, - }), + renderDetailLine( + `Cache trend: ${first}% \u2192 ${last}% ${direction}`, + Number(last) > Number(first) ? COLORS.green : COLORS.red, + ), ); } } children.push(Text({ content: '', fg: COLORS.dimWhite })); - return Box({ flexDirection: 'column', width: '100%' }, ...children); + return Box( + { + flexDirection: 'column', + width: '100%', + onMouseDown: onToggleBlock ? () => onToggleBlock(block.blockIndex) : undefined, + }, + ...children, + ); } function renderPulseChart(velocity: TokenVelocityPoint[]) { @@ -160,17 +193,20 @@ function renderDaySummary(report: ReplayReport) { parts.push(`Peak: ${formatTokens(s.peakMinute.tokensPerMinute)} tok/min at ${formatTime(s.peakMinute.minute)}`); } + const lines = wrapText(parts.join(' | '), CONTENT_WIDTH - 2, 2); return Box( { flexDirection: 'column', width: '100%', paddingLeft: 1, paddingRight: 1 }, - Text({ content: parts.join(' | '), fg: COLORS.white }), + ...lines.map((line) => Text({ content: line, fg: COLORS.white })), ); } export function createReplayPanel( report: ReplayReport | null, replayDate: string | null, - expandedBlocks: Set, + selectedBlockIndex: number, + expandedBlockIndex: number | null, scrollOffset: number, + onToggleBlock?: ReplayToggleHandler, ) { const dateLabel = replayDate ? formatShortDate(replayDate) : '\u2014'; @@ -196,9 +232,24 @@ export function createReplayPanel( } 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 blockCards = report.flowBlocks - .slice(scrollOffset, scrollOffset + 20) - .map((block) => renderFlowBlockCard(block, expandedBlocks.has(block.blockIndex))); + .slice(safeOffset, safeOffset + REPLAY_VISIBLE_BLOCKS) + .map((block) => renderFlowBlockCard( + block, + block.blockIndex === selectedBlockIndex, + block.blockIndex === expandedBlockIndex, + onToggleBlock, + )); + + const scrollIndicators: ReturnType[] = []; + if (safeOffset > 0) { + scrollIndicators.push(Text({ content: ` ${safeOffset} more above`, fg: COLORS.dimWhite })); + } + const below = report.flowBlocks.length - safeOffset - blockCards.length; + if (below > 0) { + scrollIndicators.push(Text({ content: ` ${below} more below`, fg: COLORS.dimWhite })); + } return Box( { @@ -229,6 +280,7 @@ export function createReplayPanel( }), ), ...blockCards, + ...scrollIndicators, Text({ content: '', fg: COLORS.dimWhite }), renderPulseChart(report.tokenVelocity), Text({ content: '', fg: COLORS.dimWhite }), diff --git a/packages/tui/src/panels/status-bar.ts b/packages/tui/src/panels/status-bar.ts index ad3c6fe..fe721fd 100644 --- a/packages/tui/src/panels/status-bar.ts +++ b/packages/tui/src/panels/status-bar.ts @@ -97,9 +97,9 @@ export function buildStatusBar(state: AppState) { } else if (state.selectedView === 'explain') { keys = `${nav} h/l:date r:refresh${cursorHint} ${helpHint} q:quit`; } else if (state.selectedView === 'replay') { - keys = `${nav} h/l:date j/k:scroll enter:expand r:refresh${cursorHint} ${helpHint} q:quit`; + 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:scroll enter:expand o:sort f:filter r:refresh${cursorHint} ${helpHint} q:quit`; + keys = `${nav} j/k:select enter/space:toggle o:sort f:filter r:refresh${cursorHint} ${helpHint} q:quit`; } else if ( state.selectedView === 'advisor' || state.selectedView === 'focus' || From 7f2340db426e85b09678b62d1288a019c6056b2a Mon Sep 17 00:00:00 2001 From: Deva Annamaraju Date: Sun, 26 Apr 2026 23:17:46 +0530 Subject: [PATCH 2/2] fix accordion narrow widths --- packages/tui/src/index.ts | 25 +++++++++++++------ packages/tui/src/panels/accordion.test.ts | 21 ++++++++++++++++ packages/tui/src/panels/receipts.ts | 29 +++++++++++++---------- packages/tui/src/panels/replay.ts | 25 +++++++++++-------- 4 files changed, 70 insertions(+), 30 deletions(-) diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 1b36b5c..93d48c6 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -50,9 +50,9 @@ 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_VISIBLE_BLOCKS } from './panels/replay.js'; +import { createReplayPanel, REPLAY_MAX_CONTENT_WIDTH, REPLAY_VISIBLE_BLOCKS } from './panels/replay.js'; import { createNutritionPanel, NUTRITION_VISIBLE_ROWS } from './panels/nutrition.js'; -import { createReceiptsPanel, RECEIPTS_VISIBLE_ROWS } from './panels/receipts.js'; +import { createReceiptsPanel, RECEIPTS_MAX_CONTENT_WIDTH, RECEIPTS_VISIBLE_ROWS } from './panels/receipts.js'; import { buildCursorBanner, createCursorSetupPanel, isEscapeKeySequence } from './panels/cursor-setup.js'; const CURSOR_SETUP_LABEL_INPUT_ID = 'cursor-setup-label-input'; @@ -306,6 +306,7 @@ function buildContent(state: AppState, renderer: CliRenderer) { state.replaySelectedBlockIndex, state.replayExpandedBlockIndex, state.replayScrollOffset, + getPanelContentWidth(renderer, REPLAY_MAX_CONTENT_WIDTH), ); } if (!state.replayDate) { @@ -323,6 +324,7 @@ function buildContent(state: AppState, renderer: CliRenderer) { state.replaySelectedBlockIndex, state.replayExpandedBlockIndex, state.replayScrollOffset, + getPanelContentWidth(renderer, REPLAY_MAX_CONTENT_WIDTH), (blockIndex) => { toggleReplayBlock(state, blockIndex); render(state, renderer); @@ -382,7 +384,7 @@ function buildContent(state: AppState, renderer: CliRenderer) { } case 'receipts': if (!hasWindowData) { - return createReceiptsPanel(state, null); + return createReceiptsPanel(state, null, getPanelContentWidth(renderer, RECEIPTS_MAX_CONTENT_WIDTH)); } if (!state.cachedReceipt) { const key = getSelectedViewTaskKey(state, 'receipts'); @@ -390,10 +392,15 @@ function buildContent(state: AppState, renderer: CliRenderer) { ensureReceipt(state); }); } - return createReceiptsPanel(state, state.cachedReceipt, (lineIndex) => { - toggleReceiptLine(state, lineIndex); - render(state, renderer); - }); + return createReceiptsPanel( + state, + state.cachedReceipt, + getPanelContentWidth(renderer, RECEIPTS_MAX_CONTENT_WIDTH), + (lineIndex) => { + toggleReceiptLine(state, lineIndex); + render(state, renderer); + }, + ); default: return Box({ flexDirection: 'column', width: '100%', flexGrow: 1 }); } @@ -600,6 +607,10 @@ function tryOpenCursorSetup(state: AppState, renderer: CliRenderer): boolean { let currentState: AppState; let currentRenderer: CliRenderer; +function getPanelContentWidth(renderer: CliRenderer, maxWidth: number): number { + return Math.max(1, Math.min(maxWidth, renderer.terminalWidth - 4)); +} + function clampItemIndex(index: number, itemCount: number): number { if (itemCount <= 0) return 0; return Math.max(0, Math.min(index, itemCount - 1)); diff --git a/packages/tui/src/panels/accordion.test.ts b/packages/tui/src/panels/accordion.test.ts index 558e601..48ff95d 100644 --- a/packages/tui/src/panels/accordion.test.ts +++ b/packages/tui/src/panels/accordion.test.ts @@ -130,6 +130,13 @@ describe('Replay accordion panel', () => { expect(lines.some((line) => line.includes('+1 more events'))).toBe(true); expect(lines.every((line) => line.length <= 78)).toBe(true); }); + + test('honors a narrower caller-provided content width', () => { + const lines = collectTextContent(createReplayPanel(makeReplayReport(), '2026-03-10', 0, 0, 0, 50)); + + expect(lines.some((line) => line.includes('▸ ▼'))).toBe(true); + expect(lines.every((line) => line.length <= 50)).toBe(true); + }); }); describe('Receipts accordion panel', () => { @@ -161,4 +168,18 @@ describe('Receipts accordion panel', () => { expect(lines.some((line) => line.includes('└ Please investigate this failing test'))).toBe(true); expect(lines.every((line) => line.length <= 78)).toBe(true); }); + + test('honors a narrower caller-provided content width', () => { + const state = { + receiptsScrollOffset: 0, + receiptsSelectedLineIndex: 0, + receiptsExpandedLineIndex: 0, + receiptsSortMode: 'cost' as const, + receiptsCategoryFilter: null, + }; + const lines = collectTextContent(createReceiptsPanel(state, makeReceipt(), 50)); + + expect(lines.some((line) => line.includes('▸ ▼'))).toBe(true); + expect(lines.every((line) => line.length <= 50)).toBe(true); + }); }); diff --git a/packages/tui/src/panels/receipts.ts b/packages/tui/src/panels/receipts.ts index 54be913..6307e85 100644 --- a/packages/tui/src/panels/receipts.ts +++ b/packages/tui/src/panels/receipts.ts @@ -17,7 +17,7 @@ const SORT_LABELS: Record = { }; export const RECEIPTS_VISIBLE_ROWS = 8; -const CONTENT_WIDTH = 78; +export const RECEIPTS_MAX_CONTENT_WIDTH = 78; const SAMPLE_LIMIT = 3; const SAMPLE_LINE_LIMIT = 2; @@ -30,6 +30,7 @@ function renderLine( descColWidth: number, isSelected: boolean, isExpanded: boolean, + contentWidth: number, onToggleLine?: ReceiptToggleHandler, ) { const rankStr = padLeft(`${rank}.`, 3); @@ -41,7 +42,7 @@ function renderLine( const expandIcon = isExpanded ? '▼' : '▶'; const content = truncate( ` ${pointer} ${expandIcon} ${rankStr} ${padRight(category, 9)} ${padLeft(qty, 5)} ${padRight(desc, descColWidth)} ${padLeft(cost, 10)}`, - CONTENT_WIDTH, + contentWidth, ); return Box( @@ -60,13 +61,13 @@ function renderLine( ); } -function renderDetailLine(value: string, fg: string = COLORS.dimWhite) { - return Text({ content: truncate(` ${value}`, CONTENT_WIDTH), fg }); +function renderDetailLine(value: string, contentWidth: number, fg: string = COLORS.dimWhite) { + return Text({ content: truncate(` ${value}`, contentWidth), fg }); } -function renderSamplePrompt(prompt: string) { - return wrapText(prompt, CONTENT_WIDTH - 10, SAMPLE_LINE_LIMIT).map((line, index) => - renderDetailLine(`${index === 0 ? '└ ' : ' '}${line}`), +function renderSamplePrompt(prompt: string, contentWidth: number) { + return wrapText(prompt, contentWidth - 10, SAMPLE_LINE_LIMIT).map((line, index) => + renderDetailLine(`${index === 0 ? '└ ' : ' '}${line}`, contentWidth), ); } @@ -79,6 +80,7 @@ export function createReceiptsPanel( receiptsCategoryFilter: ReceiptCategory | null; }, receipt: Receipt | null, + contentWidth: number = RECEIPTS_MAX_CONTENT_WIDTH, onToggleLine?: ReceiptToggleHandler, ) { if (!receipt || receipt.lines.length === 0) { @@ -169,7 +171,7 @@ export function createReceiptsPanel( Text({ content: truncate( ` ${padRight('#', 3)} ${padRight('Bucket', 9)} ${padLeft('Qty', 5)} ${padRight('Description', descColWidth)} ${padLeft('Cost', 10)}`, - CONTENT_WIDTH, + contentWidth, ), fg: COLORS.dimWhite, }), @@ -209,7 +211,7 @@ export function createReceiptsPanel( ), ...wrapText( `All categories · Subtotal ${formatCost(receipt.summary.subtotal)} · Service fees ${formatCost(receipt.summary.serviceFees)} · Total ${formatCost(receipt.summary.total)}`, - CONTENT_WIDTH - 2, + contentWidth - 2, 2, ).map((line) => Text({ content: line, fg: COLORS.dimWhite })), ) @@ -217,7 +219,7 @@ export function createReceiptsPanel( { flexDirection: 'column', width: '100%', paddingLeft: 1, paddingRight: 1 }, ...wrapText( `Subtotal ${formatCost(receipt.summary.subtotal)} · Service fees ${formatCost(receipt.summary.serviceFees)} · Total ${formatCost(receipt.summary.total)}`, - CONTENT_WIDTH - 2, + contentWidth - 2, 2, ).map((line) => Text({ content: line, fg: COLORS.dimWhite })), ); @@ -235,19 +237,20 @@ export function createReceiptsPanel( descColWidth, isSelected, isExpanded, + contentWidth, onToggleLine, )); if (isExpanded) { const samples = visible[i]!.samplePrompts; if (samples.length === 0) { - rows.push(renderDetailLine('└ No sample prompts available for this line.')); + rows.push(renderDetailLine('└ No sample prompts available for this line.', contentWidth)); } else { const visibleSamples = samples.slice(0, SAMPLE_LIMIT); for (const sample of visibleSamples) { - rows.push(...renderSamplePrompt(sample)); + rows.push(...renderSamplePrompt(sample, contentWidth)); } if (samples.length > visibleSamples.length) { - rows.push(renderDetailLine(`+${samples.length - visibleSamples.length} more samples`)); + rows.push(renderDetailLine(`+${samples.length - visibleSamples.length} more samples`, contentWidth)); } } } diff --git a/packages/tui/src/panels/replay.ts b/packages/tui/src/panels/replay.ts index 1072f72..22d38d5 100644 --- a/packages/tui/src/panels/replay.ts +++ b/packages/tui/src/panels/replay.ts @@ -5,7 +5,7 @@ import { COLORS, BOLD } from '../lib/theme.js'; const HEATMAP_BLOCKS = [' ', '\u2581', '\u2582', '\u2583', '\u2584', '\u2585', '\u2586', '\u2587', '\u2588']; const HEATMAP_SLOTS = 48; -const CONTENT_WIDTH = 78; +export const REPLAY_MAX_CONTENT_WIDTH = 78; const REPLAY_EVENT_DETAIL_LIMIT = 4; export const REPLAY_VISIBLE_BLOCKS = 8; @@ -53,14 +53,15 @@ function renderActivityBar(report: ReplayReport) { ); } -function renderDetailLine(content: string, fg: string = COLORS.dimWhite) { - return Text({ content: truncate(` ${content}`, CONTENT_WIDTH), fg }); +function renderDetailLine(content: string, contentWidth: number, fg: string = COLORS.dimWhite) { + return Text({ content: truncate(` ${content}`, contentWidth), fg }); } function renderFlowBlockCard( block: FlowBlock, selected: boolean, expanded: boolean, + contentWidth: number, onToggleBlock?: ReplayToggleHandler, ) { const timeRange = `${formatTime(block.start)}\u2013${formatTime(block.end)}`; @@ -69,7 +70,7 @@ function renderFlowBlockCard( const expandIcon = expanded ? '\u25BC' : '\u25B6'; const headerLine = Text({ - content: truncate(` ${cursor} ${expandIcon} ${headerText}`, CONTENT_WIDTH), + content: truncate(` ${cursor} ${expandIcon} ${headerText}`, contentWidth), fg: block.label === 'Deep Flow' ? COLORS.cyan : block.label === 'Quick Lookup' ? COLORS.dimWhite : COLORS.white, attributes: BOLD, }); @@ -90,9 +91,10 @@ function renderFlowBlockCard( const switchText = block.modelSwitches > 0 ? `, ${block.modelSwitches} switch${block.modelSwitches === 1 ? '' : 'es'}` : ''; - children.push(renderDetailLine(`Model: ${block.dominantModel}${switchText}`, COLORS.white)); + children.push(renderDetailLine(`Model: ${block.dominantModel}${switchText}`, contentWidth, COLORS.white)); children.push(renderDetailLine( `Input ${formatTokens(block.inputTokens)} | Output ${formatTokens(block.outputTokens)} | Cache read ${formatTokens(block.cacheReadTokens)} | Cache write ${formatTokens(block.cacheWriteTokens)}`, + contentWidth, )); const events = block.events.slice(0, REPLAY_EVENT_DETAIL_LIMIT); @@ -102,11 +104,11 @@ function renderFlowBlockCard( ? event.cacheReadTokens / (event.inputTokens + event.cacheReadTokens) : 0; const line = `${padRight(time, 6)} ${padRight(truncate(event.model, 20), 21)} ${padLeft(formatTokens(event.totalTokens), 8)} tok cache ${padLeft(formatPercent(cacheRate), 6)} ${padLeft(formatCost(event.cost), 8)}`; - children.push(renderDetailLine(line, COLORS.white)); + children.push(renderDetailLine(line, contentWidth, COLORS.white)); } const hiddenEventCount = block.events.length - events.length; if (hiddenEventCount > 0) { - children.push(renderDetailLine(`+${hiddenEventCount} more events`, COLORS.dimWhite)); + children.push(renderDetailLine(`+${hiddenEventCount} more events`, contentWidth, COLORS.dimWhite)); } const trend = block.cacheHitRateTrend; @@ -118,6 +120,7 @@ function renderFlowBlockCard( children.push( renderDetailLine( `Cache trend: ${first}% \u2192 ${last}% ${direction}`, + contentWidth, Number(last) > Number(first) ? COLORS.green : COLORS.red, ), ); @@ -180,7 +183,7 @@ function renderPulseChart(velocity: TokenVelocityPoint[]) { ); } -function renderDaySummary(report: ReplayReport) { +function renderDaySummary(report: ReplayReport, contentWidth: number) { const s = report.summary; const parts = [ `Sessions: ${s.totalSessions}`, @@ -193,7 +196,7 @@ function renderDaySummary(report: ReplayReport) { parts.push(`Peak: ${formatTokens(s.peakMinute.tokensPerMinute)} tok/min at ${formatTime(s.peakMinute.minute)}`); } - const lines = wrapText(parts.join(' | '), CONTENT_WIDTH - 2, 2); + const lines = wrapText(parts.join(' | '), contentWidth - 2, 2); return Box( { flexDirection: 'column', width: '100%', paddingLeft: 1, paddingRight: 1 }, ...lines.map((line) => Text({ content: line, fg: COLORS.white })), @@ -206,6 +209,7 @@ export function createReplayPanel( selectedBlockIndex: number, expandedBlockIndex: number | null, scrollOffset: number, + contentWidth: number = REPLAY_MAX_CONTENT_WIDTH, onToggleBlock?: ReplayToggleHandler, ) { const dateLabel = replayDate ? formatShortDate(replayDate) : '\u2014'; @@ -239,6 +243,7 @@ export function createReplayPanel( block, block.blockIndex === selectedBlockIndex, block.blockIndex === expandedBlockIndex, + contentWidth, onToggleBlock, )); @@ -284,6 +289,6 @@ export function createReplayPanel( Text({ content: '', fg: COLORS.dimWhite }), renderPulseChart(report.tokenVelocity), Text({ content: '', fg: COLORS.dimWhite }), - renderDaySummary(report), + renderDaySummary(report, contentWidth), ); }