diff --git a/CHANGELOG.md b/CHANGELOG.md index 79333b24619..9352bcc3312 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,7 @@ Docs: https://docs.openclaw.ai - Gateway/Pairing/CLI: align read-scope compatibility in pairing/device-token checks and add local `openclaw devices` fallback recovery for loopback `pairing required` deadlocks, with explicit fallback notice to unblock approval bootstrap flows. (#21616) Thanks @shakkernerd. - CLI/Pairing: default `pairing list` and `pairing approve` to the sole available pairing channel when omitted, so TUI-only setups can recover from `pairing required` without guessing channel arguments. (#21527) Thanks @losts1. - TUI/Pairing: show explicit pairing-required recovery guidance after gateway disconnects that return `pairing required`, including approval steps to unblock quickstart TUI hatching on fresh installs. (#21841) Thanks @nicolinux. +- TUI/Input: suppress duplicate backspace events arriving in the same input burst window so SSH sessions no longer delete two characters per backspace press in the composer. (#19318) Thanks @eheimer. - Auth/Onboarding: align OAuth profile-id config mapping with stored credential IDs for OpenAI Codex and Chutes flows, preventing `provider:default` mismatches when OAuth returns email-scoped credentials. (#12692) thanks @mudrii. - Docker: pin base images to SHA256 digests in Docker builds to prevent mutable tag drift. (#7734) Thanks @coygeek. - Docker/Security: run E2E and install-sh test images as non-root by adding appuser directives. Thanks @thewilloftheshadow. diff --git a/src/tui/tui.test.ts b/src/tui/tui.test.ts index 056cbc881c6..2ba2ba6ef0c 100644 --- a/src/tui/tui.test.ts +++ b/src/tui/tui.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { getSlashCommands, parseCommand } from "./commands.js"; import { + createBackspaceDeduper, resolveFinalAssistantText, resolveGatewayDisconnectState, resolveTuiSessionKey, @@ -87,3 +88,35 @@ describe("resolveGatewayDisconnectState", () => { expect(state.pairingHint).toBeUndefined(); }); }); + +describe("createBackspaceDeduper", () => { + it("suppresses duplicate backspace events within the dedupe window", () => { + let now = 1000; + const dedupe = createBackspaceDeduper({ + dedupeWindowMs: 8, + now: () => now, + }); + + expect(dedupe("\x7f")).toBe("\x7f"); + now += 1; + expect(dedupe("\x08")).toBe(""); + }); + + it("preserves backspace events outside the dedupe window", () => { + let now = 1000; + const dedupe = createBackspaceDeduper({ + dedupeWindowMs: 8, + now: () => now, + }); + + expect(dedupe("\x7f")).toBe("\x7f"); + now += 10; + expect(dedupe("\x7f")).toBe("\x7f"); + }); + + it("never suppresses non-backspace keys", () => { + const dedupe = createBackspaceDeduper(); + expect(dedupe("a")).toBe("a"); + expect(dedupe("\x1b[A")).toBe("\x1b[A"); + }); +}); diff --git a/src/tui/tui.ts b/src/tui/tui.ts index 43743ce2ae9..580876242ab 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -1,7 +1,9 @@ import { CombinedAutocompleteProvider, Container, + Key, Loader, + matchesKey, ProcessTerminal, Text, TUI, @@ -215,6 +217,24 @@ 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; + + return (data: string): string => { + if (!matchesKey(data, Key.backspace)) { + return data; + } + const ts = now(); + if (lastBackspaceAt >= 0 && ts - lastBackspaceAt <= dedupeWindowMs) { + return ""; + } + lastBackspaceAt = ts; + return data; + }; +} + export async function runTui(opts: TuiOptions) { const config = loadConfig(); const initialSessionInput = (opts.session ?? "").trim(); @@ -395,6 +415,14 @@ export async function runTui(opts: TuiOptions) { }); const tui = new TUI(new ProcessTerminal()); + const dedupeBackspace = createBackspaceDeduper(); + tui.addInputListener((data) => { + const next = dedupeBackspace(data); + if (next.length === 0) { + return { consume: true }; + } + return { data: next }; + }); const header = new Text("", 1, 0); const statusContainer = new Container(); const footer = new Text("", 1, 0);