From 1a79d45fafd42fbf83e35c0816ac06a223bbe5ca Mon Sep 17 00:00:00 2001 From: ParsaKhaz Date: Sat, 25 Apr 2026 11:15:45 -0700 Subject: [PATCH] fix: preserve terminal scrollback across TUI restores --- .../src/components/panels/TerminalPanel.tsx | 25 +++++- main/src/services/terminalPanelManager.ts | 76 ++++++++++++++++--- shared/types/panels.ts | 2 + 3 files changed, 91 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/panels/TerminalPanel.tsx b/frontend/src/components/panels/TerminalPanel.tsx index 372f5ae..79e1b3b 100644 --- a/frontend/src/components/panels/TerminalPanel.tsx +++ b/frontend/src/components/panels/TerminalPanel.tsx @@ -45,6 +45,8 @@ const TerminalSpinner: React.FC = () => { // Type for terminal state restoration interface TerminalRestoreState { scrollbackBuffer: string | string[]; + alternateScreenBuffer?: string; + isAlternateScreen?: boolean; serializedBuffer?: string; cursorX?: number; cursorY?: number; @@ -256,12 +258,29 @@ export const TerminalPanel: React.FC = React.memo(({ panel, onStep, } = useTerminalSearch(xtermRef); - // Refresh terminal: reset and rewrite fresh scrollback from backend + const resizePtyToFit = useCallback(() => { + if (!fitAddonRef.current) return; + fitAddonRef.current.fit(); + const dimensions = fitAddonRef.current.proposeDimensions(); + if (dimensions) { + window.electronAPI.invoke('terminal:resize', panel.id, dimensions.cols, dimensions.rows); + } + }, [panel.id]); + + // Refresh terminal: normal shells replay raw scrollback; live TUIs repaint via resize. const handleRefreshTerminal = useCallback(async () => { const terminal = xtermRef.current; if (!terminal) return; try { const state = await window.electronAPI.invoke('terminal:getState', panel.id); + if (state?.isAlternateScreen) { + resizePtyToFit(); + if (terminal.rows > 0) { + terminal.refresh(0, terminal.rows - 1); + } + return; + } + terminal.reset(); if (state?.scrollbackBuffer) { const content = typeof state.scrollbackBuffer === 'string' @@ -271,11 +290,11 @@ export const TerminalPanel: React.FC = React.memo(({ panel, : ''; if (content) terminal.write(content); } - fitAddonRef.current?.fit(); + resizePtyToFit(); } catch (e) { console.warn('[TerminalPanel] Failed to refresh terminal:', e); } - }, [panel.id]); + }, [panel.id, resizePtyToFit]); // Open search on Ctrl/Cmd+F from the container div const handleTerminalKeyDown = useCallback((e: React.KeyboardEvent) => { diff --git a/main/src/services/terminalPanelManager.ts b/main/src/services/terminalPanelManager.ts index fea77c0..53b2116 100644 --- a/main/src/services/terminalPanelManager.ts +++ b/main/src/services/terminalPanelManager.ts @@ -20,12 +20,15 @@ const OUTPUT_BATCH_SIZE_HIDDEN = 80_000; // 80KB — cap per-flush size on hidde const PAUSE_SAFETY_TIMEOUT = 5_000; // 5s — auto-resume PTY if no acks arrive (prevents permanent stall) const MAX_CONCURRENT_SPAWNS = 3; const IDLE_THRESHOLD_MS = 5_000; // 5s — mark panel idle after no PTY output +const MAX_SCROLLBACK_BUFFER_SIZE = 500_000; // 500KB of normal shell history +const MAX_ALTERNATE_SCREEN_BUFFER_SIZE = 100_000; // 100KB of recent TUI redraw state interface TerminalProcess { pty: pty.IPty; panelId: string; sessionId: string; scrollbackBuffer: string; + alternateScreenBuffer: string; commandHistory: string[]; currentCommand: string; lastActivity: Date; @@ -96,6 +99,50 @@ export class TerminalPanelManager { } } + private trimAnsiSafe(buffer: string, maxSize: number): string { + if (buffer.length <= maxSize) return buffer; + + let start = buffer.length - maxSize; + + // Prefer a line boundary so replay starts from a sane row. + const nextNewline = buffer.indexOf('\n', start); + if (nextNewline !== -1 && nextNewline < buffer.length - 1) { + start = nextNewline + 1; + } + + // If the cut lands inside a common ANSI escape sequence, advance past it. + const lastEsc = buffer.lastIndexOf('\x1b', start); + if (lastEsc !== -1) { + let sequenceEnd = -1; + const introducer = buffer[lastEsc + 1]; + + if (introducer === '[') { + const finalByte = buffer.slice(lastEsc + 2).search(/[@-~]/); + sequenceEnd = finalByte === -1 ? -1 : lastEsc + 2 + finalByte; + } else if (introducer === ']') { + const belEnd = buffer.indexOf('\x07', lastEsc + 2); + const stEnd = buffer.indexOf('\x1b\\', lastEsc + 2); + if (belEnd !== -1 && stEnd !== -1) { + sequenceEnd = Math.min(belEnd, stEnd + 1); + } else if (belEnd !== -1) { + sequenceEnd = belEnd; + } else if (stEnd !== -1) { + sequenceEnd = stEnd + 1; + } + } else if (introducer) { + sequenceEnd = lastEsc + 1; + } + + if (sequenceEnd === -1) { + start = buffer.length; + } else if (sequenceEnd >= start) { + start = sequenceEnd + 1; + } + } + + return buffer.slice(start); + } + private flushOutputBuffer(terminal: TerminalProcess): void { if (terminal.outputFlushTimer) { clearTimeout(terminal.outputFlushTimer); @@ -311,6 +358,7 @@ export class TerminalPanelManager { panelId: panel.id, sessionId: panel.sessionId, scrollbackBuffer: '', + alternateScreenBuffer: '', commandHistory: [], currentCommand: '', lastActivity: new Date(), @@ -557,7 +605,9 @@ export class TerminalPanelManager { // Strip \x1b[2J inside DEC 2026 sync blocks before xterm.js sees the data const filtered = this.filterSyncBlockClears(terminal, data); - // Add to scrollback buffer + // Keep TUI redraw traffic separate from durable shell scrollback. Full-screen + // apps emit high-volume cursor/clear sequences that are useful only as a + // recent visual frame and should not evict normal history. this.addToScrollback(terminal, filtered); // Detect commands (simple heuristic - look for carriage returns) @@ -654,15 +704,18 @@ export class TerminalPanelManager { } private addToScrollback(terminal: TerminalProcess, data: string): void { - // Add raw data to scrollback buffer - terminal.scrollbackBuffer += data; - - // Trim buffer if it exceeds max size (keep last ~500KB of data) - const maxBufferSize = 500000; // 500KB - if (terminal.scrollbackBuffer.length > maxBufferSize) { - // Keep the most recent data - terminal.scrollbackBuffer = terminal.scrollbackBuffer.slice(-maxBufferSize); + if (terminal.isAlternateScreen) { + terminal.alternateScreenBuffer = this.trimAnsiSafe( + terminal.alternateScreenBuffer + data, + MAX_ALTERNATE_SCREEN_BUFFER_SIZE + ); + return; } + + terminal.scrollbackBuffer = this.trimAnsiSafe( + terminal.scrollbackBuffer + data, + MAX_SCROLLBACK_BUFFER_SIZE + ); } private isFileOperationCommand(command: string): boolean { @@ -765,6 +818,8 @@ export class TerminalPanelManager { isInitialized: true, cwd: cwd, scrollbackBuffer: terminal.scrollbackBuffer, + alternateScreenBuffer: terminal.alternateScreenBuffer, + isAlternateScreen: terminal.isAlternateScreen, commandHistory: terminal.commandHistory.slice(-100), // Keep last 100 commands lastActivityTime: terminal.lastActivity.toISOString(), lastActiveCommand: terminal.currentCommand, @@ -810,6 +865,7 @@ export class TerminalPanelManager { } else { terminal.scrollbackBuffer = ''; } + terminal.alternateScreenBuffer = state.alternateScreenBuffer || ''; terminal.commandHistory = state.commandHistory || []; // Send restoration indicator to terminal @@ -835,6 +891,8 @@ export class TerminalPanelManager { cwd: process.cwd(), // Simplified - would need platform-specific implementation shellType: process.env.SHELL || 'bash', scrollbackBuffer: terminal.scrollbackBuffer, + alternateScreenBuffer: terminal.alternateScreenBuffer, + isAlternateScreen: terminal.isAlternateScreen, commandHistory: terminal.commandHistory, lastActivityTime: terminal.lastActivity.toISOString(), lastActiveCommand: terminal.currentCommand, diff --git a/shared/types/panels.ts b/shared/types/panels.ts index 2a7c8af..c7f2f9b 100644 --- a/shared/types/panels.ts +++ b/shared/types/panels.ts @@ -33,6 +33,8 @@ export interface TerminalPanelState { // Enhanced persistence (can be added incrementally) scrollbackBuffer?: string | string[]; // Full terminal output history (string for new format, array for legacy) + alternateScreenBuffer?: string; // Recent TUI/alternate-screen output, kept separate from shell scrollback + isAlternateScreen?: boolean; // Whether the live terminal is currently in alternate-screen/TUI mode serializedBuffer?: string; // xterm.js serialized terminal state (includes full visual buffer) commandHistory?: string[]; // Commands entered by user environmentVars?: Record; // Modified env vars