From 4ac09cf77deabede0e6100e4944a09d5263582c4 Mon Sep 17 00:00:00 2001 From: konard Date: Mon, 11 May 2026 15:36:38 +0000 Subject: [PATCH 1/4] Initial commit with task details Adding .gitkeep for PR creation (default mode). This file will be removed when the task is complete. Issue: https://github.com/ProverCoderAI/docker-git/issues/271 --- .gitkeep | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitkeep diff --git a/.gitkeep b/.gitkeep new file mode 100644 index 00000000..400b77d5 --- /dev/null +++ b/.gitkeep @@ -0,0 +1 @@ +# .gitkeep file auto-generated at 2026-05-11T15:36:38.456Z for PR creation at branch issue-271-6e1a0e1d8cfa for issue https://github.com/ProverCoderAI/docker-git/issues/271 \ No newline at end of file From 281a3fc09b43ddf7ac4cdabeb39079326d3b6fd9 Mon Sep 17 00:00:00 2001 From: konard Date: Mon, 11 May 2026 15:49:56 +0000 Subject: [PATCH 2/4] 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 --- experiments/terminal-query-suppression.md | 56 +++++++++ experiments/verify-suppression.html | 58 +++++++++ .../src/web/terminal-panel-runtime-core.ts | 2 + .../app/src/web/terminal-query-suppression.ts | 45 +++++++ .../terminal-query-suppression.test.ts | 113 ++++++++++++++++++ 5 files changed, 274 insertions(+) create mode 100644 experiments/terminal-query-suppression.md create mode 100644 experiments/verify-suppression.html create mode 100644 packages/app/src/web/terminal-query-suppression.ts create mode 100644 packages/app/tests/docker-git/terminal-query-suppression.test.ts 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..4b33379c
--- /dev/null
+++ b/packages/app/src/web/terminal-query-suppression.ts
@@ -0,0 +1,45 @@
+import type { Terminal } from "xterm"
+
+export type TerminalQuerySuppression = { readonly dispose: () => void }
+
+type Disposable = { readonly dispose: () => void }
+
+const isColorQuery = (data: string): boolean => {
+  for (const segment of data.split(";")) {
+    if (segment === "?") {
+      return true
+    }
+  }
+  return false
+}
+
+const registerOscColorQuerySuppressor = (terminal: Terminal, identifier: number): Disposable =>
+  terminal.parser.registerOscHandler(identifier, (data) => isColorQuery(data))
+
+const registerCsiSuppressor = (
+  terminal: Terminal,
+  identifier: Parameters[0]
+): Disposable => terminal.parser.registerCsiHandler(identifier, () => true)
+
+export const installTerminalQuerySuppression = (terminal: Terminal): 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..e5ff5cb0
--- /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 as never)
+    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 as never)
+    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 as never)
+    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 as never)
+    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 as never)
+    suppression.dispose()
+    expect(mock.disposedCount.value).toBe(mock.osc.length + mock.csi.length)
+  })
+})

From a00ffc342fb46ce0a6d6222680ab33ad745b3a22 Mon Sep 17 00:00:00 2001
From: konard 
Date: Mon, 11 May 2026 16:07:05 +0000
Subject: [PATCH 3/4] fix(web): drop type casts to satisfy effect-ts lint

The Lint Effect-TS check forbids `as`/`` casts outside src/core/axioms.ts,
and the new tests for terminal-query-suppression cast a mock to `Terminal`
via `as never`. Replace the parameter type with a structural
`TerminalQuerySuppressionTarget` that the real xterm `Terminal` already
satisfies, so the mock can be passed directly without any cast.
---
 .../app/src/web/terminal-query-suppression.ts | 31 ++++++++++++++-----
 .../terminal-query-suppression.test.ts        | 10 +++---
 2 files changed, 29 insertions(+), 12 deletions(-)

diff --git a/packages/app/src/web/terminal-query-suppression.ts b/packages/app/src/web/terminal-query-suppression.ts
index 4b33379c..37f11d0c 100644
--- a/packages/app/src/web/terminal-query-suppression.ts
+++ b/packages/app/src/web/terminal-query-suppression.ts
@@ -1,9 +1,22 @@
-import type { Terminal } from "xterm"
-
 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 === "?") {
@@ -13,15 +26,19 @@ const isColorQuery = (data: string): boolean => {
   return false
 }
 
-const registerOscColorQuerySuppressor = (terminal: Terminal, identifier: number): Disposable =>
-  terminal.parser.registerOscHandler(identifier, (data) => isColorQuery(data))
+const registerOscColorQuerySuppressor = (
+  terminal: TerminalQuerySuppressionTarget,
+  identifier: number
+): Disposable => terminal.parser.registerOscHandler(identifier, (data) => isColorQuery(data))
 
 const registerCsiSuppressor = (
-  terminal: Terminal,
-  identifier: Parameters[0]
+  terminal: TerminalQuerySuppressionTarget,
+  identifier: CsiIdentifier
 ): Disposable => terminal.parser.registerCsiHandler(identifier, () => true)
 
-export const installTerminalQuerySuppression = (terminal: Terminal): TerminalQuerySuppression => {
+export const installTerminalQuerySuppression = (
+  terminal: TerminalQuerySuppressionTarget
+): TerminalQuerySuppression => {
   const disposables: ReadonlyArray = [
     registerOscColorQuerySuppressor(terminal, 4),
     registerOscColorQuerySuppressor(terminal, 10),
diff --git a/packages/app/tests/docker-git/terminal-query-suppression.test.ts b/packages/app/tests/docker-git/terminal-query-suppression.test.ts
index e5ff5cb0..6fbdc07c 100644
--- a/packages/app/tests/docker-git/terminal-query-suppression.test.ts
+++ b/packages/app/tests/docker-git/terminal-query-suppression.test.ts
@@ -71,13 +71,13 @@ describe("terminal query suppression", () => {
 
   it("registers OSC color suppression handlers for 4, 10, 11, and 12", () => {
     const mock = createMockTerminal()
-    installTerminalQuerySuppression(mock.terminal as never)
+    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 as never)
+    installTerminalQuerySuppression(mock.terminal)
     expect(mock.csi.map((handler) => handler.identifier)).toEqual([
       { final: "c" },
       { final: "c", prefix: ">" },
@@ -89,7 +89,7 @@ describe("terminal query suppression", () => {
 
   it("consumes OSC color query sequences and lets explicit set commands fall through", () => {
     const mock = createMockTerminal()
-    installTerminalQuerySuppression(mock.terminal as never)
+    installTerminalQuerySuppression(mock.terminal)
     const fgHandler = mock.osc.find((handler) => handler.identifier === 10)
     expect(fgHandler).toBeDefined()
     expect(fgHandler?.callback("?")).toBe(true)
@@ -98,7 +98,7 @@ describe("terminal query suppression", () => {
 
   it("always consumes CSI device attribute and cursor position queries", () => {
     const mock = createMockTerminal()
-    installTerminalQuerySuppression(mock.terminal as never)
+    installTerminalQuerySuppression(mock.terminal)
     for (const handler of mock.csi) {
       expect(handler.callback()).toBe(true)
     }
@@ -106,7 +106,7 @@ describe("terminal query suppression", () => {
 
   it("disposes every registered handler when the suppression is disposed", () => {
     const mock = createMockTerminal()
-    const suppression = installTerminalQuerySuppression(mock.terminal as never)
+    const suppression = installTerminalQuerySuppression(mock.terminal)
     suppression.dispose()
     expect(mock.disposedCount.value).toBe(mock.osc.length + mock.csi.length)
   })

From 02d5f0db3ac8db707be270a684beb94f7d9d723a Mon Sep 17 00:00:00 2001
From: konard 
Date: Mon, 11 May 2026 16:24:56 +0000
Subject: [PATCH 4/4] Revert "Initial commit with task details"

This reverts commit 4ac09cf77deabede0e6100e4944a09d5263582c4.
---
 .gitkeep | 1 -
 1 file changed, 1 deletion(-)
 delete mode 100644 .gitkeep

diff --git a/.gitkeep b/.gitkeep
deleted file mode 100644
index 400b77d5..00000000
--- a/.gitkeep
+++ /dev/null
@@ -1 +0,0 @@
-# .gitkeep file auto-generated at 2026-05-11T15:36:38.456Z for PR creation at branch issue-271-6e1a0e1d8cfa for issue https://github.com/ProverCoderAI/docker-git/issues/271
\ No newline at end of file