diff --git a/experiments/terminal-query-suppression.md b/experiments/terminal-query-suppression.md new file mode 100644 index 00000000..c9b86ddc --- /dev/null +++ b/experiments/terminal-query-suppression.md @@ -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;;?\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. diff --git a/experiments/verify-suppression.html b/experiments/verify-suppression.html new file mode 100644 index 00000000..f33d24d3 --- /dev/null +++ b/experiments/verify-suppression.html @@ -0,0 +1,58 @@ + + + + Terminal Query Suppression Verification + + + +
+

After suppression installed

+
+

+  
+
+
diff --git a/packages/app/src/web/terminal-panel-runtime-core.ts b/packages/app/src/web/terminal-panel-runtime-core.ts
index 5f40ddea..bc25d281 100644
--- a/packages/app/src/web/terminal-panel-runtime-core.ts
+++ b/packages/app/src/web/terminal-panel-runtime-core.ts
@@ -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"
 
@@ -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)
diff --git a/packages/app/src/web/terminal-query-suppression.ts b/packages/app/src/web/terminal-query-suppression.ts
new file mode 100644
index 00000000..37f11d0c
--- /dev/null
+++ b/packages/app/src/web/terminal-query-suppression.ts
@@ -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 = [
+    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
diff --git a/packages/app/tests/docker-git/terminal-query-suppression.test.ts b/packages/app/tests/docker-git/terminal-query-suppression.test.ts
new file mode 100644
index 00000000..6fbdc07c
--- /dev/null
+++ b/packages/app/tests/docker-git/terminal-query-suppression.test.ts
@@ -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
+  readonly csi: ReadonlyArray
+  readonly terminal: {
+    parser: {
+      registerOscHandler: (id: number, cb: (data: string) => boolean) => { dispose: () => void }
+      registerCsiHandler: (id: CsiIdentifier, cb: () => boolean) => { dispose: () => void }
+    }
+  }
+} => {
+  const osc: Array = []
+  const csi: Array = []
+  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)
+  })
+})