Files
fusion/packages/cli/src/commands/dashboard-tui/controller.ts
Fusion 4a56b1c10f fix(FN-2722): simplify dashboard TUI header and panel switching behavior
- Remove stdout header overlay patching and related frame recovery hooks from the dashboard TUI controller
- Keep panel switching logic lightweight by relying on normal state updates without forced recover cycles
- Adjust status footer and utility/system panel rendering to maintain a single-line, truncation-friendly layout
- Add explicit number-key panel mapping while preserving existing tab/cycle panel order
2026-04-28 14:25:56 -07:00

743 lines
27 KiB
TypeScript

import os from "node:os";
import v8 from "node:v8";
import { execSync } from "node:child_process";
import { appendFileSync } from "node:fs";
import { getCachedUpdateStatus } from "../../update-cache.js";
const TUI_DEBUG_LOG = process.env.FUSION_TUI_DEBUG_LOG;
function tuiDebug(tag: string, data: Record<string, unknown>): void {
if (!TUI_DEBUG_LOG) return;
try {
const line = `${new Date().toISOString()} [${tag}] ${JSON.stringify(data)}\n`;
appendFileSync(TUI_DEBUG_LOG, line);
} catch {
// best-effort
}
}
import { LogRingBuffer } from "./log-ring-buffer.js";
import type { LogEntry } from "./log-ring-buffer.js";
import type {
SystemInfo,
SystemStats,
TaskStats,
SettingsValues,
TUICallbacks,
SectionId,
DashboardState,
InteractiveData,
InteractiveView,
UpdateStatus,
} from "./state.js";
import { SECTION_ORDER } from "./state.js";
// ── DashboardTUI ─────────────────────────────────────────────────────────────
//
// Public API is identical to the old imperative class so dashboard.ts requires
// no changes other than the import path. State fields are kept as direct class
// properties (matching the old names) so the test suite can reach them via
// `(tui as any).activeSection` etc. without modification.
//
// The Ink App component subscribes via `subscribe()` / `getSnapshot()` — the
// same pattern as `useSyncExternalStore`.
export class DashboardTUI {
// State fields mirror the original private layout so tests can access them.
activeSection: SectionId = "logs";
// Named `logBuffer` to match what captureConsole tests access via
// `(tui as unknown as { logBuffer: LogRingBuffer }).logBuffer`.
logBuffer: LogRingBuffer;
systemInfo: SystemInfo | null = null;
taskStats: TaskStats | null = null;
systemStats: SystemStats | null = null;
// When set, dashboard.ts refreshes task stats from this project path
// instead of the launch cwd. Mirrors BoardView's selected project.
boardScopedProjectPath: string | null = null;
private boardScopeListener: ((path: string | null) => void) | null = null;
settings: SettingsValues | null = null;
callbacks: TUICallbacks | null = null;
isRunning = false;
showHelp = false;
logsSeverityFilter: "all" | LogEntry["level"] = "all";
logsWrapEnabled = false;
logsExpandedMode = false;
selectedLogIndex = 0;
logsViewportStart = 0;
loadingStatus = "Starting…";
mode: "status" | "interactive" = "status";
// When true, sampleSystemStats() kills any running vitest processes if
// system memory usage crosses 90%. Toggled by [v] in the Utilities panel.
autoKillVitestOnPressure = true;
// System-memory ratio (0..1) at which auto-kill triggers. Adjustable from
// the Utilities panel via [+]/[-] in 5% steps. Clamped to [0.5, 0.99].
vitestKillThreshold = 0.9;
// Throttle so we don't spam kills while the sampler keeps firing during
// sustained pressure (sampler runs every 2s).
private lastAutoKillAt = 0;
interactiveData: InteractiveData | null = null;
interactiveView: InteractiveView = "board";
interactiveInputLocked = false;
updateStatus: UpdateStatus | null = null;
// Subscribers registered by the Ink App component.
private subscribers: Set<() => void> = new Set();
// Cached snapshot — useSyncExternalStore compares by Object.is, so we must
// return the same reference between renders unless state actually changed.
// notify() invalidates this; getSnapshot() rebuilds on demand.
private cachedSnapshot: DashboardState | null = null;
// Ink instance — set when start() is called.
// Loose type — the real Ink Instance has additional methods (clear,
// rerender, etc.) that we use defensively below.
private inkInstance: {
unmount: () => void;
waitUntilExit: () => Promise<unknown>;
clear?: () => void;
} & Record<string, unknown> | null = null;
// Resize listener attached at start(), detached at stop().
private resizeListener: (() => void) | null = null;
// Debounce timer for resize handling — coalesces tmux/ssh resize bursts.
private resizeDebounceTimer: ReturnType<typeof setTimeout> | null = null;
// Last observed terminal dims, used by the dim-poll fallback to detect
// resizes that didn't deliver a SIGWINCH (common under tmux/ssh).
private lastObservedCols: number = 0;
private lastObservedRows: number = 0;
// Uptime ticker to keep footer time live.
private uptimeTimer: ReturnType<typeof setInterval> | null = null;
// System stats sampler — process memory + CPU%.
private systemStatsTimer: ReturnType<typeof setInterval> | null = null;
private lastCpuUsage: NodeJS.CpuUsage | null = null;
private lastCpuSampleAt = 0;
constructor() {
this.logBuffer = new LogRingBuffer();
// Read the update-check cache synchronously at construction time so the
// splash screen and status bar can render the notice immediately, without
// any network access.
const cached = getCachedUpdateStatus();
if (cached) {
this.updateStatus = {
updateAvailable: cached.updateAvailable,
currentVersion: cached.currentVersion,
latestVersion: cached.latestVersion,
};
}
}
// ── Subscription API (for Ink App) ────────────────────────────────────────
subscribe(callback: () => void): () => void {
this.subscribers.add(callback);
return () => this.subscribers.delete(callback);
}
getSnapshot(): DashboardState {
if (this.cachedSnapshot) return this.cachedSnapshot;
this.cachedSnapshot = {
activeSection: this.activeSection,
logEntries: this.logBuffer.getAll(),
systemInfo: this.systemInfo,
taskStats: this.taskStats,
systemStats: this.systemStats,
settings: this.settings,
callbacks: this.callbacks,
showHelp: this.showHelp,
logsSeverityFilter: this.logsSeverityFilter,
logsWrapEnabled: this.logsWrapEnabled,
logsExpandedMode: this.logsExpandedMode,
selectedLogIndex: this.selectedLogIndex,
logsViewportStart: this.logsViewportStart,
loadingStatus: this.loadingStatus,
mode: this.mode,
interactiveData: this.interactiveData,
interactiveView: this.interactiveView,
interactiveInputLocked: this.interactiveInputLocked,
autoKillVitestOnPressure: this.autoKillVitestOnPressure,
vitestKillThreshold: this.vitestKillThreshold,
updateStatus: this.updateStatus,
};
return this.cachedSnapshot;
}
private notify(): void {
this.cachedSnapshot = null;
for (const cb of this.subscribers) cb();
}
// ── Public API (unchanged from original DashboardTUI) ─────────────────────
get running(): boolean {
return this.isRunning;
}
setCallbacks(callbacks: TUICallbacks): void {
this.callbacks = callbacks;
this.notify();
}
setSystemInfo(info: SystemInfo): void {
this.systemInfo = info;
this.notify();
}
setTaskStats(stats: TaskStats): void {
this.taskStats = stats;
this.notify();
}
setSystemStats(stats: SystemStats): void {
this.systemStats = stats;
this.notify();
}
setBoardScopedProjectPath(path: string | null): void {
if (this.boardScopedProjectPath === path) return;
this.boardScopedProjectPath = path;
this.boardScopeListener?.(path);
this.notify();
}
onBoardScopeChange(listener: (path: string | null) => void): () => void {
this.boardScopeListener = listener;
return () => {
if (this.boardScopeListener === listener) this.boardScopeListener = null;
};
}
/** Sample process memory + CPU% in-place. Called from the sampler timer. */
sampleSystemStats(): void {
const mem = process.memoryUsage();
const heapStats = v8.getHeapStatistics();
const now = Date.now();
const cpu = process.cpuUsage();
let cpuPercent = 0;
if (this.lastCpuUsage && this.lastCpuSampleAt > 0) {
const elapsedMicros = (now - this.lastCpuSampleAt) * 1000;
if (elapsedMicros > 0) {
const usedMicros =
(cpu.user - this.lastCpuUsage.user) +
(cpu.system - this.lastCpuUsage.system);
cpuPercent = (usedMicros / elapsedMicros) * 100;
}
}
this.lastCpuUsage = cpu;
this.lastCpuSampleAt = now;
const load = os.loadavg();
this.setSystemStats({
rss: mem.rss,
heapUsed: mem.heapUsed,
heapTotal: mem.heapTotal,
heapLimit: heapStats.heap_size_limit,
external: mem.external,
arrayBuffers: mem.arrayBuffers,
cpuPercent,
loadAvg: [load[0] ?? 0, load[1] ?? 0, load[2] ?? 0],
cpuCount: os.cpus().length,
systemTotalMem: os.totalmem(),
systemFreeMem: os.freemem(),
pid: process.pid,
nodeVersion: process.version,
platform: `${process.platform}/${process.arch}`,
});
if (this.autoKillVitestOnPressure) {
const total = os.totalmem();
const free = os.freemem();
if (total > 0) {
const usedRatio = (total - free) / total;
// 30s minimum gap between auto-kills — vitest restart and OS reclaim
// both take a few seconds; firing every 2s would flap.
if (usedRatio > this.vitestKillThreshold && now - this.lastAutoKillAt > 30_000) {
this.lastAutoKillAt = now;
const result = this.killVitestProcesses();
if (result.killed > 0) {
this.warn(
`Auto-killed ${result.killed} vitest process${result.killed === 1 ? "" : "es"} (system memory at ${Math.round(usedRatio * 100)}%, threshold ${Math.round(this.vitestKillThreshold * 100)}%)`,
"memory-guard",
);
}
}
}
}
}
/**
* Find and SIGKILL any running vitest processes, excluding this dashboard
* itself. Returns a count of pids signalled (best-effort — a pid may be
* gone by the time we send the signal).
*/
killVitestProcesses(): { killed: number; pids: number[] } {
// pgrep is POSIX-only; Windows path is a no-op above.
if (process.platform === "win32") {
return { killed: 0, pids: [] };
}
const selfPid = process.pid;
let pids: number[] = [];
try {
// pgrep -f matches against the full command line. -a would include the
// command, but we only need pids. macOS and Linux both support -f.
const out = execSync("pgrep -f vitest", { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
pids = out
.split("\n")
.map((s) => Number.parseInt(s.trim(), 10))
.filter((n) => Number.isFinite(n) && n > 0 && n !== selfPid);
} catch {
// pgrep exits non-zero when no matches — treat as "nothing to kill".
return { killed: 0, pids: [] };
}
let killed = 0;
for (const pid of pids) {
try {
process.kill(pid, "SIGKILL");
killed += 1;
} catch {
// Process already exited or we lack permission — skip.
}
}
return { killed, pids };
}
adjustVitestKillThreshold(deltaPct: number): number {
const next = this.vitestKillThreshold + deltaPct / 100;
this.vitestKillThreshold = Math.max(0.5, Math.min(0.99, Math.round(next * 100) / 100));
this.notify();
void this.persistVitestKillSettings({ thresholdPct: Math.round(this.vitestKillThreshold * 100) });
return this.vitestKillThreshold;
}
toggleAutoKillVitest(): boolean {
this.autoKillVitestOnPressure = !this.autoKillVitestOnPressure;
if (!this.autoKillVitestOnPressure) {
this.lastAutoKillAt = 0;
}
this.notify();
void this.persistVitestKillSettings({ enabled: this.autoKillVitestOnPressure });
return this.autoKillVitestOnPressure;
}
/** Apply persisted values from global settings on startup. Does not
* trigger a write-back. */
hydrateVitestKillSettings(values: { enabled?: boolean; thresholdPct?: number }): void {
if (typeof values.enabled === "boolean") {
this.autoKillVitestOnPressure = values.enabled;
}
if (typeof values.thresholdPct === "number" && Number.isFinite(values.thresholdPct)) {
const ratio = values.thresholdPct / 100;
this.vitestKillThreshold = Math.max(0.5, Math.min(0.99, ratio));
}
this.notify();
}
private async persistVitestKillSettings(
partial: { enabled?: boolean; thresholdPct?: number },
): Promise<void> {
if (!this.callbacks?.onPersistVitestKillSettings) return;
try {
await this.callbacks.onPersistVitestKillSettings(partial);
} catch {
// Best-effort persistence — the in-memory toggle remains in effect
// even if disk write fails. The next adjust will retry.
}
}
setSettings(settings: SettingsValues): void {
this.settings = settings;
this.notify();
}
setLoadingStatus(text: string): void {
this.loadingStatus = text;
this.notify();
}
setInteractiveData(data: InteractiveData): void {
this.interactiveData = data;
this.notify();
}
setInteractiveView(view: InteractiveView): void {
this.interactiveView = view;
this.notify();
}
setInteractiveInputLocked(locked: boolean): void {
if (this.interactiveInputLocked === locked) return;
this.interactiveInputLocked = locked;
this.notify();
}
setUpdateStatus(status: UpdateStatus | null): void {
this.updateStatus = status;
this.notify();
}
addLog(entry: Omit<LogEntry, "timestamp">): void {
// If the cursor was sitting on the most recent entry (or there were no
// entries yet), keep it pinned to the new tail so live logs follow the
// latest event — same behavior as `tail -f` or k9s.
const beforeEntries = this.getFilteredLogEntries();
const beforeCount = beforeEntries.length;
const wasAtTail = beforeCount === 0 || this.selectedLogIndex === beforeCount - 1;
// While the user is reading a single entry in expanded mode, pin the
// cursor on that entry so streaming logs don't yank the view away.
// Track by reference so ring-buffer eviction shifts the index correctly.
const pinnedEntry = this.logsExpandedMode ? beforeEntries[this.selectedLogIndex] : undefined;
this.logBuffer.push({ ...entry, timestamp: new Date() });
const after = this.getFilteredLogEntries();
if (pinnedEntry) {
const newIdx = after.indexOf(pinnedEntry);
if (newIdx >= 0) {
this.selectedLogIndex = newIdx;
} else {
// Pinned entry was evicted from the ring buffer — fall back to oldest.
this.selectedLogIndex = 0;
}
} else if (wasAtTail) {
this.selectedLogIndex = Math.max(0, after.length - 1);
} else {
this.clampSelectedLogIndex(after);
}
this.notify();
}
clearLogs(): void {
this.logBuffer.clear();
this.selectedLogIndex = 0;
this.logsViewportStart = 0;
this.logsExpandedMode = false;
this.notify();
}
log(message: string, prefix?: string): void {
this.addLog({ level: "info", message, prefix });
}
warn(message: string, prefix?: string): void {
this.addLog({ level: "warn", message, prefix });
}
error(message: string, prefix?: string): void {
this.addLog({ level: "error", message, prefix });
}
// ── State helpers called from Ink App ────────────────────────────────────
setActiveSection(section: SectionId): void {
this.activeSection = section;
this.showHelp = false;
this.notify();
}
setShowHelp(show: boolean): void {
this.showHelp = show;
this.notify();
}
setLogsWrapEnabled(enabled: boolean): void {
this.logsWrapEnabled = enabled;
this.notify();
}
setLogsExpandedMode(expanded: boolean): void {
this.logsExpandedMode = expanded;
this.notify();
}
setSelectedLogIndex(index: number): void {
const entries = this.getFilteredLogEntries();
this.selectedLogIndex = this.clampIndex(index, entries.length);
this.notify();
}
setLogsViewportStart(start: number): void {
this.logsViewportStart = start;
this.notify();
}
setMode(mode: "status" | "interactive"): void {
this.mode = mode;
this.notify();
}
cycleSection(direction: 1 | -1): void {
const idx = SECTION_ORDER.indexOf(this.activeSection);
this.activeSection = SECTION_ORDER[(idx + direction + SECTION_ORDER.length) % SECTION_ORDER.length];
this.showHelp = false;
this.notify();
}
cycleSeverityFilter(): void {
const order: Array<"all" | LogEntry["level"]> = ["all", "info", "warn", "error"];
const idx = order.indexOf(this.logsSeverityFilter);
this.logsSeverityFilter = order[(idx + 1) % order.length];
this.clampSelectedLogIndex(this.getFilteredLogEntries());
this.logsViewportStart = 0;
this.notify();
}
getFilteredLogEntries(): LogEntry[] {
const all = this.logBuffer.getAll();
return this.logsSeverityFilter === "all"
? all
: all.filter((e) => e.level === this.logsSeverityFilter);
}
async handleUtilityAction(key: string): Promise<void> {
if (!this.callbacks) return;
switch (key.toLowerCase()) {
case "r":
await this.callbacks.onRefreshStats();
break;
case "c":
this.callbacks.onClearLogs();
this.clearLogs();
break;
case "t":
if (this.systemInfo) {
const newPaused = this.systemInfo.engineMode !== "paused";
const newSettings = await this.callbacks.onTogglePause(newPaused);
const newEngineMode = newSettings.enginePaused ? "paused" : "active";
this.setSystemInfo({ ...this.systemInfo, engineMode: newEngineMode });
this.setSettings(newSettings);
}
break;
case "k": {
const result = this.killVitestProcesses();
if (result.killed === 0) {
this.log("No vitest processes found.", "kill-vitest");
} else {
this.warn(
`Killed ${result.killed} vitest process${result.killed === 1 ? "" : "es"}: ${result.pids.join(", ")}`,
"kill-vitest",
);
}
break;
}
case "v": {
const enabled = this.toggleAutoKillVitest();
this.log(
`Auto-kill vitest on memory pressure (>${Math.round(this.vitestKillThreshold * 100)}%): ${enabled ? "ON" : "OFF"}`,
"memory-guard",
);
break;
}
case "+":
case "=": {
const v = this.adjustVitestKillThreshold(+5);
this.log(`Vitest kill threshold: ${Math.round(v * 100)}%`, "memory-guard");
break;
}
case "-":
case "_": {
const v = this.adjustVitestKillThreshold(-5);
this.log(`Vitest kill threshold: ${Math.round(v * 100)}%`, "memory-guard");
break;
}
}
}
// ── Lifecycle ──────────────────────────────────────────────────────────────
async start(): Promise<void> {
if (this.isRunning) return;
this.isRunning = true;
// Dynamic import avoids pulling Ink into non-TTY paths (CI, tests
// that only exercise pure logic).
const { render } = await import("ink");
const { createElement } = await import("react");
const { DashboardApp } = await import("./app.js");
// Enter the terminal's alternate-screen buffer before mounting Ink so
// the TUI gets a dedicated fullscreen surface that doesn't share
// scrollback with the user's shell history. Without this, Ink writes
// top-down and any frame taller than the terminal pushes the top
// (header) into scrollback. Especially noticeable under tmux/ssh
// where dimension reporting and status bars can leave the rendered
// frame a row or two too tall.
if (process.stdout?.isTTY && typeof process.stdout.write === "function") {
// \x1b[?1049h = enter alt-screen, \x1b[H = home cursor.
process.stdout.write("\x1b[?1049h\x1b[H");
}
this.inkInstance = render(
createElement(DashboardApp, { controller: this }),
);
// Reset Ink's internal frame buffer (log-update line tracking) on every
// terminal resize. Without this Ink keeps treating the previous frame's
// line count as the clear region, leaving stale rows above/below the
// new render until another unrelated rerender happens.
//
// Debounced: tmux and mosh fire resize bursts during pane negotiation,
// and `process.stdout.rows` can briefly read stale/zero values mid-burst.
// Coalescing to a single trailing edge lets dimensions settle before we
// clear+rerender, then a follow-up notify() forces React to re-read
// stdout dims one more time so any timer-driven render that landed
// mid-burst with stale rows is corrected.
this.resizeListener = () => {
if (this.resizeDebounceTimer) clearTimeout(this.resizeDebounceTimer);
this.resizeDebounceTimer = setTimeout(() => {
this.resizeDebounceTimer = null;
const rows = process.stdout?.rows ?? 0;
const cols = process.stdout?.columns ?? 0;
if (rows <= 0 || cols <= 0) return;
this.recoverFrame(cols, rows);
}, 50);
};
if (process.stdout && typeof process.stdout.on === "function") {
process.stdout.on("resize", this.resizeListener);
}
// Prime the observed-dims baseline so the systemStats poll below can
// detect when stdout dims change without a SIGWINCH (tmux/ssh
// sometimes drop the signal — the dims still update on the stream
// object, but no resize event fires, so the user sees a stuck
// layout). Polling every 2s catches that case at minor cost.
this.lastObservedCols = process.stdout?.columns ?? 0;
this.lastObservedRows = process.stdout?.rows ?? 0;
this.uptimeTimer = setInterval(() => {
if (this.isRunning) this.notify();
}, 5000);
// Prime CPU baseline, then sample every 2s.
this.lastCpuUsage = process.cpuUsage();
this.lastCpuSampleAt = Date.now();
this.sampleSystemStats();
this.systemStatsTimer = setInterval(() => {
if (!this.isRunning) return;
this.sampleSystemStats();
// Dim-poll fallback: tmux/ssh sometimes drop SIGWINCH entirely, and
// Node only refreshes process.stdout.columns/rows when SIGWINCH
// arrives — so reading those properties returns stale values that
// never recover on their own. Force-query the OS via getWindowSize
// (ioctl-backed) and compare against Node's cached dims; if they
// diverge, SIGWINCH was lost. Calling _refreshSize() pokes Node to
// re-read and emit 'resize', which routes through our existing
// resize listener and triggers the full recovery path.
const stdout = process.stdout as (typeof process.stdout) & {
getWindowSize?: () => [number, number];
_refreshSize?: () => void;
};
try {
const [trueCols, trueRows] = stdout.getWindowSize?.() ?? [0, 0];
if (trueCols <= 0 || trueRows <= 0) return;
const cachedCols = stdout.columns ?? 0;
const cachedRows = stdout.rows ?? 0;
if (trueCols !== cachedCols || trueRows !== cachedRows) {
// Node's cache is stale — poke it to re-read so React reads the
// new dims on the next render.
stdout._refreshSize?.();
}
if (trueCols !== this.lastObservedCols || trueRows !== this.lastObservedRows) {
// We haven't recovered to this size yet. Run the full recovery
// path directly rather than relying on a 'resize' event from
// _refreshSize, which doesn't always propagate under tmux+ssh
// (or when Node's cache already happens to match the OS but
// Ink's frame buffer is still pinned to the previous layout).
this.recoverFrame(trueCols, trueRows);
}
} catch {
// ioctl can fail in edge cases (detached pty, etc.) — ignore.
}
}, 2000);
}
// Reset Ink's internal frame buffer (log-update line tracking) and wipe
// the alt-screen so a fresh render lands on a known-empty surface. Used
// by both the resize listener (SIGWINCH path) and the dim-poll fallback
// (tmux+ssh path where SIGWINCH is dropped). Idempotent: a redundant
// call is at worst a 1-frame flicker.
//
// Why: Ink's clear() only resets log-update's tracked line count; if the
// previous frame painted more rows than the new terminal height (or
// content shrunk past a layout tier), those rows linger in the
// alt-screen buffer and the new frame paints on top, leaving garbage
// visible at the bottom. \x1b[2J\x1b[H wipes the buffer first.
// Order: wipe → reset Ink's tracking → record dims → notify so React
// reads fresh dims and rerenders cleanly.
private recoverFrame(cols: number, rows: number): void {
tuiDebug("recoverFrame", {
cols,
rows,
prevCols: this.lastObservedCols,
prevRows: this.lastObservedRows,
});
if (process.stdout?.isTTY && typeof process.stdout.write === "function") {
try {
process.stdout.write("\x1b[2J\x1b[H");
} catch {
// Ignore — wipe is best-effort.
}
}
try {
this.inkInstance?.clear?.();
} catch {
// Ignore — clear is best-effort.
}
this.lastObservedCols = cols;
this.lastObservedRows = rows;
this.notify();
}
async stop(): Promise<void> {
if (!this.isRunning) return;
this.isRunning = false;
if (this.uptimeTimer) {
clearInterval(this.uptimeTimer);
this.uptimeTimer = null;
}
if (this.systemStatsTimer) {
clearInterval(this.systemStatsTimer);
this.systemStatsTimer = null;
}
if (this.resizeListener && process.stdout && typeof process.stdout.off === "function") {
process.stdout.off("resize", this.resizeListener);
this.resizeListener = null;
}
if (this.resizeDebounceTimer) {
clearTimeout(this.resizeDebounceTimer);
this.resizeDebounceTimer = null;
}
if (this.inkInstance) {
this.inkInstance.unmount();
this.inkInstance = null;
}
// Leave the alt-screen buffer last so the user's shell scrollback
// is restored cleanly. \x1b[?1049l = leave alt-screen.
if (process.stdout?.isTTY && typeof process.stdout.write === "function") {
process.stdout.write("\x1b[?1049l");
}
}
// ── Private helpers ────────────────────────────────────────────────────────
private clampSelectedLogIndex(entries: LogEntry[]): void {
if (entries.length === 0) {
this.selectedLogIndex = 0;
this.logsExpandedMode = false;
return;
}
if (this.selectedLogIndex >= entries.length) {
this.selectedLogIndex = entries.length - 1;
}
if (this.selectedLogIndex < 0) {
this.selectedLogIndex = 0;
}
}
private clampIndex(index: number, length: number): number {
if (length === 0) return 0;
return Math.max(0, Math.min(index, length - 1));
}
}