Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
17dd619
fix(core): a hovered link on the alternate screen keeps its colour (#…
Maxaubert Oct 10, 2026
a10c86a
docs: spec and plan for the Claude Code compatibility fixes (#167-#177)
Maxaubert Oct 10, 2026
b1af0ab
chore: bump core 0.32.0 and app 0.38.0 (#167-#177)
Maxaubert Oct 10, 2026
41e710b
fix(core): a found path repaints only the tab and lines that asked (#…
Maxaubert Oct 10, 2026
731bb9a
fix(core): tell programs the real ground, and push theme changes (#16…
Maxaubert Oct 10, 2026
7fded94
fix(core): OSC 8 links open on a left click and wear the link colour …
Maxaubert Oct 10, 2026
884ca47
feat(core): advertise hyperlinks to programs in a tab (#173)
Maxaubert Oct 10, 2026
cd4cfb3
feat(core): answer XTVERSION so Claude can use synchronized output (#…
Maxaubert Oct 10, 2026
bc3b162
feat(core): write-only OSC 52 copies through main (#176)
Maxaubert Oct 10, 2026
624af5f
feat: flash the taskbar on a bell while the window is unfocused (#177)
Maxaubert Oct 10, 2026
308b270
fix(core): refit once the renderer resumes after a hidden resize (#174)
Maxaubert Oct 10, 2026
7cd4f6a
fix(core): the agent's own image-paste key, and Shift+Enter armed by …
Maxaubert Oct 10, 2026
efdb9b4
fix(core): review fixes for the Claude Code compat branch
Maxaubert Oct 10, 2026
40a32cb
fix(core): Shift+Enter sends only the newline, and a first poll verdi…
Maxaubert Oct 10, 2026
1e2d9d4
perf(core): the link-ink row watch re-inks only rows that lost ink (#…
Maxaubert Oct 11, 2026
909c434
Merge origin/fix/163-link-ink into fix/cc-compat
Maxaubert Oct 11, 2026
34501aa
fix(core): the prompt takes the agent's keys back, a title-armed agen…
Maxaubert Oct 11, 2026
4af2a6a
docs: a live theme switch repaints only Claude's live frame (measured)
Maxaubert Oct 11, 2026
509e983
feat(core): nudge a program to redraw after a theme push (#172)
Maxaubert Oct 11, 2026
cc4fa02
Merge remote-tracking branch 'origin/main' into fix/cc-compat
Maxaubert Oct 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
10 changes: 9 additions & 1 deletion core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<version>`. Tags are
`core-v*` for the core and `v*` for the app, since this repo is both;
Expand Down
36 changes: 36 additions & 0 deletions core/main/agentPoll.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
])
})
})
44 changes: 35 additions & 9 deletions core/main/agentPoll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, boolean>()
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<string>()
// `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
Expand All @@ -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()) {
Expand All @@ -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))
Expand All @@ -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)
Expand Down Expand Up @@ -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)
}
65 changes: 60 additions & 5 deletions core/main/ipc.clipboard.test.ts
Original file line number Diff line number Diff line change
@@ -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<TermIpcDeps> = {}): {
write: (text: unknown) => unknown
termWrite: (text: unknown) => unknown
bell: (id: unknown) => void
written: string[]
stop: () => void
} {
const handlers = new Map<string, (e: unknown, ...args: unknown[]) => unknown>()
const listeners = new Map<string, (e: unknown, ...args: unknown[]) => 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: {
Expand All @@ -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', () => {
Expand All @@ -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()
})
})
35 changes: 34 additions & 1 deletion core/main/ipc.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions core/main/ipcTiming.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})
2 changes: 1 addition & 1 deletion core/main/ipcTiming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export const LONG_WAIT: ReadonlySet<string> = 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<string>([CH.input, CH.clipboardWrite, DCH.transcribe])
const OPAQUE = new Set<string>([CH.input, CH.clipboardWrite, CH.clipboardTerm, DCH.transcribe])
const isOwn = (ch: string): boolean => ch.startsWith('diag:')
const RECENT_MAX = 32

Expand Down
19 changes: 19 additions & 0 deletions core/main/terminal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
Loading
Loading