From efe6671f6008d8cc63547678178a9386cbc5146d Mon Sep 17 00:00:00 2001 From: AI Agent Date: Tue, 12 May 2026 08:10:00 +0000 Subject: [PATCH 1/2] fix(web): block DECRQM/DECRQSS replies and focus/mouse event leaks in xterm.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit xterm.js@5.3.0 auto-replies to several additional terminal capability probes through Terminal.onData, and the web bridge forwards every onData byte to the PTY. Building on #271, this closes the remaining leak sources that broke Claude Code's TUI: - CSI $ p / CSI ? $ p (DECRQM) — xterm.js always replies via requestMode. Ink emits CSI ?2026$p (synchronized-output probe) at startup; the reply was rendered as junk inside Claude Code's prompt. - DCS $ q ... ST (DECRQSS) — xterm.js always replies via requestStatusString. - CSI ?1004 h (focus reporting) — once asserted by Claude Code, every DOM focus/blur pumped ^[[I / ^[[O into the PTY. This was the root cause of "asd" rendering on the wrong row below the plan banner and of the plan-approval menu failing to consume arrow keys. - CSI ?1000/1002/1003/1006/1015/1016 h (mouse tracking) — every mouse event pumped coordinate bytes into the PTY, scrambling Ink's frame buffer (visible as the ─ divider bleeding across the typing line). Implementation uses Terminal.parser.registerCsiHandler / registerDcsHandler. The CSI ? h / CSI ? l handler is selective: returns true only for the seven suppressed mouse/focus modes and false for every other parameter, so 25 (cursor), 1007 (alt scroll), 1049 (alt screen), 2004 (bracketed paste), and 2026 (synchronized output) keep reaching xterm.js's built-in setters unchanged. Also added as defense-in-depth: CSI > q (XTVERSION, future-proofs the xterm.js bump), CSI t (window manipulation, gated by windowOptions today), DCS + q (XTGETTCAP). Fixes #273. Co-Authored-By: Claude Opus 4.7 --- experiments/terminal-query-suppression.md | 155 +++++++++------ .../app/src/web/terminal-query-suppression.ts | 104 +++++++++- .../terminal-query-suppression.test.ts | 182 ++++++++++++++++-- 3 files changed, 359 insertions(+), 82 deletions(-) diff --git a/experiments/terminal-query-suppression.md b/experiments/terminal-query-suppression.md index c9b86ddc..5eb8914b 100644 --- a/experiments/terminal-query-suppression.md +++ b/experiments/terminal-query-suppression.md @@ -1,56 +1,103 @@ # 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. +## Issues addressed + +- **#271** — raw `^[]10;rgb:f4f4/f7f7/fbfb^[\` and `^[[?1;2c` echoed into the + Claude Code prompt area. +- **#273** — Claude Code TUI in the web terminal: input rendered on the wrong + row ("asd" below the plan banner), plan-approval menu does not accept + arrow keys, and `─` divider lines bleed through the typing area. + +Both have the same root cause: xterm.js answers terminal capability probes +through `Terminal.onData`, and the web bridge forwards every `onData` byte +to the PTY as if the user had typed it. Claude Code (Ink) then renders +those reply bytes verbatim, scrambles its frame buffer, or misroutes +keystrokes through its input parser. + +## Probes that leaked in xterm.js@5.3.0 + +All sources cite xterm.js@5.3.0 `src/common/InputHandler.ts`. + +### Active leaks closed by #271 + +| Probe | Reply | +|---|---| +| `OSC 4;;? ST`, `OSC 10;?`, `OSC 11;?`, `OSC 12;?` color queries | indexed/foreground/background/cursor color | +| `CSI c`, `CSI > c`, `CSI = c` device attributes (DA1/DA2/DA3) | `CSI ?...c` | +| `CSI n`, `CSI ? n` device status / cursor position report (DSR/CPR) | `CSI ...n` | + +### Additional active leaks closed by this change (for #273) + +| Probe | Source in xterm.js@5.3.0 | Reply | +|---|---|---| +| `CSI Pm $ p` — DECRQM ANSI form | `InputHandler.ts:267` (`requestMode`) | `CSI Ps;Pm$y` | +| `CSI ? Pm $ p` — DECRQM private form (includes Ink's `?2026$p` synchronized-output probe) | `InputHandler.ts:268` (`requestMode`) | `CSI ?Ps;Pm$y` | +| `DCS $ q ... ST` — DECRQSS | `InputHandler.ts:381` (`requestStatusString`) | `DCS P{0,1}$r... ST` | + +### Indirect leaks (state-triggered output) closed by this change + +| Mode | Reason | Symptom in #273 | +|---|---|---| +| `CSI ? 1004 h` (focus reporting) | every DOM focus/blur pumps `CSI I` / `CSI O` into the PTY | cursor jumps, "asd" rendered one row below the plan banner, plan-approval menu mis-decodes input | +| `CSI ? 1000 h` / `1002 h` / `1003 h` / `1006 h` / `1015 h` / `1016 h` (mouse tracking) | every mouse event pumps coordinate bytes into the PTY | Ink frame buffer corruption, e.g. `─` divider bleeds through typing line | + +### Defense-in-depth (no leak today, prevents regressions) + +| Probe | Notes | +|---|---| +| `CSI > q` — XTVERSION | not in 5.3.0; xterm.js master always replies `DCS > \| xterm.js(version) ST` | +| `CSI Pm t` — window manipulation | gated by `windowOptions` (off by default in our build); explicit suppressor prevents accidental future enable | +| `DCS + q ... ST` — XTGETTCAP | not registered in 5.3.0; cheap to pre-empt | + +## Modes intentionally left to fall through + +These DEC private modes are useful and must continue to reach xterm's +built-in `h`/`l` setters: + +- `25` — cursor visibility +- `1007` — alternate scroll (changes wheel semantics; no reply) +- `1049` — alternate screen buffer (Claude Code does not use it today but + other TUIs do) +- `2004` — bracketed paste (xterm.js wraps actual paste bytes; we want this) +- `2026` — synchronized output (Ink wraps every frame in BSU/ESU; xterm.js + ≥5.3 batches the writes) + +The selective `CSI ? h` / `CSI ? l` handler returns `true` (suppress) only +when one of the seven suppressed mouse/focus modes is in the parameter +list, and `false` (fall through) for every other parameter. + +## Implementation + +`packages/app/src/web/terminal-query-suppression.ts` registers handlers via +`Terminal.parser.registerOscHandler` / `registerCsiHandler` / +`registerDcsHandler` directly after constructing the `Terminal`. Each +returns a disposable; the install function aggregates them so callers can +roll back the registrations cleanly (used by the unit tests in +`packages/app/tests/docker-git/terminal-query-suppression.test.ts`). + +The selective DEC-private-mode handler iterates the params list (handling +both flat `(number | number[])[]` shapes that xterm.js emits) and short- +circuits as soon as a suppressed mode number is found. Sub-parameters +(`number[]`) are reduced to their head, which matches DECSET/DECRST +semantics (sub-params on these modes are vendor-specific extensions). + +## Manual reproduction (unchanged from #271 notes) + +1. `bun run docker-git -- browser` and open the web terminal. +2. Run a TUI that probes capabilities, e.g. `claude` or + `bash -c 'printf "\\033[?2026\$p"'` (synchronized-output DECRQM). +3. Without the fix, reply bytes leak into the prompt and the TUI either + echoes them or mis-decodes them as keystrokes. With the fix, nothing + leaks and Ink rendering stays stable across browser focus/blur. + +## Verification + +- `bun x vitest run tests/docker-git/terminal-query-suppression.test.ts` + — 15/15 tests pass (color query detection, every probe identifier + registered, all suppressed modes consumed, all benign DEC private modes + pass through, disposal closes every handler). +- `bun run typecheck` — clean. +- `vibecode-linter src/web/terminal-query-suppression.ts tests/docker-git/terminal-query-suppression.test.ts` + — 0 errors, 0 warnings, 0 duplicates. +- `eslint --config eslint.effect-ts-check.config.mjs ...` — clean on both + files. diff --git a/packages/app/src/web/terminal-query-suppression.ts b/packages/app/src/web/terminal-query-suppression.ts index 37f11d0c..92566ff8 100644 --- a/packages/app/src/web/terminal-query-suppression.ts +++ b/packages/app/src/web/terminal-query-suppression.ts @@ -2,21 +2,55 @@ export type TerminalQuerySuppression = { readonly dispose: () => void } type Disposable = { readonly dispose: () => void } -type CsiIdentifier = { readonly final: string; readonly prefix?: string } +type FunctionIdentifier = { + readonly final: string + readonly intermediates?: string + readonly prefix?: string +} + +type CsiParam = number | ReadonlyArray + +type CsiParams = ReadonlyArray export type TerminalQuerySuppressionTarget = { readonly parser: { + readonly registerCsiHandler: ( + id: FunctionIdentifier, + callback: (params: CsiParams) => boolean + ) => Disposable + readonly registerDcsHandler: ( + id: FunctionIdentifier, + callback: (data: string, params: CsiParams) => boolean + ) => Disposable readonly registerOscHandler: ( ident: number, callback: (data: string) => boolean ) => Disposable - readonly registerCsiHandler: ( - id: CsiIdentifier, - callback: () => boolean - ) => Disposable } } +// DEC private modes whose `h`/`l` setter causes xterm.js to start emitting +// unsolicited reply bytes back through `onData` on later DOM events: +// 1000/1002/1003/1006/1015/1016 — mouse tracking (mouse events -> bytes) +// 1004 — focus reporting (focus/blur -> CSI I/O) +// Suppressing the SET (`h`) leaves xterm.js in the default state (no event +// emission); suppressing the RESET (`l`) is harmless and kept for symmetry. +// Modes intentionally left to fall through to xterm's built-in handlers: +// 25 — cursor visibility +// 1049 — alternate screen buffer +// 2004 — bracketed paste +// 2026 — synchronized output (Ink uses BSU/ESU around every frame) +// 1007 — alternate scroll (only changes wheel semantics, no leak) +const SUPPRESSED_PRIVATE_MODES: ReadonlySet = new Set([ + 1000, + 1002, + 1003, + 1004, + 1006, + 1015, + 1016 +]) + const isColorQuery = (data: string): boolean => { for (const segment of data.split(";")) { if (segment === "?") { @@ -26,6 +60,24 @@ const isColorQuery = (data: string): boolean => { return false } +const extractParam = (param: CsiParam): number | null => { + if (typeof param === "number") { + return param + } + const head = param[0] + return typeof head === "number" ? head : null +} + +const containsSuppressedPrivateMode = (params: CsiParams): boolean => { + for (const param of params) { + const value = extractParam(param) + if (value !== null && SUPPRESSED_PRIVATE_MODES.has(value)) { + return true + } + } + return false +} + const registerOscColorQuerySuppressor = ( terminal: TerminalQuerySuppressionTarget, identifier: number @@ -33,22 +85,58 @@ const registerOscColorQuerySuppressor = ( const registerCsiSuppressor = ( terminal: TerminalQuerySuppressionTarget, - identifier: CsiIdentifier + identifier: FunctionIdentifier ): Disposable => terminal.parser.registerCsiHandler(identifier, () => true) +const registerDcsSuppressor = ( + terminal: TerminalQuerySuppressionTarget, + identifier: FunctionIdentifier +): Disposable => terminal.parser.registerDcsHandler(identifier, () => true) + +const registerSelectivePrivateModeSuppressor = ( + terminal: TerminalQuerySuppressionTarget, + final: "h" | "l" +): Disposable => + terminal.parser.registerCsiHandler( + { final, prefix: "?" }, + (params) => containsSuppressedPrivateMode(params) + ) + export const installTerminalQuerySuppression = ( terminal: TerminalQuerySuppressionTarget ): TerminalQuerySuppression => { const disposables: ReadonlyArray = [ + // OSC 4/10/11/12 — color queries (`?` payload). Set-color payloads fall through. registerOscColorQuerySuppressor(terminal, 4), registerOscColorQuerySuppressor(terminal, 10), registerOscColorQuerySuppressor(terminal, 11), registerOscColorQuerySuppressor(terminal, 12), + // CSI c / > c / = c — primary/secondary/tertiary device attributes (DA1/DA2/DA3). registerCsiSuppressor(terminal, { final: "c" }), registerCsiSuppressor(terminal, { final: "c", prefix: ">" }), registerCsiSuppressor(terminal, { final: "c", prefix: "=" }), + // CSI n / ? n — device status report and cursor position report. registerCsiSuppressor(terminal, { final: "n" }), - registerCsiSuppressor(terminal, { final: "n", prefix: "?" }) + registerCsiSuppressor(terminal, { final: "n", prefix: "?" }), + // CSI $ p / CSI ? $ p — DECRQM (ANSI and DEC private forms). xterm.js always + // replies via `requestMode`, including for the `?2026 $p` synchronized-output + // probe that Ink emits during startup. + registerCsiSuppressor(terminal, { final: "p", intermediates: "$" }), + registerCsiSuppressor(terminal, { final: "p", intermediates: "$", prefix: "?" }), + // DCS $ q ... ST — DECRQSS. xterm.js always replies via `requestStatusString`. + registerDcsSuppressor(terminal, { final: "q", intermediates: "$" }), + // DCS + q ... ST — XTGETTCAP. No reply in 5.3.0; suppressed for defense-in-depth. + registerDcsSuppressor(terminal, { final: "q", intermediates: "+" }), + // CSI > q — XTVERSION. Not in 5.3.0 but auto-replies in xterm.js master. + registerCsiSuppressor(terminal, { final: "q", prefix: ">" }), + // CSI Pm t — window manipulation. Gated by `windowOptions` (off by default); + // suppressed so an accidental future enable does not leak size reports. + registerCsiSuppressor(terminal, { final: "t" }), + // CSI ? h / CSI ? l — block xterm from enabling focus reporting and mouse + // tracking modes that would later pump unsolicited bytes back through onData. + // Other DEC private modes fall through to xterm's built-in setters. + registerSelectivePrivateModeSuppressor(terminal, "h"), + registerSelectivePrivateModeSuppressor(terminal, "l") ] return { dispose: () => { @@ -60,3 +148,5 @@ export const installTerminalQuerySuppression = ( } export const isTerminalColorQuery = isColorQuery + +export const isSuppressedDecPrivateMode = (mode: number): boolean => SUPPRESSED_PRIVATE_MODES.has(mode) 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 6fbdc07c..118d9949 100644 --- a/packages/app/tests/docker-git/terminal-query-suppression.test.ts +++ b/packages/app/tests/docker-git/terminal-query-suppression.test.ts @@ -1,35 +1,67 @@ import { describe, expect, it } from "@effect/vitest" -import { installTerminalQuerySuppression, isTerminalColorQuery } from "../../src/web/terminal-query-suppression.js" +import { + installTerminalQuerySuppression, + isSuppressedDecPrivateMode, + isTerminalColorQuery +} from "../../src/web/terminal-query-suppression.js" + +type FunctionIdentifier = { + readonly final: string + readonly intermediates?: string + readonly prefix?: string +} + +type CsiParam = number | ReadonlyArray + +type CsiParams = ReadonlyArray 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 + readonly identifier: FunctionIdentifier + readonly callback: (params: CsiParams) => boolean +} + +type RegisteredDcsHandler = { + readonly identifier: FunctionIdentifier + readonly callback: (data: string, params: CsiParams) => boolean } -const createMockTerminal = (): { +type MockTerminal = { + readonly csi: ReadonlyArray + readonly dcs: ReadonlyArray 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 } + readonly parser: { + readonly registerCsiHandler: ( + id: FunctionIdentifier, + cb: (params: CsiParams) => boolean + ) => { dispose: () => void } + readonly registerDcsHandler: ( + id: FunctionIdentifier, + cb: (data: string, params: CsiParams) => boolean + ) => { dispose: () => void } + readonly registerOscHandler: ( + id: number, + cb: (data: string) => boolean + ) => { dispose: () => void } } } -} => { - const osc: Array = [] +} + +const createMockTerminal = (): MockTerminal => { const csi: Array = [] + const dcs: Array = [] + const osc: Array = [] const disposedCount = { value: 0 } return { csi, + dcs, disposedCount, osc, terminal: { @@ -42,6 +74,14 @@ const createMockTerminal = (): { } } }, + registerDcsHandler: (identifier, callback) => { + dcs.push({ callback, identifier }) + return { + dispose: () => { + disposedCount.value += 1 + } + } + }, registerOscHandler: (identifier, callback) => { osc.push({ callback, identifier }) return { @@ -55,6 +95,38 @@ const createMockTerminal = (): { } } +const findCsi = (mock: MockTerminal, identifier: FunctionIdentifier): RegisteredCsiHandler => { + const handler = mock.csi.find( + (registered) => + registered.identifier.final === identifier.final && + registered.identifier.prefix === identifier.prefix && + registered.identifier.intermediates === identifier.intermediates + ) + if (handler === undefined) { + throw new Error(`Missing CSI handler for ${JSON.stringify(identifier)}`) + } + return handler +} + +const DEVICE_QUERY_IDENTIFIERS: ReadonlyArray = [ + { final: "c" }, + { final: "c", prefix: ">" }, + { final: "c", prefix: "=" }, + { final: "n" }, + { final: "n", prefix: "?" } +] + +const ADDED_CSI_IDENTIFIERS: ReadonlyArray = [ + { final: "p", intermediates: "$" }, + { final: "p", intermediates: "$", prefix: "?" }, + { final: "q", prefix: ">" }, + { final: "t" } +] + +const SUPPRESSED_MODES: ReadonlyArray = [1000, 1002, 1003, 1004, 1006, 1015, 1016] + +const PASS_THROUGH_MODES: ReadonlyArray = [25, 1007, 1049, 2004, 2026] + describe("terminal query suppression", () => { it("detects color query payloads with the '?' placeholder", () => { expect(isTerminalColorQuery("?")).toBe(true) @@ -75,15 +147,23 @@ describe("terminal query suppression", () => { expect(mock.osc.map((handler) => handler.identifier)).toEqual([4, 10, 11, 12]) }) - it("registers CSI handlers for DA1, DA2, DA3, and CPR", () => { + it("registers all required CSI suppression handlers", () => { 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: "?" } + ...DEVICE_QUERY_IDENTIFIERS, + ...ADDED_CSI_IDENTIFIERS, + { final: "h", prefix: "?" }, + { final: "l", prefix: "?" } + ]) + }) + + it("registers DCS suppression handlers for DECRQSS and XTGETTCAP", () => { + const mock = createMockTerminal() + installTerminalQuerySuppression(mock.terminal) + expect(mock.dcs.map((handler) => handler.identifier)).toEqual([ + { final: "q", intermediates: "$" }, + { final: "q", intermediates: "+" } ]) }) @@ -99,15 +179,75 @@ describe("terminal query suppression", () => { 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) + for (const identifier of DEVICE_QUERY_IDENTIFIERS) { + expect(findCsi(mock, identifier).callback([])).toBe(true) } }) + it("always consumes DECRQM, XTVERSION and window manipulation queries", () => { + const mock = createMockTerminal() + installTerminalQuerySuppression(mock.terminal) + for (const identifier of ADDED_CSI_IDENTIFIERS) { + expect(findCsi(mock, identifier).callback([2026])).toBe(true) + } + }) + + it("always consumes DECRQSS and XTGETTCAP DCS queries", () => { + const mock = createMockTerminal() + installTerminalQuerySuppression(mock.terminal) + for (const handler of mock.dcs) { + expect(handler.callback("", [])).toBe(true) + } + }) + + it("blocks DEC private mode SET for focus reporting and mouse tracking", () => { + const mock = createMockTerminal() + installTerminalQuerySuppression(mock.terminal) + const setHandler = findCsi(mock, { final: "h", prefix: "?" }) + for (const mode of SUPPRESSED_MODES) { + expect(setHandler.callback([mode])).toBe(true) + } + }) + + it("blocks DEC private mode RESET for focus reporting and mouse tracking", () => { + const mock = createMockTerminal() + installTerminalQuerySuppression(mock.terminal) + const resetHandler = findCsi(mock, { final: "l", prefix: "?" }) + for (const mode of SUPPRESSED_MODES) { + expect(resetHandler.callback([mode])).toBe(true) + } + }) + + it("lets benign DEC private modes fall through to the built-in handler", () => { + const mock = createMockTerminal() + installTerminalQuerySuppression(mock.terminal) + const setHandler = findCsi(mock, { final: "h", prefix: "?" }) + const resetHandler = findCsi(mock, { final: "l", prefix: "?" }) + for (const mode of PASS_THROUGH_MODES) { + expect(setHandler.callback([mode])).toBe(false) + expect(resetHandler.callback([mode])).toBe(false) + } + }) + + it("treats sub-parameters (nested arrays) as the parameter head", () => { + const mock = createMockTerminal() + installTerminalQuerySuppression(mock.terminal) + const setHandler = findCsi(mock, { final: "h", prefix: "?" }) + expect(setHandler.callback([[1004, 0]])).toBe(true) + expect(setHandler.callback([[25, 0]])).toBe(false) + }) + + it("exposes the suppressed private mode set", () => { + expect(isSuppressedDecPrivateMode(1004)).toBe(true) + expect(isSuppressedDecPrivateMode(1000)).toBe(true) + expect(isSuppressedDecPrivateMode(25)).toBe(false) + expect(isSuppressedDecPrivateMode(2026)).toBe(false) + }) + 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) + expect(mock.disposedCount.value).toBe(mock.osc.length + mock.csi.length + mock.dcs.length) }) }) From 50a3b96879109bf1e3a449d574a2e57855a64f72 Mon Sep 17 00:00:00 2001 From: AI Agent Date: Tue, 12 May 2026 08:52:15 +0000 Subject: [PATCH 2/2] fix(web): give 'Show system' checkbox stable id/name for a11y Chrome DevTools Issues panel flags the unlabelled in the task panel ('A form field element should have an id or name attribute'). Add 'task-panel-show-system' as both id and name so the control becomes addressable from form-submission semantics and screen readers; wire 'htmlFor' on the surrounding label so the click region stays correct. Picked from the closed PR #275 (which mis-diagnosed issue #273 as a font loading race). The probe-leak suppressor in commit efe6671 is the real fix for #273; this commit only carries the genuinely useful checkbox hygiene cleanup. terminal-image-paths.ts hunk is a formatter auto-fix applied by 'bun run lint'. Co-Authored-By: Claude Opus 4.7 --- packages/app/src/web/panel-tasks.tsx | 4 +++- packages/app/src/web/terminal-image-paths.ts | 3 +-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/app/src/web/panel-tasks.tsx b/packages/app/src/web/panel-tasks.tsx index 39811f92..8792742e 100644 --- a/packages/app/src/web/panel-tasks.tsx +++ b/packages/app/src/web/panel-tasks.tsx @@ -50,9 +50,11 @@ const TaskSystemToggle = ( onIncludeDefaultChange }: Pick ): JSX.Element => ( -