mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-04 09:13:08 +00:00
Merge pull request #91 from AutoMaker-Org/new-fixes-terminal
feat: enhance terminal functionality with debouncing and resize valid…
This commit is contained in:
@@ -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,6 +312,10 @@ export function TerminalView() {
|
|||||||
|
|
||||||
// Create terminal in new tab
|
// Create terminal in new tab
|
||||||
const createTerminalInNewTab = async () => {
|
const createTerminalInNewTab = async () => {
|
||||||
|
if (!canCreateTerminal("[Terminal] Debounced terminal tab creation")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const tabId = addTerminalTab();
|
const tabId = addTerminalTab();
|
||||||
try {
|
try {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
@@ -332,6 +343,8 @@ export function TerminalView() {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[Terminal] Create session error:", err);
|
console.error("[Terminal] Create session error:", err);
|
||||||
|
} finally {
|
||||||
|
isCreatingRef.current = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -413,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
|
||||||
@@ -454,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"
|
||||||
@@ -465,7 +490,7 @@ export function TerminalView() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Panel defaultSize={panelSize} minSize={15}>
|
<Panel id={panelKey} order={index} defaultSize={panelSize} minSize={30}>
|
||||||
{renderPanelContent(panel)}
|
{renderPanelContent(panel)}
|
||||||
</Panel>
|
</Panel>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -59,6 +62,8 @@ export function TerminalPanel({
|
|||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const lastShortcutTimeRef = useRef<number>(0);
|
const lastShortcutTimeRef = useRef<number>(0);
|
||||||
|
const resizeDebounceRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const focusHandlerRef = useRef<{ dispose: () => void } | null>(null);
|
||||||
const [isTerminalReady, setIsTerminalReady] = useState(false);
|
const [isTerminalReady, setIsTerminalReady] = useState(false);
|
||||||
const [shellName, setShellName] = useState("shell");
|
const [shellName, setShellName] = useState("shell");
|
||||||
|
|
||||||
@@ -167,17 +172,49 @@ 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
|
// Fit terminal to container - wait for stable dimensions
|
||||||
setTimeout(() => {
|
// Use multiple RAFs to let react-resizable-panels finish layout
|
||||||
fitAddon.fit();
|
let fitAttempts = 0;
|
||||||
}, 0);
|
const MAX_FIT_ATTEMPTS = 5;
|
||||||
|
let lastWidth = 0;
|
||||||
|
let lastHeight = 0;
|
||||||
|
|
||||||
|
const attemptFit = () => {
|
||||||
|
if (!fitAddon || !terminalRef.current || fitAttempts >= MAX_FIT_ATTEMPTS) return;
|
||||||
|
|
||||||
|
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;
|
||||||
setIsTerminalReady(true);
|
setIsTerminalReady(true);
|
||||||
|
|
||||||
// Handle focus - use ref to avoid re-running effect
|
// Handle focus - use ref to avoid re-running effect
|
||||||
terminal.onData(() => {
|
// Store disposer to prevent memory leak
|
||||||
|
focusHandlerRef.current = terminal.onData(() => {
|
||||||
onFocusRef.current();
|
onFocusRef.current();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -236,6 +273,19 @@ export function TerminalPanel({
|
|||||||
// Cleanup
|
// Cleanup
|
||||||
return () => {
|
return () => {
|
||||||
mounted = false;
|
mounted = false;
|
||||||
|
|
||||||
|
// Dispose focus handler to prevent memory leak
|
||||||
|
if (focusHandlerRef.current) {
|
||||||
|
focusHandlerRef.current.dispose();
|
||||||
|
focusHandlerRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear resize debounce timer
|
||||||
|
if (resizeDebounceRef.current) {
|
||||||
|
clearTimeout(resizeDebounceRef.current);
|
||||||
|
resizeDebounceRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (xtermRef.current) {
|
if (xtermRef.current) {
|
||||||
xtermRef.current.dispose();
|
xtermRef.current.dispose();
|
||||||
xtermRef.current = null;
|
xtermRef.current = null;
|
||||||
@@ -273,8 +323,11 @@ export function TerminalPanel({
|
|||||||
terminal.write(msg.data);
|
terminal.write(msg.data);
|
||||||
break;
|
break;
|
||||||
case "scrollback":
|
case "scrollback":
|
||||||
// Replay scrollback buffer (previous terminal output)
|
// Only process scrollback if there's actual data
|
||||||
if (msg.data) {
|
// Don't clear if empty - prevents blank terminal issue
|
||||||
|
if (msg.data && msg.data.length > 0) {
|
||||||
|
// Use reset() which is more reliable than clear() or escape sequences
|
||||||
|
terminal.reset();
|
||||||
terminal.write(msg.data);
|
terminal.write(msg.data);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -343,17 +396,37 @@ export function TerminalPanel({
|
|||||||
};
|
};
|
||||||
}, [sessionId, authToken, wsUrl, isTerminalReady]);
|
}, [sessionId, authToken, wsUrl, isTerminalReady]);
|
||||||
|
|
||||||
// Handle resize
|
// Handle resize with debouncing
|
||||||
const handleResize = useCallback(() => {
|
const handleResize = useCallback(() => {
|
||||||
if (fitAddonRef.current && xtermRef.current) {
|
// Clear any pending resize
|
||||||
fitAddonRef.current.fit();
|
if (resizeDebounceRef.current) {
|
||||||
const { cols, rows } = xtermRef.current;
|
clearTimeout(resizeDebounceRef.current);
|
||||||
|
|
||||||
// Send resize to server
|
|
||||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
||||||
wsRef.current.send(JSON.stringify({ type: "resize", cols, rows }));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Debounce resize operations to prevent race conditions
|
||||||
|
resizeDebounceRef.current = setTimeout(() => {
|
||||||
|
if (!fitAddonRef.current || !xtermRef.current || !terminalRef.current) return;
|
||||||
|
|
||||||
|
const container = terminalRef.current;
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Only skip if container has no size at all
|
||||||
|
if (rect.width <= 0 || rect.height <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
fitAddonRef.current.fit();
|
||||||
|
const { cols, rows } = xtermRef.current;
|
||||||
|
|
||||||
|
// Send resize to server
|
||||||
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
wsRef.current.send(JSON.stringify({ type: "resize", cols, rows }));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[Terminal] Resize error:", err);
|
||||||
|
}
|
||||||
|
}, RESIZE_DEBOUNCE_MS);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Resize observer
|
// Resize observer
|
||||||
@@ -376,24 +449,28 @@ export function TerminalPanel({
|
|||||||
};
|
};
|
||||||
}, [handleResize]);
|
}, [handleResize]);
|
||||||
|
|
||||||
// Focus terminal when becoming active
|
// Focus terminal when becoming active or when terminal becomes ready
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isActive && xtermRef.current) {
|
if (isActive && isTerminalReady && xtermRef.current) {
|
||||||
xtermRef.current.focus();
|
xtermRef.current.focus();
|
||||||
}
|
}
|
||||||
}, [isActive]);
|
}, [isActive, isTerminalReady]);
|
||||||
|
|
||||||
// Update terminal font size when it changes
|
// Update terminal font size when it changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
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 }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,6 +190,18 @@ wss.on("connection", (ws: WebSocket) => {
|
|||||||
|
|
||||||
// Track WebSocket connections per session
|
// Track WebSocket connections per session
|
||||||
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
|
||||||
|
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(
|
||||||
@@ -241,7 +253,29 @@ terminalWss.on(
|
|||||||
}
|
}
|
||||||
terminalConnections.get(sessionId)!.add(ws);
|
terminalConnections.get(sessionId)!.add(ws);
|
||||||
|
|
||||||
// Subscribe to terminal data
|
// Send initial connection success FIRST
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "connected",
|
||||||
|
sessionId,
|
||||||
|
shell: session.shell,
|
||||||
|
cwd: session.cwd,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Send scrollback buffer BEFORE subscribing to prevent race condition
|
||||||
|
// Also clear pending output buffer to prevent duplicates from throttled flush
|
||||||
|
const scrollback = terminalService.getScrollbackAndClearPending(sessionId);
|
||||||
|
if (scrollback && scrollback.length > 0) {
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "scrollback",
|
||||||
|
data: scrollback,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOW subscribe to terminal data (after scrollback is sent)
|
||||||
const unsubscribeData = terminalService.onData((sid, data) => {
|
const unsubscribeData = terminalService.onData((sid, data) => {
|
||||||
if (sid === sessionId && ws.readyState === WebSocket.OPEN) {
|
if (sid === sessionId && ws.readyState === WebSocket.OPEN) {
|
||||||
ws.send(JSON.stringify({ type: "data", data }));
|
ws.send(JSON.stringify({ type: "data", data }));
|
||||||
@@ -268,9 +302,33 @@ terminalWss.on(
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case "resize":
|
case "resize":
|
||||||
// Resize terminal
|
// Resize terminal with deduplication and rate limiting
|
||||||
if (msg.cols && msg.rows) {
|
if (msg.cols && msg.rows) {
|
||||||
terminalService.resize(sessionId, msg.cols, msg.rows);
|
const now = Date.now();
|
||||||
|
const lastTime = lastResizeTime.get(sessionId) || 0;
|
||||||
|
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 (
|
||||||
|
!lastDimensions ||
|
||||||
|
lastDimensions.cols !== msg.cols ||
|
||||||
|
lastDimensions.rows !== msg.rows
|
||||||
|
) {
|
||||||
|
// Only suppress output on subsequent resizes, not the first one
|
||||||
|
// 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, {
|
||||||
|
cols: msg.cols,
|
||||||
|
rows: msg.rows,
|
||||||
|
});
|
||||||
|
lastResizeTime.set(sessionId, now);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -300,6 +358,10 @@ terminalWss.on(
|
|||||||
connections.delete(ws);
|
connections.delete(ws);
|
||||||
if (connections.size === 0) {
|
if (connections.size === 0) {
|
||||||
terminalConnections.delete(sessionId);
|
terminalConnections.delete(sessionId);
|
||||||
|
// DON'T delete lastResizeDimensions/lastResizeTime here!
|
||||||
|
// 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.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -309,27 +371,6 @@ terminalWss.on(
|
|||||||
unsubscribeData();
|
unsubscribeData();
|
||||||
unsubscribeExit();
|
unsubscribeExit();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send initial connection success
|
|
||||||
ws.send(
|
|
||||||
JSON.stringify({
|
|
||||||
type: "connected",
|
|
||||||
sessionId,
|
|
||||||
shell: session.shell,
|
|
||||||
cwd: session.cwd,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Send scrollback buffer to replay previous output
|
|
||||||
const scrollback = terminalService.getScrollback(sessionId);
|
|
||||||
if (scrollback && scrollback.length > 0) {
|
|
||||||
ws.send(
|
|
||||||
JSON.stringify({
|
|
||||||
type: "scrollback",
|
|
||||||
data: scrollback,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user