Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion core/package.json
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
61 changes: 50 additions & 11 deletions core/renderer/questionWatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,33 @@ import screens from './questionWidget.screens.json'

const at = (name: string): string[] => (screens as Record<string, string[]>)[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
}
}
}
Expand Down Expand Up @@ -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<string>(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[] = []
Expand Down
14 changes: 9 additions & 5 deletions core/renderer/questionWatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}

Expand All @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions core/renderer/questionWidget.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
7 changes: 4 additions & 3 deletions core/renderer/questionWidget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
2 changes: 1 addition & 1 deletion core/shared/termVersion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading