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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 150 additions & 37 deletions packages/tui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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_MAX_CONTENT_WIDTH, 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_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';
Expand Down Expand Up @@ -303,8 +303,10 @@ function buildContent(state: AppState, renderer: CliRenderer) {
return createReplayPanel(
null,
state.replayDate,
state.replayExpandedBlocks,
state.replaySelectedBlockIndex,
state.replayExpandedBlockIndex,
state.replayScrollOffset,
getPanelContentWidth(renderer, REPLAY_MAX_CONTENT_WIDTH),
);
}
if (!state.replayDate) {
Expand All @@ -319,8 +321,14 @@ function buildContent(state: AppState, renderer: CliRenderer) {
return createReplayPanel(
state.cachedReplayReport,
state.replayDate,
state.replayExpandedBlocks,
state.replaySelectedBlockIndex,
state.replayExpandedBlockIndex,
state.replayScrollOffset,
getPanelContentWidth(renderer, REPLAY_MAX_CONTENT_WIDTH),
(blockIndex) => {
toggleReplayBlock(state, blockIndex);
render(state, renderer);
},
);
case 'nutrition':
if (!hasWindowData) {
Expand Down Expand Up @@ -376,15 +384,23 @@ 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');
return deferredPanelForTask(state, renderer, key, 'Receipts', () => {
ensureReceipt(state);
});
}
return createReceiptsPanel(state, state.cachedReceipt);
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 });
}
Expand Down Expand Up @@ -473,10 +489,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;
Expand Down Expand Up @@ -589,6 +607,93 @@ 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));
}

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;
Expand All @@ -598,12 +703,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') {
Expand Down Expand Up @@ -660,12 +763,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);
}

Expand All @@ -680,6 +783,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);
Expand All @@ -692,8 +797,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 */
Expand Down Expand Up @@ -762,10 +866,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;
Expand All @@ -783,11 +884,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;
}
Expand Down Expand Up @@ -1094,6 +1195,16 @@ export async function main(): Promise<void> {
}
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);
Expand All @@ -1117,6 +1228,16 @@ export async function main(): Promise<void> {
}
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) {
Expand Down Expand Up @@ -1152,22 +1273,16 @@ export async function main(): Promise<void> {
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;
}
Expand All @@ -1177,8 +1292,7 @@ export async function main(): Promise<void> {
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;
}
Expand All @@ -1194,8 +1308,7 @@ export async function main(): Promise<void> {
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;
}
Expand Down
47 changes: 47 additions & 0 deletions packages/tui/src/lib/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
Loading
Loading