Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions experiments/terminal-query-suppression.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Terminal query suppression experiment

## Issue (GitHub #271)

The web terminal renders raw escape sequences such as
`^[]10;rgb:f4f4/f7f7/fbfb^[\` and `^[[?1;2c` inside Claude Code's
prompt area, which makes navigation and rendering look broken.

## Root cause

TUI applications (Claude Code, Ultraplan, etc.) probe the terminal with
queries like:

- `\x1b]10;?\x1b\\` – ask for the foreground color (OSC 10).
- `\x1b]11;?\x1b\\` – ask for the background color (OSC 11).
- `\x1b]12;?\x1b\\` – ask for the cursor color (OSC 12).
- `\x1b]4;<n>;?\x1b\\` – ask for an indexed palette color (OSC 4).
- `\x1b[c` – primary device attributes query (DA1).
- `\x1b[>c` – secondary device attributes (DA2).
- `\x1b[=c` – tertiary device attributes (DA3).
- `\x1b[6n` – cursor position report (CPR).

`xterm.js@5.3.0` responds to all of those out-of-the-box. Because the
web terminal is fronted by `xterm.js`, the responses are emitted via
`Terminal.onData` and we forward them to the host PTY as user input.
Claude Code receives these bytes as keystrokes inside its prompt loop
and renders them verbatim, which is exactly what the screenshot in the
issue shows.

## Fix

We install a small parser shim immediately after instantiating the
`Terminal` (see `terminal-query-suppression.ts`):

- For `OSC 4/10/11/12` we intercept the handler chain. If the payload
contains a `?` segment (query), we return `true` to consume the
sequence without invoking xterm's default handler that would
otherwise reply. Plain "set color" payloads return `false` so the
default handler still applies the requested theme change.
- For DA1/DA2/DA3 (`CSI ... c`) and CPR (`CSI ... n`) we always
return `true` so xterm never reports back to the PTY. None of the
features that depend on those responses are useful for our headless
web frontend.

The handlers are returned as disposables so callers (and the unit
tests) can roll the registration back without touching `xterm`'s
internal parser state.

## Manual reproduction notes

1. Start the web build (`bun run docker-git -- browser`) and open the
web terminal.
2. Inside the container run a TUI that probes color (for example
`bash -c 'printf "\\033]10;?\\033\\\\"'`).
3. Without the fix the printed escape sequence is echoed back into the
prompt as garbage. With the fix nothing is echoed.
58 changes: 58 additions & 0 deletions experiments/verify-suppression.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<!DOCTYPE html>
<html>
<head>
<title>Terminal Query Suppression Verification</title>
<link rel="stylesheet" href="/node_modules/xterm/css/xterm.css" />
</head>
<body>
<div id="before-host" style="background:#080a0d;padding:8px;border:1px solid #333"></div>
<h3 style="color:#fff">After suppression installed</h3>
<div id="after-host" style="background:#080a0d;padding:8px;border:1px solid #333"></div>
<pre id="result" style="color:#fff;background:#222;padding:8px"></pre>
<script type="module">
import { Terminal } from "/node_modules/xterm/lib/xterm.js"
import { installTerminalQuerySuppression } from "/packages/app/src/web/terminal-query-suppression.ts"

const collect = (terminal) => {
const collected = []
terminal.onData((data) => { collected.push(data) })
return collected
}

const beforeTerminal = new Terminal()
beforeTerminal.open(document.getElementById("before-host"))
const beforeData = collect(beforeTerminal)

const afterTerminal = new Terminal()
afterTerminal.open(document.getElementById("after-host"))
installTerminalQuerySuppression(afterTerminal)
const afterData = collect(afterTerminal)

const queries = [
"\x1b]10;?\x1b\\",
"\x1b]11;?\x1b\\",
"\x1b]12;?\x1b\\",
"\x1b]4;1;?\x1b\\",
"\x1b[c",
"\x1b[>c",
"\x1b[=c",
"\x1b[6n"
]

let done = 0
queries.forEach((q) => {
beforeTerminal.write(q, () => { done++; render() })
afterTerminal.write(q, () => { done++; render() })
})

function render() {
if (done < queries.length * 2) return
const summary = {
before: beforeData.map((s) => Array.from(s).map((c) => c.charCodeAt(0).toString(16)).join(" ")),
after: afterData.map((s) => Array.from(s).map((c) => c.charCodeAt(0).toString(16)).join(" "))
}
document.getElementById("result").textContent = JSON.stringify(summary, null, 2)
}
</script>
</body>
</html>
2 changes: 2 additions & 0 deletions packages/app/src/web/terminal-panel-runtime-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import type {
TerminalSocketListenerArgs,
TerminalSocketRef
} from "./terminal-panel-runtime-types.js"
import { installTerminalQuerySuppression } from "./terminal-query-suppression.js"
import { resolveTerminalReconnectDelay, terminalReconnectGraceMs } from "./terminal-reconnect.js"
import { parseTerminalServerMessage, resolveTerminalWebSocketUrl } from "./terminal.js"

Expand Down Expand Up @@ -79,6 +80,7 @@ export const createTerminalRuntime = (host: HTMLDivElement): TerminalRuntime =>
fontSize: 14,
theme: { background: "#080a0d", foreground: "#f4f7fb" }
})
installTerminalQuerySuppression(terminal)
const fitAddon = new FitAddon()
terminal.loadAddon(fitAddon)
terminal.open(host)
Expand Down
62 changes: 62 additions & 0 deletions packages/app/src/web/terminal-query-suppression.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
export type TerminalQuerySuppression = { readonly dispose: () => void }

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

type CsiIdentifier = { readonly final: string; readonly prefix?: string }

export type TerminalQuerySuppressionTarget = {
readonly parser: {
readonly registerOscHandler: (
ident: number,
callback: (data: string) => boolean
) => Disposable
readonly registerCsiHandler: (
id: CsiIdentifier,
callback: () => boolean
) => Disposable
}
}

const isColorQuery = (data: string): boolean => {
for (const segment of data.split(";")) {
if (segment === "?") {
return true
}
}
return false
}

const registerOscColorQuerySuppressor = (
terminal: TerminalQuerySuppressionTarget,
identifier: number
): Disposable => terminal.parser.registerOscHandler(identifier, (data) => isColorQuery(data))

const registerCsiSuppressor = (
terminal: TerminalQuerySuppressionTarget,
identifier: CsiIdentifier
): Disposable => terminal.parser.registerCsiHandler(identifier, () => true)

export const installTerminalQuerySuppression = (
terminal: TerminalQuerySuppressionTarget
): TerminalQuerySuppression => {
const disposables: ReadonlyArray<Disposable> = [
registerOscColorQuerySuppressor(terminal, 4),
registerOscColorQuerySuppressor(terminal, 10),
registerOscColorQuerySuppressor(terminal, 11),
registerOscColorQuerySuppressor(terminal, 12),
registerCsiSuppressor(terminal, { final: "c" }),
registerCsiSuppressor(terminal, { final: "c", prefix: ">" }),
registerCsiSuppressor(terminal, { final: "c", prefix: "=" }),
registerCsiSuppressor(terminal, { final: "n" }),
registerCsiSuppressor(terminal, { final: "n", prefix: "?" })
]
return {
dispose: () => {
for (const disposable of disposables) {
disposable.dispose()
}
}
}
}

export const isTerminalColorQuery = isColorQuery
113 changes: 113 additions & 0 deletions packages/app/tests/docker-git/terminal-query-suppression.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { describe, expect, it } from "@effect/vitest"

import { installTerminalQuerySuppression, isTerminalColorQuery } from "../../src/web/terminal-query-suppression.js"

type RegisteredOscHandler = {
readonly identifier: number
readonly callback: (data: string) => boolean
}

type CsiIdentifier = { readonly final: string; readonly prefix?: string }

type RegisteredCsiHandler = {
readonly identifier: CsiIdentifier
readonly callback: () => boolean
}

const createMockTerminal = (): {
readonly disposedCount: { value: number }
readonly osc: ReadonlyArray<RegisteredOscHandler>
readonly csi: ReadonlyArray<RegisteredCsiHandler>
readonly terminal: {
parser: {
registerOscHandler: (id: number, cb: (data: string) => boolean) => { dispose: () => void }
registerCsiHandler: (id: CsiIdentifier, cb: () => boolean) => { dispose: () => void }
}
}
} => {
const osc: Array<RegisteredOscHandler> = []
const csi: Array<RegisteredCsiHandler> = []
const disposedCount = { value: 0 }
return {
csi,
disposedCount,
osc,
terminal: {
parser: {
registerCsiHandler: (identifier, callback) => {
csi.push({ callback, identifier })
return {
dispose: () => {
disposedCount.value += 1
}
}
},
registerOscHandler: (identifier, callback) => {
osc.push({ callback, identifier })
return {
dispose: () => {
disposedCount.value += 1
}
}
}
}
}
}
}

describe("terminal query suppression", () => {
it("detects color query payloads with the '?' placeholder", () => {
expect(isTerminalColorQuery("?")).toBe(true)
expect(isTerminalColorQuery("1;?")).toBe(true)
expect(isTerminalColorQuery("?;1;2")).toBe(true)
})

it("treats explicit color values as non-queries", () => {
expect(isTerminalColorQuery("rgb:f4f4/f7f7/fbfb")).toBe(false)
expect(isTerminalColorQuery("#1a2b3c")).toBe(false)
expect(isTerminalColorQuery("1;rgb:00/00/00")).toBe(false)
expect(isTerminalColorQuery("")).toBe(false)
})

it("registers OSC color suppression handlers for 4, 10, 11, and 12", () => {
const mock = createMockTerminal()
installTerminalQuerySuppression(mock.terminal)
expect(mock.osc.map((handler) => handler.identifier)).toEqual([4, 10, 11, 12])
})

it("registers CSI handlers for DA1, DA2, DA3, and CPR", () => {
const mock = createMockTerminal()
installTerminalQuerySuppression(mock.terminal)
expect(mock.csi.map((handler) => handler.identifier)).toEqual([
{ final: "c" },
{ final: "c", prefix: ">" },
{ final: "c", prefix: "=" },
{ final: "n" },
{ final: "n", prefix: "?" }
])
})

it("consumes OSC color query sequences and lets explicit set commands fall through", () => {
const mock = createMockTerminal()
installTerminalQuerySuppression(mock.terminal)
const fgHandler = mock.osc.find((handler) => handler.identifier === 10)
expect(fgHandler).toBeDefined()
expect(fgHandler?.callback("?")).toBe(true)
expect(fgHandler?.callback("rgb:1010/2020/3030")).toBe(false)
})

it("always consumes CSI device attribute and cursor position queries", () => {
const mock = createMockTerminal()
installTerminalQuerySuppression(mock.terminal)
for (const handler of mock.csi) {
expect(handler.callback()).toBe(true)
}
})

it("disposes every registered handler when the suppression is disposed", () => {
const mock = createMockTerminal()
const suppression = installTerminalQuerySuppression(mock.terminal)
suppression.dispose()
expect(mock.disposedCount.value).toBe(mock.osc.length + mock.csi.length)
})
})
Loading