feat: improve terminal creation and resizing logic

- Added a debouncing mechanism for terminal creation to prevent rapid requests.
- Enhanced terminal resizing with rate limiting and suppression of output during resize to avoid duplicates.
- Updated scrollback handling to clear pending output when establishing new WebSocket connections.
- Improved stability of terminal fitting logic by ensuring dimensions are stable before fitting.
This commit is contained in:
SuperComboGamer
2025-12-14 14:40:34 -05:00
parent 480589510e
commit a5c61b0546
4 changed files with 180 additions and 55 deletions

View File

@@ -147,6 +147,18 @@ export function TerminalView() {
const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008"; const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008";
const CREATE_COOLDOWN_MS = 500; // Prevent rapid terminal creation const CREATE_COOLDOWN_MS = 500; // Prevent rapid terminal creation
// Helper to check if terminal creation should be debounced
const canCreateTerminal = (debounceMessage: string): boolean => {
const now = Date.now();
if (now - lastCreateTimeRef.current < CREATE_COOLDOWN_MS || isCreatingRef.current) {
console.log(debounceMessage);
return false;
}
lastCreateTimeRef.current = now;
isCreatingRef.current = true;
return true;
};
// Get active tab // Get active tab
const activeTab = terminalState.tabs.find(t => t.id === terminalState.activeTabId); const activeTab = terminalState.tabs.find(t => t.id === terminalState.activeTabId);
@@ -263,14 +275,9 @@ export function TerminalView() {
// Create a new terminal session // Create a new terminal session
// targetSessionId: the terminal to split (if splitting an existing terminal) // targetSessionId: the terminal to split (if splitting an existing terminal)
const createTerminal = async (direction?: "horizontal" | "vertical", targetSessionId?: string) => { const createTerminal = async (direction?: "horizontal" | "vertical", targetSessionId?: string) => {
// Debounce: prevent rapid terminal creation if (!canCreateTerminal("[Terminal] Debounced terminal creation")) {
const now = Date.now();
if (now - lastCreateTimeRef.current < CREATE_COOLDOWN_MS || isCreatingRef.current) {
console.log("[Terminal] Debounced terminal creation");
return; return;
} }
lastCreateTimeRef.current = now;
isCreatingRef.current = true;
try { try {
const headers: Record<string, string> = { const headers: Record<string, string> = {
@@ -305,14 +312,9 @@ export function TerminalView() {
// Create terminal in new tab // Create terminal in new tab
const createTerminalInNewTab = async () => { const createTerminalInNewTab = async () => {
// Debounce: prevent rapid terminal creation if (!canCreateTerminal("[Terminal] Debounced terminal tab creation")) {
const now = Date.now();
if (now - lastCreateTimeRef.current < CREATE_COOLDOWN_MS || isCreatingRef.current) {
console.log("[Terminal] Debounced terminal tab creation");
return; return;
} }
lastCreateTimeRef.current = now;
isCreatingRef.current = true;
const tabId = addTerminalTab(); const tabId = addTerminalTab();
try { try {
@@ -424,12 +426,22 @@ export function TerminalView() {
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [terminalState.isUnlocked, terminalState.activeSessionId, shortcuts]); }, [terminalState.isUnlocked, terminalState.activeSessionId, shortcuts]);
// Get a stable key for a panel // Collect all terminal IDs from a panel tree in order
const getTerminalIds = (panel: TerminalPanelContent): string[] => {
if (panel.type === "terminal") {
return [panel.sessionId];
}
return panel.panels.flatMap(getTerminalIds);
};
// Get a STABLE key for a panel - based only on terminal IDs, not tree structure
// This prevents unnecessary remounts when layout structure changes
const getPanelKey = (panel: TerminalPanelContent): string => { const getPanelKey = (panel: TerminalPanelContent): string => {
if (panel.type === "terminal") { if (panel.type === "terminal") {
return panel.sessionId; return panel.sessionId;
} }
return `split-${panel.direction}-${panel.panels.map(getPanelKey).join("-")}`; // Use joined terminal IDs - stable regardless of nesting depth
return `group-${getTerminalIds(panel).join("-")}`;
}; };
// Render panel content recursively // Render panel content recursively
@@ -465,10 +477,12 @@ export function TerminalView() {
? panel.size ? panel.size
: defaultSizePerPanel; : defaultSizePerPanel;
const panelKey = getPanelKey(panel);
return ( return (
<React.Fragment key={getPanelKey(panel)}> <React.Fragment key={panelKey}>
{index > 0 && ( {index > 0 && (
<PanelResizeHandle <PanelResizeHandle
key={`handle-${panelKey}`}
className={ className={
isHorizontal isHorizontal
? "w-1 h-full bg-border hover:bg-brand-500 transition-colors data-[resize-handle-state=hover]:bg-brand-500 data-[resize-handle-state=drag]:bg-brand-500" ? "w-1 h-full bg-border hover:bg-brand-500 transition-colors data-[resize-handle-state=hover]:bg-brand-500 data-[resize-handle-state=drag]:bg-brand-500"
@@ -476,7 +490,7 @@ export function TerminalView() {
} }
/> />
)} )}
<Panel defaultSize={panelSize} minSize={25}> <Panel id={panelKey} order={index} defaultSize={panelSize} minSize={30}>
{renderPanelContent(panel)} {renderPanelContent(panel)}
</Panel> </Panel>
</React.Fragment> </React.Fragment>

View File

@@ -21,6 +21,9 @@ const MIN_FONT_SIZE = 8;
const MAX_FONT_SIZE = 32; const MAX_FONT_SIZE = 32;
const DEFAULT_FONT_SIZE = 14; const DEFAULT_FONT_SIZE = 14;
// Resize constraints
const RESIZE_DEBOUNCE_MS = 100; // Short debounce for responsive feel
interface TerminalPanelProps { interface TerminalPanelProps {
sessionId: string; sessionId: string;
authToken: string | null; authToken: string | null;
@@ -169,20 +172,41 @@ export function TerminalPanel({
console.warn("[Terminal] WebGL addon not available, falling back to canvas"); console.warn("[Terminal] WebGL addon not available, falling back to canvas");
} }
// Fit terminal to container using requestAnimationFrame for better timing // Fit terminal to container - wait for stable dimensions
requestAnimationFrame(() => { // Use multiple RAFs to let react-resizable-panels finish layout
if (fitAddon && terminalRef.current) { let fitAttempts = 0;
const rect = terminalRef.current.getBoundingClientRect(); const MAX_FIT_ATTEMPTS = 5;
// Only fit if container has valid dimensions let lastWidth = 0;
if (rect.width >= 100 && rect.height >= 50) { let lastHeight = 0;
try {
fitAddon.fit(); const attemptFit = () => {
} catch (err) { if (!fitAddon || !terminalRef.current || fitAttempts >= MAX_FIT_ATTEMPTS) return;
console.error("[Terminal] Initial fit error:", err);
} const rect = terminalRef.current.getBoundingClientRect();
fitAttempts++;
// Check if dimensions are stable (same as last attempt) and valid
if (
rect.width === lastWidth &&
rect.height === lastHeight &&
rect.width > 0 &&
rect.height > 0
) {
try {
fitAddon.fit();
} catch (err) {
console.error("[Terminal] Initial fit error:", err);
} }
return;
} }
});
// Dimensions still changing or too small, try again
lastWidth = rect.width;
lastHeight = rect.height;
requestAnimationFrame(attemptFit);
};
requestAnimationFrame(attemptFit);
xtermRef.current = terminal; xtermRef.current = terminal;
fitAddonRef.current = fitAddon; fitAddonRef.current = fitAddon;
@@ -299,10 +323,11 @@ export function TerminalPanel({
terminal.write(msg.data); terminal.write(msg.data);
break; break;
case "scrollback": case "scrollback":
// Clear terminal before replaying scrollback to prevent duplicates on reconnection // Only process scrollback if there's actual data
terminal.clear(); // Don't clear if empty - prevents blank terminal issue
// Replay scrollback buffer (previous terminal output) if (msg.data && msg.data.length > 0) {
if (msg.data) { // Use reset() which is more reliable than clear() or escape sequences
terminal.reset();
terminal.write(msg.data); terminal.write(msg.data);
} }
break; break;
@@ -371,7 +396,7 @@ export function TerminalPanel({
}; };
}, [sessionId, authToken, wsUrl, isTerminalReady]); }, [sessionId, authToken, wsUrl, isTerminalReady]);
// Handle resize with debouncing and validation // Handle resize with debouncing
const handleResize = useCallback(() => { const handleResize = useCallback(() => {
// Clear any pending resize // Clear any pending resize
if (resizeDebounceRef.current) { if (resizeDebounceRef.current) {
@@ -382,14 +407,11 @@ export function TerminalPanel({
resizeDebounceRef.current = setTimeout(() => { resizeDebounceRef.current = setTimeout(() => {
if (!fitAddonRef.current || !xtermRef.current || !terminalRef.current) return; if (!fitAddonRef.current || !xtermRef.current || !terminalRef.current) return;
// Validate minimum dimensions before resizing
const container = terminalRef.current; const container = terminalRef.current;
const rect = container.getBoundingClientRect(); const rect = container.getBoundingClientRect();
const MIN_WIDTH = 100;
const MIN_HEIGHT = 50;
if (rect.width < MIN_WIDTH || rect.height < MIN_HEIGHT) { // Only skip if container has no size at all
console.log("[Terminal] Container too small to resize:", rect.width, "x", rect.height); if (rect.width <= 0 || rect.height <= 0) {
return; return;
} }
@@ -404,7 +426,7 @@ export function TerminalPanel({
} catch (err) { } catch (err) {
console.error("[Terminal] Resize error:", err); console.error("[Terminal] Resize error:", err);
} }
}, 100); // 100ms debounce }, RESIZE_DEBOUNCE_MS);
}, []); }, []);
// Resize observer // Resize observer
@@ -439,12 +461,16 @@ export function TerminalPanel({
if (xtermRef.current && isTerminalReady) { if (xtermRef.current && isTerminalReady) {
xtermRef.current.options.fontSize = fontSize; xtermRef.current.options.fontSize = fontSize;
// Refit after font size change // Refit after font size change
if (fitAddonRef.current) { if (fitAddonRef.current && terminalRef.current) {
fitAddonRef.current.fit(); const rect = terminalRef.current.getBoundingClientRect();
// Notify server of new dimensions // Only fit if container has any size
const { cols, rows } = xtermRef.current; if (rect.width > 0 && rect.height > 0) {
if (wsRef.current?.readyState === WebSocket.OPEN) { fitAddonRef.current.fit();
wsRef.current.send(JSON.stringify({ type: "resize", cols, rows })); // Notify server of new dimensions
const { cols, rows } = xtermRef.current;
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: "resize", cols, rows }));
}
} }
} }
} }

View File

@@ -199,6 +199,16 @@ wss.on("connection", (ws: WebSocket) => {
const terminalConnections: Map<string, Set<WebSocket>> = new Map(); const terminalConnections: Map<string, Set<WebSocket>> = new Map();
// Track last resize dimensions per session to deduplicate resize messages // Track last resize dimensions per session to deduplicate resize messages
const lastResizeDimensions: Map<string, { cols: number; rows: number }> = new Map(); const lastResizeDimensions: Map<string, { cols: number; rows: number }> = new Map();
// Track last resize timestamp to rate-limit resize operations (prevents resize storm)
const lastResizeTime: Map<string, number> = new Map();
const RESIZE_MIN_INTERVAL_MS = 100; // Minimum 100ms between resize operations
// Clean up resize tracking when sessions actually exit (not just when connections close)
terminalService.onExit((sessionId) => {
lastResizeDimensions.delete(sessionId);
lastResizeTime.delete(sessionId);
terminalConnections.delete(sessionId);
});
// Terminal WebSocket connection handler // Terminal WebSocket connection handler
terminalWss.on( terminalWss.on(
@@ -261,8 +271,8 @@ terminalWss.on(
); );
// Send scrollback buffer BEFORE subscribing to prevent race condition // Send scrollback buffer BEFORE subscribing to prevent race condition
// This ensures data isn't sent twice (once in scrollback, once via subscription) // Also clear pending output buffer to prevent duplicates from throttled flush
const scrollback = terminalService.getScrollback(sessionId); const scrollback = terminalService.getScrollbackAndClearPending(sessionId);
if (scrollback && scrollback.length > 0) { if (scrollback && scrollback.length > 0) {
ws.send( ws.send(
JSON.stringify({ JSON.stringify({
@@ -299,21 +309,32 @@ terminalWss.on(
break; break;
case "resize": case "resize":
// Resize terminal with deduplication // Resize terminal with deduplication and rate limiting
if (msg.cols && msg.rows) { if (msg.cols && msg.rows) {
// Check if dimensions are different from last resize const now = Date.now();
const lastTime = lastResizeTime.get(sessionId) || 0;
const lastDimensions = lastResizeDimensions.get(sessionId); const lastDimensions = lastResizeDimensions.get(sessionId);
// Skip if resized too recently (prevents resize storm during splits)
if (now - lastTime < RESIZE_MIN_INTERVAL_MS) {
break;
}
// Check if dimensions are different from last resize
if ( if (
!lastDimensions || !lastDimensions ||
lastDimensions.cols !== msg.cols || lastDimensions.cols !== msg.cols ||
lastDimensions.rows !== msg.rows lastDimensions.rows !== msg.rows
) { ) {
// Only resize if dimensions changed // Only suppress output on subsequent resizes, not the first one
terminalService.resize(sessionId, msg.cols, msg.rows); // The first resize happens on terminal open and we don't want to drop the initial prompt
const isFirstResize = !lastDimensions;
terminalService.resize(sessionId, msg.cols, msg.rows, !isFirstResize);
lastResizeDimensions.set(sessionId, { lastResizeDimensions.set(sessionId, {
cols: msg.cols, cols: msg.cols,
rows: msg.rows, rows: msg.rows,
}); });
lastResizeTime.set(sessionId, now);
} }
} }
break; break;
@@ -344,8 +365,10 @@ terminalWss.on(
connections.delete(ws); connections.delete(ws);
if (connections.size === 0) { if (connections.size === 0) {
terminalConnections.delete(sessionId); terminalConnections.delete(sessionId);
// Clean up resize dimensions tracking when session has no more connections // DON'T delete lastResizeDimensions/lastResizeTime here!
lastResizeDimensions.delete(sessionId); // The session still exists, and reconnecting clients need to know
// this isn't the "first resize" to prevent duplicate prompts.
// These get cleaned up when the session actually exits.
} }
} }
}); });

View File

@@ -26,6 +26,8 @@ export interface TerminalSession {
scrollbackBuffer: string; // Store recent output for replay on reconnect scrollbackBuffer: string; // Store recent output for replay on reconnect
outputBuffer: string; // Pending output to be flushed outputBuffer: string; // Pending output to be flushed
flushTimeout: NodeJS.Timeout | null; // Throttle timer flushTimeout: NodeJS.Timeout | null; // Throttle timer
resizeInProgress: boolean; // Flag to suppress scrollback during resize
resizeDebounceTimeout: NodeJS.Timeout | null; // Resize settle timer
} }
export interface TerminalOptions { export interface TerminalOptions {
@@ -213,6 +215,8 @@ export class TerminalService extends EventEmitter {
scrollbackBuffer: "", scrollbackBuffer: "",
outputBuffer: "", outputBuffer: "",
flushTimeout: null, flushTimeout: null,
resizeInProgress: false,
resizeDebounceTimeout: null,
}; };
this.sessions.set(id, session); this.sessions.set(id, session);
@@ -239,6 +243,13 @@ export class TerminalService extends EventEmitter {
// Forward data events with throttling // Forward data events with throttling
ptyProcess.onData((data) => { ptyProcess.onData((data) => {
// Skip ALL output during resize/reconnect to prevent prompt redraw duplication
// This drops both scrollback AND live output during the suppression window
// Without this, prompt redraws from SIGWINCH go to live clients causing duplicates
if (session.resizeInProgress) {
return;
}
// Append to scrollback buffer // Append to scrollback buffer
session.scrollbackBuffer += data; session.scrollbackBuffer += data;
// Trim if too large (keep the most recent data) // Trim if too large (keep the most recent data)
@@ -246,7 +257,7 @@ export class TerminalService extends EventEmitter {
session.scrollbackBuffer = session.scrollbackBuffer.slice(-MAX_SCROLLBACK_SIZE); session.scrollbackBuffer = session.scrollbackBuffer.slice(-MAX_SCROLLBACK_SIZE);
} }
// Buffer output for throttled delivery // Buffer output for throttled live delivery
session.outputBuffer += data; session.outputBuffer += data;
// Schedule flush if not already scheduled // Schedule flush if not already scheduled
@@ -282,18 +293,40 @@ export class TerminalService extends EventEmitter {
/** /**
* Resize a terminal session * Resize a terminal session
* @param suppressOutput - If true, suppress output during resize to prevent duplicate prompts.
* Should be false for the initial resize so the first prompt isn't dropped.
*/ */
resize(sessionId: string, cols: number, rows: number): boolean { resize(sessionId: string, cols: number, rows: number, suppressOutput: boolean = true): boolean {
const session = this.sessions.get(sessionId); const session = this.sessions.get(sessionId);
if (!session) { if (!session) {
console.warn(`[Terminal] Session ${sessionId} not found for resize`); console.warn(`[Terminal] Session ${sessionId} not found for resize`);
return false; return false;
} }
try { try {
// Only suppress output on subsequent resizes, not the initial one
// This prevents the shell's first prompt from being dropped
if (suppressOutput) {
session.resizeInProgress = true;
if (session.resizeDebounceTimeout) {
clearTimeout(session.resizeDebounceTimeout);
}
}
session.pty.resize(cols, rows); session.pty.resize(cols, rows);
// Clear resize flag after a delay (allow prompt to settle)
// 150ms is enough for most prompts - longer causes sluggish feel
if (suppressOutput) {
session.resizeDebounceTimeout = setTimeout(() => {
session.resizeInProgress = false;
session.resizeDebounceTimeout = null;
}, 150);
}
return true; return true;
} catch (error) { } catch (error) {
console.error(`[Terminal] Error resizing session ${sessionId}:`, error); console.error(`[Terminal] Error resizing session ${sessionId}:`, error);
session.resizeInProgress = false; // Clear flag on error
return false; return false;
} }
} }
@@ -312,6 +345,11 @@ export class TerminalService extends EventEmitter {
clearTimeout(session.flushTimeout); clearTimeout(session.flushTimeout);
session.flushTimeout = null; session.flushTimeout = null;
} }
// Clean up resize debounce timeout
if (session.resizeDebounceTimeout) {
clearTimeout(session.resizeDebounceTimeout);
session.resizeDebounceTimeout = null;
}
session.pty.kill(); session.pty.kill();
this.sessions.delete(sessionId); this.sessions.delete(sessionId);
console.log(`[Terminal] Session ${sessionId} killed`); console.log(`[Terminal] Session ${sessionId} killed`);
@@ -337,6 +375,30 @@ export class TerminalService extends EventEmitter {
return session?.scrollbackBuffer || null; return session?.scrollbackBuffer || null;
} }
/**
* Get scrollback buffer and clear pending output buffer to prevent duplicates
* Call this when establishing a new WebSocket connection
* This prevents data that's already in scrollback from being sent again via data callback
*/
getScrollbackAndClearPending(sessionId: string): string | null {
const session = this.sessions.get(sessionId);
if (!session) return null;
// Clear any pending output that hasn't been flushed yet
// This data is already in scrollbackBuffer
session.outputBuffer = "";
if (session.flushTimeout) {
clearTimeout(session.flushTimeout);
session.flushTimeout = null;
}
// NOTE: Don't set resizeInProgress here - it causes blank terminals
// if the shell hasn't output its prompt yet when WebSocket connects.
// The resize() method handles suppression during actual resize events.
return session.scrollbackBuffer || null;
}
/** /**
* Get all active sessions * Get all active sessions
*/ */