mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 20:23:36 +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:
@@ -190,6 +190,18 @@ wss.on("connection", (ws: WebSocket) => {
|
||||
|
||||
// Track WebSocket connections per session
|
||||
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
|
||||
terminalWss.on(
|
||||
@@ -241,7 +253,29 @@ terminalWss.on(
|
||||
}
|
||||
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) => {
|
||||
if (sid === sessionId && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "data", data }));
|
||||
@@ -268,9 +302,33 @@ terminalWss.on(
|
||||
break;
|
||||
|
||||
case "resize":
|
||||
// Resize terminal
|
||||
// Resize terminal with deduplication and rate limiting
|
||||
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;
|
||||
|
||||
@@ -300,6 +358,10 @@ terminalWss.on(
|
||||
connections.delete(ws);
|
||||
if (connections.size === 0) {
|
||||
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();
|
||||
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
|
||||
outputBuffer: string; // Pending output to be flushed
|
||||
flushTimeout: NodeJS.Timeout | null; // Throttle timer
|
||||
resizeInProgress: boolean; // Flag to suppress scrollback during resize
|
||||
resizeDebounceTimeout: NodeJS.Timeout | null; // Resize settle timer
|
||||
}
|
||||
|
||||
export interface TerminalOptions {
|
||||
@@ -213,6 +215,8 @@ export class TerminalService extends EventEmitter {
|
||||
scrollbackBuffer: "",
|
||||
outputBuffer: "",
|
||||
flushTimeout: null,
|
||||
resizeInProgress: false,
|
||||
resizeDebounceTimeout: null,
|
||||
};
|
||||
|
||||
this.sessions.set(id, session);
|
||||
@@ -239,6 +243,13 @@ export class TerminalService extends EventEmitter {
|
||||
|
||||
// Forward data events with throttling
|
||||
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
|
||||
session.scrollbackBuffer += 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);
|
||||
}
|
||||
|
||||
// Buffer output for throttled delivery
|
||||
// Buffer output for throttled live delivery
|
||||
session.outputBuffer += data;
|
||||
|
||||
// Schedule flush if not already scheduled
|
||||
@@ -282,18 +293,40 @@ export class TerminalService extends EventEmitter {
|
||||
|
||||
/**
|
||||
* 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);
|
||||
if (!session) {
|
||||
console.warn(`[Terminal] Session ${sessionId} not found for resize`);
|
||||
return false;
|
||||
}
|
||||
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);
|
||||
|
||||
// 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;
|
||||
} catch (error) {
|
||||
console.error(`[Terminal] Error resizing session ${sessionId}:`, error);
|
||||
session.resizeInProgress = false; // Clear flag on error
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -312,6 +345,11 @@ export class TerminalService extends EventEmitter {
|
||||
clearTimeout(session.flushTimeout);
|
||||
session.flushTimeout = null;
|
||||
}
|
||||
// Clean up resize debounce timeout
|
||||
if (session.resizeDebounceTimeout) {
|
||||
clearTimeout(session.resizeDebounceTimeout);
|
||||
session.resizeDebounceTimeout = null;
|
||||
}
|
||||
session.pty.kill();
|
||||
this.sessions.delete(sessionId);
|
||||
console.log(`[Terminal] Session ${sessionId} killed`);
|
||||
@@ -337,6 +375,30 @@ export class TerminalService extends EventEmitter {
|
||||
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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user