@phenx-inc/ctlsurf 0.8.0 → 0.10.0
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/out/headless/index.mjs +339 -2
- package/out/headless/index.mjs.map +4 -4
- package/out/main/index.js +441 -20
- package/out/preload/index.js +9 -0
- package/out/renderer/assets/{cssMode-BQN8v2ok.js → cssMode-BbCVOTaJ.js} +3 -3
- package/out/renderer/assets/{freemarker2-DbxGYYVp.js → freemarker2-DryLywQv.js} +1 -1
- package/out/renderer/assets/{handlebars-3auU1CAd.js → handlebars-CdUMtSdr.js} +1 -1
- package/out/renderer/assets/{html-D8xFiRmI.js → html-Cwkf2GMH.js} +1 -1
- package/out/renderer/assets/{htmlMode-M3MApZ4n.js → htmlMode-C7PzhXxH.js} +3 -3
- package/out/renderer/assets/{index-B-iM7dFC.css → index-BIceGcz3.css} +52 -0
- package/out/renderer/assets/{index---H6cxNl.js → index-Dg1vMspf.js} +274 -244
- package/out/renderer/assets/{javascript-BO_ViZM5.js → javascript-Cy7ROXJl.js} +2 -2
- package/out/renderer/assets/{jsonMode-CKp2zvZu.js → jsonMode-BtZtt3QW.js} +3 -3
- package/out/renderer/assets/{liquid-C1eHcrht.js → liquid-xReAQeYs.js} +1 -1
- package/out/renderer/assets/{lspLanguageFeatures-CHWJx_Tl.js → lspLanguageFeatures-Dmx0L0Bw.js} +1 -1
- package/out/renderer/assets/{mdx-Qqdtk7fL.js → mdx-YMNFO1Zz.js} +1 -1
- package/out/renderer/assets/{python-DKu7rNbs.js → python-BExaDVxv.js} +1 -1
- package/out/renderer/assets/{razor-BOMpCo6z.js → razor-HL8ANmQ4.js} +1 -1
- package/out/renderer/assets/{transformers.web-DtSCnG36.js → transformers.web-_PuZoIho.js} +824 -19
- package/out/renderer/assets/{tsMode-yAjlPR-D.js → tsMode-Dqd8vdLE.js} +1 -1
- package/out/renderer/assets/{typescript-BiJRCUcL.js → typescript-Dysz1PTX.js} +1 -1
- package/out/renderer/assets/{xml-D4PvYeQq.js → xml-BHp5Z9nc.js} +1 -1
- package/out/renderer/assets/{yaml-BeHVkmnS.js → yaml-CPDHxVup.js} +1 -1
- package/out/renderer/index.html +64 -2
- package/out/repro-toggle.mjs +305 -0
- package/package.json +1 -1
- package/src/main/agentState.ts +329 -0
- package/src/main/index.ts +107 -19
- package/src/main/orchestrator.ts +34 -2
- package/src/main/transcripts.ts +134 -0
- package/src/preload/index.ts +11 -0
- package/src/renderer/App.tsx +48 -13
- package/src/renderer/components/FloatingMic.tsx +48 -0
- package/src/renderer/components/VoiceInput.tsx +36 -149
- package/src/renderer/index.html +62 -0
- package/src/renderer/lib/localWhisper.ts +46 -2
- package/src/renderer/lib/speech.ts +18 -0
- package/src/renderer/styles.css +52 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent State Monitor
|
|
3
|
+
*
|
|
4
|
+
* Detects, in real time, whether a coding agent is BUSY (actively working) or
|
|
5
|
+
* AWAITING the user — either waiting for the next prompt (`awaiting_input`) or
|
|
6
|
+
* blocked on an approval/permission decision (`awaiting_decision`).
|
|
7
|
+
*
|
|
8
|
+
* This is the cross-platform "PTY backbone": it watches the raw terminal byte
|
|
9
|
+
* stream (which every agent produces regardless of OS) and infers state from
|
|
10
|
+
* the TUI's own affordances — the busy spinner (`esc to interrupt`) and
|
|
11
|
+
* approval prompts. It is deliberately independent of chat logging and of the
|
|
12
|
+
* ConversationBridge, whose transcript cleaner actively *discards* the spinner
|
|
13
|
+
* lines this monitor needs.
|
|
14
|
+
*
|
|
15
|
+
* Agents with native hooks (Claude Code, Codex) can deliver an authoritative
|
|
16
|
+
* state via `applyHookEvent()`, which overrides the heuristic for a short
|
|
17
|
+
* window. See agentHooks.ts.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export type AgentState = 'idle' | 'busy' | 'awaiting_input' | 'awaiting_decision'
|
|
21
|
+
|
|
22
|
+
export type AgentStateSink = (info: {
|
|
23
|
+
tabId: string
|
|
24
|
+
state: AgentState
|
|
25
|
+
/** True when any tracked tab is awaiting the user (drives the dock/taskbar). */
|
|
26
|
+
anyAwaiting: boolean
|
|
27
|
+
}) => void
|
|
28
|
+
|
|
29
|
+
// The spinner ticks roughly once per second; allow a couple of missed ticks
|
|
30
|
+
// before we stop considering the agent busy.
|
|
31
|
+
const BUSY_GRACE_MS = 2_500
|
|
32
|
+
// Output must be quiet this long after the spinner stops before we declare the
|
|
33
|
+
// agent is awaiting the user (avoids flagging mid-stream pauses).
|
|
34
|
+
const SETTLE_MS = 1_200
|
|
35
|
+
// How long a seen approval prompt keeps a tab in `awaiting_decision`.
|
|
36
|
+
const DECISION_TTL_MS = 4_000
|
|
37
|
+
// Evaluation cadence.
|
|
38
|
+
const TICK_MS = 400
|
|
39
|
+
// A hook-delivered state is authoritative for this long, suppressing the
|
|
40
|
+
// heuristic for that tab.
|
|
41
|
+
const HOOK_AUTHORITY_MS = 6_000
|
|
42
|
+
// Only the trailing slice of each tab's output is kept, enough to catch a
|
|
43
|
+
// marker split across two PTY chunks.
|
|
44
|
+
const TAIL_KEEP_CHARS = 240
|
|
45
|
+
|
|
46
|
+
// Claude Code's working spinner: a cycling star glyph followed by a gerund that
|
|
47
|
+
// ends in an ellipsis, e.g. "✶ Tempering…" or "✳ Cogitating...". The completion
|
|
48
|
+
// line ("✻ Worked for 2s") has the glyph but no ellipsis, so it doesn't match —
|
|
49
|
+
// letting the agent settle to `awaiting_input`. Newer Claude builds (Opus 4.8)
|
|
50
|
+
// don't always print "esc to interrupt" for fast turns, so this is the primary
|
|
51
|
+
// Claude signal; the older hints are kept for other builds and Codex.
|
|
52
|
+
const CLAUDE_SPINNER = /[✶✻✳✺✢✷✸✹❋][^\n]{0,60}(?:…|\.\.\.)/
|
|
53
|
+
|
|
54
|
+
/** Busy markers — the spinner/working hints a TUI shows while actively working. */
|
|
55
|
+
function hasBusyMarker(text: string): boolean {
|
|
56
|
+
return (
|
|
57
|
+
/esc to interrupt/i.test(text) ||
|
|
58
|
+
/\bWorking \(\d+s/i.test(text) ||
|
|
59
|
+
CLAUDE_SPINNER.test(text)
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Approval / permission prompts. Conservative on purpose: a false positive only
|
|
65
|
+
* mislabels the sub-state (the dock fires on either awaiting state anyway), but
|
|
66
|
+
* we still avoid obviously-noisy matches.
|
|
67
|
+
*/
|
|
68
|
+
const APPROVAL_PATTERNS: RegExp[] = [
|
|
69
|
+
/\bDo you want to (proceed|continue|create|make this edit|run)\b/i, // Claude Code
|
|
70
|
+
/❯\s*\d+\.\s*Yes\b/i, // Claude numbered-choice prompt
|
|
71
|
+
/\bAllow command\b/i, // Codex
|
|
72
|
+
/\bRun this command\?/i, // Codex
|
|
73
|
+
/\bApprove\b[^\n]*\?/i,
|
|
74
|
+
/\(y\/n\)/i,
|
|
75
|
+
/\[y\/N\]/i,
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
function hasApprovalMarker(text: string): boolean {
|
|
79
|
+
return APPROVAL_PATTERNS.some((re) => re.test(text))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Strip CSI/OSC escape sequences so markers aren't hidden behind color codes. */
|
|
83
|
+
function stripAnsi(str: string): string {
|
|
84
|
+
return str
|
|
85
|
+
// eslint-disable-next-line no-control-regex
|
|
86
|
+
.replace(/\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]/g, '')
|
|
87
|
+
// eslint-disable-next-line no-control-regex
|
|
88
|
+
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
|
|
89
|
+
// eslint-disable-next-line no-control-regex
|
|
90
|
+
.replace(/\x1b/g, '')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface TabRecord {
|
|
94
|
+
/** Agent id (e.g. 'claude', 'codex'); reserved for future per-agent tuning. */
|
|
95
|
+
agentId: string
|
|
96
|
+
cwd: string
|
|
97
|
+
state: AgentState
|
|
98
|
+
/** True once a busy spinner has been seen since the last user input. */
|
|
99
|
+
everBusy: boolean
|
|
100
|
+
lastBusyAt: number
|
|
101
|
+
lastOutputAt: number
|
|
102
|
+
lastDecisionAt: number
|
|
103
|
+
/** Authoritative state from a hook, and when it was delivered. */
|
|
104
|
+
hookState: AgentState | null
|
|
105
|
+
hookAt: number
|
|
106
|
+
/** Trailing output slice, to catch markers split across PTY chunks. */
|
|
107
|
+
tail: string
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export class AgentStateMonitor {
|
|
111
|
+
private readonly sink: AgentStateSink
|
|
112
|
+
private readonly now: () => number
|
|
113
|
+
private tabs = new Map<string, TabRecord>()
|
|
114
|
+
private timer: ReturnType<typeof setInterval> | null = null
|
|
115
|
+
private lastAnyAwaiting = false
|
|
116
|
+
|
|
117
|
+
constructor(sink: AgentStateSink, now: () => number = Date.now) {
|
|
118
|
+
this.sink = sink
|
|
119
|
+
this.now = now
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
start(): void {
|
|
123
|
+
if (this.timer) return
|
|
124
|
+
this.timer = setInterval(() => this.tick(), TICK_MS)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
stop(): void {
|
|
128
|
+
if (this.timer) {
|
|
129
|
+
clearInterval(this.timer)
|
|
130
|
+
this.timer = null
|
|
131
|
+
}
|
|
132
|
+
this.tabs.clear()
|
|
133
|
+
this.lastAnyAwaiting = false
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Register a tab so its raw PTY output can be classified. */
|
|
137
|
+
trackTab(tabId: string, agentId: string, cwd: string): void {
|
|
138
|
+
const now = this.now()
|
|
139
|
+
this.tabs.set(tabId, {
|
|
140
|
+
agentId,
|
|
141
|
+
cwd,
|
|
142
|
+
state: 'idle',
|
|
143
|
+
everBusy: false,
|
|
144
|
+
lastBusyAt: 0,
|
|
145
|
+
lastOutputAt: now,
|
|
146
|
+
lastDecisionAt: 0,
|
|
147
|
+
hookState: null,
|
|
148
|
+
hookAt: 0,
|
|
149
|
+
tail: '',
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
removeTab(tabId: string): void {
|
|
154
|
+
if (this.tabs.delete(tabId)) this.emitAggregate()
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Feed a raw PTY output chunk for a tab. */
|
|
158
|
+
feed(tabId: string, data: string): void {
|
|
159
|
+
const rec = this.tabs.get(tabId)
|
|
160
|
+
if (!rec) return
|
|
161
|
+
const now = this.now()
|
|
162
|
+
rec.lastOutputAt = now
|
|
163
|
+
|
|
164
|
+
const text = rec.tail + stripAnsi(data)
|
|
165
|
+
rec.tail = text.length > TAIL_KEEP_CHARS ? text.slice(-TAIL_KEEP_CHARS) : text
|
|
166
|
+
|
|
167
|
+
if (hasBusyMarker(text)) {
|
|
168
|
+
rec.lastBusyAt = now
|
|
169
|
+
rec.everBusy = true
|
|
170
|
+
}
|
|
171
|
+
if (hasApprovalMarker(text)) {
|
|
172
|
+
rec.lastDecisionAt = now
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Note input written to a tab's PTY. Only a *submit* (data containing a
|
|
178
|
+
* newline) counts as the user engaging — clearing any awaiting state and
|
|
179
|
+
* resetting the "has worked" flag so a leftover idle box doesn't re-flag.
|
|
180
|
+
*
|
|
181
|
+
* Individual keystrokes and, crucially, the terminal's own auto-responses
|
|
182
|
+
* (cursor-position / device-attribute replies that TUIs constantly trigger,
|
|
183
|
+
* which xterm.js writes straight back to the PTY) are ignored — otherwise
|
|
184
|
+
* the state would churn busy↔idle on every control-sequence handshake.
|
|
185
|
+
*/
|
|
186
|
+
noteInput(tabId: string, data: string): void {
|
|
187
|
+
if (!/[\r\n]/.test(data)) return
|
|
188
|
+
const rec = this.tabs.get(tabId)
|
|
189
|
+
if (!rec) return
|
|
190
|
+
const now = this.now()
|
|
191
|
+
rec.everBusy = false
|
|
192
|
+
rec.lastBusyAt = now // a submit means work is coming → busy, not awaiting
|
|
193
|
+
rec.lastDecisionAt = 0
|
|
194
|
+
rec.hookState = null
|
|
195
|
+
this.setState(rec, tabId, 'busy')
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Apply an authoritative state from an agent hook, addressed by cwd. Overrides
|
|
200
|
+
* the heuristic for HOOK_AUTHORITY_MS. `kind`:
|
|
201
|
+
* 'awaiting_input' — turn done / idle prompt (Stop, idle_prompt, agent-turn-complete)
|
|
202
|
+
* 'awaiting_decision' — permission/approval prompt (PermissionRequest, permission_prompt)
|
|
203
|
+
* 'busy' — user submitted a prompt (UserPromptSubmit)
|
|
204
|
+
*/
|
|
205
|
+
applyHookEvent(cwd: string, kind: AgentState): void {
|
|
206
|
+
const target = stripTrailingSep(cwd)
|
|
207
|
+
const now = this.now()
|
|
208
|
+
for (const [tabId, rec] of this.tabs) {
|
|
209
|
+
if (stripTrailingSep(rec.cwd) !== target) continue
|
|
210
|
+
rec.hookState = kind
|
|
211
|
+
rec.hookAt = now
|
|
212
|
+
if (kind === 'busy') {
|
|
213
|
+
rec.everBusy = false
|
|
214
|
+
rec.lastBusyAt = now
|
|
215
|
+
}
|
|
216
|
+
this.setState(rec, tabId, kind)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private tick(): void {
|
|
221
|
+
const now = this.now()
|
|
222
|
+
for (const [tabId, rec] of this.tabs) {
|
|
223
|
+
// Hook state wins while fresh.
|
|
224
|
+
if (rec.hookState && now - rec.hookAt < HOOK_AUTHORITY_MS) {
|
|
225
|
+
this.setState(rec, tabId, rec.hookState)
|
|
226
|
+
continue
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// `> 0` guards the "never seen" sentinel so a small/zero clock value
|
|
230
|
+
// doesn't read as recent activity.
|
|
231
|
+
const busyRecently = rec.lastBusyAt > 0 && now - rec.lastBusyAt < BUSY_GRACE_MS
|
|
232
|
+
const settled = now - rec.lastOutputAt >= SETTLE_MS
|
|
233
|
+
const decisionRecent = rec.lastDecisionAt > 0 && now - rec.lastDecisionAt < DECISION_TTL_MS
|
|
234
|
+
|
|
235
|
+
let next: AgentState | null = null
|
|
236
|
+
if (busyRecently) {
|
|
237
|
+
next = 'busy'
|
|
238
|
+
} else if (settled && rec.everBusy) {
|
|
239
|
+
// Awaiting the user. Keep the sub-state stable: once we're showing
|
|
240
|
+
// awaiting_decision it sticks (and awaiting_input may upgrade to it),
|
|
241
|
+
// but we never flap back down — the agent's screen repaints make an
|
|
242
|
+
// approval marker match only intermittently, which otherwise flickers
|
|
243
|
+
// the indicator between amber and red.
|
|
244
|
+
next = rec.state === 'awaiting_decision' || decisionRecent
|
|
245
|
+
? 'awaiting_decision'
|
|
246
|
+
: 'awaiting_input'
|
|
247
|
+
} else if (settled && !rec.everBusy) {
|
|
248
|
+
next = 'idle'
|
|
249
|
+
}
|
|
250
|
+
// next === null → output still streaming after spinner stopped; hold the
|
|
251
|
+
// current state until it settles.
|
|
252
|
+
if (next) this.setState(rec, tabId, next)
|
|
253
|
+
}
|
|
254
|
+
this.emitAggregate()
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private setState(rec: TabRecord, tabId: string, state: AgentState): void {
|
|
258
|
+
if (rec.state === state) return
|
|
259
|
+
rec.state = state
|
|
260
|
+
this.sink({ tabId, state, anyAwaiting: this.computeAnyAwaiting() })
|
|
261
|
+
this.lastAnyAwaiting = this.computeAnyAwaiting()
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private emitAggregate(): void {
|
|
265
|
+
const anyAwaiting = this.computeAnyAwaiting()
|
|
266
|
+
if (anyAwaiting === this.lastAnyAwaiting) return
|
|
267
|
+
this.lastAnyAwaiting = anyAwaiting
|
|
268
|
+
// Re-emit with a representative tab so the dock/aggregate listener updates
|
|
269
|
+
// even when no single tab's state label changed this tick.
|
|
270
|
+
const tabId = this.firstAwaitingTabId() ?? ''
|
|
271
|
+
const state = tabId ? (this.tabs.get(tabId)!.state) : 'idle'
|
|
272
|
+
this.sink({ tabId, state, anyAwaiting })
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private computeAnyAwaiting(): boolean {
|
|
276
|
+
for (const rec of this.tabs.values()) {
|
|
277
|
+
if (rec.state === 'awaiting_input' || rec.state === 'awaiting_decision') return true
|
|
278
|
+
}
|
|
279
|
+
return false
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private firstAwaitingTabId(): string | null {
|
|
283
|
+
for (const [tabId, rec] of this.tabs) {
|
|
284
|
+
if (rec.state === 'awaiting_input' || rec.state === 'awaiting_decision') return tabId
|
|
285
|
+
}
|
|
286
|
+
return null
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function stripTrailingSep(p: string): string {
|
|
291
|
+
return p.length > 1 ? p.replace(/[/\\]+$/, '') : p
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Decides whether the dock/taskbar "awaiting input" attention (blink + bounce)
|
|
296
|
+
* should currently be active. Pure boolean logic, separated from the Electron
|
|
297
|
+
* bindings in index.ts so it can be tested without a window.
|
|
298
|
+
*
|
|
299
|
+
* Rules:
|
|
300
|
+
* - Attention is active only while an agent is awaiting AND the window is
|
|
301
|
+
* unfocused AND the current awaiting episode hasn't been acknowledged.
|
|
302
|
+
* - Focusing the window during an episode acknowledges it; so does already
|
|
303
|
+
* being focused at the moment the episode begins (you saw it happen).
|
|
304
|
+
* - Acknowledgement lasts for the whole episode: blurring again does NOT
|
|
305
|
+
* re-nag. A fresh episode (agent works, then waits again) starts unack'd.
|
|
306
|
+
*/
|
|
307
|
+
export class AttentionController {
|
|
308
|
+
private awaiting = false
|
|
309
|
+
private focused = false
|
|
310
|
+
private acknowledged = false
|
|
311
|
+
|
|
312
|
+
/** Update awaiting state; returns whether attention should be active. */
|
|
313
|
+
setAwaiting(on: boolean): boolean {
|
|
314
|
+
if (on && !this.awaiting) this.acknowledged = this.focused // new episode
|
|
315
|
+
this.awaiting = on
|
|
316
|
+
return this.active()
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Update window focus; returns whether attention should be active. */
|
|
320
|
+
setFocused(focused: boolean): boolean {
|
|
321
|
+
this.focused = focused
|
|
322
|
+
if (focused && this.awaiting) this.acknowledged = true
|
|
323
|
+
return this.active()
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private active(): boolean {
|
|
327
|
+
return this.awaiting && !this.focused && !this.acknowledged
|
|
328
|
+
}
|
|
329
|
+
}
|
package/src/main/index.ts
CHANGED
|
@@ -24,6 +24,7 @@ function log(...args: unknown[]): void {
|
|
|
24
24
|
import { AgentConfig, getDefaultAgent, getAvailableAgents } from './agents'
|
|
25
25
|
import { getSettingsDir } from './settingsDir'
|
|
26
26
|
import { Orchestrator } from './orchestrator'
|
|
27
|
+
import { AttentionController } from './agentState'
|
|
27
28
|
|
|
28
29
|
let mainWindow: BrowserWindow | null = null
|
|
29
30
|
|
|
@@ -50,43 +51,47 @@ function getProjectAbbrev(cwdPath: string): string {
|
|
|
50
51
|
return word.slice(0, 2).toUpperCase()
|
|
51
52
|
}
|
|
52
53
|
|
|
53
|
-
|
|
54
|
-
|
|
54
|
+
// Draw a 32x32 filled circle overlay. `red` switches between the project-badge
|
|
55
|
+
// blue and the "awaiting input" red dot.
|
|
56
|
+
function createDotOverlay(red: boolean): Electron.NativeImage {
|
|
55
57
|
const size = 32
|
|
56
58
|
const canvas = Buffer.alloc(size * size * 4, 0) // RGBA
|
|
57
|
-
|
|
58
|
-
// Fill with a semi-transparent dark background circle
|
|
59
|
+
const [r, g, b] = red ? [235, 64, 52] : [122, 162, 247]
|
|
59
60
|
for (let y = 0; y < size; y++) {
|
|
60
61
|
for (let x = 0; x < size; x++) {
|
|
61
62
|
const cx = x - size / 2
|
|
62
63
|
const cy = y - size / 2
|
|
63
|
-
|
|
64
|
-
if (dist <= size / 2) {
|
|
64
|
+
if (Math.sqrt(cx * cx + cy * cy) <= size / 2) {
|
|
65
65
|
const idx = (y * size + x) * 4
|
|
66
|
-
canvas[idx] =
|
|
67
|
-
canvas[idx + 1] =
|
|
68
|
-
canvas[idx + 2] =
|
|
69
|
-
canvas[idx + 3] =
|
|
66
|
+
canvas[idx] = r
|
|
67
|
+
canvas[idx + 1] = g
|
|
68
|
+
canvas[idx + 2] = b
|
|
69
|
+
canvas[idx + 3] = 235
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
|
-
|
|
74
73
|
return nativeImage.createFromBuffer(canvas, { width: size, height: size })
|
|
75
74
|
}
|
|
76
75
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
76
|
+
// Last project abbreviation, so the steady badge can be restored after the
|
|
77
|
+
// "awaiting input" indicator clears.
|
|
78
|
+
let projectAbbrev = ''
|
|
80
79
|
|
|
81
|
-
|
|
80
|
+
function applySteadyBadge(): void {
|
|
82
81
|
if (process.platform === 'darwin' && app.dock) {
|
|
83
|
-
app.dock.setBadge(
|
|
82
|
+
app.dock.setBadge(projectAbbrev)
|
|
84
83
|
}
|
|
85
|
-
|
|
86
|
-
// Windows: overlay icon on taskbar
|
|
87
84
|
if (process.platform === 'win32' && mainWindow) {
|
|
88
|
-
mainWindow.setOverlayIcon(
|
|
85
|
+
mainWindow.setOverlayIcon(projectAbbrev ? createDotOverlay(false) : null, projectAbbrev)
|
|
89
86
|
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function updateProjectBadge(cwdPath: string | null): void {
|
|
90
|
+
if (!cwdPath) return
|
|
91
|
+
projectAbbrev = getProjectAbbrev(cwdPath)
|
|
92
|
+
|
|
93
|
+
// Don't stomp the awaiting-input blink; it restores the steady badge itself.
|
|
94
|
+
if (!blinkTimer) applySteadyBadge()
|
|
90
95
|
|
|
91
96
|
// All platforms: update window title
|
|
92
97
|
if (mainWindow) {
|
|
@@ -95,6 +100,76 @@ function updateProjectBadge(cwdPath: string | null): void {
|
|
|
95
100
|
}
|
|
96
101
|
}
|
|
97
102
|
|
|
103
|
+
// ─── "Awaiting input" attention indicator (cross-platform) ──────────
|
|
104
|
+
//
|
|
105
|
+
// The detection is OS-independent; only the visual differs:
|
|
106
|
+
// macOS — blink a red dot in the dock badge + one bounce
|
|
107
|
+
// Windows — red overlay icon on the taskbar button + flash the button
|
|
108
|
+
// Linux — badge count where supported + flash (window urgency hint)
|
|
109
|
+
// While the window is focused the dock/taskbar nag is suppressed (the in-app
|
|
110
|
+
// dot is enough); the renderer always gets `agent:state` regardless of OS.
|
|
111
|
+
|
|
112
|
+
const attention = new AttentionController()
|
|
113
|
+
let blinkTimer: ReturnType<typeof setInterval> | null = null
|
|
114
|
+
let blinkOn = false
|
|
115
|
+
let bounceId = -1
|
|
116
|
+
|
|
117
|
+
function setAwaitingIndicator(on: boolean): void {
|
|
118
|
+
applyAttention(attention.setAwaiting(on))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function applyAttention(active: boolean): void {
|
|
122
|
+
if (active) startAttention()
|
|
123
|
+
else stopAttention()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function startAttention(): void {
|
|
127
|
+
if (blinkTimer) return
|
|
128
|
+
log('[agent-state] attention START (blink + bounce)')
|
|
129
|
+
|
|
130
|
+
if (process.platform === 'darwin' && app.dock) {
|
|
131
|
+
bounceId = app.dock.bounce('informational')
|
|
132
|
+
}
|
|
133
|
+
if (process.platform === 'win32' && mainWindow) {
|
|
134
|
+
mainWindow.setOverlayIcon(createDotOverlay(true), 'Awaiting input')
|
|
135
|
+
mainWindow.flashFrame(true)
|
|
136
|
+
}
|
|
137
|
+
if (process.platform === 'linux') {
|
|
138
|
+
if (typeof app.badgeCount === 'number') app.badgeCount = 1
|
|
139
|
+
mainWindow?.flashFrame(true)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Blink the macOS dock badge — the whole red pill (project initials and all)
|
|
143
|
+
// toggles on/off, so the project letters are preserved on the "on" frames
|
|
144
|
+
// rather than being replaced by a dot.
|
|
145
|
+
blinkOn = true
|
|
146
|
+
blinkTimer = setInterval(() => {
|
|
147
|
+
blinkOn = !blinkOn
|
|
148
|
+
if (process.platform === 'darwin' && app.dock) {
|
|
149
|
+
app.dock.setBadge(blinkOn ? (projectAbbrev || '●') : '')
|
|
150
|
+
}
|
|
151
|
+
}, 600)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function stopAttention(): void {
|
|
155
|
+
if (blinkTimer) {
|
|
156
|
+
clearInterval(blinkTimer)
|
|
157
|
+
blinkTimer = null
|
|
158
|
+
log('[agent-state] attention STOP')
|
|
159
|
+
}
|
|
160
|
+
if (process.platform === 'darwin' && app.dock) {
|
|
161
|
+
if (bounceId >= 0) { app.dock.cancelBounce(bounceId); bounceId = -1 }
|
|
162
|
+
}
|
|
163
|
+
if (process.platform === 'win32' && mainWindow) {
|
|
164
|
+
mainWindow.flashFrame(false)
|
|
165
|
+
}
|
|
166
|
+
if (process.platform === 'linux') {
|
|
167
|
+
if (typeof app.badgeCount === 'number') app.badgeCount = 0
|
|
168
|
+
mainWindow?.flashFrame(false)
|
|
169
|
+
}
|
|
170
|
+
applySteadyBadge()
|
|
171
|
+
}
|
|
172
|
+
|
|
98
173
|
// ─── Orchestrator (shared logic) ──────────────────
|
|
99
174
|
|
|
100
175
|
const orchestrator = new Orchestrator(
|
|
@@ -107,6 +182,11 @@ const orchestrator = new Orchestrator(
|
|
|
107
182
|
onWorkerRegistered: (data) => mainWindow?.webContents.send('worker:registered', data),
|
|
108
183
|
onProjectChanged: (name) => mainWindow?.webContents.send('app:projectChanged', name),
|
|
109
184
|
onAgentMessage: (text) => mainWindow?.webContents.send('agent:message', text),
|
|
185
|
+
onAgentStateChange: (tabId, state, anyAwaiting) => {
|
|
186
|
+
log(`[agent-state] tab=${tabId} state=${state} anyAwaiting=${anyAwaiting}`)
|
|
187
|
+
mainWindow?.webContents.send('agent:state', tabId, state, anyAwaiting)
|
|
188
|
+
setAwaitingIndicator(anyAwaiting)
|
|
189
|
+
},
|
|
110
190
|
onCwdChanged: () => {
|
|
111
191
|
mainWindow?.webContents.send('app:cwdChanged')
|
|
112
192
|
updateProjectBadge(orchestrator.cwd)
|
|
@@ -146,6 +226,12 @@ function createWindow(): void {
|
|
|
146
226
|
mainWindow.loadFile(path.join(__dirname, '../renderer/index.html'))
|
|
147
227
|
}
|
|
148
228
|
|
|
229
|
+
// Focusing the window means the user is here — drop the dock/taskbar nag (the
|
|
230
|
+
// in-app dot still shows the state). Re-arm it if the agent is still awaiting
|
|
231
|
+
// when focus is lost again.
|
|
232
|
+
mainWindow.on('focus', () => applyAttention(attention.setFocused(true)))
|
|
233
|
+
mainWindow.on('blur', () => applyAttention(attention.setFocused(false)))
|
|
234
|
+
|
|
149
235
|
mainWindow.on('closed', () => {
|
|
150
236
|
mainWindow = null
|
|
151
237
|
})
|
|
@@ -420,6 +506,8 @@ ipcMain.handle('speak:set', (_event, enabled: boolean) => {
|
|
|
420
506
|
orchestrator.setSpeakReplies(!!enabled)
|
|
421
507
|
return { enabled: orchestrator.speakRepliesEnabled }
|
|
422
508
|
})
|
|
509
|
+
// Latest assistant reply from the active tab's transcript, for on-demand TTS.
|
|
510
|
+
ipcMain.handle('agent:getLastReply', () => orchestrator.getLastReply())
|
|
423
511
|
|
|
424
512
|
// ─── Tracking IPC ─────────────────────────────────
|
|
425
513
|
|
package/src/main/orchestrator.ts
CHANGED
|
@@ -6,7 +6,8 @@ import { PtyManager } from './pty'
|
|
|
6
6
|
import { AgentConfig, isCodingAgent } from './agents'
|
|
7
7
|
import { CtlsurfApi } from './ctlsurfApi'
|
|
8
8
|
import { ConversationBridge } from './bridge'
|
|
9
|
-
import { TranscriptTailer, supportsTranscriptLogging } from './transcripts'
|
|
9
|
+
import { TranscriptTailer, supportsTranscriptLogging, readLatestAssistantReply } from './transcripts'
|
|
10
|
+
import { AgentStateMonitor, type AgentState } from './agentState'
|
|
10
11
|
import { WorkerWsClient, type WorkerWsStatus, type IncomingMessage } from './workerWs'
|
|
11
12
|
import { TimeTracker } from './timeTracker'
|
|
12
13
|
import { TicketStore, type TicketInput } from './ticketStore'
|
|
@@ -51,6 +52,10 @@ export interface OrchestratorEvents {
|
|
|
51
52
|
// text-to-speech. Optional and wired only by the Electron entry — the
|
|
52
53
|
// headless build leaves it unset, so replies are never spoken in a terminal.
|
|
53
54
|
onAgentMessage?: (text: string) => void
|
|
55
|
+
// Real-time agent activity state (busy vs awaiting the user). `anyAwaiting`
|
|
56
|
+
// is true when any tab is waiting on the user, and drives the dock/taskbar
|
|
57
|
+
// attention indicator. Optional — only the Electron entry wires it.
|
|
58
|
+
onAgentStateChange?: (tabId: string, state: AgentState, anyAwaiting: boolean) => void
|
|
54
59
|
}
|
|
55
60
|
|
|
56
61
|
interface TabState {
|
|
@@ -102,11 +107,17 @@ export class Orchestrator {
|
|
|
102
107
|
private noProjectPollCwd: string | null = null
|
|
103
108
|
private currentProjectName: string | null = null
|
|
104
109
|
private transcriptTailer: TranscriptTailer | null = null
|
|
110
|
+
private readonly stateMonitor: AgentStateMonitor
|
|
105
111
|
|
|
106
112
|
constructor(settingsDir: string, events: OrchestratorEvents) {
|
|
107
113
|
this.settingsDir = settingsDir
|
|
108
114
|
this.events = events
|
|
109
115
|
|
|
116
|
+
this.stateMonitor = new AgentStateMonitor(({ tabId, state, anyAwaiting }) => {
|
|
117
|
+
this.events.onAgentStateChange?.(tabId, state, anyAwaiting)
|
|
118
|
+
})
|
|
119
|
+
this.stateMonitor.start()
|
|
120
|
+
|
|
110
121
|
this.workerWs = new WorkerWsClient({
|
|
111
122
|
onStatusChange: (status: WorkerWsStatus) => {
|
|
112
123
|
log(`[worker-ws] Status: ${status}`)
|
|
@@ -119,9 +130,10 @@ export class Orchestrator {
|
|
|
119
130
|
|
|
120
131
|
if (message.type === 'prompt' || message.type === 'task_dispatch') {
|
|
121
132
|
const activeTab = this.activeTabId ? this.tabs.get(this.activeTabId) : null
|
|
122
|
-
if (activeTab) {
|
|
133
|
+
if (activeTab && this.activeTabId) {
|
|
123
134
|
activeTab.ptyManager.write(message.content + '\r')
|
|
124
135
|
this.bridge.feedInput(message.content + '\r')
|
|
136
|
+
this.stateMonitor.noteInput(this.activeTabId, message.content + '\r')
|
|
125
137
|
}
|
|
126
138
|
}
|
|
127
139
|
},
|
|
@@ -159,6 +171,18 @@ export class Orchestrator {
|
|
|
159
171
|
return !!this.settings.speakReplies
|
|
160
172
|
}
|
|
161
173
|
|
|
174
|
+
// Latest assistant reply from a tab's transcript, for on-demand "read aloud".
|
|
175
|
+
// Defaults to the active tab. Returns null when the tab's agent has no native
|
|
176
|
+
// transcript (only Claude Code / Codex do) or nothing has been said yet. Reads
|
|
177
|
+
// the transcript directly, so it works regardless of the speakReplies setting.
|
|
178
|
+
getLastReply(tabId?: string): string | null {
|
|
179
|
+
const id = tabId || this.activeTabId
|
|
180
|
+
if (!id) return null
|
|
181
|
+
const tab = this.tabs.get(id)
|
|
182
|
+
if (!tab || !supportsTranscriptLogging(tab.agent.id)) return null
|
|
183
|
+
return readLatestAssistantReply({ agentId: tab.agent.id, cwd: tab.cwd })
|
|
184
|
+
}
|
|
185
|
+
|
|
162
186
|
setLogChat(enabled: boolean): void {
|
|
163
187
|
this.settings.logChat = enabled
|
|
164
188
|
this.saveSettings()
|
|
@@ -451,10 +475,14 @@ export class Orchestrator {
|
|
|
451
475
|
const ptyManager = new PtyManager(agent, cwd)
|
|
452
476
|
const tab: TabState = { ptyManager, agent, cwd, termStreamBuffer: '', termStreamTimer: null }
|
|
453
477
|
this.tabs.set(tabId, tab)
|
|
478
|
+
// Track all tabs (not just the active one): a background agent finishing is
|
|
479
|
+
// exactly when the dock indicator is most useful.
|
|
480
|
+
this.stateMonitor.trackTab(tabId, agent.id, cwd)
|
|
454
481
|
|
|
455
482
|
ptyManager.onData((data: string) => {
|
|
456
483
|
this.events.onPtyData(tabId, data)
|
|
457
484
|
this.timeTracker.recordActivity(tabId)
|
|
485
|
+
this.stateMonitor.feed(tabId, data)
|
|
458
486
|
if (tabId === this.activeTabId) {
|
|
459
487
|
this.bridge.feedOutput(data)
|
|
460
488
|
this.streamTerminalData(tabId, data)
|
|
@@ -473,6 +501,7 @@ export class Orchestrator {
|
|
|
473
501
|
// Clean up tab state
|
|
474
502
|
const t = this.tabs.get(tabId)
|
|
475
503
|
if (t?.termStreamTimer) clearTimeout(t.termStreamTimer)
|
|
504
|
+
this.stateMonitor.removeTab(tabId)
|
|
476
505
|
})
|
|
477
506
|
|
|
478
507
|
this.startChatLogging(agent, cwd)
|
|
@@ -499,6 +528,7 @@ export class Orchestrator {
|
|
|
499
528
|
|
|
500
529
|
writePty(tabId: string, data: string): void {
|
|
501
530
|
this.tabs.get(tabId)?.ptyManager.write(data)
|
|
531
|
+
this.stateMonitor.noteInput(tabId, data)
|
|
502
532
|
if (tabId === this.activeTabId) {
|
|
503
533
|
this.bridge.feedInput(data)
|
|
504
534
|
}
|
|
@@ -519,6 +549,7 @@ export class Orchestrator {
|
|
|
519
549
|
await this.timeTracker.endSession(tabId)
|
|
520
550
|
tab.ptyManager.kill()
|
|
521
551
|
this.tabs.delete(tabId)
|
|
552
|
+
this.stateMonitor.removeTab(tabId)
|
|
522
553
|
if (tabId === this.activeTabId) {
|
|
523
554
|
this.stopChatLogging()
|
|
524
555
|
if (isCodingAgent(tab.agent)) {
|
|
@@ -690,6 +721,7 @@ export class Orchestrator {
|
|
|
690
721
|
async shutdown(): Promise<void> {
|
|
691
722
|
this.stopNoProjectPolling()
|
|
692
723
|
this.stopChatLogging()
|
|
724
|
+
this.stateMonitor.stop()
|
|
693
725
|
await this.timeTracker.endAll()
|
|
694
726
|
for (const [, tab] of this.tabs) {
|
|
695
727
|
if (tab.termStreamTimer) clearTimeout(tab.termStreamTimer)
|