Skip to content

Commit 281a3fc

Browse files
committed
fix(web): suppress xterm.js terminal query auto-responses
xterm.js answers TUI capability probes (OSC 4/10/11/12 color queries, DA1/DA2/DA3 device attributes, DSR/CPR) by emitting reply bytes through onData. Those bytes were forwarded to the PTY as if the user had typed them, so Claude Code rendered the raw escape sequences inside its prompt. Install a parser shim right after constructing the Terminal that consumes color queries (while letting set-color commands fall through) and always consumes device-attribute and cursor-position queries. The shim is returned as a disposable so tests can roll it back. Fixes #271
1 parent 4ac09cf commit 281a3fc

5 files changed

Lines changed: 274 additions & 0 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Terminal query suppression experiment
2+
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.
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<title>Terminal Query Suppression Verification</title>
5+
<link rel="stylesheet" href="/node_modules/xterm/css/xterm.css" />
6+
</head>
7+
<body>
8+
<div id="before-host" style="background:#080a0d;padding:8px;border:1px solid #333"></div>
9+
<h3 style="color:#fff">After suppression installed</h3>
10+
<div id="after-host" style="background:#080a0d;padding:8px;border:1px solid #333"></div>
11+
<pre id="result" style="color:#fff;background:#222;padding:8px"></pre>
12+
<script type="module">
13+
import { Terminal } from "/node_modules/xterm/lib/xterm.js"
14+
import { installTerminalQuerySuppression } from "/packages/app/src/web/terminal-query-suppression.ts"
15+
16+
const collect = (terminal) => {
17+
const collected = []
18+
terminal.onData((data) => { collected.push(data) })
19+
return collected
20+
}
21+
22+
const beforeTerminal = new Terminal()
23+
beforeTerminal.open(document.getElementById("before-host"))
24+
const beforeData = collect(beforeTerminal)
25+
26+
const afterTerminal = new Terminal()
27+
afterTerminal.open(document.getElementById("after-host"))
28+
installTerminalQuerySuppression(afterTerminal)
29+
const afterData = collect(afterTerminal)
30+
31+
const queries = [
32+
"\x1b]10;?\x1b\\",
33+
"\x1b]11;?\x1b\\",
34+
"\x1b]12;?\x1b\\",
35+
"\x1b]4;1;?\x1b\\",
36+
"\x1b[c",
37+
"\x1b[>c",
38+
"\x1b[=c",
39+
"\x1b[6n"
40+
]
41+
42+
let done = 0
43+
queries.forEach((q) => {
44+
beforeTerminal.write(q, () => { done++; render() })
45+
afterTerminal.write(q, () => { done++; render() })
46+
})
47+
48+
function render() {
49+
if (done < queries.length * 2) return
50+
const summary = {
51+
before: beforeData.map((s) => Array.from(s).map((c) => c.charCodeAt(0).toString(16)).join(" ")),
52+
after: afterData.map((s) => Array.from(s).map((c) => c.charCodeAt(0).toString(16)).join(" "))
53+
}
54+
document.getElementById("result").textContent = JSON.stringify(summary, null, 2)
55+
}
56+
</script>
57+
</body>
58+
</html>

packages/app/src/web/terminal-panel-runtime-core.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import type {
2525
TerminalSocketListenerArgs,
2626
TerminalSocketRef
2727
} from "./terminal-panel-runtime-types.js"
28+
import { installTerminalQuerySuppression } from "./terminal-query-suppression.js"
2829
import { resolveTerminalReconnectDelay, terminalReconnectGraceMs } from "./terminal-reconnect.js"
2930
import { parseTerminalServerMessage, resolveTerminalWebSocketUrl } from "./terminal.js"
3031

@@ -79,6 +80,7 @@ export const createTerminalRuntime = (host: HTMLDivElement): TerminalRuntime =>
7980
fontSize: 14,
8081
theme: { background: "#080a0d", foreground: "#f4f7fb" }
8182
})
83+
installTerminalQuerySuppression(terminal)
8284
const fitAddon = new FitAddon()
8385
terminal.loadAddon(fitAddon)
8486
terminal.open(host)
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import type { Terminal } from "xterm"
2+
3+
export type TerminalQuerySuppression = { readonly dispose: () => void }
4+
5+
type Disposable = { readonly dispose: () => void }
6+
7+
const isColorQuery = (data: string): boolean => {
8+
for (const segment of data.split(";")) {
9+
if (segment === "?") {
10+
return true
11+
}
12+
}
13+
return false
14+
}
15+
16+
const registerOscColorQuerySuppressor = (terminal: Terminal, identifier: number): Disposable =>
17+
terminal.parser.registerOscHandler(identifier, (data) => isColorQuery(data))
18+
19+
const registerCsiSuppressor = (
20+
terminal: Terminal,
21+
identifier: Parameters<Terminal["parser"]["registerCsiHandler"]>[0]
22+
): Disposable => terminal.parser.registerCsiHandler(identifier, () => true)
23+
24+
export const installTerminalQuerySuppression = (terminal: Terminal): TerminalQuerySuppression => {
25+
const disposables: ReadonlyArray<Disposable> = [
26+
registerOscColorQuerySuppressor(terminal, 4),
27+
registerOscColorQuerySuppressor(terminal, 10),
28+
registerOscColorQuerySuppressor(terminal, 11),
29+
registerOscColorQuerySuppressor(terminal, 12),
30+
registerCsiSuppressor(terminal, { final: "c" }),
31+
registerCsiSuppressor(terminal, { final: "c", prefix: ">" }),
32+
registerCsiSuppressor(terminal, { final: "c", prefix: "=" }),
33+
registerCsiSuppressor(terminal, { final: "n" }),
34+
registerCsiSuppressor(terminal, { final: "n", prefix: "?" })
35+
]
36+
return {
37+
dispose: () => {
38+
for (const disposable of disposables) {
39+
disposable.dispose()
40+
}
41+
}
42+
}
43+
}
44+
45+
export const isTerminalColorQuery = isColorQuery
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { describe, expect, it } from "@effect/vitest"
2+
3+
import { installTerminalQuerySuppression, isTerminalColorQuery } from "../../src/web/terminal-query-suppression.js"
4+
5+
type RegisteredOscHandler = {
6+
readonly identifier: number
7+
readonly callback: (data: string) => boolean
8+
}
9+
10+
type CsiIdentifier = { readonly final: string; readonly prefix?: string }
11+
12+
type RegisteredCsiHandler = {
13+
readonly identifier: CsiIdentifier
14+
readonly callback: () => boolean
15+
}
16+
17+
const createMockTerminal = (): {
18+
readonly disposedCount: { value: number }
19+
readonly osc: ReadonlyArray<RegisteredOscHandler>
20+
readonly csi: ReadonlyArray<RegisteredCsiHandler>
21+
readonly terminal: {
22+
parser: {
23+
registerOscHandler: (id: number, cb: (data: string) => boolean) => { dispose: () => void }
24+
registerCsiHandler: (id: CsiIdentifier, cb: () => boolean) => { dispose: () => void }
25+
}
26+
}
27+
} => {
28+
const osc: Array<RegisteredOscHandler> = []
29+
const csi: Array<RegisteredCsiHandler> = []
30+
const disposedCount = { value: 0 }
31+
return {
32+
csi,
33+
disposedCount,
34+
osc,
35+
terminal: {
36+
parser: {
37+
registerCsiHandler: (identifier, callback) => {
38+
csi.push({ callback, identifier })
39+
return {
40+
dispose: () => {
41+
disposedCount.value += 1
42+
}
43+
}
44+
},
45+
registerOscHandler: (identifier, callback) => {
46+
osc.push({ callback, identifier })
47+
return {
48+
dispose: () => {
49+
disposedCount.value += 1
50+
}
51+
}
52+
}
53+
}
54+
}
55+
}
56+
}
57+
58+
describe("terminal query suppression", () => {
59+
it("detects color query payloads with the '?' placeholder", () => {
60+
expect(isTerminalColorQuery("?")).toBe(true)
61+
expect(isTerminalColorQuery("1;?")).toBe(true)
62+
expect(isTerminalColorQuery("?;1;2")).toBe(true)
63+
})
64+
65+
it("treats explicit color values as non-queries", () => {
66+
expect(isTerminalColorQuery("rgb:f4f4/f7f7/fbfb")).toBe(false)
67+
expect(isTerminalColorQuery("#1a2b3c")).toBe(false)
68+
expect(isTerminalColorQuery("1;rgb:00/00/00")).toBe(false)
69+
expect(isTerminalColorQuery("")).toBe(false)
70+
})
71+
72+
it("registers OSC color suppression handlers for 4, 10, 11, and 12", () => {
73+
const mock = createMockTerminal()
74+
installTerminalQuerySuppression(mock.terminal as never)
75+
expect(mock.osc.map((handler) => handler.identifier)).toEqual([4, 10, 11, 12])
76+
})
77+
78+
it("registers CSI handlers for DA1, DA2, DA3, and CPR", () => {
79+
const mock = createMockTerminal()
80+
installTerminalQuerySuppression(mock.terminal as never)
81+
expect(mock.csi.map((handler) => handler.identifier)).toEqual([
82+
{ final: "c" },
83+
{ final: "c", prefix: ">" },
84+
{ final: "c", prefix: "=" },
85+
{ final: "n" },
86+
{ final: "n", prefix: "?" }
87+
])
88+
})
89+
90+
it("consumes OSC color query sequences and lets explicit set commands fall through", () => {
91+
const mock = createMockTerminal()
92+
installTerminalQuerySuppression(mock.terminal as never)
93+
const fgHandler = mock.osc.find((handler) => handler.identifier === 10)
94+
expect(fgHandler).toBeDefined()
95+
expect(fgHandler?.callback("?")).toBe(true)
96+
expect(fgHandler?.callback("rgb:1010/2020/3030")).toBe(false)
97+
})
98+
99+
it("always consumes CSI device attribute and cursor position queries", () => {
100+
const mock = createMockTerminal()
101+
installTerminalQuerySuppression(mock.terminal as never)
102+
for (const handler of mock.csi) {
103+
expect(handler.callback()).toBe(true)
104+
}
105+
})
106+
107+
it("disposes every registered handler when the suppression is disposed", () => {
108+
const mock = createMockTerminal()
109+
const suppression = installTerminalQuerySuppression(mock.terminal as never)
110+
suppression.dispose()
111+
expect(mock.disposedCount.value).toBe(mock.osc.length + mock.csi.length)
112+
})
113+
})

0 commit comments

Comments
 (0)