Skip to content

fix(core): Claude Code compatibility fixes (#167-#177) - #178

Merged
Maxaubert merged 20 commits into
mainfrom
fix/cc-compat
Oct 11, 2026
Merged

Maxaubert merged 20 commits into
mainfrom
fix/cc-compat

Conversation

@Maxaubert

Copy link
Copy Markdown
Owner

Fixes the Claude Code compatibility issues from the 2026-10-10 research. Stacked on #165 (both touch termLinkPaint.ts); retarget to main before #165 merges.

Fixes #167, Fixes #168, Fixes #169, Fixes #170, Fixes #171, Fixes #172, Fixes #174, Fixes #175, Fixes #176, Fixes #177

#173 stays open (withdrawn here). FORCE_HYPERLINK=1 was added and then taken out after review: supports-hyperlinks (inside claude.exe 2.1.296) honours it before its isTTY check, so piped and redirected output from every child would carry raw OSC 8 escapes. The only other signals Claude accepts would mean claiming to be another terminal. Owner decision on whether to pursue it differently. (The squash message must not keep the Fixes #173 line from commit 884ca47.)

What changes

Core change

Core 0.32.0 and app 0.38.0. This ships to Prism. No new TermHostConfig field. New optional bridge members: TermApi.writeClipboardFromTerm?, termBell?, and TermIpcDeps.attention?.

Verification

Known gap

In a WSL tab, after Claude exits inside WSL bash, Shift+Enter and image paste keep the agent's keys until you exit back to pwsh or cmd. This is because WSL's prompt does not report itself.

Check by hand with a real Claude

  1. Shift+Enter inside Claude adds a line and doesn't submit. At a plain pwsh prompt, it still runs the line.
  2. On Fawn, Claude's auto theme comes up light. A live dark/light switch recolours Claude. After Claude quits, a switch types nothing.
  3. Clicking a link Claude prints opens it with no confirm, and right-click offers Copy link.
  4. Ctrl+V with an image pastes it once.
  5. A bell while the window is unfocused flashes the taskbar.
  6. Claude's copy shows the Copied badge.
  7. Large output full of paths in one tab doesn't freeze the window.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t

Maxaubert and others added 14 commits October 10, 2026 23:15
)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t
)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t
#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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t
…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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t
)

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 <core version> (xterm.js <version>) 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t
…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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t
- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t
…ct 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t
Maxaubert and others added 5 commits October 11, 2026 02:57
)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t
termLinkPaint.ts: the row watch re-inks only rows that lost ink (#163), and
inkRow keeps its screen row for the OSC 8 spans.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t
…t 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t
@Maxaubert
Maxaubert changed the base branch from fix/163-link-ink to main October 11, 2026 07:12
# Conflicts:
#	core/package.json
#	core/renderer/lib/termLinkPaint.ts
#	package-lock.json
#	package.json
@Maxaubert
Maxaubert merged commit 515d6d0 into main Oct 11, 2026
2 checks passed
@Maxaubert
Maxaubert deleted the fix/cc-compat branch October 11, 2026 07:41
github-actions Bot pushed a commit that referenced this pull request Oct 11, 2026
Claude Code compatibility fixes from the 2026-10-10 research, plus the redraw nudge after a theme push (owner, 2026-10-11). #173 (FORCE_HYPERLINK) was withdrawn and closed as not planned.

Fixes #167
Fixes #168
Fixes #169
Fixes #170
Fixes #171
Fixes #172
Fixes #174
Fixes #175
Fixes #176
Fixes #177

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment