Skip to content

Commit efe6671

Browse files
AI Agentclaude
andcommitted
fix(web): block DECRQM/DECRQSS replies and focus/mouse event leaks in 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>
1 parent 664f237 commit efe6671

3 files changed

Lines changed: 359 additions & 82 deletions

File tree

Lines changed: 101 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,103 @@
11
# Terminal query suppression experiment
22

3-
## Issue (GitHub #271)
4-
5-
The web terminal renders raw escape sequences such as
6-
`^[]10;rgb:f4f4/f7f7/fbfb^[\` and `^[[?1;2c` inside Claude Code's
7-
prompt area, which makes navigation and rendering look broken.
8-
9-
## Root cause
10-
11-
TUI applications (Claude Code, Ultraplan, etc.) probe the terminal with
12-
queries like:
13-
14-
- `\x1b]10;?\x1b\\` – ask for the foreground color (OSC 10).
15-
- `\x1b]11;?\x1b\\` – ask for the background color (OSC 11).
16-
- `\x1b]12;?\x1b\\` – ask for the cursor color (OSC 12).
17-
- `\x1b]4;<n>;?\x1b\\` – ask for an indexed palette color (OSC 4).
18-
- `\x1b[c` – primary device attributes query (DA1).
19-
- `\x1b[>c` – secondary device attributes (DA2).
20-
- `\x1b[=c` – tertiary device attributes (DA3).
21-
- `\x1b[6n` – cursor position report (CPR).
22-
23-
`xterm.js@5.3.0` responds to all of those out-of-the-box. Because the
24-
web terminal is fronted by `xterm.js`, the responses are emitted via
25-
`Terminal.onData` and we forward them to the host PTY as user input.
26-
Claude Code receives these bytes as keystrokes inside its prompt loop
27-
and renders them verbatim, which is exactly what the screenshot in the
28-
issue shows.
29-
30-
## Fix
31-
32-
We install a small parser shim immediately after instantiating the
33-
`Terminal` (see `terminal-query-suppression.ts`):
34-
35-
- For `OSC 4/10/11/12` we intercept the handler chain. If the payload
36-
contains a `?` segment (query), we return `true` to consume the
37-
sequence without invoking xterm's default handler that would
38-
otherwise reply. Plain "set color" payloads return `false` so the
39-
default handler still applies the requested theme change.
40-
- For DA1/DA2/DA3 (`CSI ... c`) and CPR (`CSI ... n`) we always
41-
return `true` so xterm never reports back to the PTY. None of the
42-
features that depend on those responses are useful for our headless
43-
web frontend.
44-
45-
The handlers are returned as disposables so callers (and the unit
46-
tests) can roll the registration back without touching `xterm`'s
47-
internal parser state.
48-
49-
## Manual reproduction notes
50-
51-
1. Start the web build (`bun run docker-git -- browser`) and open the
52-
web terminal.
53-
2. Inside the container run a TUI that probes color (for example
54-
`bash -c 'printf "\\033]10;?\\033\\\\"'`).
55-
3. Without the fix the printed escape sequence is echoed back into the
56-
prompt as garbage. With the fix nothing is echoed.
3+
## Issues addressed
4+
5+
- **#271** — raw `^[]10;rgb:f4f4/f7f7/fbfb^[\` and `^[[?1;2c` echoed into the
6+
Claude Code prompt area.
7+
- **#273** — Claude Code TUI in the web terminal: input rendered on the wrong
8+
row ("asd" below the plan banner), plan-approval menu does not accept
9+
arrow keys, and `` divider lines bleed through the typing area.
10+
11+
Both have the same root cause: xterm.js answers terminal capability probes
12+
through `Terminal.onData`, and the web bridge forwards every `onData` byte
13+
to the PTY as if the user had typed it. Claude Code (Ink) then renders
14+
those reply bytes verbatim, scrambles its frame buffer, or misroutes
15+
keystrokes through its input parser.
16+
17+
## Probes that leaked in xterm.js@5.3.0
18+
19+
All sources cite xterm.js@5.3.0 `src/common/InputHandler.ts`.
20+
21+
### Active leaks closed by #271
22+
23+
| Probe | Reply |
24+
|---|---|
25+
| `OSC 4;<n>;? ST`, `OSC 10;?`, `OSC 11;?`, `OSC 12;?` color queries | indexed/foreground/background/cursor color |
26+
| `CSI c`, `CSI > c`, `CSI = c` device attributes (DA1/DA2/DA3) | `CSI ?...c` |
27+
| `CSI n`, `CSI ? n` device status / cursor position report (DSR/CPR) | `CSI ...n` |
28+
29+
### Additional active leaks closed by this change (for #273)
30+
31+
| Probe | Source in xterm.js@5.3.0 | Reply |
32+
|---|---|---|
33+
| `CSI Pm $ p` — DECRQM ANSI form | `InputHandler.ts:267` (`requestMode`) | `CSI Ps;Pm$y` |
34+
| `CSI ? Pm $ p` — DECRQM private form (includes Ink's `?2026$p` synchronized-output probe) | `InputHandler.ts:268` (`requestMode`) | `CSI ?Ps;Pm$y` |
35+
| `DCS $ q ... ST` — DECRQSS | `InputHandler.ts:381` (`requestStatusString`) | `DCS P{0,1}$r... ST` |
36+
37+
### Indirect leaks (state-triggered output) closed by this change
38+
39+
| Mode | Reason | Symptom in #273 |
40+
|---|---|---|
41+
| `CSI ? 1004 h` (focus reporting) | every DOM focus/blur pumps `CSI I` / `CSI O` into the PTY | cursor jumps, "asd" rendered one row below the plan banner, plan-approval menu mis-decodes input |
42+
| `CSI ? 1000 h` / `1002 h` / `1003 h` / `1006 h` / `1015 h` / `1016 h` (mouse tracking) | every mouse event pumps coordinate bytes into the PTY | Ink frame buffer corruption, e.g. `` divider bleeds through typing line |
43+
44+
### Defense-in-depth (no leak today, prevents regressions)
45+
46+
| Probe | Notes |
47+
|---|---|
48+
| `CSI > q` — XTVERSION | not in 5.3.0; xterm.js master always replies `DCS > \| xterm.js(version) ST` |
49+
| `CSI Pm t` — window manipulation | gated by `windowOptions` (off by default in our build); explicit suppressor prevents accidental future enable |
50+
| `DCS + q ... ST` — XTGETTCAP | not registered in 5.3.0; cheap to pre-empt |
51+
52+
## Modes intentionally left to fall through
53+
54+
These DEC private modes are useful and must continue to reach xterm's
55+
built-in `h`/`l` setters:
56+
57+
- `25` — cursor visibility
58+
- `1007` — alternate scroll (changes wheel semantics; no reply)
59+
- `1049` — alternate screen buffer (Claude Code does not use it today but
60+
other TUIs do)
61+
- `2004` — bracketed paste (xterm.js wraps actual paste bytes; we want this)
62+
- `2026` — synchronized output (Ink wraps every frame in BSU/ESU; xterm.js
63+
≥5.3 batches the writes)
64+
65+
The selective `CSI ? h` / `CSI ? l` handler returns `true` (suppress) only
66+
when one of the seven suppressed mouse/focus modes is in the parameter
67+
list, and `false` (fall through) for every other parameter.
68+
69+
## Implementation
70+
71+
`packages/app/src/web/terminal-query-suppression.ts` registers handlers via
72+
`Terminal.parser.registerOscHandler` / `registerCsiHandler` /
73+
`registerDcsHandler` directly after constructing the `Terminal`. Each
74+
returns a disposable; the install function aggregates them so callers can
75+
roll back the registrations cleanly (used by the unit tests in
76+
`packages/app/tests/docker-git/terminal-query-suppression.test.ts`).
77+
78+
The selective DEC-private-mode handler iterates the params list (handling
79+
both flat `(number | number[])[]` shapes that xterm.js emits) and short-
80+
circuits as soon as a suppressed mode number is found. Sub-parameters
81+
(`number[]`) are reduced to their head, which matches DECSET/DECRST
82+
semantics (sub-params on these modes are vendor-specific extensions).
83+
84+
## Manual reproduction (unchanged from #271 notes)
85+
86+
1. `bun run docker-git -- browser` and open the web terminal.
87+
2. Run a TUI that probes capabilities, e.g. `claude` or
88+
`bash -c 'printf "\\033[?2026\$p"'` (synchronized-output DECRQM).
89+
3. Without the fix, reply bytes leak into the prompt and the TUI either
90+
echoes them or mis-decodes them as keystrokes. With the fix, nothing
91+
leaks and Ink rendering stays stable across browser focus/blur.
92+
93+
## Verification
94+
95+
- `bun x vitest run tests/docker-git/terminal-query-suppression.test.ts`
96+
— 15/15 tests pass (color query detection, every probe identifier
97+
registered, all suppressed modes consumed, all benign DEC private modes
98+
pass through, disposal closes every handler).
99+
- `bun run typecheck` — clean.
100+
- `vibecode-linter src/web/terminal-query-suppression.ts tests/docker-git/terminal-query-suppression.test.ts`
101+
— 0 errors, 0 warnings, 0 duplicates.
102+
- `eslint --config eslint.effect-ts-check.config.mjs ...` — clean on both
103+
files.

packages/app/src/web/terminal-query-suppression.ts

Lines changed: 97 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,55 @@ export type TerminalQuerySuppression = { readonly dispose: () => void }
22

33
type Disposable = { readonly dispose: () => void }
44

5-
type CsiIdentifier = { readonly final: string; readonly prefix?: string }
5+
type FunctionIdentifier = {
6+
readonly final: string
7+
readonly intermediates?: string
8+
readonly prefix?: string
9+
}
10+
11+
type CsiParam = number | ReadonlyArray<number>
12+
13+
type CsiParams = ReadonlyArray<CsiParam>
614

715
export type TerminalQuerySuppressionTarget = {
816
readonly parser: {
17+
readonly registerCsiHandler: (
18+
id: FunctionIdentifier,
19+
callback: (params: CsiParams) => boolean
20+
) => Disposable
21+
readonly registerDcsHandler: (
22+
id: FunctionIdentifier,
23+
callback: (data: string, params: CsiParams) => boolean
24+
) => Disposable
925
readonly registerOscHandler: (
1026
ident: number,
1127
callback: (data: string) => boolean
1228
) => Disposable
13-
readonly registerCsiHandler: (
14-
id: CsiIdentifier,
15-
callback: () => boolean
16-
) => Disposable
1729
}
1830
}
1931

32+
// DEC private modes whose `h`/`l` setter causes xterm.js to start emitting
33+
// unsolicited reply bytes back through `onData` on later DOM events:
34+
// 1000/1002/1003/1006/1015/1016 — mouse tracking (mouse events -> bytes)
35+
// 1004 — focus reporting (focus/blur -> CSI I/O)
36+
// Suppressing the SET (`h`) leaves xterm.js in the default state (no event
37+
// emission); suppressing the RESET (`l`) is harmless and kept for symmetry.
38+
// Modes intentionally left to fall through to xterm's built-in handlers:
39+
// 25 — cursor visibility
40+
// 1049 — alternate screen buffer
41+
// 2004 — bracketed paste
42+
// 2026 — synchronized output (Ink uses BSU/ESU around every frame)
43+
// 1007 — alternate scroll (only changes wheel semantics, no leak)
44+
const SUPPRESSED_PRIVATE_MODES: ReadonlySet<number> = new Set([
45+
1000,
46+
1002,
47+
1003,
48+
1004,
49+
1006,
50+
1015,
51+
1016
52+
])
53+
2054
const isColorQuery = (data: string): boolean => {
2155
for (const segment of data.split(";")) {
2256
if (segment === "?") {
@@ -26,29 +60,83 @@ const isColorQuery = (data: string): boolean => {
2660
return false
2761
}
2862

63+
const extractParam = (param: CsiParam): number | null => {
64+
if (typeof param === "number") {
65+
return param
66+
}
67+
const head = param[0]
68+
return typeof head === "number" ? head : null
69+
}
70+
71+
const containsSuppressedPrivateMode = (params: CsiParams): boolean => {
72+
for (const param of params) {
73+
const value = extractParam(param)
74+
if (value !== null && SUPPRESSED_PRIVATE_MODES.has(value)) {
75+
return true
76+
}
77+
}
78+
return false
79+
}
80+
2981
const registerOscColorQuerySuppressor = (
3082
terminal: TerminalQuerySuppressionTarget,
3183
identifier: number
3284
): Disposable => terminal.parser.registerOscHandler(identifier, (data) => isColorQuery(data))
3385

3486
const registerCsiSuppressor = (
3587
terminal: TerminalQuerySuppressionTarget,
36-
identifier: CsiIdentifier
88+
identifier: FunctionIdentifier
3789
): Disposable => terminal.parser.registerCsiHandler(identifier, () => true)
3890

91+
const registerDcsSuppressor = (
92+
terminal: TerminalQuerySuppressionTarget,
93+
identifier: FunctionIdentifier
94+
): Disposable => terminal.parser.registerDcsHandler(identifier, () => true)
95+
96+
const registerSelectivePrivateModeSuppressor = (
97+
terminal: TerminalQuerySuppressionTarget,
98+
final: "h" | "l"
99+
): Disposable =>
100+
terminal.parser.registerCsiHandler(
101+
{ final, prefix: "?" },
102+
(params) => containsSuppressedPrivateMode(params)
103+
)
104+
39105
export const installTerminalQuerySuppression = (
40106
terminal: TerminalQuerySuppressionTarget
41107
): TerminalQuerySuppression => {
42108
const disposables: ReadonlyArray<Disposable> = [
109+
// OSC 4/10/11/12 — color queries (`?` payload). Set-color payloads fall through.
43110
registerOscColorQuerySuppressor(terminal, 4),
44111
registerOscColorQuerySuppressor(terminal, 10),
45112
registerOscColorQuerySuppressor(terminal, 11),
46113
registerOscColorQuerySuppressor(terminal, 12),
114+
// CSI c / > c / = c — primary/secondary/tertiary device attributes (DA1/DA2/DA3).
47115
registerCsiSuppressor(terminal, { final: "c" }),
48116
registerCsiSuppressor(terminal, { final: "c", prefix: ">" }),
49117
registerCsiSuppressor(terminal, { final: "c", prefix: "=" }),
118+
// CSI n / ? n — device status report and cursor position report.
50119
registerCsiSuppressor(terminal, { final: "n" }),
51-
registerCsiSuppressor(terminal, { final: "n", prefix: "?" })
120+
registerCsiSuppressor(terminal, { final: "n", prefix: "?" }),
121+
// CSI $ p / CSI ? $ p — DECRQM (ANSI and DEC private forms). xterm.js always
122+
// replies via `requestMode`, including for the `?2026 $p` synchronized-output
123+
// probe that Ink emits during startup.
124+
registerCsiSuppressor(terminal, { final: "p", intermediates: "$" }),
125+
registerCsiSuppressor(terminal, { final: "p", intermediates: "$", prefix: "?" }),
126+
// DCS $ q ... ST — DECRQSS. xterm.js always replies via `requestStatusString`.
127+
registerDcsSuppressor(terminal, { final: "q", intermediates: "$" }),
128+
// DCS + q ... ST — XTGETTCAP. No reply in 5.3.0; suppressed for defense-in-depth.
129+
registerDcsSuppressor(terminal, { final: "q", intermediates: "+" }),
130+
// CSI > q — XTVERSION. Not in 5.3.0 but auto-replies in xterm.js master.
131+
registerCsiSuppressor(terminal, { final: "q", prefix: ">" }),
132+
// CSI Pm t — window manipulation. Gated by `windowOptions` (off by default);
133+
// suppressed so an accidental future enable does not leak size reports.
134+
registerCsiSuppressor(terminal, { final: "t" }),
135+
// CSI ? h / CSI ? l — block xterm from enabling focus reporting and mouse
136+
// tracking modes that would later pump unsolicited bytes back through onData.
137+
// Other DEC private modes fall through to xterm's built-in setters.
138+
registerSelectivePrivateModeSuppressor(terminal, "h"),
139+
registerSelectivePrivateModeSuppressor(terminal, "l")
52140
]
53141
return {
54142
dispose: () => {
@@ -60,3 +148,5 @@ export const installTerminalQuerySuppression = (
60148
}
61149

62150
export const isTerminalColorQuery = isColorQuery
151+
152+
export const isSuppressedDecPrivateMode = (mode: number): boolean => SUPPRESSED_PRIVATE_MODES.has(mode)

0 commit comments

Comments
 (0)