Skip to content

fix(web): block DECRQM/DECRQSS replies and focus/mouse event leaks in xterm.js#277

Merged
skulidropek merged 2 commits into
mainfrom
issue-273
May 12, 2026
Merged

fix(web): block DECRQM/DECRQSS replies and focus/mouse event leaks in xterm.js#277
skulidropek merged 2 commits into
mainfrom
issue-273

Conversation

@skulidropek
Copy link
Copy Markdown
Member

Closes #273.

Problem

Claude Code's TUI in the web terminal exhibited three visible symptoms:

  1. Input renders on the wrong row — the user typed asd but it appeared below the plan-mode banner instead of inside the prompt area.
  2. Plan-approval menu does not accept arrow keys — the user can see the four options (Yes, and bypass permissionsTell Claude what to change) but cannot navigate or confirm.
  3. Horizontal divider bleeds across the typing line, producing a strikethrough-like artifact through user input.

Root cause

xterm.js@5.3.0 auto-answers several terminal capability probes by emitting reply bytes through `Terminal.onData`. Our web bridge forwards every `onData` byte to the PTY as if the user had typed it. After #271 closed OSC color queries and DA/DSR/CPR replies, four leak channels remained:

Leak Where in xterm.js@5.3.0 What Claude Code/Ink sends
`CSI $ p` / `CSI ? $ p` (DECRQM) `InputHandler.ts:267-268` always replies via `requestMode` Ink emits `CSI ?2026$p` to detect synchronized output
`DCS $ q ... ST` (DECRQSS) `InputHandler.ts:381` always replies via `requestStatusString` various TUIs use it for capability detection
`CSI ?1004 h` (focus reporting) setter flips `sendFocus = true`; every DOM focus/blur then pumps `^[[I` / `^[[O` into the PTY Claude Code enables 1004 at startup
`CSI ?1000/1002/1003/1006/...` (mouse tracking) setters flip mouse modes; every mouse event pumps coordinate bytes Claude Code enables 1000 + 1006

The focus-reporting leak is the direct cause of symptoms (1) and (2): every browser focus/blur (and the tiny one that happens whenever the user clicks into the terminal) injects `^[[I` / `^[[O` into Ink's input stream, which misroutes keystrokes through the alt-key parser and shifts the cursor. The mouse-tracking leak corrupts Ink's frame buffer (symptom 3).

Fix

Extend `packages/app/src/web/terminal-query-suppression.ts` to register additional parser hooks immediately after constructing the `Terminal`:

  • CSI $ p and CSI ? $ p — DECRQM (ANSI + private). Always consume.
  • DCS $ q ... ST — DECRQSS. Always consume.
  • DCS + q ... ST — XTGETTCAP. Defense-in-depth.
  • CSI > q — XTVERSION. Future-proof for xterm.js > 5.3.
  • CSI Pm t — window manipulation. Gated by `windowOptions` today; explicit suppressor prevents accidental future enable.
  • CSI ? h / CSI ? l — selective handler that returns `true` only when the parameter list contains a suppressed mode (`1000, 1002, 1003, 1004, 1006, 1015, 1016`), and `false` for everything else. Modes `25`, `1007`, `1049`, `2004`, `2026` continue to fall through to xterm.js's built-in setters.

Why these modes and not others

Mode Action Rationale
25 (cursor visibility) pass through needed for cursor hide/show during Ink frames
1004 (focus reporting) suppress unsolicited `CSI I` / `CSI O` on focus/blur
1000/1002/1003/1006/1015/1016 (mouse) suppress unsolicited mouse-coordinate bytes
1007 (alt scroll) pass through only changes wheel semantics, no reply
1049 (alt screen) pass through required for TUIs that opt into alt buffer
2004 (bracketed paste) pass through xterm.js wraps paste bytes correctly
2026 (synchronized output) pass through Ink wraps every frame in BSU/ESU; xterm.js ≥5.3 batches the writes for smoother rendering

Verification

```
bun x vitest run tests/docker-git/terminal-query-suppression.test.ts
Test Files 1 passed (1)
Tests 15 passed (15)

bun run typecheck # clean (exit 0)
vibecode-linter src/web/terminal-query-suppression.ts ... # 0 errors, 0 warnings, 0 duplicates
eslint --config eslint.effect-ts-check.config.mjs ... # clean (exit 0)
```

New tests cover:

  • every CSI/OSC/DCS identifier is registered in the expected order;
  • DECRQM, DECRQSS, XTVERSION, XTGETTCAP, window-manipulation handlers return `true` (consumed);
  • DEC-private-mode SET/RESET return `true` for each of the seven suppressed modes;
  • DEC-private-mode SET/RESET return `false` for `25, 1007, 1049, 2004, 2026` (pass-through);
  • nested sub-parameter shape `[[mode, 0]]` is handled identically to the flat `[mode]` shape;
  • `dispose()` closes every registered handler.

Files changed

  • `packages/app/src/web/terminal-query-suppression.ts` — extend `TerminalQuerySuppressionTarget` with `registerDcsHandler`; add 5 new CSI suppressors, 2 DCS suppressors, and a selective DEC-private-mode handler driven by an explicit allowlist of mode numbers.
  • `packages/app/tests/docker-git/terminal-query-suppression.test.ts` — mock `registerDcsHandler`; cover every new identifier and the selective pass-through behaviour.
  • `experiments/terminal-query-suppression.md` — document the additional probes and the rationale for each mode's pass-through / suppression decision.

🤖 Generated with Claude Code

… xterm.js

xterm.js@5.3.0 auto-replies to several additional terminal capability
probes through Terminal.onData, and the web bridge forwards every
onData byte to the PTY. Building on #271, this closes the remaining
leak sources that broke Claude Code's TUI:

- CSI $ p / CSI ? $ p (DECRQM) — xterm.js always replies via
  requestMode. Ink emits CSI ?2026$p (synchronized-output probe) at
  startup; the reply was rendered as junk inside Claude Code's prompt.
- DCS $ q ... ST (DECRQSS) — xterm.js always replies via
  requestStatusString.
- CSI ?1004 h (focus reporting) — once asserted by Claude Code, every
  DOM focus/blur pumped ^[[I / ^[[O into the PTY. This was the root
  cause of "asd" rendering on the wrong row below the plan banner and
  of the plan-approval menu failing to consume arrow keys.
- CSI ?1000/1002/1003/1006/1015/1016 h (mouse tracking) — every mouse
  event pumped coordinate bytes into the PTY, scrambling Ink's frame
  buffer (visible as the ─ divider bleeding across the typing line).

Implementation uses Terminal.parser.registerCsiHandler /
registerDcsHandler. The CSI ? h / CSI ? l handler is selective:
returns true only for the seven suppressed mouse/focus modes and
false for every other parameter, so 25 (cursor), 1007 (alt scroll),
1049 (alt screen), 2004 (bracketed paste), and 2026 (synchronized
output) keep reaching xterm.js's built-in setters unchanged.

Also added as defense-in-depth: CSI > q (XTVERSION, future-proofs the
xterm.js bump), CSI t (window manipulation, gated by windowOptions
today), DCS + q (XTGETTCAP).

Fixes #273.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Chrome DevTools Issues panel flags the unlabelled <input type='checkbox'>
in the task panel ('A form field element should have an id or name
attribute'). Add 'task-panel-show-system' as both id and name so the
control becomes addressable from form-submission semantics and screen
readers; wire 'htmlFor' on the surrounding label so the click region
stays correct.

Picked from the closed PR #275 (which mis-diagnosed issue #273 as a font
loading race). The probe-leak suppressor in commit efe6671 is the real
fix for #273; this commit only carries the genuinely useful checkbox
hygiene cleanup. terminal-image-paths.ts hunk is a formatter auto-fix
applied by 'bun run lint'.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@skulidropek
Copy link
Copy Markdown
Member Author

skulidropek commented May 12, 2026

AI Session Backup

Commit: 50a3b96
Status: skipped
Message: No session directories found.

git status

On branch issue-273
nothing to commit, working tree clean

@skulidropek skulidropek merged commit 9843e87 into main May 12, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Всё равно терминал багается очень сильно.

1 participant