diff --git a/src/tui/tui-pty-harness.e2e.test.ts b/src/tui/tui-pty-harness.e2e.test.ts index cde0d2103313..dabc249365f4 100644 --- a/src/tui/tui-pty-harness.e2e.test.ts +++ b/src/tui/tui-pty-harness.e2e.test.ts @@ -345,6 +345,22 @@ describe.sequential("TUI PTY harness", () => { STARTUP_TEST_TIMEOUT_MS, ); + it( + "preserves consecutive backspaces received in the same terminal input chunk", + async () => { + await fixture.run.write("abc\x7f\x7f\r", { delay: false }); + + const sent = await fixture.waitForLogEntry( + (entry) => + entry.method === "sendChat" && + (objectFieldEquals(entry, "message", "a") || objectFieldEquals(entry, "message", "ab")), + ); + expect(sent.payload).toMatchObject({ message: "a" }); + await fixture.run.waitForOutput("PTY_RESPONSE: a"); + }, + TEST_TIMEOUT_MS, + ); + it( "deletes forward with Ctrl+D without exiting a nonempty terminal editor", async () => { diff --git a/src/tui/tui.test.ts b/src/tui/tui.test.ts index 92abcc212aa2..e1cc8d893b11 100644 --- a/src/tui/tui.test.ts +++ b/src/tui/tui.test.ts @@ -338,6 +338,18 @@ describe("resolveGatewayDisconnectState", () => { }); describe("createBackspaceDeduper", () => { + function withLegacyBackspaceEnv(fn: () => T): T { + return withEnv( + { + WT_SESSION: undefined, + SSH_CONNECTION: undefined, + SSH_CLIENT: undefined, + SSH_TTY: undefined, + }, + fn, + ); + } + function createTimedDedupe(start = 1000) { let now = start; const dedupe = createBackspaceDeduper({ @@ -353,27 +365,114 @@ describe("createBackspaceDeduper", () => { } it("suppresses duplicate backspace events within the dedupe window", () => { - const { dedupe, advance } = createTimedDedupe(); + withLegacyBackspaceEnv(() => { + const { dedupe, advance } = createTimedDedupe(); - expect(dedupe("\x7f")).toBe("\x7f"); - advance(1); - expect(dedupe("\x08")).toBe(""); + expect(dedupe("\x7f")).toBe("\x7f"); + advance(1); + expect(dedupe("\x08")).toBe(""); + }); }); it("preserves backspace events outside the dedupe window", () => { - const { dedupe, advance } = createTimedDedupe(); + withLegacyBackspaceEnv(() => { + const { dedupe, advance } = createTimedDedupe(); - expect(dedupe("\x7f")).toBe("\x7f"); - advance(10); - expect(dedupe("\x7f")).toBe("\x7f"); + expect(dedupe("\x7f")).toBe("\x7f"); + advance(10); + expect(dedupe("\x7f")).toBe("\x7f"); + }); }); it("treats ASCII BS as backspace when it is the first event", () => { - const { dedupe, advance } = createTimedDedupe(); + withLegacyBackspaceEnv(() => { + const { dedupe, advance } = createTimedDedupe(); - expect(dedupe("\x08")).toBe("\x08"); - advance(1); - expect(dedupe("\x7f")).toBe(""); + expect(dedupe("\x08")).toBe("\x08"); + advance(1); + expect(dedupe("\x7f")).toBe(""); + }); + }); + + it.each([ + { + name: "consecutive DEL events", + input: ["\x7f", "\x7f"], + expected: ["\x7f", "\x7f"], + }, + { + name: "consecutive ASCII BS events", + input: ["\x08", "\x08"], + expected: ["\x08", "\x08"], + }, + { + name: "an intervening printable key", + input: ["\x7f", "a", "\x08"], + expected: ["\x7f", "a", "\x08"], + }, + { + name: "Kitty backspace press, repeat, and release events", + input: ["\x1b[127;1u", "\x1b[127;1:2u", "\x1b[127;1:3u"], + expected: ["\x1b[127;1u", "\x1b[127;1:2u", "\x1b[127;1:3u"], + }, + { + name: "bracketed paste between legacy backspaces", + input: ["\x7f", "\x1b[200~\x08\x1b[201~", "\x08"], + expected: ["\x7f", "\x1b[200~\x08\x1b[201~", "\x08"], + }, + { + name: "independently repeated complementary legacy pairs", + input: ["\x7f", "\x08", "\x7f", "\x08"], + expected: ["\x7f", "", "\x7f", ""], + }, + ])("handles $name", ({ input, expected }) => { + withLegacyBackspaceEnv(() => { + const { dedupe } = createTimedDedupe(); + + expect(input.map(dedupe)).toEqual(expected); + }); + }); + + it("preserves complementary legacy events outside the dedupe window", () => { + withLegacyBackspaceEnv(() => { + const { dedupe, advance } = createTimedDedupe(); + + expect(dedupe("\x7f")).toBe("\x7f"); + advance(10); + expect(dedupe("\x08")).toBe("\x08"); + }); + }); + + it("preserves Ctrl+Backspace in Windows Terminal", () => { + withEnv( + { + WT_SESSION: "openclaw-tui-test", + SSH_CONNECTION: undefined, + SSH_CLIENT: undefined, + SSH_TTY: undefined, + }, + () => { + const { dedupe } = createTimedDedupe(); + + expect(["\x7f", "\x08", "\x7f"].map(dedupe)).toEqual(["\x7f", "\x08", "\x7f"]); + }, + ); + }); + + it("still deduplicates legacy backspace through an SSH session in Windows Terminal", () => { + withEnv( + { + WT_SESSION: "openclaw-tui-test", + SSH_CONNECTION: "192.0.2.10 12345 192.0.2.20 22", + SSH_CLIENT: undefined, + SSH_TTY: undefined, + }, + () => { + const { dedupe } = createTimedDedupe(); + + expect(["\x7f", "\x08"].map(dedupe)).toEqual(["\x7f", ""]); + }, + ); }); it("never suppresses non-backspace keys", () => { diff --git a/src/tui/tui.ts b/src/tui/tui.ts index 74b5df6b170d..44e7f4808d29 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -7,7 +7,6 @@ import { fileURLToPath } from "node:url"; import { CombinedAutocompleteProvider, Container, - Key, Loader, matchesKey, ProcessTerminal, @@ -259,18 +258,21 @@ export function resolveGatewayDisconnectState(reason?: string): { export function createBackspaceDeduper(params?: { dedupeWindowMs?: number; now?: () => number }) { const dedupeWindowMs = Math.max(0, Math.floor(params?.dedupeWindowMs ?? 8)); const now = params?.now ?? (() => Date.now()); - let lastBackspaceAt = -1; + let previousBackspace: { data: string; at: number } | undefined; return (data: string): string => { - if (data !== "\x08" && !matchesKey(data, Key.backspace)) { + if ((data !== "\x08" && data !== "\x7f") || !matchesKey(data, "backspace")) { + previousBackspace = undefined; return data; } - const ts = now(); - if (lastBackspaceAt >= 0 && ts - lastBackspaceAt <= dedupeWindowMs) { - return ""; - } - lastBackspaceAt = ts; - return data; + const at = now(); + // SSH can emit both legacy encodings for one press; matching bytes are real repeats. + const isDuplicate = + previousBackspace !== undefined && + previousBackspace.data !== data && + at - previousBackspace.at <= dedupeWindowMs; + previousBackspace = isDuplicate ? undefined : { data, at }; + return isDuplicate ? "" : data; }; }