diff --git a/PRIVACY.md b/PRIVACY.md index fccfe2f..808d7c4 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -36,6 +36,11 @@ Everything else happens **only when you ask for it**: own machine (127.0.0.1, or straight into the engine's input for the Parakeet model) and is never written to disk. - **Links:** clicking a link printed in the terminal opens it in your browser. +- **Sonara** (the text-to-speech reader for Claude Code, if you run it): when Claude Code's + question widget in a tab changes question, Prism Terminal tells the Sonara running on your own + PC (127.0.0.1, at the address and with the key in Sonara's own `runtime.json`) the question + showing and a random id of that tab, so Sonara can read the question you moved to. Without + Sonara nothing is sent. Each tab's shell carries that id as `PRISM_TAB_ID`. - **What you run:** the terminal runs your own shell, and whatever you run in it reaches the network as you tell it to. diff --git a/core/README.md b/core/README.md index dd921b5..3f5bc6f 100644 --- a/core/README.md +++ b/core/README.md @@ -262,6 +262,18 @@ the rainbow across the whole tab (`rainbowInk` picks its name's ink). (`lib/switchKnob.ts`: the theme's black or white, flipped only under 2:1 on the track), else `--p-on-accent` as before. Prism has not defined it. +**THE QUESTION FOLLOWER (Sonara #323, core 0.41.0)** tells Sonara, the +text-to-speech reader for Claude Code, which question of an AskUserQuestion +the user is on, which Claude Code itself tells no plugin. +`renderer/questionWidget.ts` reads the question off the visible rows (pure, +tested on measured screens), `renderer/questionWatch.ts` watches a session +after output settles and reports a change once, `term:question` carries it, +and `main/sonaraLink.ts` posts it to the Sonara on 127.0.0.1 named by its +`runtime.json`, naming the tab by its shell's `PRISM_TAB_ID` (`ptyEnv`, one +random token per shell, warm or not; Sonara's hook reads the same variable). +No Sonara: nothing is sent. `termQuestion` is optional on `TermApi`; a host on +`createTermApi` and `registerTermIpc` (both apps) gets it with nothing to add. + ## Rules for code in here (lint-enforced in Prism Terminal's `eslint.config.js`) - **Relative imports only.** No `@shared` / `@renderer` / `@core` aliases: a diff --git a/core/main/ipc.ts b/core/main/ipc.ts index 7d00c5e..286d7c2 100644 --- a/core/main/ipc.ts +++ b/core/main/ipc.ts @@ -3,7 +3,9 @@ import { OSC52_MAX } from '../shared/termLimits' import { validResume } from './agentResume' import { pollAgentAgain, pollAgentsNow, pollAgentsSoon, startAgentPoll } from './agentPoll' import { detectShells } from './shells' -import { cdTerm, killTerm, prewarmShell, resizeTerm, spawnTerm, writeTerm, type ClaudePluginEnv } from './terminal' +import { cdTerm, killTerm, prewarmShell, resizeTerm, spawnTerm, tabToken, writeTerm, type ClaudePluginEnv } from './terminal' +import { postQuestionShown } from './sonaraLink' +import { asShownQuestion } from '../shared/questionShown' import { openTermPath, pathKinds, PATHS_MAX, type PathOpeners } from './termPathOpen' import { isWebUrl } from '../shared/webUrl' @@ -132,6 +134,14 @@ export function registerTermIpc(deps: TermIpcDeps): () => void { if (typeof id === 'string') pollAgentAgain(id) }) + // Claude Code's question widget changed question in a tab (Sonara #323): + // Sonara, if it runs, reads the question the user is on. Quiet on failure. + ipcMain.on(CH.question, (_e: unknown, id: unknown, shown: unknown) => { + const q = asShownQuestion(shown) + const token = typeof id === 'string' ? tabToken(id) : undefined + if (q && token) void postQuestionShown(token, q) + }) + ipcMain.on(CH.prewarm, (_e: unknown, cwd: unknown, shellId: unknown, hooks: unknown) => { if (typeof cwd !== 'string') return void deps.mayPrewarm(cwd).then((ok) => { diff --git a/core/main/sonaraLink.test.ts b/core/main/sonaraLink.test.ts new file mode 100644 index 0000000..13eb1f5 --- /dev/null +++ b/core/main/sonaraLink.test.ts @@ -0,0 +1,62 @@ +import { mkdtempSync, writeFileSync } from 'node:fs' +import { createServer, type IncomingMessage } from 'node:http' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { postQuestionShown, sonaraHome } from './sonaraLink' + +/** A stand-in Sonara: records each request and answers 200. */ +async function fakeSonara(): Promise<{ port: number; got: Array<{ req: IncomingMessage; body: string }>; close: () => void }> { + const got: Array<{ req: IncomingMessage; body: string }> = [] + const server = createServer((req, res) => { + let body = '' + req.on('data', (d) => (body += d)) + req.on('end', () => { + got.push({ req, body }) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end('{"ok":true,"moved":true}') + }) + }) + await new Promise((r) => server.listen(0, '127.0.0.1', r)) + const port = (server.address() as { port: number }).port + return { port, got, close: () => server.close() } +} + +let close: (() => void) | undefined +afterEach(() => close?.()) + +describe('sonaraHome', () => { + it('is SONARA_HOME, else LOCALAPPDATA\\Sonara, else none', () => { + expect(sonaraHome({ SONARA_HOME: 'C:\\s', LOCALAPPDATA: 'C:\\l' })).toBe('C:\\s') + expect(sonaraHome({ LOCALAPPDATA: 'C:\\l' })).toBe(join('C:\\l', 'Sonara')) + expect(sonaraHome({})).toBeNull() + }) +}) + +describe('postQuestionShown', () => { + it('posts the tab and the question to the Sonara in runtime.json', async () => { + const s = await fakeSonara() + close = s.close + const home = mkdtempSync(join(tmpdir(), 'pt-sonara-')) + writeFileSync(join(home, 'runtime.json'), JSON.stringify({ http_port: s.port, token: 'secret', port: 1 })) + expect(await postQuestionShown('tab-1', { text: 'Pick a size?' }, { SONARA_HOME: home })).toBe(true) + expect(await postQuestionShown('tab-1', { review: true }, { SONARA_HOME: home })).toBe(true) + expect(s.got.map((g) => g.req.url)).toEqual(['/v1/question_shown', '/v1/question_shown']) + expect(s.got[0].req.headers.authorization).toBe('Bearer secret') + expect(JSON.parse(s.got[0].body)).toEqual({ host_tab: 'tab-1', text: 'Pick a size?' }) + expect(JSON.parse(s.got[1].body)).toEqual({ host_tab: 'tab-1', review: true }) + }) + + it('does nothing without Sonara, and fails quietly when it is gone', async () => { + const home = mkdtempSync(join(tmpdir(), 'pt-sonara-')) + expect(await postQuestionShown('tab-1', { text: 'Q?' }, { SONARA_HOME: home })).toBe(false) + writeFileSync(join(home, 'runtime.json'), '{not json') + expect(await postQuestionShown('tab-1', { text: 'Q?' }, { SONARA_HOME: home })).toBe(false) + // A port nobody listens on. + const s = await fakeSonara() + s.close() + writeFileSync(join(home, 'runtime.json'), JSON.stringify({ http_port: s.port, token: 't' })) + expect(await postQuestionShown('tab-1', { text: 'Q?' }, { SONARA_HOME: home })).toBe(false) + expect(await postQuestionShown('', { text: 'Q?' }, { SONARA_HOME: home })).toBe(false) + }) +}) diff --git a/core/main/sonaraLink.ts b/core/main/sonaraLink.ts new file mode 100644 index 0000000..7de48b3 --- /dev/null +++ b/core/main/sonaraLink.ts @@ -0,0 +1,78 @@ +import { readFile } from 'node:fs/promises' +import { request } from 'node:http' +import { join } from 'node:path' +import type { ShownQuestion } from '../shared/questionShown' + +/** + * SONARA, THE TEXT-TO-SPEECH READER (Sonara #323). Claude Code tells its + * plugins nothing while the user moves between the questions of one + * AskUserQuestion, so Sonara cannot read the question the user is on. The + * tab can: the renderer reads it off the screen, and this hands it to a + * Sonara running on this PC, naming the tab by the `PRISM_TAB_ID` its shell + * carries (Sonara's own hook tagged the Claude session with the same value). + * + * Only to 127.0.0.1, only when Sonara wrote its `runtime.json` (its home is + * `SONARA_HOME`, else `%LOCALAPPDATA%\Sonara`). No Sonara: nothing is sent + * and nothing fails. Every failure is quiet; the caller gets false. + */ + +/** Sonara's home folder, or null when neither variable says where. */ +export function sonaraHome(env: NodeJS.ProcessEnv = process.env): string | null { + if (env.SONARA_HOME) return env.SONARA_HOME + return env.LOCALAPPDATA ? join(env.LOCALAPPDATA, 'Sonara') : null +} + +interface Runtime { + httpPort: number + token: string +} + +async function runtime(home: string): Promise { + try { + const r = JSON.parse(await readFile(join(home, 'runtime.json'), 'utf8')) as Record + const port = r.http_port + const token = r.token + if (typeof port !== 'number' || !Number.isInteger(port) || port <= 0 || port > 65535) return null + if (typeof token !== 'string' || !token) return null + return { httpPort: port, token } + } catch { + return null + } +} + +/** Tell Sonara what tab `tabToken` shows. True when Sonara took it. */ +export async function postQuestionShown( + tabToken: string, + shown: ShownQuestion, + env: NodeJS.ProcessEnv = process.env, + timeoutMs = 1500 +): Promise { + const home = sonaraHome(env) + if (!home || !tabToken) return false + const rt = await runtime(home) + if (!rt) return false + const body = JSON.stringify({ host_tab: tabToken, ...shown }) + return new Promise((resolve) => { + const req = request( + { + host: '127.0.0.1', + port: rt.httpPort, + path: '/v1/question_shown', + method: 'POST', + headers: { + Authorization: `Bearer ${rt.token}`, + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body) + }, + timeout: timeoutMs + }, + (res) => { + res.resume() + resolve(res.statusCode === 200) + } + ) + req.on('timeout', () => req.destroy()) + req.on('error', () => resolve(false)) + req.end(body) + }) +} diff --git a/core/main/terminal.test.ts b/core/main/terminal.test.ts index fdaa787..73beaa0 100644 --- a/core/main/terminal.test.ts +++ b/core/main/terminal.test.ts @@ -34,6 +34,14 @@ describe('OutputBatcher', () => { }) describe('ptyEnv', () => { + it('names the tab for Sonara and never keeps an inherited one (#323)', () => { + expect(ptyEnv({ PRISM_TAB_ID: 'old', Prism_Tab_Id: 'older' }, 'pwsh', undefined, undefined, 'tok-1')).toMatchObject({ + PRISM_TAB_ID: 'tok-1' + }) + const env = ptyEnv({ PRISM_TAB_ID: 'old' }) + expect(Object.keys(env).some((k) => k.toUpperCase() === 'PRISM_TAB_ID')).toBe(false) + }) + it('answers for what the panel can display, not for its launcher', () => { const env = ptyEnv({ TERM: 'dumb', COLORTERM: undefined, PATH: 'C:\bin' }) expect(env.TERM).toBe('xterm-256color') diff --git a/core/main/terminal.ts b/core/main/terminal.ts index 3360678..65b9853 100644 --- a/core/main/terminal.ts +++ b/core/main/terminal.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto' import type { IPty } from 'node-pty' import { cdCommand } from '../shared/termCwd' import { detectShells, shellById } from './shells' @@ -67,6 +68,10 @@ interface Session { subs: Array<{ dispose(): void }> /** Which shell this is, for the one line Prism may write into it (#99). */ defId: string + /** The shell's `PRISM_TAB_ID` (Sonara #323): Sonara's hook tags the + * Claude session it runs in with it, and the question follower names the + * tab to Sonara by it. One per shell, warm or not. */ + tabToken: string } /** @@ -162,7 +167,8 @@ export function ptyEnv( from: NodeJS.ProcessEnv, shellId?: string, plugin?: ClaudePluginEnv, - isOurs?: (d: string) => boolean + isOurs?: (d: string) => boolean, + tabToken?: string ): Record { const env: Record = {} let prompt: string | undefined @@ -178,6 +184,11 @@ export function ptyEnv( } env.TERM = PTY_OPTS.name env.COLORTERM = 'truecolor' + // The tab, for Sonara (#323): its Claude hook reads PRISM_TAB_ID into the + // session's host tab, and the question follower names the tab by it. A + // value inherited from a Prism the app was started in is never kept. + for (const k of Object.keys(env)) if (k.toUpperCase() === 'PRISM_TAB_ID') delete env[k] + if (tabToken) env.PRISM_TAB_ID = tabToken // 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 @@ -219,6 +230,11 @@ export function cdTerm(id: string, path: string): boolean { const sessions = new Map() +/** The `PRISM_TAB_ID` of tab `id`'s shell (Sonara #323), if it has one. */ +export function tabToken(id: string): string | undefined { + return sessions.get(id)?.tabToken +} + /** * Warm shells, one per recently-active root (two at most). Opening a terminal * used to pay the whole bill at the click - chunk, pty, pwsh startup, the @@ -236,6 +252,8 @@ interface WarmShell { buf: string sub: { dispose(): void } exited: boolean + /** Its `PRISM_TAB_ID`, carried to the tab that adopts it. */ + tabToken: string } const warm = new Map() const rootKey = (root: string): string => root.toLowerCase() @@ -328,14 +346,23 @@ export async function prewarmShell( try { const pty = await import('node-pty') const size = desiredSize.get('') ?? { cols: 80, rows: 24 } + const tabToken = randomUUID() const p = pty.spawn(def.exe, def.args, { ...PTY_OPTS, cols: size.cols, rows: size.rows, cwd: root, - env: ptyEnv(process.env, def.id, plugin, isOurPlugin) + env: ptyEnv(process.env, def.id, plugin, isOurPlugin, tabToken) }) - const w: WarmShell = { pty: p, defId: def.id, plugin: pluginKey(plugin), buf: '', sub: { dispose: () => {} }, exited: false } + const w: WarmShell = { + pty: p, + defId: def.id, + plugin: pluginKey(plugin), + buf: '', + sub: { dispose: () => {} }, + exited: false, + tabToken + } w.sub = p.onData((d) => { // The banner and prompt, kept for replay. Capped: a warm shell should // be quiet, and a runaway one is not worth adopting anyway. @@ -501,7 +528,7 @@ async function spawnPending( send('term:exit', id) }) ] - sessions.set(id, { pty: w.pty, batcher, subs, defId: w.defId }) + sessions.set(id, { pty: w.pty, batcher, subs, defId: w.defId, tabToken: w.tabToken }) shellCrumb('shell-spawn', { id, pid: w.pty.pid, shell: def.id, warm: true }) const want = desiredSize.get(id) if (want) resizeTerm(id, want.cols, want.rows) @@ -512,12 +539,13 @@ async function spawnPending( const pty = await import('node-pty') const size = desiredSize.get(id) ?? { cols: 80, rows: 24 } const launch = resume ? withResume(def, resume) : def + const tabToken = randomUUID() const p = pty.spawn(launch.exe, launch.args, { ...PTY_OPTS, cols: size.cols, rows: size.rows, cwd: root, - env: { ...ptyEnv(process.env, def.id, plugin, isOurPlugin), ...('env' in launch ? launch.env : {}) } + env: { ...ptyEnv(process.env, def.id, plugin, isOurPlugin, tabToken), ...('env' in launch ? launch.env : {}) } }) // Closed while node-pty loaded: the tab is gone, so is this shell. if (killedWhilePending.has(id)) { @@ -537,7 +565,7 @@ async function spawnPending( send('term:exit', id) }) ] - sessions.set(id, { pty: p, batcher, subs, defId: def.id }) + sessions.set(id, { pty: p, batcher, subs, defId: def.id, tabToken }) shellCrumb('shell-spawn', { id, pid: p.pid, shell: def.id, cwd: root, resume: !!resume, ms: Math.round(performance.now() - t0) }) return true } catch (err) { diff --git a/core/package.json b/core/package.json index 54b078b..075d114 100644 --- a/core/package.json +++ b/core/package.json @@ -1,6 +1,6 @@ { "name": "prism-term-core", - "version": "0.40.12", + "version": "0.41.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 5403f9b..96e0b44 100644 --- a/core/preload/api.ts +++ b/core/preload/api.ts @@ -1,5 +1,6 @@ import type { DetectedAgent, ShellDef } from '../shared/types' import { CH } from '../shared/channels' +import type { ShownQuestion } from '../shared/questionShown' import { isWebUrl } from '../shared/webUrl' /** @@ -50,6 +51,8 @@ export interface TermPreloadApi { termAgentLook(): void /** Ask the process poll for this session's next answer, changed or not. */ termAgentAgain(id: string): void + /** The question Claude Code's widget shows in tab `id` (Sonara #323). */ + termQuestion(id: string, shown: ShownQuestion): void /** The shell ended by itself (`exit`); a shell main was told to kill says nothing. */ onTermExit(cb: (id: string) => void): () => void /** @@ -119,6 +122,7 @@ export function createTermApi(ipc: IpcRendererLike): TermPreloadApi { onTermAgent: (cb) => on(CH.agent, cb), termAgentLook: () => ipc.send(CH.agentLook), termAgentAgain: (id) => ipc.send(CH.agentAgain, id), + termQuestion: (id, shown) => ipc.send(CH.question, id, shown), onTermExit: (cb) => on(CH.exit, cb), readClipboard: () => ipc.sendSync(CH.clipboardRead) as ClipboardRead, readClipboardAsync: () => ipc.invoke(CH.clipboardReadAsync) as Promise, diff --git a/core/renderer/components/TerminalPanel.tsx b/core/renderer/components/TerminalPanel.tsx index c8349e8..8b71ffe 100644 --- a/core/renderer/components/TerminalPanel.tsx +++ b/core/renderer/components/TerminalPanel.tsx @@ -77,6 +77,7 @@ import { takeResume } from '../lib/termActivity' import { isKey, termKeyAction } from '../lib/termKeys' +import { watchQuestions } from '../questionWatch' import '@xterm/xterm/css/xterm.css' // The terminal surface. This module is a lazy chunk (xterm is ~350KB the @@ -1041,6 +1042,9 @@ function createSession(id: string, root: string, shellId: string | undefined): S // 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))) + // Claude Code's question widget: the question the user moved to is told + // to Sonara, the text-to-speech reader, which reads it (Sonara #323). + const questions = watchQuestions(term, (shown) => termApi().termQuestion?.(id, shown)) // "COPIED" FOR A PROGRAM'S OWN SELECTION (lib/appCopyNotice): Claude // Code's fullscreen view copies on the mouse's release itself, past the // terminal; a selecting press is followed by a look at the clipboard. @@ -1068,6 +1072,7 @@ function createSession(id: string, root: string, shellId: string | undefined): S el.addEventListener('mousedown', copyDown, true) window.addEventListener('mouseup', copyUp, true) const unsub = [ + () => questions.dispose(), () => { el.removeEventListener('mousedown', copyDown, true) window.removeEventListener('mouseup', copyUp, true) diff --git a/core/renderer/host.ts b/core/renderer/host.ts index 3dd96c5..e4ba8a0 100644 --- a/core/renderer/host.ts +++ b/core/renderer/host.ts @@ -1,5 +1,6 @@ import type { DetectedAgent, ShellDef } from '../shared/types' import type { DictationApi } from '../preload/dictationApi' +import type { ShownQuestion } from '../shared/questionShown' /** * THE HOST SEAM (2026-09-19, #15). @@ -46,6 +47,9 @@ export interface TermApi { * 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 + /** The question Claude Code's widget shows in tab `id`, for Sonara + * (#323). Optional: a host bridge without it simply tells no one. */ + termQuestion?(id: string, shown: ShownQuestion): void onTermExit(cb: (id: string) => void): () => void readClipboard(): ClipboardRead /** The same, without the page waiting on main (2026-10-15: a paste in a diff --git a/core/renderer/questionWatch.test.ts b/core/renderer/questionWatch.test.ts new file mode 100644 index 0000000..9b9c251 --- /dev/null +++ b/core/renderer/questionWatch.test.ts @@ -0,0 +1,77 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { watchQuestions, type WatchedTerm } from './questionWatch' +import screens from './questionWidget.screens.json' + +const at = (name: string): string[] => (screens as Record)[name] + +/** A terminal showing `rows`; `write` swaps the screen and fires the parse event. */ +function fakeTerm(): WatchedTerm & { write(rows: string[]): void } { + let screen: string[] = [] + const subs = new Set<() => void>() + return { + rows: 40, + buffer: { + active: { + viewportY: 0, + getLine: (y) => ({ translateToString: () => screen[y] ?? '' }) + } + }, + onWriteParsed: (cb) => { + subs.add(cb) + return { dispose: () => subs.delete(cb) } + }, + write(rows) { + screen = rows + for (const cb of subs) cb() + } + } +} + +beforeEach(() => vi.useFakeTimers()) +afterEach(() => vi.useRealTimers()) + +describe('watchQuestions', () => { + it('reports each change of question once, after output settles', () => { + const term = fakeTerm() + const got: unknown[] = [] + watchQuestions(term, (s) => got.push(s), 150) + term.write(at('run2-01-question-1')) + term.write(at('run2-01-question-1')) + expect(got).toEqual([]) + vi.advanceTimersByTime(150) + expect(got).toEqual([{ text: 'Pick a colour?' }]) + term.write(at('run2-01-question-1')) + vi.advanceTimersByTime(150) + expect(got).toHaveLength(1) + term.write(at('run2-02-right')) + vi.advanceTimersByTime(150) + term.write(at('run2-08-enter-answer')) + vi.advanceTimersByTime(150) + expect(got).toEqual([{ text: 'Pick a colour?' }, { text: 'Pick a size?' }, { review: true }]) + }) + + it('reports the same question again after the widget went away', () => { + const term = fakeTerm() + const got: unknown[] = [] + watchQuestions(term, (s) => got.push(s), 150) + term.write(at('run2-02-right')) + vi.advanceTimersByTime(150) + term.write(['PS C:\\> ']) + vi.advanceTimersByTime(150) + term.write(at('run2-02-right')) + vi.advanceTimersByTime(150) + expect(got).toEqual([{ text: 'Pick a size?' }, { text: 'Pick a size?' }]) + }) + + it('stops on dispose', () => { + const term = fakeTerm() + const got: unknown[] = [] + const w = watchQuestions(term, (s) => got.push(s), 150) + term.write(at('run2-02-right')) + w.dispose() + vi.advanceTimersByTime(500) + term.write(at('run2-01-question-1')) + vi.advanceTimersByTime(500) + expect(got).toEqual([]) + }) +}) diff --git a/core/renderer/questionWatch.ts b/core/renderer/questionWatch.ts new file mode 100644 index 0000000..74f3cb6 --- /dev/null +++ b/core/renderer/questionWatch.ts @@ -0,0 +1,48 @@ +import { extractQuestion, sameQuestion, type ShownQuestion } from './questionWidget' + +/** + * WATCHES ONE TAB FOR CLAUDE CODE'S QUESTION WIDGET (Sonara #323) and says + * when the question showing changes: after output settles (`delayMs`), the + * visible rows are read and the widget's question extracted. A change is + * reported once; the widget gone resets, so the same question shown again + * later is reported again. Cheap: one pass over the visible rows, only after + * output, never on a timer of its own. + */ + +/** The slice of an xterm `Terminal` this reads. */ +export interface WatchedTerm { + rows: number + buffer: { active: { viewportY: number; getLine(y: number): { translateToString(trim?: boolean): string } | undefined } } + onWriteParsed(cb: () => void): { dispose(): void } +} + +export function watchQuestions( + term: WatchedTerm, + report: (shown: ShownQuestion) => void, + delayMs = 150 +): { dispose(): void } { + let last: ShownQuestion | null = null + let timer: ReturnType | undefined + const look = (): void => { + timer = undefined + const b = term.buffer.active + const rows: string[] = [] + for (let y = b.viewportY; y < b.viewportY + term.rows; y++) { + rows.push(b.getLine(y)?.translateToString(true) ?? '') + } + const now = extractQuestion(rows) + if (sameQuestion(now, last)) return + last = now + if (now) report(now) + } + const sub = term.onWriteParsed(() => { + if (timer) clearTimeout(timer) + timer = setTimeout(look, delayMs) + }) + return { + dispose: () => { + if (timer) clearTimeout(timer) + sub.dispose() + } + } +} diff --git a/core/renderer/questionWidget.screens.json b/core/renderer/questionWidget.screens.json new file mode 100644 index 0000000..d083622 --- /dev/null +++ b/core/renderer/questionWidget.screens.json @@ -0,0 +1,236 @@ +{ + "run2-01-question-1": [ + "", + " ▐▛███▛█ Claude Code v2.1.301", + "▝▜██████▀ Haiku 5.5 · Claude Max", + " ▝▝ ▝▝ ~\\AppData\\Local\\Temp\\claude\\C--Users-Admin-Documents-Claude-Github-Sonara\\45b9fa57-2594-4a75-bf34-… · /rc", + "", + "", + "❯ This is a UI test. Call the AskUserQuestion tool once with exactly three questions: 'Pick a colour?' (options Red,", + " Blue), 'Pick a size?' (options Small, Large), 'Pick a shape?' (options Circle, Square). Do nothing else.", + "────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────", + "← ☐ Colour ☐ Size ☐ Shape ✔ Submit →", + "", + "Pick a colour?", + "", + "❯ 1. Red", + " Red", + " 2. Blue", + " Blue", + " 3. Type something.", + "────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────", + " 4. Chat about this", + "", + "Enter to select · Tab/Arrow keys to navigate · Esc to cancel" + ], + "run2-02-right": [ + "", + " ▐▛███▛█ Claude Code v2.1.301", + "▝▜██████▀ Haiku 5.5 · Claude Max", + " ▝▝ ▝▝ ~\\AppData\\Local\\Temp\\claude\\C--Users-Admin-Documents-Claude-Github-Sonara\\45b9fa57-2594-4a75-bf34-… · /rc", + "", + "", + "❯ This is a UI test. Call the AskUserQuestion tool once with exactly three questions: 'Pick a colour?' (options Red,", + " Blue), 'Pick a size?' (options Small, Large), 'Pick a shape?' (options Circle, Square). Do nothing else.", + "────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────", + "← ☐ Colour ☐ Size ☐ Shape ✔ Submit →", + "", + "Pick a size?", + "", + "❯ 1. Small", + " Small", + " 2. Large", + " Large", + " 3. Type something.", + "────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────", + " 4. Chat about this", + "", + "Enter to select · Tab/Arrow keys to navigate · Esc to cancel" + ], + "run2-07-enter-answer": [ + "", + " ▐▛███▛█ Claude Code v2.1.301", + "▝▜██████▀ Haiku 5.5 · Claude Max", + " ▝▝ ▝▝ ~\\AppData\\Local\\Temp\\claude\\C--Users-Admin-Documents-Claude-Github-Sonara\\45b9fa57-2594-4a75-bf34-… · /rc", + "", + "", + "❯ This is a UI test. Call the AskUserQuestion tool once with exactly three questions: 'Pick a colour?' (options Red,", + " Blue), 'Pick a size?' (options Small, Large), 'Pick a shape?' (options Circle, Square). Do nothing else.", + "────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────", + "← ☐ Colour ☒ Size ☐ Shape ✔ Submit →", + "", + "Pick a shape?", + "", + "❯ 1. Circle", + " Circle", + " 2. Square", + " Square", + " 3. Type something.", + "────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────", + " 4. Chat about this", + "", + "Enter to select · Tab/Arrow keys to navigate · Esc to cancel" + ], + "run2-08-enter-answer": [ + "", + " ▐▛███▛█ Claude Code v2.1.301", + "▝▜██████▀ Haiku 5.5 · Claude Max", + " ▝▝ ▝▝ ~\\AppData\\Local\\Temp\\claude\\C--Users-Admin-Documents-Claude-Github-Sonara\\45b9fa57-2594-4a75-bf34-… · /rc", + "", + "", + "❯ This is a UI test. Call the AskUserQuestion tool once with exactly three questions: 'Pick a colour?' (options Red,", + " Blue), 'Pick a size?' (options Small, Large), 'Pick a shape?' (options Circle, Square). Do nothing else.", + "", + "────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────", + "← ☐ Colour ☒ Size ☒ Shape ✔ Submit →", + "", + "Review your answers", + "", + "⚠ You have not answered all questions", + "", + " ● Pick a size?", + " → Small", + " ● Pick a shape?", + " → Circle", + "", + "Ready to submit your answers?", + "", + "❯ 1. Submit answers", + " 2. Cancel" + ], + "run3-01-question-1": [ + "", + " ▐▛███▛█ Claude Code v2.1.301", + "▝▜██████▀ Haiku 5.5 · Claude Max", + " ▝▝ ▝▝ ~\\AppData\\Local\\Temp\\claude\\C--Users-Admin-Docum… · /rc", + "", + "", + "❯ This is a UI test. Call the AskUserQuestion tool once with exactly", + " three questions. Question 1: \"When the installer finds the search", + " service already running from an earlier version, should it stop the", + " service, replace its files and start it again, or leave the", + " running copy alone until the next reboot?\" with options Restart", + " now, Wait for reboot. Question 2: \"Which platforms should the build", + " cover?\" with multiSelect true and options Windows, Linux, macOS.", + " Question 3: \"Name the release?\" with options Keep the default, Pick", + " later. Do nothing else.", + "──────────────────────────────────────────────────────────────────────", + "← ☐ Service ☐ Platforms ☐ Release name ✔ Submit →", + "", + "│ When the installer finds the search service already running from an", + "│ earlier version, should it stop the service, replace its files and", + "│ start it again, or leave the running copy alone until the next", + "│ reboot?", + "", + "❯ 1. Restart now", + " Stop the running service, replace its files, and start it again.", + " 2. Wait for reboot", + " Leave the running copy alone and apply the update at the next", + " reboot.", + " 3. Type something.", + "──────────────────────────────────────────────────────────────────────", + " 4. Chat about this", + "", + "Enter to select · Tab/Arrow keys to navigate · Esc to cancel" + ], + "run3-02-right": [ + "", + " ▐▛███▛█ Claude Code v2.1.301", + "▝▜██████▀ Haiku 5.5 · Claude Max", + " ▝▝ ▝▝ ~\\AppData\\Local\\Temp\\claude\\C--Users-Admin-Docum… · /rc", + "", + "", + "❯ This is a UI test. Call the AskUserQuestion tool once with exactly", + " three questions. Question 1: \"When the installer finds the search", + " service already running from an earlier version, should it stop the", + " service, replace its files and start it again, or leave the", + " running copy alone until the next reboot?\" with options Restart", + " now, Wait for reboot. Question 2: \"Which platforms should the build", + " cover?\" with multiSelect true and options Windows, Linux, macOS.", + " Question 3: \"Name the release?\" with options Keep the default, Pick", + " later. Do nothing else.", + "──────────────────────────────────────────────────────────────────────", + "← ☐ Service ☐ Platforms ☐ Release name ✔ Submit →", + "", + "Which platforms should the build cover?", + "", + "❯ 1. [ ] Windows", + " Build for Windows.", + " 2. [ ] Linux", + " Build for Linux.", + " 3. [ ] macOS", + " Build for macOS.", + " 4. [ ] Type something", + " Next", + "──────────────────────────────────────────────────────────────────────", + " 5. Chat about this", + "", + "Enter to select · Tab/Arrow keys to navigate · Esc to cancel" + ], + "run3-03-space-on-windows": [ + "", + " ▐▛███▛█ Claude Code v2.1.301", + "▝▜██████▀ Haiku 5.5 · Claude Max", + " ▝▝ ▝▝ ~\\AppData\\Local\\Temp\\claude\\C--Users-Admin-Docum… · /rc", + "", + "", + "❯ This is a UI test. Call the AskUserQuestion tool once with exactly", + " three questions. Question 1: \"When the installer finds the search", + " service already running from an earlier version, should it stop the", + " service, replace its files and start it again, or leave the", + " running copy alone until the next reboot?\" with options Restart", + " now, Wait for reboot. Question 2: \"Which platforms should the build", + " cover?\" with multiSelect true and options Windows, Linux, macOS.", + " Question 3: \"Name the release?\" with options Keep the default, Pick", + " later. Do nothing else.", + "──────────────────────────────────────────────────────────────────────", + "← ☐ Service ☒ Platforms ☐ Release name ✔ Submit →", + "", + "Which platforms should the build cover?", + "", + "❯ 1. [✔] Windows", + " Build for Windows.", + " 2. [ ] Linux", + " Build for Linux.", + " 3. [ ] macOS", + " Build for macOS.", + " 4. [ ] Type something", + " Next", + "──────────────────────────────────────────────────────────────────────", + " 5. Chat about this", + "", + "Enter to select · Tab/Arrow keys to navigate · Esc to cancel" + ], + "run3-07-type": [ + "", + " ▐▛███▛█ Claude Code v2.1.301", + "▝▜██████▀ Haiku 5.5 · Claude Max", + " ▝▝ ▝▝ ~\\AppData\\Local\\Temp\\claude\\C--Users-Admin-Docum… · /rc", + "", + "", + "❯ This is a UI test. Call the AskUserQuestion tool once with exactly", + " three questions. Question 1: \"When the installer finds the search", + " service already running from an earlier version, should it stop the", + " service, replace its files and start it again, or leave the", + " running copy alone until the next reboot?\" with options Restart", + " now, Wait for reboot. Question 2: \"Which platforms should the build", + " cover?\" with multiSelect true and options Windows, Linux, macOS.", + " Question 3: \"Name the release?\" with options Keep the default, Pick", + " later. Do nothing else.", + "──────────────────────────────────────────────────────────────────────", + "← ☐ Service ☒ Platforms ☐ Release name ✔ Submit →", + "", + "Name the release?", + "", + " 1. Keep the default", + " Use the default release name.", + " 2. Pick later", + " Leave the release name undecided for now.", + "❯ 3. Version Two", + "──────────────────────────────────────────────────────────────────────", + " 4. Chat about this", + "", + "Enter to select · Tab/Arrow keys to navigate · ctrl+g to edit in", + "Notepad · Esc to cancel" + ] +} \ No newline at end of file diff --git a/core/renderer/questionWidget.test.ts b/core/renderer/questionWidget.test.ts new file mode 100644 index 0000000..1a25a61 --- /dev/null +++ b/core/renderer/questionWidget.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { extractQuestion } from './questionWidget' +import screens from './questionWidget.screens.json' + +// The screens are Claude Code 2.1.301's question widget as the probe of +// 2026-10-16 captured it (Sonara #323): 120 columns (run2) and 70 (run3). +const at = (name: string): string[] => (screens as Record)[name] + +describe('extractQuestion', () => { + it('reads the question under the tab row', () => { + expect(extractQuestion(at('run2-01-question-1'))).toEqual({ text: 'Pick a colour?' }) + expect(extractQuestion(at('run2-02-right'))).toEqual({ text: 'Pick a size?' }) + }) + + it('moves on when an answer moves the widget on', () => { + expect(extractQuestion(at('run2-07-enter-answer'))).toEqual({ text: 'Pick a shape?' }) + }) + + it('joins a wrapped question with single spaces', () => { + expect(extractQuestion(at('run3-01-question-1'))).toEqual({ + text: + 'When the installer finds the search service already running from an earlier version, should it stop the service, replace its files and start it again, or leave the running copy alone until the next reboot?' + }) + }) + + it('reads a multi-select question, ticked or not', () => { + expect(extractQuestion(at('run3-02-right'))).toEqual({ text: 'Which platforms should the build cover?' }) + expect(extractQuestion(at('run3-03-space-on-windows'))).toEqual({ + text: 'Which platforms should the build cover?' + }) + }) + + it('keeps the question while an answer is typed', () => { + expect(extractQuestion(at('run3-07-type'))).toEqual({ text: 'Name the release?' }) + }) + + it('reports the review of the answers', () => { + expect(extractQuestion(at('run2-08-enter-answer'))).toEqual({ review: true }) + }) + + it('is null without the widget', () => { + expect(extractQuestion(['PS C:\\> claude', '', 'Some reply text.'])).toBeNull() + expect(extractQuestion([])).toBeNull() + }) +}) diff --git a/core/renderer/questionWidget.ts b/core/renderer/questionWidget.ts new file mode 100644 index 0000000..099213e --- /dev/null +++ b/core/renderer/questionWidget.ts @@ -0,0 +1,53 @@ +/** + * CLAUDE CODE'S QUESTION WIDGET, READ OFF THE SCREEN (Sonara #323). + * + * Claude Code shows the questions of one AskUserQuestion one at a time and + * tells plugins nothing until all are answered, so Sonara (the text-to-speech + * reader) cannot follow the user from question to question. The screen can: + * under the tab row (`← ☐ Colour ☒ Size ✔ Submit →`) stands the full text + * of the question showing, a long one wrapped over lines that start with `│`. + * It changes on the arrows, Tab and an answer, and not while an answer is + * typed. After the last question the widget reads "Review your answers". + * + * MEASURED (2026-10-16, Claude Code 2.1.301, 120 and 70 columns, one-line, + * wrapped and multi-select questions); the fixtures are those screens. + */ + +import type { ShownQuestion } from '../shared/questionShown' + +export type { ShownQuestion } + +/** The tab row: the arrows around the tabs, the last one Submit. */ +const TAB_ROW = /^\s*←.*✔\s*Submit\s*→\s*$/ +/** The first option ends the question: `❯ 1. Red`, ` 2. [ ] Linux`. */ +const OPTION = /^\s*(?:❯\s*)?\d+\.\s/ +/** The widget's last line while a question shows. */ +const HINT = /^\s*Enter to select/ +const REVIEW = /^\s*Review your answers\s*$/ + +/** + * The question the widget shows on these screen rows (the visible ones, + * top to bottom), or null when no question widget is on them. + */ +export function extractQuestion(rows: readonly string[]): ShownQuestion | null { + const tab = rows.findIndex((r) => TAB_ROW.test(r)) + if (tab < 0) return null + const rest = rows.slice(tab + 1) + if (rest.some((r) => REVIEW.test(r))) return { review: true } + if (!rest.some((r) => HINT.test(r))) return null + const lines: string[] = [] + for (const row of rest) { + if (OPTION.test(row)) break + const line = row.replace(/^\s*│\s?/, '').trim() + if (line) lines.push(line) + } + const text = lines.join(' ').replace(/\s+/g, ' ').trim() + return text ? { text } : null +} + +/** Two extracts are the same question (or both the review). */ +export function sameQuestion(a: ShownQuestion | null, b: ShownQuestion | null): boolean { + if (!a || !b) return a === b + if ('review' in a || 'review' in b) return 'review' in a && 'review' in b + return a.text === b.text +} diff --git a/core/shared/channels.ts b/core/shared/channels.ts index 99545d7..e55489b 100644 --- a/core/shared/channels.ts +++ b/core/shared/channels.ts @@ -26,7 +26,9 @@ export const CH = { bell: 'term:bell', openExternal: 'shell:open-external', pathKinds: 'term:path-kinds', - openPath: 'term:open-path' + openPath: 'term:open-path', + /** The question Claude Code's widget shows in a tab (Sonara #323). */ + question: 'term:question' } as const /** The diagnostics log's channels (#140). A table of its own, like diff --git a/core/shared/questionShown.ts b/core/shared/questionShown.ts new file mode 100644 index 0000000..25a24a4 --- /dev/null +++ b/core/shared/questionShown.ts @@ -0,0 +1,16 @@ +/** + * What Claude Code's question widget shows (Sonara #323): the text of the + * question on screen, or the review of the answers after the last one. The + * renderer reads it off the screen (`questionWidget`), main hands it to + * Sonara (`sonaraLink`). + */ +export type ShownQuestion = { text: string } | { review: true } + +/** A value from the bridge, checked: a question text (capped) or the review. */ +export function asShownQuestion(v: unknown): ShownQuestion | null { + if (!v || typeof v !== 'object') return null + const o = v as Record + if (o.review === true) return { review: true } + if (typeof o.text === 'string' && o.text.trim() && o.text.length <= 2000) return { text: o.text.trim() } + return null +} diff --git a/core/shared/termVersion.ts b/core/shared/termVersion.ts index 3bb8b15..84e2103 100644 --- a/core/shared/termVersion.ts +++ b/core/shared/termVersion.ts @@ -7,5 +7,5 @@ * 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.40.12' +export const TERM_CORE_VERSION = '0.41.0' export const XTERM_VERSION = '6.0.0' diff --git a/package-lock.json b/package-lock.json index b6084eb..ffa2013 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "prism-terminal", - "version": "0.54.0", + "version": "0.55.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "prism-terminal", - "version": "0.54.0", + "version": "0.55.0", "license": "MIT", "dependencies": { "@xterm/addon-fit": "^0.11.0", diff --git a/package.json b/package.json index 671bc10..2285771 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "prism-terminal", "productName": "Prism Terminal", - "version": "0.54.0", + "version": "0.55.0", "description": "A tabbed Windows terminal for AI CLIs.", "main": "./out/main/index.js", "author": "Max", diff --git a/tools/e2e/run.mjs b/tools/e2e/run.mjs index 5cb39a2..0a872ed 100644 --- a/tools/e2e/run.mjs +++ b/tools/e2e/run.mjs @@ -18,6 +18,7 @@ import { execFileSync, spawn } from 'child_process' import { createHash } from 'crypto' import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, truncateSync, writeFileSync } from 'fs' import { tmpdir } from 'os' +import { createServer } from 'http' import { dirname, join, resolve } from 'path' import { createRequire } from 'module' @@ -3712,6 +3713,79 @@ const scenarios = { } }, + // SONARA HEARS WHICH QUESTION IS ON SCREEN (Sonara #323). A stand-in draws + // Claude Code's question widget as the probe of 2026-10-16 measured it + // (core/renderer/questionWidget.screens.json); `n` draws the next screen, + // `x` redraws the same one (an answer being typed). A stand-in Sonara on + // 127.0.0.1, named by a runtime.json in SONARA_HOME, records the posts: + // one per change of question, the review once, the tab named by one id. + async sonaraQuestion(ok) { + const w = world() + const home = join(w.alpha, 'sonara-home') + mkdirSync(home) + const got = [] + const server = createServer((req, res) => { + let body = '' + req.on('data', (d) => (body += d)) + req.on('end', () => { + got.push({ url: req.url, auth: req.headers.authorization, body: JSON.parse(body) }) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end('{"ok":true,"moved":true}') + }) + }) + await new Promise((r) => server.listen(0, '127.0.0.1', r)) + writeFileSync(join(home, 'runtime.json'), JSON.stringify({ http_port: server.address().port, token: 'e2e-token' })) + const screens = JSON.parse(readFileSync(resolve(process.cwd(), 'core/renderer/questionWidget.screens.json'), 'utf8')) + const order = ['run2-01-question-1', 'run2-02-right', 'run2-07-enter-answer', 'run2-08-enter-answer'].map((k) => screens[k]) + const probe = join(w.alpha, 'question-probe.cjs') + writeFileSync( + probe, + [ + `const screens = ${JSON.stringify(order)}`, + 'let k = 0', + "const draw = () => process.stdout.write('\\x1b[2J\\x1b[H' + screens[k].join('\\r\\n'))", + 'process.stdin.setRawMode(true)', + 'process.stdin.resume()', + "process.stdin.on('data', (b) => {", + ' const s = b.toString()', + " if (s === 'q') process.exit(0)", + " if (s === 'n' && k < screens.length - 1) k += 1", + ' draw()', + '})', + 'draw()' + ].join('\n') + ) + const { app, page } = await launch(w, { args: [w.alpha], env: { SONARA_HOME: home } }) + const texts = () => got.map((g) => ('review' in g.body ? 'REVIEW' : g.body.text)) + try { + await typeLine(page, `& '${process.execPath}' '${probe}'`) + ok(!!(await until(async () => got.length >= 1, 15000)), 'Sonara hears the question the widget shows') + ok(got[0].url === '/v1/question_shown' && got[0].auth === 'Bearer e2e-token', `posted to question_shown with runtime.json's key (${got[0]?.url})`) + ok(typeof got[0].body.host_tab === 'string' && got[0].body.host_tab.length >= 16, `the tab is named by its shell's PRISM_TAB_ID (${got[0]?.body.host_tab})`) + ok(texts()[0] === 'Pick a colour?', `the first question (${texts()[0]})`) + await page.keyboard.type('x') + await sleep(800) + ok(got.length === 1, `a redraw of the same question says nothing (${got.length} posts)`) + await page.keyboard.type('n') + ok(!!(await until(async () => got.length >= 2, 5000)), 'moving to the next question is heard') + // One move at a time: moves inside the settle time (150 ms) are told + // as the question landed on, by design. + await page.keyboard.type('n') + ok(!!(await until(async () => got.length >= 3, 5000)), 'the next move is heard') + await page.keyboard.type('n') + ok(!!(await until(async () => got.length >= 4, 5000)), 'and the review after the last answer') + ok( + JSON.stringify(texts()) === JSON.stringify(['Pick a colour?', 'Pick a size?', 'Pick a shape?', 'REVIEW']), + `in order, the review last (${texts().join(' | ')})` + ) + ok(new Set(got.map((g) => g.body.host_tab)).size === 1, 'always the same tab id') + await page.keyboard.type('q') + } finally { + server.close() + 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