Skip to content

Commit 664f237

Browse files
authored
Merge pull request #272 from konard/issue-271-6e1a0e1d8cfa
fix(web): suppress xterm.js terminal query auto-responses
2 parents a5cffa1 + 02d5f0d commit 664f237

5 files changed

Lines changed: 291 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: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
export type TerminalQuerySuppression = { readonly dispose: () => void }
2+
3+
type Disposable = { readonly dispose: () => void }
4+
5+
type CsiIdentifier = { readonly final: string; readonly prefix?: string }
6+
7+
export type TerminalQuerySuppressionTarget = {
8+
readonly parser: {
9+
readonly registerOscHandler: (
10+
ident: number,
11+
callback: (data: string) => boolean
12+
) => Disposable
13+
readonly registerCsiHandler: (
14+
id: CsiIdentifier,
15+
callback: () => boolean
16+
) => Disposable
17+
}
18+
}
19+
20+
const isColorQuery = (data: string): boolean => {
21+
for (const segment of data.split(";")) {
22+
if (segment === "?") {
23+
return true
24+
}
25+
}
26+
return false
27+
}
28+
29+
const registerOscColorQuerySuppressor = (
30+
terminal: TerminalQuerySuppressionTarget,
31+
identifier: number
32+
): Disposable => terminal.parser.registerOscHandler(identifier, (data) => isColorQuery(data))
33+
34+
const registerCsiSuppressor = (
35+
terminal: TerminalQuerySuppressionTarget,
36+
identifier: CsiIdentifier
37+
): Disposable => terminal.parser.registerCsiHandler(identifier, () => true)
38+
39+
export const installTerminalQuerySuppression = (
40+
terminal: TerminalQuerySuppressionTarget
41+
): TerminalQuerySuppression => {
42+
const disposables: ReadonlyArray<Disposable> = [
43+
registerOscColorQuerySuppressor(terminal, 4),
44+
registerOscColorQuerySuppressor(terminal, 10),
45+
registerOscColorQuerySuppressor(terminal, 11),
46+
registerOscColorQuerySuppressor(terminal, 12),
47+
registerCsiSuppressor(terminal, { final: "c" }),
48+
registerCsiSuppressor(terminal, { final: "c", prefix: ">" }),
49+
registerCsiSuppressor(terminal, { final: "c", prefix: "=" }),
50+
registerCsiSuppressor(terminal, { final: "n" }),
51+
registerCsiSuppressor(terminal, { final: "n", prefix: "?" })
52+
]
53+
return {
54+
dispose: () => {
55+
for (const disposable of disposables) {
56+
disposable.dispose()
57+
}
58+
}
59+
}
60+
}
61+
62+
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)
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)
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)
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)
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)
110+
suppression.dispose()
111+
expect(mock.disposedCount.value).toBe(mock.osc.length + mock.csi.length)
112+
})
113+
})

0 commit comments

Comments
 (0)