Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions frontend/src/components/panels/TerminalPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -256,12 +258,29 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = 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'
Expand All @@ -271,11 +290,11 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = 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) => {
Expand Down
76 changes: 67 additions & 9 deletions main/src/services/terminalPanelManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -311,6 +358,7 @@ export class TerminalPanelManager {
panelId: panel.id,
sessionId: panel.sessionId,
scrollbackBuffer: '',
alternateScreenBuffer: '',
commandHistory: [],
currentCommand: '',
lastActivity: new Date(),
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -810,6 +865,7 @@ export class TerminalPanelManager {
} else {
terminal.scrollbackBuffer = '';
}
terminal.alternateScreenBuffer = state.alternateScreenBuffer || '';
terminal.commandHistory = state.commandHistory || [];

// Send restoration indicator to terminal
Expand All @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions shared/types/panels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>; // Modified env vars
Expand Down
Loading