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/PRIVACY.md b/PRIVACY.md index 7e37ca7..fccfe2f 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -48,6 +48,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/README.md b/core/README.md index 1fef098..2b94ec7 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 @@ -306,7 +312,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/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.clipboard.test.ts b/core/main/ipc.clipboard.test.ts index 5218260..9afd02a 100644 --- a/core/main/ipc.clipboard.test.ts +++ b/core/main/ipc.clipboard.test.ts @@ -1,18 +1,26 @@ 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 + bell: (id: unknown) => void + 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 +34,16 @@ 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), + bell: (id) => listeners.get(CH.bell)?.({}, id), + written, + stop + } } describe('clipboard:write', () => { @@ -48,3 +63,43 @@ 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() + }) +}) + +// 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 132a1b8..dbd8797 100644 --- a/core/main/ipc.ts +++ b/core/main/ipc.ts @@ -1,6 +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' @@ -72,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 @@ -116,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 @@ -167,6 +181,25 @@ 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 + } + }) + + // 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/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/main/terminal.test.ts b/core/main/terminal.test.ts index 8bbe0c1..8ddde89 100644 --- a/core/main/terminal.test.ts +++ b/core/main/terminal.test.ts @@ -90,6 +90,25 @@ describe('ptyEnv', () => { expect(env.ANTHROPIC_API_KEY).toBe('k') }) + // #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", () => { + 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..21b8a74 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' + // 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/package.json b/core/package.json index 4b0c7b4..30861e7 100644 --- a/core/package.json +++ b/core/package.json @@ -1,6 +1,6 @@ { "name": "prism-term-core", - "version": "0.31.0", + "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/core/preload/api.ts b/core/preload/api.ts index 5c36aad..55ceae1 100644 --- a/core/preload/api.ts +++ b/core/preload/api.ts @@ -47,12 +47,14 @@ 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 /** * 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. @@ -66,6 +68,15 @@ 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 + /** 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 @@ -96,9 +107,12 @@ 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, + 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 1453323..3e89f08 100644 --- a/core/renderer/components/TerminalPanel.tsx +++ b/core/renderer/components/TerminalPanel.tsx @@ -1,10 +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, sanitizePaste, type PathShell } from '../lib/termPaste' +import { decidePaste, imagePasteKey, newlineKey, sanitizePaste, type PathShell } from '../lib/termPaste' +import { armAtStart, armOnPoll, armOnPrompt, armOnTitle, type ArmStep } from '../lib/agentArm' import { shellOfShellId } from '../../shared/help/shells' import { onResumingChange, @@ -34,9 +35,29 @@ 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, + bellGate, + colourQueryReplies, + decrqmReply, + dsrThemeReply, + groundMode, + modeParams, + nudgeWidths, + NUDGE_AFTER_MS, + NUDGE_HOLD_MS, + noteThemeMode, + themePush, + themeReports, + xtversionAsked, + xtversionReply, + 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, @@ -45,7 +66,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, @@ -54,6 +75,7 @@ import { type CaretPos } from '../lib/termCaretHold' import { cellFrom, cellText, type CellInfo } from '../lib/termCells' +import { bufferRowCells, isWebLink, osc8Target, Osc8Spans, parseOsc8, spanText, type SpanAnchor } from '../lib/termOsc8' import { onTermLookChange, termBaseFontPx, @@ -92,6 +114,11 @@ 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 + /** 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() @@ -184,7 +211,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 @@ -236,7 +268,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( @@ -291,6 +328,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 @@ -401,7 +451,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)] - const link = 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()) } } @@ -420,8 +474,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 @@ -619,7 +677,23 @@ 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. + // A label that shows another host's address opens what it shows + // (`osc8Target`, review 2026-10-11). + linkHandler: { + 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 + } }) const fit = new FitAddon() term.loadAddon(fit) @@ -644,7 +718,15 @@ 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. + // 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), + 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 // click opens it; the right one is the menu's (Open, Show in Explorer, Copy @@ -743,12 +825,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 }) @@ -761,12 +862,201 @@ 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 + // 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. + 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)) + // 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) + 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 + }) + // 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) 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 + // 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 + }) + // 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 + }) + // 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 // 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 => { @@ -805,11 +1095,12 @@ 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)) } + /** * 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. @@ -818,23 +1109,24 @@ 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(arm.kind)) } 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 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), - // A path asked about turned out to exist: paint it now. - onPathsFound(() => links.repaint()), - termApi().onTermAgent((forId, present) => { - if (forId === id) agentHere = present + 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) moveArm(armOnPoll(arm, present, kind)) }), termApi().onTermData((forId, data) => { if (forId === id) writeOutput(data) @@ -894,14 +1186,20 @@ 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) { - // Newline-without-submit, the continuation form Claude Code accepts - // everywhere. This is what /terminal-setup exists to configure; here it + 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 // 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(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 @@ -911,7 +1209,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() @@ -932,7 +1230,13 @@ function createSession(id: string, root: string, shellId: string | undefined): S return true }) - const session: Session = { term, fit, search, links, el, unsub, cwd: () => cwdNow } + 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: @@ -996,6 +1300,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 @@ -1014,6 +1335,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/core/renderer/host.ts b/core/renderer/host.ts index 2cf370d..f0c6a87 100644 --- a/core/renderer/host.ts +++ b/core/renderer/host.ts @@ -42,12 +42,22 @@ 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 * (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 + /** 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/agentArm.test.ts b/core/renderer/lib/agentArm.test.ts new file mode 100644 index 0000000..bc518ad --- /dev/null +++ b/core/renderer/lib/agentArm.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from 'vitest' +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', () => { + 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(titled(IDLE)).toMatchObject({ here: true, kind: 'claude' }) + }) + + it('is not armed by a bare braille spinner', () => { + expect(titled('⠙ npm')).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(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) + }) + + it('is disarmed by the prompt when only the title armed it', () => { + 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')).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) + }) + + 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 new file mode 100644 index 0000000..f93ab08 --- /dev/null +++ b/core/renderer/lib/agentArm.ts @@ -0,0 +1,96 @@ +import type { DetectedAgent } from '../../shared/types' +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`), 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. 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. 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 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 +} + +/** 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): ArmStep { + const named = titleArmsAgent(title) + 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): 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): 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/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/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/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/termLinkPaint.ts b/core/renderer/lib/termLinkPaint.ts index 44b45d2..ce1aaee 100644 --- a/core/renderer/lib/termLinkPaint.ts +++ b/core/renderer/lib/termLinkPaint.ts @@ -1,6 +1,18 @@ import type { IBufferLine, IDecoration, IDisposable, IMarker, Terminal } from '@xterm/xterm' +import { crumb } from './diag' import { rowsToReink } from './linkInkRows' +import { sliceRows, type RowPlan } from './linkScanPlan' import { findLinks } from './termLinks' +import { cellText } from './termCells' +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 @@ -18,6 +30,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 @@ -27,10 +45,28 @@ 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. */ 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 + /** 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 { @@ -46,28 +82,68 @@ 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 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 - 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 => { @@ -100,6 +176,16 @@ export function attachLinkPaint( rows.push(line) 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) + 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 // cells: an index into the string is not a column once a line holds one. @@ -116,24 +202,59 @@ 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) { + 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) + } + } + + /** + * 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 of the alternate screen. True when the - * row now holds ink. */ - const inkRow = (row: Element, ink: string): boolean => { + /** Ink the links in one drawn row (screen row `r`) of the alternate screen. + * True when the row now holds ink. */ + const inkRow = (row: Element, ink: string, r: number): boolean => { const text = row.textContent ?? '' - if (!/[\\/.]/.test(text)) return false - 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 false // Where each text node starts in the row's text. const nodes: Array<{ node: Text; at: number }> = [] @@ -172,8 +293,8 @@ export function attachLinkPaint( * 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) + const inkAndRemember = (row: Element, ink: string, r: number): void => { + if (inkRow(row, ink, r)) inked.add(row) else inked.delete(row) } @@ -186,7 +307,7 @@ export function attachLinkPaint( const ink = color() for (let r = start; r <= end; r += 1) { const row = rows[r] - if (row) inkAndRemember(row, ink) + if (row) inkAndRemember(row, ink, r) } } @@ -215,11 +336,77 @@ export function attachLinkPaint( ) if (!lost.length) return const ink = color() - for (const row of lost) inkAndRemember(row, ink) + const all = Array.from(box.children) + for (const row of lost) inkAndRemember(row, ink, all.indexOf(row)) }) 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 unwatchRows = (): void => { watch?.disconnect() watch = undefined @@ -232,21 +419,30 @@ export function attachLinkPaint( 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 => { @@ -254,6 +450,7 @@ export function attachLinkPaint( } const startOver = (): void => { + cancelBacklog() forgetFrom(0) finished?.dispose() finished = undefined @@ -262,6 +459,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. @@ -277,10 +512,12 @@ export function attachLinkPaint( return { repaint: startOver, + revisit, dispose: () => { dead = true unwatchRows() if (timer !== undefined) window.clearTimeout(timer) + cancelBacklog() subs.forEach((s) => s.dispose()) forgetFrom(0) finished?.dispose() 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/renderer/lib/termOsc8.test.ts b/core/renderer/lib/termOsc8.test.ts new file mode 100644 index 0000000..3b665ae --- /dev/null +++ b/core/renderer/lib/termOsc8.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, it } from 'vitest' +import type { CellInfo } from './termCells' +import { + isWebLink, + osc8Target, + Osc8Spans, + parseOsc8, + piecesOverlap, + 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) } + 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 }) + }) +}) + +// 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 new file mode 100644 index 0000000..0552115 --- /dev/null +++ b/core/renderer/lib/termOsc8.ts @@ -0,0 +1,197 @@ +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 + * (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: + * 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) + +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 { + 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 + }) + } +} + +/** 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): 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 + if (to > from) out.push({ line: span.anchor.line + r, x: from, width: to - from }) + } + 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 } { + 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/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..c322f0b 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,40 @@ 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. 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' +} + +/** + * 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/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/core/renderer/lib/termReplies.test.ts b/core/renderer/lib/termReplies.test.ts new file mode 100644 index 0000000..5bbdb87 --- /dev/null +++ b/core/renderer/lib/termReplies.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from 'vitest' +import { + BELL_GAP_MS, + THEME_REPORTS_OFF, + bellGate, + colourQueryReplies, + decrqmReply, + dsrThemeReply, + groundMode, + modeParams, + nudgeWidths, + noteThemeMode, + oscColour, + themePush, + themeReports, + xtversionAsked, + xtversionReply +} 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() + }) +}) + +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) + }) +}) + +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) + }) +}) + +// 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 new file mode 100644 index 0000000..277c298 --- /dev/null +++ b/core/renderer/lib/termReplies.ts @@ -0,0 +1,162 @@ +import { opaque } from './colour' +import { luminance } from './termAnsi' + +// 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 +// 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 } +} + +/** + * 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 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 } { + 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/core/shared/channels.ts b/core/shared/channels.ts index 7b4311a..6624915 100644 --- a/core/shared/channels.ts +++ b/core/shared/channels.ts @@ -14,9 +14,15 @@ 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', + /** 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/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/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/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 ea2bfb4..8b7cfbe 100644 --- a/docs/regression-rules.md +++ b/docs/regression-rules.md @@ -24,8 +24,17 @@ 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 (#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 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 @@ -161,6 +170,39 @@ 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) + 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, 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 + 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. 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`. 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. +- **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 @@ -322,7 +364,24 @@ 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. **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 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 @@ -334,7 +393,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/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..2294d23 --- /dev/null +++ b/docs/superpowers/specs/2026-10-10-claude-code-compat-design.md @@ -0,0 +1,567 @@ +# 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. + +### 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 + +`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 + +> **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 +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; 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). 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 + `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. diff --git a/docs/two-apps.md b/docs/two-apps.md index e9f4a32..c879041 100644 --- a/docs/two-apps.md +++ b/docs/two-apps.md @@ -528,7 +528,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/package-lock.json b/package-lock.json index 3c0521f..166c6d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "prism-terminal", - "version": "0.37.0", + "version": "0.38.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "prism-terminal", - "version": "0.37.0", + "version": "0.38.0", "license": "MIT", "dependencies": { "@xterm/addon-fit": "^0.11.0", diff --git a/package.json b/package.json index c47d0d9..be63a39 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "prism-terminal", "productName": "Prism Terminal", - "version": "0.37.0", + "version": "0.38.0", "description": "A tabbed Windows terminal for AI CLIs.", "main": "./out/main/index.js", "author": "Max", 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/src/renderer/src/lib/chromeTheme.test.ts b/src/renderer/src/lib/chromeTheme.test.ts index 6c97695..2a7868f 100644 --- a/src/renderer/src/lib/chromeTheme.test.ts +++ b/src/renderer/src/lib/chromeTheme.test.ts @@ -4,6 +4,7 @@ import { TERM_PRESETS, presetAccent, resolveTermTheme, type TermTheme } from '@c import { SEE_THROUGH_ALPHA, seeThroughAlpha } from '@core/renderer/lib/seeThrough' 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', () => { @@ -28,6 +29,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 8d89f63..5efb54e 100644 --- a/tools/e2e/run.mjs +++ b/tools/e2e/run.mjs @@ -2469,6 +2469,476 @@ 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) + } + }, + + // 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')", + '})', + // 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') + ) + 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. + 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') + 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() + 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") + + 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) + } + }, + + /** + * 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) + } + }, + + /** + * 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 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') + + // #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 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 + .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 + * 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) + } + }, + + /** + * 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] }) @@ -2585,6 +3055,121 @@ 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 { + // #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--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')}`) + 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: @@ -6205,7 +6790,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