feat(FN-2284): enhance dashboard TUI log navigation and inspection

- Add rich Logs tab keyboard controls including arrow/j-k movement, Home/End jumps, and Enter/Space/e expansion toggles
- Introduce expanded log inspection view with escape-to-close behavior and wrap mode support for long entries
- Improve log wrapping to hard-wrap long unbroken tokens like URLs and stack traces at terminal width
- Expand dashboard TUI command tests and document the new interactive log navigation shortcuts in CLI reference
- Add two patch changesets for @runfusion/fusion covering log inspection and navigation fixes
This commit is contained in:
Fusion
2026-04-23 04:06:52 -07:00
committed by gsxdsm
parent bfc89081c2
commit 585e48041d
5 changed files with 685 additions and 14 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -90,6 +90,23 @@ interactive TUI with five sections:
| `q` | Quit | | `q` | Quit |
| `Ctrl+C` | Force 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 In non-TTY mode (CI, piped output, scripts), the dashboard falls back to
plain console output to maintain compatibility with automated workflows. plain console output to maintain compatibility with automated workflows.

View File

@@ -1,9 +1,10 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { import {
LogRingBuffer, LogRingBuffer,
DashboardLogSink, DashboardLogSink,
isTTYAvailable, isTTYAvailable,
renderHeaderToString, renderHeaderToString,
DashboardTUI,
type SystemInfo, type SystemInfo,
type TaskStats, type TaskStats,
type SettingsValues, type SettingsValues,
@@ -375,3 +376,359 @@ describe("Help overlay border alignment in box-drawing mode", () => {
expect(row.endsWith("│")).toBe(true); 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);
});
});

View File

@@ -274,6 +274,11 @@ export class DashboardTUI {
private uptimeTimer: ReturnType<typeof setInterval> | null = null; private uptimeTimer: ReturnType<typeof setInterval> | null = null;
private resizeHandler: (() => void) | null = null; private resizeHandler: (() => void) | null = null;
// Logs interaction state
private selectedLogIndex = 0;
private logsWrapEnabled = false;
private logsExpandedMode = false;
constructor() { constructor() {
this.logBuffer = new LogRingBuffer(); this.logBuffer = new LogRingBuffer();
} }
@@ -309,9 +314,23 @@ export class DashboardTUI {
...entry, ...entry,
timestamp: new Date(), 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(); 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 { log(message: string, prefix?: string): void {
this.addLog({ level: "info", message, prefix }); this.addLog({ level: "info", message, prefix });
} }
@@ -355,6 +374,8 @@ export class DashboardTUI {
this.handleKeypress(str); this.handleKeypress(str);
} else if (key.ctrl && key.name === "c") { } else if (key.ctrl && key.name === "c") {
this.handleKeypress("\x03"); // Ctrl+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") { } else if (key.name === "right") {
this.handleKeypress("\x1b[C"); this.handleKeypress("\x1b[C");
} else if (key.name === "left") { } else if (key.name === "left") {
@@ -365,6 +386,12 @@ export class DashboardTUI {
this.handleKeypress("\x1b[B"); this.handleKeypress("\x1b[B");
} else if (key.name === "escape") { } else if (key.name === "escape") {
this.handleKeypress("\x1b"); 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 // Utility actions based on active section
if (this.activeSection === "utilities") { if (this.activeSection === "utilities") {
this.handleUtilityKeypress(key); 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; break;
case "c": // Clear logs case "c": // Clear logs
this.callbacks.onClearLogs(); this.callbacks.onClearLogs();
this.logBuffer.clear(); this.clearLogs();
this.render();
break; break;
case "t": // Toggle pause case "t": // Toggle pause
if (this.systemInfo) { if (this.systemInfo) {
@@ -664,32 +786,191 @@ export class DashboardTUI {
const maxRows = Math.max(1, (process.stdout.rows ?? 38) - 9); const maxRows = Math.max(1, (process.stdout.rows ?? 38) - 9);
process.stdout.write(colorize("\n LOGS\n", "bold")); 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) { if (entries.length === 0) {
process.stdout.write(colorize(" No log entries yet.\n", "dim")); process.stdout.write(colorize(" No log entries yet.\n", "dim"));
return; return;
} }
// Show most recent entries first (reverse chronological) // Clamp selection index to valid range
const displayEntries = entries.slice(-maxRows).reverse(); const safeSelectedIndex = Math.min(this.selectedLogIndex, Math.max(0, entries.length - 1));
for (const entry of displayEntries) {
// 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 ts = colorize(formatTimestamp(entry.timestamp), "dim");
const prefix = entry.prefix ? colorize(`[${entry.prefix}]`, "gray") : ""; const prefix = entry.prefix ? colorize(`[${entry.prefix}]`, "gray") : "";
const levelChar = entry.level === "error" ? colorize("✗", "brightRed") const levelChar = entry.level === "error" ? colorize("✗", "brightRed")
: entry.level === "warn" ? colorize("⚠", "brightYellow") : entry.level === "warn" ? colorize("⚠", "brightYellow")
: colorize("✓", "brightGreen"); : colorize("✓", "brightGreen");
// Clamp message width to prevent negative truncation on narrow terminals if (this.logsWrapEnabled) {
const messageWidth = Math.max(8, cols - 40); // Wrapped mode: wrap message to available width
// Use visibleTruncate to handle ANSI sequences in log messages const wrappedLines = this.wrapText(entry.message, availableWidth);
const message = visibleTruncate(entry.message, messageWidth); // First line includes prefix
const line = ` ${ts} ${levelChar} ${prefix ? prefix + " " : ""}${message}`; const firstLine = `${selector}${ts} ${levelChar} ${prefix ? prefix + " " : ""}${wrappedLines[0]}`;
process.stdout.write(visibleTruncate(firstLine, cols - 1) + "\n");
process.stdout.write(visibleTruncate(line, 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 { private renderSystemSection(): void {
if (!this.systemInfo) { if (!this.systemInfo) {
process.stdout.write(colorize("\n System information not available.\n", "dim")); process.stdout.write(colorize("\n System information not available.\n", "dim"));
@@ -914,6 +1195,10 @@ export class DashboardTUI {
boxRow(" [r] Refresh stats (Utilities)"), boxRow(" [r] Refresh stats (Utilities)"),
boxRow(" [c] Clear logs (Utilities)"), boxRow(" [c] Clear logs (Utilities)"),
boxRow(" [t] Toggle engine pause (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(" [?] / [h] Toggle help"),
boxRow(" [q] Quit"), boxRow(" [q] Quit"),
boxRow(" [Ctrl+C] Force quit"), boxRow(" [Ctrl+C] Force quit"),
@@ -924,6 +1209,8 @@ export class DashboardTUI {
return [ return [
"KEYBOARD SHORTCUTS", "KEYBOARD SHORTCUTS",
" [1-5] Switch tab | [n/p] Next/Prev | [q] Quit", " [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", " [r] Refresh | [c] Clear logs | [t] Toggle engine",
" [?/h] Help | [Ctrl+C] Force quit", " [?/h] Help | [Ctrl+C] Force quit",
]; ];