From 12a373e598c0daa1dc9f5244fdc8b0040467eddf Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Wed, 30 Sep 2026 22:45:44 +0800 Subject: [PATCH] fix(ssh-terminal): preserve input order during local echo reconciliation (#1505) --- .../src/frontend/lib/terminal-local-echo.ts | 8 ++- .../frontend/lib/terminal-local-echo.test.ts | 31 +++++++++++ .../lib/terminal-local-echo.xterm.test.ts | 53 +++++++++++++++++++ 3 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 plugins/ssh-terminal/tests/frontend/lib/terminal-local-echo.xterm.test.ts diff --git a/plugins/ssh-terminal/src/frontend/lib/terminal-local-echo.ts b/plugins/ssh-terminal/src/frontend/lib/terminal-local-echo.ts index 052ec438f..5cecee3eb 100644 --- a/plugins/ssh-terminal/src/frontend/lib/terminal-local-echo.ts +++ b/plugins/ssh-terminal/src/frontend/lib/terminal-local-echo.ts @@ -24,13 +24,16 @@ export class TerminalLocalEcho { } if (this.secretInput || !SAFE_INPUT.test(data)) return ""; + // Do not draw newer input ahead of characters still awaiting remote echo. const predicted = - this.mode === "on" || (this.mode === "auto" && this.slowSamples >= 2); + (this.mode === "on" || (this.mode === "auto" && this.slowSamples >= 2)) && + this.pending.every((item) => item.predicted); this.pending.push({ value: data, sentAt: this.now(), predicted }); return predicted ? data : ""; } handleOutput(data: string): string { + if (!data) return ""; const plainOutput = this.stripAnsi(data); this.outputTail = (this.outputTail + plainOutput).slice(-128); if (PASSWORD_PROMPT.test(this.outputTail)) { @@ -65,7 +68,8 @@ export class TerminalLocalEcho { if (consumed > 0) { const matched = this.pending.splice(0, consumed); const predictedCount = matched.filter((item) => item.predicted).length; - return data.slice(predictedCount); + const rollback = consumed < data.length ? this.rollback() : ""; + return rollback + data.slice(predictedCount); } return this.rollback() + data; diff --git a/plugins/ssh-terminal/tests/frontend/lib/terminal-local-echo.test.ts b/plugins/ssh-terminal/tests/frontend/lib/terminal-local-echo.test.ts index fb7e1e7b6..cff65fbb5 100644 --- a/plugins/ssh-terminal/tests/frontend/lib/terminal-local-echo.test.ts +++ b/plugins/ssh-terminal/tests/frontend/lib/terminal-local-echo.test.ts @@ -62,6 +62,37 @@ describe("TerminalLocalEcho", () => { expect(echo.handleInput("c")).toBe("c"); }); + it("waits for outstanding unpredicted input before enabling auto prediction", () => { + let now = 0; + const echo = new TerminalLocalEcho("auto", () => now, 100); + expect(echo.handleInput("d")).toBe(""); + now = 150; + expect(echo.handleOutput("d")).toBe("d"); + expect(echo.handleInput("o")).toBe(""); + expect(echo.handleInput("c")).toBe(""); + now = 300; + expect(echo.handleOutput("o")).toBe("o"); + expect(echo.handleInput("k")).toBe(""); + expect(echo.handleOutput("ck")).toBe("ck"); + expect(echo.handleInput("e")).toBe("e"); + expect(echo.handleInput("r")).toBe("r"); + expect(echo.handleOutput("er")).toBe(""); + }); + + it("rolls back the unmatched suffix after a partially matching output chunk", () => { + const echo = new TerminalLocalEcho("on"); + for (const character of "abc") echo.handleInput(character); + expect(echo.handleOutput("abZ")).toBe("\x1b[1D\x1b[1XZ"); + expect(echo.handleOutput("c")).toBe("c"); + }); + + it("keeps pending predictions across an empty output chunk", () => { + const echo = new TerminalLocalEcho("on"); + expect(echo.handleInput("a")).toBe("a"); + expect(echo.handleOutput("")).toBe(""); + expect(echo.handleOutput("a")).toBe(""); + }); + it("uses an explicit host mode before the global mode", () => { expect(resolveLocalEchoMode("off", "on")).toBe("off"); expect(resolveLocalEchoMode("default", "on")).toBe("on"); diff --git a/plugins/ssh-terminal/tests/frontend/lib/terminal-local-echo.xterm.test.ts b/plugins/ssh-terminal/tests/frontend/lib/terminal-local-echo.xterm.test.ts new file mode 100644 index 000000000..2a6914918 --- /dev/null +++ b/plugins/ssh-terminal/tests/frontend/lib/terminal-local-echo.xterm.test.ts @@ -0,0 +1,53 @@ +import { Terminal } from "@xterm/xterm"; +import { afterEach, describe, expect, it } from "vitest"; +import { TerminalLocalEcho } from "../../../src/frontend/lib/terminal-local-echo"; + +let terminal: Terminal | undefined; +afterEach(() => terminal?.dispose()); + +const write = (data: string) => + new Promise((resolve) => terminal!.write(data, resolve)); + +function line() { + return terminal!.buffer.active.getLine(0)?.translateToString(true); +} + +describe("local echo with xterm", () => { + it("keeps fast typing in order when auto prediction activates with input in flight", async () => { + terminal = new Terminal({ cols: 80, rows: 5 }); + let now = 0; + const echo = new TerminalLocalEcho("auto", () => now, 100); + const input = async (data: string) => { + for (const character of data) await write(echo.handleInput(character)); + }; + await input("d"); + now = 150; + await write(echo.handleOutput("d")); + await input("oc"); + now = 300; + await write(echo.handleOutput("o")); + await input("k"); + await write(echo.handleOutput("ck")); + await input("er ps"); + await write(echo.handleOutput("er ps")); + expect(line()).toBe("docker ps"); + expect(terminal.buffer.active.cursorX).toBe(9); + }); + + it.each(["abZ", "ab\r\n$ "])( + "reconciles a matching prefix followed by remote output %j", + async (output) => { + terminal = new Terminal({ cols: 80, rows: 5 }); + const echo = new TerminalLocalEcho("on"); + for (const character of "abc") await write(echo.handleInput(character)); + await write(echo.handleOutput(output)); + expect(line()).toBe(output.startsWith("abZ") ? "abZ" : "ab"); + if (output.includes("\n")) { + expect(terminal.buffer.active.getLine(1)?.translateToString(true)).toBe( + "$ ", + ); + expect(terminal.buffer.active.cursorX).toBe(2); + } + }, + ); +});