From 3be4489ee71a451f75ac7157b276088e3e89c227 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Mon, 11 May 2026 15:20:59 +0200 Subject: [PATCH] Prevent terminal replay from sending input --- src/client/src/components/TerminalPanel.ts | 33 +++++++++++++++++++--- src/server/terminals/terminalRoutes.ts | 2 +- src/server/terminals/terminalService.ts | 6 ++-- 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/src/client/src/components/TerminalPanel.ts b/src/client/src/components/TerminalPanel.ts index d455522..69c004e 100644 --- a/src/client/src/components/TerminalPanel.ts +++ b/src/client/src/components/TerminalPanel.ts @@ -21,6 +21,7 @@ export class TerminalPanel extends LitElement { private socket: WebSocket | undefined; private resizeObserver: ResizeObserver | undefined; private intersectionObserver: IntersectionObserver | undefined; + private suppressTerminalInput = false; private observedCwd: string | undefined; private loadedCwd: string | undefined; @@ -128,7 +129,11 @@ export class TerminalPanel extends LitElement { this.fitAddon = fitAddon; this.resizeObserver = new ResizeObserver(() => { this.fitAndNotify(); }); this.resizeObserver.observe(this.terminalHost); - terminal.onData((data) => { this.send({ type: "input", data }); }); + terminal.onData((data) => { + if (this.suppressTerminalInput) return; + const filtered = filterTerminalInput(data); + if (filtered !== "") this.send({ type: "input", data: filtered }); + }); this.connectSocket(workspace.projectId, workspace.id, this.selectedId, terminal); requestAnimationFrame(() => { this.fitAndNotify(); }); terminal.focus(); @@ -150,7 +155,9 @@ export class TerminalPanel extends LitElement { private async handleSocketMessage(data: unknown, terminalId: string, terminal: Terminal): Promise { try { const message = parseServerMessage(await socketDataToString(data)); - if (message.type === "output") terminal.write(message.data); + if (message.type === "output") { + this.writeTerminalOutput(terminal, message.data, message.replay === true); + } if (message.type === "exit") { terminal.writeln(`\r\n[process exited${message.exitCode === undefined ? "" : ` with code ${String(message.exitCode)}`}]`); this.terminals = this.terminals.map((item) => item.id === terminalId ? { ...item, exited: true, ...(message.exitCode === undefined ? {} : { exitCode: message.exitCode }) } : item); @@ -161,6 +168,17 @@ export class TerminalPanel extends LitElement { } } + private writeTerminalOutput(terminal: Terminal, data: string, replay: boolean): void { + if (!replay) { + terminal.write(data); + return; + } + this.suppressTerminalInput = true; + terminal.write(data, () => { + this.suppressTerminalInput = false; + }); + } + private fitAndNotify(): void { if (this.fitAddon === undefined || this.terminal === undefined) return; this.fitAddon.fit(); @@ -232,7 +250,7 @@ export class TerminalPanel extends LitElement { } type ServerTerminalMessage = - | { type: "output"; data: string } + | { type: "output"; data: string; replay?: boolean } | { type: "exit"; exitCode?: number } | { type: "error"; message: string }; @@ -240,12 +258,19 @@ function parseServerMessage(data: string): ServerTerminalMessage { const value: unknown = JSON.parse(data); if (!isRecord(value)) return { type: "error", message: "Invalid terminal message" }; const record = value; - if (record["type"] === "output" && typeof record["data"] === "string") return { type: "output", data: record["data"] }; + if (record["type"] === "output" && typeof record["data"] === "string") return { type: "output", data: record["data"], ...(typeof record["replay"] === "boolean" ? { replay: record["replay"] } : {}) }; if (record["type"] === "exit") return { type: "exit", ...(typeof record["exitCode"] === "number" ? { exitCode: record["exitCode"] } : {}) }; if (record["type"] === "error" && typeof record["message"] === "string") return { type: "error", message: record["message"] }; return { type: "error", message: "Invalid terminal message" }; } +export function filterTerminalInput(data: string): string { + // Xterm can emit focus-in/focus-out sequences when replayed output leaves focus + // tracking enabled. Bash/readline treats those sequences as typed text, which + // leaves stray characters on the prompt after reconnecting to an active shell. + return data.replaceAll("\x1b[I", "").replaceAll("\x1b[O", ""); +} + async function socketDataToString(data: unknown): Promise { if (typeof data === "string") return data; if (data instanceof ArrayBuffer) return new TextDecoder().decode(data); diff --git a/src/server/terminals/terminalRoutes.ts b/src/server/terminals/terminalRoutes.ts index 5276b1a..c1709f5 100644 --- a/src/server/terminals/terminalRoutes.ts +++ b/src/server/terminals/terminalRoutes.ts @@ -25,7 +25,7 @@ export function registerTerminalRoutes(app: FastifyInstance, terminals: Terminal let detach: (() => void) | undefined; try { detach = terminals.attach(request.params.terminalId, { - output: (data) => { socket.send(JSON.stringify({ type: "output", data })); }, + output: (data, replay) => { socket.send(JSON.stringify({ type: "output", data, replay })); }, exit: (exitCode) => { socket.send(JSON.stringify({ type: "exit", exitCode })); }, }); } catch (error) { diff --git a/src/server/terminals/terminalService.ts b/src/server/terminals/terminalService.ts index dea5268..098f0f7 100644 --- a/src/server/terminals/terminalService.ts +++ b/src/server/terminals/terminalService.ts @@ -76,11 +76,11 @@ export class TerminalService { return terminal === undefined ? undefined : toInfo(terminal); } - attach(id: string, handlers: { output: (data: string) => void; exit: (exitCode: number | undefined) => void }): () => void { + attach(id: string, handlers: { output: (data: string, replay: boolean) => void; exit: (exitCode: number | undefined) => void }): () => void { const terminal = this.require(id); - if (terminal.buffer !== "") handlers.output(terminal.buffer); + if (terminal.buffer !== "") handlers.output(terminal.buffer, true); if (terminal.exited) handlers.exit(terminal.exitCode); - const onOutput = (data: string) => { handlers.output(data); }; + const onOutput = (data: string) => { handlers.output(data, false); }; const onExit = (exitCode: number | undefined) => { handlers.exit(exitCode); }; terminal.events.on("output", onOutput); terminal.events.on("exit", onExit);