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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -274,6 +274,11 @@ export class DashboardTUI {
|
||||
private uptimeTimer: ReturnType<typeof setInterval> | 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",
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user