diff --git a/core/README.md b/core/README.md index 3f5bc6f..a7e27de 100644 --- a/core/README.md +++ b/core/README.md @@ -265,7 +265,8 @@ 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, +`renderer/questionWidget.ts` reads the question off the screen rows, the last +tab row being the live widget (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 diff --git a/core/package.json b/core/package.json index b458487..200a1d7 100644 --- a/core/package.json +++ b/core/package.json @@ -1,6 +1,6 @@ { "name": "prism-term-core", - "version": "0.41.4", + "version": "0.41.5", "description": "What Prism Terminal and Prism share: the terminal (pty, shells, agent detection and indicator, themes, links, the panel, dictation) and the update chip with its window. TypeScript source, compiled by the host.", "license": "MIT", "private": true, diff --git a/core/renderer/questionWatch.test.ts b/core/renderer/questionWatch.test.ts index 9b9c251..79c6cc4 100644 --- a/core/renderer/questionWatch.test.ts +++ b/core/renderer/questionWatch.test.ts @@ -4,25 +4,33 @@ 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 } { +/** + * A terminal `rows` high whose buffer is `write`'s lines: the last `rows` of + * them are the live screen (`baseY`), the rest scrollback. `write` swaps the + * buffer and fires the parse event; `scrollTop` moves the viewport to the top. + */ +function fakeTerm(rows = 40): WatchedTerm & { write(lines: string[]): void; scrollTop(): void } { let screen: string[] = [] const subs = new Set<() => void>() + const active = { + baseY: 0, + viewportY: 0, + getLine: (y: number) => (y < screen.length ? { translateToString: () => screen[y] } : undefined) + } return { - rows: 40, - buffer: { - active: { - viewportY: 0, - getLine: (y) => ({ translateToString: () => screen[y] ?? '' }) - } - }, + rows, + buffer: { active }, onWriteParsed: (cb) => { subs.add(cb) return { dispose: () => subs.delete(cb) } }, - write(rows) { - screen = rows + write(lines) { + screen = lines + active.baseY = active.viewportY = Math.max(0, lines.length - rows) for (const cb of subs) cb() + }, + scrollTop() { + active.viewportY = 0 } } } @@ -63,6 +71,37 @@ describe('watchQuestions', () => { expect(got).toEqual([{ text: 'Pick a size?' }, { text: 'Pick a size?' }]) }) + it('finds a widget taller than the pane, its tab row in scrollback', () => { + const term = fakeTerm(8) + const got: unknown[] = [] + watchQuestions(term, (s) => got.push(s), 150) + term.write(at('run2-02-right')) + vi.advanceTimersByTime(150) + expect(got).toEqual([{ text: 'Pick a size?' }]) + }) + + it('follows the newer of two widget frames, a stale one above', () => { + const term = fakeTerm(50) + const got: unknown[] = [] + watchQuestions(term, (s) => got.push(s), 150) + term.write(at('run2-01-question-1')) + vi.advanceTimersByTime(150) + term.write([...at('run2-01-question-1'), ...at('run2-02-right')]) + vi.advanceTimersByTime(150) + expect(got).toEqual([{ text: 'Pick a colour?' }, { text: 'Pick a size?' }]) + }) + + it('reads the live screen while the user has scrolled up', () => { + const term = fakeTerm(30) + const got: unknown[] = [] + watchQuestions(term, (s) => got.push(s), 150) + const lines = [...at('run2-01-question-1'), ...Array(40).fill('output'), ...at('run2-02-right')] + term.write(lines) + term.scrollTop() + vi.advanceTimersByTime(150) + expect(got).toEqual([{ text: 'Pick a size?' }]) + }) + it('stops on dispose', () => { const term = fakeTerm() const got: unknown[] = [] diff --git a/core/renderer/questionWatch.ts b/core/renderer/questionWatch.ts index 74f3cb6..83a32fd 100644 --- a/core/renderer/questionWatch.ts +++ b/core/renderer/questionWatch.ts @@ -3,16 +3,20 @@ import { extractQuestion, sameQuestion, type ShownQuestion } from './questionWid /** * 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 + * live screen is 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. + * later is reported again. Cheap: one pass over a few screens of rows, only + * after output, never on a timer of its own. + * + * The live screen is the buffer's bottom (`baseY`), not the viewport: a user + * scrolled up still has the widget followed. A screen of scrollback above it + * is read too, as a widget taller than the pane pushes its tab row up there. */ /** 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 } } + buffer: { active: { baseY: number; getLine(y: number): { translateToString(trim?: boolean): string } | undefined } } onWriteParsed(cb: () => void): { dispose(): void } } @@ -27,7 +31,7 @@ export function watchQuestions( timer = undefined const b = term.buffer.active const rows: string[] = [] - for (let y = b.viewportY; y < b.viewportY + term.rows; y++) { + for (let y = Math.max(0, b.baseY - term.rows); y < b.baseY + term.rows; y++) { rows.push(b.getLine(y)?.translateToString(true) ?? '') } const now = extractQuestion(rows) diff --git a/core/renderer/questionWidget.test.ts b/core/renderer/questionWidget.test.ts index 1a25a61..3256ebb 100644 --- a/core/renderer/questionWidget.test.ts +++ b/core/renderer/questionWidget.test.ts @@ -38,6 +38,10 @@ describe('extractQuestion', () => { expect(extractQuestion(at('run2-08-enter-answer'))).toEqual({ review: true }) }) + it('reads the last widget frame when a stale one stands above it', () => { + expect(extractQuestion([...at('run2-01-question-1'), ...at('run2-02-right')])).toEqual({ text: 'Pick a size?' }) + }) + 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 index 099213e..ecdf7e6 100644 --- a/core/renderer/questionWidget.ts +++ b/core/renderer/questionWidget.ts @@ -26,11 +26,12 @@ 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. + * The question the widget shows on these screen rows (top to bottom), or + * null when no question widget is on them. The LAST tab row is the live + * widget: a resize can leave a stale frame of it above (Ink redraws). */ export function extractQuestion(rows: readonly string[]): ShownQuestion | null { - const tab = rows.findIndex((r) => TAB_ROW.test(r)) + const tab = rows.findLastIndex((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 } diff --git a/core/shared/termVersion.ts b/core/shared/termVersion.ts index 233179f..aa11374 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.41.4' +export const TERM_CORE_VERSION = '0.41.5' export const XTERM_VERSION = '6.0.0' diff --git a/package-lock.json b/package-lock.json index 1f8cb16..673956b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "prism-terminal", - "version": "0.55.4", + "version": "0.55.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "prism-terminal", - "version": "0.55.4", + "version": "0.55.5", "license": "MIT", "dependencies": { "@xterm/addon-fit": "^0.11.0", diff --git a/package.json b/package.json index 8ac2f9b..789114d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "prism-terminal", "productName": "Prism Terminal", - "version": "0.55.4", + "version": "0.55.5", "description": "A tabbed Windows terminal for AI CLIs.", "main": "./out/main/index.js", "author": "Max",