@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.
- package/package.json +1 -1
- package/src/cli/commands/tab/list.ts +34 -3
- package/src/cli/commands/tab/prompt-io.ts +83 -0
- package/src/cli/commands/tab/run.ts +222 -0
- package/src/cli/commands/tab/send.ts +63 -59
- package/src/cli/index.ts +1 -0
- package/src/cli/output.ts +2 -0
- package/src/cli/registry.ts +2 -0
- package/src/daemon/daemon.ts +41 -0
- package/src/ipc/protocol.ts +84 -2
- package/src/pty/assistant-question-extractor.ts +124 -0
- package/src/pty/assistant-status-detection-loop.ts +80 -3
- package/src/pty/assistant-status-detector.ts +13 -2
- package/src/pty/last-line.ts +29 -0
- package/src/session-backend/remote-session-backend.ts +5 -0
- package/src/state/types.ts +8 -0
- package/src/ui/components/layout/sidebar/workspace-list.tsx +6 -3
- package/src/ui/components/layout/sidebar/worktree-row.tsx +5 -3
- package/src/ui/components/layout/status-bar.tsx +7 -3
- package/src/ui/theme-store.ts +23 -14
- package/src/ui/theme.ts +1 -0
package/src/ipc/protocol.ts
CHANGED
|
@@ -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 =
|
|
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
|
|
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),
|
|
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,
|
|
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
|
-
|
|
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
|
|
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,
|
package/src/state/types.ts
CHANGED
|
@@ -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 =
|
|
309
|
+
bgColor = base.backgroundElement
|
|
307
310
|
} else if (inCurrentGroup) {
|
|
308
|
-
bgColor =
|
|
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 =
|
|
107
|
+
bgColor = base.backgroundElement
|
|
106
108
|
} else if (inCurrentGroup) {
|
|
107
|
-
bgColor =
|
|
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={
|
|
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={
|
|
193
|
+
<text fg={base.background} selectable={false}>
|
|
190
194
|
v{APP_VERSION}
|
|
191
195
|
</text>
|
|
192
196
|
</box>
|
package/src/ui/theme-store.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
61
|
-
} else {
|
|
62
|
-
cachedFinal = cachedBase
|
|
61
|
+
cachedOverlay = overlay
|
|
63
62
|
}
|
|
64
|
-
return
|
|
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()
|