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
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.27.0",
"version": "0.27.1",
"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
3 changes: 3 additions & 0 deletions core/renderer/components/TerminalPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
registerPaste,
reportAgentSignal,
reportCwd,
reportTermKey,
reportTitle,
resumingIds,
setResuming,
Expand Down Expand Up @@ -374,14 +375,14 @@
* files inside Prism), else main's: the file's own app, a folder in Explorer,
* anything runnable only SHOWN in Explorer.
*/
export function openTermPath(id: string, target: TermPathAt, mode: 'open' | 'reveal'): void {

Check warning on line 378 in core/renderer/components/TerminalPanel.tsx

View workflow job for this annotation

GitHub Actions / check

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
const s = sessions.get(id)
if (!s) return
if (termHost().openPath?.({ abs: target.abs, kind: target.kind, mode })) return
termApi().termOpenPath?.(s.cwd(), target.text, mode)
}

export function termContextAt(

Check warning on line 385 in core/renderer/components/TerminalPanel.tsx

View workflow job for this annotation

GitHub Actions / check

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
id: string,
clientX: number,
clientY: number
Expand Down Expand Up @@ -456,7 +457,7 @@

/** Wipe a session's screen and scrollback, back to a bare prompt. The shell
* itself is untouched: same process, same cwd, same history. */
export function clearTermSession(id: string): void {

Check warning on line 460 in core/renderer/components/TerminalPanel.tsx

View workflow job for this annotation

GitHub Actions / check

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
sessions.get(id)?.term.clear()
}

Expand All @@ -464,7 +465,7 @@
* Claude session must resume now, not when the tab is first visited. The
* xterm lives against its detached element (the same way hidden tabs keep
* theirs) and the panel simply attaches it later, scrollback intact. */
export function ensureTermSession(id: string, root: string, shellId: string | undefined): void {

Check warning on line 468 in core/renderer/components/TerminalPanel.tsx

View workflow job for this annotation

GitHub Actions / check

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
if (!sessions.has(id)) createSession(id, root, shellId)
}

Expand All @@ -475,7 +476,7 @@
* owner, 2026-09-23): in vim and less Ctrl+F is page down, and stays theirs.
* False when no shell has the keyboard.
*/
export function focusedTermFullScreen(): boolean {

Check warning on line 479 in core/renderer/components/TerminalPanel.tsx

View workflow job for this annotation

GitHub Actions / check

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
const active = document.activeElement
for (const s of sessions.values()) {
if (!active || !s.el.contains(active)) continue
Expand All @@ -487,7 +488,7 @@
/** Give a live session the keyboard back. Used after a tab interaction (a
* click, a reorder drag) stole focus from a shell the user never left: the
* next keystroke would otherwise go to the strip instead of Claude Code. */
export function focusTermSession(id: string): void {

Check warning on line 491 in core/renderer/components/TerminalPanel.tsx

View workflow job for this annotation

GitHub Actions / check

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
sessions.get(id)?.term.focus()
}

Expand All @@ -511,7 +512,7 @@

/** Kill a session's renderer half: the xterm instance and its element. Main's
* pty half is killed separately (term:kill) or already exited. */
export function disposeTermSession(id: string): void {

Check warning on line 515 in core/renderer/components/TerminalPanel.tsx

View workflow job for this annotation

GitHub Actions / check

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
const s = sessions.get(id)
if (!s) return
sessions.delete(id)
Expand Down Expand Up @@ -566,7 +567,7 @@
* character at a time. Enter and the arrows pass false, which is what makes
* them step.
*/
export function findInTerm(id: string, query: string, dir: 1 | -1, incremental = false): boolean {

Check warning on line 570 in core/renderer/components/TerminalPanel.tsx

View workflow job for this annotation

GitHub Actions / check

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
const s = sessions.get(id)
if (!s) return false
if (!query) {
Expand All @@ -579,13 +580,13 @@
}

/** Drop the highlights: the bar closed, or the query emptied. */
export function clearTermFind(id: string): void {

Check warning on line 583 in core/renderer/components/TerminalPanel.tsx

View workflow job for this annotation

GitHub Actions / check

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
sessions.get(id)?.search.clearDecorations()
}

/** Hear the running count. `resultIndex` is -1 past xterm's match threshold,
* which the bar reports as "many" rather than as "none". */
export function onTermFindResults(

Check warning on line 589 in core/renderer/components/TerminalPanel.tsx

View workflow job for this annotation

GitHub Actions / check

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
id: string,
cb: (r: { index: number; count: number }) => void
): () => void {
Expand Down Expand Up @@ -688,6 +689,8 @@
// heard on onKey, which only fires for a key; the rest of onData counts
// only when it is plain text (an IME commit), never when it is a reply.
term.onKey(() => markTouched(id))
// A key can answer an agent's question where no hook says so (#144).
term.onKey((e) => reportTermKey(id, e.key))
// THE CARET FOLLOWS TYPING, NOT A STREAMING AGENT (#101). Magnifiers, screen
// readers and the IME window follow xterm's helper textarea, which xterm
// puts on the cursor after every write (`_syncTextArea` on onCursorMove).
Expand Down
14 changes: 13 additions & 1 deletion core/renderer/lib/agentHookState.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { hookStep, type HookEvent, type HookSession } from './agentHookState'
import { hookStep, raisedWhileSeen, type HookEvent, type HookSession } from './agentHookState'

/** Run events through the rules from no state, as the indicator does. */
function run(events: HookEvent[]): { s: HookSession | undefined; last: ReturnType<typeof hookStep> } {
Expand Down Expand Up @@ -71,3 +71,15 @@ describe('hookStep', () => {
expect(last?.raise).toEqual(['finished'])
})
})

// A QUESTION LASTS UNTIL IT IS ANSWERED (#144; owner, 2026-10-09: "it should
// only disappear if you actually answered the question").
describe('raisedWhileSeen', () => {
it('a question goes up on the tab in front too: being seen is not an answer', () => {
expect(raisedWhileSeen('question')).toBe(true)
})
it('finished and failed are news, which a look has already told', () => {
expect(raisedWhileSeen('finished')).toBe(false)
expect(raisedWhileSeen('failed')).toBe(false)
})
})
15 changes: 14 additions & 1 deletion core/renderer/lib/agentHookState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ import type { AgentSignal } from './agentHookSignal'
* idle title (the two race) still raises Finished.
* - Whether an agent is there at all stays the process poll's.
*
* Marks are RAISED only on a tab nobody is looking at; the hook decides that.
* Finished and Failed are RAISED only on a tab nobody is looking at; the hook
* decides that. A question goes up on any tab (`raisedWhileSeen`).
* Question outranks Failed, which outranks Finished (the strip draws one).
*/

Expand Down Expand Up @@ -63,3 +64,15 @@ export function hookStep(prev: HookSession | undefined, ev: HookEvent): HookOutc
}
}
}

/**
* WHETHER A MARK GOES UP ON THE TAB IN FRONT (#144; owner, 2026-10-09: the
* question line "should only disappear if you actually answered the
* question"). Finished and Failed are NEWS: a look tells it, so they go up
* only where nobody is looking and opening the tab takes them down. A question
* is a STATE: it lasts while the agent waits on you, so it goes up whoever is
* looking, and seeing the tab never takes it down; an answer does.
*/
export function raisedWhileSeen(mark: AttentionMark): boolean {
return mark === 'question'
}
28 changes: 27 additions & 1 deletion core/renderer/lib/agentQuestion.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { looksLikeQuestion } from './agentQuestion'
import { answersQuestion, looksLikeQuestion, questionAnswered } from './agentQuestion'

// The bottom of Claude Code's screen in each state (Claude Code 2.1.283,
// 2026-09-28), trimmed to the rows that matter. The question box's footer and
Expand Down Expand Up @@ -54,3 +54,29 @@ describe('looksLikeQuestion', () => {
expect(looksLikeQuestion(['Do you want to know more? Read the docs.'])).toBe(false)
})
})

// A QUESTION LASTS UNTIL IT IS ANSWERED (#144). No hook fires when a
// permission prompt is turned down or a question is cancelled with Esc, so
// the keys that settle a box, and the box gone after them, are the answer.
describe('answersQuestion', () => {
it('Enter, Esc, Ctrl+C and a numbered choice settle a question box', () => {
for (const k of ['\r', '\x1b', '\x03', '1', '3', '9']) expect(answersQuestion(k)).toBe(true)
})
it('walking the choices or typing a letter does not', () => {
for (const k of ['\x1b[A', '\x1b[B', '\t', 'a', ' ', '0', '12', '']) expect(answersQuestion(k)).toBe(false)
})
})

describe('questionAnswered', () => {
it('an answering key with the box gone is an answer', () => {
expect(questionAnswered('\r', FINISHED)).toBe(true)
expect(questionAnswered('\x1b', ['PS C:\\Users\\me> '])).toBe(true)
})
it('the box still up (the next of several questions) is not', () => {
expect(questionAnswered('\r', QUESTION_BOX)).toBe(false)
expect(questionAnswered('1', PERMISSION)).toBe(false)
})
it('a key that answers nothing is not, whatever the screen says', () => {
expect(questionAnswered('\x1b[B', FINISHED)).toBe(false)
})
})
20 changes: 20 additions & 0 deletions core/renderer/lib/agentQuestion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,23 @@ export function looksLikeQuestion(rows: readonly string[]): boolean {
if (/Do you want to [^\n]*\?/i.test(text) && /(^|\s)1\.\s+Yes\b/m.test(text)) return true
return false
}

/**
* A QUESTION LASTS UNTIL IT IS ANSWERED (#144; owner, 2026-10-09: "if you go on
* that tab and then just move to another tab without answering the question,
* the blue bar shouldn't disappear ... it should only disappear if you actually
* answered the question"). Most answers say so themselves: a yes is work again
* (a working hook or title). Turning a permission prompt down, or cancelling a
* question with Esc, ends the turn with no hook at all (no Stop fires on an
* interrupt, MEASURED for #131), so the keys that settle a box are heard too:
* Enter, Esc, Ctrl+C and a numbered choice. Walking the choices is not one.
*/
export function answersQuestion(key: string): boolean {
return key === '\r' || key === '\x1b' || key === '\x03' || /^[1-9]$/.test(key)
}

/** A key that settles a box, with the box gone from the screen after it. A box
* still up is the next of several questions, still waiting. */
export function questionAnswered(key: string, rows: readonly string[]): boolean {
return answersQuestion(key) && !looksLikeQuestion(rows)
}
16 changes: 16 additions & 0 deletions core/renderer/lib/termBus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,22 @@ export function onTitle(fn: (sessionId: string, title: string) => void): () => v
}
}

// THE KEYS PRESSED IN A SESSION (#144): the indicator hears a question being
// answered by a key no hook reports (Esc, a "No"). xterm's onKey, so only keys
// the user pressed, never the terminal's own replies. Nothing is kept.
const keyListeners = new Set<(sessionId: string, key: string) => void>()

export function reportTermKey(sessionId: string, key: string): void {
keyListeners.forEach((fn) => fn(sessionId, key))
}

export function onTermKey(fn: (sessionId: string, key: string) => void): () => void {
keyListeners.add(fn)
return () => {
keyListeners.delete(fn)
}
}

// WHAT CLAUDE CODE'S HOOKS SAY (#131). TerminalPanel hears our OSC 777 through
// xterm's parser and posts the parsed state here; the indicator listens, so it
// never imports the panel (which sits behind Prism's lazy boundary).
Expand Down
85 changes: 71 additions & 14 deletions core/renderer/lib/useAgentIndicator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import type { DetectedAgent } from '../../shared/types'
import { activitySuppressed, inputEcho, markBorn, startupOutput } from './termActivity'
import { forgetAgentTitle, readAgentTitle } from './agentTitle'
import { noteWorking } from './agentClock'
import { onAgentSignal, onTitle, readScreenTail } from './termBus'
import { looksLikeQuestion } from './agentQuestion'
import { hookStep, type AttentionMark, type HookEvent, type HookSession } from './agentHookState'
import { onAgentSignal, onTermKey, onTitle, readScreenTail } from './termBus'
import { answersQuestion, looksLikeQuestion, questionAnswered } from './agentQuestion'
import { hookStep, raisedWhileSeen, type AttentionMark, type HookEvent, type HookSession } from './agentHookState'
import { termApi } from '../host'

/**
Expand All @@ -24,7 +24,7 @@ export interface AgentIndicator {
workingIds: ReadonlySet<string>
doneIds: ReadonlySet<string>
/** Sessions whose agent is waiting on YOU (a question or a permission
* prompt), marked while you were not looking at them (2026-09-28). */
* prompt), 2026-09-28. Marked until it is ANSWERED, looking or not (#144). */
questionIds: ReadonlySet<string>
/** Sessions whose turn ended on an error, told by Claude Code's own hook
* (#131), marked while you were not looking at them. */
Expand All @@ -50,6 +50,11 @@ export function useAgentIndicator(activeId: string | null): AgentIndicator {
const [workingIds, setWorkingIds] = useState<ReadonlySet<string>>(new Set())
const [doneIds, setDoneIds] = useState<ReadonlySet<string>>(new Set())
const [questionIds, setQuestionIds] = useState<ReadonlySet<string>>(new Set())
/** The pending questions, for a key handler to ask at the moment of the key. */
const questionsNow = useRef(questionIds)
useEffect(() => {
questionsNow.current = questionIds
}, [questionIds])
const [failedIds, setFailedIds] = useState<ReadonlySet<string>>(new Set())
const failedKinds = useRef(new Map<string, string>())
/**
Expand Down Expand Up @@ -86,8 +91,16 @@ export function useAgentIndicator(activeId: string | null): AgentIndicator {
* for a question only while its agent is idle. */
const titleState = useRef(new Map<string, string>())
const questionTimers = useRef(new Map<string, number>())
/** The screen reads pending after a key that may have answered a question.
* A NEW question cancels them: its box may not be drawn yet, and a read in
* that gap would take it down unanswered (review of #144). */
const answerTimers = useRef(new Map<string, number[]>())
const stopAnswerCheck = useCallback((id: string): void => {
for (const t of answerTimers.current.get(id) ?? []) clearTimeout(t)
answerTimers.current.delete(id)
}, [])
/** Read the session's screen for Claude's question box: mark it when it
* appears (unless you are looking), unmark it when it has gone. */
* appears (on the tab in front too, #144), unmark it when it has gone. */
const checkQuestion = useCallback((id: string): void => {
if (questionTimers.current.has(id)) return
questionTimers.current.set(
Expand All @@ -96,7 +109,7 @@ export function useAgentIndicator(activeId: string | null): AgentIndicator {
questionTimers.current.delete(id)
const asking = titleState.current.get(id) === 'idle' && looksLikeQuestion(readScreenTail(id))
setQuestionIds((prev) => {
if (asking && !prev.has(id) && !lookedAt(id)) return new Set(prev).add(id)
if (asking && !prev.has(id)) return new Set(prev).add(id)
if (!asking && prev.has(id)) return without(prev, id)
return prev
})
Expand Down Expand Up @@ -129,8 +142,9 @@ export function useAgentIndicator(activeId: string | null): AgentIndicator {
if (o.phase === 'failed' && o.kind) failedKinds.current.set(id, o.kind)
else if (o.phase === 'failed' || o.clear.includes('failed')) failedKinds.current.delete(id)
const away = !lookedAt(id)
if (o.raise.includes('question')) stopAnswerCheck(id)
const marks = (mark: AttentionMark) => (prev: ReadonlySet<string>): ReadonlySet<string> => {
if (away && o.raise.includes(mark)) return prev.has(id) ? prev : new Set(prev).add(id)
if ((away || raisedWhileSeen(mark)) && o.raise.includes(mark)) return prev.has(id) ? prev : new Set(prev).add(id)
return o.clear.includes(mark) ? without(prev, id) : prev
}
setDoneIds(marks('finished'))
Expand All @@ -140,7 +154,7 @@ export function useAgentIndicator(activeId: string | null): AgentIndicator {
if (prev.has(id) === o.working) return prev
return o.working ? new Set(prev).add(id) : without(prev, id)
})
}, [])
}, [stopAnswerCheck])

useEffect(
() =>
Expand All @@ -159,6 +173,8 @@ export function useAgentIndicator(activeId: string | null): AgentIndicator {
forgetAgentTitle(id)
stopFallback(id)
setWorkingIds((prev) => without(prev, id))
// Nobody is left to answer a question (#144).
setQuestionIds((prev) => without(prev, id))
failedKinds.current.delete(id)
setFailedIds((prev) => without(prev, id))
}
Expand Down Expand Up @@ -270,8 +286,10 @@ export function useAgentIndicator(activeId: string | null): AgentIndicator {
// Codex says it outright (#131). Any other state is the agent at work
// again, so no question is pending.
if (r.state === 'idle') checkQuestion(id)
else if (r.state === 'question')
setQuestionIds((prev) => (prev.has(id) || lookedAt(id) ? prev : new Set(prev).add(id)))
else if (r.state === 'question') {
if (!questionsNow.current.has(id)) stopAnswerCheck(id)
setQuestionIds((prev) => (prev.has(id) ? prev : new Set(prev).add(id)))
}
else setQuestionIds((prev) => without(prev, id))
const working = r.state === 'working'
setWorkingIds((prev) => {
Expand All @@ -282,7 +300,7 @@ export function useAgentIndicator(activeId: string | null): AgentIndicator {
return next
})
}),
[stopFallback, checkQuestion, applyHook]
[stopFallback, checkQuestion, applyHook, stopAnswerCheck]
)

/**
Expand All @@ -309,6 +327,36 @@ export function useAgentIndicator(activeId: string | null): AgentIndicator {
[stopFallback, applyHook]
)

/**
* AN ANSWER NO HOOK REPORTS (#144). Turning a permission prompt down, or
* cancelling a question with Esc, ends the turn without a hook (no Stop on an
* interrupt), so a key that settles a box is heard here: the question is
* answered once the box has gone from the screen after it (`questionAnswered`).
* Read four times over five seconds, since the agent repaints a moment after
* the key and a busy machine can make that moment long: a hooked session's
* screen is read nowhere else, so a single late repaint would leave the line
* up until the next prompt (review of #144). A box still up is the next of
* several questions, still pending.
*/
useEffect(
() =>
onTermKey((id, key) => {
if (!answersQuestion(key) || !questionsNow.current.has(id)) return
stopAnswerCheck(id)
answerTimers.current.set(
id,
[250, 1000, 2500, 5000].map((ms) =>
window.setTimeout(() => {
if (!questionAnswered(key, readScreenTail(id))) return
stopAnswerCheck(id)
setQuestionIds((prev) => without(prev, id))
}, ms)
)
)
}),
[stopAnswerCheck]
)

// Finished-while-away: an agent that STOPS working on a background tab
// leaves a mark that stays until the tab is visited (or work restarts).
const prevWorking = useRef<ReadonlySet<string>>(new Set())
Expand All @@ -334,8 +382,16 @@ export function useAgentIndicator(activeId: string | null): AgentIndicator {
}
return next ?? prev
})
// A question, or a failure, you are now looking at has been seen.
setQuestionIds((prev) => (activeId && focused && prev.has(activeId) ? without(prev, activeId) : prev))
// A QUESTION IS NOT CLEARED BY A LOOK (#144; owner, 2026-10-09: "if you go
// on that tab and then just move to another tab without answering the
// question, the blue bar shouldn't disappear"). It goes when it is
// answered, or when no agent is left in the session to wait.
setQuestionIds((prev) => {
let next = prev
for (const id of prev) if (!agentIds.has(id)) next = without(next, id)
return next
})
// A failure you are now looking at has been seen.
setFailedIds((prev) => {
let next = prev
for (const id of prev) if (seeing(id) || workingIds.has(id) || !agentIds.has(id)) next = without(next, id)
Expand All @@ -362,7 +418,8 @@ export function useAgentIndicator(activeId: string | null): AgentIndicator {
const t = questionTimers.current.get(id)
if (t !== undefined) clearTimeout(t)
questionTimers.current.delete(id)
}, [stopFallback])
stopAnswerCheck(id)
}, [stopFallback, stopAnswerCheck])

return { agentIds, workingIds, doneIds, questionIds, failedIds, failedKinds, agentKinds, forget }
}
9 changes: 8 additions & 1 deletion docs/regression-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,14 @@ one-liner in `CLAUDE.md` only if it is among the most important.
A tab whose agent finished, or waits on you, while you were NOT LOOKING (another tab in front, or
the window unfocused) gets a static 3 px line along its bottom: Finished colour, or Question
colour (default blue) which outranks it; each behind its own switch (`agent-done-on`,
`agent-question-on`, both on). Opening the tab clears it. Full's fill is for working alone now.
`agent-question-on`, both on). Opening the tab clears Finished (and Failed). **A QUESTION LASTS
UNTIL IT IS ANSWERED** (#144; owner, 2026-10-09: "if you go on that tab and then just move to
another tab without answering the question, the blue bar shouldn't disappear ... it should only
disappear if you actually answered the question"): it goes up on the tab in front too
(`raisedWhileSeen`), a look never takes it down, and the badge counts it until it goes: work
again (a hook or title), another hook state, the agent leaving, the tab closing, or a key that
settles the box (Enter, Esc, Ctrl+C, a digit; `answersQuestion`) with the box gone after it, the
one way a "No" or an Esc is heard, since no hook fires for those. Full's fill is for working alone now.
CLAUDE GIVES NO SIGNAL FOR A QUESTION (MEASURED in a pty: the title is `✳` exactly as when done, no
bell, no OSC 9), so `agentQuestion.looksLikeQuestion` reads the last text rows of the screen
(through termBus, never by importing the panel) for its footer when the title goes idle and as
Expand Down
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.34.0",
"version": "0.34.1",
"description": "A tabbed Windows terminal for AI CLIs.",
"main": "./out/main/index.js",
"author": "Max",
Expand Down
Loading
Loading