diff --git a/.changeset/dashboard-tui-logs-inspection.md b/.changeset/dashboard-tui-logs-inspection.md new file mode 100644 index 000000000..9d4cc2df0 --- /dev/null +++ b/.changeset/dashboard-tui-logs-inspection.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Add keyboard navigation and inspection features to the Dashboard TUI Logs tab: arrow keys and j/k to navigate entries, Enter to expand selected entry, Esc to close expanded view, and w to toggle wrap mode for long messages. diff --git a/.changeset/fix-dashboard-tui-log-navigation.md b/.changeset/fix-dashboard-tui-log-navigation.md new file mode 100644 index 000000000..b1d95b16a --- /dev/null +++ b/.changeset/fix-dashboard-tui-log-navigation.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Fix dashboard TUI log navigation: add Home/End shortcuts for jumping to first/last log entry, add Space and e keys as alternatives to Enter for expanding logs, improve word wrap to handle long unbroken tokens (URLs, stack traces) by hard-wrapping them at terminal width. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index eff54ddb4..513c286ce 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -90,6 +90,23 @@ interactive TUI with five sections: | `q` | Quit | | `Ctrl+C` | Force quit | +**Logs Tab Navigation:** + +| Key | Action | +|---|---| +| `↑` or `k` | Move selection to older log entry | +| `↓` or `j` | Move selection to newer log entry | +| `Home` | Jump to first log entry | +| `End` | Jump to last log entry | +| `Enter`, `Space`, or `e` | Toggle expanded view for selected entry | +| `Esc` | Close expanded view | +| `w` | Toggle wrap mode (long messages wrap vs. truncate) | + +In wrapped mode, long log messages are displayed with word wrapping. Long +unbroken tokens (such as URLs or stack traces) are hard-wrapped at the +available width. In expanded view, the full message is shown with complete +wrapping for inspection. + In non-TTY mode (CI, piped output, scripts), the dashboard falls back to plain console output to maintain compatibility with automated workflows. diff --git a/packages/cli/src/commands/dashboard-tui.test.ts b/packages/cli/src/commands/dashboard-tui.test.ts index 1f56b5d75..673840317 100644 --- a/packages/cli/src/commands/dashboard-tui.test.ts +++ b/packages/cli/src/commands/dashboard-tui.test.ts @@ -1,9 +1,10 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { LogRingBuffer, DashboardLogSink, isTTYAvailable, renderHeaderToString, + DashboardTUI, type SystemInfo, type TaskStats, type SettingsValues, @@ -375,3 +376,359 @@ describe("Help overlay border alignment in box-drawing mode", () => { expect(row.endsWith("│")).toBe(true); }); }); + +// ── DashboardTUI Logs Interaction Tests ───────────────────────────────────────── + +// Helper to create a TUI with mocked stdout for testing +function createTestTUI(): DashboardTUI & { + _stdout: string[]; + _setTerminalSize: (cols: number, rows: number) => void; +} { + const tui = new DashboardTUI() as DashboardTUI & { + _stdout: string[]; + _setTerminalSize: (cols: number, rows: number) => void; + }; + + // Mock stdout writes + const originalWrite = process.stdout.write.bind(process.stdout); + tui._stdout = []; + tui._setTerminalSize = (cols: number, rows: number) => { + Object.defineProperty(process.stdout, "columns", { value: cols, writable: true }); + Object.defineProperty(process.stdout, "rows", { value: rows, writable: true }); + }; + + // Set default terminal size + tui._setTerminalSize(80, 24); + + return tui; +} + +// Helper to simulate keypress +function simulateKeypress(tui: DashboardTUI, key: string): void { + // Access the private handleLogsKeypress method via any + (tui as any).handleLogsKeypress(key); +} + +describe("DashboardTUI Logs Selection", () => { + let tui: DashboardTUI & { + _stdout: string[]; + _setTerminalSize: (cols: number, rows: number) => void; + }; + + beforeEach(() => { + tui = createTestTUI(); + // Add some test entries + for (let i = 1; i <= 5; i++) { + tui.log(`Entry ${i}`); + } + }); + + afterEach(() => { + // Restore stdout + Object.defineProperty(process.stdout, "columns", { value: 80, writable: true }); + Object.defineProperty(process.stdout, "rows", { value: 24, writable: true }); + }); + + it("selection starts at 0", () => { + expect((tui as any).selectedLogIndex).toBe(0); + }); + + it("arrow up moves selection to older entry", () => { + // Move down first (towards newer) + simulateKeypress(tui, "\x1b[B"); // down + expect((tui as any).selectedLogIndex).toBe(1); + + // Move up (towards older) + simulateKeypress(tui, "\x1b[A"); // up + expect((tui as any).selectedLogIndex).toBe(0); + }); + + it("arrow down moves selection to newer entry", () => { + simulateKeypress(tui, "\x1b[B"); // down + expect((tui as any).selectedLogIndex).toBe(1); + + simulateKeypress(tui, "\x1b[B"); // down again + expect((tui as any).selectedLogIndex).toBe(2); + }); + + it("k key moves selection to older entry", () => { + simulateKeypress(tui, "\x1b[B"); // down + expect((tui as any).selectedLogIndex).toBe(1); + + simulateKeypress(tui, "k"); + expect((tui as any).selectedLogIndex).toBe(0); + }); + + it("j key moves selection to newer entry", () => { + simulateKeypress(tui, "j"); + expect((tui as any).selectedLogIndex).toBe(1); + }); + + it("selection clamps at first entry (up boundary)", () => { + expect((tui as any).selectedLogIndex).toBe(0); + simulateKeypress(tui, "\x1b[A"); // up from 0 + expect((tui as any).selectedLogIndex).toBe(0); // Should stay at 0 + }); + + it("selection clamps at last entry (down boundary)", () => { + // Move to last entry + for (let i = 0; i < 10; i++) { + simulateKeypress(tui, "\x1b[B"); + } + expect((tui as any).selectedLogIndex).toBe(4); // Max index for 5 entries + + // Try to go past last + simulateKeypress(tui, "\x1b[B"); + expect((tui as any).selectedLogIndex).toBe(4); // Should stay at 4 + }); + + it("selection is entry-based, not visual-line-based", () => { + // Selection should always be by entry index, regardless of wrap mode + simulateKeypress(tui, "\x1b[B"); + expect((tui as any).selectedLogIndex).toBe(1); + + // Enable wrap mode + simulateKeypress(tui, "w"); + + // Selection should still be entry-based + simulateKeypress(tui, "\x1b[A"); + expect((tui as any).selectedLogIndex).toBe(0); + }); +}); + +describe("DashboardTUI Logs Expanded Mode", () => { + let tui: DashboardTUI & { + _stdout: string[]; + _setTerminalSize: (cols: number, rows: number) => void; + }; + + beforeEach(() => { + tui = createTestTUI(); + // Add some test entries + for (let i = 1; i <= 5; i++) { + tui.log(`Entry ${i}`); + } + }); + + afterEach(() => { + Object.defineProperty(process.stdout, "columns", { value: 80, writable: true }); + Object.defineProperty(process.stdout, "rows", { value: 24, writable: true }); + }); + + it("expanded mode starts as false", () => { + expect((tui as any).logsExpandedMode).toBe(false); + }); + + it("Enter toggles expanded mode on", () => { + simulateKeypress(tui, "\r"); + expect((tui as any).logsExpandedMode).toBe(true); + }); + + it("Enter toggles expanded mode off when already expanded", () => { + simulateKeypress(tui, "\r"); // turn on + expect((tui as any).logsExpandedMode).toBe(true); + + simulateKeypress(tui, "\r"); // turn off + expect((tui as any).logsExpandedMode).toBe(false); + }); + + it("Esc closes expanded mode", () => { + simulateKeypress(tui, "\r"); // turn on + expect((tui as any).logsExpandedMode).toBe(true); + + simulateKeypress(tui, "\x1b"); // Esc + expect((tui as any).logsExpandedMode).toBe(false); + }); + + it("Enter on empty logs is a no-op", () => { + // Clear all logs + (tui as any).clearLogs(); + expect((tui as any).logsExpandedMode).toBe(false); + + simulateKeypress(tui, "\r"); // Should not throw or change state + expect((tui as any).logsExpandedMode).toBe(false); + }); + + it("Esc on empty logs does nothing", () => { + (tui as any).clearLogs(); + simulateKeypress(tui, "\x1b"); // Should not throw + expect((tui as any).logsExpandedMode).toBe(false); + }); +}); + +describe("DashboardTUI Logs Wrap Mode", () => { + let tui: DashboardTUI & { + _stdout: string[]; + _setTerminalSize: (cols: number, rows: number) => void; + }; + + beforeEach(() => { + tui = createTestTUI(); + tui.log("This is a long message that should wrap when wrap mode is enabled"); + }); + + afterEach(() => { + Object.defineProperty(process.stdout, "columns", { value: 80, writable: true }); + Object.defineProperty(process.stdout, "rows", { value: 24, writable: true }); + }); + + it("wrap mode starts as false", () => { + expect((tui as any).logsWrapEnabled).toBe(false); + }); + + it("w toggles wrap mode on", () => { + simulateKeypress(tui, "w"); + expect((tui as any).logsWrapEnabled).toBe(true); + }); + + it("w toggles wrap mode off when already on", () => { + simulateKeypress(tui, "w"); // turn on + expect((tui as any).logsWrapEnabled).toBe(true); + + simulateKeypress(tui, "w"); // turn off + expect((tui as any).logsWrapEnabled).toBe(false); + }); + + it("W (uppercase) also toggles wrap mode", () => { + simulateKeypress(tui, "W"); + expect((tui as any).logsWrapEnabled).toBe(true); + }); + + it("wrap mode persists across tab switches", () => { + simulateKeypress(tui, "w"); // Enable wrap + expect((tui as any).logsWrapEnabled).toBe(true); + + // Switch to utilities tab (would be tab 3) + (tui as any).activeSection = "utilities"; + + // Switch back to logs + (tui as any).activeSection = "logs"; + + // Wrap mode should still be enabled + expect((tui as any).logsWrapEnabled).toBe(true); + }); +}); + +describe("DashboardTUI Logs Selection Reset on Clear", () => { + let tui: DashboardTUI & { + _stdout: string[]; + _setTerminalSize: (cols: number, rows: number) => void; + }; + + beforeEach(() => { + tui = createTestTUI(); + // Add some test entries + for (let i = 1; i <= 5; i++) { + tui.log(`Entry ${i}`); + } + // Move selection to middle + simulateKeypress(tui, "\x1b[B"); + simulateKeypress(tui, "\x1b[B"); + expect((tui as any).selectedLogIndex).toBe(2); + }); + + afterEach(() => { + Object.defineProperty(process.stdout, "columns", { value: 80, writable: true }); + Object.defineProperty(process.stdout, "rows", { value: 24, writable: true }); + }); + + it("clearing logs resets selection to 0", () => { + (tui as any).clearLogs(); + expect((tui as any).selectedLogIndex).toBe(0); + }); + + it("clearing logs closes expanded mode", () => { + simulateKeypress(tui, "\r"); // Enable expanded + expect((tui as any).logsExpandedMode).toBe(true); + + (tui as any).clearLogs(); + expect((tui as any).logsExpandedMode).toBe(false); + }); + + it("clearing logs preserves wrap mode (wrap persists for session)", () => { + simulateKeypress(tui, "w"); // Enable wrap + expect((tui as any).logsWrapEnabled).toBe(true); + + (tui as any).clearLogs(); + // Wrap mode should persist across clear per spec: "w wrap mode persists for the active TUI session" + expect((tui as any).logsWrapEnabled).toBe(true); + }); +}); + +describe("DashboardTUI Narrow Terminal Safety", () => { + let tui: DashboardTUI & { + _stdout: string[]; + _setTerminalSize: (cols: number, rows: number) => void; + }; + + beforeEach(() => { + tui = createTestTUI(); + tui.log("Test message"); + }); + + afterEach(() => { + Object.defineProperty(process.stdout, "columns", { value: 80, writable: true }); + Object.defineProperty(process.stdout, "rows", { value: 24, writable: true }); + }); + + it("handles very narrow terminal width without throwing", () => { + tui._setTerminalSize(20, 10); + expect(() => simulateKeypress(tui, "\x1b[A")).not.toThrow(); + expect(() => simulateKeypress(tui, "\x1b[B")).not.toThrow(); + expect(() => simulateKeypress(tui, "\r")).not.toThrow(); + }); + + it("handles very short terminal height without throwing", () => { + tui._setTerminalSize(80, 5); + expect(() => simulateKeypress(tui, "\x1b[A")).not.toThrow(); + expect(() => simulateKeypress(tui, "\x1b[B")).not.toThrow(); + }); + + it("handles single-row terminal without throwing", () => { + tui._setTerminalSize(80, 1); + expect(() => simulateKeypress(tui, "\x1b[A")).not.toThrow(); + }); + + it("no negative widths in wrap calculation", () => { + tui._setTerminalSize(5, 10); // Very narrow + // This should not cause negative width issues + expect(() => simulateKeypress(tui, "w")).not.toThrow(); + }); +}); + +describe("DashboardTUI Help Overlay Interaction", () => { + let tui: DashboardTUI & { + _stdout: string[]; + _setTerminalSize: (cols: number, rows: number) => void; + }; + + beforeEach(() => { + tui = createTestTUI(); + tui.log("Test message"); + }); + + afterEach(() => { + Object.defineProperty(process.stdout, "columns", { value: 80, writable: true }); + Object.defineProperty(process.stdout, "rows", { value: 24, writable: true }); + }); + + it("Esc closes expanded mode and help overlay together", () => { + simulateKeypress(tui, "\r"); // Enable expanded + expect((tui as any).logsExpandedMode).toBe(true); + + (tui as any).showHelp = true; // Manually enable help + expect((tui as any).showHelp).toBe(true); + + simulateKeypress(tui, "\x1b"); // Esc closes both + expect((tui as any).logsExpandedMode).toBe(false); + expect((tui as any).showHelp).toBe(false); + }); + + it("Esc closes help overlay when expanded mode is not active", () => { + (tui as any).showHelp = true; + expect((tui as any).showHelp).toBe(true); + + simulateKeypress(tui, "\x1b"); // Esc + expect((tui as any).showHelp).toBe(false); + }); +}); diff --git a/packages/cli/src/commands/dashboard-tui.ts b/packages/cli/src/commands/dashboard-tui.ts index 4c84c062d..dcefc084e 100644 --- a/packages/cli/src/commands/dashboard-tui.ts +++ b/packages/cli/src/commands/dashboard-tui.ts @@ -274,6 +274,11 @@ export class DashboardTUI { private uptimeTimer: ReturnType | null = null; private resizeHandler: (() => void) | null = null; + // Logs interaction state + private selectedLogIndex = 0; + private logsWrapEnabled = false; + private logsExpandedMode = false; + constructor() { this.logBuffer = new LogRingBuffer(); } @@ -309,9 +314,23 @@ export class DashboardTUI { ...entry, timestamp: new Date(), }); + // Clamp selection index if it exceeds new length + const newLength = this.logBuffer.getAll().length; + if (this.selectedLogIndex >= newLength) { + this.selectedLogIndex = Math.max(0, newLength - 1); + } this.render(); } + /** + * Clear logs and reset selection state. + */ + clearLogs(): void { + this.logBuffer.clear(); + this.selectedLogIndex = 0; + this.logsExpandedMode = false; + } + log(message: string, prefix?: string): void { this.addLog({ level: "info", message, prefix }); } @@ -355,6 +374,8 @@ export class DashboardTUI { this.handleKeypress(str); } else if (key.ctrl && key.name === "c") { this.handleKeypress("\x03"); // Ctrl+C + } else if (key.name === "return" || key.name === "enter") { + this.handleKeypress("\r"); // Enter/Return key } else if (key.name === "right") { this.handleKeypress("\x1b[C"); } else if (key.name === "left") { @@ -365,6 +386,12 @@ export class DashboardTUI { this.handleKeypress("\x1b[B"); } else if (key.name === "escape") { this.handleKeypress("\x1b"); + } else if (key.name === "home") { + this.handleKeypress("Home"); + } else if (key.name === "end") { + this.handleKeypress("End"); + } else if (key.name === "space") { + this.handleKeypress(" "); } }); @@ -503,6 +530,102 @@ export class DashboardTUI { // Utility actions based on active section if (this.activeSection === "utilities") { this.handleUtilityKeypress(key); + return; + } + + // Logs-specific key handling + if (this.activeSection === "logs") { + this.handleLogsKeypress(key); + return; + } + } + + private handleLogsKeypress(key: string): void { + const entries = this.logBuffer.getAll(); + const maxIndex = Math.max(0, entries.length - 1); + + // Esc: close expanded mode (also closes help overlay) + if (key === "\x1b") { + if (this.logsExpandedMode) { + this.logsExpandedMode = false; + this.showHelp = false; + this.render(); + } else if (this.showHelp) { + this.showHelp = false; + this.render(); + } + return; + } + + // Enter: toggle expanded mode for selected entry + if (key === "\r") { + if (entries.length === 0) { + // No-op when no entries + return; + } + this.logsExpandedMode = !this.logsExpandedMode; + this.render(); + return; + } + + // w: toggle wrap mode (case-insensitive) + if (key === "w" || key === "W") { + this.logsWrapEnabled = !this.logsWrapEnabled; + this.render(); + return; + } + + // Arrow up or k: move selection to older entry + if (key === "\x1b[A" || key === "k" || key === "K") { + if (entries.length === 0) return; + // Clamp to first entry + if (this.selectedLogIndex > 0) { + this.selectedLogIndex--; + this.render(); + } + return; + } + + // Arrow down or j: move selection to newer entry + if (key === "\x1b[B" || key === "j" || key === "J") { + if (entries.length === 0) return; + // Clamp to last entry + if (this.selectedLogIndex < maxIndex) { + this.selectedLogIndex++; + this.render(); + } + return; + } + + // Home: jump to first entry + if (key === "Home") { + if (entries.length === 0) return; + if (this.selectedLogIndex !== 0) { + this.selectedLogIndex = 0; + this.render(); + } + return; + } + + // End: jump to last entry + if (key === "End") { + if (entries.length === 0) return; + if (this.selectedLogIndex !== maxIndex) { + this.selectedLogIndex = maxIndex; + this.render(); + } + return; + } + + // Space or e: toggle expanded mode for selected entry + if (key === " " || key === "e" || key === "E") { + if (entries.length === 0) { + // No-op when no entries + return; + } + this.logsExpandedMode = !this.logsExpandedMode; + this.render(); + return; } } @@ -515,8 +638,7 @@ export class DashboardTUI { break; case "c": // Clear logs this.callbacks.onClearLogs(); - this.logBuffer.clear(); - this.render(); + this.clearLogs(); break; case "t": // Toggle pause if (this.systemInfo) { @@ -664,32 +786,191 @@ export class DashboardTUI { const maxRows = Math.max(1, (process.stdout.rows ?? 38) - 9); process.stdout.write(colorize("\n LOGS\n", "bold")); - process.stdout.write(colorize(` Ring buffer: ${this.logBuffer.total}/${MAX_LOG_ENTRIES} entries\n\n`, "dim")); + process.stdout.write(colorize(` Ring buffer: ${this.logBuffer.total}/${MAX_LOG_ENTRIES} entries\n`, "dim")); if (entries.length === 0) { process.stdout.write(colorize(" No log entries yet.\n", "dim")); return; } - // Show most recent entries first (reverse chronological) - const displayEntries = entries.slice(-maxRows).reverse(); - for (const entry of displayEntries) { + // Clamp selection index to valid range + const safeSelectedIndex = Math.min(this.selectedLogIndex, Math.max(0, entries.length - 1)); + + // If expanded mode is on, render the detail pane + if (this.logsExpandedMode) { + this.renderLogsExpandedPane(entries[safeSelectedIndex], safeSelectedIndex, entries.length); + return; + } + + // Normal list mode + // Show mode indicator (only in list mode) + const modeIndicator = this.logsWrapEnabled ? colorize(" [w] wrap on", "dim") : colorize(" [w] wrap off", "dim"); + process.stdout.write(modeIndicator + "\n\n"); + + // Calculate which entries are visible (last maxRows entries, reversed for display) + const startIndex = Math.max(0, entries.length - maxRows); + const visibleEntries = entries.slice(startIndex); + const visibleReversed = [...visibleEntries].reverse(); + + // Map selected index to display index (for highlighting) + const selectedDisplayIndex = safeSelectedIndex >= startIndex + ? safeSelectedIndex - startIndex + : -1; + + // In wrap mode, calculate available width for message body + const prefixLen = 30; // timestamp + level + prefix overhead + const availableWidth = Math.max(8, cols - prefixLen); + + for (let displayIdx = 0; displayIdx < visibleReversed.length; displayIdx++) { + const entry = visibleReversed[displayIdx]; + const isSelected = displayIdx === selectedDisplayIndex; + + // Selection indicator + const selector = isSelected ? colorize("▸ ", "brightGreen") : " "; const ts = colorize(formatTimestamp(entry.timestamp), "dim"); const prefix = entry.prefix ? colorize(`[${entry.prefix}]`, "gray") : ""; const levelChar = entry.level === "error" ? colorize("✗", "brightRed") : entry.level === "warn" ? colorize("⚠", "brightYellow") : colorize("✓", "brightGreen"); - // Clamp message width to prevent negative truncation on narrow terminals - const messageWidth = Math.max(8, cols - 40); - // Use visibleTruncate to handle ANSI sequences in log messages - const message = visibleTruncate(entry.message, messageWidth); - const line = ` ${ts} ${levelChar} ${prefix ? prefix + " " : ""}${message}`; - - process.stdout.write(visibleTruncate(line, cols - 1) + "\n"); + if (this.logsWrapEnabled) { + // Wrapped mode: wrap message to available width + const wrappedLines = this.wrapText(entry.message, availableWidth); + // First line includes prefix + const firstLine = `${selector}${ts} ${levelChar} ${prefix ? prefix + " " : ""}${wrappedLines[0]}`; + process.stdout.write(visibleTruncate(firstLine, cols - 1) + "\n"); + // Continuation lines (indented) + for (let i = 1; i < wrappedLines.length; i++) { + const continuation = ` ${wrappedLines[i]}`; + process.stdout.write(visibleTruncate(continuation, cols - 1) + "\n"); + } + } else { + // Single-line mode: truncate to available width + const messageWidth = Math.max(8, cols - prefixLen); + const message = visibleTruncate(entry.message, messageWidth); + const line = `${selector}${ts} ${levelChar} ${prefix ? prefix + " " : ""}${message}`; + process.stdout.write(visibleTruncate(line, cols - 1) + "\n"); + } } } + /** + * Render the expanded log entry detail pane. + * Replaces the normal list view with a focused view of a single entry. + */ + private renderLogsExpandedPane(entry: LogEntry, index: number, total: number): void { + const cols = process.stdout.columns || 80; + const rows = process.stdout.rows ?? 24; + + // Reserve 3 rows for footer area + 3 rows for header (2 title + 1 hint) + const maxContentRows = Math.max(1, rows - 12); + + // Title and navigation hint + process.stdout.write(colorize(" EXPANDED LOG ENTRY\n", "bold")); + const navHint = colorize(` Entry ${index + 1} of ${total} | [↑/k] older [↓/j] newer [Enter/Esc] close\n`, "dim"); + process.stdout.write(navHint); + process.stdout.write(colorize(" " + "─".repeat(Math.max(20, cols - 4)) + "\n", "dim")); + + // Metadata section + const ts = formatTimestamp(entry.timestamp); + const levelLabel = entry.level === "error" ? colorize("ERROR", "brightRed") + : entry.level === "warn" ? colorize("WARN", "brightYellow") + : colorize("INFO", "brightGreen"); + + process.stdout.write(colorize(` Timestamp: `, "gray") + colorize(ts, "white") + "\n"); + process.stdout.write(colorize(` Level: `, "gray") + levelLabel + "\n"); + if (entry.prefix) { + process.stdout.write(colorize(` Prefix: `, "gray") + colorize(entry.prefix, "dim") + "\n"); + } + process.stdout.write("\n"); + + // Message section header + process.stdout.write(colorize(" MESSAGE\n", "bold")); + + // Calculate available width for message body (accounting for leading spaces) + const messageIndent = " "; + const availableWidth = Math.max(8, cols - messageIndent.length); + + // Wrap and display the full message + const wrappedMessage = this.wrapText(entry.message, availableWidth); + let linesPrinted = 5; // header + metadata lines (approximate) + + for (const line of wrappedMessage) { + if (linesPrinted >= maxContentRows) { + // Stop before footer + process.stdout.write(colorize(`\n ... (truncated)\n`, "dim")); + break; + } + process.stdout.write(messageIndent + line + "\n"); + linesPrinted++; + } + + // Footer hint + const footerHint = colorize(`\n [Esc] or [Enter] to close expanded view\n`, "dim"); + process.stdout.write(footerHint); + } + + /** + * Wrap text to fit within available width, returning an array of lines. + * Respects ANSI escape sequences via visibleLength. + */ + private wrapText(text: string, maxWidth: number): string[] { + if (maxWidth <= 0) return [""]; + if (visibleLength(text) <= maxWidth) return [text]; + + const lines: string[] = []; + let remaining = text; + + while (visibleLength(remaining) > maxWidth) { + // Find the best break point (prefer whitespace) + let breakIdx = 0; + for (let i = 0; i < remaining.length; i++) { + const char = remaining[i]; + if (char === " " || char === "\t") { + if (visibleLength(remaining.substring(0, i)) <= maxWidth) { + breakIdx = i; + } + } + // Check if we've exceeded width + if (visibleLength(remaining.substring(0, i + 1)) > maxWidth) { + break; + } + } + + if (breakIdx === 0) { + // No whitespace found within width - check if this is a long unbroken token + const firstTokenMatch = remaining.match(/^(\S+)/); + if (firstTokenMatch) { + const firstToken = firstTokenMatch[1]; + if (visibleLength(firstToken) > maxWidth) { + // Long unbroken token exceeds width - split into chunks + const chunkSize = Math.max(1, maxWidth - 1); + let chunkStart = 0; + while (chunkStart < firstToken.length) { + const chunk = firstToken.substring(chunkStart, chunkStart + chunkSize); + lines.push(chunk); + chunkStart += chunkSize; + } + // Update remaining to skip the entire token and process rest + remaining = remaining.substring(firstToken.length).trimStart(); + continue; + } + } + // No long token - hard break at width + breakIdx = Math.min(maxWidth, remaining.length); + } + + lines.push(remaining.substring(0, breakIdx).trimEnd()); + remaining = remaining.substring(breakIdx).trimStart(); + } + + if (remaining.length > 0) { + lines.push(remaining); + } + + return lines.length > 0 ? lines : [""]; + } + private renderSystemSection(): void { if (!this.systemInfo) { process.stdout.write(colorize("\n System information not available.\n", "dim")); @@ -914,6 +1195,10 @@ export class DashboardTUI { boxRow(" [r] Refresh stats (Utilities)"), boxRow(" [c] Clear logs (Utilities)"), boxRow(" [t] Toggle engine pause (Utilities)"), + boxRow(" [↑/↓/k/j] Navigate log entries (Logs)"), + boxRow(" [Home/End] First/last log entry (Logs)"), + boxRow(" [Enter/Space/e] Expand log (Logs)"), + boxRow(" [w] Toggle word wrap (Logs)"), boxRow(" [?] / [h] Toggle help"), boxRow(" [q] Quit"), boxRow(" [Ctrl+C] Force quit"), @@ -924,6 +1209,8 @@ export class DashboardTUI { return [ "KEYBOARD SHORTCUTS", " [1-5] Switch tab | [n/p] Next/Prev | [q] Quit", + " [↑↓/k/j] Navigate logs | [Home/End] First/Last (Logs)", + " [Enter/Space/e] Expand log | [w] Toggle wrap (Logs)", " [r] Refresh | [c] Clear logs | [t] Toggle engine", " [?/h] Help | [Ctrl+C] Force quit", ];