@brimveyn/aimux 1.18.4 → 1.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  AssistantId,
3
+ QuestionKind,
3
4
  SessionStatus,
4
5
  TabActivity,
5
6
  TabSession,
@@ -29,8 +30,15 @@ import { isWorkspaceSnapshotV1, isWorktreeRecord } from '../state/validation'
29
30
  // `switchWorkspace`, `closeWorkspace`, `announceWorkspaceSwitched`) and
30
31
  // worktree record requests (`addWorktreeRecord`, `removeWorktreeRecord`),
31
32
  // plus matching broadcast events. All capability-gated; MIN stays at 10.
33
+ //
34
+ // v13: additive — agent-orchestration signals. Two new broadcast events,
35
+ // `tabTurnComplete` (a tab's `idle` held long enough to call the turn done)
36
+ // and `tabQuestion` (a tab entered `waiting-input`; carries the prompt text
37
+ // plus best-effort parsed options), and an additive `lastLine` field on
38
+ // `TabSessionSummary`. Gated behind `turnLifecycle`, `questionEvents`, and
39
+ // `listTabsLastLine` respectively; MIN stays at 10.
32
40
  export const IPC_PROTOCOL_MIN_VERSION = 10
33
- export const IPC_PROTOCOL_VERSION = 12
41
+ export const IPC_PROTOCOL_VERSION = 13
34
42
 
35
43
  /**
36
44
  * Capability advertised by a daemon that knows how to drain + handoff its
@@ -104,6 +112,31 @@ export const IPC_CAPABILITY_WORKTREE_LIFECYCLE_EVENTS = 'worktreeLifecycleEvents
104
112
  */
105
113
  export const IPC_CAPABILITY_TAB_TAIL = 'tabTail'
106
114
 
115
+ /**
116
+ * v13 — capability gating the `tabTurnComplete` event. When advertised, the
117
+ * daemon broadcasts an authoritative end-of-turn signal once a tab's `idle`
118
+ * activity has held continuously for the settle window, so a driver need not
119
+ * poll `tabStatus` and re-confirm that idle held. Pre-cap peers never see the
120
+ * event and fall back to settle-polling `tabStatus`.
121
+ */
122
+ export const IPC_CAPABILITY_TURN_LIFECYCLE = 'turnLifecycle'
123
+
124
+ /**
125
+ * v13 — capability gating the `tabQuestion` event. When advertised, the daemon
126
+ * broadcasts the captured prompt text (plus best-effort parsed options) when a
127
+ * tab transitions into `waiting-input`, so a driver need not re-`snapshot` the
128
+ * screen and substring-match to learn what the worker is asking.
129
+ */
130
+ export const IPC_CAPABILITY_QUESTION_EVENTS = 'questionEvents'
131
+
132
+ /**
133
+ * v13 — capability gating the additive `lastLine` field on `listTabs`
134
+ * summaries. When advertised, each summary carries the tab's last non-blank
135
+ * rendered line so a fleet poll can read "what each worker is doing" without a
136
+ * `snapshot` round-trip per tab. Pre-cap daemons omit the field.
137
+ */
138
+ export const IPC_CAPABILITY_LIST_TABS_LAST_LINE = 'listTabsLastLine'
139
+
107
140
  /**
108
141
  * Capabilities advertised by *this* process in its `helloResult`. Additive
109
142
  * features should be introduced as new capability strings here rather than
@@ -124,6 +157,9 @@ export const IPC_PROTOCOL_CAPABILITIES: readonly string[] = [
124
157
  IPC_CAPABILITY_WORKSPACE_LIFECYCLE,
125
158
  IPC_CAPABILITY_WORKTREE_LIFECYCLE_EVENTS,
126
159
  IPC_CAPABILITY_TAB_TAIL,
160
+ IPC_CAPABILITY_TURN_LIFECYCLE,
161
+ IPC_CAPABILITY_QUESTION_EVENTS,
162
+ IPC_CAPABILITY_LIST_TABS_LAST_LINE,
127
163
  ]
128
164
 
129
165
  export interface ProtocolHelloRequest {
@@ -184,6 +220,12 @@ export interface TabSessionSummary {
184
220
  activity?: TabActivity
185
221
  command: string
186
222
  worktreeId?: string
223
+ /**
224
+ * v13 / capability `listTabsLastLine`. The tab's last non-blank rendered
225
+ * line, trimmed. Present only when the daemon advertises the capability;
226
+ * omitted when the tab has produced no viewport yet.
227
+ */
228
+ lastLine?: string
187
229
  }
188
230
 
189
231
  export interface ListTabsResult {
@@ -324,6 +366,26 @@ export type ServerEvent =
324
366
  | { type: 'tabExit'; payload: { tabId: string; exitCode: number } }
325
367
  | { type: 'tabError'; payload: { tabId: string; message: string } }
326
368
  | { type: 'tabStatus'; payload: { sessionId: string; tabId: string; status: TabActivity } }
369
+ // v13 / capability `turnLifecycle`. Authoritative end-of-turn: broadcast
370
+ // once a tab's `idle` activity has held continuously for the settle window.
371
+ // Edge-triggered — re-armed only after the tab leaves `idle` again — so a
372
+ // driver gets exactly one per turn. `idleMs` is how long idle had held when
373
+ // the event fired.
374
+ | { type: 'tabTurnComplete'; payload: { sessionId: string; tabId: string; idleMs: number } }
375
+ // v13 / capability `questionEvents`. Broadcast when a tab transitions into
376
+ // `waiting-input`. `prompt` is the captured tail text (authoritative);
377
+ // `options` is a best-effort per-CLI parse of the choice list and may be
378
+ // absent even when the prompt clearly offers choices.
379
+ | {
380
+ type: 'tabQuestion'
381
+ payload: {
382
+ sessionId: string
383
+ tabId: string
384
+ kind: QuestionKind
385
+ prompt: string
386
+ options?: string[]
387
+ }
388
+ }
327
389
  | { type: 'sessionStatus'; payload: { sessionId: string; status: SessionStatus } }
328
390
  // Capability-gated on `tabLifecycleEvents`. Broadcast after a successful
329
391
  // `createTab` so every UI/CLI client attached to the same session learns
@@ -510,10 +572,15 @@ function isTabSessionSummary(value: unknown): value is TabSessionSummary {
510
572
  value.activity === 'waiting-input' ||
511
573
  value.activity === 'idle') &&
512
574
  isString(value.command) &&
513
- (value.worktreeId === undefined || isString(value.worktreeId))
575
+ (value.worktreeId === undefined || isString(value.worktreeId)) &&
576
+ (value.lastLine === undefined || isString(value.lastLine))
514
577
  )
515
578
  }
516
579
 
580
+ function isQuestionKind(value: unknown): value is QuestionKind {
581
+ return value === 'question' || value === 'permission'
582
+ }
583
+
517
584
  function isListTabsResult(value: unknown): value is ListTabsResult {
518
585
  return (
519
586
  isObjectRecord(value) &&
@@ -786,6 +853,21 @@ export function parseServerMessage(value: unknown): ServerResponse | ServerEvent
786
853
  assert(isString(value.payload.tabId), 'tabStatus.tabId must be a string')
787
854
  assert(isTabActivity(value.payload.status), 'tabStatus.status is invalid')
788
855
  return value as ServerEvent
856
+ case 'tabTurnComplete':
857
+ assert(isString(value.payload.sessionId), 'tabTurnComplete.sessionId must be a string')
858
+ assert(isString(value.payload.tabId), 'tabTurnComplete.tabId must be a string')
859
+ assert(isFiniteNumber(value.payload.idleMs), 'tabTurnComplete.idleMs must be a number')
860
+ return value as ServerEvent
861
+ case 'tabQuestion':
862
+ assert(isString(value.payload.sessionId), 'tabQuestion.sessionId must be a string')
863
+ assert(isString(value.payload.tabId), 'tabQuestion.tabId must be a string')
864
+ assert(isQuestionKind(value.payload.kind), 'tabQuestion.kind is invalid')
865
+ assert(isString(value.payload.prompt), 'tabQuestion.prompt must be a string')
866
+ assert(
867
+ value.payload.options === undefined || isStringArray(value.payload.options),
868
+ 'tabQuestion.options must be a string array when present'
869
+ )
870
+ return value as ServerEvent
789
871
  case 'tabAdded':
790
872
  assert(isString(value.payload.sessionId), 'tabAdded.sessionId must be a string')
791
873
  assert(isTabSession(value.payload.tab), 'tabAdded.tab is invalid')
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Question / permission extraction.
3
+ *
4
+ * When a tab transitions into `waiting-input`, the status detection loop calls
5
+ * this to turn "the worker is blocked" into a structured event: what it's
6
+ * asking (the captured prompt text — authoritative) plus a best-effort parse
7
+ * of the choice list. It reuses the detector's tail extraction so both read
8
+ * the screen identically.
9
+ *
10
+ * Option parsing is explicitly best-effort and per-CLI: TUIs render menus in
11
+ * shapes that shift between versions. `prompt` is always populated; `options`
12
+ * may be absent even when the screen clearly offers choices. Consumers should
13
+ * treat `prompt` as the source of truth and `options` as a convenience.
14
+ */
15
+ import type { AssistantId, QuestionKind, TerminalSnapshot } from '../state/types'
16
+
17
+ import { extractTailLines } from './assistant-status-detector'
18
+
19
+ /** How many trailing non-blank lines to capture as the prompt text. Wider than
20
+ * the 10-line classification tail so a multi-line permission block or a long
21
+ * question is captured whole. */
22
+ const PROMPT_TAIL_LINES = 20
23
+
24
+ export interface QuestionDetail {
25
+ kind: QuestionKind
26
+ /** The captured waiting-input tail, trailing-trimmed, joined with newlines. */
27
+ prompt: string
28
+ /** Best-effort parsed choice list; omitted when none could be recognised. */
29
+ options?: string[]
30
+ }
31
+
32
+ /**
33
+ * Substrings that mark a blocked prompt as a permission / approval request
34
+ * rather than a free-form question. Lower-cased haystack match. Kept broad and
35
+ * shared across CLIs; per-CLI extras are folded in below.
36
+ */
37
+ const PERMISSION_SIGNALS: readonly string[] = [
38
+ 'do you want',
39
+ 'permission required',
40
+ 'permission to',
41
+ '△ permission',
42
+ 'allow this',
43
+ 'approve',
44
+ 'grant',
45
+ 'press enter to confirm',
46
+ ]
47
+
48
+ /**
49
+ * Extract the structured question for a tab already classified as
50
+ * `waiting-input`. Returns null only when there's no viewport / no text to
51
+ * report; otherwise `prompt` is always present.
52
+ */
53
+ export function extractQuestion(
54
+ assistant: AssistantId,
55
+ viewport: TerminalSnapshot | undefined
56
+ ): QuestionDetail | null {
57
+ if (!viewport) return null
58
+ const lines = extractTailLines(viewport, PROMPT_TAIL_LINES)
59
+ if (lines.length === 0) return null
60
+
61
+ const prompt = lines.join('\n')
62
+ const haystack = prompt.toLowerCase()
63
+ const kind = detectKind(assistant, haystack)
64
+ const options = parseOptions(lines, haystack)
65
+ return options ? { kind, options, prompt } : { kind, prompt }
66
+ }
67
+
68
+ function detectKind(assistant: AssistantId, haystack: string): QuestionKind {
69
+ for (const signal of PERMISSION_SIGNALS) {
70
+ if (haystack.includes(signal)) return 'permission'
71
+ }
72
+ // opencode surfaces tool approvals under a "permission" banner; codex uses an
73
+ // approval confirm. Both already covered by the shared signals, but keep the
74
+ // assistant param so future per-CLI divergence has a seam.
75
+ void assistant
76
+ return 'question'
77
+ }
78
+
79
+ /**
80
+ * Matches a numbered menu row: an optional selection marker (❯ › > *), a digit,
81
+ * a `.`/`)` separator, then the option label. Capturing the label lets us strip
82
+ * the marker/number so consumers get clean text.
83
+ */
84
+ const NUMBERED_OPTION = /^\s*[❯›>*]?\s*\d+[.)]\s+(\S.*)$/u
85
+
86
+ /**
87
+ * Matches an arrow-selected label with no number, e.g. Claude's `❯ Yes` /
88
+ * ` No` yes-no menus.
89
+ */
90
+ const MARKED_OPTION = /^\s*[❯›]\s+(\S.*)$/u
91
+
92
+ function parseOptions(lines: readonly string[], haystack: string): string[] | undefined {
93
+ const numbered: string[] = []
94
+ for (const line of lines) {
95
+ const captured = NUMBERED_OPTION.exec(line)?.[1]
96
+ if (captured != null && captured !== '') numbered.push(captured.trimEnd())
97
+ }
98
+ if (numbered.length >= 2) return numbered
99
+
100
+ // No numbered menu — look for a single arrow-marked choice paired with its
101
+ // siblings is unreliable, so fall back to explicit yes/no affordances.
102
+ if (
103
+ haystack.includes('[y/n]') ||
104
+ haystack.includes('(y/n)') ||
105
+ haystack.includes('yes/no') ||
106
+ haystack.includes('y/n?')
107
+ ) {
108
+ return ['Yes', 'No']
109
+ }
110
+
111
+ const marked = lines.map((line) => MARKED_OPTION.exec(line)?.[1]?.trimEnd()).filter(isNonEmpty)
112
+ if (marked.length >= 1 && numbered.length === 0) {
113
+ // A lone highlighted option (e.g. a confirm dialog defaulting to Yes) — only
114
+ // surface it when it's a short label, not a highlighted sentence.
115
+ const short = marked.filter((label) => label.length <= 40)
116
+ if (short.length >= 1) return short
117
+ }
118
+
119
+ return undefined
120
+ }
121
+
122
+ function isNonEmpty(value: string | undefined): value is string {
123
+ return value !== undefined && value.length > 0
124
+ }
@@ -17,6 +17,7 @@ import type { AssistantId, SessionStatus, TabActivity, TerminalSnapshot } from '
17
17
 
18
18
  import { logDebug } from '../debug/input-log'
19
19
  import { getLineText } from '../input/terminal-text-extraction'
20
+ import { extractQuestion } from './assistant-question-extractor'
20
21
  import { AssistantStatusArbiter, type RecordHookEventInput } from './assistant-status-arbiter'
21
22
  import { AssistantStatusDetector } from './assistant-status-detector'
22
23
 
@@ -35,6 +36,15 @@ function tailPreview(viewport: TerminalSnapshot | undefined): string {
35
36
  /** Default polling interval. Cheap — detector is a handful of substring checks. */
36
37
  const DEFAULT_TICK_MS = 500
37
38
 
39
+ /**
40
+ * How long a tab's `idle` activity must hold continuously before we call the
41
+ * turn complete. `idle` can flash for a fraction of a second between two tool
42
+ * calls, so an end-of-turn signal that fired on the first idle tick would catch
43
+ * the worker mid-turn. Requiring idle to *hold* this long is the authoritative
44
+ * replacement for a driver's settle-poll loop.
45
+ */
46
+ const DEFAULT_TURN_SETTLE_MS = 1500
47
+
38
48
  export interface LoopTabView {
39
49
  id: string
40
50
  assistant: AssistantId
@@ -49,7 +59,30 @@ export interface StatusDetectionLoopOptions {
49
59
  onTabStatus: (tabId: string, status: TabActivity, sessionId: string) => void
50
60
  /** Emitted when either flag on a session changes. */
51
61
  onSessionStatus: (sessionId: string, status: SessionStatus) => void
62
+ /**
63
+ * Emitted once per turn, when a tab's `idle` activity has held continuously
64
+ * for `turnSettleMs`. Edge-triggered: re-armed only after the tab leaves
65
+ * `idle`, so a driver receives exactly one signal per turn. `idleMs` is how
66
+ * long idle had held when the signal fired.
67
+ */
68
+ onTurnComplete?: (tabId: string, sessionId: string, idleMs: number) => void
69
+ /**
70
+ * Emitted when a tab transitions into `waiting-input`. Edge-triggered: fires
71
+ * once per transition, carrying the captured prompt text and any parsed
72
+ * options. Fires on both tick and attach replay so a client that attaches to
73
+ * an already-blocked tab still learns the question.
74
+ */
75
+ onTabQuestion?: (
76
+ tabId: string,
77
+ sessionId: string,
78
+ detail: { kind: 'question' | 'permission'; prompt: string; options?: string[] }
79
+ ) => void
52
80
  tickMs?: number
81
+ /** Override the turn-complete settle window (default 1500ms). */
82
+ turnSettleMs?: number
83
+ /** Override the clock. Defaults to Date.now. Tests inject a logical clock so
84
+ * the wall-clock-driven turn-complete settle is deterministic. */
85
+ nowFn?: () => number
53
86
  }
54
87
 
55
88
  export interface StatusDetectionLoopHandle {
@@ -80,10 +113,18 @@ export function runStatusDetectionLoop(
80
113
  options: StatusDetectionLoopOptions
81
114
  ): StatusDetectionLoopHandle {
82
115
  const tickMs = options.tickMs ?? DEFAULT_TICK_MS
116
+ const turnSettleMs = options.turnSettleMs ?? DEFAULT_TURN_SETTLE_MS
117
+ const now = options.nowFn ?? Date.now
83
118
  const detector = new AssistantStatusDetector()
84
119
  const arbiter = new AssistantStatusArbiter()
85
120
  const lastTabStatus = new Map<string, { status: TabActivity; sessionId: string }>()
86
121
  const lastSessionStatus = new Map<string, SessionStatus>()
122
+ // Timestamp when a tab most recently entered `idle`. Cleared when it leaves
123
+ // idle. `turnEmitted` guards against re-firing `onTurnComplete` within the
124
+ // same idle episode; it's cleared alongside `idleSince` so the next turn
125
+ // re-arms.
126
+ const idleSince = new Map<string, number>()
127
+ const turnEmitted = new Set<string>()
87
128
 
88
129
  const timer = setInterval(() => {
89
130
  try {
@@ -134,6 +175,38 @@ export function runStatusDetectionLoop(
134
175
  lastTabStatus.set(tab.id, { sessionId, status })
135
176
  options.onTabStatus(tab.id, status, sessionId)
136
177
  }
178
+
179
+ // Turn-complete bookkeeping. The emission itself is gated to `tick` so a
180
+ // synchronous attach replay (`classifyNow`) never fabricates an
181
+ // end-of-turn — only the wall-clock-driven poll does. The idleSince /
182
+ // turnEmitted maps are maintained on every call so state stays coherent
183
+ // regardless of source.
184
+ if (status === 'idle') {
185
+ let since = idleSince.get(tab.id)
186
+ if (since === undefined) {
187
+ since = now
188
+ idleSince.set(tab.id, since)
189
+ }
190
+ if (source === 'tick' && !turnEmitted.has(tab.id) && now - since >= turnSettleMs) {
191
+ turnEmitted.add(tab.id)
192
+ options.onTurnComplete?.(tab.id, sessionId, now - since)
193
+ }
194
+ } else {
195
+ idleSince.delete(tab.id)
196
+ turnEmitted.delete(tab.id)
197
+ }
198
+
199
+ // Question extraction on the idle/working → waiting-input edge. `prev`
200
+ // still holds the pre-update status, so this fires exactly once per
201
+ // transition even though the loop broadcasts to every client.
202
+ if (
203
+ status === 'waiting-input' &&
204
+ (!prev || prev.status !== 'waiting-input') &&
205
+ options.onTabQuestion
206
+ ) {
207
+ const detail = extractQuestion(tab.assistant, tab.viewport)
208
+ if (detail) options.onTabQuestion(tab.id, sessionId, detail)
209
+ }
137
210
  }
138
211
  const next: SessionStatus = { waiting, working }
139
212
  const prevSession = lastSessionStatus.get(sessionId)
@@ -154,14 +227,14 @@ export function runStatusDetectionLoop(
154
227
  }
155
228
 
156
229
  function tick(): void {
157
- const now = Date.now()
230
+ const ts = now()
158
231
  const sessionIds = options.listSessions()
159
232
  const seenSessions = new Set<string>()
160
233
  const seenTabs = new Set<string>()
161
234
 
162
235
  for (const sessionId of sessionIds) {
163
236
  seenSessions.add(sessionId)
164
- classifySession(sessionId, options.listTabs(sessionId), now, 'tick', seenTabs)
237
+ classifySession(sessionId, options.listTabs(sessionId), ts, 'tick', seenTabs)
165
238
  }
166
239
 
167
240
  for (const tabId of lastTabStatus.keys()) {
@@ -169,6 +242,8 @@ export function runStatusDetectionLoop(
169
242
  detector.forget(tabId)
170
243
  arbiter.forget(tabId)
171
244
  lastTabStatus.delete(tabId)
245
+ idleSince.delete(tabId)
246
+ turnEmitted.delete(tabId)
172
247
  }
173
248
  }
174
249
  for (const sessionId of lastSessionStatus.keys()) {
@@ -180,7 +255,7 @@ export function runStatusDetectionLoop(
180
255
 
181
256
  return {
182
257
  classifyNow: (sessionId, tabs) => {
183
- classifySession(sessionId, tabs, Date.now(), 'classifyNow')
258
+ classifySession(sessionId, tabs, now(), 'classifyNow')
184
259
  },
185
260
  getSessionStatus: (sessionId) => lastSessionStatus.get(sessionId),
186
261
  getTabStatus: (tabId) => lastTabStatus.get(tabId)?.status,
@@ -206,6 +281,8 @@ export function runStatusDetectionLoop(
206
281
  arbiter.clear()
207
282
  lastTabStatus.clear()
208
283
  lastSessionStatus.clear()
284
+ idleSince.clear()
285
+ turnEmitted.clear()
209
286
  },
210
287
  }
211
288
  }
@@ -85,7 +85,14 @@ export class AssistantStatusDetector {
85
85
  }
86
86
  }
87
87
 
88
- function extractTailText(viewport: TerminalSnapshot, lineCount: number): string {
88
+ /**
89
+ * Last `lineCount` non-blank rendered lines, trailing-trimmed, oldest-first.
90
+ * Shared by the status detector (10-line tail for classification) and the
91
+ * question extractor (larger tail for prompt capture) so both read the screen
92
+ * the same way. Prefers `tailLines` when the user has scrolled the viewport
93
+ * off the active screen.
94
+ */
95
+ export function extractTailLines(viewport: TerminalSnapshot, lineCount: number): string[] {
89
96
  const isScrolledToBottom = viewport.viewportY === viewport.baseY
90
97
  const lines = isScrolledToBottom ? viewport.lines : (viewport.tailLines ?? viewport.lines)
91
98
  // Full-screen TUIs (claude, opencode) paint in the alternate buffer and
@@ -112,7 +119,11 @@ function extractTailText(viewport: TerminalSnapshot, lineCount: number): string
112
119
  if (!line) continue
113
120
  parts.push(getLineText(line).replace(/\s+$/u, ''))
114
121
  }
115
- return parts.join('\n')
122
+ return parts
123
+ }
124
+
125
+ function extractTailText(viewport: TerminalSnapshot, lineCount: number): string {
126
+ return extractTailLines(viewport, lineCount).join('\n')
116
127
  }
117
128
 
118
129
  function classifyBuiltin(
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Extract a terminal viewport's last non-blank rendered line.
3
+ *
4
+ * Used by the daemon's `listTabs` handler to answer "what is this worker
5
+ * doing?" without a per-tab `snapshot` round-trip. Unlike the status loop's
6
+ * private `tailPreview`, this does NOT cap the width — the orchestrator wants
7
+ * the whole status line — and it returns `undefined` (rather than a sentinel)
8
+ * when there is nothing to show, so the field is simply omitted from the wire.
9
+ */
10
+ import type { TerminalSnapshot } from '../state/types'
11
+
12
+ import { getLineText } from '../input/terminal-text-extraction'
13
+
14
+ /**
15
+ * The last non-blank line of `viewport`, trimmed. Scans rows from the bottom so
16
+ * trailing blank rows are skipped. Returns `undefined` when the viewport is
17
+ * missing or entirely blank.
18
+ */
19
+ export function lastNonBlankLine(viewport: TerminalSnapshot | undefined): string | undefined {
20
+ if (!viewport) return undefined
21
+ const lines = viewport.lines
22
+ for (let i = lines.length - 1; i >= 0; i--) {
23
+ const line = lines[i]
24
+ if (!line) continue
25
+ const text = getLineText(line).trim()
26
+ if (text.length > 0) return text
27
+ }
28
+ return undefined
29
+ }
@@ -192,6 +192,11 @@ export class RemoteSessionBackend
192
192
  })
193
193
  this.emit('tabActivity', message.payload.tabId, message.payload.status)
194
194
  break
195
+ case 'tabTurnComplete':
196
+ case 'tabQuestion':
197
+ // v13 orchestration signals — consumed by the headless CLI, not the UI
198
+ // backend. Enumerated so the switch stays exhaustive; no UI wiring yet.
199
+ break
195
200
  case 'sessionStatus':
196
201
  logDebug('backend.remote.sessionStatus', {
197
202
  sessionId: message.payload.sessionId,
@@ -18,6 +18,14 @@ export type LegacyPersistedTabStatus = TabStatus | 'exited'
18
18
 
19
19
  export type TabActivity = 'working' | 'waiting-input' | 'idle'
20
20
 
21
+ /**
22
+ * Classifies why a tab is blocked on user input. `permission` is a tool /
23
+ * command approval prompt; `question` is any other prompt the assistant is
24
+ * waiting on. Carried by the `tabQuestion` server event so an orchestrator can
25
+ * branch without re-scraping the screen.
26
+ */
27
+ export type QuestionKind = 'question' | 'permission'
28
+
21
29
  /**
22
30
  * Per-session status flags. Both can be true at once (e.g. one tab working,
23
31
  * another waiting for user input) so we keep them as independent booleans
@@ -15,7 +15,7 @@ import { formatDivergence } from '../../../../state/session-worktrees'
15
15
  import { IDLE_SESSION_STATUS } from '../../../../state/types'
16
16
  import { useBusySpinner } from '../../../hooks/use-busy-spinner'
17
17
  import { moveIdToIdPosition, orderSessionsForDisplay } from '../../../session-ordering'
18
- import { useTheme } from '../../../theme'
18
+ import { useBaseTheme, useTheme } from '../../../theme'
19
19
  import { FlashLabelBadge } from '../../flash/flash-label-badge'
20
20
  import { ContextMenuBox } from '../../overlays/context-menu/context-menu-box'
21
21
  import { useSidebarAutoScroll } from './use-sidebar-auto-scroll'
@@ -298,14 +298,17 @@ const WorkspaceRow = memo(function WorkspaceRow({
298
298
  status,
299
299
  }: WorkspaceRowProps) {
300
300
  const t = useTheme()
301
+ // Selection highlight must stay opaque in transparent mode — otherwise the
302
+ // cursor row visually disappears against the see-through chrome.
303
+ const base = useBaseTheme()
301
304
  const showSpinner = status.working
302
305
  const showWaiting = status.waiting
303
306
  const spinner = useBusySpinner(showSpinner)
304
307
  let bgColor: string | undefined
305
308
  if (dragging || isActiveItem) {
306
- bgColor = t.backgroundElement
309
+ bgColor = base.backgroundElement
307
310
  } else if (inCurrentGroup) {
308
- bgColor = t.backgroundPanel
311
+ bgColor = base.backgroundPanel
309
312
  }
310
313
  const workingColor = t.primary
311
314
  const waitingColor = t.warning
@@ -7,7 +7,7 @@ import type { SessionRecord, WorktreeRecord } from '../../../../state/types'
7
7
  import { useAppStore } from '../../../../state/app-store'
8
8
  import { dispatchGlobal, runSideEffectGlobal } from '../../../../state/dispatch-ref'
9
9
  import { formatDivergence } from '../../../../state/session-worktrees'
10
- import { useTheme } from '../../../theme'
10
+ import { useBaseTheme, useTheme } from '../../../theme'
11
11
  import { FlashLabelBadge } from '../../flash/flash-label-badge'
12
12
  import { ContextMenuBox } from '../../overlays/context-menu/context-menu-box'
13
13
 
@@ -33,6 +33,8 @@ export const WorktreeRow = memo(function WorktreeRow({
33
33
  worktree,
34
34
  }: WorktreeRowProps) {
35
35
  const t = useTheme()
36
+ // Selection highlight must stay opaque in transparent mode.
37
+ const base = useBaseTheme()
36
38
  const currentSessionId = useAppStore((s) => s.currentSessionId)
37
39
  const isCurrentSession = session.id === currentSessionId
38
40
  const divergence = useAppStore((s) => s.worktreeDivergence[worktree.id])
@@ -102,9 +104,9 @@ export const WorktreeRow = memo(function WorktreeRow({
102
104
 
103
105
  let bgColor: string | undefined
104
106
  if (isActiveItem) {
105
- bgColor = t.backgroundElement
107
+ bgColor = base.backgroundElement
106
108
  } else if (inCurrentGroup) {
107
- bgColor = t.backgroundPanel
109
+ bgColor = base.backgroundPanel
108
110
  }
109
111
 
110
112
  const connector = isLast ? '└─' : '├─'
@@ -11,7 +11,7 @@ import { useAIUsageStore } from '../../../state/ai-usage-store'
11
11
  import { useAppStore } from '../../../state/app-store'
12
12
  import { useKeymap } from '../../keymap-context'
13
13
  import { getStatusBarModel, type IdentitySegment } from '../../status-bar-model'
14
- import { useTheme } from '../../theme'
14
+ import { useBaseTheme, useTheme } from '../../theme'
15
15
  import { AIUsageIndicator } from '../overlays/ai-usage/ai-usage-indicator'
16
16
 
17
17
  // Powerline-style separator glyph pairs.
@@ -110,6 +110,10 @@ function Separator({ bg, fg, glyph }: { bg: string; fg: string; glyph: string })
110
110
 
111
111
  export function StatusBar() {
112
112
  const t = useTheme()
113
+ // Badge foregrounds (mode label, version) sit on colored tiles and would
114
+ // render invisible with a transparent chrome color — always use the base
115
+ // (opaque) background token for contrast against those tiles.
116
+ const base = useBaseTheme()
113
117
  const state = useAppStore((s) => s)
114
118
  const config = useKeymap()
115
119
  const model = getStatusBarModel(state, config)
@@ -139,7 +143,7 @@ export function StatusBar() {
139
143
  <box height={1} flexShrink={0} flexDirection="row" overflow="hidden">
140
144
  {/* A: mode */}
141
145
  <box backgroundColor={modeColor} paddingLeft={1} paddingRight={1}>
142
- <text fg={t.background} selectable={false}>
146
+ <text fg={base.background} selectable={false}>
143
147
  {getModeBadge(state.focusMode)}
144
148
  </text>
145
149
  </box>
@@ -186,7 +190,7 @@ export function StatusBar() {
186
190
 
187
191
  {/* Y: version */}
188
192
  <box backgroundColor={tileY} paddingLeft={1} paddingRight={1} flexShrink={0}>
189
- <text fg={t.background} selectable={false}>
193
+ <text fg={base.background} selectable={false}>
190
194
  v{APP_VERSION}
191
195
  </text>
192
196
  </box>
@@ -26,8 +26,7 @@ const themeStore = createStore<ThemeStore>(() => ({
26
26
  let cachedId: ThemeId | null = null
27
27
  let cachedMode: ThemeMode | null = null
28
28
  let cachedBase: ResolvedTuiTheme | null = null
29
- let cachedTransparent: boolean | null = null
30
- let cachedFinal: ResolvedTuiTheme | null = null
29
+ let cachedOverlay: ResolvedTuiTheme | null = null
31
30
 
32
31
  // Chrome surface tokens: when transparent mode is on we replace these with
33
32
  // 'transparent' so opentui's BoxRenderable skips its fill (alpha=0 early-returns
@@ -41,27 +40,27 @@ const CHROME_BG_TOKENS = [
41
40
  'backgroundMenu',
42
41
  ] as const
43
42
 
43
+ // Both the opaque base and the transparent overlay are cached side-by-side
44
+ // (both keyed by id+mode). A single-slot cache would thrash when useTheme() and
45
+ // useBaseTheme() are both mounted with different transparent values — each call
46
+ // would invalidate the other's cache, hand React a fresh object reference every
47
+ // render, and drive an infinite update loop.
44
48
  function derive(id: ThemeId, mode: ThemeMode, transparent: boolean): ResolvedTuiTheme {
45
- if (cachedFinal && cachedId === id && cachedMode === mode && cachedTransparent === transparent)
46
- return cachedFinal
47
-
48
49
  if (!cachedBase || cachedId !== id || cachedMode !== mode) {
49
50
  const json = TUI_THEMES[id] ?? TUI_THEMES.aimux
50
51
  if (!json) throw new Error(`No theme JSON for ${id}`)
51
52
  cachedBase = resolveTuiTheme(json, mode)
53
+ cachedOverlay = null
54
+ cachedId = id
55
+ cachedMode = mode
52
56
  }
53
- cachedId = id
54
- cachedMode = mode
55
- cachedTransparent = transparent
56
-
57
- if (transparent) {
57
+ if (!transparent) return cachedBase
58
+ if (!cachedOverlay) {
58
59
  const overlay: ResolvedTuiTheme = { ...cachedBase }
59
60
  for (const key of CHROME_BG_TOKENS) overlay[key] = 'transparent'
60
- cachedFinal = overlay
61
- } else {
62
- cachedFinal = cachedBase
61
+ cachedOverlay = overlay
63
62
  }
64
- return cachedFinal
63
+ return cachedOverlay
65
64
  }
66
65
 
67
66
  /** Subscribe to the resolved TUI theme for the active id+mode (+transparent overlay). */
@@ -69,6 +68,16 @@ export function useTheme(): ResolvedTuiTheme {
69
68
  return useStore(themeStore, (s) => derive(s.id, s.mode, s.transparent))
70
69
  }
71
70
 
71
+ /**
72
+ * Resolved TUI theme WITHOUT the transparent chrome overlay. Use for the rare
73
+ * chrome sites that must stay opaque even in transparent mode — e.g. the
74
+ * sidebar selection highlight (would otherwise vanish) and the status-bar
75
+ * badge foregrounds (would otherwise render as transparent-on-color).
76
+ */
77
+ export function useBaseTheme(): ResolvedTuiTheme {
78
+ return useStore(themeStore, (s) => derive(s.id, s.mode, false))
79
+ }
80
+
72
81
  /** Synchronous snapshot of the resolved theme for non-React callers. */
73
82
  export function getCurrentTheme(): ResolvedTuiTheme {
74
83
  const s = themeStore.getState()