fix(tui): preserve legitimate consecutive backspaces (#117989)

Co-authored-by: Peter Steinberger <steipete@macos.shared>
This commit is contained in:
Peter Steinberger
2026-08-02 06:28:01 -07:00
committed by GitHub
parent 860960e543
commit 3748fcfd2c
3 changed files with 138 additions and 21 deletions

View File

@@ -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 () => {

View File

@@ -338,6 +338,18 @@ describe("resolveGatewayDisconnectState", () => {
});
describe("createBackspaceDeduper", () => {
function withLegacyBackspaceEnv<T>(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", () => {

View File

@@ -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;
};
}