diff --git a/web/src/lib/pty-mobile-input.test.ts b/web/src/lib/pty-mobile-input.test.ts index 1b436943902..85ba22007ca 100644 --- a/web/src/lib/pty-mobile-input.test.ts +++ b/web/src/lib/pty-mobile-input.test.ts @@ -67,4 +67,25 @@ describe("updatePtyInputLine", () => { expect(updatePtyInputLine("abc", "\x7f")).toBe("ab"); expect(updatePtyInputLine("abc", "\r")).toBe(""); }); + + it("resets tracking on escape sequences instead of appending their payload", () => { + // Left-arrow arrives as one CSI chunk; the tracker cannot model cursor + // moves, so it must disarm rather than record "hello[D". + expect(updatePtyInputLine("hello", "\x1b[D")).toBe(""); + expect(updatePtyInputLine("hello", "\x1b[H")).toBe(""); + expect(updatePtyInputLine("hello", "\x1bOP")).toBe(""); + }); +}); + +describe("normalizePtyMobileInput after cursor movement", () => { + it("does not emit a replacement against a tracker reset by arrow keys", () => { + // Simulate: type "hello my name is kain", press left-arrow, then a + // Gboard suggestion arrives. The tracker reset means no replacement + // heuristic can fire against a stale line snapshot. + const afterArrow = updatePtyInputLine("hello my name is kain", "\x1b[D"); + const result = normalizePtyMobileInput("Kain", afterArrow, true); + + expect(result.normalized).toBe(false); + expect(result.data).toBe("Kain"); + }); }); diff --git a/web/src/lib/pty-mobile-input.ts b/web/src/lib/pty-mobile-input.ts index a4991ad6ec2..aaee4c88cbc 100644 --- a/web/src/lib/pty-mobile-input.ts +++ b/web/src/lib/pty-mobile-input.ts @@ -76,6 +76,15 @@ export function shouldTreatInputAsMobileReplacement( } export function updatePtyInputLine(currentLine: string, data: string): string { + // Escape sequences (arrow keys, home/end, function keys, paste guards) + // move the cursor or edit the line in ways this flat tracker cannot + // model — and the per-char loop below would append their printable + // payload (e.g. the "[D" of a left-arrow) as if it were typed text. + // Reset instead: an unknown cursor position must disarm replacement + // normalization until the user starts a fresh, cleanly-tracked line. + if (data.includes("\x1b")) { + return ""; + } let next = currentLine; for (const ch of chars(data)) { if (ch === "\r" || ch === "\n") { diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 7a133227c39..9d7ef5c149e 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -940,6 +940,13 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { // eslint-disable-next-line no-control-regex -- intentional ESC byte in xterm SGR mouse report parser const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/; onDataDisposable = term.onData((data) => { + // Mouse reports (scroll wheel etc.) are not typed input — swallow + // them before the blocked-input check so scrolling a disconnected + // terminal doesn't trip the "reconnecting" notice. + if (SGR_MOUSE_RE.test(data)) { + return; + } + if ( ws.readyState !== WebSocket.OPEN || shouldBlockPtyInput(ptyStateRef.current) @@ -953,10 +960,6 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { return; } - if (SGR_MOUSE_RE.test(data)) { - return; - } - const normalized = normalizePtyMobileInput( data, ptyInputLineRef.current,