From 17dd619bd68e3b1c768890fd19435ea40766193b Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:15:22 +0200 Subject: [PATCH 01/18] fix(core): a hovered link on the alternate screen keeps its colour (#163) xterm's DOM renderer underlines a hovered link by replacing the row's contents, with no onRender, so the ink was wiped until a scroll redrew the rows. A MutationObserver inks any replaced row that holds no ink. The e2e hovers and leaves a link on the alternate screen (failed before the fix). Core 0.28.3, app 0.35.3. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t --- core/package.json | 2 +- core/renderer/lib/termLinkPaint.ts | 34 ++++++++++++++++++++++++++++-- package-lock.json | 4 ++-- package.json | 2 +- tools/e2e/run.mjs | 13 ++++++++++++ 5 files changed, 49 insertions(+), 6 deletions(-) diff --git a/core/package.json b/core/package.json index 092d71d..f071079 100644 --- a/core/package.json +++ b/core/package.json @@ -1,6 +1,6 @@ { "name": "prism-term-core", - "version": "0.28.2", + "version": "0.28.3", "description": "What Prism Terminal and Prism share: the terminal (pty, shells, agent detection and indicator, themes, links, the panel, dictation) and the update chip with its window. TypeScript source, compiled by the host.", "license": "MIT", "private": true, diff --git a/core/renderer/lib/termLinkPaint.ts b/core/renderer/lib/termLinkPaint.ts index bd6888b..d433ea9 100644 --- a/core/renderer/lib/termLinkPaint.ts +++ b/core/renderer/lib/termLinkPaint.ts @@ -163,10 +163,15 @@ export function attachLinkPaint( } } + /** Watches the rows for redraws that fire no `onRender` (below). */ + let watch: MutationObserver | undefined + const inkDrawn = (start: number, end: number): void => { if (dead || term.buffer.active.type !== 'alternate') return - const rows = term.element?.querySelector('.xterm-rows')?.children - if (!rows) return + const box = term.element?.querySelector('.xterm-rows') + if (!box) return + watchRows(box) + const rows = box.children const ink = color() for (let r = start; r <= end; r += 1) { const row = rows[r] @@ -174,6 +179,30 @@ export function attachLinkPaint( } } + // A HOVER REDRAWS A ROW BEHIND onRender's BACK (#163; owner's recording, + // 2026-10-10: a hovered link in Claude Code's fullscreen view turned white + // and stayed white until a scroll). xterm's DOM renderer underlines a hovered + // link, and takes the underline off again, by REPLACING the row's contents + // (`_setCellUnderline`), and that fires no onRender. So any row whose + // contents are replaced and that holds no ink is inked again here. Inking + // itself is a mutation too, but the row then holds ink and is left alone. + const watchRows = (box: Element): void => { + if (watch) return + watch = new MutationObserver((records) => { + if (dead || term.buffer.active.type !== 'alternate') return + const seen = new Set() + for (const rec of records) { + let row: Node | null = rec.target + while (row && row.parentNode !== box) row = row.parentNode + if (row instanceof Element) seen.add(row) + } + if (!seen.size) return + const ink = color() + for (const row of seen) if (!row.querySelector('[data-link-ink]')) inkRow(row, ink) + }) + watch.observe(box, { childList: true, subtree: true }) + } + const scan = (): void => { timer = undefined if (dead) return @@ -223,6 +252,7 @@ export function attachLinkPaint( repaint: startOver, dispose: () => { dead = true + watch?.disconnect() if (timer !== undefined) window.clearTimeout(timer) subs.forEach((s) => s.dispose()) forgetFrom(0) diff --git a/package-lock.json b/package-lock.json index f2ecb57..0fc7834 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "prism-terminal", - "version": "0.35.2", + "version": "0.35.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "prism-terminal", - "version": "0.35.2", + "version": "0.35.3", "license": "MIT", "dependencies": { "@xterm/addon-fit": "^0.11.0", diff --git a/package.json b/package.json index f93f5f0..a8e8e34 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "prism-terminal", "productName": "Prism Terminal", - "version": "0.35.2", + "version": "0.35.3", "description": "A tabbed Windows terminal for AI CLIs.", "main": "./out/main/index.js", "author": "Max", diff --git a/tools/e2e/run.mjs b/tools/e2e/run.mjs index b8f6511..cf45242 100644 --- a/tools/e2e/run.mjs +++ b/tools/e2e/run.mjs @@ -2246,6 +2246,19 @@ const scenarios = { const alt = 'https://example.org/alt/path?x=1' await typeLine(page, `Write-Host -NoNewline "$([char]27)[?1049h$([char]27)[H"; Write-Host 'open ${alt} here'`) ok(await until(async () => (await inked(BLUE)).includes(alt), 8000), 'on the alternate screen a printed link wears the link blue') + // HOVER AND LEAVE KEEP IT (#163; owner's recording, 2026-10-10: a hovered + // link in Claude Code's fullscreen view "turn[s] white" until a scroll). + // xterm underlines a hovered link by REPLACING the row's contents, with + // no onRender, which wiped the ink. + const box = await page.locator('.xterm-rows > div span', { hasText: 'example.org' }).first().boundingBox() + if (box) { + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2) + await new Promise((r) => setTimeout(r, 600)) + ok((await inked(BLUE)).includes(alt), 'a hovered link on the alternate screen keeps the link blue') + await page.mouse.move(box.x + box.width / 2, box.y + box.height * 6) + await new Promise((r) => setTimeout(r, 600)) + ok((await inked(BLUE)).includes(alt), 'and keeps it once the pointer leaves, with no scroll') + } else ok(false, 'the alternate-screen link is on screen to hover') await typeLine(page, `Write-Host -NoNewline "$([char]27)[H$([char]27)[2Kplain words where the link was"`) ok( await until(async () => !(await inked(BLUE)).some((t) => t.includes('plain') || t.includes('words')), 8000), From a10c86afaffa3b7c320da10541d869fb74e8b77f Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:00:11 +0200 Subject: [PATCH 02/18] docs: spec and plan for the Claude Code compatibility fixes (#167-#177) One spec and one plan for the eleven issues from the 2026-10-10 compat research, in five build groups: G1 #167; G2 #168 + #172; G3 #169 + #173; G4 #171 + #176 + #177 + #174; G5 #170 + #175. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t --- .../plans/2026-10-10-claude-code-compat.md | 236 ++++++++ .../2026-10-10-claude-code-compat-design.md | 539 ++++++++++++++++++ 2 files changed, 775 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-10-claude-code-compat.md create mode 100644 docs/superpowers/specs/2026-10-10-claude-code-compat-design.md diff --git a/docs/superpowers/plans/2026-10-10-claude-code-compat.md b/docs/superpowers/plans/2026-10-10-claude-code-compat.md new file mode 100644 index 0000000..4d738fa --- /dev/null +++ b/docs/superpowers/plans/2026-10-10-claude-code-compat.md @@ -0,0 +1,236 @@ +# Plan: Claude Code compatibility fixes (#167 to #177) + +Spec: `docs/superpowers/specs/2026-10-10-claude-code-compat-design.md` (section numbers below are +its). Evidence: `C:\Users\Admin\Documents\Claude\research\prism-terminal\2026-10-10-claude-code-compat.md`. +Branch `fix/cc-compat`, worktree `.claude/worktrees/cc-compat`, stacked on `fix/163-link-ink` +(PR #165). Versions: core 0.28.3 to **0.32.0**, app 0.35.3 to **0.38.0**, bumped in G1. + +Rules for every task: work only in the worktree, absolute paths for every file write. Test first, +run it RED, then the code (pure logic always gets a unit test). No em-dashes. Comments say WHY, +with the measurement when there was one. `core/` stays lint-walled (relative imports, no +`window.prism`, no `electron`, no host `src/`). `npm test`, `npm run typecheck`, `npm run lint` green +at the end of each group. Commits end with: + +``` +Co-Authored-By: Claude Opus 5.5 +Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t +``` + +No push, PR or merge. Never close or touch `PrismTerminalStable` or a PrismTerminal you did not +start. **Only the Gate agent runs e2e, one run at a time**; build groups add scenarios and run the +unit suite, typecheck and lint only. The owner is visually impaired and zoomed in: anything visible +(link colour, badge) is checked against the theme's contrast floor, never eyeballed. + +## Task 0. Setup (once) + +- The worktree has no `node_modules`: `npm ci` in the worktree (never a junction to the main + checkout's, which another session may be using). +- `npm test`, `npm run typecheck`, `npm run lint` green on the untouched branch; note any + pre-existing failure in the G1 commit message rather than fixing it here. + +## G1. #167, the link painter stall (spec section 2) + +1. **Versions.** `core/package.json` 0.32.0; `npm version 0.38.0 --no-git-tag-version` (package.json + and package-lock.json). Commit `chore: bump core 0.32.0 and app 0.38.0 (#167-#177)`. +2. **Red tests.** `core/renderer/lib/termPathLinks.test.ts`: configure the core with a fake + `termPathKinds` (see `core/renderer/host.test.ts` for `configureTermCore`/`resetTermCore`), use + fake timers for `BATCH_MS`: + - owner A asks `a.txt` (hit), owner B has a listener: A's listener fires, B's does not; + - A asks `b.txt`, B asks `b.txt` while A's is in flight: both fire once; + - a miss fires nobody; + - `takeAsked('A')` is true once after a queuing `linkRanges`, false after a fully cached one. + New `core/renderer/lib/linkScanPlan.test.ts`: `liveRange`; `sliceRows` with an injected clock + (+1 ms per line, budget 8) walks a 10,000-line backlog bottom up in slices of at most 8 lines, + every line once; a cancelled plan yields nothing. +3. **Code.** + - `termPathLinks.ts`: `listeners` becomes `Map>`; `queued` holds + `Map>>`; `inFlight` `Map>` (a later asker joins the + key's owners); `flush` collects the owners of the hit keys and calls only theirs; + `linkRanges(text, cwd, owner?)`; `onPathsFound(owner, fn)`; `takeAsked(owner)`; + `resetPathCache` clears the new maps. Comment: the measured 2 s stalls (diag log 2026-10-10 + 16:58Z) and why an un-asked tab must not hear it. + - New `core/renderer/lib/linkScanPlan.ts` (pure). + - `termLinkPaint.ts`: `attachLinkPaint(term, color, find, opts?: { asked?(): boolean })`; + `waiting` markers (cap 500) recorded in `paintLogical` when `opts.asked?.()` is true; + `forgetLines(first, last)`; `revisit()` on `LinkPainter`; `startOver` paints the live range now + and schedules the scrollback backlog via `sliceRows` with `setTimeout(0)` between slices and a + marker as the slice cursor; a new `startOver` cancels the backlog; `dispose` cancels it. + Alternate screen: `revisit()` refreshes only rows whose text holds a found path. Keep #163's + `watchRows` exactly as merged in PR #165. + - `crumb('link-paint', { ms, lines })` when a slice or the live pass took over 50 ms (no text). + - `TerminalPanel.tsx`: the painter's `find` becomes `(text) => linkRanges(text, cwdNow, id)`, + `opts.asked: () => takeAsked(id)`; `:835` becomes `onPathsFound(id, () => links.revisit())`. +4. **e2e** `tools/e2e/run.mjs`, new scenario **`linkPaintStall`** after `pathLinks` (spec section 2 + Tests): two tabs; tab B prints 10,000 dotted lines and settles; a `PerformanceObserver` for + `longtask` installed with `page.evaluate` records durations on `window.__longTasks`; tab A + creates `found-167.txt` and prints its name; wait for its span in the link colour; assert no long + task over 200 ms in the 5 s after; then pick Fawn and back (full passes over B's 10,000 lines): + no long task over 200 ms, and B's top line's link is painted within 10 s after scrolling to the + top. Add `linkPaintStall: 300000` to `SLOW` if the 10,000-line print needs it. Note in the + scenario comment that it FAILED on the pre-fix code (the Gate confirms by running it on the + parent commit if asked). +5. **Docs.** `docs/regression-rules.md` `#path-links` and `#links-painted`: the two sentences of spec + section 2 Docs. +6. **Verify and commit** `fix(core): a found path repaints only the tab and lines that asked (#167)`. + +## G2. #168 OSC 10/11 and #172 mode 2031 (spec sections 3 and 4) + +1. **Measure** (scratchpad, research harness `compat/runclaude.js`, bundled ConPTY): does Claude + 2.1.296 send DECRQM `?2031$p` before trusting 2031? Record yes/no for the commit message. +2. **Red tests.** New `core/renderer/lib/termReplies.test.ts`: `oscColour`, `colourQueryReplies` + (one query, `?;?`, a SET is null), `groundMode` per preset ground, `dsrThemeReply`, + `decrqmReply`, `modeParams`, `themePush` (off sends nothing; first change sends; same mode + again sends nothing; after `?2031l` nothing). `src/renderer/src/lib/chromeTheme.test.ts`: for + every preset, `groundMode(bg)` (from `@core/renderer/lib/termReplies`) equals `chromeTokens`'s + `mode`. +3. **Code.** + - New `core/renderer/lib/termReplies.ts` with the functions above (threshold 0.4, comment naming + `chromeTheme.ts:108` and rule 15). + - `TerminalPanel.tsx` `createSession`: OSC 10 and 11 handlers (answer a full query from + `groundedTheme()`, true; else false); `?h`/`?l` handlers tracking 2031 (return false); + `?n` handler answering 996 (true; else false); `?$p` handler answering 2031 (true; else + false). Every reply via `term.input(reply, false)`. `Session.tellTheme(mode)`; `applyLook` + computes `groundMode` once and calls it for each session, sending through `themePush`. + - Comment at each: the measurement (xterm answered `rgb:0000/0000/0000`; Claude writes `?2031h` + and re-queries only on the push) and rule `replies-only-when-asked`. +4. **e2e** new scenario **`termReplies`** (G4 extends it): a node probe `replies-probe.cjs` in the + world folder, raw stdin, prints every chunk it reads as one line `IN ` (ESC as `\e`, BEL + as `\a`), and on start writes `CSI ? 2031 h`, `ESC]11;?BEL`, `ESC]10;?BEL`, `CSI ? 996 n`, + `CSI ? 2031 $ p`, then `CSI c` (DA1, a sentinel every terminal answers). Keys: `t` writes + `CSI ? 996 n` again; `q` exits. Assertions on PT Default: the OSC 11 reply is PT Default's ground + (`rgb:1212/1212/1212`), OSC 10 its text; `?997;1n`; `?2031;1$y` (once `?2031h` was seen); the DA1 + reply comes LAST. Pick Fawn in Settings (as `scrollbar` does): exactly one `IN \e[?997;2n` + arrives; a new OSC 11 query (key `t` then the probe re-asks) answers Fawn's ground. Back to PT + Default: one `?997;1n`. A font size change: nothing new arrives within 1 s. +5. **Docs.** `docs/regression-rules.md`: the new `` entry (spec + section 15). `CLAUDE.md` rule 2 one-liner: append "; a program gets only replies to what it + asked" linking it. +6. **Verify and commit** `fix(core): tell programs the real ground, and push theme changes (#168, #172)`. + +## G3. #169 OSC 8 links and #173 FORCE_HYPERLINK (spec sections 5 and 6) + +1. **Red tests.** New `core/renderer/lib/termOsc8.test.ts` (spec section 5 Tests). + `core/main/terminal.test.ts` `ptyEnv`: `FORCE_HYPERLINK` is `'1'`; a user's `'0'` kept; a + `force_hyperlink` spelling is kept, not duplicated. +2. **Code, #169 first.** + - New `core/renderer/lib/termOsc8.ts`: `parseOsc8`, `Osc8Spans`, `isWebLink`. + - `TerminalPanel.tsx`: `linkHandler` option (left click, http(s) only, + `allowNonHttpProtocols: false`, never `confirm`); OSC 8 handler returning false that records + spans (positions from `term.buffer.active`, text through `termCells`); spans cleared on + `onBufferChange` (alternate ones) and `onResize`; `termContextAt` returns a span's uri as + `link`. + - `termLinkPaint.ts`: `opts.spans?(first, last)` painted on the normal screen while the text + matches; the alternate-screen inker inks span cells on drawn rows (cells to text offsets per + `termCells`, rule 11), only while the text matches. +3. **Then #173.** `core/main/terminal.ts` `ptyEnv`: `FORCE_HYPERLINK=1` unless set (case-blind, the + spelling already there). **Measure** who reads it: `rg -a FORCE_HYPERLINK` over the Claude + 2.1.296 binary and over the `node_modules` of this repo (as a sample of common node CLIs); list + the readers in the commit message and in the regression entry. +4. **e2e** new scenario **`osc8Links`** (spec sections 5 and 6 Tests): a `page.on('dialog')` recorder + (any dialog fails); the pwsh OSC 8 line; colour by position equals the link colour; right click + shows Copy link, records no opened link; left click records `https://example.com/osc8` once; a + `file:///C:/x` OSC 8 link records nothing; the alternate-screen probe repeats colour and click; + `FH-1` printed. Existing **`links`** and **`dropAndMenu`** stay green (the menu over a plain URL is + unchanged). +5. **Docs.** `docs/regression-rules.md` `#links-painted`: "OSC 8 links (#169) open on a LEFT click, + http(s) only, never through a confirm; their cells are painted from the stream while their text + stands; FORCE_HYPERLINK=1 is set for every shell unless the user set it (#173; readers: ...)". +6. **Verify and commit** twice: `fix(core): OSC 8 links open on a left click and wear the link colour (#169)`, + then `feat(core): advertise hyperlinks to programs in a tab (#173)`. + +## G4. #171 XTVERSION, #176 OSC 52, #177 the bell, #174 hidden resize (spec sections 7 to 10) + +1. **Measure** (#171): Claude 2.1.296 in a dev build of this branch with `--debug-file`, a minute of + streaming: `synchronizedOutput=yes`? how many `?2026h`? (After the code in step 3, before the + commit; numbers go in the commit message and the `replies-only-when-asked` entry.) +2. **Red tests.** + - `termReplies.test.ts`: `xtversionReply`, its name not starting with `xterm.js`; `bellGate`. + - New `core/shared/termVersion.test.ts`: `TERM_CORE_VERSION` equals `core/package.json`'s + version (0.32.0); `XTERM_VERSION` equals `node_modules/@xterm/xterm/package.json`'s (6.0.0). + - New `core/renderer/lib/termOsc52.test.ts` (spec section 8 Tests). + - `core/main/ipc.clipboard.test.ts`: the `clipboard:term-write` channel (string, empty, + non-string, `OSC52_MAX + 1`); `term:bell` calls `attention` when given and is a no-op without. + - New `src/main/bellFlash.test.ts`. +3. **Code.** + - New `core/shared/termVersion.ts`. XTVERSION handler `{ prefix: '>', final: 'q' }` in + `createSession`, via `term.input`. + - New `core/renderer/lib/termOsc52.ts`; OSC 52 handler (true for every 52, handled or refused, + so nothing is drawn); `core/shared/channels.ts` `clipboardTerm`, `bell`; + `core/preload/api.ts` `writeClipboardFromTerm`, `termBell`; `core/renderer/host.ts` `TermApi` + optional members with comments; `core/main/ipc.ts` the two handlers, `OSC52_MAX`, + `TermIpcDeps.attention?`. Success raises `announceCopied()`. + - `term.onBell` through `bellGate` to `termApi().termBell?.(id)`. + - New `src/main/bellFlash.ts`; `src/main/index.ts` passes `attention`; under `--e2e` it records + `globalThis.__e2eFlashes` (count) and never calls `flashFrame`. + - #174, after the e2e below has run red at the Gate (or been recorded as not reproducing): the + deferred refit in the attach effect (`requestAnimationFrame` then `setTimeout(0)`, cancelled + in cleanup, `suppressActivity` first), plus `term.refresh(0, rows - 1)` only if the e2e needs + it. No xterm internals. +4. **e2e.** + - **`termReplies`** extended: the probe also writes `CSI > q`, `CSI > 0 q`, `CSI > 1 q` before the + DA1 sentinel; exactly two `\eP>|PrismTerminal 0.32.0 (xterm.js 6.0.0)\e\\` replies arrive, none + for `> 1 q`. + - New **`termClipboard`** (spec section 8 Tests; save and restore the clipboard as `paste` does). + - New **`bell`** (spec section 9 Tests; reads `__e2eFlashes` with `app.evaluate`). + - New **`hiddenResize`** (spec section 10 Tests; the alternate-screen probe enables `?1049h` and + `?1000h`/`?1006h`, draws every row, stays alive until `q`). +5. **Docs.** `PRIVACY.md` paragraph (spec section 8 Docs). `core/README.md`: in "The contract", a + short paragraph: the bell (`attention` in `registerTermIpc`; without it a host is silent), the + OSC 52 channel (automatic through `createTermApi` / `registerTermIpc`), and under "Releasing the + core": "bump `core/shared/termVersion.ts` with `core/package.json`; `termVersion.test.ts` fails + otherwise". `docs/regression-rules.md`: `` (spec section 9 Docs); the + XTVERSION measurement and OSC 52's write-only rule added to `replies-only-when-asked`. + `docs/two-apps.md` `#terminal-change-in-core` or a new short entry: the bell needs Prism's one + line (spec section 13). +6. **Verify and commit** four commits, in order: `feat(core): answer XTVERSION so Claude can use synchronized output (#171)`, + `feat(core): write-only OSC 52 copies through main (#176)`, + `feat: flash the taskbar on a bell while the window is unfocused (#177)`, + `fix(core): refit once the renderer resumes after a hidden resize (#174)`. + +## G5. #170 the image key and #175 Shift+Enter (spec sections 11 and 12) + +1. **Measure** (#175, research harness, bundled ConPTY): for Claude 2.1.296 and codex, `'\\\r'`, + `'\n'`, `'\x1b\r'` after typing `ab`: newline without submit? stray backslash? Write the table + into the commit message; pick per the spec's decision rule. +2. **Red tests.** `termPaste.test.ts`: `imagePasteKey` (claude `'\x1bv'`; codex, other, null + `'\x16'`); `newlineKey` per the table. `agentTitle.test.ts`: `titleAgent` cases (spec section 12 + Tests); every existing `readAgentTitle` and `useAgentIndicator`-backed test unchanged. +3. **Code.** + - `termPaste.ts`: `imagePasteKey`, `newlineKey` (comments: the binary's `alt+v` on Windows, the + owner's bindings binding both, why ONE key). + - `agentTitle.ts`: extract `titleAgent`, `readAgentTitle` uses it. + - `TerminalPanel.tsx`: `agentKind` beside `agentHere` (from `onTermAgent`'s kind, the resume, + the title via `titleAgent` on `onTitleChange`; only the poll's "left" clears both); + `pasteHere` sends `imagePasteKey(agentKind)`; Shift+Enter sends `newlineKey(agentKind)`, still + inside `attachCustomKeyEventHandler`, with `markTouched` (rule 10 unchanged). + - `core/shared/help/agents.ts` `claude-paste-image` summary (spec section 11); + `claude-new-line` summary only if the measured bytes change what it says. +4. **e2e.** New **`imagePaste`** (spec section 11 Tests; clipboard saved and restored). Existing + **`reviewKeys`** extended (spec section 12 Tests). **`helpPanel`** stays green (the catalogue + entry's text changed). +5. **Docs.** `docs/regression-rules.md` `#cells-not-characters`: "Shift+Enter sends + where an agent runs, armed by the agent's title before the poll (#175); an image on Ctrl+V sends + the AGENT's key, Alt+V to Claude and ^V otherwise, exactly one (#170)". +6. **Verify and commit** `fix(core): send Claude its own image-paste key (#170)` and + `fix(core): Shift+Enter's newline, armed by the agent's title (#175)`. + +## The Gate + +After G5, the Gate agent runs, one at a time, `npm run e2e -- ` for each scenario below, then +the whole suite once (`npm run e2e`), and LOOKS at `.e2e-shots/` for any settings or panel shot the +run writes. A red scenario goes back to its group with the failure; a stall of 1 s or more in the +report table against a new scenario is a failure for `linkPaintStall` and a note for the rest. + +New: `linkPaintStall`, `termReplies`, `osc8Links`, `termClipboard`, `bell`, `hiddenResize`, +`imagePaste`. +Extended or at risk: `reviewKeys`, `pathLinks`, `links`, `dropAndMenu`, `paste`, `scrollbar`, +`theme`, `themeSwitch`, `pickedGround`, `agentHooks`, `attention`, `helpPanel`, `launchSkeleton`, +`restore`, `diagLog`. + +Then, per CLAUDE.md, the last verification is an install of this build (`npm run package`, kill +only `PrismTerminal` and `PrismTerminal-Setup*` processes, silent setup, poll the exe's +LastWriteTime) and a live check with a real Claude in a tab: Fawn theme, Claude `"theme": "auto"` +picks light; switching to PT Default turns Claude dark without a restart; a "PR #nnn" link is blue +and opens on a left click; Ctrl+V with a screenshot attaches it with default Claude keybindings. +The PR (opened by the orchestrator, not here) says the core changes reach Prism and that Prism needs +the one `attention` line for the bell. diff --git a/docs/superpowers/specs/2026-10-10-claude-code-compat-design.md b/docs/superpowers/specs/2026-10-10-claude-code-compat-design.md new file mode 100644 index 0000000..d5b16d4 --- /dev/null +++ b/docs/superpowers/specs/2026-10-10-claude-code-compat-design.md @@ -0,0 +1,539 @@ +# Claude Code compatibility fixes (#167 to #177) + +Status: the owner said "do all these fixes" for the eleven issues (2026-10-10). This spec and the +plan (`docs/superpowers/plans/2026-10-10-claude-code-compat.md`) are written together for one +approval. Where a choice was the owner's and was delegated (#177, the bell) or had to be made to +build at all, it is listed in section 14 as a decision made, with the reason. + +Evidence: `C:\Users\Admin\Documents\Claude\research\prism-terminal\2026-10-10-claude-code-compat.md` +(Claude Code 2.1.296 in fullscreen, PT 0.35.1, xterm.js 6.0.0, node-pty 1.2.0-beta.15, bundled +ConPTY). Each issue body links it. + +Branch `fix/cc-compat`, worktree `.claude/worktrees/cc-compat`, stacked on `fix/163-link-ink` +(PR #165, alternate-screen link ink in `core/renderer/lib/termLinkPaint.ts`). Versions: core +`0.28.3` to **0.32.0**, app `0.35.3` to **0.38.0** (minor: new behaviour; above every open PR). + +**Every change below except the main-process wiring in `src/main/index.ts` is in `core/`, so it is a +change to Prism too** (section 13). The core lint wall holds: relative imports, no `window.prism`, +no `electron`, no host `src/`. + +## 1. What the eleven have in common + +Claude Code talks to its terminal in-band: it ASKS (OSC 10/11 colours, XTVERSION, DSR 996, DECRQM), +it TELLS (OSC 8 links, OSC 52 copies, BEL, mode 2031) and it reads KEYS (Alt+V, Shift+Enter). PT +answered some of that wrongly (black ground, #168), none of some (#171, #172, #176, #177), and opened +nothing for some (#169, #173). One problem is ours alone (#167, the painter stall). Two are xterm.js +gaps PT works around (#174, #175). + +A new must-not-regress rule comes out of it (section 12): **the app answers a program only what it +asked**. Every byte the core writes to a pty without a key is a reply to a query in that pty's own +stream (or the 2031 report the program turned on), never contains CR or LF, and is never counted as +typing. + +## 2. #167 (high): one tab's found path rescans every tab + +### What exists + +- `core/renderer/lib/termPathLinks.ts:29` one module-wide `listeners` set; `onPathsFound` (`:41-46`) + adds to it; `flush` (`:48-72`) fires EVERY listener when any batch finds a hit (`:67`). +- `core/renderer/components/TerminalPanel.tsx:835` every session subscribes + `onPathsFound(() => links.repaint())`. +- `core/renderer/lib/termLinkPaint.ts:252` `repaint` is `startOver` (`:233-240`): `forgetFrom(0)`, + drops the `finished` marker, so the next `scan` (`:206-227`) starts at line 0 and walks the whole + normal buffer (scrollback 10,000, `TerminalPanel.tsx:605`) cell by cell in ONE task. On the + alternate screen it also forces `term.refresh(0, rows - 1)`. +- `termPathLinks.ts:21` a "no" expires after 15 s, so the next pass asks again and the loop can + repeat. +- MEASURED (Stable's diag log, 2026-10-10 16:58Z): `page-stack` 2009 ms and 2046 ms, both in `scan`, + around `ipc-slow term:path-kinds 2153ms`; page stalls 1.8 to 2.2 s in the same minute. + +### The change + +1. **Found paths reach only the session that asked.** `linkRanges(text, cwd, owner)` takes the + session id; `request` records, per queued key (`cwd\npath`), the set of owners that asked. + `onPathsFound(owner, fn)` registers per owner (a `Map>`); `flush` collects the + owners of the keys that came back as hits and calls only theirs. An owner that asked for a path + another owner's batch already has in flight is added to that key's owners, so it hears the + answer too (`inFlight` becomes a `Map>`). +2. **A hit repaints the lines that asked, not the buffer.** The painter remembers each logical line + whose `find` call queued a question (a WAITING line): `attachLinkPaint` takes an optional + `opts.asked?: () => boolean`, which the panel wires to a new `takeAsked(owner)` from + `termPathLinks` (true when the last `linkRanges` for that owner queued anything; reading it + clears it). Per waiting line the painter keeps a marker on its first row (cap 500, oldest + dropped). `LinkPainter` gains `revisit()`: for each waiting marker still alive, forget the + decorations on THAT logical line only (new `forgetLines(first, last)`) and paint it again; + lines on the live screen are left to the next ordinary pass (`soon()`), which already repaints + from `finished` down. On the alternate screen `revisit()` redraws only the rows whose text holds + one of the found paths (a `term.refresh(row, row)` per row), not the whole screen. + The panel subscribes `onPathsFound(id, () => links.revisit())`. +3. **No single pass blocks for long.** A FULL pass (`startOver`: resize, theme, buffer change) + paints the live screen first, synchronously as today (at most `rows` lines), then the + scrollback from the bottom UP in slices of at most **8 ms** each (`performance.now()` budget, + checked every logical line), yielding with `setTimeout(0)` between slices. The slice cursor is a + marker (the buffer trims under it; a disposed marker ends the backlog). A new `startOver` + cancels the pending backlog. `finished` keeps its meaning (everything above it that is not in + the backlog has been painted). The incremental pass (`soon` on `onWriteParsed`) is unchanged: + it covers `finished` to the bottom of the live screen. +4. A crumb: when a slice or the live-screen pass took over 50 ms, `crumb('link-paint', { ms, + lines })` (the diag log already has `crumb`; it names no text). Lets the next stall report say + whether the painter is involved. + +The pure parts go in a new `core/renderer/lib/linkScanPlan.ts`: `liveRange(baseY, rows, length)`, +`sliceRows(from, to, budgetMs, now)` (the bottom-up walk with a clock passed in) and the owner +bookkeeping in `termPathLinks` itself. + +### Tests + +- `termPathLinks.test.ts` (failing first on today's code): with a fake `termPathKinds` through + `configureTermCore`, a hit asked by owner A calls A's listener and NOT B's; a key asked by A and + then by B while in flight calls both; a "no" calls nobody; `takeAsked` is true once after a + queuing call and false after a fully cached one. +- `linkScanPlan.test.ts`: the live range; a 10,000-line backlog with a clock that advances 1 ms + per line comes out in slices of at most 8 lines, bottom up, covering every line once; a cancelled + plan yields nothing more. +- e2e **`linkPaintStall`** (new, fails on today's code): tab B prints 10,000 lines of dotted text + (`1..10000 | % { "line $_ see docs/x.md and a.b" }`) and settles; tab A (in front) creates a file + and prints its name so a path is found. A `PerformanceObserver({ type: 'longtask' })` installed in + the page before the print records every long task; assert none over **200 ms** within 5 s of the + path lighting up, and that the path in A is painted (the `pathLinks` way: a span of the link + colour by position). Also a theme switch with B's 10,000 lines: no long task over 200 ms, and B's + topmost painted link (scroll to top) is painted within 10 s (the backlog finished). +- Existing **`pathLinks`** and **`links`** stay green (a path still lights up; a scrolled-off path + still paints once found). + +### Docs + +`docs/regression-rules.md` `#path-links` and `#links-painted`: add "a found path repaints only the +tab that asked and only the lines that asked (#167); a full pass paints the live screen, then the +scrollback in slices of 8 ms or less, so no paint blocks the page (MEASURED 2 s stalls before)". + +## 3. #168 (medium): OSC 10/11 answer transparent black + +### What exists + +`core/renderer/lib/termXterm.ts:50-52`: where the panel paints the ground (rule 14), xterm's theme +background is `#00000000`; xterm answers `ESC]11;?` itself from that, dropping the alpha: +`rgb:0000/0000/0000` (MEASURED), so Claude's `"theme": "auto"` always picks dark. The panel +registers OSC 9 and 777 only (`TerminalPanel.tsx:746`, `:758`). + +### The change + +A new pure module `core/renderer/lib/termReplies.ts` (what the terminal answers a program): + +- `oscColour(hex)`: `#rrggbb` (alpha dropped, `opaque()` from `colour.ts`) to + `rgb:rrrr/gggg/bbbb` (each byte doubled, xterm's own form). +- `colourQueryReplies(ident: 10 | 11, data: string, colours: { fg: string; bg: string })`: + `string[] | null`. OSC 10 and 11 take a list: `10;?;?` asks for 10 then 11. Every item must be `?` + for this to answer (returns null otherwise, and the handler returns false so xterm handles a SET + as it always did). Each reply is `ESC ] ; rgb:... ESC \`. +- `groundMode(bg)`: `'light' | 'dark'`, `luminance(opaque(bg)) > 0.4` is light: the SAME + measurement `src/renderer/src/lib/chromeTheme.ts:108` makes for the chrome (rule 15: measured from + the ground, never read off a name). The core cannot import the host's file; a comment names it, + and a unit test pins that both agree on every preset. + +In `createSession`: `term.parser.registerOscHandler(10, ...)` and `(11, ...)`: on a full query, +answer with `groundedTheme()`'s `foreground` and `background` (the theme on the ground the panel +really paints, a picked Background included) and return true. Replies go through +`term.input(reply, false)`: the same `onData` path xterm's own replies take, so ordering with a +DA1 sent right after is kept, and `looksTyped` (starts with ESC) never counts it as typing. + +In Prism (`paintsGround: false`) the canvas carries the ground unless an acrylic style clears it; +the answer is the same `groundedTheme()` either way, so Prism's answer is right too. + +### Tests + +- `termReplies.test.ts` (failing first: the module does not exist): `#f5f5f0` gives + `rgb:f5f5/f5f5/f0f0` (the value MEASURED from xterm on an opaque light ground); `#f5f5f0cc` the + same; `colourQueryReplies(10, '?;?', ...)` gives two replies, 10 then 11; `(11, 'rgb:00/00/00')` + is null; `groundMode` is light for the light presets (Fawn, Paper) and dark for PT Default and + Prism. The core test cannot import the host's chromeTheme (lint wall), so the agreement is pinned + from the host side: `src/renderer/src/lib/chromeTheme.test.ts` gains a check that + `groundMode(bg)` from `@core` gives the same mode as `chromeTokens` for every preset's ground. +- e2e **`termReplies`** (new; the probe is described in the plan, G2): the probe asks `ESC]11;?` + and `ESC]10;?` on PT Default + and on Fawn; the replies are the themes' grounds and inks, never `rgb:0000/0000/0000` on Fawn. + +## 4. #172 (low): a theme switch never reaches Claude (mode 2031) + +### What exists + +Claude writes `CSI ? 2031 h` (MEASURED) and re-queries OSC 11 only when the terminal pushes +`CSI ? 997 ; 1 n` (dark) or `; 2 n` (light). xterm 6.0.0 has no 2031, 996 or 997. `applyLook` +(`TerminalPanel.tsx:419-433`) restyles each session and tells the program nothing. + +### The change + +- Per session, in `createSession`: `let themeReports = false; let lastMode: 'dark'|'light'|null`. + CSI handlers `{ prefix: '?', final: 'h' }` / `'l'` (like `attachClickCaret`'s DECTCEM ones, which + return false so xterm still applies its own modes) set `themeReports` when the params hold 2031, + and return false. +- `CSI ? 996 n` (`registerCsiHandler({ prefix: '?', final: 'n' })`, params `[996]`): answer + `CSI ? 997 ; 1|2 n` from `groundMode(groundedTheme().background)`, set `lastMode`, return true. + Anything else returns false (xterm keeps DSR 6 and the rest). +- `DECRQM` for 2031 (`{ prefix: '?', intermediates: '$', final: 'p' }`, params `[2031]`): answer + `CSI ? 2031 ; 1|2 $ y` (set / reset) and return true; anything else false. Claude may probe + before it trusts 2031; whether it does is MEASURED in the plan (G2 step 1) and the result goes in + the commit message. +- `Session` gains `tellTheme(mode)`. `applyLook` computes `groundMode` once and calls it for every + session; it writes `CSI ? 997 ; n n` only when `themeReports` is on AND the mode differs from + `lastMode` (a font change or a same-mode theme sends nothing). A host style change and a picked + ground reach it through `applyLook` already (`watchHostStyle`, `onChromeChange`). +- Pure, in `termReplies.ts`: `dsrThemeReply(mode)`, `decrqmReply(mode, set)`, + `modeParams(params)` (flattens xterm's `(number | number[])[]`), and `themePush(state, mode)` + returning `{ send: string | null; state }` so the "only on a change, only while on" rule is + tested. +- `?2031l` stops the pushes; a buffer switch keeps the mode (Claude turns it on once). + +### Tests + +- `termReplies.test.ts`: `dsrThemeReply('dark') === '\x1b[?997;1n'`, light `;2n`; + `decrqmReply(2031, true) === '\x1b[?2031;1$y'`; `themePush` sends nothing while off, sends on the + first change, nothing for the same mode again, nothing after `?2031l`. +- e2e **`termReplies`**: the probe sends `?2031h`, then `?996n` (reply `?997;1n` on PT Default); + the scenario picks Fawn in Settings; the probe receives `?997;2n` exactly once; back to PT Default + gives `?997;1n`; a font size change sends nothing. + +## 5. #169 (medium): OSC 8 links open nothing + +### What exists + +`TerminalPanel.tsx:603-623` passes no `linkHandler`. xterm 6.0.0's default for an OSC 8 link is +`confirm()` then a blank `window.open()`, which main denies (not http(s), +`src/main/index.ts:372-375`), for a click of ANY button. OSC 8 cells are not painted: the painter +only finds URLs and paths in text, and Claude's labels ("#165") are neither. + +### The change + +1. `linkHandler` on the `Terminal` options: + `{ activate: (e, uri) => { if (e.button === 0 && isWebLink(uri)) termApi().openExternal(uri) }, + allowNonHttpProtocols: false }`. A left click opens; the right one is the menu's (owner, + 2026-09-28, the same rule as the web-links addon at `:637-641`). No confirm, ever. `isWebLink` is + `/^https?:\/\//i` (the preload and main check again). `file://` and other schemes stay inert: + xterm ignores them with `allowNonHttpProtocols: false`, and a path in the text still lights up + through #99's painter. +2. **OSC 8 cells wear the link colour.** xterm keeps a cell's link id internally (`urlId`) with no + public API, so the panel FOLLOWS the stream: `registerOscHandler(8, ...)` returning **false** + (xterm still records the link) notes, on an open with an http(s) uri, the cursor's absolute + position and the buffer, and on the close the end position and the text between (read from the + cells through `termCells`, rule 11). A span is `{ buffer, start: {line, x}, end: {line, x}, uri, + text }`. Pure parser and bookkeeping in a new `core/renderer/lib/termOsc8.ts`: `parseOsc8(data)` + (`params;uri`, empty uri is a close, malformed is null), `Osc8Spans` (add, at(line, x), on a + row, cap 300, an alternate-screen span replaces an older one over the same cells, cleared on a + buffer switch and on a resize, since a reflow moves cells). + - Normal screen: the painter takes the spans through a new optional `opts.spans?(first, last)` + and paints each like a found link (`paintRow`), ONLY while the cells still hold the recorded + text (checked on every pass). Spans ride a marker on their start line, so scrollback keeps + them as it keeps everything else. + - Alternate screen (Claude's fullscreen view, where the status line's "PR #165" lives): + `inkDrawn` / `watchRows` ink the cells of each span on a drawn row, columns converted to the + row's text offsets cell by cell (`termCells`), again only while the text matches. + - A redraw of the same label without OSC 8 at the same cells keeps the ink until the row + changes: accepted, it is the same label. +3. The right-click menu: `termContextAt` (`:385-406`) returns the OSC 8 span's uri as `link` when + the point is inside one, so Copy link and Open link work on "#165" too. + +### Tests + +- `termOsc8.test.ts` (failing first): `parseOsc8('id=x;https://a.b/c')` gives the uri; + `';'` is a close; `'garbage'` is null; spans: `at` hits inside, misses outside, a newer alternate + span replaces an overlapping one, cap holds, a wide character counts two cells. +- e2e **`osc8Links`** (new): a pwsh tab writes + `[Console]::Write([char]27 + ']8;;https://example.com/osc8' + [char]7 + 'OSC8LABEL' + [char]27 + ']8;;' + [char]7)`. + A `page.on('dialog')` recorder is armed: no dialog ever. The label's cells wear the link colour + (found by POSITION, as `links` does). A RIGHT click opens the menu with Copy link and records + nothing in `__e2eOpenedLinks`; a LEFT click records `https://example.com/osc8` exactly once. Then + a node probe on the alternate screen (`?1049h`, the label on row 5, kept alive) shows the same + colour and the same left-click result. A `file:///C:/x` OSC 8 link opens nothing. + +## 6. #173 (low): FORCE_HYPERLINK + +### What exists + +`core/main/terminal.ts:161-180` `ptyEnv` sets `TERM` and `COLORTERM` only. Claude emits OSC 8 only +for `FORCE_HYPERLINK`, `WT_SESSION` or a known terminal name; otherwise "label (long url)". + +### The change + +`env.FORCE_HYPERLINK = '1'` in `ptyEnv`, unless the user's own environment already sets it (any +value, `0` included, is kept: it is their choice). Lands in the SAME build group as #169 and after +its handler, so no link ever becomes a dead confirm box. Who else reads it, to be MEASURED in G3 +(grep the Claude binary and a sample of common CLIs' `node_modules` for `FORCE_HYPERLINK`): the +`supports-hyperlinks` npm package (via `terminal-link`, so many node CLIs) switches to OSC 8 too, +which is the point; the list found goes in the commit message and `docs/regression-rules.md`. + +### Tests + +- `terminal.test.ts` `ptyEnv` (failing first): sets `FORCE_HYPERLINK=1`; keeps a user's `0`; keeps + the case-blind spelling rule (a `force_hyperlink` already there is not duplicated). +- e2e **`osc8Links`**: `Write-Host "FH-$env:FORCE_HYPERLINK"` prints `FH-1`. + +## 7. #171 (low): no XTVERSION reply + +### What exists + +Claude asks `CSI > 0 q` and turns on synchronized output (`?2026`) only after a reply. xterm 6.0.0 +has no handler (MEASURED: `XTVERSION: no reply`, 0 `?2026h`; with a reply added, +`synchronizedOutput=yes`, 4 `?2026h`). + +### The change + +`registerCsiHandler({ prefix: '>', final: 'q' }, ...)`: params empty or `[0]` answers +`ESC P >| PrismTerminal (xterm.js ) ESC \` through `term.input(..., +false)` and returns true. The name must NOT start with `xterm.js` (Claude then applies its VS Code +scroll tuning). Pure: `xtversionReply(core, xterm)` in `termReplies.ts`. + +- The versions are constants in a new `core/shared/termVersion.ts` (`TERM_CORE_VERSION`, + `XTERM_VERSION`): a JSON import of `package.json` is not possible in Prism, whose renderer + tsconfig is `composite` and does not include the core's `package.json`. Two unit tests keep them + honest: `TERM_CORE_VERSION` equals `core/package.json`'s version, and `XTERM_VERSION` equals + `node_modules/@xterm/xterm/package.json`'s. A core PR that bumps one without the other fails + `npm test`; `core/README.md`'s release section says so. +- Prism answers with the same name: the terminal emulator IS this core in both apps (decision 5). + +### Measure + +G4 step 1 runs Claude 2.1.296 in PT with `--debug-file` and records whether +`synchronizedOutput=yes` and how many `?2026h` it writes in a minute of streaming; the numbers go +in the commit message and the regression entry. + +### Tests + +- `termReplies.test.ts`: `xtversionReply('0.32.0', '6.0.0') === '\x1bP>|PrismTerminal 0.32.0 (xterm.js 6.0.0)\x1b\\'` + and the reply's name does not start with `xterm.js`. +- `termVersion.test.ts`: the two equalities above. +- e2e **`termReplies`**: the probe sends `CSI > q` and `CSI > 0 q`; each gets the reply once; a + `CSI > 1 q` gets none. + +## 8. #176 (low): OSC 52 writes are dropped + +### What exists + +No OSC 52 handler in xterm 6.0.0 or PT. The page's `copyText` (`copyNotice.ts`) tries +`navigator.clipboard` (refused without focus) then main's `writeClipboard`, capped at 4000 +characters (`core/main/ipc.ts:160`, built for command help). + +### The change + +- Pure, `core/renderer/lib/termOsc52.ts`: `parseOsc52(data, max)`: `Pc;Pd`. Only `c` or an empty + selection (Pc may list several letters; it must be exactly `c` or empty). `Pd === '?'` is a READ: + returns `{ kind: 'read' }`, which the handler swallows (true) and NEVER answers: a program in a + tab, or a remote host over ssh, must not read the user's clipboard. Base64 checked by shape before + decoding (an encoded length over `ceil(max / 3) * 4 + 4` is refused unread), decoded as UTF-8 + (`TextDecoder` with `fatal: true`; invalid is refused). Empty Pd (a "clear") is ignored. + `OSC52_MAX` is **1,048,576** characters of decoded text. +- A new bridge member: `TermApi.writeClipboardFromTerm?(text)` (`createTermApi`, channel + `CH.clipboardTerm = 'clipboard:term-write'`). Main checks it is a string of 1 to `OSC52_MAX` + characters (refused, not trimmed, over it) and writes it with the host's `clipboard.writeText`. + Not `writeClipboard`: its 4000 cap is right for command help and wrong for a program's copy. + Through main always, so a background tab or an unfocused window still copies. +- The handler raises the "Copied" badge (`announceCopied`) when main says it landed, so a copy a + program made is never silent. +- `sanitizePaste` still guards the other direction: what OSC 52 put there is pasted like any text. + +### Tests + +- `termOsc52.test.ts` (failing first): `c;aGVsbG8=` is `hello`; `;aGVsbG8=` too; `p;...` and + `cs;...` are refused; `c;?` is a read (never a reply); bad base64 and invalid UTF-8 are refused; + one character over the cap is refused; a UTF-8 `æøå` round-trips. +- `ipc.clipboard.test.ts`: the new channel writes a string, refuses a non-string, empty and + `OSC52_MAX + 1`. +- e2e **`termClipboard`** (new; the clipboard saved and restored as `paste` does): a pwsh tab writes + `ESC]52;c;BEL`; the clipboard reads `OSC52 æ` and the Copied badge shows; a + `ESC]52;c;?BEL` read gets no reply (the probe sees nothing within 1 s) and leaves the clipboard + alone; a `p` selection changes nothing. + +### Docs + +`PRIVACY.md`, a short "What programs in a tab can do" paragraph: a program running in a tab +(Claude Code's /copy, or anything over ssh) can put text ON the clipboard with the standard OSC 52 +sequence, up to 1 MB, and a "Copied" badge shows when it does; nothing in a tab can READ the +clipboard that way. And the terminal tells programs in a tab its name and version (XTVERSION) and +its colours when they ask, which stays on the PC. + +## 9. #177 (low): the bell is silent + +### What exists + +xterm fires `onBell`; nothing in PT listens (grep: comments only). PSReadLine rings on a bad key +(`useAgentIndicator.ts:19`). + +### The change (decided under the owner's delegation, decision 1) + +On a bell, **flash the taskbar button while the window is NOT focused; nothing while it is; no +sound.** A bell while unfocused cannot be the user's own bad keystroke, and a flash is what Windows +Terminal does; a sound in a tool the owner hears through screen narration would be noise. + +- Renderer (core): `term.onBell(() => ...)` calls `termApi().termBell?.(id)`, at most once per + **1 s** per session (pure `bellGate(last, now)` in `termReplies.ts`; a `cat` of a binary file + rings hundreds of times). +- Bridge (core): `TermApi.termBell?(id)`, `createTermApi` sends `CH.bell = 'term:bell'`; + `registerTermIpc` hands it to a new optional `TermIpcDeps.attention?(): void`. **This is a bridge + member, not a `TermHostConfig` field** (decision 2): the behaviour is the same in both apps; what + the host supplies is only access to its window, the way it supplies `openExternal` and `paths`. + A host that passes no `attention` (Prism, until its one-line wiring) stays silent, exactly as + today. +- Main (PT, `src/main/index.ts`): `attention: () => bellFlash(win)`; new `src/main/bellFlash.ts` + with the pure `wantsFlash(focused)` and `bellFlash(win)`: `if (!win.isFocused()) + win.flashFrame(true)`, and `win.on('focus', () => win.flashFrame(false))` registered once. Under + `--e2e` it RECORDS on `globalThis.__e2eFlashes` and flashes nothing (e2e has no side effects). + +### Tests + +- `termReplies.test.ts`: `bellGate` lets the first bell through, drops one 300 ms later, lets one + 1.1 s later through. +- `src/main/bellFlash.test.ts`: `wantsFlash(false)` true, `(true)` false; `bellFlash` with a fake + window calls `flashFrame(true)` only unfocused, and `flashFrame(false)` on focus. +- e2e **`bell`** (new): the parked window is never focused, so a `[Console]::Write([char]7)` in a + tab records one flash; ten bells in 200 ms record one. + +### Docs + +`docs/regression-rules.md`, new ``: the bell flashes the taskbar only while the +window is unfocused, at most once a second per tab, never a sound (owner's delegation, #177). +`core/README.md`: "A host wires the bell by passing `attention` to `registerTermIpc`". + +## 10. #174 (low): stale scroll range after a hidden resize (xterm.js #6117) + +### What exists + +`TerminalPanel.tsx:982-1022`: the attach appends the element and calls `refit()` in the same task. +xterm's viewport syncs its scroll dimensions in a render callback (`Viewport.queueSync`), and the +renderer is paused while the element is detached (`RenderService` IntersectionObserver). A tab +resized while hidden comes back with a wrong slider until a scroll (upstream #6117, open in 6.0.0; +not reproduced in PT yet). + +### The change + +1. REPRODUCE FIRST in the e2e below; record the slider numbers on today's code. +2. After the attach's `refit()`, a deferred second pass once the renderer has resumed: + `requestAnimationFrame(() => setTimeout(() => refit(), 0))`, cancelled in the effect's cleanup. + `fit()` does nothing when the size already matches, so if the e2e still shows the stale slider, + the second pass also calls `s.term.refresh(0, s.term.rows - 1)` (a full redraw runs the render + callbacks the viewport sync waits on). Only public xterm API; if neither corrects it, stop, + record the measurement on #174 and leave the code as step 2 (a no-op cost of one frame). +3. `suppressActivity` covers the second pass like the first (it is us, not the shell working). + +### Tests + +- No pure logic. e2e **`hiddenResize`** (new): two tabs; tab 2 runs a node probe on the alternate + screen with mouse tracking on (Claude fullscreen's modes) drawing a full screen; switch to tab 1; + resize the window (`BrowserWindow.setSize` from `app.evaluate`, 300 px narrower and shorter); + switch back; hover the panel; the vertical slider (`.xterm-scrollable-element > .scrollbar.vertical + > .slider`) is either invisible or as tall as its track (the alternate screen has no scrollback), + and the scroll range (`scrollHeight` of `.xterm-scrollable-element`'s content vs its height) + matches `rows` lines. Must fail on today's code before step 2 counts as the fix; if it never + fails, the scenario stays as the guard and the issue notes "not reproduced". + +## 11. #170 (medium): image paste sends ^V; Claude on Windows wants Alt+V + +### What exists + +`termPaste.ts:67-79` `decidePaste` returns `{ kind: 'key' }` for an image; the panel sends `\x16` +(`TerminalPanel.tsx:817-826`). Claude 2.1.296 binds `ctrl+v` to image paste only off Windows (and on +WSL); on Windows it is `alt+v` (read in the binary). The owner's `keybindings.json` binds both, which +hid it. Codex reads the clipboard image on Ctrl+V. + +### The change + +- Pure, `termPaste.ts`: `imagePasteKey(agent: DetectedAgent | null): string`: `'\x1bv'` (Alt+V) + for `claude`; `'\x16'` (^V) for everything else (codex, other agents, a plain shell, and WSL, + where the poll sees `wsl.exe` and Claude inside wants Ctrl+V). EXACTLY ONE key is sent, never + both: with the owner's bindings both keys paste, and two would paste the image twice. +- The panel tracks `agentKind: DetectedAgent | null` beside `agentHere`: from `onTermAgent`'s + `kind` (`:836-838`), from the resume (`agentOfResume`), and from the title (section 12's + `titleAgent`). `pasteHere` sends `imagePasteKey(agentKind)`. +- Help: `core/shared/help/agents.ts:563-588` `claude-paste-image`: the summary says this terminal + hands Claude its own image-paste key (Alt+V on Windows) when Ctrl+V is pressed with a picture on + the clipboard; the Alt+V variant stays as the key itself. `catalogue.test.ts` stays the gate. + +### Tests + +- `termPaste.test.ts` (failing first): `imagePasteKey('claude') === '\x1bv'`; `'codex'`, `'other'`, + `null` give `'\x16'`. +- e2e **`imagePaste`** (new; clipboard saved and restored): a node probe saved as a file named + `claude` (no extension, so the poll's first-token rule sees `...\claude` on its command line and + reports `claude`) in raw mode prints every byte it reads as hex; the scenario waits for the strip + to show the agent, puts a 2x2 image on the clipboard (`nativeImage`), presses Ctrl+V, and the + probe prints `1b 76` and NOT `16`. In a plain pwsh tab the same Ctrl+V sends `16` (PSReadLine + shows `^V` or nothing; the scenario reads the bytes with the same probe started as `probe.cjs`, + which the poll does not call an agent). + +## 12. #175 (low): Shift+Enter waits for the poll; no extended keys + +### What exists + +`TerminalPanel.tsx:897-906`: Shift+Enter sends `\` + CR (`'\\\r'`) ONLY while `agentHere`, which is +true from a resume or once the process poll (2.5 to 20 s backoff) reports an agent. Before that +Shift+Enter is Enter and submits. xterm 6.0.0 has no kitty keyboard or modifyOtherKeys and gives no +`CSI ? u` reply (MEASURED), so Ctrl+Enter is Enter. (The task text says the shim sends ESC CR; it +sends `\` CR.) + +### The change + +1. **MEASURE first** (G5 step 1, with the research harness `compat/runclaude.js` through the bundled + ConPTY): for Claude 2.1.296 and for codex, send each of `'\\\r'`, `'\n'` (Ctrl+J) and `'\x1b\r'` + (Alt+Enter) into an empty prompt with text `ab`, and record: newline inserted without submit? + stray backslash left? Decision rule: use `'\n'` if it inserts a newline in BOTH; else the + per-agent byte that does (`newlineKey(agent)` in `termPaste.ts`, `'\\\r'` the fallback, today's + behaviour). Expected from Claude's docs: `\n` is a newline in Claude everywhere; codex is the + unknown. The table goes in the commit message and the regression entry. +2. **The title turns it on, not only the poll.** A pure, stateless `titleAgent(title): + 'claude' | 'codex' | null` extracted from `readAgentTitle` (`agentTitle.ts:57-86`: a Claude + half-circle spinner or the idle `✳`, a codex braille spinner or its "Action Required" title), + which itself then uses it (no change to the indicator's results: its tests stay green). The + panel's `onTitleChange` sets `agentHere = true` and `agentKind` when a title NAMES an agent; only + the poll's "left" turns it off (the #73 rule: only the poll takes a title's claim back). So a + stale `✳ Claude Code` title after Claude exits does not re-arm it, since the title does not + change again. +3. Keys stay heard on `onKey` (rule 10): the shim is in `attachCustomKeyEventHandler`, sends through + `termApi().termInput` with `markTouched`, as today. At a plain prompt Shift+Enter is Enter. +4. Extended keys (kitty flag 1) are OUT: xterm.js has no support; a later xterm upgrade is its own + issue. Noted on #175. + +### Tests + +- `agentTitle.test.ts` (failing first: `titleAgent` does not exist): `◐ Claude Code` and `✳ Claude + Code` are claude; a braille spinner is codex; `PS C:\x` is null; `readAgentTitle`'s existing + cases unchanged. +- `termPaste.test.ts`: `newlineKey` per the measured table. +- e2e **`reviewKeys`** (existing, holds rule 11's Shift+Enter half): extended: a stand-in that + titles itself `✳ Claude Code` (no process poll yet: assert within 1 s of the title, well inside + the poll's first 2.5 s) receives the chosen newline bytes for Shift+Enter (probe prints hex); a + plain pwsh tab's Shift+Enter still runs the line with no `\` on it. + +## 13. Prism + +Every core change reaches Prism through the core release. What Prism gets with no change of its +own: the painter fix, OSC 10/11, 2031/996, OSC 8 click and paint, FORCE_HYPERLINK (its `ptyEnv` is +the core's), XTVERSION, OSC 52 (its preload uses `createTermApi`, its main `registerTermIpc`), the +hidden-resize refit, the image key, Shift+Enter by title. What Prism needs one line for: the bell +(`attention` in its `registerTermIpc` call). Until then its bell is silent as today. The PR body says +so, and the core-release PR in Prism is where the line lands (a follow-up issue in Prism if the +owner wants it separate). Nothing here conflicts with Prism: no default changes, no new +`TermHostConfig` field. + +## 14. Decisions made (owner's to overturn) + +1. **The bell** (#177, delegated): flash the taskbar while unfocused, nothing while focused, no sound, + any tab, at most once a second per tab. +2. **The bell is a bridge member** (`TermApi.termBell?`, `TermIpcDeps.attention?`), not a + `TermHostConfig` field: the behaviour is identical in both apps; the host only lends its window. + No new `TermHostConfig` field anywhere in this work. +3. **Image paste sends ONE key, chosen by the agent**: Alt+V to Claude, ^V otherwise (never both, + which would paste twice under the owner's own bindings). +4. **OSC 52 is write-only, 1 MB, `c` only, through main, with the Copied badge.** +5. **XTVERSION says `PrismTerminal ` in both apps**: the emulator is the core. +6. **FORCE_HYPERLINK=1 unless the user set it**, and only together with the OSC 8 handler. +7. **Shift+Enter's bytes follow the measurement** (section 12's rule), and a title arms it before the + poll. + +## 15. The new must-not-regress rule + +`docs/regression-rules.md`, new ``: **THE APP ANSWERS A PROGRAM ONLY +WHAT IT ASKED** (#168, #171, #172, #176). Every byte the core writes into a pty that no key produced +is a reply to a query in that pty's own stream (OSC 10/11 `?`, `CSI ? 996 n`, `CSI > q`, DECRQM +2031) or the `?997` report the program turned on with `?2031h`, and only on a change. Replies hold no +CR, LF or printable command, go through `term.input(reply, false)` so `onData` and `looksTyped` +treat them as the replies they are (rule 10), and never read anything private: OSC 52 is write-only +and a read gets no answer. CLAUDE.md's rule 2 one-liner gains "; a program gets only replies to what +it asked" with the link. From b1af0ab0199e83b94e27365ef2fb661f9e94036c Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:02:15 +0200 Subject: [PATCH 03/18] chore: bump core 0.32.0 and app 0.38.0 (#167-#177) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t --- core/package.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/core/package.json b/core/package.json index f071079..30861e7 100644 --- a/core/package.json +++ b/core/package.json @@ -1,6 +1,6 @@ { "name": "prism-term-core", - "version": "0.28.3", + "version": "0.32.0", "description": "What Prism Terminal and Prism share: the terminal (pty, shells, agent detection and indicator, themes, links, the panel, dictation) and the update chip with its window. TypeScript source, compiled by the host.", "license": "MIT", "private": true, diff --git a/package-lock.json b/package-lock.json index 0fc7834..166c6d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "prism-terminal", - "version": "0.35.3", + "version": "0.38.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "prism-terminal", - "version": "0.35.3", + "version": "0.38.0", "license": "MIT", "dependencies": { "@xterm/addon-fit": "^0.11.0", diff --git a/package.json b/package.json index a8e8e34..be63a39 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "prism-terminal", "productName": "Prism Terminal", - "version": "0.35.3", + "version": "0.38.0", "description": "A tabbed Windows terminal for AI CLIs.", "main": "./out/main/index.js", "author": "Max", From 41e710b11a7e38fde3af4fd8b12775c45d3cdf45 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:10:55 +0200 Subject: [PATCH 04/18] fix(core): a found path repaints only the tab and lines that asked (#167) One tab's found path woke every tab's link painter, and each repainted its whole scrollback in one task (Stable's diag log, 2026-10-10 16:58Z: 2009 ms and 2046 ms page stacks in scan around a 2153 ms term:path-kinds). - termPathLinks: listeners, queued and in-flight keys are kept per owner (the session id); a hit wakes only the owners that asked, a later asker joins an in-flight key; takeAsked tells the painter which lines wait on an answer. - termLinkPaint: revisit() repaints only the waiting scrollback lines (cap 500 markers) and leaves the live screen to the next pass; on the alternate screen it redraws only the rows holding a link. A full pass paints the live screen, then the scrollback bottom up in slices of 8 ms or less, a marker as the cursor; a crumb when a pass takes over 50 ms. #163's watchRows is unchanged. - linkScanPlan (pure): liveRange, sliceRows. Headless bench: one full pass's text work alone is 65-79 ms over 10,000 lines; sliced, the longest slice is 8.0 ms. - e2e linkPaintStall (new, not run here): no long task of 200 ms or more when a path lights up beside a 10,000-line tab, or on a theme switch, and the backlog finishes within 10 s. A core change: it ships to Prism too. onPathsFound now takes the owner first. Fixes #167 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t --- core/renderer/components/TerminalPanel.tsx | 13 +- core/renderer/lib/linkScanPlan.test.ts | 81 +++++++++ core/renderer/lib/linkScanPlan.ts | 65 +++++++ core/renderer/lib/termLinkPaint.ts | 189 +++++++++++++++++++-- core/renderer/lib/termPathLinks.test.ts | 106 +++++++++++- core/renderer/lib/termPathLinks.ts | 89 +++++++--- docs/regression-rules.md | 10 +- tools/e2e/run.mjs | 91 +++++++++- 8 files changed, 602 insertions(+), 42 deletions(-) create mode 100644 core/renderer/lib/linkScanPlan.test.ts create mode 100644 core/renderer/lib/linkScanPlan.ts diff --git a/core/renderer/components/TerminalPanel.tsx b/core/renderer/components/TerminalPanel.tsx index 1453323..01db8f3 100644 --- a/core/renderer/components/TerminalPanel.tsx +++ b/core/renderer/components/TerminalPanel.tsx @@ -45,7 +45,7 @@ import { type SelectionGate } from '../lib/termSelectionEdit' import { attachLinkPaint, type LinkPainter } from '../lib/termLinkPaint' -import { knownPath, linkRanges, onPathsFound, pathCandidates, type PathHit } from '../lib/termPathLinks' +import { knownPath, linkRanges, onPathsFound, pathCandidates, takeAsked, type PathHit } from '../lib/termPathLinks' import { initialCaretHold, onCaretKey, @@ -644,7 +644,11 @@ function createSession(id: string, root: string, shellId: string | undefined): S // The folder this shell is in (#99): where it started, then the prompt's // own report below. Paths on screen are read against it. let cwdNow = root - const links = attachLinkPaint(term, currentLinkColor, (text) => linkRanges(text, cwdNow)) + // Paths are asked about as THIS session (#167): a found one repaints only + // this terminal, and only the lines that were waiting for it. + const links = attachLinkPaint(term, currentLinkColor, (text) => linkRanges(text, cwdNow, id), { + asked: () => takeAsked(id) + }) // A PATH IS A LINK TOO, when it exists (#99). The painter colours it; this // makes it clickable, with the pointer and the underline of a link. A left // click opens it; the right one is the menu's (Open, Show in Explorer, Copy @@ -831,8 +835,9 @@ function createSession(id: string, root: string, shellId: string | undefined): S let agentHere = Boolean(resume) const unsub = [ attachClickCaret(term, el, id), - // A path asked about turned out to exist: paint it now. - onPathsFound(() => links.repaint()), + // A path this session asked about turned out to exist: paint the lines + // that asked (#167; it used to be every line of every tab). + onPathsFound(id, () => links.revisit()), termApi().onTermAgent((forId, present) => { if (forId === id) agentHere = present }), diff --git a/core/renderer/lib/linkScanPlan.test.ts b/core/renderer/lib/linkScanPlan.test.ts new file mode 100644 index 0000000..d527e62 --- /dev/null +++ b/core/renderer/lib/linkScanPlan.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import { liveRange, sliceRows } from './linkScanPlan' + +describe('liveRange', () => { + it('is the screen: baseY to baseY + rows - 1', () => { + expect(liveRange(9970, 30, 10000)).toEqual({ first: 9970, last: 9999 }) + }) + it('stops at the last line of a buffer shorter than the screen', () => { + expect(liveRange(0, 30, 12)).toEqual({ first: 0, last: 11 }) + }) +}) + +describe('sliceRows (#167)', () => { + /** A clock that moves only when a line is painted: 1 ms of work per line. */ + const work = (): { now: () => number; paint: (line: number) => void; painted: number[] } => { + let t = 0 + const painted: number[] = [] + return { + now: () => t, + paint: (line) => { + painted.push(line) + t += 1 + }, + painted + } + } + + it('walks a 10,000-line backlog bottom up, in slices of at most 8 ms of painting, every line once', () => { + const w = work() + const plan = sliceRows(0, 9999, 8, w.now) + let slices = 0 + for (let slice = plan.next(w.paint); slice; slice = plan.next(w.paint)) { + slices += 1 + expect(slice.length).toBeGreaterThan(0) + expect(slice.length).toBeLessThanOrEqual(8) + } + const seen = w.painted + expect(seen.length).toBe(10000) + expect(new Set(seen).size).toBe(10000) + expect(seen[0]).toBe(9999) + expect(seen[seen.length - 1]).toBe(0) + // Bottom up all the way: each line is above the one before it. + expect(seen.every((v, i) => i === 0 || v === seen[i - 1] - 1)).toBe(true) + expect(slices).toBe(1250) + }) + + it('the budget covers the painting, not only the walk', () => { + // A clock that never moves while walking: only the paint spends time. + const w = work() + const plan = sliceRows(0, 99, 8, w.now) + expect(plan.next(w.paint)?.length).toBe(8) + }) + + it('cheap lines all go in one slice', () => { + const plan = sliceRows(10, 19, 8, () => 0) + expect(plan.next()).toEqual([19, 18, 17, 16, 15, 14, 13, 12, 11, 10]) + expect(plan.next()).toBeNull() + }) + + it('an empty range yields nothing', () => { + expect(sliceRows(5, 4, 8, () => 0).next()).toBeNull() + }) + + it('resumes where it is told, when the buffer trimmed lines under the walk', () => { + const w = work() + const plan = sliceRows(0, 99, 8, w.now) + expect(plan.next(w.paint)?.[0]).toBe(99) + // 50 lines trimmed off the top: the walk stood on 91, it now stands on 41. + expect(plan.next(w.paint, 41)?.slice(0, 2)).toEqual([41, 40]) + }) + + it('a cancelled plan yields nothing more, even mid-slice', () => { + const w = work() + const plan = sliceRows(0, 99, 8, w.now) + expect(plan.next(w.paint)).not.toBeNull() + plan.cancel() + expect(plan.next(w.paint)).toBeNull() + const other = sliceRows(0, 99, 8, () => 0) + expect(other.next(() => other.cancel())).toEqual([99]) + }) +}) diff --git a/core/renderer/lib/linkScanPlan.ts b/core/renderer/lib/linkScanPlan.ts new file mode 100644 index 0000000..0470af3 --- /dev/null +++ b/core/renderer/lib/linkScanPlan.ts @@ -0,0 +1,65 @@ +/** + * How the link painter walks a buffer without freezing the page (#167). + * + * A full pass used to paint all 10,000 lines of scrollback cell by cell in ONE + * task: MEASURED 2009 ms and 2046 ms page stacks in the painter (Stable's diag + * log, 2026-10-10 16:58Z). Now a full pass paints the live screen at once (at + * most `rows` lines, what the user is looking at) and the scrollback after + * it, bottom up so the lines nearest the screen come first, in slices that + * stop once a time budget is spent. The page gets the thread back between + * slices. + * + * MEASURED (2026-10-11, @xterm/headless, 10,000 lines each holding a URL and a + * path, the core's own findLinks and pathCandidates): the text work alone of + * one full pass is 65 to 79 ms in one task, before any decoration, DOM or + * second tab; the same walk through `sliceRows` with an 8 ms budget is 9 + * slices, the longest 8.0 ms. The budget must cover the painting, so the + * painting happens inside the walk (`visit`). + */ + +/** The live screen's lines: the first is `baseY`, the last no further than the buffer. */ +export function liveRange(baseY: number, rows: number, length: number): { first: number; last: number } { + return { first: baseY, last: Math.min(length - 1, baseY + rows - 1) } +} + +export interface RowPlan { + /** + * The next slice: `visit` is called for each line, bottom up, until the + * budget is spent, and the lines visited are returned; null when the walk is + * done or cancelled. `resume` is the line the walk stands on NOW, when the + * buffer has trimmed lines off its top since the last slice and every + * number moved. + */ + next(visit?: (line: number) => void, resume?: number): number[] | null + cancel(): void +} + +/** + * Lines `from` to `to`, walked from `to` UP, `budgetMs` at a time by `now`. + * The clock is read after each line's `visit`, so the budget covers the work + * itself; a slice ends on the first line that finds it spent (a line is never + * split, and a slice holds at least one line, so the walk always moves). + */ +export function sliceRows(from: number, to: number, budgetMs: number, now: () => number): RowPlan { + let at = to + let cancelled = false + return { + next(visit, resume) { + if (resume !== undefined) at = resume + if (cancelled || at < from) return null + const out: number[] = [] + const start = now() + while (at >= from) { + const line = at + at -= 1 + out.push(line) + visit?.(line) + if (cancelled || now() - start >= budgetMs) break + } + return out + }, + cancel() { + cancelled = true + } + } +} diff --git a/core/renderer/lib/termLinkPaint.ts b/core/renderer/lib/termLinkPaint.ts index d433ea9..e45ffb7 100644 --- a/core/renderer/lib/termLinkPaint.ts +++ b/core/renderer/lib/termLinkPaint.ts @@ -1,4 +1,6 @@ import type { IBufferLine, IDecoration, IDisposable, IMarker, Terminal } from '@xterm/xterm' +import { crumb } from './diag' +import { sliceRows, type RowPlan } from './linkScanPlan' import { findLinks } from './termLinks' /** @@ -17,6 +19,12 @@ import { findLinks } from './termLinks' * last finished line down and paints that part again. A marker remembers where * "finished" was, which survives the buffer scrolling and trimming under it. * + * NO PASS HOLDS THE PAGE (#167). A full pass (a resize, a theme, a buffer + * switch) paints the live screen at once and the scrollback after it, bottom + * up, in slices of SLICE_MS (`linkScanPlan.ts`); a found path repaints only + * the lines that asked about it (`revisit`). Before, both walked all 10,000 + * lines of scrollback in one task, in every tab: 2 s window stalls, MEASURED. + * * THE ALTERNATE SCREEN IS INKED AS IT IS DRAWN (owner, 2026-09-28: a link in * Claude Code's fullscreen view "is not blue ... it seems to know it's a link * since i can click it"). Markers do not exist there, so neither do @@ -30,6 +38,15 @@ import { findLinks } from './termLinks' export interface LinkPainter extends IDisposable { /** The colour changed (a theme, a custom ground): paint everything again. */ repaint(): void + /** A path this terminal asked about exists (#167): paint again only the + * lines that were waiting for the answer, never the whole buffer. */ + revisit(): void +} + +export interface LinkPaintOptions { + /** Whether the `find` call just made left a question open (a path not yet + * known): that line is remembered and painted again by `revisit`. */ + asked?: () => boolean } interface Painted { @@ -45,28 +62,61 @@ interface Cell { } const DEBOUNCE_MS = 80 +/** The longest one slice of the scrollback backlog may hold the page (#167). */ +const SLICE_MS = 8 +/** A pass slower than this leaves a crumb in the diag log, so the next stall + * report says whether the painter was in it. */ +const SLOW_PASS_MS = 50 +/** Lines remembered as waiting for a path's answer; the oldest go first. */ +const WAITING_CAP = 500 export function attachLinkPaint( term: Terminal, color: () => string, /** What wears the link colour in a line of text: the web links, and the * host panel adds the paths that exist (#99). */ - find: (text: string) => Array<{ start: number; end: number }> = findLinks + find: (text: string) => Array<{ start: number; end: number }> = findLinks, + opts: LinkPaintOptions = {} ): LinkPainter { let painted: Painted[] = [] - /** Everything above this line is scrollback that has been painted. */ + /** Everything above this line is scrollback that has been painted, or is + * still in the backlog below. */ let finished: IMarker | undefined let timer: number | undefined let dead = false + /** The first row of each painted line whose `find` left a question open. */ + let waiting: IMarker[] = [] + /** The scrollback a full pass still has to paint, bottom up (#167). */ + let backlog: { plan: RowPlan; cursor: IMarker; timer?: number } | undefined + + const markerAt = (line: number): IMarker | undefined => { + const b = term.buffer.active + return term.registerMarker(line - (b.baseY + b.cursorY)) + } - const forgetFrom = (line: number): void => { + /** Drop what was painted on lines `first` to `last` (and anything trimmed). */ + const forgetLines = (first: number, last: number): void => { + const keep = (m: IMarker): boolean => !m.isDisposed && (m.line < first || m.line > last) painted = painted.filter((p) => { - const at = p.marker.isDisposed ? -1 : p.marker.line - if (at >= 0 && at < line) return true + if (keep(p.marker)) return true p.deco.dispose() p.marker.dispose() return false }) + waiting = waiting.filter((m) => { + if (keep(m)) return true + m.dispose() + return false + }) + } + + const forgetFrom = (line: number): void => forgetLines(line, Number.MAX_SAFE_INTEGER) + + const noteWaiting = (line: number): void => { + const m = markerAt(line) + if (!m) return + waiting.push(m) + if (waiting.length > WAITING_CAP) waiting.shift()?.dispose() } const paintRow = (row: number, x: number, width: number, ink: string): void => { @@ -115,7 +165,9 @@ export function attachLinkPaint( for (let k = 0; k < chars.length; k += 1) cells.push({ row: first + i, x, w }) } }) - for (const link of find(text)) { + const links = find(text) + if (opts.asked?.()) noteWaiting(first) + for (const link of links) { let from = link.start while (from < link.end) { const row = cells[from].row @@ -203,27 +255,101 @@ export function attachLinkPaint( watch.observe(box, { childList: true, subtree: true }) } + /** The last row of the logical line that starts on `first`. */ + const lastRowOf = (first: number, end: number): number => { + const b = term.buffer.active + let last = first + while (last + 1 <= end && b.getLine(last + 1)?.isWrapped) last += 1 + return last + } + + const slow = (where: string, t0: number, lines: number): void => { + const ms = Math.round(performance.now() - t0) + if (ms > SLOW_PASS_MS) crumb('link-paint', { where, ms, lines }) + } + + const cancelBacklog = (): void => { + if (!backlog) return + backlog.plan.cancel() + if (backlog.timer !== undefined) window.clearTimeout(backlog.timer) + backlog.cursor.dispose() + backlog = undefined + } + + /** + * One slice of the backlog: lines from the cursor UP, until SLICE_MS is + * spent, then the page gets the thread back. The cursor is a marker, so the + * buffer trimming lines off its top moves it too; once it is trimmed itself, + * everything above it is gone and the backlog has nothing left to paint. + */ + const backlogStep = (): void => { + const bl = backlog + if (!bl) return + bl.timer = undefined + const b = term.buffer.active + if (dead || b.type !== 'normal' || bl.cursor.isDisposed) return cancelBacklog() + const t0 = performance.now() + const ink = color() + // Painted INSIDE the walk, so the slice's clock counts the painting. + const rows = bl.plan.next((r) => { + // A wrapped row is painted with the line it continues, from that line's + // first row, which the walk reaches next. The backlog stops above the + // live pass's first line, so no line here runs into painted rows. + if (!b.getLine(r)?.isWrapped) paintLogical(r, lastRowOf(r, b.length - 1), ink) + }, bl.cursor.line) + if (!rows) return cancelBacklog() + slow('backlog', t0, rows.length) + const next = rows[rows.length - 1] - 1 + bl.cursor.dispose() + const cursor = next >= 0 ? markerAt(next) : undefined + if (!cursor) { + backlog = undefined + return + } + bl.cursor = cursor + bl.timer = window.setTimeout(backlogStep, 0) + } + + /** Paint lines `top` and up, after the live screen, a slice at a time. */ + const startBacklog = (top: number): void => { + cancelBacklog() + if (top < 0) return + const cursor = markerAt(top) + if (!cursor) return + backlog = { plan: sliceRows(0, top, SLICE_MS, () => performance.now()), cursor } + backlog.timer = window.setTimeout(backlogStep, 0) + } + const scan = (): void => { timer = undefined if (dead) return const b = term.buffer.active if (b.type !== 'normal') return const live = b.baseY - let start = finished && !finished.isDisposed && finished.line >= 0 ? Math.min(finished.line, live) : 0 + // A FULL pass (nothing painted yet, a start over, or the marker trimmed + // away under a flood of output) paints the live screen now and leaves the + // scrollback to the backlog. It used to paint all 10,000 lines here in + // one task: MEASURED 2009 ms and 2046 ms (#167). + const done = finished && !finished.isDisposed && finished.line >= 0 ? finished.line : -1 + const full = done < 0 + let start = full ? live : Math.min(done, live) // A pass starts on a whole logical line. while (start > 0 && b.getLine(start)?.isWrapped) start -= 1 const end = Math.min(b.length - 1, live + term.rows - 1) - forgetFrom(start) + if (full) cancelBacklog() + forgetFrom(full ? 0 : start) + const t0 = performance.now() const ink = color() let first = start while (first <= end) { - let last = first - while (last + 1 <= end && b.getLine(last + 1)?.isWrapped) last += 1 + const last = lastRowOf(first, end) paintLogical(first, last, ink) first = last + 1 } + slow('live', t0, end - start + 1) finished?.dispose() - finished = term.registerMarker(live - (b.baseY + b.cursorY)) + finished = markerAt(live) + if (full) startBacklog(start - 1) } const soon = (): void => { @@ -231,6 +357,7 @@ export function attachLinkPaint( } const startOver = (): void => { + cancelBacklog() forgetFrom(0) finished?.dispose() finished = undefined @@ -239,6 +366,44 @@ export function attachLinkPaint( if (term.buffer.active.type === 'alternate') term.refresh(0, term.rows - 1) } + /** + * A path this terminal asked about exists (#167). Each scrollback line that + * was waiting is forgotten and painted again on its own; the live screen is + * left to the next ordinary pass, which repaints it anyway. It used to be a + * start over: every tab, every line. + */ + const revisit = (): void => { + if (dead) return + const b = term.buffer.active + if (b.type === 'alternate') { + // No markers here: redraw only the rows that hold something to ink, and + // xterm's onRender inks them. + const rows = term.element?.querySelector('.xterm-rows')?.children + if (!rows) return + let lo = -1 + let hi = -1 + for (let r = 0; r < rows.length; r += 1) { + const text = rows[r].textContent ?? '' + if (!/[\\/.]/.test(text) || !find(text).length) continue + if (lo < 0) lo = r + hi = r + } + if (lo >= 0) term.refresh(lo, hi) + return + } + const top = finished && !finished.isDisposed ? finished.line : -1 + const t0 = performance.now() + const ink = color() + const again = waiting.filter((m) => !m.isDisposed && m.line < top).map((m) => m.line) + for (const first of again) { + const last = lastRowOf(first, b.length - 1) + forgetLines(first, last) + paintLogical(first, last, ink) + } + slow('revisit', t0, again.length) + soon() + } + const subs: IDisposable[] = [ term.onWriteParsed(soon), // A resize reflows every wrapped line: nothing painted is where it was. @@ -250,10 +415,12 @@ export function attachLinkPaint( return { repaint: startOver, + revisit, dispose: () => { dead = true watch?.disconnect() if (timer !== undefined) window.clearTimeout(timer) + cancelBacklog() subs.forEach((s) => s.dispose()) forgetFrom(0) finished?.dispose() diff --git a/core/renderer/lib/termPathLinks.test.ts b/core/renderer/lib/termPathLinks.test.ts index 17b7713..821d5bd 100644 --- a/core/renderer/lib/termPathLinks.test.ts +++ b/core/renderer/lib/termPathLinks.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, it } from 'vitest' -import { pathCandidates } from './termPathLinks' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { configureTermCore, resetTermCore, type TermApi, type TermHostConfig } from '../host' +import { linkRanges, onPathsFound, pathCandidates, resetPathCache, takeAsked } from './termPathLinks' const paths = (text: string): string[] => pathCandidates(text).map((p) => p.path) @@ -14,3 +15,104 @@ describe('pathCandidates', () => { expect(paths('see https://github.com/a/b/blob/main/README.md and README.md')).toEqual(['README.md']) }) }) + +// #167: a found path woke EVERY tab's painter, and each rescanned its whole +// scrollback (MEASURED in Stable's diag log, 2026-10-10 16:58Z: 2009 ms and +// 2046 ms page stacks in `scan` around a 2153 ms term:path-kinds). A found path +// is news only to the tab that asked about it. +describe('who hears that a path exists (#167)', () => { + /** The paths that exist; every other one names nothing. */ + const real = new Set(['a.txt', 'b.txt']) + let pending: Array<() => void> = [] + const ask = vi.fn((cwd: string, texts: string[]) => { + void cwd + return new Promise>((resolve) => { + pending.push(() => resolve(texts.map((t) => (real.has(t) ? { kind: 'file', abs: `C:\\w\\${t}` } : null)))) + }) + }) + /** Main answers every batch it was asked, then the promises settle. */ + const answer = async (): Promise => { + const now = pending + pending = [] + now.forEach((f) => f()) + await vi.advanceTimersByTimeAsync(0) + } + + beforeEach(() => { + vi.useFakeTimers() + pending = [] + ask.mockClear() + configureTermCore({ api: { termPathKinds: ask } as unknown as TermApi } as TermHostConfig) + }) + afterEach(() => { + resetPathCache() + resetTermCore() + vi.useRealTimers() + }) + + it('a hit wakes the tab that asked, and no other', async () => { + const a = vi.fn() + const b = vi.fn() + onPathsFound('A', a) + onPathsFound('B', b) + linkRanges('see a.txt', 'C:\\w', 'A') + await vi.advanceTimersByTimeAsync(40) + await answer() + expect(a).toHaveBeenCalledTimes(1) + expect(b).not.toHaveBeenCalled() + }) + + it('a tab that asks for a path already in flight hears the answer too, once', async () => { + const a = vi.fn() + const b = vi.fn() + onPathsFound('A', a) + onPathsFound('B', b) + linkRanges('see b.txt', 'C:\\w', 'A') + await vi.advanceTimersByTimeAsync(40) // A's batch is asked + linkRanges('see b.txt', 'C:\\w', 'B') // ... and B asks while it is out + await vi.advanceTimersByTimeAsync(40) + await answer() + expect(ask).toHaveBeenCalledTimes(1) + expect(a).toHaveBeenCalledTimes(1) + expect(b).toHaveBeenCalledTimes(1) + }) + + it('a path that names nothing wakes nobody', async () => { + const a = vi.fn() + onPathsFound('A', a) + linkRanges('see nope.txt', 'C:\\w', 'A') + await vi.advanceTimersByTimeAsync(40) + await answer() + expect(ask).toHaveBeenCalledTimes(1) + expect(a).not.toHaveBeenCalled() + }) + + it('an unsubscribed listener hears nothing', async () => { + const a = vi.fn() + const off = onPathsFound('A', a) + off() + linkRanges('see a.txt', 'C:\\w', 'A') + await vi.advanceTimersByTimeAsync(40) + await answer() + expect(a).not.toHaveBeenCalled() + }) + + it('takeAsked says once that a line queued a question, and not for a line fully known', async () => { + linkRanges('see a.txt', 'C:\\w', 'A') + expect(takeAsked('A')).toBe(true) + expect(takeAsked('A')).toBe(false) + expect(takeAsked('B')).toBe(false) + await vi.advanceTimersByTimeAsync(40) + await answer() + expect(linkRanges('see a.txt', 'C:\\w', 'A')).toEqual([{ start: 4, end: 9, path: 'a.txt' }]) + expect(takeAsked('A')).toBe(false) + }) + + it('a line whose path is already being asked about still waits for it', async () => { + linkRanges('see a.txt', 'C:\\w', 'A') + takeAsked('A') + await vi.advanceTimersByTimeAsync(40) // in flight now + linkRanges('again a.txt', 'C:\\w', 'A') + expect(takeAsked('A')).toBe(true) + }) +}) diff --git a/core/renderer/lib/termPathLinks.ts b/core/renderer/lib/termPathLinks.ts index b9685c7..006866a 100644 --- a/core/renderer/lib/termPathLinks.ts +++ b/core/renderer/lib/termPathLinks.ts @@ -5,8 +5,8 @@ import { findPaths, type PathCandidate } from './termPaths' /** * WHICH PATHS ON SCREEN EXIST (#99). The painter and the click both ask * `known`; a path nobody has asked about yet is queued and asked about in one - * batch, and when any turns out to exist, `onPathsFound` listeners paint - * again. So a path lights up a moment after it is printed, and only if it is + * batch, and when any turns out to exist, the `onPathsFound` listeners of the + * tab that asked paint again (#167: that tab only). So a path lights up a moment after it is printed, and only if it is * real: nothing is painted that a click could not open. * * Per folder, since `docs/a.pdf` is a different file in every shell. A "no" is @@ -22,11 +22,26 @@ const NO_TTL_MS = 15_000 const MAX_ENTRIES = 5000 const BATCH_MS = 40 +/** + * WHO ASKED (#167). One module-wide listener set used to wake EVERY tab when + * any batch found a path, and each woken painter rescanned its whole + * scrollback in one task: MEASURED in Stable's diag log (2026-10-10 16:58Z), + * page stacks of 2009 ms and 2046 ms in the painter's `scan` around a 2153 ms + * term:path-kinds call, the window frozen 1.8 to 2.2 s. A tab that never + * printed the path has nothing to repaint, so the answer goes only to the + * owners (the session ids) that asked for that path. + */ +const NO_OWNER = '' + const cache = new Map() -const queued = new Map>() -const inFlight = new Set() +/** cwd -> path -> the owners that asked for it, waiting for the batch. */ +const queued = new Map>>() +/** key -> the owners waiting on a batch already asked; a later asker joins. */ +const inFlight = new Map>() let timer: ReturnType | undefined -const listeners = new Set<() => void>() +const listeners = new Map void>>() +/** Owners whose last `linkRanges` queued (or joined) a question. */ +const asked = new Set() const key = (cwd: string, path: string): string => `${cwd}\n${path}` @@ -38,47 +53,66 @@ export function knownPath(cwd: string, path: string): PathHit | null | undefined return e.hit } -export function onPathsFound(fn: () => void): () => void { - listeners.add(fn) +/** `fn` runs when a path `owner` asked about turns out to exist. */ +export function onPathsFound(owner: string, fn: () => void): () => void { + let set = listeners.get(owner) + if (!set) listeners.set(owner, (set = new Set())) + set.add(fn) return () => { - listeners.delete(fn) + set.delete(fn) + if (!set.size && listeners.get(owner) === set) listeners.delete(owner) } } +/** + * Whether the last `linkRanges` for `owner` left a question open (it queued a + * path, or joined one already asked). Reading it clears it, so the painter + * learns per line which lines wait for an answer. + */ +export function takeAsked(owner: string): boolean { + return asked.delete(owner) +} + function flush(): void { timer = undefined const ask = termApi().termPathKinds const batches = [...queued] queued.clear() if (!ask) return - for (const [cwd, set] of batches) { - const list = [...set] - list.forEach((p) => inFlight.add(key(cwd, p))) + for (const [cwd, byPath] of batches) { + const list = [...byPath.keys()] + list.forEach((p) => inFlight.set(key(cwd, p), byPath.get(p) ?? new Set())) void ask(cwd, list) .then((hits) => { if (cache.size > MAX_ENTRIES) cache.clear() - let found = false + const wake = new Set() const at = Date.now() list.forEach((p, i) => { const hit = hits?.[i] ?? null cache.set(key(cwd, p), { hit, at }) - if (hit) found = true + if (hit) inFlight.get(key(cwd, p))?.forEach((o) => wake.add(o)) }) - if (found) listeners.forEach((fn) => fn()) + wake.forEach((o) => listeners.get(o)?.forEach((fn) => fn())) }) .catch(() => {}) .finally(() => list.forEach((p) => inFlight.delete(key(cwd, p)))) } } -function request(cwd: string, paths: string[]): void { - let set = queued.get(cwd) +function request(cwd: string, paths: string[], owner: string): void { + let byPath = queued.get(cwd) for (const p of paths) { - if (inFlight.has(key(cwd, p))) continue - if (!set) queued.set(cwd, (set = new Set())) - set.add(p) + const flying = inFlight.get(key(cwd, p)) + if (flying) { + flying.add(owner) + continue + } + if (!byPath) queued.set(cwd, (byPath = new Map())) + let owners = byPath.get(p) + if (!owners) byPath.set(p, (owners = new Set())) + owners.add(owner) } - if (set && set.size && timer === undefined) timer = setTimeout(flush, BATCH_MS) + if (byPath && byPath.size && timer === undefined) timer = setTimeout(flush, BATCH_MS) } /** @@ -98,7 +132,13 @@ export function pathCandidates(text: string, links = findLinks(text)): PathCandi * Everything in `text` that wears the link colour: the web links, and the * paths known to exist from `cwd`. Unknown paths are asked about on the way. */ -export function linkRanges(text: string, cwd: string | null): Array<{ start: number; end: number }> { +export function linkRanges( + text: string, + cwd: string | null, + /** The session asking: only it hears when one of its paths exists (#167). */ + owner: string = NO_OWNER +): Array<{ start: number; end: number }> { + asked.delete(owner) const links = findLinks(text) if (!cwd || !termApi().termPathKinds || !/[\\/.]/.test(text)) return links const out: Array<{ start: number; end: number }> = [...links] @@ -108,7 +148,10 @@ export function linkRanges(text: string, cwd: string | null): Array<{ start: num if (k) out.push(c) else if (k === undefined) ask.push(c.path) } - if (ask.length) request(cwd, ask) + if (ask.length) { + request(cwd, ask, owner) + asked.add(owner) + } return out.sort((a, b) => a.start - b.start) } @@ -117,6 +160,8 @@ export function resetPathCache(): void { cache.clear() queued.clear() inFlight.clear() + listeners.clear() + asked.clear() if (timer !== undefined) clearTimeout(timer) timer = undefined } diff --git a/docs/regression-rules.md b/docs/regression-rules.md index 78836ae..bc3f03a 100644 --- a/docs/regression-rules.md +++ b/docs/regression-rules.md @@ -298,7 +298,11 @@ one-liner in `CLAUDE.md` only if it is among the most important. wrapped in a span of the link colour; xterm replaces a row's contents when it draws it, so a rewritten row starts clean. Per row, since a TUI places its own text. Columns are counted in CELLS, since a wide character is one character and two cells. xterm splits a row into spans as - it likes, so the e2e finds a link's span by POSITION, never by its text. + it likes, so the e2e finds a link's span by POSITION, never by its text. **NO PASS HOLDS THE + PAGE** (#167; MEASURED 2 s stalls before, 2009 ms and 2046 ms in `scan`, Stable's diag log + 2026-10-10): a full pass paints the live screen, then the scrollback bottom up in slices of 8 ms + or less (`lib/linkScanPlan.ts`, a marker as the cursor), and a found path repaints only the tab + and the lines that asked (`revisit`). The `linkPaintStall` e2e holds it. - **A PATH ON SCREEN IS A LINK WHEN IT EXISTS** (#99; owner, 2026-09-29: "clickable links that would open the file or folder", then "go ahead"). `lib/termPaths` finds what COULD be a path (relative or absolute, sentence punctuation and `:12` taken off); main's `termPathOpen` answers which exist from @@ -310,7 +314,9 @@ one-liner in `CLAUDE.md` only if it is among the most important. stat). A prompt (`PS C:\x>`, `C:\x>`) is not a link. Menu: Open, Show in Explorer, Copy path. Under `--e2e` main records (`__e2eOpenedPaths`) and opens nothing. A host without `paths` in its main deps (Prism, until wired) paints no paths at all; `TermHostConfig.openPath` is where Prism - opens them inside Prism. The `pathLinks` e2e holds it. + opens them inside Prism. A found path repaints only the tab that asked and only the lines that + asked (#167: `linkRanges(text, cwd, owner)`, `onPathsFound(owner, fn)`, `takeAsked`); waking + every tab's full repaint froze the window 2 s (MEASURED). The `pathLinks` e2e holds it. - **A file dropped on the terminal types its quoted path, and the terminal answers a right-click** (2026-09-19, #16). Both lived in Prism's `TermDock.tsx`, the split dock, and went with it when the dock was stripped, while the README, the spec and PR #3 went on listing the drop as shipped diff --git a/tools/e2e/run.mjs b/tools/e2e/run.mjs index cf45242..923e959 100644 --- a/tools/e2e/run.mjs +++ b/tools/e2e/run.mjs @@ -2206,6 +2206,95 @@ const scenarios = { } }, + /** + * A FOUND PATH NEVER FREEZES THE WINDOW (#167). One tab's found path woke + * every tab's painter, and each repainted its whole scrollback in one task: + * MEASURED in Stable's diag log (2026-10-10 16:58Z), 2009 ms and 2046 ms + * page stacks in the painter. Written to FAIL on the code before the fix + * (the 10,000 lines in tab B were repainted in one task when tab A found a + * path); the Gate confirms it on the parent commit if asked. + */ + async linkPaintStall(ok) { + const w = world() + const { app, page } = await launch(w, { args: [w.alpha, w.beta] }) + const INK = '121,167,216' // LINK_BLUE on the default theme + const inked = () => + page.evaluate((want) => { + const norm = (c) => (c.match(/\d+/g) ?? []).slice(0, 3).join(',') + return [...document.querySelectorAll('.xterm .xterm-rows > div')].map((row) => + [...row.querySelectorAll('span')] + .filter((sp) => norm(getComputedStyle(sp).color) === want) + .map((sp) => sp.textContent ?? '') + .join('') + ) + }, INK) + const rowsText = () => page.evaluate(() => [...document.querySelectorAll('.xterm .xterm-rows > div')].map((r) => r.textContent ?? '')) + /** The longest task that STARTED after `since` (performance.now()). */ + const longest = (since) => + page.evaluate((s) => Math.max(0, ...(window.__longTasks ?? []).filter((t) => t.at >= s).map((t) => t.ms)), since) + const now = () => page.evaluate(() => performance.now()) + try { + ok(await until(async () => (await tabLabels(page)).length === 2), 'two tabs open') + // Tab B (beta, in front at launch): 10,000 lines, each with a path that + // names nothing (asked about, so painted lines wait on it) and a web + // link (painted at once, so the backlog's progress can be seen). + await typeLine(page, '1..10000 | % { "line $_ see docs/x.md at https://e.x/n$_" }') + ok( + !!(await until(async () => (await rowsText()).some((t) => t.includes('https://e.x/n10000')), 180000, 500)), + 'tab B printed its 10,000 lines' + ) + await page.waitForFunction( + () => /PS [^>]*>\s*$/.test((document.querySelector('.xterm .xterm-rows')?.textContent ?? '').trimEnd()), + null, + { timeout: 60000 } + ) + await sleep(1500) // its own painting settles + await page.evaluate(() => { + window.__longTasks = [] + new PerformanceObserver((list) => { + for (const e of list.getEntries()) window.__longTasks.push({ at: e.startTime, ms: Math.round(e.duration) }) + }).observe({ type: 'longtask' }) + }) + // Tab A finds a path. + await page.locator('[data-tab]').nth(0).click() + await typeLine(page, "cls; Set-Content found-167.txt x; Write-Host 'made found-167.txt here'") + ok( + !!(await until(async () => (await inked()).some((t) => t.includes('found-167.txt')), 8000, 100)), + 'the path tab A printed wears the link colour' + ) + const lit = await now() + await sleep(5000) + const afterFind = await longest(lit - 500) + ok(afterFind < 200, `and no task held the window 200 ms or more while it lit up (longest ${afterFind} ms)`) + + // A full pass over B's 10,000 lines: a theme, and back. + const before = await now() + await pickTheme(page, 'fawn') + await sleep(2000) + await pickTheme(page, 'prism') + await sleep(3000) + const afterTheme = await longest(before) + ok(afterTheme < 200, `a theme switch with 10,000 lines in a tab holds the window under 200 ms (longest ${afterTheme} ms)`) + await backToFirst(page) + await page.locator('[data-tab]').nth(1).click() + // B's oldest lines are painted by the backlog, a slice at a time. + const box = await page.locator('.xterm').first().boundingBox() + if (box) await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2) + for (let i = 0; i < 40; i += 1) await page.mouse.wheel(0, -20000) + ok( + !!(await until(async () => { + const text = await rowsText() + const ink = await inked() + const top = text.findIndex((t) => t.includes('https://e.x/n')) + return top >= 0 && ink[top].includes('https://e.x/n') ? text[top].trim() : null + }, 10000, 200)), + "and B's oldest lines are painted within 10 s (the backlog finished)" + ) + } finally { + await closeApp(app) + } + }, + async links(ok) { const w = world() const { app, page } = await launch(w, { args: [w.alpha] }) @@ -5733,7 +5822,7 @@ function collectStalls(scenario) { } } /** Scenarios that honestly take longer than the default limit. */ -const SLOW = { dictation: 360000, dictationParakeet: 360000, helpPanel: 300000, updateWindow: 300000, indicatorStyles: 360000 } +const SLOW = { linkPaintStall: 300000, dictation: 360000, dictationParakeet: 360000, helpPanel: 300000, updateWindow: 300000, indicatorStyles: 360000 } reapStrays() for (const [name, run] of Object.entries(scenarios)) { if (only.length && !only.some((o) => name.toLowerCase().includes(o.toLowerCase()))) continue From 731bb9ad375d26d4d979dcbdfd81d6be542e033e Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:19:09 +0200 Subject: [PATCH 05/18] fix(core): tell programs the real ground, and push theme changes (#168, #172) OSC 10 and 11 queries are answered from the theme on the ground the panel really paints, not xterm's clear canvas (which answered rgb:0000/0000/0000, so Claude's "auto" theme was dark on every ground). Mode 2031 is followed from the stream; a look change that flips light/dark pushes CSI ? 997 ; 1|2 n once to a program that turned reports on, CSI ? 996 n is answered, and DECRQM 2031 reports set/reset. Light/dark is the chrome's own measurement (luminance > 0.4), pinned to agree with chromeTokens on every preset. Measured (Claude Code 2.1.296, bare node-pty + bundled ConPTY): Claude does NOT send DECRQM ?2031$p before trusting 2031 (it writes ?2031h straight away), never asks ?996n or OSC 10, and on a pushed ?997;2n re-asks OSC 11 and switches ("detected=dark" to "detected=light" in its debug log). All six queries and replies pass through the bundled ConPTY untouched. New must-not-regress rule replies-only-when-asked; CLAUDE.md rule 2 links it. New e2e scenario termReplies (not run here; the Gate runs it). Core change: reaches Prism with the core release, no new TermHostConfig field. Fixes #168 Fixes #172 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t --- CLAUDE.md | 3 +- core/renderer/components/TerminalPanel.tsx | 73 +++++++++++++- core/renderer/lib/termReplies.test.ts | 104 ++++++++++++++++++++ core/renderer/lib/termReplies.ts | 108 +++++++++++++++++++++ docs/regression-rules.md | 11 +++ src/renderer/src/lib/chromeTheme.test.ts | 8 ++ tools/e2e/run.mjs | 83 ++++++++++++++++ 7 files changed, 388 insertions(+), 2 deletions(-) create mode 100644 core/renderer/lib/termReplies.test.ts create mode 100644 core/renderer/lib/termReplies.ts diff --git a/CLAUDE.md b/CLAUDE.md index 40133ed..096a94f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,7 +50,8 @@ read the full entry before changing anything it names. 1. [What reaches a shell is inert until the user acts](docs/regression-rules.md#inert-input): pastes go through `sanitizePaste`, paths through `quotePath` per shell, Windows tools by full path. 2. [The only command the app writes into a shell is the agent resume](docs/regression-rules.md#only-command-is-resume), - as the STARTUP command with a validated id. Never type into a user's shell. + as the STARTUP command with a validated id. Never type into a user's shell; a program gets only + [replies to what it asked](docs/regression-rules.md#replies-only-when-asked). 3. [Main installs only what main offered](docs/regression-rules.md#main-installs-only-offered): `update:install` refuses any url but `pendingUpdate.url`; state files are written atomically. 4. [Renderer is sandboxed](docs/regression-rules.md#renderer-sandboxed): `sandbox: true`, context diff --git a/core/renderer/components/TerminalPanel.tsx b/core/renderer/components/TerminalPanel.tsx index 01db8f3..ddfe3b8 100644 --- a/core/renderer/components/TerminalPanel.tsx +++ b/core/renderer/components/TerminalPanel.tsx @@ -34,6 +34,18 @@ import { parseAgentSignal } from '../lib/agentHookSignal' import { resolveTermTheme, watchTermTheme } from '../lib/termTheme' import { onGround } from '../lib/termGround' import { xtermTheme, type XtermTheme } from '../lib/termXterm' +import { + THEME_REPORTS_OFF, + colourQueryReplies, + decrqmReply, + dsrThemeReply, + groundMode, + modeParams, + noteThemeMode, + themePush, + themeReports, + type GroundMode +} from '../lib/termReplies' import { followsHostStyle, paintsGround, termApi, termHost } from '../host' import { findLinks, linkColor } from '../lib/termLinks' import { copyText } from '../lib/copyNotice' @@ -92,6 +104,8 @@ interface Session { /** The folder the shell is in: where it started, then what its prompt * last reported. A relative path on screen is relative to it (#99). */ cwd(): string + /** The look changed to this mode: tell the program, if it asked (#172). */ + tellTheme(mode: GroundMode): void } const sessions = new Map() @@ -420,8 +434,12 @@ function applyLook(): void { const theme = currentTermTheme() const base = termBaseFontPx() const family = termFontStack() + // Measured once from the ground as painted (rule 15), told to each program + // that asked (?2031h) only when it differs from what it was last told. + const mode = groundMode(groundedTheme().background) for (const [id, s] of sessions) { s.term.options.theme = theme + s.tellTheme(mode) s.links.repaint() // the link colour is measured against the theme's ground const want = s.fontOverride ?? base const sizeChanged = s.term.options.fontSize !== want @@ -765,6 +783,59 @@ function createSession(id: string, root: string, shellId: string | undefined): S reportAgentSignal(id, signal) return true }) + // THE PROGRAM IS TOLD THE GROUND IT REALLY SITS ON (#168, #172; rule + // replies-only-when-asked). MEASURED (Claude Code 2.1.296): xterm answered + // `ESC]11;?` as rgb:0000/0000/0000, read off the clear canvas the panel + // paints behind (rule 14), so Claude's "auto" theme was dark on Fawn too. + // Claude writes ?2031h without a DECRQM probe, and on a pushed ?997;2n asks + // OSC 11 again and turns light. Every reply goes through term.input(.., false): + // onData, in stream order with xterm's own replies (a DA1 right after stays + // after), and starting with ESC so `looksTyped` never counts it as typing. + // Anything these do not answer returns false and stays xterm's. + let reports = THEME_REPORTS_OFF + const reply = (r: string): void => term.input(r, false) + const modeNow = (): GroundMode => groundMode(groundedTheme().background) + const colourQuery = (ident: 10 | 11) => (data: string): boolean => { + const theme = groundedTheme() + const replies = colourQueryReplies(ident, data, { fg: theme.foreground, bg: theme.background }) + if (!replies) return false + replies.forEach(reply) + // An answered ground tells the program the mode: no push for it later. + if (ident === 11 || replies.length > 1) reports = noteThemeMode(reports, groundMode(theme.background)) + return true + } + term.parser.registerOscHandler(10, colourQuery(10)) + term.parser.registerOscHandler(11, colourQuery(11)) + // ?2031h / ?2031l, followed from the stream. False: xterm still applies + // whatever other modes ride the same sequence. + const trackReports = (on: boolean) => (params: (number | number[])[]): boolean => { + if (modeParams(params).includes(2031)) reports = themeReports(reports, on, modeNow()) + return false + } + term.parser.registerCsiHandler({ prefix: '?', final: 'h' }, trackReports(true)) + term.parser.registerCsiHandler({ prefix: '?', final: 'l' }, trackReports(false)) + // CSI ? 996 n: "which are you now?" xterm keeps every other DSR. + term.parser.registerCsiHandler({ prefix: '?', final: 'n' }, (params) => { + const p = modeParams(params) + if (p.length !== 1 || p[0] !== 996) return false + const mode = modeNow() + reply(dsrThemeReply(mode)) + reports = noteThemeMode(reports, mode) + return true + }) + // DECRQM for 2031, which xterm 6.0 does not know (it would say 0, "not + // recognised"); every other mode stays xterm's to report. + term.parser.registerCsiHandler({ prefix: '?', intermediates: '$', final: 'p' }, (params) => { + const p = modeParams(params) + if (p.length !== 1 || p[0] !== 2031) return false + reply(decrqmReply(2031, reports.on)) + return true + }) + const tellTheme = (mode: GroundMode): void => { + const r = themePush(reports, mode) + reports = r.state + if (r.send) reply(r.send) + } // A RESUMING TAB WEARS A SKELETON (#106), not a text spinner: the shell's // own words (its prompt, the resume command) are cleared the moment the // agent takes the console, and the terminal is shown once the agent has @@ -937,7 +1008,7 @@ function createSession(id: string, root: string, shellId: string | undefined): S return true }) - const session: Session = { term, fit, search, links, el, unsub, cwd: () => cwdNow } + const session: Session = { term, fit, search, links, el, unsub, cwd: () => cwdNow, tellTheme } sessions.set(id, session) // A session restored over a Claude conversation launches straight into it: diff --git a/core/renderer/lib/termReplies.test.ts b/core/renderer/lib/termReplies.test.ts new file mode 100644 index 0000000..12b2142 --- /dev/null +++ b/core/renderer/lib/termReplies.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest' +import { + THEME_REPORTS_OFF, + colourQueryReplies, + decrqmReply, + dsrThemeReply, + groundMode, + modeParams, + noteThemeMode, + oscColour, + themePush, + themeReports +} from './termReplies' +import { resolveTermTheme } from './termTheme' + +const COLOURS = { fg: '#3d2a1f', bg: '#e6d8c0' } + +describe('oscColour', () => { + it("doubles each byte, xterm's own form", () => { + // The value MEASURED from xterm itself on an opaque light ground. + expect(oscColour('#f5f5f0')).toBe('rgb:f5f5/f5f5/f0f0') + }) + it('drops the alpha: the program is told the colour it sees', () => { + expect(oscColour('#f5f5f0cc')).toBe('rgb:f5f5/f5f5/f0f0') + }) +}) + +describe('colourQueryReplies', () => { + it('answers one query with its own number', () => { + expect(colourQueryReplies(11, '?', COLOURS)).toEqual(['\x1b]11;rgb:e6e6/d8d8/c0c0\x1b\\']) + expect(colourQueryReplies(10, '?', COLOURS)).toEqual(['\x1b]10;rgb:3d3d/2a2a/1f1f\x1b\\']) + }) + it('answers a list in order: 10;?;? asks for 10 then 11', () => { + expect(colourQueryReplies(10, '?;?', COLOURS)).toEqual([ + '\x1b]10;rgb:3d3d/2a2a/1f1f\x1b\\', + '\x1b]11;rgb:e6e6/d8d8/c0c0\x1b\\' + ]) + }) + it('leaves a SET, and anything past 11, to xterm', () => { + expect(colourQueryReplies(11, 'rgb:00/00/00', COLOURS)).toBeNull() + expect(colourQueryReplies(10, '?;#000000', COLOURS)).toBeNull() + expect(colourQueryReplies(11, '?;?', COLOURS)).toBeNull() + expect(colourQueryReplies(11, '', COLOURS)).toBeNull() + }) + it('never holds CR, LF or BEL: a reply, not a command', () => { + for (const r of colourQueryReplies(10, '?;?', COLOURS) ?? []) + for (const c of ['\r', '\n', '\x07']) expect(r.includes(c)).toBe(false) + }) +}) + +describe('groundMode', () => { + it.each(['fawn', 'paper'])('%s is light', (id) => { + expect(groundMode(resolveTermTheme(id).background)).toBe('light') + }) + it.each(['pt-default', 'prism'])('%s is dark', (id) => { + expect(groundMode(resolveTermTheme(id).background)).toBe('dark') + }) + it('measures the colour, never its alpha', () => { + expect(groundMode('#e6d8c040')).toBe('light') + }) +}) + +describe('the mode reports (#172)', () => { + it('DSR 996 is answered 997;1 dark, 997;2 light', () => { + expect(dsrThemeReply('dark')).toBe('\x1b[?997;1n') + expect(dsrThemeReply('light')).toBe('\x1b[?997;2n') + }) + it('DECRQM 2031 says set or reset', () => { + expect(decrqmReply(2031, true)).toBe('\x1b[?2031;1$y') + expect(decrqmReply(2031, false)).toBe('\x1b[?2031;2$y') + }) + it("flattens xterm's params", () => { + expect(modeParams([1004, [2031, 1], 25])).toEqual([1004, 2031, 1, 25]) + }) +}) + +describe('themePush', () => { + it('sends nothing while the program has not asked for reports', () => { + expect(themePush(THEME_REPORTS_OFF, 'light').send).toBeNull() + }) + it('sends a change once, and nothing for the same mode again', () => { + // ?2031h while the ground is dark: the mode the program can already know. + let s = themeReports(THEME_REPORTS_OFF, true, 'dark') + expect(themePush(s, 'dark').send).toBeNull() // a font change: no news + const r = themePush(s, 'light') + expect(r.send).toBe('\x1b[?997;2n') + s = r.state + expect(themePush(s, 'light').send).toBeNull() + expect(themePush(s, 'dark').send).toBe('\x1b[?997;1n') + }) + it('stops after ?2031l, and turning it on again keeps what was told', () => { + let s = themeReports(THEME_REPORTS_OFF, true, 'dark') + s = themeReports(s, false, 'dark') + expect(themePush(s, 'light').send).toBeNull() + s = themeReports(s, true, 'light') + // Told dark last; the ground is light now, so the next look change says so. + expect(themePush(s, 'light').send).toBe('\x1b[?997;2n') + }) + it('an answered query counts as told', () => { + let s = themeReports(THEME_REPORTS_OFF, true, 'dark') + s = noteThemeMode(s, 'light') + expect(themePush(s, 'light').send).toBeNull() + }) +}) diff --git a/core/renderer/lib/termReplies.ts b/core/renderer/lib/termReplies.ts new file mode 100644 index 0000000..c7350f6 --- /dev/null +++ b/core/renderer/lib/termReplies.ts @@ -0,0 +1,108 @@ +import { opaque } from './colour' +import { luminance } from './termAnsi' + +// WHAT THE TERMINAL ANSWERS A PROGRAM THAT ASKS (#168, #172). Pure: the panel +// registers the parser handlers and writes these through `term.input(reply, +// false)`, the path xterm's own replies take. Rule `replies-only-when-asked` +// (docs/regression-rules.md): every byte here answers a query in the pty's own +// stream, or is the `?997` report the program switched on with `?2031h`, and +// none holds CR, LF or anything a shell would run. +// +// MEASURED (Claude Code 2.1.296, bundled ConPTY, 2026-10-10): xterm answered +// `ESC]11;?` from the CLEAR canvas the panel paints behind (rule 14) as +// `rgb:0000/0000/0000`, so Claude's "auto" theme picked dark on every ground. +// Claude asks OSC 11 only (never OSC 10, never `?996n`), writes `?2031h` +// without a DECRQM probe first, and on a pushed `?997;2n` asks OSC 11 again and +// switches: its debug log went from "detected=dark" to "detected=light". + +export type GroundMode = 'dark' | 'light' + +const ESC = '\x1b' +/** String terminator. xterm itself ends its colour replies with BEL; ST is + * the standard form and Claude parses it (the measurement above used it). */ +const ST = `${ESC}\\` + +/** `#rrggbb` (any alpha dropped) as `rgb:rrrr/gggg/bbbb`, each byte doubled: + * xterm's own reply form. */ +export function oscColour(hex: string): string { + const h = opaque(hex).slice(1) + const ch = (i: number): string => h.slice(i, i + 2).repeat(2) + return `rgb:${ch(0)}/${ch(2)}/${ch(4)}` +} + +/** + * The replies to an OSC 10 (text) or 11 (ground) whose every item is `?`. The + * OSC takes a list: `10;?;?` asks for 10 then 11. Null for anything else - a + * SET, an empty one, or a list running past 11 (the cursor and beyond, which + * xterm answers itself) - so the handler returns false and xterm does what it + * always did. + */ +export function colourQueryReplies( + ident: 10 | 11, + data: string, + colours: { fg: string; bg: string } +): string[] | null { + const items = data.split(';') + if (!items.length || items.some((i) => i !== '?')) return null + if (ident + items.length - 1 > 11) return null + return items.map((_, i) => { + const n = ident + i + return `${ESC}]${n};${oscColour(n === 10 ? colours.fg : colours.bg)}${ST}` + }) +} + +/** + * Light or dark, MEASURED from the ground: luminance over 0.4 is light. The + * SAME measurement the window chrome makes (`src/renderer/src/lib/chromeTheme.ts`, + * `chromeTokens`'s `mode`; rule 15, never read off a name). The core cannot + * import the host's file, so `chromeTheme.test.ts` pins that the two agree on + * every preset: a program must never be told light under a dark title bar. + */ +export function groundMode(bg: string): GroundMode { + return luminance(opaque(bg, '#0b0b0f')) > 0.4 ? 'light' : 'dark' +} + +/** The answer to `CSI ? 996 n`, and the report mode 2031 pushes. */ +export function dsrThemeReply(mode: GroundMode): string { + return `${ESC}[?997;${mode === 'dark' ? 1 : 2}n` +} + +/** DECRQM's answer for a private mode: 1 set, 2 reset. */ +export function decrqmReply(mode: number, set: boolean): string { + return `${ESC}[?${mode};${set ? 1 : 2}$y` +} + +/** xterm hands CSI params as `(number | number[])[]` (sub-params nested). */ +export function modeParams(params: (number | number[])[]): number[] { + return params.flatMap((p) => p) +} + +/** Whether the program asked for theme reports (`?2031h`), and the mode it + * was last told or could have learned (null: nothing yet). */ +export interface ThemeReports { + on: boolean + last: GroundMode | null +} +export const THEME_REPORTS_OFF: ThemeReports = { on: false, last: null } + +/** + * `?2031h` / `?2031l`. Turned on, a session with nothing told yet takes the + * mode in force as known: a report is news of a CHANGE, and the program asks + * for the current one itself (OSC 11, or `?996n`). Without this, the first + * font change after `?2031h` would push a mode that never changed. + */ +export function themeReports(s: ThemeReports, on: boolean, now: GroundMode): ThemeReports { + return { on, last: on ? (s.last ?? now) : s.last } +} + +/** A query answered (OSC 11, `?996n`): the program now knows `mode`. */ +export function noteThemeMode(s: ThemeReports, mode: GroundMode): ThemeReports { + return { ...s, last: mode } +} + +/** The look changed: the report to push, only while the program asked for + * them and only when the mode differs from what it was last told. */ +export function themePush(s: ThemeReports, mode: GroundMode): { send: string | null; state: ThemeReports } { + if (!s.on || s.last === mode) return { send: null, state: s } + return { send: dsrThemeReply(mode), state: { ...s, last: mode } } +} diff --git a/docs/regression-rules.md b/docs/regression-rules.md index bc3f03a..0df3bbf 100644 --- a/docs/regression-rules.md +++ b/docs/regression-rules.md @@ -161,6 +161,17 @@ one-liner in `CLAUDE.md` only if it is among the most important. the FIRST token too: a native `claude.exe` started by bare name has the command line `claude`. - **The only command the app writes into a shell is the agent resume**, as the shell's STARTUP command, with the id shape-checked in main (`validResume`). Never type into a user's shell. +- **THE APP ANSWERS A PROGRAM ONLY WHAT IT ASKED** (#168, #171, #172, #176; spec + `docs/superpowers/specs/2026-10-10-claude-code-compat-design.md` section 15). Every byte the core writes into a pty that + no key produced is a reply to a query in that pty's own stream (OSC 10/11 `?`, `CSI ? 996 n`, `CSI > q`, DECRQM 2031) + or the `?997` report the program turned on with `?2031h`, and only on a change. Replies hold no CR, LF or printable + command, go through `term.input(reply, false)` so `onData` and `looksTyped` treat them as the replies they are + ([typing on onKey](#typing-on-onkey)), and never read anything private: OSC 52 is write-only and a read gets no + answer. The pure half is `core/renderer/lib/termReplies.ts` (tested); the light/dark it reports is the chrome's own + measurement (`chromeTheme.test.ts` pins the two agree on every preset). MEASURED (Claude Code 2.1.296, 2026-10-10): + xterm had answered OSC 11 from the clear canvas as `rgb:0000/0000/0000`, so Claude was dark on every ground; Claude + writes `?2031h` without a DECRQM probe, never asks `?996n` or OSC 10, and on a pushed `?997;2n` re-asks OSC 11 and + turns light. - **Closing the window QUITS; closing the last tab does not** (owner, 2026-09-18, after using the first build, which hid the window and stayed resident: "the app should actually close when you close it"). The last tab lands on the start screen (`EmptyState`, Tabby's shape by owner diff --git a/src/renderer/src/lib/chromeTheme.test.ts b/src/renderer/src/lib/chromeTheme.test.ts index 96cecae..7cafe65 100644 --- a/src/renderer/src/lib/chromeTheme.test.ts +++ b/src/renderer/src/lib/chromeTheme.test.ts @@ -3,6 +3,7 @@ import { CHROME_COLOUR_TOKENS, chromeTokens } from './chromeTheme' import { TERM_PRESETS, resolveTermTheme, type TermTheme } from '@core/renderer/lib/termTheme' import { contrastRatio } from '@core/renderer/lib/termAnsi' import { composite } from '@core/renderer/lib/colour' +import { groundMode } from '@core/renderer/lib/termReplies' import { WINDOW_EDGES } from '@shared/windowEdges' describe('chromeTokens', () => { @@ -27,6 +28,13 @@ describe('chromeTokens', () => { expect(contrastRatio(vars['--p-on-accent'], vars['--p-sel-bg'])).toBeGreaterThanOrEqual(4.5) } ) + // What a program is told (#168, #172) and what the chrome wears are one + // measurement: Claude must never pick light under a dark title bar. The + // core cannot import this file (its lint wall), so the agreement is pinned here. + it.each(TERM_PRESETS.map((p) => p.id))('%s: a program is told the mode the chrome wears', (id) => { + const theme = resolveTermTheme(id) + expect(groundMode(theme.background)).toBe(chromeTokens(theme).mode) + }) it('wears a chosen accent exactly when the ground can show it', () => { const theme = resolveTermTheme('prism') const { vars } = chromeTokens(theme, 1, undefined, 'hairline', '#e07a2f') diff --git a/tools/e2e/run.mjs b/tools/e2e/run.mjs index 923e959..3136588 100644 --- a/tools/e2e/run.mjs +++ b/tools/e2e/run.mjs @@ -2295,6 +2295,89 @@ const scenarios = { } }, + // A PROGRAM IS TOLD THE GROUND IT SITS ON, AND HEARS A THEME SWITCH (#168, + // #172; rule replies-only-when-asked). A stand-in for Claude asks what + // Claude asks (MEASURED, 2.1.296: ?2031h, then OSC 11) plus OSC 10, ?996n + // and DECRQM 2031, then DA1 as a sentinel every terminal answers, and prints + // every chunk it reads as `IN `. Before the fix OSC 11 came back + // rgb:0000/0000/0000 on every theme and a switch told the program nothing. + // Measured over bare node-pty and the bundled ConPTY (2026-10-11): every one + // of these queries and replies passes through ConPTY untouched. + async termReplies(ok) { + const w = world() + const probe = join(w.alpha, 'replies-probe.cjs') + writeFileSync( + probe, + [ + 'process.stdin.setRawMode(true)', + 'process.stdin.resume()', + "const show = (s) => s.replace(/\\x1b/g, '\\\\e').replace(/\\x07/g, '\\\\a').replace(/\\r/g, '\\\\r').replace(/\\n/g, '\\\\n')", + "process.stdin.on('data', (b) => {", + ' const s = b.toString()', + " if (s === 'q') process.exit(0)", + " if (s === 't') { process.stdout.write('\\x1b]11;?\\x07\\x1b[?996n'); return }", + " process.stdout.write('IN ' + show(s) + '\\r\\n')", + '})', + "process.stdout.write('\\x1b[?2031h\\x1b]11;?\\x07\\x1b]10;?\\x07\\x1b[?996n\\x1b[?2031$p\\x1b[c')" + ].join('\n') + ) + const { app, page } = await launch(w, { args: [w.alpha] }) + // Chunks may coalesce on the way through ConPTY, so the screen is read as + // one text and the escaped replies are counted in it. + const heard = async () => (await termText(page)).replace(/\u00a0/g, ' ') + const count = (text, s) => text.split(s).length - 1 + const PT_GROUND = '\\e]11;rgb:1212/1212/1212\\e\\' + const FAWN_GROUND = '\\e]11;rgb:e6e6/d8d8/c0c0\\e\\' + const DARK = '\\e[?997;1n' + const LIGHT = '\\e[?997;2n' + const backToShell = async () => { + await backToFirst(page) + // Focused without a click: a click on the input line would send the + // caret's arrow keys to the program (click to put the caret there). + await page.locator('.xterm-helper-textarea').first().focus() + } + try { + await typeLine(page, `& '${process.execPath}' '${probe}'`) + ok(!!(await until(async () => (await heard()).includes('\\e[?1;2c'), 15000)), 'the stand-in program heard its DA1 answer') + const first = await heard() + ok(first.includes(PT_GROUND), `OSC 11 answers PT Default's ground, not black (${first.match(/\\e\]11;[^\\]*/)?.[0]})`) + ok(!first.includes('rgb:0000/0000/0000'), 'and nothing answers transparent black') + const ink = first.match(/\\e\]10;(rgb:[0-9a-f/]+)/)?.[1] + ok(!!ink && ink !== 'rgb:1212/1212/1212', `OSC 10 answers the theme's text (${ink})`) + ok(first.includes(DARK), 'CSI ? 996 n answers dark on PT Default') + ok(first.includes('\\e[?2031;1$y'), 'DECRQM says 2031 is set once the program set it') + ok(first.lastIndexOf('\\e[?1;2c') > first.lastIndexOf('\\e[?2031;1$y'), 'and the DA1 answer comes LAST: the replies keep stream order') + + await pickTheme(page, 'fawn') + await backToShell() + ok(!!(await until(async () => count(await heard(), LIGHT) >= 1, 5000)), 'picking Fawn tells the program light') + await sleep(800) + ok(count(await heard(), LIGHT) === 1, `exactly once (${count(await heard(), LIGHT)})`) + await page.keyboard.type('t') + ok(!!(await until(async () => (await heard()).includes(FAWN_GROUND), 5000)), "asked again, OSC 11 answers Fawn's ground") + + await pickTheme(page, 'pt-default') + await backToShell() + ok(!!(await until(async () => count(await heard(), DARK) >= 2, 5000)), 'back to PT Default tells the program dark') + await sleep(800) + const settled = await heard() + // A font size change restyles every session and must tell nothing. + await gotoPref(page, 'term-font') + await page.locator('[data-pref="term-font"] button[aria-haspopup="listbox"]').click() + await page.locator('[role="listbox"] [role="option"]').filter({ hasText: /^\s*120%\s*$/ }).first().click() + await backToShell() + await sleep(1000) + const after = await heard() + ok( + count(after, DARK) === count(settled, DARK) && count(after, LIGHT) === count(settled, LIGHT), + 'a font size change sends the program nothing' + ) + await page.keyboard.type('q') + } finally { + await closeApp(app) + } + }, + async links(ok) { const w = world() const { app, page } = await launch(w, { args: [w.alpha] }) From 7fded943389817ca1d1619ea56f8afbaaffd10ad Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:32:46 +0200 Subject: [PATCH 06/18] fix(core): OSC 8 links open on a left click and wear the link colour (#169) xterm 6.0's default for an OSC 8 link asked confirm() and then opened a blank window that main denies, on a click of any button. A linkHandler now opens http(s) through openExternal on a LEFT click only (the right one is the menu's), allowNonHttpProtocols off, never a confirm. The cells are painted from the stream (core/renderer/lib/termOsc8.ts): the cursor at the open and at the close bracket the label, a marker holds the line on the normal screen, and a span stands only while its cells hold the text it was printed with. Both screens paint them; the menu's Copy link / Open link take the uri. Measured 2026-10-11 (node-pty with the bundled ConPTY, @xterm/headless): Claude Code 2.1.296 (haiku) printed "[OSC8LABEL](https://example.com/osc8)" as one OSC 8 link on the alternate screen, and following the stream found "OSC8LABEL" at row 9, cells 2 to 11; a pwsh OSC 8 line on the normal screen, and one wrapping from cell 35 of a 40-cell row, came out exact too. New e2e scenario osc8Links (not run here: the Gate runs e2e). Fixes #169 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t --- core/renderer/components/TerminalPanel.tsx | 106 ++++++++++++- core/renderer/lib/termLinkPaint.ts | 65 +++++++- core/renderer/lib/termOsc8.test.ts | 176 +++++++++++++++++++++ core/renderer/lib/termOsc8.ts | 158 ++++++++++++++++++ docs/regression-rules.md | 8 +- tools/e2e/run.mjs | 111 +++++++++++++ 6 files changed, 613 insertions(+), 11 deletions(-) create mode 100644 core/renderer/lib/termOsc8.test.ts create mode 100644 core/renderer/lib/termOsc8.ts diff --git a/core/renderer/components/TerminalPanel.tsx b/core/renderer/components/TerminalPanel.tsx index ddfe3b8..7e7bc93 100644 --- a/core/renderer/components/TerminalPanel.tsx +++ b/core/renderer/components/TerminalPanel.tsx @@ -66,6 +66,7 @@ import { type CaretPos } from '../lib/termCaretHold' import { cellFrom, cellText, type CellInfo } from '../lib/termCells' +import { bufferRowCells, isWebLink, Osc8Spans, parseOsc8, spanText, type SpanAnchor } from '../lib/termOsc8' import { onTermLookChange, termBaseFontPx, @@ -106,6 +107,9 @@ interface Session { cwd(): string /** The look changed to this mode: tell the program, if it asked (#172). */ tellTheme(mode: GroundMode): void + /** The uri of the OSC 8 link over buffer cell (`line`, `x`) of the screen in + * front, while it still stands (#169). */ + oscLink(line: number, x: number): string | null } const sessions = new Map() @@ -415,7 +419,9 @@ export function termContextAt( const line = logicalLine(term, y) const target = (y - line.first) * term.cols + col const at = line.index[Math.min(target, line.index.length - 1)] - const link = linkAt(line.text, at) + // An OSC 8 label ("PR #165") is a link though its text is not (#169); its + // uri is what a left click opens, so it is what the menu copies and opens. + const link = s.oscLink(y, col) ?? linkAt(line.text, at) return { selection, link, path: link ? null : pathAt(line.text, at, s.cwd()) } } @@ -637,7 +643,19 @@ function createSession(id: string, root: string, shellId: string | undefined): S // The bundled dll is newer than any inbox conhost; to xterm only // "21376 or later" matters, which keeps its reflow ON and grows the // scrollback the way ConPTY expects when rows are added. - windowsPty: { backend: 'conpty', buildNumber: 22621 } + windowsPty: { backend: 'conpty', buildNumber: 22621 }, + // OSC 8 LINKS OPEN ON A LEFT CLICK (#169). Without a handler xterm 6.0 + // asks confirm() and then opens a blank window, which main denies (only + // http(s) navigates), on a click of ANY button: the right one belongs to + // the menu, as for every other link (owner, 2026-09-28). No confirm, ever. + // Only http(s): xterm drops any other scheme with allowNonHttpProtocols + // off, and this checks again, as do the preload and main. + linkHandler: { + activate: (e, uri) => { + if (e.button === 0 && isWebLink(uri)) termApi().openExternal(uri) + }, + allowNonHttpProtocols: false + } }) const fit = new FitAddon() term.loadAddon(fit) @@ -664,8 +682,12 @@ function createSession(id: string, root: string, shellId: string | undefined): S let cwdNow = root // Paths are asked about as THIS session (#167): a found one repaints only // this terminal, and only the lines that were waiting for it. + // The OSC 8 links this session's programs printed (#169), followed from the + // stream below; the painter colours them on both screens. + const osc8 = new Osc8Spans() const links = attachLinkPaint(term, currentLinkColor, (text) => linkRanges(text, cwdNow, id), { - asked: () => takeAsked(id) + asked: () => takeAsked(id), + spans: (first, last) => osc8.overlapping(first, last, term.buffer.active.type === 'alternate') }) // A PATH IS A LINK TOO, when it exists (#99). The painter colours it; this // makes it clickable, with the pointer and the underline of a link. A left @@ -783,6 +805,76 @@ function createSession(id: string, root: string, shellId: string | undefined): S reportAgentSignal(id, signal) return true }) + // OSC 8 LINKS, FOLLOWED FROM THE STREAM (#169; termOsc8.ts says why and what + // was measured). The handler only LOOKS: it returns false, so xterm still + // records the link and its own provider makes it clickable (linkHandler). + // The cursor at the open and at the close bracket the label; a marker holds + // the open's line on the normal screen while the buffer trims under it. + let osc8Open: { anchor: SpanAnchor; x: number; uri: string; alt: boolean } | null = null + const osc8Drop = (): void => { + osc8Open?.anchor.dispose?.() + osc8Open = null + } + const osc8Close = (): void => { + const open = osc8Open + osc8Open = null + if (!open) return + const b = term.buffer.active + const alt = b.type === 'alternate' + const rows = b.baseY + b.cursorY - open.anchor.line + const endX = Math.min(b.cursorX, term.cols) + // A label is a few cells on a row or two; anything else (the program + // moved the cursor between the open and the close) is not a label. + if (open.alt !== alt || open.anchor.isDisposed || rows < 0 || rows > 16 || (rows === 0 && endX <= open.x)) { + open.anchor.dispose?.() + return + } + const span = { anchor: open.anchor, x: open.x, rows, endX, uri: open.uri, text: '', alt } + const text = spanText(span, term.cols, (line) => bufferRowCells(b.getLine(line), term.cols)) + if (!text?.trim()) { + open.anchor.dispose?.() + return + } + osc8.add({ ...span, text }) + // A row drawn before the close arrived (a write split mid-label) is + // drawn again, so the alternate screen's inker sees the span. + if (alt) { + const top = open.anchor.line - b.viewportY + term.refresh(Math.max(0, top), Math.min(term.rows - 1, top + rows)) + } + } + term.parser.registerOscHandler(8, (data) => { + const p = parseOsc8(data) + if (!p) return false + // An open while one is still open ends the first one there, as OSC 8 has it. + osc8Close() + if (!p.uri || !isWebLink(p.uri)) return false + const b = term.buffer.active + const alt = b.type === 'alternate' + let x = b.cursorX + let down = 0 + // The cursor waits past the last cell (a pending wrap): the label's first + // character lands at the start of the next row. + if (x >= term.cols) { + x = 0 + down = 1 + } + const anchor: SpanAnchor | undefined = alt + ? { line: b.baseY + b.cursorY + down } + : term.registerMarker(down) + if (anchor) osc8Open = { anchor, x, uri: p.uri, alt } + return false + }) + term.buffer.onBufferChange(() => { + // The alternate screen's links leave with it, and a new one starts bare. + osc8Drop() + osc8.clear(true) + }) + term.onResize(() => { + // A reflow moves the cells out from under every span. + osc8Drop() + osc8.clear() + }) // THE PROGRAM IS TOLD THE GROUND IT REALLY SITS ON (#168, #172; rule // replies-only-when-asked). MEASURED (Claude Code 2.1.296): xterm answered // `ESC]11;?` as rgb:0000/0000/0000, read off the clear canvas the panel @@ -1008,7 +1100,13 @@ function createSession(id: string, root: string, shellId: string | undefined): S return true }) - const session: Session = { term, fit, search, links, el, unsub, cwd: () => cwdNow, tellTheme } + const oscLink = (line: number, x: number): string | null => { + const sp = osc8.at(line, x, term.buffer.active.type === 'alternate') + if (!sp) return null + const now = spanText(sp, term.cols, (l) => bufferRowCells(term.buffer.active.getLine(l), term.cols)) + return now === sp.text ? sp.uri : null + } + const session: Session = { term, fit, search, links, el, unsub, cwd: () => cwdNow, tellTheme, oscLink } sessions.set(id, session) // A session restored over a Claude conversation launches straight into it: diff --git a/core/renderer/lib/termLinkPaint.ts b/core/renderer/lib/termLinkPaint.ts index e45ffb7..8c69dc9 100644 --- a/core/renderer/lib/termLinkPaint.ts +++ b/core/renderer/lib/termLinkPaint.ts @@ -2,6 +2,8 @@ import type { IBufferLine, IDecoration, IDisposable, IMarker, Terminal } from '@ import { crumb } from './diag' import { sliceRows, type RowPlan } from './linkScanPlan' import { findLinks } from './termLinks' +import { cellText } from './termCells' +import { bufferRowCells, spanRows, spanText, spanTextRange, type Osc8Span } from './termOsc8' /** * Paints the links in a terminal's buffer (owner, 2026-09-19). xterm's link @@ -34,6 +36,10 @@ import { findLinks } from './termLinks' * TUI rewrites starts clean and is inked again only if it still holds a link: * no smear to clean up. Per row: a program that owns the screen places its own * text, and a link it breaks over two rows is two pieces of text there. + * + * OSC 8 LINKS ARE PAINTED FROM THE STREAM (#169): the panel notes where each + * one was printed (`termOsc8.ts`) and hands them over as `opts.spans`; both + * screens paint them like any link, while their cells hold the same text. */ export interface LinkPainter extends IDisposable { /** The colour changed (a theme, a custom ground): paint everything again. */ @@ -47,6 +53,11 @@ export interface LinkPaintOptions { /** Whether the `find` call just made left a question open (a path not yet * known): that line is remembered and painted again by `revisit`. */ asked?: () => boolean + /** The OSC 8 links (#169) with a cell on buffer lines `first` to `last` of + * the screen in front. Their labels ("PR #165") are neither URLs nor paths, + * so `find` never sees them; each is painted while its cells still hold + * the text it was printed with. */ + spans?: (first: number, last: number) => Osc8Span[] } interface Painted { @@ -94,6 +105,13 @@ export function attachLinkPaint( return term.registerMarker(line - (b.baseY + b.cursorY)) } + const rowCells = (line: number): ReturnType => + bufferRowCells(term.buffer.active.getLine(line), term.cols) + + /** An OSC 8 link stands while its cells hold what was printed there: a + * TUI that writes something else over them took the link away. */ + const standing = (s: Osc8Span): boolean => spanText(s, term.cols, rowCells) === s.text + /** Drop what was painted on lines `first` to `last` (and anything trimmed). */ const forgetLines = (first: number, last: number): void => { const keep = (m: IMarker): boolean => !m.isDisposed && (m.line < first || m.line > last) @@ -149,6 +167,12 @@ export function attachLinkPaint( rows.push(line) quick += line.translateToString(r === last) } + // OSC 8 links first: their labels need not look like links at all. + for (const s of opts.spans?.(first, last) ?? []) { + if (!standing(s)) continue + for (const p of spanRows(s, term.cols)) + if (p.line >= first && p.line <= last) paintRow(p.line, p.x, p.width, ink) + } if (!/[\\/.]/.test(quick)) return // no URL and no path can be here // Built cell by cell, because a wide character is one character and TWO // cells: an index into the string is not a column once a line holds one. @@ -179,11 +203,39 @@ export function attachLinkPaint( } } - /** Ink the links in one drawn row of the alternate screen. */ - const inkRow = (row: Element, ink: string): void => { + /** + * Where the OSC 8 links on screen row `r` sit in that row's drawn text. + * Columns become text offsets cell by cell (rule 11), and a piece is kept + * only where the drawn text there is the cells' text, so a row xterm drew + * differently is left alone rather than inked in the wrong place. + */ + const spanRanges = (r: number, text: string): Array<{ start: number; end: number }> => { + const line = term.buffer.active.viewportY + r + const spans = opts.spans?.(line, line) ?? [] + if (!spans.length) return [] + const cells = rowCells(line) + if (!cells) return [] + const own = cellText(cells).text + const out: Array<{ start: number; end: number }> = [] + for (const s of spans) { + if (!standing(s)) continue + for (const p of spanRows(s, term.cols)) { + if (p.line !== line) continue + const { start, end } = spanTextRange(cells, p.x, p.x + p.width) + if (end > start && text.slice(start, end) === own.slice(start, end)) out.push({ start, end }) + } + } + return out + } + + /** Ink the links in one drawn row (screen row `r`) of the alternate screen. */ + const inkRow = (row: Element, ink: string, r: number): void => { const text = row.textContent ?? '' - if (!/[\\/.]/.test(text)) return - const links = find(text) + const found = /[\\/.]/.test(text) ? find(text) : [] + // An OSC 8 label that is itself a URL is inked once, as the URL. + const spans = spanRanges(r, text).filter((s) => !found.some((l) => l.start < s.end && s.start < l.end)) + // In order along the row: the pieces are wrapped last first, below. + const links = [...found, ...spans].sort((a, b) => a.start - b.start) if (!links.length) return // Where each text node starts in the row's text. const nodes: Array<{ node: Text; at: number }> = [] @@ -227,7 +279,7 @@ export function attachLinkPaint( const ink = color() for (let r = start; r <= end; r += 1) { const row = rows[r] - if (row) inkRow(row, ink) + if (row) inkRow(row, ink, r) } } @@ -250,7 +302,8 @@ export function attachLinkPaint( } if (!seen.size) return const ink = color() - for (const row of seen) if (!row.querySelector('[data-link-ink]')) inkRow(row, ink) + const all = Array.from(box.children) + for (const row of seen) if (!row.querySelector('[data-link-ink]')) inkRow(row, ink, all.indexOf(row)) }) watch.observe(box, { childList: true, subtree: true }) } diff --git a/core/renderer/lib/termOsc8.test.ts b/core/renderer/lib/termOsc8.test.ts new file mode 100644 index 0000000..0cce70b --- /dev/null +++ b/core/renderer/lib/termOsc8.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from 'vitest' +import type { CellInfo } from './termCells' +import { isWebLink, Osc8Spans, parseOsc8, spanRows, spanText, spanTextRange, type Osc8Span } from './termOsc8' + +const anchor = (line: number): { line: number; isDisposed: boolean; dispose(): void } => { + const a = { line, isDisposed: false, dispose: () => (a.isDisposed = true) } + return a +} + +const span = (line: number, x: number, rows: number, endX: number, alt = false, text = 'label'): Osc8Span => ({ + anchor: anchor(line), + x, + rows, + endX, + uri: 'https://a.b/c', + text, + alt +}) + +const cells = (s: string): CellInfo[] => [...s].map((ch) => ({ chars: ch === ' ' ? '' : ch, width: 1 })) + +describe('parseOsc8', () => { + it('reads the uri after the params', () => { + expect(parseOsc8('id=x;https://a.b/c')).toEqual({ uri: 'https://a.b/c' }) + expect(parseOsc8(';https://a.b/c')).toEqual({ uri: 'https://a.b/c' }) + }) + + it('keeps a semicolon inside the uri', () => { + expect(parseOsc8(';https://a.b/c?x=1;y=2')).toEqual({ uri: 'https://a.b/c?x=1;y=2' }) + }) + + it('reads an empty uri as the close', () => { + expect(parseOsc8(';')).toEqual({ uri: '' }) + expect(parseOsc8('id=x;')).toEqual({ uri: '' }) + }) + + it('gives null for what is not OSC 8 at all', () => { + expect(parseOsc8('garbage')).toBeNull() + expect(parseOsc8('')).toBeNull() + }) +}) + +describe('isWebLink', () => { + it('is http and https only', () => { + expect(isWebLink('https://a.b')).toBe(true) + expect(isWebLink('HTTP://a.b')).toBe(true) + expect(isWebLink('file:///C:/x')).toBe(false) + expect(isWebLink('javascript:alert(1)')).toBe(false) + expect(isWebLink('ms-settings:display')).toBe(false) + }) +}) + +describe('Osc8Spans', () => { + it('finds a span inside its cells and misses outside them', () => { + const s = new Osc8Spans() + s.add(span(5, 10, 0, 19)) + expect(s.at(5, 10, false)?.uri).toBe('https://a.b/c') + expect(s.at(5, 18, false)).toBeDefined() + expect(s.at(5, 19, false)).toBeUndefined() // the end is exclusive + expect(s.at(5, 9, false)).toBeUndefined() + expect(s.at(4, 12, false)).toBeUndefined() + expect(s.at(5, 12, true)).toBeUndefined() // the other screen + }) + + it('follows a span over the rows it wraps onto', () => { + const s = new Osc8Spans() + s.add(span(5, 70, 1, 4)) + expect(s.at(5, 75, false)).toBeDefined() + expect(s.at(6, 2, false)).toBeDefined() + expect(s.at(6, 4, false)).toBeUndefined() + expect(s.overlapping(6, 6, false)).toHaveLength(1) + expect(s.overlapping(7, 9, false)).toHaveLength(0) + }) + + it('reads the line where its anchor is now, after the buffer trimmed', () => { + const s = new Osc8Spans() + const sp = span(500, 0, 0, 5) + s.add(sp) + ;(sp.anchor as { line: number }).line = 400 + expect(s.at(400, 2, false)).toBeDefined() + expect(s.at(500, 2, false)).toBeUndefined() + }) + + it('forgets a span whose anchor is gone', () => { + const s = new Osc8Spans() + const sp = span(1, 0, 0, 5) + s.add(sp) + sp.anchor.dispose?.() + expect(s.at(1, 2, false)).toBeUndefined() + expect(s.overlapping(0, 10, false)).toHaveLength(0) + }) + + it('a newer span over the same cells replaces the older one', () => { + // A TUI redraws its links in place; a ConPTY repaint re-sends them. + const s = new Osc8Spans() + const old = span(3, 2, 0, 9, true) + s.add(old) + s.add({ ...span(3, 5, 0, 12, true), uri: 'https://new/' }) + expect(s.at(3, 6, true)?.uri).toBe('https://new/') + expect(s.at(3, 3, true)).toBeUndefined() + expect(old.anchor.isDisposed).toBe(true) + }) + + it('keeps spans side by side that do not overlap', () => { + const s = new Osc8Spans() + s.add(span(3, 0, 0, 4, true)) + s.add(span(3, 4, 0, 8, true)) + expect(s.overlapping(3, 3, true)).toHaveLength(2) + }) + + it('holds its cap, dropping the oldest', () => { + const s = new Osc8Spans(3) + const first = span(0, 0, 0, 2) + s.add(first) + for (let i = 1; i <= 3; i += 1) s.add(span(i, 0, 0, 2)) + expect(s.at(0, 1, false)).toBeUndefined() + expect(first.anchor.isDisposed).toBe(true) + expect(s.at(3, 1, false)).toBeDefined() + }) + + it('clears one screen and leaves the other', () => { + const s = new Osc8Spans() + s.add(span(1, 0, 0, 2, true)) + s.add(span(1, 0, 0, 2, false)) + s.clear(true) + expect(s.at(1, 1, true)).toBeUndefined() + expect(s.at(1, 1, false)).toBeDefined() + s.clear() + expect(s.at(1, 1, false)).toBeUndefined() + }) +}) + +describe('spanRows', () => { + it('cuts a span into one piece per row', () => { + expect(spanRows(span(5, 70, 2, 4), 80)).toEqual([ + { line: 5, x: 70, width: 10 }, + { line: 6, x: 0, width: 80 }, + { line: 7, x: 0, width: 4 } + ]) + expect(spanRows(span(2, 3, 0, 8), 80)).toEqual([{ line: 2, x: 3, width: 5 }]) + }) + + it('gives nothing for an empty span', () => { + expect(spanRows(span(2, 3, 0, 3), 80)).toEqual([]) + }) +}) + +describe('spanText and spanTextRange', () => { + it('reads the cells the span covers', () => { + const rows: Record = { 4: cells('see OSC8LABEL here') } + expect(spanText(span(4, 4, 0, 13), 18, (l) => rows[l])).toBe('OSC8LABEL') + }) + + it('reads across a wrap', () => { + const rows: Record = { 0: cells('abcdeOSC'), 1: cells('8LABEL ') } + expect(spanText(span(0, 5, 1, 6), 8, (l) => rows[l])).toBe('OSC8LABEL') + }) + + it('gives null when a row is gone', () => { + expect(spanText(span(0, 0, 0, 3), 8, () => undefined)).toBeNull() + }) + + it('counts a wide character as two cells and one character', () => { + // "界" is one character in two cells: cells 0 and 1, the label from cell 3. + const row: CellInfo[] = [ + { chars: '界', width: 2 }, + { chars: '', width: 0 }, + { chars: '', width: 1 }, + ...cells('PR #165') + ] + const sp = span(0, 3, 0, 10) + expect(spanText(sp, row.length, () => row)).toBe('PR #165') + // In the row's TEXT the label starts at 2 ("界" + " "), not at 3. + expect(spanTextRange(row, 3, 10)).toEqual({ start: 2, end: 9 }) + }) +}) diff --git a/core/renderer/lib/termOsc8.ts b/core/renderer/lib/termOsc8.ts new file mode 100644 index 0000000..8652688 --- /dev/null +++ b/core/renderer/lib/termOsc8.ts @@ -0,0 +1,158 @@ +import type { IBufferLine } from '@xterm/xterm' +import { cellText, type CellInfo } from './termCells' + +/** + * OSC 8 HYPERLINKS (#169): a program prints a label and says, in-band, which + * uri it stands for (`ESC ] 8 ; params ; uri ST`, label, `ESC ] 8 ; ; ST`). + * Claude Code prints its links this way once told the terminal takes them + * (FORCE_HYPERLINK, #173), "PR #165" in its status line among them. + * + * xterm records which cells belong to a link, but keeps it internal (the + * cell's `urlId`), with no public way to ask. So the panel FOLLOWS THE STREAM: + * where the cursor was at the open, where it was at the close, and the text + * between. MEASURED (Claude Code 2.1.296, bundled ConPTY, headless xterm, + * 2026-10-11): ConPTY passes the sequence through, and the cursor at the open + * and at the close bracket the label exactly ("OSC8LABEL" at row 9, cells 2 to + * 11 of the alternate screen). This is the pure half: the parse, the spans and + * the cell arithmetic. Cells are not characters (rule 11): every cell-to-text + * step goes through `termCells`. + */ + +/** The uri an OSC 8 sequence carries ('' is the close), or null when it is not + * OSC 8's `params;uri` at all. The uri is everything after the FIRST `;`. */ +export function parseOsc8(data: string): { uri: string } | null { + const at = data.indexOf(';') + if (at < 0) return null + return { uri: data.slice(at + 1) } +} + +/** The only links the app opens (main and the preload check again). */ +export const isWebLink = (uri: string): boolean => /^https?:\/\//i.test(uri) + +/** Where a span's first row is now: an xterm marker on the normal screen (it + * follows the buffer as it trims), a plain line on the alternate one. */ +export interface SpanAnchor { + readonly line: number + readonly isDisposed?: boolean + dispose?(): void +} + +export interface Osc8Span { + anchor: SpanAnchor + /** The first cell, on the anchor's row. */ + x: number + /** How many rows below the anchor the span ends. */ + rows: number + /** The cell just past the last one, on the end row. */ + endX: number + uri: string + /** What the cells held when the link was printed: the span stands only + * while they still hold it. */ + text: string + alt: boolean +} + +type Pos = readonly [line: number, x: number] + +const before = (a: Pos, b: Pos): boolean => a[0] < b[0] || (a[0] === b[0] && a[1] < b[1]) + +const live = (s: Osc8Span): boolean => !s.anchor.isDisposed && s.anchor.line >= 0 + +const startOf = (s: Osc8Span): Pos => [s.anchor.line, s.x] +const endOf = (s: Osc8Span): Pos => [s.anchor.line + s.rows, s.endX] + +const overlaps = (a: Osc8Span, b: Osc8Span): boolean => + before(startOf(a), endOf(b)) && before(startOf(b), endOf(a)) + +/** Spans kept for a terminal: a few hundred, newest last. */ +export class Osc8Spans { + private spans: Osc8Span[] = [] + + constructor(private readonly cap = 300) {} + + /** A newer span over the same cells replaces the older one: a TUI redraws + * its links in place, and a ConPTY repaint sends them again. */ + add(span: Osc8Span): void { + this.spans = this.spans.filter((s) => { + if (live(s) && (s.alt !== span.alt || !overlaps(s, span))) return true + s.anchor.dispose?.() + return false + }) + this.spans.push(span) + while (this.spans.length > this.cap) this.spans.shift()?.anchor.dispose?.() + } + + /** The span over cell (`line`, `x`) of one screen. */ + at(line: number, x: number, alt: boolean): Osc8Span | undefined { + const p: Pos = [line, x] + for (let i = this.spans.length - 1; i >= 0; i -= 1) { + const s = this.spans[i] + if (s.alt === alt && live(s) && !before(p, startOf(s)) && before(p, endOf(s))) return s + } + return undefined + } + + /** The spans with a cell on rows `first` to `last` of one screen. */ + overlapping(first: number, last: number, alt: boolean): Osc8Span[] { + return this.spans.filter( + (s) => s.alt === alt && live(s) && s.anchor.line <= last && s.anchor.line + s.rows >= first + ) + } + + /** Forget one screen's spans (a buffer switch), or all of them (a resize + * reflows the cells away from where they were). */ + clear(alt?: boolean): void { + this.spans = this.spans.filter((s) => { + if (alt !== undefined && s.alt !== alt) return true + s.anchor.dispose?.() + return false + }) + } +} + +/** The span's cells, one piece per row. */ +export function spanRows(span: Osc8Span, cols: number): Array<{ line: number; x: number; width: number }> { + const out: Array<{ line: number; x: number; width: number }> = [] + for (let r = 0; r <= span.rows; r += 1) { + const from = r === 0 ? span.x : 0 + const to = r === span.rows ? span.endX : cols + if (to > from) out.push({ line: span.anchor.line + r, x: from, width: to - from }) + } + return out +} + +/** Where cells `from` to `to` of a row sit in the row's TEXT (a wide + * character is two cells and one character). */ +export function spanTextRange(row: readonly CellInfo[], from: number, to: number): { start: number; end: number } { + const { index } = cellText(row) + const at = (x: number): number => index[Math.min(Math.max(0, x), index.length - 1)] + return { start: at(from), end: at(to) } +} + +/** A buffer row's cells, for the cell arithmetic above. */ +export function bufferRowCells(line: IBufferLine | undefined, cols: number): CellInfo[] | undefined { + if (!line) return undefined + const out: CellInfo[] = [] + for (let x = 0; x < cols; x += 1) { + const cell = line.getCell(x) + out.push({ chars: cell?.getChars() ?? '', width: cell?.getWidth() ?? 1 }) + } + return out +} + +/** What the span's cells hold now, or null when a row of it is gone. */ +export function spanText( + span: Osc8Span, + cols: number, + cellsOf: (line: number) => readonly CellInfo[] | undefined +): string | null { + let text = '' + for (const piece of spanRows(span, cols)) { + const row = cellsOf(piece.line) + if (!row) return null + const { text: t } = cellText(row) + const { start, end } = spanTextRange(row, piece.x, piece.x + piece.width) + text += t.slice(start, end) + } + return text +} diff --git a/docs/regression-rules.md b/docs/regression-rules.md index 0df3bbf..5005bf2 100644 --- a/docs/regression-rules.md +++ b/docs/regression-rules.md @@ -313,7 +313,13 @@ one-liner in `CLAUDE.md` only if it is among the most important. PAGE** (#167; MEASURED 2 s stalls before, 2009 ms and 2046 ms in `scan`, Stable's diag log 2026-10-10): a full pass paints the live screen, then the scrollback bottom up in slices of 8 ms or less (`lib/linkScanPlan.ts`, a marker as the cursor), and a found path repaints only the tab - and the lines that asked (`revisit`). The `linkPaintStall` e2e holds it. + and the lines that asked (`revisit`). The `linkPaintStall` e2e holds it. **OSC 8 LINKS** (#169) + open on a LEFT click, http(s) only, never through a confirm (the `linkHandler` option; xterm's + default asked `confirm()` and opened a blank window main denied, on any button). Their cells are + painted from the stream (`lib/termOsc8.ts`: the cursor at the open and at the close, MEASURED to + bracket Claude Code's label through the bundled ConPTY) while their text stands, on both screens, + and the menu's Copy link / Open link take their uri. A reflow or a buffer switch forgets them. + The `osc8Links` e2e holds it. - **A PATH ON SCREEN IS A LINK WHEN IT EXISTS** (#99; owner, 2026-09-29: "clickable links that would open the file or folder", then "go ahead"). `lib/termPaths` finds what COULD be a path (relative or absolute, sentence punctuation and `:12` taken off); main's `termPathOpen` answers which exist from diff --git a/tools/e2e/run.mjs b/tools/e2e/run.mjs index 3136588..a2094cb 100644 --- a/tools/e2e/run.mjs +++ b/tools/e2e/run.mjs @@ -2494,6 +2494,117 @@ const scenarios = { await closeApp(app) }, + /** + * OSC 8 LINKS (#169): a left click opens an http(s) one, with no confirm() + * box (xterm's default asked one, then opened a blank window main denied); + * a right click is the menu's, with Copy link; any other scheme opens + * nothing. Their cells wear the link colour, found by POSITION as in + * `links`, on the normal screen and on the alternate one (where Claude + * Code's fullscreen view prints "PR #165"). + */ + async osc8Links(ok) { + const w = world() + const URL8 = 'https://example.com/osc8' + const probe = join(w.alpha, 'osc8-probe.cjs') + writeFileSync( + probe, + [ + 'process.stdin.setRawMode(true)', + 'process.stdin.resume()', + "process.stdin.on('data', (b) => { if (b.toString() === 'q') { process.stdout.write('\\x1b[?1049l'); process.exit(0) } })", + `process.stdout.write('\\x1b[?1049h\\x1b[2J\\x1b[5;3H\\x1b]8;;${URL8}\\x07ALTLABEL\\x1b]8;;\\x07\\x1b[9;1H')` + ].join('\n') + ) + const { app, page } = await launch(w, { args: [w.alpha] }) + const dialogs = [] + page.on('dialog', (d) => { + dialogs.push(d.message()) + d.dismiss().catch(() => {}) + }) + const BLUE = '121,167,216' // the link colour on PT Default + const opened = async () => (await app.evaluate(() => globalThis.__e2eOpenedLinks)) ?? [] + // The row that holds exactly `label`: its first character's box and colour. + const label = (needle) => + page.evaluate((n) => { + const norm = (c) => (c.match(/\d+/g) ?? []).slice(0, 3).join(',') + const rows = [...document.querySelectorAll('.xterm .xterm-rows > div')] + for (let i = rows.length - 1; i >= 0; i -= 1) { + const text = (rows[i].textContent ?? '').replace(/\u00a0/g, ' ') + if (text.trim() !== n) continue + const at = text.indexOf(n) + const walker = document.createTreeWalker(rows[i], NodeFilter.SHOW_TEXT) + let left = at + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + if (left < node.textContent.length) { + const range = document.createRange() + range.setStart(node, left) + range.setEnd(node, left + 1) + const b = range.getBoundingClientRect() + return { x: b.left + b.width / 2, y: b.top + b.height / 2, color: norm(getComputedStyle(node.parentElement).color) } + } + left -= node.textContent.length + } + } + return null + }, needle) + // xterm finds a link only when the pointer comes onto it and rests. + const click = async (at, button = 'left') => { + await page.mouse.move(at.x + 60, at.y + 60) + await sleep(200) + await page.mouse.move(at.x, at.y) + await sleep(400) + await page.mouse.click(at.x, at.y, { button }) + } + try { + const esc = (s) => `[char]27 + '${s}'` + const osc8 = (uri, text) => `[Console]::Write(${esc(`]8;;${uri}`)} + [char]7 + '${text}' + ${esc(']8;;')} + [char]7); Write-Host ''` + await typeLine(page, `cls; ${osc8(URL8, 'OSC8LABEL')}`) + const at = await until(async () => { + const l = await label('OSC8LABEL') + return l && l.color === BLUE ? l : null + }, 8000) + ok(!!at, `an OSC 8 label wears the link colour (${JSON.stringify(await label('OSC8LABEL'))})`) + if (at) { + await click(at, 'right') + const rows = await until(async () => { + const t = await page.locator('[role="menu"] [role="menuitem"]').allTextContents() + return t.length ? t : null + }, 4000) + await sleep(400) + ok(!!rows && rows.some((r) => r.includes('Copy link')), `a right click on it offers Copy link (${JSON.stringify(rows)})`) + ok((await opened()).length === 0, 'and opens nothing') + await page.keyboard.press('Escape') + await click(at) + ok(!!(await until(async () => (await opened()).filter((u) => u === URL8).length === 1, 4000)), 'a left click opens it, once (recorded under --e2e)') + } + // Any other scheme stays inert: xterm drops it, and nothing is painted. + await typeLine(page, `cls; ${osc8('file:///C:/x', 'FILELABEL')}`) + const file = await until(() => label('FILELABEL'), 8000) + if (file) { + ok(file.color !== BLUE, 'a file:// OSC 8 label is not painted as a link') + await click(file) + await sleep(600) + ok((await opened()).length === 1, `and a click on it opens nothing (${JSON.stringify(await opened())})`) + } else ok(false, 'the file:// label is on screen') + // The alternate screen, as in Claude Code's fullscreen view. + await typeLine(page, `& '${process.execPath}' '${probe}'`) + const alt = await until(async () => { + const l = await label('ALTLABEL') + return l && l.color === BLUE ? l : null + }, 8000) + ok(!!alt, `on the alternate screen an OSC 8 label wears the link colour too (${JSON.stringify(await label('ALTLABEL'))})`) + if (alt) { + await click(alt) + ok(!!(await until(async () => (await opened()).filter((u) => u === URL8).length === 2, 4000)), 'and a left click opens it') + } + await page.locator('.xterm-helper-textarea').first().focus() + await page.keyboard.type('q') + ok(dialogs.length === 0, `no dialog ever (${JSON.stringify(dialogs)})`) + } finally { + await closeApp(app) + } + }, + /** * KEYS AND FOCUS (code review 2026-09-24, PR 2), each in a real pwsh, each * failing on the code before the fix: From 884ca4720e19148599bc4562b4605daa2c479f17 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:33:35 +0200 Subject: [PATCH 07/18] feat(core): advertise hyperlinks to programs in a tab (#173) ptyEnv sets FORCE_HYPERLINK=1 for every shell unless the user's environment already has it, under any spelling and with any value (0 included: their choice). It lands after #169's linkHandler, so no link ever becomes a dead confirm box. Measured 2026-10-11 (Claude Code 2.1.296 haiku, node-pty with the bundled ConPTY, WT_SESSION and TERM_PROGRAM removed): with FORCE_HYPERLINK=1 one OSC 8 link for a markdown link, without it none. Who reads it (searched the same day): Claude Code (its bundled supports-hyperlinks; Claude's own "hyperlinks" setting still wins over it), the globally installed vercel CLI. Not Codex (codex.exe has no such string), and no package in this repo's node_modules. Fixes #173 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t --- core/main/terminal.test.ts | 17 +++++++++++++++++ core/main/terminal.ts | 9 +++++++++ docs/regression-rules.md | 5 ++++- tools/e2e/run.mjs | 3 +++ 4 files changed, 33 insertions(+), 1 deletion(-) diff --git a/core/main/terminal.test.ts b/core/main/terminal.test.ts index 8bbe0c1..39e3704 100644 --- a/core/main/terminal.test.ts +++ b/core/main/terminal.test.ts @@ -90,6 +90,23 @@ describe('ptyEnv', () => { expect(env.ANTHROPIC_API_KEY).toBe('k') }) + // #173: Claude Code prints its links as OSC 8 hyperlinks only when told the + // terminal takes them; without it a link is "label (long url)". + it('tells programs the terminal takes hyperlinks', () => { + expect(ptyEnv({}).FORCE_HYPERLINK).toBe('1') + }) + + it("keeps the user's own FORCE_HYPERLINK, 0 included", () => { + expect(ptyEnv({ FORCE_HYPERLINK: '0' }).FORCE_HYPERLINK).toBe('0') + }) + + it('writes FORCE_HYPERLINK under no second spelling', () => { + // Windows names are case-blind: two spellings would be one variable twice. + const env = ptyEnv({ force_hyperlink: '0' }) + expect(env.force_hyperlink).toBe('0') + expect(Object.keys(env).filter((k) => k.toUpperCase() === 'FORCE_HYPERLINK')).toEqual(['force_hyperlink']) + }) + it('passes everything else through untouched, undefined aside', () => { const env = ptyEnv({ FOO: 'bar', GONE: undefined }) expect(env.FOO).toBe('bar') diff --git a/core/main/terminal.ts b/core/main/terminal.ts index 0337962..ffc4219 100644 --- a/core/main/terminal.ts +++ b/core/main/terminal.ts @@ -178,6 +178,15 @@ export function ptyEnv( } env.TERM = PTY_OPTS.name env.COLORTERM = 'truecolor' + // THE PANEL TAKES OSC 8 HYPERLINKS (#173), and opens them on a left click + // (#169). Claude Code prints them only when told: MEASURED (2.1.296, bundled + // ConPTY, 2026-10-11) one OSC 8 link with FORCE_HYPERLINK=1, none without, + // where it printed "label (long url)" instead. Who reads it, MEASURED the + // same day: Claude Code (its bundled supports-hyperlinks; its own + // `hyperlinks` setting still wins) and the vercel CLI; not Codex, and no + // package in this repo's node_modules. A user's own value, 0 included, is + // their choice and stays, under the spelling they gave it. + if (!Object.keys(env).some((k) => k.toUpperCase() === 'FORCE_HYPERLINK')) env.FORCE_HYPERLINK = '1' // cmd reports its folder through PROMPT (#99), in front of whatever prompt // the user already had. if (shellId === 'cmd') env.PROMPT = cmdPrompt(prompt) diff --git a/docs/regression-rules.md b/docs/regression-rules.md index 5005bf2..e9e1d01 100644 --- a/docs/regression-rules.md +++ b/docs/regression-rules.md @@ -319,7 +319,10 @@ one-liner in `CLAUDE.md` only if it is among the most important. painted from the stream (`lib/termOsc8.ts`: the cursor at the open and at the close, MEASURED to bracket Claude Code's label through the bundled ConPTY) while their text stands, on both screens, and the menu's Copy link / Open link take their uri. A reflow or a buffer switch forgets them. - The `osc8Links` e2e holds it. + `FORCE_HYPERLINK=1` is set for every shell unless the user set it, any value kept (#173; `ptyEnv`; + MEASURED 2026-10-11: Claude Code prints OSC 8 only with it, its own `hyperlinks` setting still + winning; the vercel CLI reads it too; Codex and this repo's `node_modules` do not). It came after + the handler, so no link is ever a dead confirm box. The `osc8Links` e2e holds both. - **A PATH ON SCREEN IS A LINK WHEN IT EXISTS** (#99; owner, 2026-09-29: "clickable links that would open the file or folder", then "go ahead"). `lib/termPaths` finds what COULD be a path (relative or absolute, sentence punctuation and `:12` taken off); main's `termPathOpen` answers which exist from diff --git a/tools/e2e/run.mjs b/tools/e2e/run.mjs index a2094cb..becf078 100644 --- a/tools/e2e/run.mjs +++ b/tools/e2e/run.mjs @@ -2556,6 +2556,9 @@ const scenarios = { await page.mouse.click(at.x, at.y, { button }) } try { + // #173: every shell is told the terminal takes hyperlinks. + await typeLine(page, 'Write-Host "FH-$env:FORCE_HYPERLINK-END"') + ok(!!(await until(async () => (await termText(page)).includes('FH-1-END'), 8000)), 'FORCE_HYPERLINK is 1 in the shell') const esc = (s) => `[char]27 + '${s}'` const osc8 = (uri, text) => `[Console]::Write(${esc(`]8;;${uri}`)} + [char]7 + '${text}' + ${esc(']8;;')} + [char]7); Write-Host ''` await typeLine(page, `cls; ${osc8(URL8, 'OSC8LABEL')}`) From cd4cfb30e0d9c7270e0d2924d339a86b1998c72c Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:47:35 +0200 Subject: [PATCH 08/18] feat(core): answer XTVERSION so Claude can use synchronized output (#171) xterm 6.0.0 has no XTVERSION handler, and Claude Code turns on synchronized output (?2026) only after a reply. CSI > q and CSI > 0 q now answer DCS >|PrismTerminal (xterm.js ) ST through term.input(.., false); any other parameter stays xterm's. The name does not start with "xterm.js", which Claude reads as VS Code's terminal. Both apps answer PrismTerminal: the emulator is this core (spec decision 5). The versions are constants in core/shared/termVersion.ts (Prism's renderer cannot import package.json); termVersion.test.ts holds them to core/package.json and node_modules/@xterm/xterm, and core/README.md's release section says so. Measured (Claude Code 2.1.296 --model haiku over the bundled ConPTY, xterm 6.0's other replies stood in, one streamed answer, 45 s): no reply, synchronizedOutput=no and 0 ?2026h; with this reply, "terminal identified as PrismTerminal 0.32.0 (xterm.js 6.0.0)", synchronizedOutput=yes and 65 frames wrapped in ?2026h/?2026l. e2e termReplies extended: CSI > q and CSI > 0 q each get the reply once, CSI > 1 q none, all before the DA1 sentinel. Not run here (the Gate runs e2e). Fixes #171 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t --- core/README.md | 4 +++- core/renderer/components/TerminalPanel.tsx | 11 +++++++++ core/renderer/lib/termReplies.test.ts | 27 +++++++++++++++++++++- core/renderer/lib/termReplies.ts | 25 +++++++++++++++++++- core/shared/termVersion.test.ts | 20 ++++++++++++++++ core/shared/termVersion.ts | 11 +++++++++ docs/regression-rules.md | 6 ++++- tools/e2e/run.mjs | 12 +++++++++- 8 files changed, 111 insertions(+), 5 deletions(-) create mode 100644 core/shared/termVersion.test.ts create mode 100644 core/shared/termVersion.ts diff --git a/core/README.md b/core/README.md index a881270..85a9b47 100644 --- a/core/README.md +++ b/core/README.md @@ -288,7 +288,9 @@ Nobody does, by hand (PrismTerminal #23; owner, 2026-09-19: "that compiled copy needs to be auto bumped when a new Prism Terminal release or merge to main happens"). Land the change through a PR in Prism Terminal, WITH a bump of `core/package.json`'s version (CI fails the PR otherwise: a released tag is never -moved). On merge, `.github/workflows/core-release.yml`: +moved). Bump `core/shared/termVersion.ts` (`TERM_CORE_VERSION`, what XTVERSION +reports, #171) with `core/package.json`, and `XTERM_VERSION` with xterm: +`termVersion.test.ts` fails otherwise. On merge, `.github/workflows/core-release.yml`: 1. re-splits this folder into `core-dist` and tags `core-v`. Tags are `core-v*` for the core and `v*` for the app, since this repo is both; diff --git a/core/renderer/components/TerminalPanel.tsx b/core/renderer/components/TerminalPanel.tsx index 7e7bc93..b7b534a 100644 --- a/core/renderer/components/TerminalPanel.tsx +++ b/core/renderer/components/TerminalPanel.tsx @@ -44,8 +44,11 @@ import { noteThemeMode, themePush, themeReports, + xtversionAsked, + xtversionReply, type GroundMode } from '../lib/termReplies' +import { TERM_CORE_VERSION, XTERM_VERSION } from '../../shared/termVersion' import { followsHostStyle, paintsGround, termApi, termHost } from '../host' import { findLinks, linkColor } from '../lib/termLinks' import { copyText } from '../lib/copyNotice' @@ -928,6 +931,14 @@ function createSession(id: string, root: string, shellId: string | undefined): S reports = r.state if (r.send) reply(r.send) } + // XTVERSION, `CSI > q` / `CSI > 0 q` (#171; termReplies.ts has the + // measurement): xterm 6.0.0 has no handler, and without a reply Claude never + // turns on synchronized output. Any other parameter stays xterm's. + term.parser.registerCsiHandler({ prefix: '>', final: 'q' }, (params) => { + if (!xtversionAsked(modeParams(params))) return false + reply(xtversionReply(TERM_CORE_VERSION, XTERM_VERSION)) + return true + }) // A RESUMING TAB WEARS A SKELETON (#106), not a text spinner: the shell's // own words (its prompt, the resume command) are cleared the moment the // agent takes the console, and the terminal is shown once the agent has diff --git a/core/renderer/lib/termReplies.test.ts b/core/renderer/lib/termReplies.test.ts index 12b2142..4607435 100644 --- a/core/renderer/lib/termReplies.test.ts +++ b/core/renderer/lib/termReplies.test.ts @@ -9,7 +9,9 @@ import { noteThemeMode, oscColour, themePush, - themeReports + themeReports, + xtversionAsked, + xtversionReply } from './termReplies' import { resolveTermTheme } from './termTheme' @@ -102,3 +104,26 @@ describe('themePush', () => { expect(themePush(s, 'light').send).toBeNull() }) }) + +describe('xtversionReply (#171)', () => { + it('names this terminal, then the xterm.js under it, as DCS >| ... ST', () => { + expect(xtversionReply('0.32.0', '6.0.0')).toBe('\x1bP>|PrismTerminal 0.32.0 (xterm.js 6.0.0)\x1b\\') + }) + it('never starts the name with "xterm.js": Claude would apply its VS Code tuning', () => { + const name = xtversionReply('0.32.0', '6.0.0').slice('\x1bP>|'.length) + expect(name.startsWith('xterm.js')).toBe(false) + }) + it('holds no CR, LF or BEL a shell could act on', () => { + const r = xtversionReply('0.32.0', '6.0.0') + for (const c of ['\r', '\n', '\x07']) expect(r.includes(c)).toBe(false) + }) +}) + +describe('xtversionAsked', () => { + it('answers CSI > q and CSI > 0 q only', () => { + expect(xtversionAsked([])).toBe(true) + expect(xtversionAsked([0])).toBe(true) + expect(xtversionAsked([1])).toBe(false) + expect(xtversionAsked([0, 0])).toBe(false) + }) +}) diff --git a/core/renderer/lib/termReplies.ts b/core/renderer/lib/termReplies.ts index c7350f6..10a5693 100644 --- a/core/renderer/lib/termReplies.ts +++ b/core/renderer/lib/termReplies.ts @@ -1,7 +1,7 @@ import { opaque } from './colour' import { luminance } from './termAnsi' -// WHAT THE TERMINAL ANSWERS A PROGRAM THAT ASKS (#168, #172). Pure: the panel +// WHAT THE TERMINAL ANSWERS A PROGRAM THAT ASKS (#168, #172, #171). Pure: the panel // registers the parser handlers and writes these through `term.input(reply, // false)`, the path xterm's own replies take. Rule `replies-only-when-asked` // (docs/regression-rules.md): every byte here answers a query in the pty's own @@ -100,6 +100,29 @@ export function noteThemeMode(s: ThemeReports, mode: GroundMode): ThemeReports { return { ...s, last: mode } } +/** + * XTVERSION's answer (#171): `DCS > | name ST`. MEASURED (Claude Code + * 2.1.296 --model haiku over the bundled ConPTY, xterm 6.0's other replies + * stood in, one streamed four-paragraph answer, 45 s, 2026-10-11): with no + * reply Claude logs "XTVERSION: no reply" and `synchronizedOutput=no`, and + * wraps 0 frames in `?2026` (synchronized output), so a frame can tear + * mid-draw; with this reply it logs `terminal identified as "PrismTerminal + * 0.32.0 (xterm.js 6.0.0)"`, `synchronizedOutput=yes (probe: DECRPM 2026 + * status=2)`, and wraps 65 frames (65 `?2026h`, 65 `?2026l`). The name must NOT + * start with "xterm.js": Claude reads that as VS Code's terminal and applies + * its VS Code scroll tuning. The emulator IS this core in both apps, so both + * answer `PrismTerminal ` (spec decision 5). + */ +export function xtversionReply(core: string, xterm: string): string { + return `${ESC}P>|PrismTerminal ${core} (xterm.js ${xterm})${ST}` +} + +/** `CSI > q` and `CSI > 0 q` ask for the version; any other parameter is not + * XTVERSION and is left to xterm. */ +export function xtversionAsked(params: number[]): boolean { + return params.length === 0 || (params.length === 1 && params[0] === 0) +} + /** The look changed: the report to push, only while the program asked for * them and only when the mode differs from what it was last told. */ export function themePush(s: ThemeReports, mode: GroundMode): { send: string | null; state: ThemeReports } { diff --git a/core/shared/termVersion.test.ts b/core/shared/termVersion.test.ts new file mode 100644 index 0000000..d259859 --- /dev/null +++ b/core/shared/termVersion.test.ts @@ -0,0 +1,20 @@ +import { readFileSync } from 'fs' +import { join } from 'path' +import { describe, expect, it } from 'vitest' +import { TERM_CORE_VERSION, XTERM_VERSION } from './termVersion' + +// THE VERSIONS XTVERSION REPORTS (#171) ARE CONSTANTS, kept honest here: Prism's +// renderer cannot import the core's package.json, so a core release that bumps +// one and not the other fails this suite (core/README.md, "Releasing the core"). + +const version = (file: string): string => (JSON.parse(readFileSync(file, 'utf8')) as { version: string }).version + +describe('termVersion', () => { + it("is the core's own package version", () => { + expect(TERM_CORE_VERSION).toBe(version(join(__dirname, '..', 'package.json'))) + }) + + it('is the xterm.js the app bundles', () => { + expect(XTERM_VERSION).toBe(version(join(__dirname, '..', '..', 'node_modules', '@xterm', 'xterm', 'package.json'))) + }) +}) diff --git a/core/shared/termVersion.ts b/core/shared/termVersion.ts new file mode 100644 index 0000000..9a0a6f7 --- /dev/null +++ b/core/shared/termVersion.ts @@ -0,0 +1,11 @@ +/** + * The versions the terminal reports to a program that asks (XTVERSION, #171). + * + * Constants, not a JSON import of package.json: Prism's renderer tsconfig is + * `composite` and does not include the core's package.json, so the import + * would not build there. `termVersion.test.ts` holds both to the package files, + * so a core release that bumps `core/package.json` (or xterm) without this + * fails `npm test` (core/README.md, "Releasing the core"). + */ +export const TERM_CORE_VERSION = '0.32.0' +export const XTERM_VERSION = '6.0.0' diff --git a/docs/regression-rules.md b/docs/regression-rules.md index e9e1d01..9aa2b56 100644 --- a/docs/regression-rules.md +++ b/docs/regression-rules.md @@ -171,7 +171,11 @@ one-liner in `CLAUDE.md` only if it is among the most important. measurement (`chromeTheme.test.ts` pins the two agree on every preset). MEASURED (Claude Code 2.1.296, 2026-10-10): xterm had answered OSC 11 from the clear canvas as `rgb:0000/0000/0000`, so Claude was dark on every ground; Claude writes `?2031h` without a DECRQM probe, never asks `?996n` or OSC 10, and on a pushed `?997;2n` re-asks OSC 11 and - turns light. + turns light. XTVERSION (#171) answers `CSI > q` and `CSI > 0 q` only, as `PrismTerminal (xterm.js + )` (the constants in `core/shared/termVersion.ts`, held to the package files by `termVersion.test.ts`); the + name must NOT start with `xterm.js`, which Claude reads as VS Code's terminal. MEASURED (Claude Code 2.1.296 --model + haiku, bundled ConPTY, one streamed answer, 2026-10-11): no reply, `synchronizedOutput=no` and 0 `?2026h`; with the + reply, `synchronizedOutput=yes` and 65 frames wrapped in `?2026h`/`?2026l`. - **Closing the window QUITS; closing the last tab does not** (owner, 2026-09-18, after using the first build, which hid the window and stayed resident: "the app should actually close when you close it"). The last tab lands on the start screen (`EmptyState`, Tabby's shape by owner diff --git a/tools/e2e/run.mjs b/tools/e2e/run.mjs index becf078..1f58315 100644 --- a/tools/e2e/run.mjs +++ b/tools/e2e/run.mjs @@ -2318,9 +2318,13 @@ const scenarios = { " if (s === 't') { process.stdout.write('\\x1b]11;?\\x07\\x1b[?996n'); return }", " process.stdout.write('IN ' + show(s) + '\\r\\n')", '})', - "process.stdout.write('\\x1b[?2031h\\x1b]11;?\\x07\\x1b]10;?\\x07\\x1b[?996n\\x1b[?2031$p\\x1b[c')" + // XTVERSION three ways (#171): `> q` and `> 0 q` ask, `> 1 q` does not. + "process.stdout.write('\\x1b[?2031h\\x1b]11;?\\x07\\x1b]10;?\\x07\\x1b[?996n\\x1b[?2031$p\\x1b[>q\\x1b[>0q\\x1b[>1q\\x1b[c')" ].join('\n') ) + const coreVersion = JSON.parse(readFileSync(resolve(process.cwd(), 'core/package.json'), 'utf8')).version + const xtermVersion = JSON.parse(readFileSync(resolve(process.cwd(), 'node_modules/@xterm/xterm/package.json'), 'utf8')).version + const XTVERSION = `\\eP>|PrismTerminal ${coreVersion} (xterm.js ${xtermVersion})\\e\\` const { app, page } = await launch(w, { args: [w.alpha] }) // Chunks may coalesce on the way through ConPTY, so the screen is read as // one text and the escaped replies are counted in it. @@ -2347,6 +2351,12 @@ const scenarios = { ok(first.includes(DARK), 'CSI ? 996 n answers dark on PT Default') ok(first.includes('\\e[?2031;1$y'), 'DECRQM says 2031 is set once the program set it') ok(first.lastIndexOf('\\e[?1;2c') > first.lastIndexOf('\\e[?2031;1$y'), 'and the DA1 answer comes LAST: the replies keep stream order') + ok( + count(first, XTVERSION) === 2, + `XTVERSION answers CSI > q and CSI > 0 q, and not CSI > 1 q (${count(first, XTVERSION)} of ${XTVERSION})` + ) + ok(count(first, '\\eP>|') === 2, 'and nothing else answers as a version') + ok(first.lastIndexOf('\\e[?1;2c') > first.lastIndexOf(XTVERSION), 'the version answers come before DA1, in stream order') await pickTheme(page, 'fawn') await backToShell() From bc3b162740fd347fce3405843475dc8f6915f359 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:48:23 +0200 Subject: [PATCH 09/18] feat(core): write-only OSC 52 copies through main (#176) xterm 6.0.0 has no OSC 52 handler, so a program's copy (Claude Code's /copy, anything over ssh) was dropped. The panel now handles every OSC 52 (none is drawn): parseOsc52 (core/renderer/lib/termOsc52.ts, pure) takes only the "c" or empty selection, base64 of strict UTF-8, at most OSC52_MAX (1 MB of text, core/shared/termLimits.ts), and ignores a clear. A READ (?) is swallowed and never answered: a program in a tab must not read the user's clipboard. The write goes through main on its own channel, clipboard:term-write (TermApi.writeClipboardFromTerm, createTermApi, registerTermIpc), which checks the string and the cap again and refuses rather than trims; clipboard:write's 4000 cap stays the help panel's. The channel is opaque to the diagnostics log like the other clipboard write. A copy that landed raises the Copied badge. Both apps get it through the core's bridge with no host change. PRIVACY.md says what programs in a tab can and cannot do. Tests: termOsc52.test.ts, ipc.clipboard.test.ts (the new channel), ipcTiming.test.ts (the copied text is never logged). e2e termClipboard added: a write lands with the badge, a read gets no reply and leaves the clipboard, a "p" selection changes nothing. Not run here (the Gate runs e2e). Fixes #176 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t --- PRIVACY.md | 9 +++ core/main/ipc.clipboard.test.ts | 44 ++++++++++-- core/main/ipc.ts | 16 +++++ core/main/ipcTiming.test.ts | 15 ++++ core/main/ipcTiming.ts | 2 +- core/preload/api.ts | 7 ++ core/renderer/components/TerminalPanel.tsx | 20 +++++- core/renderer/host.ts | 4 ++ core/renderer/lib/termOsc52.test.ts | 51 ++++++++++++++ core/renderer/lib/termOsc52.ts | 49 +++++++++++++ core/shared/channels.ts | 2 + core/shared/termLimits.ts | 9 +++ docs/regression-rules.md | 5 +- tools/e2e/run.mjs | 80 ++++++++++++++++++++++ 14 files changed, 305 insertions(+), 8 deletions(-) create mode 100644 core/renderer/lib/termOsc52.test.ts create mode 100644 core/renderer/lib/termOsc52.ts create mode 100644 core/shared/termLimits.ts diff --git a/PRIVACY.md b/PRIVACY.md index ea2cf0a..16653fe 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -46,6 +46,15 @@ nothing, and write no file. It is passed through an environment variable of the written into your Claude Code settings. Settings > Appearance > "Exact status from Claude Code" turns it off for every terminal opened afterwards. +## What programs in a tab can do + +A program running in a tab (Claude Code's /copy, or anything you reach over ssh) can put text ON +your clipboard with the standard OSC 52 sequence, up to 1 MB, and a "Copied" badge shows when it +does. Nothing in a tab can READ your clipboard that way: such a request gets no answer. The text a +program copies is never written to the diagnostics log. When a program asks, the terminal also +tells it its own name and version (XTVERSION) and the colours it is painted in, so it can draw to +match; that answer goes only to the program that asked, on your PC. + This file is the privacy statement the [code signing policy](README.md#code-signing-policy) refers to. If the app ever gains a request that is not listed here, this file changes in the same pull request. diff --git a/core/main/ipc.clipboard.test.ts b/core/main/ipc.clipboard.test.ts index 5218260..28f1b90 100644 --- a/core/main/ipc.clipboard.test.ts +++ b/core/main/ipc.clipboard.test.ts @@ -1,18 +1,25 @@ import { describe, expect, it } from 'vitest' import { CH } from '../shared/channels' -import { CLIPBOARD_WRITE_MAX, registerTermIpc } from './ipc' +import { OSC52_MAX } from '../shared/termLimits' +import { CLIPBOARD_WRITE_MAX, registerTermIpc, type TermIpcDeps } from './ipc' // THE ONE WRITE THE PAGE MAY MAKE TO THE CLIPBOARD (#12, the help panel's copy // buttons). It is text, it is bounded, and an oversized write is REFUSED, not // trimmed: a trimmed command is a different command. -function wire(): { write: (text: unknown) => unknown; written: string[]; stop: () => void } { +function wire(extra: Partial = {}): { + write: (text: unknown) => unknown + termWrite: (text: unknown) => unknown + written: string[] + stop: () => void +} { const handlers = new Map unknown>() + const listeners = new Map void>() const written: string[] = [] const stop = registerTermIpc({ ipcMain: { handle: (channel, listener) => handlers.set(channel, listener), - on: () => undefined + on: (channel, listener) => listeners.set(channel, listener) }, send: () => undefined, clipboard: { @@ -26,9 +33,15 @@ function wire(): { write: (text: unknown) => unknown; written: string[]; stop: ( openExternal: () => undefined, spawnDir: async () => null, mayPrewarm: async () => false, - mayCd: () => false + mayCd: () => false, + ...extra }) - return { write: (text) => handlers.get(CH.clipboardWrite)?.({}, text), written, stop } + return { + write: (text) => handlers.get(CH.clipboardWrite)?.({}, text), + termWrite: (text) => handlers.get(CH.clipboardTerm)?.({}, text), + written, + stop + } } describe('clipboard:write', () => { @@ -48,3 +61,24 @@ describe('clipboard:write', () => { w.stop() }) }) + +// A PROGRAM'S COPY, OSC 52 (#176): a channel of its own, because the help +// panel's 4000 cap is right for a command and wrong for what Claude's /copy +// puts there. Up to OSC52_MAX, refused (never trimmed) over it. +describe('clipboard:term-write', () => { + it('writes a string, up to and including the cap', () => { + const w = wire() + expect(w.termWrite('OSC52 æ')).toBe(true) + const big = 'x'.repeat(OSC52_MAX) + expect(w.termWrite(big)).toBe(true) + expect(w.written).toEqual(['OSC52 æ', big]) + w.stop() + }) + + it('refuses a non-string, an empty one and one over the cap', () => { + const w = wire() + for (const bad of [undefined, null, 7, {}, ['x'], '', 'x'.repeat(OSC52_MAX + 1)]) expect(w.termWrite(bad)).toBe(false) + expect(w.written).toEqual([]) + w.stop() + }) +}) diff --git a/core/main/ipc.ts b/core/main/ipc.ts index 132a1b8..c783221 100644 --- a/core/main/ipc.ts +++ b/core/main/ipc.ts @@ -1,4 +1,5 @@ import { CH } from '../shared/channels' +import { OSC52_MAX } from '../shared/termLimits' import { validResume } from './agentResume' import { pollAgentsNow, pollAgentsSoon, startAgentPoll } from './agentPoll' import { detectShells } from './shells' @@ -167,6 +168,21 @@ export function registerTermIpc(deps: TermIpcDeps): () => void { } }) + // A PROGRAM'S COPY, OSC 52 (#176). Not clipboard:write, whose 4000 cap is + // right for a command and wrong for an answer Claude's /copy puts there. + // Checked again here (the page is never trusted with a limit), refused, not + // trimmed, over OSC52_MAX. Through main so a background tab or an unfocused + // window still copies. There is no read channel: OSC 52 is write-only. + ipcMain.handle(CH.clipboardTerm, (_e: unknown, text: unknown) => { + if (typeof text !== 'string' || text.length === 0 || text.length > OSC52_MAX) return false + try { + deps.clipboard.writeText(text) + return true + } catch { + return false + } + }) + // A link clicked in the terminal. The page is never trusted with a scheme. ipcMain.on(CH.openExternal, (_e: unknown, url: unknown) => { if (typeof url === 'string' && /^https?:\/\//i.test(url)) deps.openExternal(url) diff --git a/core/main/ipcTiming.test.ts b/core/main/ipcTiming.test.ts index a51169f..54af6d8 100644 --- a/core/main/ipcTiming.test.ts +++ b/core/main/ipcTiming.test.ts @@ -240,4 +240,19 @@ describe('timeIpcMain', () => { ipc.emit('term:input', {}, 't1', 'hunter2') expect(log.lines[0].fields.args).toEqual(['string:2', 'string:7']) }) + + it("never writes down a program's clipboard copy (#176), by default", async () => { + const ipc = new FakeIpcMain() + const log = fakeLog() + const c = clock() + timeIpcMain(ipc, log, { now: c.now }) + ipc.handle('clipboard:term-write', () => { + c.at(c.now() + 700) + return true + }) + await ipc.invoke('clipboard:term-write', 'secret token') + await Promise.resolve() + expect(log.lines.length).toBeGreaterThan(0) + expect(JSON.stringify(log.lines)).not.toContain('secret token') + }) }) diff --git a/core/main/ipcTiming.ts b/core/main/ipcTiming.ts index b1d874d..ae08091 100644 --- a/core/main/ipcTiming.ts +++ b/core/main/ipcTiming.ts @@ -79,7 +79,7 @@ export const LONG_WAIT: ReadonlySet = new Set([DCH.download]) /** Typed text, the clipboard and recorded audio: never written down, even * when the call that carried them was slow. */ -const OPAQUE = new Set([CH.input, CH.clipboardWrite, DCH.transcribe]) +const OPAQUE = new Set([CH.input, CH.clipboardWrite, CH.clipboardTerm, DCH.transcribe]) const isOwn = (ch: string): boolean => ch.startsWith('diag:') const RECENT_MAX = 32 diff --git a/core/preload/api.ts b/core/preload/api.ts index 5c36aad..abe6847 100644 --- a/core/preload/api.ts +++ b/core/preload/api.ts @@ -66,6 +66,12 @@ export interface TermPreloadApi { * and main caps its length. Answers whether it was written. */ writeClipboard(text: string): Promise + /** + * A program's OSC 52 copy (#176): its own channel, capped at OSC52_MAX + * (1 MB) rather than the help panel's 4000. Write-only: nothing in the + * bridge lets a program read the clipboard. Answers whether it landed. + */ + writeClipboardFromTerm(text: string): Promise /** The web-links addon's click-through: external URLs go to the OS browser. */ openExternal(url: string): void /** Which of these texts name a file or a folder from `cwd` (#99). Main @@ -99,6 +105,7 @@ export function createTermApi(ipc: IpcRendererLike): TermPreloadApi { onTermExit: (cb) => on(CH.exit, cb), readClipboard: () => ipc.sendSync(CH.clipboardRead) as ClipboardRead, writeClipboard: (text) => ipc.invoke(CH.clipboardWrite, text) as Promise, + writeClipboardFromTerm: (text) => ipc.invoke(CH.clipboardTerm, text) as Promise, openExternal: (url) => { if (/^https?:/i.test(url)) ipc.send(CH.openExternal, url) }, diff --git a/core/renderer/components/TerminalPanel.tsx b/core/renderer/components/TerminalPanel.tsx index b7b534a..58f5703 100644 --- a/core/renderer/components/TerminalPanel.tsx +++ b/core/renderer/components/TerminalPanel.tsx @@ -49,9 +49,10 @@ import { type GroundMode } from '../lib/termReplies' import { TERM_CORE_VERSION, XTERM_VERSION } from '../../shared/termVersion' +import { OSC52_MAX, parseOsc52 } from '../lib/termOsc52' import { followsHostStyle, paintsGround, termApi, termHost } from '../host' import { findLinks, linkColor } from '../lib/termLinks' -import { copyText } from '../lib/copyNotice' +import { announceCopied, copyText } from '../lib/copyNotice' import { arrowKeys, caretClickAllowed, caretDelta, type ClickGate } from '../lib/termClickCaret' import { linkAt, @@ -939,6 +940,23 @@ function createSession(id: string, root: string, shellId: string | undefined): S reply(xtversionReply(TERM_CORE_VERSION, XTERM_VERSION)) return true }) + // OSC 52, WRITE-ONLY (#176; termOsc52.ts says why). Every OSC 52 is handled + // (true), written or refused, so none is drawn as stray text; a READ is + // swallowed and NEVER answered. A copy goes through main (a background tab + // or an unfocused window still copies) and raises the Copied badge only when + // it landed, so a program's copy is never silent. + term.parser.registerOscHandler(52, (data) => { + const p = parseOsc52(data, OSC52_MAX) + if (p?.kind === 'write') { + void termApi() + .writeClipboardFromTerm?.(p.text) + .then((ok) => { + if (ok) announceCopied() + }) + .catch(() => undefined) + } + return true + }) // A RESUMING TAB WEARS A SKELETON (#106), not a text spinner: the shell's // own words (its prompt, the resume command) are cleared the moment the // agent takes the console, and the terminal is shown once the agent has diff --git a/core/renderer/host.ts b/core/renderer/host.ts index 2cf370d..dcd95af 100644 --- a/core/renderer/host.ts +++ b/core/renderer/host.ts @@ -48,6 +48,10 @@ export interface TermApi { * (no focus). Both hosts' bridges carry it (`createTermApi`); optional so a * host that has none still builds, and `copyText` then reports a failure. */ writeClipboard?(text: string): Promise + /** A program's OSC 52 copy (#176), through main and capped at 1 MB. + * Optional so an older host bridge still builds; the copy is then dropped, + * as it was before. Write-only: there is no read. */ + writeClipboardFromTerm?(text: string): Promise openExternal(url: string): void /** Paths (#99). Optional so a host bridge without them still builds; the * terminal then paints and opens no paths. */ diff --git a/core/renderer/lib/termOsc52.test.ts b/core/renderer/lib/termOsc52.test.ts new file mode 100644 index 0000000..5e2a3b7 --- /dev/null +++ b/core/renderer/lib/termOsc52.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { OSC52_MAX, parseOsc52 } from './termOsc52' + +// OSC 52 IS WRITE-ONLY (#176): a program may put text ON the clipboard, never +// read it. Everything else is refused (null), and a read is named so the +// handler can swallow it without an answer. + +const b64 = (s: string): string => Buffer.from(s, 'utf8').toString('base64') + +describe('parseOsc52', () => { + it('decodes a "c" write', () => { + expect(parseOsc52('c;aGVsbG8=', OSC52_MAX)).toEqual({ kind: 'write', text: 'hello' }) + }) + it('takes an empty selection as the clipboard', () => { + expect(parseOsc52(';aGVsbG8=', OSC52_MAX)).toEqual({ kind: 'write', text: 'hello' }) + }) + it('refuses the primary selection and any list of selections', () => { + expect(parseOsc52('p;aGVsbG8=', OSC52_MAX)).toBeNull() + expect(parseOsc52('cs;aGVsbG8=', OSC52_MAX)).toBeNull() + expect(parseOsc52('s0;aGVsbG8=', OSC52_MAX)).toBeNull() + }) + it('names a read, so it is swallowed and never answered', () => { + expect(parseOsc52('c;?', OSC52_MAX)).toEqual({ kind: 'read' }) + expect(parseOsc52(';?', OSC52_MAX)).toEqual({ kind: 'read' }) + }) + it('ignores a clear (empty data) and a sequence with no data part', () => { + expect(parseOsc52('c;', OSC52_MAX)).toBeNull() + expect(parseOsc52('c', OSC52_MAX)).toBeNull() + }) + it('refuses what is not base64', () => { + expect(parseOsc52('c;not base64!', OSC52_MAX)).toBeNull() + expect(parseOsc52('c;aGVsbG8', OSC52_MAX)).toBeNull() // a length that is not a multiple of 4 + expect(parseOsc52('c;a=GV', OSC52_MAX)).toBeNull() + }) + it('refuses invalid UTF-8', () => { + expect(parseOsc52(`c;${Buffer.from([0xff, 0xfe, 0x41]).toString('base64')}`, OSC52_MAX)).toBeNull() + }) + it('round-trips UTF-8', () => { + expect(parseOsc52(`c;${b64('æøå OSC52 æ')}`, OSC52_MAX)).toEqual({ kind: 'write', text: 'æøå OSC52 æ' }) + }) + it('refuses one character over the cap, takes the cap itself', () => { + expect(parseOsc52(`c;${b64('x'.repeat(10))}`, 10)).toEqual({ kind: 'write', text: 'x'.repeat(10) }) + expect(parseOsc52(`c;${b64('x'.repeat(11))}`, 10)).toBeNull() + }) + it('refuses an encoded length far past the cap without decoding it', () => { + expect(parseOsc52(`c;${'A'.repeat(4 * 1000)}`, 10)).toBeNull() + }) + it('caps at 1 MB of text', () => { + expect(OSC52_MAX).toBe(1_048_576) + }) +}) diff --git a/core/renderer/lib/termOsc52.ts b/core/renderer/lib/termOsc52.ts new file mode 100644 index 0000000..23823a3 --- /dev/null +++ b/core/renderer/lib/termOsc52.ts @@ -0,0 +1,49 @@ +import { OSC52_MAX } from '../../shared/termLimits' + +export { OSC52_MAX } + +// OSC 52, WRITE-ONLY (#176; rule replies-only-when-asked). A program in a tab +// (Claude Code's /copy, or anything at the other end of an ssh) may put text +// ON the clipboard; nothing in a tab may READ it this way, since the reply +// would hand the user's clipboard to whatever is running, local or remote. +// xterm 6.0.0 has no OSC 52 handler at all, so before this the copy was +// silently dropped. Pure: the panel registers the handler and sends a write +// through main (`writeClipboardFromTerm`). + +export type Osc52 = { kind: 'write'; text: string } | { kind: 'read' } + +const BASE64 = /^[A-Za-z0-9+/]*={0,2}$/ + +/** + * `Pc;Pd`. Pc must be exactly `c` or empty (the clipboard): the primary + * selection and lists of several are refused, since only the clipboard is a + * thing Windows has. `Pd` `?` is a read: named, so the handler swallows it, + * and never answered. Base64 is checked by shape before it is decoded, and an + * encoded length past what `max` characters could need is refused unread. + * Decoded as strict UTF-8; invalid is refused. Empty Pd (a "clear") is + * ignored: a program must not be able to wipe what the user copied. + */ +export function parseOsc52(data: string, max: number): Osc52 | null { + const semi = data.indexOf(';') + if (semi < 0) return null + const pc = data.slice(0, semi) + const pd = data.slice(semi + 1) + if (pc !== '' && pc !== 'c') return null + if (pd === '?') return { kind: 'read' } + if (!pd || pd.length % 4 !== 0 || !BASE64.test(pd)) return null + // Every UTF-16 unit is at most 3 UTF-8 bytes, so `max` characters are at + // most 3 * max bytes, which base64 makes 4 * max; longer is refused without + // decoding a byte of it. The exact cap is the character count below. + if (pd.length > max * 4 + 4) return null + let text: string + try { + const bin = atob(pd) + const bytes = new Uint8Array(bin.length) + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i) + text = new TextDecoder('utf-8', { fatal: true }).decode(bytes) + } catch { + return null + } + if (!text || text.length > max) return null + return { kind: 'write', text } +} diff --git a/core/shared/channels.ts b/core/shared/channels.ts index 7b4311a..72926aa 100644 --- a/core/shared/channels.ts +++ b/core/shared/channels.ts @@ -17,6 +17,8 @@ export const CH = { exit: 'term:exit', clipboardRead: 'clipboard:read', clipboardWrite: 'clipboard:write', + /** A program's OSC 52 copy (#176): its own channel and its own cap. */ + clipboardTerm: 'clipboard:term-write', openExternal: 'shell:open-external', pathKinds: 'term:path-kinds', openPath: 'term:open-path' diff --git a/core/shared/termLimits.ts b/core/shared/termLimits.ts new file mode 100644 index 0000000..43225b0 --- /dev/null +++ b/core/shared/termLimits.ts @@ -0,0 +1,9 @@ +/** + * The most text a program in a tab may put on the clipboard with one OSC 52 + * write (#176): 1 MB of decoded text. Shared, because the page refuses more + * before it crosses the bridge and main refuses it again (the page is never + * trusted with a limit). Large enough for any answer Claude Code's /copy + * makes; small enough that a runaway `cat` of a base64 blob cannot hand main + * hundreds of megabytes. + */ +export const OSC52_MAX = 1_048_576 diff --git a/docs/regression-rules.md b/docs/regression-rules.md index 9aa2b56..87f9e82 100644 --- a/docs/regression-rules.md +++ b/docs/regression-rules.md @@ -175,7 +175,10 @@ one-liner in `CLAUDE.md` only if it is among the most important. )` (the constants in `core/shared/termVersion.ts`, held to the package files by `termVersion.test.ts`); the name must NOT start with `xterm.js`, which Claude reads as VS Code's terminal. MEASURED (Claude Code 2.1.296 --model haiku, bundled ConPTY, one streamed answer, 2026-10-11): no reply, `synchronizedOutput=no` and 0 `?2026h`; with the - reply, `synchronizedOutput=yes` and 65 frames wrapped in `?2026h`/`?2026l`. + reply, `synchronizedOutput=yes` and 65 frames wrapped in `?2026h`/`?2026l`. OSC 52 (#176) is WRITE-ONLY: only the + `c` (or empty) selection, base64 of strict UTF-8, at most 1 MB (`OSC52_MAX`), through main + (`clipboard:term-write`, its own channel and cap, opaque to the diagnostics log), with the Copied badge; a read + (`?`) is swallowed and never answered, a clear is ignored, every OSC 52 is handled so none is drawn. - **Closing the window QUITS; closing the last tab does not** (owner, 2026-09-18, after using the first build, which hid the window and stayed resident: "the app should actually close when you close it"). The last tab lands on the start screen (`EmptyState`, Tabby's shape by owner diff --git a/tools/e2e/run.mjs b/tools/e2e/run.mjs index 1f58315..79dea71 100644 --- a/tools/e2e/run.mjs +++ b/tools/e2e/run.mjs @@ -2388,6 +2388,86 @@ const scenarios = { } }, + /** + * OSC 52 IS WRITE-ONLY (#176; rule replies-only-when-asked). A program puts + * text on the clipboard with `ESC]52;c;BEL` (Claude Code's /copy, + * anything over ssh), the "Copied" badge says so; a READ (`c;?`) gets no + * reply and leaves the clipboard alone, and a `p` selection changes nothing. + * Before the fix xterm 6.0.0 dropped every OSC 52. The clipboard is saved + * and put back, as `paste` does. + */ + async termClipboard(ok) { + const w = world() + const probe = join(w.alpha, 'osc52-probe.cjs') + const b64 = (s) => Buffer.from(s, 'utf8').toString('base64') + writeFileSync( + probe, + [ + 'process.stdin.setRawMode(true)', + 'process.stdin.resume()', + "const show = (s) => s.replace(/\\x1b/g, '\\\\e').replace(/\\x07/g, '\\\\a')", + "process.stdin.on('data', (b) => {", + ' const s = b.toString()', + " if (s === 'q') process.exit(0)", + ` if (s === '1') { process.stdout.write('\\x1b]52;c;${b64('OSC52 æ')}\\x07W1\\r\\n'); return }`, + " if (s === '2') { process.stdout.write('\\x1b]52;c;?\\x07R2\\r\\n'); return }", + ` if (s === '3') { process.stdout.write('\\x1b]52;p;${b64('PSEL52')}\\x07P3\\r\\n'); return }`, + " process.stdout.write('IN ' + show(s) + '\\r\\n')", + '})', + "process.stdout.write('READY52\\r\\n')" + ].join('\n') + ) + const { app, page } = await launch(w, { args: [w.alpha] }) + const held = await app.evaluate(({ clipboard }) => ({ + text: clipboard.readText(), + html: clipboard.readHTML(), + rtf: clipboard.readRTF(), + image: clipboard.readImage().isEmpty() ? null : clipboard.readImage().toDataURL(), + formats: clipboard.availableFormats() + })) + const clip = () => app.evaluate(({ clipboard }) => clipboard.readText()) + const shown = () => page.evaluate(() => !!document.querySelector('[data-copied-badge="shown"]')) + try { + await app.evaluate(({ clipboard }) => clipboard.writeText('BEFORE-52')) + await typeLine(page, `& '${process.execPath}' '${probe}'`) + ok(!!(await until(async () => (await termText(page)).includes('READY52'), 15000)), 'the stand-in program is running') + await page.locator('.xterm-helper-textarea').first().focus() + + await page.keyboard.type('1') + ok(!!(await until(async () => (await clip()) === 'OSC52 æ', 5000)), `a program's OSC 52 write lands on the clipboard, UTF-8 intact (${JSON.stringify(await clip())})`) + ok(!!(await until(shown, 3000)), 'and the "Copied" badge says so') + ok(!(await termText(page)).includes(']52;'), 'nothing of the sequence is drawn') + + await app.evaluate(({ clipboard }) => clipboard.writeText('HELD-52')) + await page.keyboard.type('2') + await until(async () => (await termText(page)).includes('R2'), 5000) + await sleep(1000) + const afterRead = (await termText(page)).replace(/\u00a0/g, ' ') + ok(!afterRead.includes('IN \\e]52'), 'a READ gets no reply: the program hears nothing') + ok((await clip()) === 'HELD-52', 'and the clipboard is left as it was') + + await page.keyboard.type('3') + await until(async () => (await termText(page)).includes('P3'), 5000) + await sleep(800) + ok((await clip()) === 'HELD-52', 'a "p" selection changes nothing') + await page.keyboard.type('q') + } finally { + await app + .evaluate(({ clipboard, nativeImage }, was) => { + const data = {} + if (was.text) data.text = was.text + if (was.html) data.html = was.html + if (was.rtf) data.rtf = was.rtf + if (was.image) data.image = nativeImage.createFromDataURL(was.image) + if (Object.keys(data).length) clipboard.write(data) + else clipboard.clear() + }, held) + .catch(() => {}) + if (held.formats.some((f) => /FileName|uri-list/i.test(f))) console.log(' (the clipboard held copied FILES, which cannot be put back; it is empty now)') + await closeApp(app) + } + }, + async links(ok) { const w = world() const { app, page } = await launch(w, { args: [w.alpha] }) From 624af5ffc1f2667ac276d94ff05f700881ac1226 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:49:05 +0200 Subject: [PATCH 10/18] feat: flash the taskbar on a bell while the window is unfocused (#177) The bell was silent: xterm fires onBell and nothing listened. Decided under the owner's delegation (spec decision 1): a bell flashes the taskbar button while the window is NOT focused, does nothing while it is (most often the user's own bad key; PSReadLine rings on one), and never makes a sound. - Core (both apps): term.onBell goes through the pure bellGate (at most one a second per tab; a cat of a binary rings hundreds of times) to TermApi.termBell?(id), channel term:bell, and registerTermIpc hands it to the new optional TermIpcDeps.attention. A bridge member, not a TermHostConfig field (spec decision 2): the behaviour is the same in both apps, the host only lends its window. No new TermHostConfig field. - This app: src/main/bellFlash.ts flashes an unfocused window and stops the flash on focus (one handler per window). Under --e2e it counts on globalThis.__e2eFlashes and flashes nothing. - Prism: needs one line (attention in its registerTermIpc call) in its core-release PR; until then its bell stays silent, as today. Tests: bellGate in termReplies.test.ts, term:bell in ipc.clipboard.test.ts, bellFlash.test.ts. e2e bell added (one bell one flash, ten in a burst one); not run here (the Gate runs e2e). Docs: regression-rules #bell-flashes, core/README.md, docs/two-apps.md. Fixes #177 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t --- core/README.md | 6 ++ core/main/ipc.clipboard.test.ts | 21 +++++++ core/main/ipc.ts | 12 ++++ core/preload/api.ts | 4 ++ core/renderer/components/TerminalPanel.tsx | 11 ++++ core/renderer/host.ts | 2 + core/renderer/lib/termReplies.test.ts | 17 ++++++ core/renderer/lib/termReplies.ts | 10 ++++ core/shared/channels.ts | 2 + docs/regression-rules.md | 7 +++ docs/two-apps.md | 4 +- src/main/bellFlash.test.ts | 65 ++++++++++++++++++++++ src/main/bellFlash.ts | 39 +++++++++++++ src/main/index.ts | 21 ++++++- tools/e2e/run.mjs | 24 ++++++++ 15 files changed, 243 insertions(+), 2 deletions(-) create mode 100644 src/main/bellFlash.test.ts create mode 100644 src/main/bellFlash.ts diff --git a/core/README.md b/core/README.md index 85a9b47..06c3cd5 100644 --- a/core/README.md +++ b/core/README.md @@ -48,6 +48,12 @@ ask me."* And: *"why can't this repo be the core?"* It can, and this is it. the page's setting to `termPrewarm`; one that passes no folder changes no shell's environment. Its strip draws `failedIds` as a third line, and its `themedAgentColors` may name `failed` (else `FAILED_RED`). + **The bell and OSC 52** (#177, #176) ride the bridge, not `TermHostConfig`. + A program's OSC 52 copy needs nothing from a host: `createTermApi` and + `registerTermIpc` carry `clipboard:term-write` (write-only, 1 MB). The bell + needs ONE line: a host wires it by passing `attention` to `registerTermIpc` + (Prism Terminal flashes its taskbar button while the window is unfocused, + `src/main/bellFlash.ts`); a host that passes none stays silent. **AND WHAT THE TWO APPS MUST SHOW IDENTICALLY, TERMINAL OR NOT** (#28, owner, 2026-09-19). This WIDENS the core, on purpose, from "the terminal" to "what the two apps share". Asked to build the update window (*"when you click the diff --git a/core/main/ipc.clipboard.test.ts b/core/main/ipc.clipboard.test.ts index 28f1b90..9afd02a 100644 --- a/core/main/ipc.clipboard.test.ts +++ b/core/main/ipc.clipboard.test.ts @@ -10,6 +10,7 @@ import { CLIPBOARD_WRITE_MAX, registerTermIpc, type TermIpcDeps } from './ipc' function wire(extra: Partial = {}): { write: (text: unknown) => unknown termWrite: (text: unknown) => unknown + bell: (id: unknown) => void written: string[] stop: () => void } { @@ -39,6 +40,7 @@ function wire(extra: Partial = {}): { return { write: (text) => handlers.get(CH.clipboardWrite)?.({}, text), termWrite: (text) => handlers.get(CH.clipboardTerm)?.({}, text), + bell: (id) => listeners.get(CH.bell)?.({}, id), written, stop } @@ -82,3 +84,22 @@ describe('clipboard:term-write', () => { w.stop() }) }) + +// THE BELL (#177): the host lends its window through `attention`; without it +// a host stays silent, as Prism is until its own one line. +describe('term:bell', () => { + it('calls the host attention once per bell', () => { + let calls = 0 + const w = wire({ attention: () => calls++ }) + w.bell('tab-1') + w.bell('tab-2') + expect(calls).toBe(2) + w.stop() + }) + + it('is a no-op for a host without attention', () => { + const w = wire() + expect(() => w.bell('tab-1')).not.toThrow() + w.stop() + }) +}) diff --git a/core/main/ipc.ts b/core/main/ipc.ts index c783221..f853742 100644 --- a/core/main/ipc.ts +++ b/core/main/ipc.ts @@ -73,6 +73,14 @@ export interface TermIpcDeps { * environment is touched, whatever the page says. */ claudePluginDir?: string + /** + * A session rang the bell (#177): the host draws the user's eye to its + * window (Prism Terminal flashes the taskbar button while the window is not + * focused). A bridge member, not a TermHostConfig field: the behaviour is + * the same in both apps, and the host only lends its window, as it lends + * `openExternal`. Absent: the bell is silent, as it always was. + */ + attention?(): void } /** Registers every terminal channel and starts the agent poll. Returns the @@ -183,6 +191,10 @@ export function registerTermIpc(deps: TermIpcDeps): () => void { } }) + // The bell (#177). The page already let at most one a second per tab + // through; what it means is the host's (`attention`), silence without one. + ipcMain.on(CH.bell, () => deps.attention?.()) + // A link clicked in the terminal. The page is never trusted with a scheme. ipcMain.on(CH.openExternal, (_e: unknown, url: unknown) => { if (typeof url === 'string' && /^https?:\/\//i.test(url)) deps.openExternal(url) diff --git a/core/preload/api.ts b/core/preload/api.ts index abe6847..92adc27 100644 --- a/core/preload/api.ts +++ b/core/preload/api.ts @@ -72,6 +72,9 @@ export interface TermPreloadApi { * bridge lets a program read the clipboard. Answers whether it landed. */ writeClipboardFromTerm(text: string): Promise + /** A session rang the bell (#177). What it looks like is the host's + * (`attention` in `registerTermIpc`); silent where it passes none. */ + termBell(id: string): void /** The web-links addon's click-through: external URLs go to the OS browser. */ openExternal(url: string): void /** Which of these texts name a file or a folder from `cwd` (#99). Main @@ -106,6 +109,7 @@ export function createTermApi(ipc: IpcRendererLike): TermPreloadApi { readClipboard: () => ipc.sendSync(CH.clipboardRead) as ClipboardRead, writeClipboard: (text) => ipc.invoke(CH.clipboardWrite, text) as Promise, writeClipboardFromTerm: (text) => ipc.invoke(CH.clipboardTerm, text) as Promise, + termBell: (id) => ipc.send(CH.bell, id), openExternal: (url) => { if (/^https?:/i.test(url)) ipc.send(CH.openExternal, url) }, diff --git a/core/renderer/components/TerminalPanel.tsx b/core/renderer/components/TerminalPanel.tsx index 58f5703..d9db653 100644 --- a/core/renderer/components/TerminalPanel.tsx +++ b/core/renderer/components/TerminalPanel.tsx @@ -36,6 +36,7 @@ import { onGround } from '../lib/termGround' import { xtermTheme, type XtermTheme } from '../lib/termXterm' import { THEME_REPORTS_OFF, + bellGate, colourQueryReplies, decrqmReply, dsrThemeReply, @@ -957,6 +958,16 @@ function createSession(id: string, root: string, shellId: string | undefined): S } return true }) + // THE BELL (#177): at most one a second per tab reaches the host (a `cat` of + // a binary rings hundreds of times), which flashes the taskbar while the + // window is unfocused (spec decision 1). Never a sound. + let lastBell: number | null = null + term.onBell(() => { + const now = performance.now() + if (!bellGate(lastBell, now)) return + lastBell = now + termApi().termBell?.(id) + }) // A RESUMING TAB WEARS A SKELETON (#106), not a text spinner: the shell's // own words (its prompt, the resume command) are cleared the moment the // agent takes the console, and the terminal is shown once the agent has diff --git a/core/renderer/host.ts b/core/renderer/host.ts index dcd95af..9c3c5b4 100644 --- a/core/renderer/host.ts +++ b/core/renderer/host.ts @@ -52,6 +52,8 @@ export interface TermApi { * Optional so an older host bridge still builds; the copy is then dropped, * as it was before. Write-only: there is no read. */ writeClipboardFromTerm?(text: string): Promise + /** A session rang the bell (#177). Optional; without it the bell is silent. */ + termBell?(id: string): void openExternal(url: string): void /** Paths (#99). Optional so a host bridge without them still builds; the * terminal then paints and opens no paths. */ diff --git a/core/renderer/lib/termReplies.test.ts b/core/renderer/lib/termReplies.test.ts index 4607435..ae08f3c 100644 --- a/core/renderer/lib/termReplies.test.ts +++ b/core/renderer/lib/termReplies.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest' import { + BELL_GAP_MS, THEME_REPORTS_OFF, + bellGate, colourQueryReplies, decrqmReply, dsrThemeReply, @@ -127,3 +129,18 @@ describe('xtversionAsked', () => { expect(xtversionAsked([0, 0])).toBe(false) }) }) + +describe('bellGate (#177)', () => { + it('lets the first bell through', () => { + expect(bellGate(null, 5000)).toBe(true) + }) + it('drops a bell 300 ms after the last one, lets one 1.1 s after through', () => { + expect(bellGate(5000, 5300)).toBe(false) + expect(bellGate(5000, 6100)).toBe(true) + }) + it('is a one-second gap', () => { + expect(BELL_GAP_MS).toBe(1000) + expect(bellGate(0, 999)).toBe(false) + expect(bellGate(0, 1000)).toBe(true) + }) +}) diff --git a/core/renderer/lib/termReplies.ts b/core/renderer/lib/termReplies.ts index 10a5693..371c691 100644 --- a/core/renderer/lib/termReplies.ts +++ b/core/renderer/lib/termReplies.ts @@ -123,6 +123,16 @@ export function xtversionAsked(params: number[]): boolean { return params.length === 0 || (params.length === 1 && params[0] === 0) } +/** The least time between two bells that reach the host (#177): a `cat` of a + * binary file rings hundreds of times, and a taskbar flash per ring is noise. */ +export const BELL_GAP_MS = 1000 + +/** Whether a bell at `now` gets through, given the last one that did (null: + * none yet). Per session. */ +export function bellGate(last: number | null, now: number): boolean { + return last === null || now - last >= BELL_GAP_MS +} + /** The look changed: the report to push, only while the program asked for * them and only when the mode differs from what it was last told. */ export function themePush(s: ThemeReports, mode: GroundMode): { send: string | null; state: ThemeReports } { diff --git a/core/shared/channels.ts b/core/shared/channels.ts index 72926aa..e5c6e14 100644 --- a/core/shared/channels.ts +++ b/core/shared/channels.ts @@ -19,6 +19,8 @@ export const CH = { clipboardWrite: 'clipboard:write', /** A program's OSC 52 copy (#176): its own channel and its own cap. */ clipboardTerm: 'clipboard:term-write', + /** A session rang the bell (#177); the host decides what that looks like. */ + bell: 'term:bell', openExternal: 'shell:open-external', pathKinds: 'term:path-kinds', openPath: 'term:open-path' diff --git a/docs/regression-rules.md b/docs/regression-rules.md index 87f9e82..74a6b64 100644 --- a/docs/regression-rules.md +++ b/docs/regression-rules.md @@ -179,6 +179,13 @@ one-liner in `CLAUDE.md` only if it is among the most important. `c` (or empty) selection, base64 of strict UTF-8, at most 1 MB (`OSC52_MAX`), through main (`clipboard:term-write`, its own channel and cap, opaque to the diagnostics log), with the Copied badge; a read (`?`) is swallowed and never answered, a clear is ignored, every OSC 52 is handled so none is drawn. +- **The bell flashes the taskbar only while the window is unfocused** (#177; decided under the + owner's delegation, spec decision 1). At most one bell a second per tab reaches main (`bellGate`), which flashes + the taskbar button (`src/main/bellFlash.ts`) only while the window is NOT focused, stops it on focus, and never + makes a sound: a bell while focused is most often the user's own bad key (PSReadLine rings on one). A bridge + member (`TermApi.termBell?`, `TermIpcDeps.attention?`), not a `TermHostConfig` field; a host that passes no + `attention` (Prism, until its one line) stays silent. Under `--e2e` it is counted on `__e2eFlashes`, never + flashed (e2e `bell`). - **Closing the window QUITS; closing the last tab does not** (owner, 2026-09-18, after using the first build, which hid the window and stayed resident: "the app should actually close when you close it"). The last tab lands on the start screen (`EmptyState`, Tabby's shape by owner diff --git a/docs/two-apps.md b/docs/two-apps.md index bc09c5b..ec87bab 100644 --- a/docs/two-apps.md +++ b/docs/two-apps.md @@ -506,7 +506,9 @@ so an update never silently changes what an existing user sees; the bridge to ma the workflow warns and only releases the core. - **A terminal change goes in `core/`**, and is a change to Prism too: say so in the PR, and ask the owner when it would conflict with how Prism works. App-shell changes (tabs, start screen, window) - stay in `src/`. + stay in `src/`. The bell (#177) is the one terminal behaviour that needs a line of the host's own: + `attention` in its `registerTermIpc` call (this app: `bellFlash`). Prism's bell stays silent until + its core-release PR adds that line ([bell-flashes](regression-rules.md#bell-flashes)). - **`core/` is lint-walled** (`eslint.config.js`): relative imports only (a consumer resolves `@shared` against ITS OWN tree, MEASURED, silently), never `window.prism` (use `termApi()`), never a host's `src/`, never `electron`, never `chromeTheme`. The app reaches the core through `@core`. diff --git a/src/main/bellFlash.test.ts b/src/main/bellFlash.test.ts new file mode 100644 index 0000000..206e171 --- /dev/null +++ b/src/main/bellFlash.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { bellFlash, wantsFlash, type FlashWindow } from './bellFlash' + +// THE BELL FLASHES THE TASKBAR ONLY WHILE THE WINDOW IS NOT FOCUSED (#177, +// decided under the owner's delegation): a bell while focused is most often +// the user's own bad key (PSReadLine rings on one), and no sound, ever. + +function fakeWindow(focused: boolean): FlashWindow & { flashes: boolean[]; focus(): void; focusHandlers: number } { + const handlers: Array<() => void> = [] + const w = { + flashes: [] as boolean[], + focused, + get focusHandlers(): number { + return handlers.length + }, + isFocused: () => w.focused, + isDestroyed: () => false, + flashFrame: (on: boolean) => { + w.flashes.push(on) + }, + on: (_event: 'focus', fn: () => void) => { + handlers.push(fn) + }, + focus: () => { + w.focused = true + handlers.forEach((h) => h()) + } + } + return w +} + +describe('wantsFlash', () => { + it('flashes only when the window is not focused', () => { + expect(wantsFlash(false)).toBe(true) + expect(wantsFlash(true)).toBe(false) + }) +}) + +describe('bellFlash', () => { + it('flashes an unfocused window, and stops when it gains the focus', () => { + const w = fakeWindow(false) + bellFlash(w) + expect(w.flashes).toEqual([true]) + w.focus() + expect(w.flashes).toEqual([true, false]) + }) + + it('does nothing to a focused window', () => { + const w = fakeWindow(true) + bellFlash(w) + expect(w.flashes).toEqual([]) + }) + + it('registers its focus handler once, however many bells ring', () => { + const w = fakeWindow(false) + bellFlash(w) + bellFlash(w) + bellFlash(w) + expect(w.focusHandlers).toBe(1) + }) + + it('does nothing without a window', () => { + expect(() => bellFlash(null)).not.toThrow() + }) +}) diff --git a/src/main/bellFlash.ts b/src/main/bellFlash.ts new file mode 100644 index 0000000..770bce1 --- /dev/null +++ b/src/main/bellFlash.ts @@ -0,0 +1,39 @@ +/** + * THE BELL (#177; decided under the owner's delegation, spec decision 1): a + * bell flashes the taskbar button while the window is NOT focused, does + * nothing while it is, and never makes a sound. A bell while unfocused cannot + * be the user's own bad keystroke (PSReadLine rings on one), and a flash is + * what Windows Terminal does; a sound in a tool the owner hears through screen + * narration would be noise. The page already lets at most one bell a second + * per tab through (core `bellGate`). + */ + +/** The slice of a BrowserWindow this needs. */ +export interface FlashWindow { + isFocused(): boolean + isDestroyed(): boolean + flashFrame(flag: boolean): void + on(event: 'focus', listener: () => void): unknown +} + +/** A bell flashes only an unfocused window. */ +export function wantsFlash(focused: boolean): boolean { + return !focused +} + +// The windows whose focus already stops the flash: one handler per window, +// however many bells ring. +const stopsOnFocus = new WeakSet() + +/** Flash `win`'s taskbar button if it is not focused; the flash stops when it + * gains the focus (Windows keeps flashing until told otherwise). */ +export function bellFlash(win: FlashWindow | null): void { + if (!win || win.isDestroyed() || !wantsFlash(win.isFocused())) return + if (!stopsOnFocus.has(win)) { + stopsOnFocus.add(win) + win.on('focus', () => { + if (!win.isDestroyed()) win.flashFrame(false) + }) + } + win.flashFrame(true) +} diff --git a/src/main/index.ts b/src/main/index.ts index 8f5d7cf..5887b14 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -22,6 +22,7 @@ import { killAll, shellsDying, shellsGone } from '@core/main/terminal' import { installUpdate, updateCalls, watchForUpdates } from './update' import { previewUpdate, runPreviewInstall, wantsPreview } from '@core/main/updatePreview' import { createVerbSwitch } from './verbSwitch' +import { bellFlash } from './bellFlash' import { readWindowState, watchWindowState } from './windowState' // Prism Terminal's main process: the window, its lifecycle and the @@ -91,6 +92,22 @@ const pathOpeners = { } } +/** + * THE BELL FLASHES THE TASKBAR (#177; `bellFlash.ts` says when). Under --e2e + * it is COUNTED on `globalThis.__e2eFlashes` and nothing flashes: the parked + * window is never focused, so every test bell would otherwise blink the + * owner's taskbar (e2e has no side effects, #64). + */ +const e2eFlashes = { count: 0 } +if (E2E) Object.assign(globalThis, { __e2eFlashes: e2eFlashes }) +function bellAttention(): void { + if (E2E) { + if (mainWindow && !mainWindow.isFocused()) e2eFlashes.count++ + return + } + bellFlash(mainWindow) +} + /** * The Claude Code plugin this app hands its shells (#131), or undefined when * it is not where it should be: then no shell is given a folder that is not @@ -486,7 +503,9 @@ function wireIpc(): void { // AGENT STATES FROM CLAUDE CODE'S HOOKS (#131): the plugin ships beside // the app (electron-builder's extraResources), and a dev build reads the // core's own copy. A shell gets it while the page's setting is on. - claudePluginDir: claudePluginDir() + claudePluginDir: claudePluginDir(), + // A bell flashes the taskbar while the window is unfocused (#177). + attention: bellAttention }) // DICTATION (#13) is the core's too. What is this app's own: where ITS // installer put the CPU engine, the folder it shares with Prism, and the GPU diff --git a/tools/e2e/run.mjs b/tools/e2e/run.mjs index 79dea71..0dca6ae 100644 --- a/tools/e2e/run.mjs +++ b/tools/e2e/run.mjs @@ -2468,6 +2468,30 @@ const scenarios = { } }, + /** + * THE BELL FLASHES THE TASKBAR WHILE THE WINDOW IS UNFOCUSED (#177; owner's + * delegation). The parked window is never focused, so a bell counts one + * flash; under --e2e main COUNTS it on `__e2eFlashes` and flashes nothing. + * Ten bells in a burst count one: at most one a second per tab. + */ + async bell(ok) { + const w = world() + const { app, page } = await launch(w, { args: [w.alpha] }) + const flashes = async () => (await app.evaluate(() => globalThis.__e2eFlashes?.count)) ?? -1 + try { + await page.waitForFunction(() => /PS [^>]*>\s*$/.test((document.querySelector('.xterm .xterm-rows')?.textContent ?? '').trimEnd()), null, { timeout: 45000 }) + ok((await flashes()) === 0, `no flash before a bell (${await flashes()})`) + await typeLine(page, '[Console]::Write([char]7)') + ok(!!(await until(async () => (await flashes()) === 1, 5000)), `one bell, one flash (${await flashes()})`) + await sleep(1300) // past the one-second gate + await typeLine(page, '1..10 | % { [Console]::Write([char]7); Start-Sleep -Milliseconds 20 }') + await sleep(1500) + ok((await flashes()) === 2, `ten bells in a burst flash once (${(await flashes()) - 1})`) + } finally { + await closeApp(app) + } + }, + async links(ok) { const w = world() const { app, page } = await launch(w, { args: [w.alpha] }) From 308b27096ca3b37fb81f8ff72d8ebaf9853572ed Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:49:49 +0200 Subject: [PATCH 11/18] fix(core): refit once the renderer resumes after a hidden resize (#174) xterm.js #6117 (open in 6.0.0): the viewport syncs its scroll range in a render callback, and the renderer is paused while a tab's element is detached, so a tab resized while hidden could come back with a stale slider until the next scroll. The attach now runs a second pass after a frame and a task, once the renderer has resumed: refit() (a no-op when the size already matches; it calls suppressActivity itself) and term.refresh over every row, which runs the render callbacks the sync waits on. Cancelled in the effect's cleanup, and skipped if the element has moved. Public xterm API only. NOT REPRODUCED: the build group runs no e2e, so the plan's red run of the new hiddenResize scenario on the code before this is left to the Gate. The refresh is included up front (the spec's step 2 fallback), since a second fit alone cannot change anything when the size already matches. e2e hiddenResize added: tab B on the alternate screen with mouse tracking, the window shrunk by 300 px while B is hidden, back on B the slider is hidden or fills its track and the program's frame fills the rows. Fixes #174 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t --- core/renderer/components/TerminalPanel.tsx | 19 ++++++ tools/e2e/run.mjs | 72 ++++++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/core/renderer/components/TerminalPanel.tsx b/core/renderer/components/TerminalPanel.tsx index d9db653..0dbdbc9 100644 --- a/core/renderer/components/TerminalPanel.tsx +++ b/core/renderer/components/TerminalPanel.tsx @@ -1210,6 +1210,23 @@ export default function TerminalPanel({ termApi().termResize(sessionId, s.term.cols, s.term.rows) } refit() + // A SECOND PASS ONCE THE RENDERER HAS RESUMED (#174, xterm.js #6117, open + // in 6.0.0). While the element was detached xterm's renderer was paused + // (its IntersectionObserver), and the viewport syncs its scroll range in a + // render callback; a tab resized while hidden could come back with a stale + // slider until the next scroll. After a frame plus a task the renderer runs + // again: fit once more (a no-op when the size already matches) and redraw + // every row, which runs the render callbacks the viewport's sync waits on. + // Public API only. NOT REPRODUCED before this landed (no e2e in the build + // group); the `hiddenResize` e2e is the guard either way. + let resumeTimer: ReturnType | undefined + const resumeFrame = requestAnimationFrame(() => { + resumeTimer = setTimeout(() => { + if (s.el.parentElement !== host) return + refit() + s.term.refresh(0, s.term.rows - 1) + }, 0) + }) const ro = new ResizeObserver(refit) ro.observe(host) // Ctrl+scroll zooms this session's text - unpersisted, the Settings base @@ -1228,6 +1245,8 @@ export default function TerminalPanel({ } host.addEventListener('wheel', wheel, { passive: false, capture: true }) return () => { + cancelAnimationFrame(resumeFrame) + if (resumeTimer) clearTimeout(resumeTimer) ro.disconnect() host.removeEventListener('wheel', wheel, { capture: true }) // Detach, don't dispose: the shell runs on unseen. diff --git a/tools/e2e/run.mjs b/tools/e2e/run.mjs index 0dca6ae..c54c02d 100644 --- a/tools/e2e/run.mjs +++ b/tools/e2e/run.mjs @@ -2492,6 +2492,78 @@ const scenarios = { } }, + /** + * A TAB RESIZED WHILE HIDDEN COMES BACK WITH A RIGHT SCROLL RANGE (#174, + * xterm.js #6117). Tab B runs a stand-in for Claude's fullscreen view: the + * alternate screen with mouse tracking on, every row drawn, redrawn on a + * resize. With B hidden the window shrinks; back on B, the alternate screen + * has no scrollback, so the vertical slider is invisible or as tall as its + * track, and the rows shown are the program's last frame. Written to fail on + * a stale range; if it never failed on the code before the fix, the scenario + * stays as the guard and #174 notes "not reproduced". + */ + async hiddenResize(ok) { + const w = world() + const probe = join(w.beta, 'alt-probe.cjs') + writeFileSync( + probe, + [ + 'process.stdin.setRawMode(true)', + 'process.stdin.resume()', + "const draw = () => { const r = process.stdout.rows, c = process.stdout.columns; let s = '\\x1b[H'; for (let i = 1; i <= r; i++) s += ('ALTROW ' + i + ' of ' + r).padEnd(c - 1).slice(0, c - 1) + (i < r ? '\\r\\n' : ''); process.stdout.write(s) }", + "process.stdout.on('resize', draw)", + "process.stdin.on('data', (b) => { if (b.toString() === 'q') { process.stdout.write('\\x1b[?1006l\\x1b[?1000l\\x1b[?1049l'); process.exit(0) } })", + "process.stdout.write('\\x1b[?1049h\\x1b[?1000h\\x1b[?1006h\\x1b[2J')", + 'draw()' + ].join('\n') + ) + const { app, page } = await launch(w, { args: [w.alpha, w.beta] }) + const range = () => + page.evaluate(() => { + const track = document.querySelector('.xterm .xterm-scrollable-element > .scrollbar.vertical') + const slider = track?.querySelector('.slider') + const t = track?.getBoundingClientRect() + const s = slider?.getBoundingClientRect() + const visible = !!slider && getComputedStyle(slider).display !== 'none' && (s?.height ?? 0) > 0 && getComputedStyle(track).visibility !== 'hidden' + const rows = [...document.querySelectorAll('.xterm .xterm-rows > div')].map((r) => (r.textContent ?? '').trim()) + return { visible, track: Math.round(t?.height ?? 0), slider: Math.round(s?.height ?? 0), rows } + }) + try { + ok(await until(async () => (await tabLabels(page)).length === 2), 'two tabs open') + // Tab B (beta) is in front at launch. + await typeLine(page, `& '${process.execPath}' '${probe}'`) + ok(!!(await until(async () => (await range()).rows.some((t) => /^ALTROW 1 of \d+/.test(t)), 15000)), 'tab B is on the alternate screen, every row drawn') + await page.locator('[data-tab]').nth(0).click() + await sleep(500) + const size = await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].getSize()) + await app.evaluate(({ BrowserWindow }, s) => BrowserWindow.getAllWindows()[0].setSize(s[0] - 300, s[1] - 300), size) + await sleep(1000) + await page.locator('[data-tab]').nth(1).click() + await page.mouse.move(400, 300) + // The program redraws for the new size; its last row says how many it has. + ok( + !!(await until(async () => { + const r = await range() + const last = r.rows.filter(Boolean).at(-1) ?? '' + const m = last.match(/^ALTROW (\d+) of (\d+)/) + return m && m[1] === m[2] && Number(m[2]) === r.rows.length + }, 10000)), + 'back on tab B, the program drew a full frame for the new size' + ) + await sleep(600) + const r = await range() + ok(!r.visible || r.slider >= r.track - 1, `no stale scroll range: the slider is hidden or fills its track (slider ${r.slider}px of ${r.track}px, visible ${r.visible})`) + await page.mouse.wheel(0, -200) + await sleep(300) + const top = (await range()).rows[0] ?? '' + ok(/^ALTROW 1 of/.test(top), `and nothing above the frame to scroll to (top row "${top.slice(0, 20)}")`) + await page.keyboard.type('q') + } finally { + await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(1100, 700)).catch(() => {}) + await closeApp(app) + } + }, + async links(ok) { const w = world() const { app, page } = await launch(w, { args: [w.alpha] }) From 7cd4f6ac0937c701c4804a313203145d644c78fe Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:04:20 +0200 Subject: [PATCH 12/18] fix(core): the agent's own image-paste key, and Shift+Enter armed by the title (#170, #175) #170: an image on Ctrl+V sends the AGENT's key. Claude Code 2.1.296 binds image paste to alt+v on Windows (read in its binary: `Me=Ie?"alt+v":"ctrl+v"`), so the ^V we forwarded did nothing on a fresh install; the owner's keybindings.json binds both, which hid it. `imagePasteKey`: ESC v to Claude, ^V to everything else, exactly one (two would paste twice where both are bound). Measured through the bundled ConPTY: ESC v reaches Claude as alt+v. The help entry says so. #175: Shift+Enter's newline, measured 2026-10-11 through the bundled ConPTY, typing `ab` (and `a\`) into an empty prompt, then each candidate, then `c`: bytes Claude 2.1.296 Codex 0.153.2 `\` + CR newline, no `\` newline, no `\` LF (Ctrl+J) newline DROPPED (`abc`) ESC CR newline newline Nothing submitted; a typed trailing `\` survived everywhere. LF is not a newline in both, so per the spec's rule `newlineKey` is per agent: Ctrl+J (Claude's own chat:newline) for Claude, `\` + CR (today's) otherwise. Claude's title glyphs and Codex's "Action Required" now arm Shift+Enter before the poll's first verdict (`titleArmsAgent`); a bare braille spinner does not (any CLI draws one, and a plain shell would stay armed, #21). Only the poll takes it back. Keys are still heard on onKey (rule 10). New e2e `imagePaste` (the gate runs it). Fixes #170 Fixes #175 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t --- core/preload/api.ts | 4 +- core/renderer/components/TerminalPanel.tsx | 51 ++++++++--- core/renderer/lib/agentTitle.test.ts | 35 +++++++- core/renderer/lib/agentTitle.ts | 38 ++++++++- core/renderer/lib/termBus.ts | 5 +- core/renderer/lib/termPaste.test.ts | 29 ++++++- core/renderer/lib/termPaste.ts | 36 +++++++- core/shared/help/agents.ts | 2 +- docs/regression-rules.md | 8 +- tools/e2e/run.mjs | 99 ++++++++++++++++++++++ 10 files changed, 279 insertions(+), 28 deletions(-) diff --git a/core/preload/api.ts b/core/preload/api.ts index 92adc27..1fcb9aa 100644 --- a/core/preload/api.ts +++ b/core/preload/api.ts @@ -51,8 +51,8 @@ export interface TermPreloadApi { onTermExit(cb: (id: string) => void): () => void /** * What the clipboard holds RIGHT NOW, for the terminal's paste rule. An - * image forwards the ^V key (a clipboard-aware TUI like Claude Code reads - * the image itself); text becomes a bracketed paste; copied files paste as + * image forwards the agent's image-paste key (a clipboard-aware TUI like + * Claude Code reads the image itself); text becomes a bracketed paste; copied files paste as * quoted paths. The decision itself is pure and lives in lib/termPaste. * Synchronous, as the key handler that calls it has to be; main does the * reading, since a sandboxed preload has no clipboard module. diff --git a/core/renderer/components/TerminalPanel.tsx b/core/renderer/components/TerminalPanel.tsx index 0dbdbc9..a6e1488 100644 --- a/core/renderer/components/TerminalPanel.tsx +++ b/core/renderer/components/TerminalPanel.tsx @@ -4,7 +4,9 @@ import { FitAddon } from '@xterm/addon-fit' import { Unicode11Addon } from '@xterm/addon-unicode11' import { WebLinksAddon } from '@xterm/addon-web-links' import { SearchAddon } from '@xterm/addon-search' -import { decidePaste, sanitizePaste, type PathShell } from '../lib/termPaste' +import { decidePaste, imagePasteKey, newlineKey, sanitizePaste, type PathShell } from '../lib/termPaste' +import { titleArmsAgent } from '../lib/agentTitle' +import type { DetectedAgent } from '../../shared/types' import { shellOfShellId } from '../../shared/help/shells' import { onResumingChange, @@ -1012,11 +1014,17 @@ function createSession(id: string, root: string, shellId: string | undefined): S if (r.clearAt < 0) term.write(data) else term.write(data.slice(0, r.clearAt) + CLEAR + data.slice(r.clearAt)) } + + // Whether an agent runs in this shell right now, and which, for Shift+Enter + // and an image on Ctrl+V below: a resumed session is its agent from its + // first moment, and main's process poll says when one arrives or leaves. + let agentHere = Boolean(resume) + let agentKind: DetectedAgent | null = resume ? agentOfResume(resume) : null /** * The one paste. Bracketed for text - without that framing a multi-line * paste reaches the shell as a run of Enter presses, so the first line runs - * and the rest are typed after it - and the ^V KEYSTROKE for an image, - * which is what lets the TUI read the clipboard itself. + * and the rest are typed after it - and, for an image, the agent's own + * image-paste KEYSTROKE, which lets the TUI read the clipboard itself. * * Named and registered so a right-click Paste calls THIS rather than * growing a second, wrong copy of it. @@ -1025,24 +1033,38 @@ function createSession(id: string, root: string, shellId: string | undefined): S const decision = decidePaste(termApi().readClipboard(), pathShell) if (decision.kind === 'key') { markTouched(id) - termApi().termInput(id, '') + // The AGENT's image-paste key, Alt+V to Claude on Windows (#170). + termApi().termInput(id, imagePasteKey(agentKind)) } else if (decision.kind === 'text') { markTouched(id) // a paste is typing; bracketed, it starts with ESC term.paste(decision.data) } } - // Whether an agent runs in this shell right now, for Shift+Enter below: a - // resumed session is Claude from its first moment, and main's process poll - // says when one arrives or leaves. - let agentHere = Boolean(resume) + // THE TITLE ARMS IT BEFORE THE POLL (#175): the poll's first verdict comes + // 2.5 s at the earliest (backing off to 20 s), and until then Shift+Enter + // SUBMITTED a half-written message. Only Claude's own glyphs and Codex's + // "Action Required" arm it (`titleArmsAgent`). Only the poll's "left" takes + // it back (the #73 rule), and a stale title does not re-arm, since it does + // not change again. + term.onTitleChange((t) => { + const named = titleArmsAgent(t) + if (named) { + agentHere = true + agentKind = named + } + }) const unsub = [ attachClickCaret(term, el, id), // A path this session asked about turned out to exist: paint the lines // that asked (#167; it used to be every line of every tab). onPathsFound(id, () => links.revisit()), - termApi().onTermAgent((forId, present) => { - if (forId === id) agentHere = present + termApi().onTermAgent((forId, present, kind) => { + if (forId !== id) return + agentHere = present + // The poll knows a process, the title its agent: an 'other' process + // (a wrapper) does not undo the title's claude or codex. + agentKind = !present ? null : kind === 'other' && agentKind ? agentKind : kind }), termApi().onTermData((forId, data) => { if (forId === id) writeOutput(data) @@ -1103,13 +1125,14 @@ function createSession(id: string, root: string, shellId: string | undefined): S // to xterm, Ctrl+` became a NUL, which counted as the user typing. if (termHost().ownsKey(e)) return false if (e.key === 'Enter' && e.shiftKey && agentHere) { - // Newline-without-submit, the continuation form Claude Code accepts - // everywhere. This is what /terminal-setup exists to configure; here it + // Newline-without-submit, in the bytes the agent reads as one + // (`newlineKey`, measured #175: Ctrl+J for Claude, `\` + Enter + // otherwise). This is what /terminal-setup exists to configure; here it // simply works. ONLY where an agent runs (code review 2026-09-24, #21): // at a plain prompt `\` then Enter RAN the line with a backslash on its // end, a command nobody wrote. There Shift+Enter is Enter, as xterm sends it. markTouched(id) // input like any other: its repaint is echo, not work - termApi().termInput(id, '\\\r') + termApi().termInput(id, newlineKey(agentKind)) return false } // ONE CTRL+V IS ONE PASTE (owner, 2026-09-22: "when I copy text and paste @@ -1119,7 +1142,7 @@ function createSession(id: string, root: string, shellId: string | undefined): S // event too. So every paste arrived twice, once from here and once from // there. preventDefault cancels the native one; this handler is the paste, // since it is the one that knows about images (Claude Code reads those - // itself when it gets the ^V keystroke) and bracketed framing. + // itself when it gets its image-paste keystroke) and bracketed framing. if (isKey(e, 'v') && e.ctrlKey && !e.shiftKey && !e.altKey) { e.preventDefault() pasteHere() diff --git a/core/renderer/lib/agentTitle.test.ts b/core/renderer/lib/agentTitle.test.ts index 88ca223..a4922a9 100644 --- a/core/renderer/lib/agentTitle.test.ts +++ b/core/renderer/lib/agentTitle.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it } from 'vitest' -import { forgetAgentTitle, readAgentTitle } from './agentTitle' +import { forgetAgentTitle, readAgentTitle, titleAgent, titleArmsAgent } from './agentTitle' const read = (t: string): string | null => { const r = readAgentTitle('s', t) @@ -73,3 +73,36 @@ describe('readAgentTitle, everything else', () => { expect(read('◐ Claude Code')).toBe('claude:starting') }) }) + +describe('titleAgent, the title naming an agent with no memory (#175)', () => { + it("is claude for Claude's spinner and its idle glyph", () => { + expect(titleAgent('◐ Claude Code')).toBe('claude') + expect(titleAgent('◓ Create and read note.txt')).toBe('claude') + expect(titleAgent('✳ Claude Code')).toBe('claude') + }) + it("is codex for a braille spinner and Codex's Action Required", () => { + expect(titleAgent('⠙ yeah')).toBe('codex') + expect(titleAgent('[ ! ] Action Required | proj')).toBe('codex') + }) + it('is null for a shell, a path, a bare word and an empty title', () => { + expect(titleAgent('PS C:\\x')).toBeNull() + expect(titleAgent('C:\\Program Files\\PowerShell\\7\\pwsh.exe')).toBeNull() + expect(titleAgent('yeah')).toBeNull() // Codex's idle needs the session's memory + expect(titleAgent('')).toBeNull() + }) + it('remembers nothing: a later read is not changed by it', () => { + expect(titleAgent('◐ Claude Code')).toBe('claude') + expect(read('◐ Claude Code')).toBe('claude:starting') + }) +}) +describe('titleArmsAgent, what may arm Shift+Enter before the poll (#175)', () => { + it("arms on Claude's glyphs and Codex's Action Required", () => { + expect(titleArmsAgent('✳ Claude Code')).toBe('claude') + expect(titleArmsAgent('◐ Claude Code')).toBe('claude') + expect(titleArmsAgent('[ ! ] Action Required | proj')).toBe('codex') + }) + it('never on a bare braille spinner: any CLI draws one, and a plain shell would stay armed (#21)', () => { + expect(titleArmsAgent('⠙ npm install')).toBeNull() + expect(titleArmsAgent('PS C:\\x')).toBeNull() + }) +}) \ No newline at end of file diff --git a/core/renderer/lib/agentTitle.ts b/core/renderer/lib/agentTitle.ts index 208496d..b617301 100644 --- a/core/renderer/lib/agentTitle.ts +++ b/core/renderer/lib/agentTitle.ts @@ -53,6 +53,34 @@ interface Seen { const seen = new Map() +/** + * The agent a title NAMES on its own, with no session memory (#175): Claude's + * half-circle spinner or idle glyph, a braille spinner or Codex's "Action + * Required". A bare folder name (Codex at rest) needs the memory and is null. + * A braille spinner alone is weak proof of Codex: see `titleArmsAgent`. + */ +export function titleAgent(title: string): 'claude' | 'codex' | null { + const t = title.trim() + const first = [...t][0] + if (!first) return null + if (CODEX_ASKS.test(t)) return 'codex' + if (HALF.has(first) || first === IDLE_GLYPH) return 'claude' + if (isBraille(first)) return 'codex' + return null +} + +/** + * The agent a title is proof enough of to ARM Shift+Enter's newline before the + * poll (#175): `titleAgent` less a bare braille spinner. That spinner is any + * CLI's (ora, npm), and in a plain shell it would arm a `\` + Enter that the + * poll, never having seen an agent there, would never take back: the line run + * with a backslash on its end (code review 2026-09-24, #21). + */ +export function titleArmsAgent(title: string): 'claude' | 'codex' | null { + const named = titleAgent(title) + return named === 'codex' && !CODEX_ASKS.test(title.trim()) ? null : named +} + /** Read one title change for a session. Null when it says nothing about an agent. */ export function readAgentTitle(id: string, title: string): AgentTitle | null { const t = title.trim() @@ -60,19 +88,21 @@ export function readAgentTitle(id: string, title: string): AgentTitle | null { if (!first) return null const rest = t.slice(first.length).trim() const s = seen.get(id) - if (CODEX_ASKS.test(t)) { + const named = titleAgent(t) + if (named === 'codex' && CODEX_ASKS.test(t)) { // Asking is past startup; the name it returns to at rest is kept. if (s?.kind === 'codex') s.ready = true else seen.set(id, { kind: 'codex', name: s?.name ?? '', ready: true }) return { kind: 'codex', state: 'question' } } - if (HALF.has(first) || isBraille(first)) { - const kind = HALF.has(first) ? 'claude' : 'codex' + // What is left that names an agent is a spinner, or Claude's idle glyph. + if (named && first !== IDLE_GLYPH) { + const kind = named const next: Seen = { kind, name: rest, ready: s?.kind === kind ? s.ready : false } seen.set(id, next) return { kind, state: next.ready ? 'working' : 'starting' } } - if (first === IDLE_GLYPH) { + if (named === 'claude') { const next: Seen = { kind: 'claude', name: s?.kind === 'claude' ? s.name : rest, ready: true } seen.set(id, next) return { kind: 'claude', state: 'idle' } diff --git a/core/renderer/lib/termBus.ts b/core/renderer/lib/termBus.ts index 173014b..40fdeae 100644 --- a/core/renderer/lib/termBus.ts +++ b/core/renderer/lib/termBus.ts @@ -8,8 +8,9 @@ import type { AgentSignal } from './agentHookSignal' * wraps the text in the bracketed-paste escape the shell is waiting for - * without it a multi-line paste arrives as a run of Enter presses, so the * first line executes and the rest are typed in after it. An image on the - * clipboard is different again: the ^V KEYSTROKE is forwarded so the TUI - * reads the clipboard itself, which is how Claude Code takes a screenshot. + * clipboard is different again: the agent's image-paste KEYSTROKE is + * forwarded so the TUI reads the clipboard itself, which is how Claude Code + * takes a screenshot. * * All of that already exists, once, in TerminalPanel's key handler. This lets * the menu call THAT rather than growing a second, wrong copy of it. diff --git a/core/renderer/lib/termPaste.test.ts b/core/renderer/lib/termPaste.test.ts index 8264fa9..4a8cd07 100644 --- a/core/renderer/lib/termPaste.test.ts +++ b/core/renderer/lib/termPaste.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { decidePaste, quotePath, quotePaths, sanitizePaste } from './termPaste' +import { decidePaste, imagePasteKey, newlineKey, quotePath, quotePaths, sanitizePaste } from './termPaste' describe('decidePaste', () => { // Image wins: a clipboard-aware TUI (Claude Code) reads it itself off the @@ -86,3 +86,30 @@ describe('quotePaths', () => { expect(quotePaths(['C:\\a.png'])).toBe('"C:\\a.png"') }) }) + +describe('imagePasteKey (#170)', () => { + // Claude 2.1.296 on Windows binds image paste to alt+v, not ctrl+v (read in + // its binary: `Me=Ie?"alt+v":"ctrl+v"`), so Ctrl+V's ^V did nothing there. + it('is Alt+V (ESC v) for Claude', () => { + expect(imagePasteKey('claude')).toBe('\x1bv') + }) + it('is ^V for Codex, another agent and a plain shell', () => { + expect(imagePasteKey('codex')).toBe('\x16') + expect(imagePasteKey('other')).toBe('\x16') + expect(imagePasteKey(null)).toBe('\x16') + }) + it('is ONE key, never both: two would paste the image twice where both are bound', () => { + expect(imagePasteKey('claude')).not.toContain('\x16') + }) +}) + +describe('newlineKey (#175, measured 2026-10-11)', () => { + it('is Ctrl+J for Claude, its own chat:newline binding', () => { + expect(newlineKey('claude')).toBe('\n') + }) + it('is backslash + Enter for Codex (it drops a bare LF) and for anything else', () => { + expect(newlineKey('codex')).toBe('\\\r') + expect(newlineKey('other')).toBe('\\\r') + expect(newlineKey(null)).toBe('\\\r') + }) +}) \ No newline at end of file diff --git a/core/renderer/lib/termPaste.ts b/core/renderer/lib/termPaste.ts index 33f6e52..390f0f3 100644 --- a/core/renderer/lib/termPaste.ts +++ b/core/renderer/lib/termPaste.ts @@ -6,6 +6,8 @@ // terminal's whole job is to forward that keystroke instead of swallowing it. // This is exactly where generic terminals break image paste. +import type { DetectedAgent } from '../../shared/types' + export interface ClipboardState { image: boolean text: string @@ -13,7 +15,7 @@ export interface ClipboardState { } export type PasteDecision = - | { kind: 'key' } // forward the raw ^V byte; the TUI does the reading + | { kind: 'key' } // forward the agent's image-paste key (imagePasteKey); the TUI does the reading | { kind: 'text'; data: string } // bracketed paste | { kind: 'none' } @@ -58,6 +60,38 @@ export function quotePaths(paths: string[], shell?: PathShell): string { return paths.map((p) => quotePath(p, shell)).join(' ') } +/** + * THE AGENT'S OWN IMAGE-PASTE KEY (#170). Claude Code 2.1.296 binds image + * paste to alt+v on Windows and ctrl+v only elsewhere (read in its binary: + * `Me=Ie?"alt+v":"ctrl+v"`, `Ie` being windows or wsl), so the ^V this + * terminal forwarded did nothing on a fresh install; the owner's own + * keybindings.json binds both, which hid it. MEASURED 2026-10-11 through the + * bundled ConPTY: ESC v reaches Claude as alt+v (it ran chat:imagePaste). + * Codex reads the image on ^V, and so does Claude under WSL, which the poll + * sees as wsl.exe and never calls claude. EXACTLY ONE key: with both bound, + * sending both pasted the image twice. + */ +export function imagePasteKey(agent: DetectedAgent | null): string { + return agent === 'claude' ? '\x1bv' : '\x16' +} + +/** + * SHIFT+ENTER'S NEWLINE (#175). MEASURED 2026-10-11 through the bundled ConPTY, + * typing `ab` (and `a\`) into an empty prompt, then each candidate, then `c`: + * + * bytes Claude 2.1.296 Codex 0.153.2 + * `\` + CR newline, no `\` newline, no `\` + * LF (Ctrl+J) newline DROPPED (`abc`) + * ESC CR newline newline + * + * Nothing submitted, and a typed trailing `\` survived in both. So Claude gets + * Ctrl+J, its own `chat:newline` binding rather than the backslash heuristic, + * and everything else keeps the `\` + CR every agent took before. + */ +export function newlineKey(agent: DetectedAgent | null): string { + return agent === 'claude' ? '\n' : '\\\r' +} + export function decidePaste(clip: ClipboardState, shell?: PathShell): PasteDecision { // A copied image file also offers pixels to document apps. In a terminal, // its file representation remains the useful one. diff --git a/core/shared/help/agents.ts b/core/shared/help/agents.ts index e47f923..eca309d 100644 --- a/core/shared/help/agents.ts +++ b/core/shared/help/agents.ts @@ -566,7 +566,7 @@ export const AGENTS_HELP: readonly HelpEntry[] = [ category: 'agents', task: 'Paste a screenshot or image into Claude', summary: - 'A key, pressed inside Claude Code. Copy an image (Win+Shift+S takes a screenshot to the clipboard), then paste: this terminal hands the keystroke to Claude, which reads the image off the clipboard itself and shows an [Image] marker. Dragging an image file onto the terminal types its path instead, which works as well.', + 'A key, pressed inside Claude Code. Copy an image (Win+Shift+S takes a screenshot to the clipboard), then press Ctrl+V: with a picture on the clipboard, this terminal hands Claude its own image-paste key (Alt+V on Windows), and Claude reads the image off the clipboard itself and shows an [Image] marker. Dragging an image file onto the terminal types its path instead, which works as well.', command: 'Ctrl+V', variants: [ { diff --git a/docs/regression-rules.md b/docs/regression-rules.md index 74a6b64..0fdac35 100644 --- a/docs/regression-rules.md +++ b/docs/regression-rules.md @@ -24,8 +24,12 @@ one-liner in `CLAUDE.md` only if it is among the most important. that turns a screen cell into a place in the line's TEXT goes through `lib/termCells.ts` (`cellText`'s index), never by counting cells or `text.length`: the resize carry and link hit-testing were each one off after a trailing space, a wide character or an emoji. Ctrl+C and - Ctrl+V match `e.code` too (a Russian layout's C is 'с'). Shift+Enter sends the `\` continuation - ONLY where an agent runs; at a plain prompt it is Enter. The panel's attach is keyed on the + Ctrl+V match `e.code` too (a Russian layout's C is 'с'). Shift+Enter sends a newline ONLY where an + agent runs, at a plain prompt it is Enter: Ctrl+J to Claude and the `\` continuation otherwise + (`newlineKey`, measured: Codex 0.153.2 drops a bare LF), armed by Claude's title glyphs or Codex's + Action Required before the poll, never a bare braille spinner, and disarmed only by the poll (#175). + An image on Ctrl+V sends the AGENT's key, Alt+V to Claude and ^V otherwise, exactly one, since a + keybindings.json binding both would paste twice (`imagePasteKey`, #170). The panel's attach is keyed on the session alone (a `cd` must not re-attach it and take the focus). Under a question or the update window the tab chords do nothing (Ctrl+W keeps its rule). A kill while a spawn is pending wins, and a warm shell's exit removes only itself. A stop during transcribing is not heard, so a clip is diff --git a/tools/e2e/run.mjs b/tools/e2e/run.mjs index c54c02d..9fcd37c 100644 --- a/tools/e2e/run.mjs +++ b/tools/e2e/run.mjs @@ -2468,6 +2468,105 @@ const scenarios = { } }, + /** + * AN IMAGE ON CTRL+V SENDS THE AGENT'S OWN KEY (#170). Claude Code on + * Windows pastes an image on Alt+V, not ^V (read in its binary), so with + * Claude in the tab the terminal sends ESC v, exactly one key; in a plain + * shell it stays ^V. A node probe that prints every byte it reads in hex + * stands in for both: saved as `claude` (no extension) the process poll's + * rule sees `...\claude` on its command line and calls it Claude; saved as + * `probe.cjs` it is no agent. Shift+Enter to the poll's Claude is Ctrl+J + * (#175, measured), and once the poll has seen it leave, a plain probe that + * titles itself "✳ Claude Code" arms it within 1 s, the title alone (#175). + * The clipboard is saved and put back, as `termClipboard` does. + */ + async imagePaste(ok) { + const w = world() + const src = [ + 'process.stdin.setRawMode(true)', + 'process.stdin.resume()', + "const tag = process.argv[2] || '?'", + "process.stdin.on('data', (b) => {", + " if (b.toString() === 'q') process.exit(0)", + " if (b.toString() === 't') { process.stdout.write('\\x1b]0;\\u2733 Claude Code\\x07TITLED\\r\\n'); return }", + " process.stdout.write('IN ' + tag + ' ' + [...b].map((x) => x.toString(16).padStart(2, '0')).join(' ') + '\\r\\n')", + '})', + "process.stdout.write('READY ' + tag + '\\r\\n')" + ].join('\n') + const asClaude = join(w.alpha, 'claude') + const plain = join(w.alpha, 'probe.cjs') + writeFileSync(asClaude, src) + writeFileSync(plain, src) + const { app, page } = await launch(w, { args: [w.alpha] }) + const held = await app.evaluate(({ clipboard }) => ({ + text: clipboard.readText(), + html: clipboard.readHTML(), + rtf: clipboard.readRTF(), + image: clipboard.readImage().isEmpty() ? null : clipboard.readImage().toDataURL(), + formats: clipboard.availableFormats() + })) + const putImage = () => + app.evaluate(({ clipboard, nativeImage }) => + clipboard.writeImage(nativeImage.createFromBitmap(Buffer.alloc(16, 255), { width: 2, height: 2 })) + ) + const text = async () => (await termText(page)).replace(/\u00a0/g, ' ') + try { + await until(async () => (await tabLabels(page)).length === 1) + await polled(page) // the poll's first "no agent" verdict, so the next one is a change + + // A plain program: ^V. + await typeLine(page, `& '${process.execPath}' '${plain}' P`) + ok(!!(await until(async () => (await text()).includes('READY P'), 15000)), 'the plain probe is running') + await putImage() + await page.locator('.xterm-helper-textarea').first().focus() + await page.keyboard.press('Control+v') + ok(!!(await until(async () => /IN P 16\b/.test(await text()), 5000)), 'an image on Ctrl+V sends ^V to a program that is no agent') + await page.keyboard.type('q') + + // Claude, as the poll sees it: ESC v, and only that. + await typeLine(page, `& '${process.execPath}' '${asClaude}' C`) + ok(!!(await until(async () => (await text()).includes('READY C'), 15000)), 'the stand-in for Claude is running') + ok(!!(await until(() => page.evaluate(() => !!document.querySelector('[data-agent-present]')), 30000, 100)), 'and the poll calls it an agent') + await putImage() + await page.locator('.xterm-helper-textarea').first().focus() + await page.keyboard.press('Control+v') + ok(!!(await until(async () => /IN C 1b 76\b/.test(await text()), 5000)), 'an image on Ctrl+V sends Alt+V (ESC v) to Claude') + await sleep(500) + ok(!/IN C 16\b/.test(await text()), 'and no ^V with it: one key, one paste') + await page.keyboard.press('Shift+Enter') + ok(!!(await until(async () => /IN C 0a\b/.test(await text()), 5000)), "Shift+Enter is Ctrl+J, Claude's own newline (#175)") + await page.keyboard.type('q') + ok(!!(await until(() => page.evaluate(() => !document.querySelector('[data-agent-present]')), 45000, 200)), 'the poll sees the agent leave') + + // #175: Claude's TITLE arms Shift+Enter before any poll could. The probe + // is no agent to the poll, so only the title can have armed it. + await typeLine(page, `& '${process.execPath}' '${plain}' T`) + ok(!!(await until(async () => (await text()).includes('READY T'), 15000)), 'a plain probe again') + await page.locator('.xterm-helper-textarea').first().focus() + await page.keyboard.press('Shift+Enter') + ok(!!(await until(async () => /IN T 0d\b/.test(await text()), 5000)), 'with no agent, Shift+Enter is Enter (CR)') + await page.keyboard.type('t') + await until(async () => (await text()).includes('TITLED'), 5000, 50) + await page.keyboard.press('Shift+Enter') + ok(!!(await until(async () => /IN T 0a\b/.test(await text()), 1000, 50)), 'once it titles itself "✳ Claude Code", Shift+Enter is Ctrl+J within 1 s') + await page.keyboard.type('q') + } finally { + await app + .evaluate(({ clipboard, nativeImage }, was) => { + const data = {} + if (was.text) data.text = was.text + if (was.html) data.html = was.html + if (was.rtf) data.rtf = was.rtf + if (was.image) data.image = nativeImage.createFromDataURL(was.image) + if (Object.keys(data).length) clipboard.write(data) + else clipboard.clear() + }, held) + .catch(() => {}) + if (held.formats.some((f) => /FileName|uri-list/i.test(f))) console.log(' (the clipboard held copied FILES, which cannot be put back; it is empty now)') + await closeApp(app) + } + }, + /** * THE BELL FLASHES THE TASKBAR WHILE THE WINDOW IS UNFOCUSED (#177; owner's * delegation). The parked window is never focused, so a bell counts one From efdb9b418793916b09190862a546892c210cf131 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:19:34 +0200 Subject: [PATCH 13/18] fix(core): review fixes for the Claude Code compat branch - An OSC 8 label that shows another host's address opens what it shows (osc8Target): xterm's OSC 8 provider outranks the visible-text match, so https://github.com/... printed over a hidden evil.example opened evil.example with no confirm. The menu's Copy/Open link follow it. - The app no longer sets FORCE_HYPERLINK (#173): supports-hyperlinks reads it before its isTTY test (read in claude.exe 2.1.296), so it put raw OSC 8 into every pipe and redirect, Claude's Bash tool commands among them. - Theme reports (?2031h) end with their program: the prompt report, the poll's agent-left and RIS turn them off, so a later theme switch no longer types ESC[?997;1n into a pwsh prompt after a killed Claude. - A click on an OSC 8 label no longer also walks the caret. - The prompt report takes back agent keys only a title armed (agentArm): a WSL tab's poll never says left, so Shift+Enter stayed Ctrl+J at pwsh. - Claude under WSL wants Alt+V too: comment and spec corrected. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t --- core/main/terminal.test.ts | 10 +- core/main/terminal.ts | 18 ++-- core/renderer/components/TerminalPanel.tsx | 100 ++++++++++++------ core/renderer/lib/agentArm.test.ts | 48 +++++++++ core/renderer/lib/agentArm.ts | 47 ++++++++ core/renderer/lib/termOsc8.test.ts | 35 +++++- core/renderer/lib/termOsc8.ts | 28 ++++- core/renderer/lib/termPaste.ts | 8 +- docs/regression-rules.md | 24 +++-- .../2026-10-10-claude-code-compat-design.md | 17 ++- tools/e2e/run.mjs | 5 +- 11 files changed, 276 insertions(+), 64 deletions(-) create mode 100644 core/renderer/lib/agentArm.test.ts create mode 100644 core/renderer/lib/agentArm.ts diff --git a/core/main/terminal.test.ts b/core/main/terminal.test.ts index 39e3704..8ddde89 100644 --- a/core/main/terminal.test.ts +++ b/core/main/terminal.test.ts @@ -90,10 +90,12 @@ describe('ptyEnv', () => { expect(env.ANTHROPIC_API_KEY).toBe('k') }) - // #173: Claude Code prints its links as OSC 8 hyperlinks only when told the - // terminal takes them; without it a link is "label (long url)". - it('tells programs the terminal takes hyperlinks', () => { - expect(ptyEnv({}).FORCE_HYPERLINK).toBe('1') + // #173, then the review of 2026-10-11: supports-hyperlinks reads + // FORCE_HYPERLINK BEFORE its isTTY test, so set for the whole shell it put + // raw OSC 8 into every pipe and redirect (`vercel ls > out.txt`, the + // commands Claude's own Bash tool runs). The app never sets it. + it('never forces hyperlinks on the programs in a tab', () => { + expect(Object.keys(ptyEnv({})).some((k) => k.toUpperCase() === 'FORCE_HYPERLINK')).toBe(false) }) it("keeps the user's own FORCE_HYPERLINK, 0 included", () => { diff --git a/core/main/terminal.ts b/core/main/terminal.ts index ffc4219..21b8a74 100644 --- a/core/main/terminal.ts +++ b/core/main/terminal.ts @@ -178,15 +178,15 @@ export function ptyEnv( } env.TERM = PTY_OPTS.name env.COLORTERM = 'truecolor' - // THE PANEL TAKES OSC 8 HYPERLINKS (#173), and opens them on a left click - // (#169). Claude Code prints them only when told: MEASURED (2.1.296, bundled - // ConPTY, 2026-10-11) one OSC 8 link with FORCE_HYPERLINK=1, none without, - // where it printed "label (long url)" instead. Who reads it, MEASURED the - // same day: Claude Code (its bundled supports-hyperlinks; its own - // `hyperlinks` setting still wins) and the vercel CLI; not Codex, and no - // package in this repo's node_modules. A user's own value, 0 included, is - // their choice and stays, under the spelling they gave it. - if (!Object.keys(env).some((k) => k.toUpperCase() === 'FORCE_HYPERLINK')) env.FORCE_HYPERLINK = '1' + // NO FORCE_HYPERLINK (#173 set it; review 2026-10-11 took it out). Claude + // Code prints OSC 8 links only when told (MEASURED 2.1.296: "label (long + // url)" without it), but supports-hyperlinks, which it and the vercel CLI + // bundle, reads FORCE_HYPERLINK BEFORE its isTTY test (read in claude.exe: + // `if(e)return!(...)` ahead of `if(r&&!r.isTTY)return!1`), so set for the + // shell it put raw OSC 8 into every pipe and redirect, the commands + // Claude's own Bash tool runs among them. The panel still opens any OSC 8 + // link a program prints (#169); Claude's `hyperlinks` setting turns them on. + // A user's own FORCE_HYPERLINK passes through like any other variable. // cmd reports its folder through PROMPT (#99), in front of whatever prompt // the user already had. if (shellId === 'cmd') env.PROMPT = cmdPrompt(prompt) diff --git a/core/renderer/components/TerminalPanel.tsx b/core/renderer/components/TerminalPanel.tsx index a6e1488..c5d1727 100644 --- a/core/renderer/components/TerminalPanel.tsx +++ b/core/renderer/components/TerminalPanel.tsx @@ -1,12 +1,11 @@ import { useEffect, useRef, useState, useSyncExternalStore, type JSX } from 'react' -import { Terminal } from '@xterm/xterm' +import { Terminal, type IBufferRange } from '@xterm/xterm' import { FitAddon } from '@xterm/addon-fit' import { Unicode11Addon } from '@xterm/addon-unicode11' import { WebLinksAddon } from '@xterm/addon-web-links' import { SearchAddon } from '@xterm/addon-search' import { decidePaste, imagePasteKey, newlineKey, sanitizePaste, type PathShell } from '../lib/termPaste' -import { titleArmsAgent } from '../lib/agentTitle' -import type { DetectedAgent } from '../../shared/types' +import { armAtStart, armOnPoll, armOnPrompt, armOnTitle } from '../lib/agentArm' import { shellOfShellId } from '../../shared/help/shells' import { onResumingChange, @@ -73,7 +72,7 @@ import { type CaretPos } from '../lib/termCaretHold' import { cellFrom, cellText, type CellInfo } from '../lib/termCells' -import { bufferRowCells, isWebLink, Osc8Spans, parseOsc8, spanText, type SpanAnchor } from '../lib/termOsc8' +import { bufferRowCells, isWebLink, osc8Target, Osc8Spans, parseOsc8, spanText, type SpanAnchor } from '../lib/termOsc8' import { onTermLookChange, termBaseFontPx, @@ -209,7 +208,12 @@ function fitKeepingCursorLine(term: Terminal, fit: FitAddon): void { * exactly as it was. The rules are `termClickCaret`'s, pure and tested; this * only reads the event and the buffer into them. Returns the disposer. */ -function attachClickCaret(term: Terminal, el: HTMLElement, id: string): () => void { +function attachClickCaret( + term: Terminal, + el: HTMLElement, + id: string, + oscLinkAt: (line: number, x: number) => boolean +): () => void { // DECTCEM, followed from the stream: xterm keeps whether the cursor is // hidden to itself. Returning false lets xterm apply it as always. let cursorHidden = false @@ -261,7 +265,12 @@ function attachClickCaret(term: Terminal, el: HTMLElement, id: string): () => vo cursorHidden, textBelow, offLine: clicked < first || clicked > last, - onLink: findLinks(text).some((l) => charAt >= l.start && charAt < l.end) + // An OSC 8 label is a link too, though its text is not one (review + // 2026-10-11: a click on a prompt's linked git segment opened it AND + // walked the caret). + onLink: + findLinks(text).some((l) => charAt >= l.start && charAt < l.end) || + oscLinkAt(clicked, Math.floor((e.clientX - r.left) / cellW)) } if (!caretClickAllowed(gate)) return const keys = arrowKeys( @@ -316,6 +325,19 @@ function logicalLine(term: Terminal, y: number): { return { first, last, widths: cells.map((c) => c.width), text, index, textEnd } } +/** The web address SHOWN over an OSC 8 link's cells (xterm's range: 1-based, + * one row, the end cell included), read across the whole logical line so a + * shown address that wraps still counts. */ +function shownLinkOver(term: Terminal, range: IBufferRange): string | null { + const line = logicalLine(term, range.start.y - 1) + const charOf = (p: { x: number; y: number }): number => + line.index[Math.min(Math.max(0, (p.y - 1 - line.first) * term.cols + p.x - 1), line.index.length - 1)] + const from = charOf(range.start) + const to = charOf(range.end) + const hit = findLinks(line.text).find((l) => l.start <= to && l.end > from) + return hit ? line.text.slice(hit.start, hit.end) : null +} + /** * BACKSPACE (or Delete) OVER A SELECTION deletes it (owner, 2026-09-23), when * the selection sits on the line being edited at a quiet prompt; the rules and @@ -426,9 +448,11 @@ export function termContextAt( const line = logicalLine(term, y) const target = (y - line.first) * term.cols + col const at = line.index[Math.min(target, line.index.length - 1)] - // An OSC 8 label ("PR #165") is a link though its text is not (#169); its - // uri is what a left click opens, so it is what the menu copies and opens. - const link = s.oscLink(y, col) ?? linkAt(line.text, at) + // An OSC 8 label ("PR #165") is a link though its text is not (#169); where + // a left click goes (`osc8Target`) is what the menu copies and opens. + const shown = linkAt(line.text, at) + const osc = s.oscLink(y, col) + const link = osc ? osc8Target(osc, shown) : shown return { selection, link, path: link ? null : pathAt(line.text, at, s.cwd()) } } @@ -657,9 +681,13 @@ function createSession(id: string, root: string, shellId: string | undefined): S // the menu, as for every other link (owner, 2026-09-28). No confirm, ever. // Only http(s): xterm drops any other scheme with allowNonHttpProtocols // off, and this checks again, as do the preload and main. + // A label that shows another host's address opens what it shows + // (`osc8Target`, review 2026-10-11). linkHandler: { - activate: (e, uri) => { - if (e.button === 0 && isWebLink(uri)) termApi().openExternal(uri) + activate: (e, uri, range) => { + if (e.button !== 0 || !isWebLink(uri)) return + const to = osc8Target(uri, shownLinkOver(term, range)) + if (isWebLink(to)) termApi().openExternal(to) }, allowNonHttpProtocols: false } @@ -913,6 +941,11 @@ function createSession(id: string, root: string, shellId: string | undefined): S } term.parser.registerCsiHandler({ prefix: '?', final: 'h' }, trackReports(true)) term.parser.registerCsiHandler({ prefix: '?', final: 'l' }, trackReports(false)) + // RIS (ESC c) resets every mode, this one too. False: xterm still resets. + term.parser.registerEscHandler({ final: 'c' }, () => { + reports = THEME_REPORTS_OFF + return false + }) // CSI ? 996 n: "which are you now?" xterm keeps every other DSR. term.parser.registerCsiHandler({ prefix: '?', final: 'n' }, (params) => { const p = modeParams(params) @@ -1017,9 +1050,8 @@ function createSession(id: string, root: string, shellId: string | undefined): S // Whether an agent runs in this shell right now, and which, for Shift+Enter // and an image on Ctrl+V below: a resumed session is its agent from its - // first moment, and main's process poll says when one arrives or leaves. - let agentHere = Boolean(resume) - let agentKind: DetectedAgent | null = resume ? agentOfResume(resume) : null + // first moment; the title, the poll and the prompt move it (`agentArm`). + let arm = armAtStart(resume ? agentOfResume(resume) : null) /** * The one paste. Bracketed for text - without that framing a multi-line * paste reaches the shell as a run of Enter presses, so the first line runs @@ -1034,37 +1066,41 @@ function createSession(id: string, root: string, shellId: string | undefined): S if (decision.kind === 'key') { markTouched(id) // The AGENT's image-paste key, Alt+V to Claude on Windows (#170). - termApi().termInput(id, imagePasteKey(agentKind)) + termApi().termInput(id, imagePasteKey(arm.kind)) } else if (decision.kind === 'text') { markTouched(id) // a paste is typing; bracketed, it starts with ESC term.paste(decision.data) } } - // THE TITLE ARMS IT BEFORE THE POLL (#175): the poll's first verdict comes - // 2.5 s at the earliest (backing off to 20 s), and until then Shift+Enter - // SUBMITTED a half-written message. Only Claude's own glyphs and Codex's - // "Action Required" arm it (`titleArmsAgent`). Only the poll's "left" takes - // it back (the #73 rule), and a stale title does not re-arm, since it does - // not change again. + // The title arms the agent's keys before the poll (#175; `agentArm` has the + // rules). term.onTitleChange((t) => { - const named = titleArmsAgent(t) - if (named) { - agentHere = true - agentKind = named + arm = armOnTitle(arm, t) + }) + // THE SHELL'S PROMPT IS BACK (OSC 9;9): nothing runs in front of it. It takes + // back what only a title armed (a WSL tab's poll never says "left"), and a + // ?2031h whose program died without its ?2031l (review 2026-10-11): a theme + // push after it reached the shell, and PSReadLine read the ESC as + // RevertLine and typed the rest. Registered after the cwd handler, so it is + // asked first, and returns false: that handler still takes the sequence. + term.parser.registerOscHandler(9, (data) => { + if (parseOsc9(data)) { + arm = armOnPrompt(arm) + reports = THEME_REPORTS_OFF } + return false }) const unsub = [ - attachClickCaret(term, el, id), + attachClickCaret(term, el, id, (line, x) => oscLink(line, x) !== null), // A path this session asked about turned out to exist: paint the lines // that asked (#167; it used to be every line of every tab). onPathsFound(id, () => links.revisit()), termApi().onTermAgent((forId, present, kind) => { if (forId !== id) return - agentHere = present - // The poll knows a process, the title its agent: an 'other' process - // (a wrapper) does not undo the title's claude or codex. - agentKind = !present ? null : kind === 'other' && agentKind ? agentKind : kind + arm = armOnPoll(arm, present, kind) + // The agent that asked for theme reports has gone with it. + if (!present) reports = THEME_REPORTS_OFF }), termApi().onTermData((forId, data) => { if (forId === id) writeOutput(data) @@ -1124,7 +1160,7 @@ function createSession(id: string, root: string, shellId: string | undefined): S // Returning false keeps xterm from ALSO feeding the bytes to the pty: left // to xterm, Ctrl+` became a NUL, which counted as the user typing. if (termHost().ownsKey(e)) return false - if (e.key === 'Enter' && e.shiftKey && agentHere) { + if (e.key === 'Enter' && e.shiftKey && arm.here) { // Newline-without-submit, in the bytes the agent reads as one // (`newlineKey`, measured #175: Ctrl+J for Claude, `\` + Enter // otherwise). This is what /terminal-setup exists to configure; here it @@ -1132,7 +1168,7 @@ function createSession(id: string, root: string, shellId: string | undefined): S // at a plain prompt `\` then Enter RAN the line with a backslash on its // end, a command nobody wrote. There Shift+Enter is Enter, as xterm sends it. markTouched(id) // input like any other: its repaint is echo, not work - termApi().termInput(id, newlineKey(agentKind)) + termApi().termInput(id, newlineKey(arm.kind)) return false } // ONE CTRL+V IS ONE PASTE (owner, 2026-09-22: "when I copy text and paste diff --git a/core/renderer/lib/agentArm.test.ts b/core/renderer/lib/agentArm.test.ts new file mode 100644 index 0000000..1238f79 --- /dev/null +++ b/core/renderer/lib/agentArm.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { armAtStart, armOnPoll, armOnPrompt, armOnTitle } from './agentArm' + +const IDLE = '✳ Claude Code' + +describe('agentArm', () => { + it('starts armed for a resumed agent, bare otherwise', () => { + expect(armAtStart('claude')).toMatchObject({ here: true, kind: 'claude' }) + expect(armAtStart(null)).toMatchObject({ here: false, kind: null }) + }) + + it("is armed by the agent's own title before the poll (#175)", () => { + expect(armOnTitle(armAtStart(null), IDLE)).toMatchObject({ here: true, kind: 'claude' }) + }) + + it('is not armed by a bare braille spinner', () => { + expect(armOnTitle(armAtStart(null), '⠙ npm')).toMatchObject({ here: false, kind: null }) + }) + + it("is disarmed by the poll's left (#73)", () => { + const s = armOnPoll(armOnTitle(armAtStart(null), IDLE), true, 'claude') + expect(armOnPoll(s, false, null)).toMatchObject({ here: false, kind: null }) + }) + + it("keeps the title's agent when the poll only sees a wrapper", () => { + expect(armOnPoll(armOnTitle(armAtStart(null), IDLE), true, 'other')).toMatchObject({ here: true, kind: 'claude' }) + }) + + // Review 2026-10-11: in a WSL tab the poll only ever sees wsl.exe, so it + // never says "left", and a title-armed tab kept Shift+Enter as Ctrl+J and + // Ctrl+V as ESC v at the plain pwsh prompt after `exit`. The shell's own + // prompt report says nothing runs in front of it. + it('is disarmed by the prompt when only the title armed it', () => { + expect(armOnPrompt(armOnTitle(armAtStart(null), IDLE))).toMatchObject({ here: false, kind: null }) + }) + + it('is disarmed by the prompt after a resumed agent ends', () => { + expect(armOnPrompt(armAtStart('codex'))).toMatchObject({ here: false, kind: null }) + }) + + // An agent the poll saw is the poll's to take back: a codex restarted inside + // one poll interval is never reported again, and Codex's title arms only + // while it asks. + it('leaves an agent the poll saw to the poll', () => { + const s = armOnPoll(armAtStart(null), true, 'codex') + expect(armOnPrompt(s)).toMatchObject({ here: true, kind: 'codex' }) + }) +}) diff --git a/core/renderer/lib/agentArm.ts b/core/renderer/lib/agentArm.ts new file mode 100644 index 0000000..10f75c4 --- /dev/null +++ b/core/renderer/lib/agentArm.ts @@ -0,0 +1,47 @@ +import type { DetectedAgent } from '../../shared/types' +import { titleArmsAgent } from './agentTitle' + +/** + * WHETHER AN AGENT RUNS IN A SHELL, AND WHICH, for the keys that change with + * it: Shift+Enter's newline and an image's paste key (`termPaste`). Three + * voices, pure so the rules are tested: + * + * - The TITLE arms it before the poll (#175): the poll's first verdict comes + * 2.5 s at the earliest, and until then Shift+Enter SUBMITTED a half-written + * message. Only what `titleArmsAgent` takes as proof arms it, and a stale + * title does not re-arm, since it does not change again. + * - The POLL says when an agent arrives or leaves (#73); a wrapper ('other') + * does not undo the title's claude or codex. + * - The shell's PROMPT report (OSC 9;9) takes back what only a title armed + * (review 2026-10-11): in a WSL tab the poll sees wsl.exe and never says + * "left", so a tab that ran claude in WSL kept the agent's keys at the plain + * prompt for the rest of its life. An agent the poll saw stays the poll's: + * the poll reports a change only, so a codex restarted inside one interval + * would never be armed again, Codex's title arming only while it asks. + */ +export interface AgentArm { + here: boolean + kind: DetectedAgent | null + /** The poll's last word: an agent it saw is its to take back. */ + polled: boolean +} + +export const armAtStart = (resumed: DetectedAgent | null): AgentArm => ({ + here: resumed !== null, + kind: resumed, + polled: false +}) + +export function armOnTitle(s: AgentArm, title: string): AgentArm { + const named = titleArmsAgent(title) + return named ? { ...s, here: true, kind: named } : s +} + +export function armOnPoll(s: AgentArm, present: boolean, kind: DetectedAgent | null): AgentArm { + if (!present) return { here: false, kind: null, polled: false } + return { here: true, kind: kind === 'other' && s.kind ? s.kind : kind, polled: true } +} + +export function armOnPrompt(s: AgentArm): AgentArm { + return s.polled ? s : { here: false, kind: null, polled: false } +} diff --git a/core/renderer/lib/termOsc8.test.ts b/core/renderer/lib/termOsc8.test.ts index 0cce70b..ee3c0a0 100644 --- a/core/renderer/lib/termOsc8.test.ts +++ b/core/renderer/lib/termOsc8.test.ts @@ -1,6 +1,39 @@ import { describe, expect, it } from 'vitest' import type { CellInfo } from './termCells' -import { isWebLink, Osc8Spans, parseOsc8, spanRows, spanText, spanTextRange, type Osc8Span } from './termOsc8' +import { + isWebLink, + osc8Target, + Osc8Spans, + parseOsc8, + spanRows, + spanText, + spanTextRange, + type Osc8Span +} from './termOsc8' + +// Review 2026-10-11: xterm's OSC 8 provider outranks the web-links match on the +// visible text, so a label that READS as one address could open another. +describe('osc8Target', () => { + it('opens the uri behind a label that is not an address', () => { + expect(osc8Target('https://github.com/o/r/pull/165', null)).toBe('https://github.com/o/r/pull/165') + }) + + it('opens the uri when the label shows the same host', () => { + expect(osc8Target('https://github.com/o/r/pull/165', 'https://github.com/o/r')).toBe( + 'https://github.com/o/r/pull/165' + ) + expect(osc8Target('https://GitHub.com/x', 'https://github.com/y')).toBe('https://GitHub.com/x') + }) + + it('opens what the label SHOWS when it names another host', () => { + expect(osc8Target('https://evil.example/', 'https://github.com/org/repo')).toBe('https://github.com/org/repo') + expect(osc8Target('https://github.com:8443/', 'https://github.com/')).toBe('https://github.com/') + }) + + it('opens the label when the uri does not parse', () => { + expect(osc8Target('https://', 'https://github.com/')).toBe('https://github.com/') + }) +}) const anchor = (line: number): { line: number; isDisposed: boolean; dispose(): void } => { const a = { line, isDisposed: false, dispose: () => (a.isDisposed = true) } diff --git a/core/renderer/lib/termOsc8.ts b/core/renderer/lib/termOsc8.ts index 8652688..fc4a322 100644 --- a/core/renderer/lib/termOsc8.ts +++ b/core/renderer/lib/termOsc8.ts @@ -5,7 +5,8 @@ import { cellText, type CellInfo } from './termCells' * OSC 8 HYPERLINKS (#169): a program prints a label and says, in-band, which * uri it stands for (`ESC ] 8 ; params ; uri ST`, label, `ESC ] 8 ; ; ST`). * Claude Code prints its links this way once told the terminal takes them - * (FORCE_HYPERLINK, #173), "PR #165" in its status line among them. + * (its `hyperlinks` setting, or a FORCE_HYPERLINK of the user's own: the app + * no longer sets it, see `ptyEnv`), "PR #165" in its status line among them. * * xterm records which cells belong to a link, but keeps it internal (the * cell's `urlId`), with no public way to ask. So the panel FOLLOWS THE STREAM: @@ -29,6 +30,31 @@ export function parseOsc8(data: string): { uri: string } | null { /** The only links the app opens (main and the preload check again). */ export const isWebLink = (uri: string): boolean => /^https?:\/\//i.test(uri) +const hostOf = (url: string): string | null => { + try { + return new URL(url).host.toLowerCase() + } catch { + return null + } +} + +/** + * WHERE AN OSC 8 LINK GOES, on a click, Open link or Copy link (review + * 2026-10-11). xterm registers its OSC 8 provider before any other, and the + * lowest one wins, so the hidden uri outranked the web-links match on the + * visible text, both painted the link colour and with no confirm: output + * printing `https://github.com/org/repo` over `https://evil.example/` opened + * evil.example. A label that SHOWS an address (`shown`, the visible link under + * it) on another host is taken at its word; any other label ("PR #165") opens + * its uri. Hosts, not whole urls: a label is often a shortened form of its own + * uri. + */ +export function osc8Target(uri: string, shown: string | null): string { + if (!shown) return uri + const want = hostOf(uri) + return want !== null && want === hostOf(shown) ? uri : shown +} + /** Where a span's first row is now: an xterm marker on the normal screen (it * follows the buffer as it trims), a plain line on the alternate one. */ export interface SpanAnchor { diff --git a/core/renderer/lib/termPaste.ts b/core/renderer/lib/termPaste.ts index 390f0f3..c322f0b 100644 --- a/core/renderer/lib/termPaste.ts +++ b/core/renderer/lib/termPaste.ts @@ -67,9 +67,11 @@ export function quotePaths(paths: string[], shell?: PathShell): string { * terminal forwarded did nothing on a fresh install; the owner's own * keybindings.json binds both, which hid it. MEASURED 2026-10-11 through the * bundled ConPTY: ESC v reaches Claude as alt+v (it ran chat:imagePaste). - * Codex reads the image on ^V, and so does Claude under WSL, which the poll - * sees as wsl.exe and never calls claude. EXACTLY ONE key: with both bound, - * sending both pasted the image twice. + * Codex reads the image on ^V. Claude under WSL wants alt+v too (`O()` is + * "wsl" there, review 2026-10-11): the poll only sees wsl.exe, so a WSL tab + * gets ESC v once Claude's own title has armed `claude` (`agentArm`), and ^V + * before it. EXACTLY ONE key: with both bound, sending both pasted the image + * twice. */ export function imagePasteKey(agent: DetectedAgent | null): string { return agent === 'claude' ? '\x1bv' : '\x16' diff --git a/docs/regression-rules.md b/docs/regression-rules.md index 0fdac35..400f1e3 100644 --- a/docs/regression-rules.md +++ b/docs/regression-rules.md @@ -27,8 +27,10 @@ one-liner in `CLAUDE.md` only if it is among the most important. Ctrl+V match `e.code` too (a Russian layout's C is 'с'). Shift+Enter sends a newline ONLY where an agent runs, at a plain prompt it is Enter: Ctrl+J to Claude and the `\` continuation otherwise (`newlineKey`, measured: Codex 0.153.2 drops a bare LF), armed by Claude's title glyphs or Codex's - Action Required before the poll, never a bare braille spinner, and disarmed only by the poll (#175). - An image on Ctrl+V sends the AGENT's key, Alt+V to Claude and ^V otherwise, exactly one, since a + Action Required before the poll, never a bare braille spinner, and disarmed by the poll (#175), or + by the shell's prompt report when only a title armed it (`lib/agentArm.ts`: a WSL tab's poll sees + wsl.exe and never says left). An image on Ctrl+V sends the AGENT's key, Alt+V to Claude (on WSL + too) and ^V otherwise, exactly one, since a keybindings.json binding both would paste twice (`imagePasteKey`, #170). The panel's attach is keyed on the session alone (a `cd` must not re-attach it and take the focus). Under a question or the update window the tab chords do nothing (Ctrl+W keeps its rule). A kill while a spawn is pending wins, @@ -168,7 +170,9 @@ one-liner in `CLAUDE.md` only if it is among the most important. - **THE APP ANSWERS A PROGRAM ONLY WHAT IT ASKED** (#168, #171, #172, #176; spec `docs/superpowers/specs/2026-10-10-claude-code-compat-design.md` section 15). Every byte the core writes into a pty that no key produced is a reply to a query in that pty's own stream (OSC 10/11 `?`, `CSI ? 996 n`, `CSI > q`, DECRQM 2031) - or the `?997` report the program turned on with `?2031h`, and only on a change. Replies hold no CR, LF or printable + or the `?997` report the program turned on with `?2031h`, only on a change and only while that program is there: + the shell's prompt report, the poll's "agent left" and RIS turn the reports off (review 2026-10-11: a push after a + killed Claude reached PSReadLine as ESC, RevertLine, then typed `[?997;1n`). Replies hold no CR, LF or printable command, go through `term.input(reply, false)` so `onData` and `looksTyped` treat them as the replies they are ([typing on onKey](#typing-on-onkey)), and never read anything private: OSC 52 is write-only and a read gets no answer. The pure half is `core/renderer/lib/termReplies.ts` (tested); the light/dark it reports is the chrome's own @@ -336,11 +340,15 @@ one-liner in `CLAUDE.md` only if it is among the most important. default asked `confirm()` and opened a blank window main denied, on any button). Their cells are painted from the stream (`lib/termOsc8.ts`: the cursor at the open and at the close, MEASURED to bracket Claude Code's label through the bundled ConPTY) while their text stands, on both screens, - and the menu's Copy link / Open link take their uri. A reflow or a buffer switch forgets them. - `FORCE_HYPERLINK=1` is set for every shell unless the user set it, any value kept (#173; `ptyEnv`; - MEASURED 2026-10-11: Claude Code prints OSC 8 only with it, its own `hyperlinks` setting still - winning; the vercel CLI reads it too; Codex and this repo's `node_modules` do not). It came after - the handler, so no link is ever a dead confirm box. The `osc8Links` e2e holds both. + and the menu's Copy link / Open link take where a click goes. A LABEL THAT SHOWS ANOTHER HOST'S + ADDRESS OPENS WHAT IT SHOWS (`osc8Target`, review 2026-10-11): xterm's OSC 8 provider outranks the + visible-text match, so `https://github.com/...` printed over a hidden `https://evil.example/` + opened evil.example. An OSC 8 label is a link to the click-caret gate too. A reflow or a buffer + switch forgets them. **THE APP NEVER SETS `FORCE_HYPERLINK`** (#173 did; review 2026-10-11 took + it out): supports-hyperlinks reads it BEFORE its isTTY test (read in claude.exe 2.1.296), so it + put raw OSC 8 into every pipe and redirect, Claude's own Bash tool commands among them. Claude + prints `label (long url)` without it; its `hyperlinks` setting, or a user's own variable, turns + OSC 8 on. The `osc8Links` e2e holds both. - **A PATH ON SCREEN IS A LINK WHEN IT EXISTS** (#99; owner, 2026-09-29: "clickable links that would open the file or folder", then "go ahead"). `lib/termPaths` finds what COULD be a path (relative or absolute, sentence punctuation and `:12` taken off); main's `termPathOpen` answers which exist from diff --git a/docs/superpowers/specs/2026-10-10-claude-code-compat-design.md b/docs/superpowers/specs/2026-10-10-claude-code-compat-design.md index d5b16d4..fc69f47 100644 --- a/docs/superpowers/specs/2026-10-10-claude-code-compat-design.md +++ b/docs/superpowers/specs/2026-10-10-claude-code-compat-design.md @@ -247,6 +247,13 @@ only finds URLs and paths in text, and Claude's labels ("#165") are neither. ## 6. #173 (low): FORCE_HYPERLINK +> **Withdrawn by the review of 2026-10-11.** `supports-hyperlinks` (in claude.exe 2.1.296 and the +> vercel CLI) reads `FORCE_HYPERLINK` BEFORE its `isTTY` test, so set for the whole shell it put raw +> OSC 8 into every pipe and redirect, the commands Claude's own Bash tool runs among them. `ptyEnv` +> no longer sets it; a user's own value passes through. The OSC 8 handler (section 5) stays, and a +> label showing another host's address opens what it shows (`osc8Target`). The plan below is the +> history. + ### What exists `core/main/terminal.ts:161-180` `ptyEnv` sets `TERM` and `COLORTERM` only. Claude emits OSC 8 only @@ -429,15 +436,17 @@ not reproduced in PT yet). ### What exists `termPaste.ts:67-79` `decidePaste` returns `{ kind: 'key' }` for an image; the panel sends `\x16` -(`TerminalPanel.tsx:817-826`). Claude 2.1.296 binds `ctrl+v` to image paste only off Windows (and on -WSL); on Windows it is `alt+v` (read in the binary). The owner's `keybindings.json` binds both, which +(`TerminalPanel.tsx:817-826`). Claude 2.1.296 binds `ctrl+v` to image paste only off Windows; on +Windows AND on WSL it is `alt+v` (read in the binary: `Ie=M==="windows"||M==="wsl"`, +`Me=Ie?"alt+v":"ctrl+v"`; corrected by the review of 2026-10-11, the research report had WSL wrong). The owner's `keybindings.json` binds both, which hid it. Codex reads the clipboard image on Ctrl+V. ### The change - Pure, `termPaste.ts`: `imagePasteKey(agent: DetectedAgent | null): string`: `'\x1bv'` (Alt+V) - for `claude`; `'\x16'` (^V) for everything else (codex, other agents, a plain shell, and WSL, - where the poll sees `wsl.exe` and Claude inside wants Ctrl+V). EXACTLY ONE key is sent, never + for `claude`; `'\x16'` (^V) for everything else (codex, other agents, a plain shell). Claude in + WSL wants Alt+V too: the poll sees only `wsl.exe`, so a WSL tab sends it once Claude's title has + armed `claude`, and the shell's prompt report takes that back (`lib/agentArm.ts`). EXACTLY ONE key is sent, never both: with the owner's bindings both keys paste, and two would paste the image twice. - The panel tracks `agentKind: DetectedAgent | null` beside `agentHere`: from `onTermAgent`'s `kind` (`:836-838`), from the resume (`agentOfResume`), and from the title (section 12's diff --git a/tools/e2e/run.mjs b/tools/e2e/run.mjs index 9fcd37c..2a962ea 100644 --- a/tools/e2e/run.mjs +++ b/tools/e2e/run.mjs @@ -2841,9 +2841,10 @@ const scenarios = { await page.mouse.click(at.x, at.y, { button }) } try { - // #173: every shell is told the terminal takes hyperlinks. + // #173 set FORCE_HYPERLINK; the review of 2026-10-11 took it out (it put + // OSC 8 into pipes and redirects): no shell is told it. await typeLine(page, 'Write-Host "FH-$env:FORCE_HYPERLINK-END"') - ok(!!(await until(async () => (await termText(page)).includes('FH-1-END'), 8000)), 'FORCE_HYPERLINK is 1 in the shell') + ok(!!(await until(async () => (await termText(page)).includes('FH--END'), 8000)), 'FORCE_HYPERLINK is not set in the shell') const esc = (s) => `[char]27 + '${s}'` const osc8 = (uri, text) => `[Console]::Write(${esc(`]8;;${uri}`)} + [char]7 + '${text}' + ${esc(']8;;')} + [char]7); Write-Host ''` await typeLine(page, `cls; ${osc8(URL8, 'OSC8LABEL')}`) From 40a32cb651737adf87f2b78a01564548d132eab1 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:35:12 +0200 Subject: [PATCH 14/18] fix(core): Shift+Enter sends only the newline, and a first poll verdict keeps theme reports - Shift+Enter under an agent sent Ctrl+J and THEN a CR: returning false only stops xterm's keydown, and xterm sent Enter on the keypress that followed, so Claude submitted. preventDefault cancels the keypress (e2e imagePaste measured "IN C 0a" then "IN C 0d"). - The review's agent-left reset of ?2031h fired on the poll's first "no agent" verdict too, so a program that asked for reports before it landed heard no theme switch (e2e termReplies). Only an agent that was there and leaves ends them now (themeReportsOnPoll, tested). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t --- core/renderer/components/TerminalPanel.tsx | 11 +++++++++-- core/renderer/lib/termReplies.test.ts | 18 ++++++++++++++++++ core/renderer/lib/termReplies.ts | 12 ++++++++++++ tools/e2e/run.mjs | 5 +++++ 4 files changed, 44 insertions(+), 2 deletions(-) diff --git a/core/renderer/components/TerminalPanel.tsx b/core/renderer/components/TerminalPanel.tsx index c5d1727..d03aaff 100644 --- a/core/renderer/components/TerminalPanel.tsx +++ b/core/renderer/components/TerminalPanel.tsx @@ -46,6 +46,7 @@ import { noteThemeMode, themePush, themeReports, + themeReportsOnPoll, xtversionAsked, xtversionReply, type GroundMode @@ -1098,9 +1099,10 @@ function createSession(id: string, root: string, shellId: string | undefined): S onPathsFound(id, () => links.revisit()), termApi().onTermAgent((forId, present, kind) => { if (forId !== id) return + // The agent that asked for theme reports has gone with it; a first + // "no agent" verdict is no departure (`themeReportsOnPoll`). + reports = themeReportsOnPoll(reports, arm.here, present) arm = armOnPoll(arm, present, kind) - // The agent that asked for theme reports has gone with it. - if (!present) reports = THEME_REPORTS_OFF }), termApi().onTermData((forId, data) => { if (forId === id) writeOutput(data) @@ -1169,6 +1171,11 @@ function createSession(id: string, root: string, shellId: string | undefined): S // end, a command nobody wrote. There Shift+Enter is Enter, as xterm sends it. markTouched(id) // input like any other: its repaint is echo, not work termApi().termInput(id, newlineKey(arm.kind)) + // Returning false only stops xterm's keydown; the browser still fires a + // keypress for Enter and xterm sent a CR on it, so Claude got Ctrl+J + // THEN Enter and submitted (MEASURED, e2e imagePaste 2026-10-11: + // "IN C 0a" then "IN C 0d"). preventDefault cancels that keypress. + e.preventDefault() return false } // ONE CTRL+V IS ONE PASTE (owner, 2026-09-22: "when I copy text and paste diff --git a/core/renderer/lib/termReplies.test.ts b/core/renderer/lib/termReplies.test.ts index ae08f3c..39617ac 100644 --- a/core/renderer/lib/termReplies.test.ts +++ b/core/renderer/lib/termReplies.test.ts @@ -12,6 +12,7 @@ import { oscColour, themePush, themeReports, + themeReportsOnPoll, xtversionAsked, xtversionReply } from './termReplies' @@ -107,6 +108,23 @@ describe('themePush', () => { }) }) +describe('themeReportsOnPoll', () => { + const on = themeReports(THEME_REPORTS_OFF, true, 'dark') + it('ends the reports when the agent the poll or title had armed leaves', () => { + expect(themeReportsOnPoll(on, true, false)).toEqual(THEME_REPORTS_OFF) + }) + // MEASURED (e2e termReplies, 2026-10-11): the poll's FIRST verdict for a tab + // is a change from nothing, "no agent", and it landed after a program that + // is no agent had sent ?2031h, so a theme switch told it nothing. + it('keeps them on a "no agent" verdict when no agent was there', () => { + expect(themeReportsOnPoll(on, false, false)).toBe(on) + }) + it('keeps them when an agent arrives or stays', () => { + expect(themeReportsOnPoll(on, false, true)).toBe(on) + expect(themeReportsOnPoll(on, true, true)).toBe(on) + }) +}) + describe('xtversionReply (#171)', () => { it('names this terminal, then the xterm.js under it, as DCS >| ... ST', () => { expect(xtversionReply('0.32.0', '6.0.0')).toBe('\x1bP>|PrismTerminal 0.32.0 (xterm.js 6.0.0)\x1b\\') diff --git a/core/renderer/lib/termReplies.ts b/core/renderer/lib/termReplies.ts index 371c691..ccb5fac 100644 --- a/core/renderer/lib/termReplies.ts +++ b/core/renderer/lib/termReplies.ts @@ -95,6 +95,18 @@ export function themeReports(s: ThemeReports, on: boolean, now: GroundMode): The return { on, last: on ? (s.last ?? now) : s.last } } +/** + * The poll's word on the agent (`term:agent`): the reports end with the agent + * that was there (`wasHere`, armed by the poll or its title) leaving, in a + * shell with no prompt report to end them (cmd, WSL). A "no agent" with no + * agent before is not a departure: the poll's FIRST verdict for a tab is that, + * and it can land after a program that is no agent sent ?2031h (MEASURED, e2e + * termReplies 2026-10-11: a theme switch then told the program nothing). + */ +export function themeReportsOnPoll(s: ThemeReports, wasHere: boolean, present: boolean): ThemeReports { + return wasHere && !present ? THEME_REPORTS_OFF : s +} + /** A query answered (OSC 11, `?996n`): the program now knows `mode`. */ export function noteThemeMode(s: ThemeReports, mode: GroundMode): ThemeReports { return { ...s, last: mode } diff --git a/tools/e2e/run.mjs b/tools/e2e/run.mjs index 2a962ea..c94fc8b 100644 --- a/tools/e2e/run.mjs +++ b/tools/e2e/run.mjs @@ -2535,6 +2535,9 @@ const scenarios = { ok(!/IN C 16\b/.test(await text()), 'and no ^V with it: one key, one paste') await page.keyboard.press('Shift+Enter') ok(!!(await until(async () => /IN C 0a\b/.test(await text()), 5000)), "Shift+Enter is Ctrl+J, Claude's own newline (#175)") + await sleep(500) + // xterm sent a CR on Enter's keypress too, so Claude submitted (2026-10-11). + ok(!/IN C 0d/.test(await text()), 'and no Enter with it: the newline does not submit') await page.keyboard.type('q') ok(!!(await until(() => page.evaluate(() => !document.querySelector('[data-agent-present]')), 45000, 200)), 'the poll sees the agent leave') @@ -2549,6 +2552,8 @@ const scenarios = { await until(async () => (await text()).includes('TITLED'), 5000, 50) await page.keyboard.press('Shift+Enter') ok(!!(await until(async () => /IN T 0a\b/.test(await text()), 1000, 50)), 'once it titles itself "✳ Claude Code", Shift+Enter is Ctrl+J within 1 s') + await sleep(500) + ok((await text()).split('IN T 0d').length - 1 === 1, 'and no Enter with it') await page.keyboard.type('q') } finally { await app From 1e2d9d42a91f97d4194ef0c1db807e3d3f26dd96 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:57:39 +0200 Subject: [PATCH 15/18] perf(core): the link-ink row watch re-inks only rows that lost ink (#163) The MutationObserver re-inked every replaced alternate-screen row holding no ink, so each row onRender had just scanned was scanned again, and it stayed attached on the normal screen. It now re-inks only a row that held ink and holds none (rowsToReink, unit tested), and disconnects when leaving the alternate screen; the first onRender there attaches it again. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t --- core/renderer/lib/linkInkRows.test.ts | 27 ++++++++++++++ core/renderer/lib/linkInkRows.ts | 16 ++++++++ core/renderer/lib/termLinkPaint.ts | 53 ++++++++++++++++++++------- 3 files changed, 83 insertions(+), 13 deletions(-) create mode 100644 core/renderer/lib/linkInkRows.test.ts create mode 100644 core/renderer/lib/linkInkRows.ts diff --git a/core/renderer/lib/linkInkRows.test.ts b/core/renderer/lib/linkInkRows.test.ts new file mode 100644 index 0000000..5430208 --- /dev/null +++ b/core/renderer/lib/linkInkRows.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { rowsToReink } from './linkInkRows' + +describe('rowsToReink', () => { + const run = (rows: string[], held: string[], holds: string[]): string[] => + rowsToReink( + rows, + (r) => held.includes(r), + (r) => holds.includes(r) + ) + + it('re-inks a row that held ink and lost it (a hover redraw)', () => { + expect(run(['a'], ['a'], [])).toEqual(['a']) + }) + + it('leaves a row that never held ink: onRender just looked at it', () => { + expect(run(['a', 'b'], [], [])).toEqual([]) + }) + + it('leaves a row that still holds ink (its own inking, or onRender re-inked it)', () => { + expect(run(['a'], ['a'], ['a'])).toEqual([]) + }) + + it('picks only the rows that lost ink out of a mixed batch', () => { + expect(run(['a', 'b', 'c', 'd'], ['a', 'c', 'd'], ['c'])).toEqual(['a', 'd']) + }) +}) diff --git a/core/renderer/lib/linkInkRows.ts b/core/renderer/lib/linkInkRows.ts new file mode 100644 index 0000000..5c58627 --- /dev/null +++ b/core/renderer/lib/linkInkRows.ts @@ -0,0 +1,16 @@ +/** + * Which rows of the alternate screen the mutation watch inks again (#163). + * Every row xterm draws through onRender is replaced and then inked there, and + * most rows hold no link, so "replaced and holds no ink" is nearly every row + * of every frame, each scanned twice. Only a row that HELD ink and holds none + * now lost it behind onRender's back (a hover redraw), so only it is inked. + */ +export function rowsToReink( + mutated: Iterable, + heldInk: (row: T) => boolean, + holdsInk: (row: T) => boolean +): T[] { + const out: T[] = [] + for (const row of mutated) if (heldInk(row) && !holdsInk(row)) out.push(row) + return out +} diff --git a/core/renderer/lib/termLinkPaint.ts b/core/renderer/lib/termLinkPaint.ts index d433ea9..44b45d2 100644 --- a/core/renderer/lib/termLinkPaint.ts +++ b/core/renderer/lib/termLinkPaint.ts @@ -1,4 +1,5 @@ import type { IBufferLine, IDecoration, IDisposable, IMarker, Terminal } from '@xterm/xterm' +import { rowsToReink } from './linkInkRows' import { findLinks } from './termLinks' /** @@ -127,12 +128,13 @@ export function attachLinkPaint( } } - /** Ink the links in one drawn row of the alternate screen. */ - const inkRow = (row: Element, ink: string): void => { + /** Ink the links in one drawn row of the alternate screen. True when the + * row now holds ink. */ + const inkRow = (row: Element, ink: string): boolean => { const text = row.textContent ?? '' - if (!/[\\/.]/.test(text)) return + if (!/[\\/.]/.test(text)) return false const links = find(text) - if (!links.length) return + if (!links.length) return false // Where each text node starts in the row's text. const nodes: Array<{ node: Text; at: number }> = [] const walk = document.createTreeWalker(row, NodeFilter.SHOW_TEXT) @@ -161,10 +163,19 @@ export function attachLinkPaint( span.style.textDecorationColor = `${ink}8c` range.surroundContents(span) } + return pieces.length > 0 } /** Watches the rows for redraws that fire no `onRender` (below). */ let watch: MutationObserver | undefined + /** The row elements that held ink when last drawn or inked. xterm keeps a + * row's element and replaces its contents, so the element is the key. */ + let inked = new WeakSet() + + const inkAndRemember = (row: Element, ink: string): void => { + if (inkRow(row, ink)) inked.add(row) + else inked.delete(row) + } const inkDrawn = (start: number, end: number): void => { if (dead || term.buffer.active.type !== 'alternate') return @@ -175,7 +186,7 @@ export function attachLinkPaint( const ink = color() for (let r = start; r <= end; r += 1) { const row = rows[r] - if (row) inkRow(row, ink) + if (row) inkAndRemember(row, ink) } } @@ -183,9 +194,10 @@ export function attachLinkPaint( // 2026-10-10: a hovered link in Claude Code's fullscreen view turned white // and stayed white until a scroll). xterm's DOM renderer underlines a hovered // link, and takes the underline off again, by REPLACING the row's contents - // (`_setCellUnderline`), and that fires no onRender. So any row whose - // contents are replaced and that holds no ink is inked again here. Inking - // itself is a mutation too, but the row then holds ink and is left alone. + // (`_setCellUnderline`), and that fires no onRender. So a row that held ink + // and lost it is inked again here; a row onRender just drew is not scanned + // twice (`rowsToReink`). Watched on the alternate screen only: the normal + // screen is painted with decorations, and the watch would fire every frame. const watchRows = (box: Element): void => { if (watch) return watch = new MutationObserver((records) => { @@ -196,13 +208,24 @@ export function attachLinkPaint( while (row && row.parentNode !== box) row = row.parentNode if (row instanceof Element) seen.add(row) } - if (!seen.size) return + const lost = rowsToReink( + seen, + (row) => inked.has(row), + (row) => row.querySelector('[data-link-ink]') !== null + ) + if (!lost.length) return const ink = color() - for (const row of seen) if (!row.querySelector('[data-link-ink]')) inkRow(row, ink) + for (const row of lost) inkAndRemember(row, ink) }) watch.observe(box, { childList: true, subtree: true }) } + const unwatchRows = (): void => { + watch?.disconnect() + watch = undefined + inked = new WeakSet() + } + const scan = (): void => { timer = undefined if (dead) return @@ -243,8 +266,12 @@ export function attachLinkPaint( term.onWriteParsed(soon), // A resize reflows every wrapped line: nothing painted is where it was. term.onResize(startOver), - // Into the alternate screen and back out of it. - term.buffer.onBufferChange(startOver), + // Into the alternate screen and back out of it. The row watch goes with + // the alternate screen; its first onRender there attaches it again. + term.buffer.onBufferChange((b) => { + if (b.type !== 'alternate') unwatchRows() + startOver() + }), term.onRender(({ start, end }) => inkDrawn(start, end)) ] @@ -252,7 +279,7 @@ export function attachLinkPaint( repaint: startOver, dispose: () => { dead = true - watch?.disconnect() + unwatchRows() if (timer !== undefined) window.clearTimeout(timer) subs.forEach((s) => s.dispose()) forgetFrom(0) From 34501aac3fcff19552bd7b94bf7d3d0ce4cbe6c7 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:15:55 +0200 Subject: [PATCH 16/18] fix(core): the prompt takes the agent's keys back, a title-armed agent goes with its title Review findings on #178: - A first "no agent" from the poll is no departure: theme reports end only when an agent the poll saw leaves, and a resume's agent is asked about again rather than disarmed (agentArm's gone and recheck). - An agent only its title vouches for (WSL: no prompt report, the poll sees wsl.exe) is disarmed, reports off, when the title stops naming it. - The shell's prompt takes every agent key back at once, and asks main's poll for its next answer even if unchanged (term:agent-again, optional termAgentAgain on TermApi), so a Codex restarted between looks is armed again. - The prompt's arm and report reset live in the one OSC 9 handler. - On the normal screen a URL match inside an OSC 8 span is painted once (piecesOverlap). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t --- core/main/agentPoll.test.ts | 36 ++++++ core/main/agentPoll.ts | 44 ++++++-- core/main/ipc.ts | 7 +- core/preload/api.ts | 3 + core/renderer/components/TerminalPanel.tsx | 57 +++++----- core/renderer/host.ts | 4 + core/renderer/lib/agentArm.test.ts | 125 +++++++++++++++++---- core/renderer/lib/agentArm.ts | 99 +++++++++++----- core/renderer/lib/termLinkPaint.ts | 23 +++- core/renderer/lib/termOsc8.test.ts | 21 ++++ core/renderer/lib/termOsc8.ts | 17 ++- core/renderer/lib/termReplies.test.ts | 18 --- core/renderer/lib/termReplies.ts | 12 -- core/shared/channels.ts | 2 + docs/regression-rules.md | 14 ++- 15 files changed, 357 insertions(+), 125 deletions(-) diff --git a/core/main/agentPoll.test.ts b/core/main/agentPoll.test.ts index 79589f7..b5a3c34 100644 --- a/core/main/agentPoll.test.ts +++ b/core/main/agentPoll.test.ts @@ -161,4 +161,40 @@ describe('the agent poll', () => { ['t1', false, null] ]) }) + + // Review 2026-10-11: the shell's prompt takes an agent's keys back at once, + // and a codex quit and started again between two looks is still "present" + // to the poll, which says only what changed: it was never armed again. + it('sends the next answer of a session asked about again, changed or not, past the waits', () => { + const w = world() + const poll = createAgentPoll(w.send, w.deps) + w.set({ out: '100 1\n200 100 codex\n' }) + poll() + poll.again('t1') + w.set({ now: 1 }) // quiet and inside the backoff: only the ask lets it through + poll() + expect(w.sent).toEqual([ + ['t1', true, 'codex'], + ['t1', true, 'codex'] + ]) + poll() + expect(w.asked()).toBe(2) // once answered, the ask is spent + }) + + it('keeps an ask that came during a look for the look after it', () => { + const w = world() + let finish: ((out: string | null) => void) | undefined + const deps = { ...w.deps, query: (done: (out: string | null) => void) => (finish = done) } + const poll = createAgentPoll(w.send, deps) + poll() + poll.again('t1') // the look in flight was taken before this ask + finish?.('100 1\n') + expect(w.sent).toEqual([['t1', false, null]]) + poll() + finish?.('100 1\n') + expect(w.sent).toEqual([ + ['t1', false, null], + ['t1', false, null] + ]) + }) }) diff --git a/core/main/agentPoll.ts b/core/main/agentPoll.ts index 671d346..cb2091f 100644 --- a/core/main/agentPoll.ts +++ b/core/main/agentPoll.ts @@ -75,20 +75,29 @@ const REAL: AgentPollDeps = { * without a timer: `createAgentPoll` holds the state, `startAgentPoll` is the * interval around it. */ -export function createAgentPoll(send: AgentSend, deps: AgentPollDeps = REAL): (force?: boolean) => void { +/** A look, and `again(id)`: that session's next answer is sent even if it is + * unchanged, and looked for at the next tick, past the waits. */ +export type AgentPoll = ((force?: boolean) => void) & { again: (id: string) => void } + +export function createAgentPoll(send: AgentSend, deps: AgentPollDeps = REAL): AgentPoll { const agentState = new Map() let agentBusy = false let agentSeenTicks = -1 let agentEvery = AGENT_POLL_MIN let agentNext = 0 + // Asked again (review 2026-10-11): the renderer's prompt took an agent's + // keys back, and only an answer sent anyway gives back the keys of one + // started again between two looks. Held until a look STARTS, so a look + // already in flight, taken before the ask, does not spend it. + const asked = new Set() // `force` skips the two waits below, never the answer (#73): a look the // renderer asked for sends only what CHANGED, like any other. - return (force = false): void => { + const poll = (force = false): void => { const pids = deps.pids() if (!pids.length || agentBusy) return const ticks = deps.ticks() const quiet = ticks === agentSeenTicks - const known = pids.every((s) => agentState.has(s.id)) + const known = pids.every((s) => agentState.has(s.id) && !asked.has(s.id)) // A shell that has said nothing since the last look, whose answer we // already have, cannot have changed its mind. if (quiet && known && !force) return @@ -98,14 +107,19 @@ export function createAgentPoll(send: AgentSend, deps: AgentPollDeps = REAL): (f const now = deps.now() if (known && now < agentNext && !force) return agentBusy = true + const asking = new Set(asked) + asked.clear() deps.query((stdout) => { agentBusy = false // Only a query that actually answered counts as having looked: a // failed one used to consume the activity tick, so a shell that then // fell quiet kept a stale dot until it printed again (2026-08-28). - if (!stdout) return - const rows = parseProcLines(stdout) - if (!rows.length) return + // Nor does it spend an ask. + const rows = stdout ? parseProcLines(stdout) : [] + if (!rows.length) { + asking.forEach((id) => asked.add(id)) + return + } agentSeenTicks = ticks let changed = false for (const { id, pid } of deps.pids()) { @@ -115,7 +129,7 @@ export function createAgentPoll(send: AgentSend, deps: AgentPollDeps = REAL): (f agentState.set(id, has) changed = true send(id, has, kind) - } + } else if (asking.has(id)) send(id, has, kind) } // forget sessions that ended const live = new Set(deps.pids().map((s) => s.id)) @@ -124,15 +138,17 @@ export function createAgentPoll(send: AgentSend, deps: AgentPollDeps = REAL): (f agentNext = deps.now() + agentEvery }) } + return Object.assign(poll, { again: (id: string): void => void asked.add(id) }) } /** The running poll, so a spawn can ask for an early look. */ -let running: ((force?: boolean) => void) | null = null +let running: AgentPoll | null = null /** * Start the poll; the function it returns stops it. `send` is how an answer * that CHANGED reaches the renderer (`term:agent`): nothing is sent for a - * session whose answer is what it was. + * session whose answer is what it was, unless the renderer asked again + * (`pollAgentAgain`). */ export function startAgentPoll(send: AgentSend): () => void { const poll = createAgentPoll(send) @@ -166,3 +182,13 @@ export function pollAgentsSoon(ms = 300): void { export function pollAgentsNow(): void { setTimeout(() => running?.(true), 0) } + +/** + * SAY THIS SESSION'S NEXT ANSWER, CHANGED OR NOT (review 2026-10-11). The + * renderer asks when the shell's prompt took an agent's keys back, or when a + * resumed agent's first answer was "no agent": it needs the poll's word at + * the next tick either way, and the poll otherwise says only what changed. + */ +export function pollAgentAgain(id: string): void { + running?.again(id) +} diff --git a/core/main/ipc.ts b/core/main/ipc.ts index f853742..dbd8797 100644 --- a/core/main/ipc.ts +++ b/core/main/ipc.ts @@ -1,7 +1,7 @@ import { CH } from '../shared/channels' import { OSC52_MAX } from '../shared/termLimits' import { validResume } from './agentResume' -import { pollAgentsNow, pollAgentsSoon, startAgentPoll } from './agentPoll' +import { pollAgentAgain, pollAgentsNow, pollAgentsSoon, startAgentPoll } from './agentPoll' import { detectShells } from './shells' import { cdTerm, killTerm, prewarmShell, resizeTerm, spawnTerm, writeTerm, type ClaudePluginEnv } from './terminal' import { openTermPath, pathKinds, PATHS_MAX, type PathOpeners } from './termPathOpen' @@ -125,6 +125,11 @@ export function registerTermIpc(deps: TermIpcDeps): () => void { // A title claimed an agent the poll has not seen (#73): look now. ipcMain.on(CH.agentLook, () => pollAgentsNow()) + // The shell's prompt took an agent's keys back, or a resume's agent was + // not there yet: this session's next answer is wanted, news or not. + ipcMain.on(CH.agentAgain, (_e: unknown, id: unknown) => { + if (typeof id === 'string') pollAgentAgain(id) + }) ipcMain.on(CH.prewarm, (_e: unknown, cwd: unknown, shellId: unknown, hooks: unknown) => { if (typeof cwd !== 'string') return diff --git a/core/preload/api.ts b/core/preload/api.ts index 1fcb9aa..55ceae1 100644 --- a/core/preload/api.ts +++ b/core/preload/api.ts @@ -47,6 +47,8 @@ export interface TermPreloadApi { onTermAgent(cb: (id: string, has: boolean, kind: DetectedAgent | null) => void): () => void /** Ask the process poll to look now, past its backoff (#73). */ termAgentLook(): void + /** Ask the process poll for this session's next answer, changed or not. */ + termAgentAgain(id: string): void /** The shell ended by itself (`exit`); a shell main was told to kill says nothing. */ onTermExit(cb: (id: string) => void): () => void /** @@ -105,6 +107,7 @@ export function createTermApi(ipc: IpcRendererLike): TermPreloadApi { onTermData: (cb) => on(CH.data, cb), onTermAgent: (cb) => on(CH.agent, cb), termAgentLook: () => ipc.send(CH.agentLook), + termAgentAgain: (id) => ipc.send(CH.agentAgain, id), onTermExit: (cb) => on(CH.exit, cb), readClipboard: () => ipc.sendSync(CH.clipboardRead) as ClipboardRead, writeClipboard: (text) => ipc.invoke(CH.clipboardWrite, text) as Promise, diff --git a/core/renderer/components/TerminalPanel.tsx b/core/renderer/components/TerminalPanel.tsx index d03aaff..ec865d0 100644 --- a/core/renderer/components/TerminalPanel.tsx +++ b/core/renderer/components/TerminalPanel.tsx @@ -5,7 +5,7 @@ import { Unicode11Addon } from '@xterm/addon-unicode11' import { WebLinksAddon } from '@xterm/addon-web-links' import { SearchAddon } from '@xterm/addon-search' import { decidePaste, imagePasteKey, newlineKey, sanitizePaste, type PathShell } from '../lib/termPaste' -import { armAtStart, armOnPoll, armOnPrompt, armOnTitle } from '../lib/agentArm' +import { armAtStart, armOnPoll, armOnPrompt, armOnTitle, type ArmStep } from '../lib/agentArm' import { shellOfShellId } from '../../shared/help/shells' import { onResumingChange, @@ -46,7 +46,6 @@ import { noteThemeMode, themePush, themeReports, - themeReportsOnPoll, xtversionAsked, xtversionReply, type GroundMode @@ -823,12 +822,31 @@ function createSession(id: string, root: string, shellId: string | undefined): S // The title (OSC 0/2) is where Claude Code writes its working state; App // reads the glyph. xterm parses it either way, this only passes it on. term.onTitleChange((t) => reportTitle(id, t)) + // Whether an agent runs in this shell right now, and which, for Shift+Enter + // and an image on Ctrl+V below: a resumed session is its agent from its + // first moment (set where the resume is taken, below); the title, the poll + // and the prompt move it (`agentArm`). + let arm = armAtStart(null) + // Whether the program in front asked for theme reports (?2031h, below). + let reports = THEME_REPORTS_OFF + const moveArm = (s: ArmStep): void => { + arm = s.arm + // The agent that asked for theme reports has gone with them. + if (s.gone) reports = THEME_REPORTS_OFF + if (s.recheck) termApi().termAgentAgain?.(id) + } term.parser.registerOscHandler(9, (data) => { const p = parseOsc9(data) if (p) { cwdNow = p markPrompt(id) reportCwd(id, p) + // THE PROMPT IS BACK: nothing runs in front of the shell (review + // 2026-10-11). The agent's keys go at once (Ctrl+V's ESC v reached + // PSReadLine, whose RevertLine wiped the line), and so does a ?2031h + // whose program died without its ?2031l: a theme push would reach the + // shell, and PSReadLine read the ESC as RevertLine and typed the rest. + moveArm(armOnPrompt(arm)) } return true }) @@ -920,8 +938,7 @@ function createSession(id: string, root: string, shellId: string | undefined): S // onData, in stream order with xterm's own replies (a DA1 right after stays // after), and starting with ESC so `looksTyped` never counts it as typing. // Anything these do not answer returns false and stays xterm's. - let reports = THEME_REPORTS_OFF - const reply = (r: string): void => term.input(r, false) + const reply =(r: string): void => term.input(r, false) const modeNow = (): GroundMode => groundMode(groundedTheme().background) const colourQuery = (ident: 10 | 11) => (data: string): boolean => { const theme = groundedTheme() @@ -1010,6 +1027,7 @@ function createSession(id: string, root: string, shellId: string | undefined): S // drawn itself (`resumeReveal`, which says when). The overlay is the // component's; this says which sessions still wear it (termBus). const resume = takeResume(id) + arm = armAtStart(resume ? agentOfResume(resume) : null) let reveal: RevealState | null = null let revealClock: ReturnType | null = null const finishReveal = (): void => { @@ -1049,10 +1067,6 @@ function createSession(id: string, root: string, shellId: string | undefined): S else term.write(data.slice(0, r.clearAt) + CLEAR + data.slice(r.clearAt)) } - // Whether an agent runs in this shell right now, and which, for Shift+Enter - // and an image on Ctrl+V below: a resumed session is its agent from its - // first moment; the title, the poll and the prompt move it (`agentArm`). - let arm = armAtStart(resume ? agentOfResume(resume) : null) /** * The one paste. Bracketed for text - without that framing a multi-line * paste reaches the shell as a run of Enter presses, so the first line runs @@ -1074,35 +1088,16 @@ function createSession(id: string, root: string, shellId: string | undefined): S } } - // The title arms the agent's keys before the poll (#175; `agentArm` has the - // rules). - term.onTitleChange((t) => { - arm = armOnTitle(arm, t) - }) - // THE SHELL'S PROMPT IS BACK (OSC 9;9): nothing runs in front of it. It takes - // back what only a title armed (a WSL tab's poll never says "left"), and a - // ?2031h whose program died without its ?2031l (review 2026-10-11): a theme - // push after it reached the shell, and PSReadLine read the ESC as - // RevertLine and typed the rest. Registered after the cwd handler, so it is - // asked first, and returns false: that handler still takes the sequence. - term.parser.registerOscHandler(9, (data) => { - if (parseOsc9(data)) { - arm = armOnPrompt(arm) - reports = THEME_REPORTS_OFF - } - return false - }) + // The title arms the agent's keys before the poll (#175), and takes back an + // agent only it vouched for (`agentArm` has the rules). + term.onTitleChange((t) => moveArm(armOnTitle(arm, t))) const unsub = [ attachClickCaret(term, el, id, (line, x) => oscLink(line, x) !== null), // A path this session asked about turned out to exist: paint the lines // that asked (#167; it used to be every line of every tab). onPathsFound(id, () => links.revisit()), termApi().onTermAgent((forId, present, kind) => { - if (forId !== id) return - // The agent that asked for theme reports has gone with it; a first - // "no agent" verdict is no departure (`themeReportsOnPoll`). - reports = themeReportsOnPoll(reports, arm.here, present) - arm = armOnPoll(arm, present, kind) + if (forId === id) moveArm(armOnPoll(arm, present, kind)) }), termApi().onTermData((forId, data) => { if (forId === id) writeOutput(data) diff --git a/core/renderer/host.ts b/core/renderer/host.ts index 9c3c5b4..f0c6a87 100644 --- a/core/renderer/host.ts +++ b/core/renderer/host.ts @@ -42,6 +42,10 @@ export interface TermApi { /** Ask the process poll to look NOW, past its backoff (#73). Optional so a * host bridge without it still builds; the poll then finds agents late. */ termAgentLook?(): void + /** Ask the poll for this session's NEXT answer, sent even if unchanged. + * Optional like the look: without it a Codex started again between two + * looks waits for its title to give its keys back. */ + termAgentAgain?(id: string): void onTermExit(cb: (id: string) => void): () => void readClipboard(): ClipboardRead /** Text onto the clipboard through main, for when the page itself is refused diff --git a/core/renderer/lib/agentArm.test.ts b/core/renderer/lib/agentArm.test.ts index 1238f79..bc518ad 100644 --- a/core/renderer/lib/agentArm.test.ts +++ b/core/renderer/lib/agentArm.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from 'vitest' -import { armAtStart, armOnPoll, armOnPrompt, armOnTitle } from './agentArm' +import { armAtStart, armOnPoll, armOnPrompt, armOnTitle, type AgentArm } from './agentArm' const IDLE = '✳ Claude Code' +const ASKS = '[ ! ] Action Required | yeah' + +const titled = (title: string, s: AgentArm = armAtStart(null)): AgentArm => armOnTitle(s, title).arm +const polled = (s: AgentArm, kind: 'claude' | 'codex' | 'other' = 'claude'): AgentArm => armOnPoll(s, true, kind).arm describe('agentArm', () => { it('starts armed for a resumed agent, bare otherwise', () => { @@ -10,39 +14,122 @@ describe('agentArm', () => { }) it("is armed by the agent's own title before the poll (#175)", () => { - expect(armOnTitle(armAtStart(null), IDLE)).toMatchObject({ here: true, kind: 'claude' }) + expect(titled(IDLE)).toMatchObject({ here: true, kind: 'claude' }) }) it('is not armed by a bare braille spinner', () => { - expect(armOnTitle(armAtStart(null), '⠙ npm')).toMatchObject({ here: false, kind: null }) + expect(titled('⠙ npm')).toMatchObject({ here: false, kind: null }) }) - it("is disarmed by the poll's left (#73)", () => { - const s = armOnPoll(armOnTitle(armAtStart(null), IDLE), true, 'claude') - expect(armOnPoll(s, false, null)).toMatchObject({ here: false, kind: null }) + it("is disarmed by the poll's left (#73), and that ends what the agent asked for", () => { + const step = armOnPoll(polled(titled(IDLE)), false, null) + expect(step.arm).toMatchObject({ here: false, kind: null }) + expect(step.gone).toBe(true) }) it("keeps the title's agent when the poll only sees a wrapper", () => { - expect(armOnPoll(armOnTitle(armAtStart(null), IDLE), true, 'other')).toMatchObject({ here: true, kind: 'claude' }) + expect(polled(titled(IDLE), 'other')).toMatchObject({ here: true, kind: 'claude' }) + }) + + // Review 2026-10-11: a restored tab starts armed by its resume, and the + // poll's FIRST answer can be taken before claude has started. Read as the + // agent leaving, it turned Claude's theme reports off for good and took + // Shift+Enter and Alt+V back until the next change the poll reported. + it("does not take a resume's agent back on the poll's first 'no agent'", () => { + const step = armOnPoll(armAtStart('claude'), false, null) + expect(step.arm).toMatchObject({ here: true, kind: 'claude' }) + expect(step.gone).toBe(false) + // It asks the poll for its next answer, changed or not, to confirm. + expect(step.recheck).toBe(true) + }) + + it("takes a resume's agent back when the poll says 'no agent' twice", () => { + const once = armOnPoll(armAtStart('claude'), false, null).arm + const twice = armOnPoll(once, false, null) + expect(twice.arm).toMatchObject({ here: false, kind: null }) + expect(twice.gone).toBe(false) + expect(twice.recheck).toBe(false) + }) + + // MEASURED (e2e termReplies, 2026-10-11): the first verdict landed after a + // program that is no agent had sent ?2031h, and ending the reports then told + // it nothing on a theme switch. + it("is no departure on a first 'no agent' with nothing armed", () => { + expect(armOnPoll(armAtStart(null), false, null).gone).toBe(false) + }) + + // A WSL tab: the poll sees wsl.exe, never claude, so its "no agent" says + // nothing about the agent the title armed. The title says when it goes. + it("keeps a title's agent on the poll's 'no agent'", () => { + const step = armOnPoll(titled(IDLE), false, null) + expect(step.arm).toMatchObject({ here: true, kind: 'claude' }) + expect(step.gone).toBe(false) + }) + + // Review 2026-10-11: in WSL no prompt report comes (bash prints no OSC 9;9) + // and the poll never sees claude, so after claude exited the tab kept its + // keys, and a stale ?2031h typed ESC[?997;Nn into bash on a theme switch. + it("is disarmed when the title stops naming a title-armed agent, and that ends its reports", () => { + const step = armOnTitle(titled('◐ Claude Code'), 'me@box: ~/src') + expect(step.arm).toMatchObject({ here: false, kind: null }) + expect(step.gone).toBe(true) + }) + + it("keeps a title-armed agent through its own titles", () => { + let s = titled(IDLE) + for (const t of ['◐ Claude Code', '◓ Claude Code', '✳ Fix the bug']) s = titled(t, s) + expect(s).toMatchObject({ here: true, kind: 'claude' }) + }) + + it('takes an empty title as the agent gone', () => { + expect(armOnTitle(titled(IDLE), '').arm).toMatchObject({ here: false }) + }) + + // Codex's title names it only while it asks; at rest it is the bare folder + // the question named, and busy that folder behind a braille spinner. + it("keeps a title-armed Codex at rest and busy", () => { + let s = titled(ASKS) + expect(s).toMatchObject({ here: true, kind: 'codex' }) + for (const t of ['yeah', '⠙ yeah', '[ . ] Action Required | yeah', 'yeah']) s = titled(t, s) + expect(s).toMatchObject({ here: true, kind: 'codex' }) + expect(armOnTitle(s, 'me@box: ~').arm).toMatchObject({ here: false }) + }) + + // An agent the poll saw is the poll's: its title is not asked. + it("leaves a polled agent to the poll when the title changes", () => { + const s = polled(titled(IDLE)) + const step = armOnTitle(s, 'npm') + expect(step.arm).toMatchObject({ here: true, kind: 'claude' }) + expect(step.gone).toBe(false) }) - // Review 2026-10-11: in a WSL tab the poll only ever sees wsl.exe, so it - // never says "left", and a title-armed tab kept Shift+Enter as Ctrl+J and - // Ctrl+V as ESC v at the plain pwsh prompt after `exit`. The shell's own - // prompt report says nothing runs in front of it. it('is disarmed by the prompt when only the title armed it', () => { - expect(armOnPrompt(armOnTitle(armAtStart(null), IDLE))).toMatchObject({ here: false, kind: null }) + expect(armOnPrompt(titled(IDLE)).arm).toMatchObject({ here: false, kind: null }) }) it('is disarmed by the prompt after a resumed agent ends', () => { - expect(armOnPrompt(armAtStart('codex'))).toMatchObject({ here: false, kind: null }) + expect(armOnPrompt(armAtStart('codex')).arm).toMatchObject({ here: false, kind: null }) + }) + + // Review 2026-10-11: the prompt proves nothing runs in front of the shell. + // Keeping a poll-seen agent armed past it sent ESC v to PSReadLine on an + // image paste for up to 20 s, and PSReadLine's RevertLine wiped the line. + // The poll is asked for its next answer, changed or not, so a Codex + // restarted between two looks still gets its keys back. + it('is disarmed by the prompt even when the poll saw the agent, and asks the poll again', () => { + const step = armOnPrompt(polled(armAtStart(null), 'codex')) + expect(step.arm).toMatchObject({ here: false, kind: null }) + expect(step.recheck).toBe(true) + expect(step.gone).toBe(true) + }) + + it('does not ask the poll again on a prompt it has nothing to correct', () => { + expect(armOnPrompt(armAtStart(null)).recheck).toBe(false) + expect(armOnPrompt(titled(IDLE)).recheck).toBe(false) }) - // An agent the poll saw is the poll's to take back: a codex restarted inside - // one poll interval is never reported again, and Codex's title arms only - // while it asks. - it('leaves an agent the poll saw to the poll', () => { - const s = armOnPoll(armAtStart(null), true, 'codex') - expect(armOnPrompt(s)).toMatchObject({ here: true, kind: 'codex' }) + it('is armed again when the poll answers after the prompt', () => { + const after = armOnPrompt(polled(armAtStart(null), 'codex')).arm + expect(polled(after, 'codex')).toMatchObject({ here: true, kind: 'codex' }) }) }) diff --git a/core/renderer/lib/agentArm.ts b/core/renderer/lib/agentArm.ts index 10f75c4..f93ab08 100644 --- a/core/renderer/lib/agentArm.ts +++ b/core/renderer/lib/agentArm.ts @@ -1,47 +1,96 @@ import type { DetectedAgent } from '../../shared/types' -import { titleArmsAgent } from './agentTitle' +import { titleAgent, titleArmsAgent } from './agentTitle' /** * WHETHER AN AGENT RUNS IN A SHELL, AND WHICH, for the keys that change with - * it: Shift+Enter's newline and an image's paste key (`termPaste`). Three - * voices, pure so the rules are tested: + * it: Shift+Enter's newline and an image's paste key (`termPaste`), and for + * the theme reports it asked for (`gone`). Four voices, pure so the rules are + * tested (review 2026-10-11 for the last three rules): * + * - The RESUME arms it from the tab's first moment. The poll's first answer + * can be taken before the agent started, so a first "no agent" only asks + * the poll again (`recheck`); a second one takes it back. * - The TITLE arms it before the poll (#175): the poll's first verdict comes * 2.5 s at the earliest, and until then Shift+Enter SUBMITTED a half-written - * message. Only what `titleArmsAgent` takes as proof arms it, and a stale - * title does not re-arm, since it does not change again. + * message. Only what `titleArmsAgent` takes as proof arms it. An agent only + * the title vouches for goes when the title stops naming it: in a WSL tab + * the poll sees wsl.exe and bash prints no prompt report, so nothing else + * would ever say it left. * - The POLL says when an agent arrives or leaves (#73); a wrapper ('other') - * does not undo the title's claude or codex. - * - The shell's PROMPT report (OSC 9;9) takes back what only a title armed - * (review 2026-10-11): in a WSL tab the poll sees wsl.exe and never says - * "left", so a tab that ran claude in WSL kept the agent's keys at the plain - * prompt for the rest of its life. An agent the poll saw stays the poll's: - * the poll reports a change only, so a codex restarted inside one interval - * would never be armed again, Codex's title arming only while it asks. + * does not undo the title's claude or codex. Its "no agent" takes back only + * what it saw itself. + * - The shell's PROMPT report (OSC 9;9) proves nothing runs in front of it, + * and takes everything back at once. An agent the poll saw is still + * "present" to the poll, which says only what changed, so the poll is asked + * for its next answer whatever it is: a Codex started again before the next + * look is armed again. */ export interface AgentArm { here: boolean kind: DetectedAgent | null - /** The poll's last word: an agent it saw is its to take back. */ + /** The poll's last word was "present": an agent it saw is its to take back. */ polled: boolean + /** The title vouches for it; for Codex, `rest` is its title at rest. */ + titled: boolean + rest: string + /** A resume's agent the poll has said "no agent" about once. */ + doubted: boolean } -export const armAtStart = (resumed: DetectedAgent | null): AgentArm => ({ - here: resumed !== null, - kind: resumed, - polled: false -}) +/** One move. `gone`: an agent known to be here has left, and whatever it + * asked the terminal for (theme reports) goes with it. `recheck`: ask the + * poll for its next answer for this session, changed or not. */ +export interface ArmStep { + arm: AgentArm + gone: boolean + recheck: boolean +} + +const BARE: AgentArm = { here: false, kind: null, polled: false, titled: false, rest: '', doubted: false } +const step = (arm: AgentArm, gone = false, recheck = false): ArmStep => ({ arm, gone, recheck }) + +export const armAtStart = (resumed: DetectedAgent | null): AgentArm => + resumed ? { ...BARE, here: true, kind: resumed } : BARE + +/** Codex asking names the folder its title returns to at rest (#131). */ +const ASK_REST = /Action Required\s*\|\s*(.*)$/ + +/** Whether `title` is still the title-armed agent's own. */ +function stillNames(s: AgentArm, title: string): boolean { + const t = title.trim() + const named = titleAgent(t) + if (s.kind === 'claude') return named === 'claude' + // Codex: asking, busy (its braille spinner before the folder) or at rest. + if (s.kind === 'codex') return named === 'codex' || (s.rest !== '' && t === s.rest) + return false +} -export function armOnTitle(s: AgentArm, title: string): AgentArm { +export function armOnTitle(s: AgentArm, title: string): ArmStep { const named = titleArmsAgent(title) - return named ? { ...s, here: true, kind: named } : s + if (named) { + const rest = named === 'codex' ? (ASK_REST.exec(title.trim())?.[1]?.trim() ?? s.rest) : '' + return step({ ...s, here: true, kind: named, titled: true, rest, doubted: false }) + } + if (!s.titled || stillNames(s, title)) return step(s) + // The poll saw it: the poll says when it leaves, whatever the title does. + if (s.polled) return step({ ...s, titled: false }) + return step(BARE, true) } -export function armOnPoll(s: AgentArm, present: boolean, kind: DetectedAgent | null): AgentArm { - if (!present) return { here: false, kind: null, polled: false } - return { here: true, kind: kind === 'other' && s.kind ? s.kind : kind, polled: true } +export function armOnPoll(s: AgentArm, present: boolean, kind: DetectedAgent | null): ArmStep { + if (present) { + return step({ ...s, here: true, kind: kind === 'other' && s.kind ? s.kind : kind, polled: true, doubted: false }) + } + if (s.polled) return step(BARE, true) + // The poll never saw the title's agent (WSL): its "no" is not about it. + if (s.titled) return step(s) + if (s.here && !s.doubted) return step({ ...s, doubted: true }, false, true) + return step(BARE) } -export function armOnPrompt(s: AgentArm): AgentArm { - return s.polled ? s : { here: false, kind: null, polled: false } +export function armOnPrompt(s: AgentArm): ArmStep { + // Gone even with nothing armed: a program that died without its ?2031l + // left reports on, and a push would reach the shell (PSReadLine reads the + // ESC as RevertLine). + return step(BARE, true, s.polled) } diff --git a/core/renderer/lib/termLinkPaint.ts b/core/renderer/lib/termLinkPaint.ts index fe58661..ce1aaee 100644 --- a/core/renderer/lib/termLinkPaint.ts +++ b/core/renderer/lib/termLinkPaint.ts @@ -4,7 +4,15 @@ import { rowsToReink } from './linkInkRows' import { sliceRows, type RowPlan } from './linkScanPlan' import { findLinks } from './termLinks' import { cellText } from './termCells' -import { bufferRowCells, spanRows, spanText, spanTextRange, type Osc8Span } from './termOsc8' +import { + bufferRowCells, + piecesOverlap, + spanRows, + spanText, + spanTextRange, + type Osc8Span, + type RowPiece +} from './termOsc8' /** * Paints the links in a terminal's buffer (owner, 2026-09-19). xterm's link @@ -169,10 +177,14 @@ export function attachLinkPaint( quick += line.translateToString(r === last) } // OSC 8 links first: their labels need not look like links at all. + const taken: RowPiece[] = [] for (const s of opts.spans?.(first, last) ?? []) { if (!standing(s)) continue for (const p of spanRows(s, term.cols)) - if (p.line >= first && p.line <= last) paintRow(p.line, p.x, p.width, ink) + if (p.line >= first && p.line <= last) { + paintRow(p.line, p.x, p.width, ink) + taken.push(p) + } } if (!/[\\/.]/.test(quick)) return // no URL and no path can be here // Built cell by cell, because a wide character is one character and TWO @@ -193,14 +205,19 @@ export function attachLinkPaint( const links = find(text) if (opts.asked?.()) noteWaiting(first) for (const link of links) { + const pieces: RowPiece[] = [] let from = link.start while (from < link.end) { const row = cells[from].row let to = from while (to + 1 < link.end && cells[to + 1].row === row) to += 1 - paintRow(row, cells[from].x, cells[to].x + cells[to].w - cells[from].x, ink) + pieces.push({ line: row, x: cells[from].x, width: cells[to].x + cells[to].w - cells[from].x }) from = to + 1 } + // An OSC 8 label that is itself a URL is painted once, as the span: two + // coats of the same ink and two underlines otherwise. + if (piecesOverlap(pieces, taken)) continue + for (const p of pieces) paintRow(p.line, p.x, p.width, ink) } } diff --git a/core/renderer/lib/termOsc8.test.ts b/core/renderer/lib/termOsc8.test.ts index ee3c0a0..3b665ae 100644 --- a/core/renderer/lib/termOsc8.test.ts +++ b/core/renderer/lib/termOsc8.test.ts @@ -5,6 +5,7 @@ import { osc8Target, Osc8Spans, parseOsc8, + piecesOverlap, spanRows, spanText, spanTextRange, @@ -207,3 +208,23 @@ describe('spanText and spanTextRange', () => { expect(spanTextRange(row, 3, 10)).toEqual({ start: 2, end: 9 }) }) }) + +// Review 2026-10-11: on the normal screen an OSC 8 link whose label is itself +// a URL was painted twice, once as the span and once as the URL the text +// matched; the alternate screen's ink already took it once. +describe('piecesOverlap', () => { + const span = [ + { line: 4, x: 70, width: 10 }, + { line: 5, x: 0, width: 12 } + ] + it('is true for a match that shares a cell with a span, on any of its rows', () => { + expect(piecesOverlap([{ line: 4, x: 70, width: 10 }], span)).toBe(true) + expect(piecesOverlap([{ line: 5, x: 11, width: 4 }], span)).toBe(true) + }) + it('is false beside it, on the same row or another', () => { + expect(piecesOverlap([{ line: 4, x: 60, width: 10 }], span)).toBe(false) + expect(piecesOverlap([{ line: 5, x: 12, width: 8 }], span)).toBe(false) + expect(piecesOverlap([{ line: 6, x: 0, width: 12 }], span)).toBe(false) + expect(piecesOverlap([{ line: 4, x: 70, width: 10 }], [])).toBe(false) + }) +}) \ No newline at end of file diff --git a/core/renderer/lib/termOsc8.ts b/core/renderer/lib/termOsc8.ts index fc4a322..0552115 100644 --- a/core/renderer/lib/termOsc8.ts +++ b/core/renderer/lib/termOsc8.ts @@ -136,9 +136,16 @@ export class Osc8Spans { } } +/** A stretch of cells on one buffer row. */ +export interface RowPiece { + line: number + x: number + width: number +} + /** The span's cells, one piece per row. */ -export function spanRows(span: Osc8Span, cols: number): Array<{ line: number; x: number; width: number }> { - const out: Array<{ line: number; x: number; width: number }> = [] +export function spanRows(span: Osc8Span, cols: number): RowPiece[] { + const out: RowPiece[] = [] for (let r = 0; r <= span.rows; r += 1) { const from = r === 0 ? span.x : 0 const to = r === span.rows ? span.endX : cols @@ -147,6 +154,12 @@ export function spanRows(span: Osc8Span, cols: number): Array<{ line: number; x: return out } +/** Whether any of `pieces` shares a cell with any of `taken`: a URL match + * inside an OSC 8 link is that link, painted once (review 2026-10-11). */ +export function piecesOverlap(pieces: readonly RowPiece[], taken: readonly RowPiece[]): boolean { + return pieces.some((p) => taken.some((t) => t.line === p.line && t.x < p.x + p.width && p.x < t.x + t.width)) +} + /** Where cells `from` to `to` of a row sit in the row's TEXT (a wide * character is two cells and one character). */ export function spanTextRange(row: readonly CellInfo[], from: number, to: number): { start: number; end: number } { diff --git a/core/renderer/lib/termReplies.test.ts b/core/renderer/lib/termReplies.test.ts index 39617ac..ae08f3c 100644 --- a/core/renderer/lib/termReplies.test.ts +++ b/core/renderer/lib/termReplies.test.ts @@ -12,7 +12,6 @@ import { oscColour, themePush, themeReports, - themeReportsOnPoll, xtversionAsked, xtversionReply } from './termReplies' @@ -108,23 +107,6 @@ describe('themePush', () => { }) }) -describe('themeReportsOnPoll', () => { - const on = themeReports(THEME_REPORTS_OFF, true, 'dark') - it('ends the reports when the agent the poll or title had armed leaves', () => { - expect(themeReportsOnPoll(on, true, false)).toEqual(THEME_REPORTS_OFF) - }) - // MEASURED (e2e termReplies, 2026-10-11): the poll's FIRST verdict for a tab - // is a change from nothing, "no agent", and it landed after a program that - // is no agent had sent ?2031h, so a theme switch told it nothing. - it('keeps them on a "no agent" verdict when no agent was there', () => { - expect(themeReportsOnPoll(on, false, false)).toBe(on) - }) - it('keeps them when an agent arrives or stays', () => { - expect(themeReportsOnPoll(on, false, true)).toBe(on) - expect(themeReportsOnPoll(on, true, true)).toBe(on) - }) -}) - describe('xtversionReply (#171)', () => { it('names this terminal, then the xterm.js under it, as DCS >| ... ST', () => { expect(xtversionReply('0.32.0', '6.0.0')).toBe('\x1bP>|PrismTerminal 0.32.0 (xterm.js 6.0.0)\x1b\\') diff --git a/core/renderer/lib/termReplies.ts b/core/renderer/lib/termReplies.ts index ccb5fac..371c691 100644 --- a/core/renderer/lib/termReplies.ts +++ b/core/renderer/lib/termReplies.ts @@ -95,18 +95,6 @@ export function themeReports(s: ThemeReports, on: boolean, now: GroundMode): The return { on, last: on ? (s.last ?? now) : s.last } } -/** - * The poll's word on the agent (`term:agent`): the reports end with the agent - * that was there (`wasHere`, armed by the poll or its title) leaving, in a - * shell with no prompt report to end them (cmd, WSL). A "no agent" with no - * agent before is not a departure: the poll's FIRST verdict for a tab is that, - * and it can land after a program that is no agent sent ?2031h (MEASURED, e2e - * termReplies 2026-10-11: a theme switch then told the program nothing). - */ -export function themeReportsOnPoll(s: ThemeReports, wasHere: boolean, present: boolean): ThemeReports { - return wasHere && !present ? THEME_REPORTS_OFF : s -} - /** A query answered (OSC 11, `?996n`): the program now knows `mode`. */ export function noteThemeMode(s: ThemeReports, mode: GroundMode): ThemeReports { return { ...s, last: mode } diff --git a/core/shared/channels.ts b/core/shared/channels.ts index e5c6e14..6624915 100644 --- a/core/shared/channels.ts +++ b/core/shared/channels.ts @@ -14,6 +14,8 @@ export const CH = { data: 'term:data', agent: 'term:agent', agentLook: 'term:agent-look', + /** Send one session's next agent answer, changed or not. */ + agentAgain: 'term:agent-again', exit: 'term:exit', clipboardRead: 'clipboard:read', clipboardWrite: 'clipboard:write', diff --git a/docs/regression-rules.md b/docs/regression-rules.md index 400f1e3..4ea8583 100644 --- a/docs/regression-rules.md +++ b/docs/regression-rules.md @@ -27,9 +27,12 @@ one-liner in `CLAUDE.md` only if it is among the most important. Ctrl+V match `e.code` too (a Russian layout's C is 'с'). Shift+Enter sends a newline ONLY where an agent runs, at a plain prompt it is Enter: Ctrl+J to Claude and the `\` continuation otherwise (`newlineKey`, measured: Codex 0.153.2 drops a bare LF), armed by Claude's title glyphs or Codex's - Action Required before the poll, never a bare braille spinner, and disarmed by the poll (#175), or - by the shell's prompt report when only a title armed it (`lib/agentArm.ts`: a WSL tab's poll sees - wsl.exe and never says left). An image on Ctrl+V sends the AGENT's key, Alt+V to Claude (on WSL + Action Required before the poll, never a bare braille spinner (#175). The shell's prompt report + takes them back at once and asks the poll for its next answer even if unchanged (`termAgentAgain`: + Ctrl+V's ESC v reached PSReadLine as RevertLine), the poll's "left" takes back only what the poll + saw (a resume's first "no agent" is asked again, not obeyed), and an agent only its title vouches + for goes when the title stops naming it: a WSL tab's poll sees wsl.exe and bash prints no prompt + report (`lib/agentArm.ts`, review 2026-10-11). An image on Ctrl+V sends the AGENT's key, Alt+V to Claude (on WSL too) and ^V otherwise, exactly one, since a keybindings.json binding both would paste twice (`imagePasteKey`, #170). The panel's attach is keyed on the session alone (a `cd` must not re-attach it and take the focus). Under a question or the update @@ -171,8 +174,9 @@ one-liner in `CLAUDE.md` only if it is among the most important. `docs/superpowers/specs/2026-10-10-claude-code-compat-design.md` section 15). Every byte the core writes into a pty that no key produced is a reply to a query in that pty's own stream (OSC 10/11 `?`, `CSI ? 996 n`, `CSI > q`, DECRQM 2031) or the `?997` report the program turned on with `?2031h`, only on a change and only while that program is there: - the shell's prompt report, the poll's "agent left" and RIS turn the reports off (review 2026-10-11: a push after a - killed Claude reached PSReadLine as ESC, RevertLine, then typed `[?997;1n`). Replies hold no CR, LF or printable + the shell's prompt report, an agent leaving (`agentArm`'s `gone`: the poll's "left" for an agent it saw, or a + title-armed agent's title going) and RIS turn the reports off; the poll's first "no agent" never does (review + 2026-10-11: a push after a killed Claude reached PSReadLine as ESC, RevertLine, then typed `[?997;1n`). Replies hold no CR, LF or printable command, go through `term.input(reply, false)` so `onData` and `looksTyped` treat them as the replies they are ([typing on onKey](#typing-on-onkey)), and never read anything private: OSC 52 is write-only and a read gets no answer. The pure half is `core/renderer/lib/termReplies.ts` (tested); the light/dark it reports is the chrome's own From 4af2a6aaecb872a336772a0e8603cf267db28e97 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sun, 11 Oct 2026 08:37:34 +0200 Subject: [PATCH 17/18] docs: a live theme switch repaints only Claude's live frame (measured) The owner saw Claude stay dark after a dark-to-light switch on 0.38.0. Traced in the app and over bare node-pty: PT pushes ?997;2n, Claude asks OSC 11 again and detects light, then redraws only its live frame; rows it already committed keep the old palette. Claude Code's side; the PT-side options (Ctrl+L, a resize nudge) break two rules and are the owner's call. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t --- .../2026-10-10-claude-code-compat-design.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/superpowers/specs/2026-10-10-claude-code-compat-design.md b/docs/superpowers/specs/2026-10-10-claude-code-compat-design.md index fc69f47..2294d23 100644 --- a/docs/superpowers/specs/2026-10-10-claude-code-compat-design.md +++ b/docs/superpowers/specs/2026-10-10-claude-code-compat-design.md @@ -193,6 +193,25 @@ Claude writes `CSI ? 2031 h` (MEASURED) and re-queries OSC 11 only when the term the scenario picks Fawn in Settings; the probe receives `?997;2n` exactly once; back to PT Default gives `?997;1n`; a font size change sends nothing. +### Measured after release: a live switch repaints only Claude's live frame (2026-10-11) + +Owner, on 0.38.0: switched dark to light with Claude Code 2.1.296 ("auto") running, and Claude +"stayed dark". MEASURED, in this app (a dev build with the reports state traced) and over bare +node-pty with the bundled ConPTY: PT does its part in every case tried (a typed `claude`, a +restored tab's `claude --resume`, a PowerShell-tool turn, hooks, focus out and in, replies held +back 8 s as a stalled restore would): `?2031h` is seen, the switch pushes `?997;2n` once, Claude +asks OSC 11 again and its debug log says `detected=light`. Claude then REDRAWS ONLY ITS LIVE FRAME +(the input box, the spinner line, the status, whatever of the last answer is still in the frame). +Rows it has already committed above that frame keep the old palette: with a 22-line answer the +prompt row's dark band (`48;2;55;55;55`) was not rewritten, and a long resumed session is almost all +committed transcript, so it looks unchanged. A short session repaints whole because all of it is +still the frame. Claude's own redraw (Ctrl+L) and a resize clear the screen and draw the viewport in +the new palette (MEASURED); the scrollback above stays as written. So the rest is Claude Code's: +it does not repaint committed output on a theme change. PT could force a redraw (Ctrl+L or a +one-column resize nudge after the push), but the first is typing into the user's program (rule +`only-command-is-resume`) and the second a resize every program sees (rule `conpty-resize`): an +owner decision, not taken. The user's own Ctrl+L in Claude redraws the screen in the new palette. + ## 5. #169 (medium): OSC 8 links open nothing ### What exists From 509e98330eba20c5e93affb0c12be3bf5558dc7e Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:09:38 +0200 Subject: [PATCH 18/18] feat(core): nudge a program to redraw after a theme push (#172) Owner, 2026-10-11, choosing "Tiny resize nudge": Claude Code 2.1.296 hears the ?997 push and switches palette but redraws only its live frame, so text it drew before kept the old colours. After a push the program asked for, the panel resizes that terminal one column narrower and refits it back, once the tab is shown (the theme is switched in Settings, so it is usually hidden). A resize, never a key. e2e termReplies: the stand-in sees 151 then 152. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t --- core/renderer/components/TerminalPanel.tsx | 31 +++++++++++++++++++++- core/renderer/lib/termReplies.test.ts | 14 ++++++++++ core/renderer/lib/termReplies.ts | 21 +++++++++++++++ docs/regression-rules.md | 5 ++++ tools/e2e/run.mjs | 8 ++++++ 5 files changed, 78 insertions(+), 1 deletion(-) diff --git a/core/renderer/components/TerminalPanel.tsx b/core/renderer/components/TerminalPanel.tsx index ec865d0..3e89f08 100644 --- a/core/renderer/components/TerminalPanel.tsx +++ b/core/renderer/components/TerminalPanel.tsx @@ -43,6 +43,9 @@ import { dsrThemeReply, groundMode, modeParams, + nudgeWidths, + NUDGE_AFTER_MS, + NUDGE_HOLD_MS, noteThemeMode, themePush, themeReports, @@ -981,10 +984,36 @@ function createSession(id: string, root: string, shellId: string | undefined): S reply(decrqmReply(2031, reports.on)) return true }) + // The redraw nudge after a push (termReplies.ts says why): one column less, + // then a refit back, through the panel's own resize path so xterm and the pty + // agree. Several switches in a row give one nudge. The theme is switched in + // Settings, so the program's tab is usually HIDDEN then (no size): the nudge + // waits until the tab is shown again, since showing it refits to the same + // size, which tells the program nothing. + let nudge: number | undefined + const nudgeWhenShown = (): void => { + const s = sessions.get(id) + if (!s) return + if (!el.clientWidth || !el.clientHeight) { + nudge = window.setTimeout(nudgeWhenShown, NUDGE_AFTER_MS) + return + } + const w = nudgeWidths(term.cols) + if (!w) return + suppressActivity(id) + term.resize(w[0], term.rows) + termApi().termResize(id, w[0], term.rows) + nudge = window.setTimeout(() => { + if (sessions.get(id) === s) refitSession(id, s) + }, NUDGE_HOLD_MS) + } const tellTheme = (mode: GroundMode): void => { const r = themePush(reports, mode) reports = r.state - if (r.send) reply(r.send) + if (!r.send) return + reply(r.send) + window.clearTimeout(nudge) + nudge = window.setTimeout(nudgeWhenShown, NUDGE_AFTER_MS) } // XTVERSION, `CSI > q` / `CSI > 0 q` (#171; termReplies.ts has the // measurement): xterm 6.0.0 has no handler, and without a reply Claude never diff --git a/core/renderer/lib/termReplies.test.ts b/core/renderer/lib/termReplies.test.ts index ae08f3c..5bbdb87 100644 --- a/core/renderer/lib/termReplies.test.ts +++ b/core/renderer/lib/termReplies.test.ts @@ -8,6 +8,7 @@ import { dsrThemeReply, groundMode, modeParams, + nudgeWidths, noteThemeMode, oscColour, themePush, @@ -144,3 +145,16 @@ describe('bellGate (#177)', () => { expect(bellGate(0, 1000)).toBe(true) }) }) + +// The redraw nudge after a theme push (owner, 2026-10-11): one column less, +// then the width back, never a key. +describe('nudgeWidths', () => { + it('is one column narrower and back', () => { + expect(nudgeWidths(120)).toEqual([119, 120]) + }) + it('is nothing for a terminal too narrow or not measured', () => { + expect(nudgeWidths(2)).toBeNull() + expect(nudgeWidths(0)).toBeNull() + expect(nudgeWidths(Number.NaN)).toBeNull() + }) +}) \ No newline at end of file diff --git a/core/renderer/lib/termReplies.ts b/core/renderer/lib/termReplies.ts index 371c691..277c298 100644 --- a/core/renderer/lib/termReplies.ts +++ b/core/renderer/lib/termReplies.ts @@ -139,3 +139,24 @@ export function themePush(s: ThemeReports, mode: GroundMode): { send: string | n if (!s.on || s.last === mode) return { send: null, state: s } return { send: dsrThemeReply(mode), state: { ...s, last: mode } } } + +/** + * THE REDRAW NUDGE (owner, 2026-10-11, choosing "Tiny resize nudge" over + * sending Ctrl+L or leaving it). Claude Code 2.1.296 hears a theme push and + * switches its palette, but only redraws its live frame: what it drew before + * keeps the old colours (measured, the #172 section of the compat spec). A + * resize makes it redraw everything visible. So after a push PT shrinks the + * terminal one column and gives it back: a resize, never a key, and only after + * a push the program itself asked for (?2031h). + * + * NUDGE_AFTER_MS leaves the program time to re-query OSC 11 and switch before + * the redraw it triggers; NUDGE_HOLD_MS is how long the narrower size stands, + * long enough that ConPTY passes both sizes on rather than only the last. + */ +export const NUDGE_AFTER_MS = 250 +export const NUDGE_HOLD_MS = 80 + +/** The two widths of a nudge, or null when a terminal is too narrow for one. */ +export function nudgeWidths(cols: number): [number, number] | null { + return Number.isInteger(cols) && cols >= 3 ? [cols - 1, cols] : null +} \ No newline at end of file diff --git a/docs/regression-rules.md b/docs/regression-rules.md index 4ea8583..6f781ba 100644 --- a/docs/regression-rules.md +++ b/docs/regression-rules.md @@ -170,6 +170,11 @@ one-liner in `CLAUDE.md` only if it is among the most important. the FIRST token too: a native `claude.exe` started by bare name has the command line `claude`. - **The only command the app writes into a shell is the agent resume**, as the shell's STARTUP command, with the id shape-checked in main (`validResume`). Never type into a user's shell. +- **A THEME PUSH IS FOLLOWED BY A RESIZE NUDGE, NEVER A KEY** (owner, 2026-10-11, choosing + "Tiny resize nudge" over sending Ctrl+L or leaving it). Claude Code 2.1.296 switches palette on `?997` but redraws + only its live frame (measured, the #172 section of the compat spec). After a push the program asked for, the panel + resizes that terminal one column narrower for `NUDGE_HOLD_MS` and refits it back (`termReplies.ts`), waiting until + a hidden tab is shown. Nothing is typed. Held by the `termReplies` e2e (the stand-in sees cols-1 then cols, once). - **THE APP ANSWERS A PROGRAM ONLY WHAT IT ASKED** (#168, #171, #172, #176; spec `docs/superpowers/specs/2026-10-10-claude-code-compat-design.md` section 15). Every byte the core writes into a pty that no key produced is a reply to a query in that pty's own stream (OSC 10/11 `?`, `CSI ? 996 n`, `CSI > q`, DECRQM 2031) diff --git a/tools/e2e/run.mjs b/tools/e2e/run.mjs index c94fc8b..02747d7 100644 --- a/tools/e2e/run.mjs +++ b/tools/e2e/run.mjs @@ -2318,6 +2318,8 @@ const scenarios = { " if (s === 't') { process.stdout.write('\\x1b]11;?\\x07\\x1b[?996n'); return }", " process.stdout.write('IN ' + show(s) + '\\r\\n')", '})', + // The redraw nudge (owner, 2026-10-11): a resize after a theme push. + "process.stdout.on('resize', () => process.stdout.write('WINCH ' + process.stdout.columns + '\\r\\n'))", // XTVERSION three ways (#171): `> q` and `> 0 q` ask, `> 1 q` does not. "process.stdout.write('\\x1b[?2031h\\x1b]11;?\\x07\\x1b]10;?\\x07\\x1b[?996n\\x1b[?2031$p\\x1b[>q\\x1b[>0q\\x1b[>1q\\x1b[c')" ].join('\n') @@ -2363,6 +2365,12 @@ const scenarios = { ok(!!(await until(async () => count(await heard(), LIGHT) >= 1, 5000)), 'picking Fawn tells the program light') await sleep(800) ok(count(await heard(), LIGHT) === 1, `exactly once (${count(await heard(), LIGHT)})`) + // THE REDRAW NUDGE (owner, 2026-10-11: "Tiny resize nudge"): once the tab is + // shown again the program sees one column less, then its width back. + const winch = (text) => [...text.matchAll(/WINCH (\d+)/g)].map((m) => Number(m[1])) + const nudged = await until(async () => { const ws = winch(await heard()); return ws.length >= 2 ? ws : null }, 5000) + ok(!!nudged && nudged[nudged.length - 2] === nudged[nudged.length - 1] - 1, `the program is nudged one column narrower and back (${nudged?.join(', ')})`) + ok(!!nudged && nudged.length === 2, `once, for one switch (${nudged?.length} resizes)`) await page.keyboard.type('t') ok(!!(await until(async () => (await heard()).includes(FAWN_GROUND), 5000)), "asked again, OSC 11 answers Fawn's ground")