fix(dashboard): harden PTY input tracker against escape sequences

Two review fixes on the mobile input normalization path:

- updatePtyInputLine appended the printable payload of escape sequences
  (the '[D' of a left-arrow) to the tracked line, and after any cursor
  movement the flat tracker no longer matched the visual line — the
  DELETE-repeat replacement could then be computed against a stale
  snapshot. Any chunk containing ESC now resets the tracker, disarming
  replacement normalization until a cleanly-tracked line starts.
- Move the SGR mouse-report filter ahead of the blocked-input check so
  scrolling a disconnected terminal doesn't print the reconnect notice.
This commit is contained in:
Shannon Sands 2026-07-09 16:06:16 +10:00 committed by kshitij
parent 0b2b08d54c
commit 3e88cae243
3 changed files with 37 additions and 4 deletions

View file

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

View file

@ -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") {

View file

@ -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,