@phenx-inc/ctlsurf 0.9.0 → 0.11.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 +196 -2
- package/out/headless/index.mjs.map +4 -4
- package/out/main/index.js +326 -24
- package/out/preload/index.js +7 -0
- package/out/renderer/assets/{cssMode-BdYp0fp9.js → cssMode-Bmzy9hKX.js} +3 -3
- package/out/renderer/assets/{freemarker2-Cya-9zr-.js → freemarker2-CDAsExTa.js} +1 -1
- package/out/renderer/assets/{handlebars-EJs9W5Ub.js → handlebars-CvpBYG1L.js} +1 -1
- package/out/renderer/assets/{html-Ex4qy2lh.js → html-CNhEQqr8.js} +1 -1
- package/out/renderer/assets/{htmlMode-CCm0aJ6C.js → htmlMode-DD7YPgga.js} +3 -3
- package/out/renderer/assets/{index-Cj7K9-Ba.js → index-BDlCPzCG.js} +153 -146
- package/out/renderer/assets/{index-nvsNYP5c.css → index-CGHeDwZa.css} +23 -46
- package/out/renderer/assets/{javascript-B2niCouI.js → javascript-CsGLsswK.js} +2 -2
- package/out/renderer/assets/{jsonMode-BgCUHaFo.js → jsonMode-CTuGgRvC.js} +3 -3
- package/out/renderer/assets/{liquid-DOQmSo4G.js → liquid-BrrN4uul.js} +1 -1
- package/out/renderer/assets/{lspLanguageFeatures-8SIcRAMh.js → lspLanguageFeatures-Bhdn2Eqy.js} +1 -1
- package/out/renderer/assets/{mdx-DuKdGNKE.js → mdx-DJo-gax4.js} +1 -1
- package/out/renderer/assets/{python-BUa4XFG-.js → python-DYHeg5b2.js} +1 -1
- package/out/renderer/assets/{razor-BUw8jho0.js → razor-Cqcks14K.js} +1 -1
- package/out/renderer/assets/{tsMode-8FLRnobw.js → tsMode-YTLgCayh.js} +1 -1
- package/out/renderer/assets/{typescript-rdwAjCi4.js → typescript-oxBn_Vcw.js} +1 -1
- package/out/renderer/assets/{xml-HOzT59Qg.js → xml-BEqGMCTc.js} +1 -1
- package/out/renderer/assets/{yaml-B81JfLsW.js → yaml-BGSrk5HC.js} +1 -1
- package/out/renderer/index.html +2 -2
- package/package.json +1 -1
- package/src/main/agentState.ts +329 -0
- package/src/main/index.ts +135 -20
- package/src/main/orchestrator.ts +21 -1
- package/src/preload/index.ts +8 -0
- package/src/renderer/App.tsx +32 -9
- package/src/renderer/components/TicketPanel.tsx +20 -25
- package/src/renderer/styles.css +23 -46
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { conf as conf$1, language as language$1 } from "./typescript-
|
|
2
|
-
import "./index-
|
|
1
|
+
import { conf as conf$1, language as language$1 } from "./typescript-oxBn_Vcw.js";
|
|
2
|
+
import "./index-BDlCPzCG.js";
|
|
3
3
|
const conf = conf$1;
|
|
4
4
|
const language = {
|
|
5
5
|
// Set defaultToken to invalid to see what you do not tokenize yet
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { c as createWebWorker, l as languages, e as editor } from "./index-
|
|
2
|
-
import { f as DocumentFormattingEditProvider, g as DocumentRangeFormattingEditProvider, C as CompletionAdapter, H as HoverAdapter, b as DocumentSymbolAdapter, d as DocumentColorAdapter, F as FoldingRangeAdapter, S as SelectionRangeAdapter, e as DiagnosticsAdapter } from "./lspLanguageFeatures-
|
|
3
|
-
import { a, D, h, R, c, i, j, t, k } from "./lspLanguageFeatures-
|
|
1
|
+
import { c as createWebWorker, l as languages, e as editor } from "./index-BDlCPzCG.js";
|
|
2
|
+
import { f as DocumentFormattingEditProvider, g as DocumentRangeFormattingEditProvider, C as CompletionAdapter, H as HoverAdapter, b as DocumentSymbolAdapter, d as DocumentColorAdapter, F as FoldingRangeAdapter, S as SelectionRangeAdapter, e as DiagnosticsAdapter } from "./lspLanguageFeatures-Bhdn2Eqy.js";
|
|
3
|
+
import { a, D, h, R, c, i, j, t, k } from "./lspLanguageFeatures-Bhdn2Eqy.js";
|
|
4
4
|
const STOP_WHEN_IDLE_FOR = 2 * 60 * 1e3;
|
|
5
5
|
class WorkerManager {
|
|
6
6
|
constructor(defaults) {
|
package/out/renderer/assets/{lspLanguageFeatures-8SIcRAMh.js → lspLanguageFeatures-Bhdn2Eqy.js}
RENAMED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { R as Range$1, l as languages, e as editor, U as Uri, M as MarkerSeverity } from "./index-
|
|
1
|
+
import { R as Range$1, l as languages, e as editor, U as Uri, M as MarkerSeverity } from "./index-BDlCPzCG.js";
|
|
2
2
|
var DocumentUri;
|
|
3
3
|
(function(DocumentUri2) {
|
|
4
4
|
function is(value) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { c as createWebWorker, e as editor, U as Uri, a as MarkerTag, M as MarkerSeverity, l as languages, t as typescriptDefaults, R as Range } from "./index-
|
|
1
|
+
import { c as createWebWorker, e as editor, U as Uri, a as MarkerTag, M as MarkerSeverity, l as languages, t as typescriptDefaults, R as Range } from "./index-BDlCPzCG.js";
|
|
2
2
|
class WorkerManager {
|
|
3
3
|
constructor(_modeId, _defaults) {
|
|
4
4
|
this._modeId = _modeId;
|
package/out/renderer/index.html
CHANGED
|
@@ -49,8 +49,8 @@
|
|
|
49
49
|
color: #565f89;
|
|
50
50
|
}
|
|
51
51
|
</style>
|
|
52
|
-
<script type="module" crossorigin src="./assets/index-
|
|
53
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
52
|
+
<script type="module" crossorigin src="./assets/index-BDlCPzCG.js"></script>
|
|
53
|
+
<link rel="stylesheet" crossorigin href="./assets/index-CGHeDwZa.css">
|
|
54
54
|
</head>
|
|
55
55
|
<body>
|
|
56
56
|
<div id="boot-splash">
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phenx-inc/ctlsurf",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Agent-agnostic terminal and desktop app for ctlsurf — run Claude Code, Codex, or any coding agent with live session logging and remote control",
|
|
5
5
|
"main": "out/main/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -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,85 @@ 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 (Windows taskbar). `ready` switches between
|
|
55
|
+
// the project-badge blue and the green "ready for you" dot.
|
|
56
|
+
function createDotOverlay(ready: 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] = ready ? [52, 199, 89] : [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
|
+
// The plain dock icon (macOS), loaded once at startup. Reused to composite the
|
|
77
|
+
// green "ready" dot on top, and to restore the clean icon when it clears.
|
|
78
|
+
let baseDockIcon: Electron.NativeImage | null = null
|
|
79
|
+
|
|
80
|
+
// Composite a green "ready for you" dot (with a soft dark ring for contrast) into
|
|
81
|
+
// the lower-right of the dock icon. Reads/writes the bitmap as a matched
|
|
82
|
+
// toBitmap()/createFromBitmap() pair, which share the same (BGRA) byte order.
|
|
83
|
+
function dockIconWithReadyDot(base: Electron.NativeImage): Electron.NativeImage {
|
|
84
|
+
const { width, height } = base.getSize()
|
|
85
|
+
if (!width || !height) return base
|
|
86
|
+
const bmp = base.toBitmap()
|
|
87
|
+
const r = Math.round(Math.min(width, height) * 0.17) // dot radius
|
|
88
|
+
const ring = Math.max(2, Math.round(r * 0.22)) // ring thickness
|
|
89
|
+
const cx = width - r - Math.round(width * 0.05)
|
|
90
|
+
const cy = height - r - Math.round(height * 0.05)
|
|
91
|
+
const put = (x: number, y: number, b: number, g: number, rr: number) => {
|
|
92
|
+
const idx = (y * width + x) * 4
|
|
93
|
+
bmp[idx] = b; bmp[idx + 1] = g; bmp[idx + 2] = rr; bmp[idx + 3] = 255
|
|
94
|
+
}
|
|
95
|
+
for (let y = cy - r - ring; y <= cy + r + ring; y++) {
|
|
96
|
+
if (y < 0 || y >= height) continue
|
|
97
|
+
for (let x = cx - r - ring; x <= cx + r + ring; x++) {
|
|
98
|
+
if (x < 0 || x >= width) continue
|
|
99
|
+
const d = Math.sqrt((x - cx) ** 2 + (y - cy) ** 2)
|
|
100
|
+
if (d <= r) put(x, y, 89, 199, 52) // green #34c759 (BGRA)
|
|
101
|
+
else if (d <= r + ring) put(x, y, 24, 26, 31) // dark ring #1f1a18
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return nativeImage.createFromBitmap(bmp, { width, height })
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Show/clear the green "ready" dot on the macOS dock icon.
|
|
108
|
+
function setDockReadyDot(on: boolean): void {
|
|
109
|
+
if (process.platform !== 'darwin' || !app.dock || !baseDockIcon) return
|
|
110
|
+
app.dock.setIcon(on ? dockIconWithReadyDot(baseDockIcon) : baseDockIcon)
|
|
111
|
+
}
|
|
80
112
|
|
|
81
|
-
|
|
113
|
+
// Last project abbreviation, so the steady badge can be restored after the
|
|
114
|
+
// "awaiting input" indicator clears.
|
|
115
|
+
let projectAbbrev = ''
|
|
116
|
+
|
|
117
|
+
function applySteadyBadge(): void {
|
|
82
118
|
if (process.platform === 'darwin' && app.dock) {
|
|
83
|
-
app.dock.setBadge(
|
|
119
|
+
app.dock.setBadge(projectAbbrev)
|
|
84
120
|
}
|
|
85
|
-
|
|
86
|
-
// Windows: overlay icon on taskbar
|
|
87
121
|
if (process.platform === 'win32' && mainWindow) {
|
|
88
|
-
mainWindow.setOverlayIcon(
|
|
122
|
+
mainWindow.setOverlayIcon(projectAbbrev ? createDotOverlay(false) : null, projectAbbrev)
|
|
89
123
|
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function updateProjectBadge(cwdPath: string | null): void {
|
|
127
|
+
if (!cwdPath) return
|
|
128
|
+
projectAbbrev = getProjectAbbrev(cwdPath)
|
|
129
|
+
|
|
130
|
+
// The project badge and the "ready" dot are now independent surfaces (badge
|
|
131
|
+
// vs dock icon), so the badge can always update.
|
|
132
|
+
applySteadyBadge()
|
|
90
133
|
|
|
91
134
|
// All platforms: update window title
|
|
92
135
|
if (mainWindow) {
|
|
@@ -95,6 +138,66 @@ function updateProjectBadge(cwdPath: string | null): void {
|
|
|
95
138
|
}
|
|
96
139
|
}
|
|
97
140
|
|
|
141
|
+
// ─── "Ready for you" attention indicator (cross-platform) ──────────
|
|
142
|
+
//
|
|
143
|
+
// The detection is OS-independent; only the visual differs:
|
|
144
|
+
// macOS — steady green dot composited on the dock icon + one bounce
|
|
145
|
+
// Windows — green overlay icon on the taskbar button + flash the button
|
|
146
|
+
// Linux — badge count where supported + flash (window urgency hint)
|
|
147
|
+
// While the window is focused the dock/taskbar nag is suppressed (the in-app
|
|
148
|
+
// dot is enough); the renderer always gets `agent:state` regardless of OS.
|
|
149
|
+
|
|
150
|
+
const attention = new AttentionController()
|
|
151
|
+
let attentionActive = false
|
|
152
|
+
let bounceId = -1
|
|
153
|
+
|
|
154
|
+
function setAwaitingIndicator(on: boolean): void {
|
|
155
|
+
applyAttention(attention.setAwaiting(on))
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function applyAttention(active: boolean): void {
|
|
159
|
+
if (active) startAttention()
|
|
160
|
+
else stopAttention()
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function startAttention(): void {
|
|
164
|
+
if (attentionActive) return
|
|
165
|
+
attentionActive = true
|
|
166
|
+
log('[agent-state] attention START (green ready dot + bounce)')
|
|
167
|
+
|
|
168
|
+
if (process.platform === 'darwin' && app.dock) {
|
|
169
|
+
setDockReadyDot(true)
|
|
170
|
+
bounceId = app.dock.bounce('informational')
|
|
171
|
+
}
|
|
172
|
+
if (process.platform === 'win32' && mainWindow) {
|
|
173
|
+
mainWindow.setOverlayIcon(createDotOverlay(true), 'Ready for you')
|
|
174
|
+
mainWindow.flashFrame(true)
|
|
175
|
+
}
|
|
176
|
+
if (process.platform === 'linux') {
|
|
177
|
+
if (typeof app.badgeCount === 'number') app.badgeCount = 1
|
|
178
|
+
mainWindow?.flashFrame(true)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function stopAttention(): void {
|
|
183
|
+
if (!attentionActive) return
|
|
184
|
+
attentionActive = false
|
|
185
|
+
log('[agent-state] attention STOP')
|
|
186
|
+
|
|
187
|
+
if (process.platform === 'darwin' && app.dock) {
|
|
188
|
+
setDockReadyDot(false)
|
|
189
|
+
if (bounceId >= 0) { app.dock.cancelBounce(bounceId); bounceId = -1 }
|
|
190
|
+
}
|
|
191
|
+
if (process.platform === 'win32' && mainWindow) {
|
|
192
|
+
mainWindow.flashFrame(false)
|
|
193
|
+
}
|
|
194
|
+
if (process.platform === 'linux') {
|
|
195
|
+
if (typeof app.badgeCount === 'number') app.badgeCount = 0
|
|
196
|
+
mainWindow?.flashFrame(false)
|
|
197
|
+
}
|
|
198
|
+
applySteadyBadge()
|
|
199
|
+
}
|
|
200
|
+
|
|
98
201
|
// ─── Orchestrator (shared logic) ──────────────────
|
|
99
202
|
|
|
100
203
|
const orchestrator = new Orchestrator(
|
|
@@ -107,6 +210,11 @@ const orchestrator = new Orchestrator(
|
|
|
107
210
|
onWorkerRegistered: (data) => mainWindow?.webContents.send('worker:registered', data),
|
|
108
211
|
onProjectChanged: (name) => mainWindow?.webContents.send('app:projectChanged', name),
|
|
109
212
|
onAgentMessage: (text) => mainWindow?.webContents.send('agent:message', text),
|
|
213
|
+
onAgentStateChange: (tabId, state, anyAwaiting) => {
|
|
214
|
+
log(`[agent-state] tab=${tabId} state=${state} anyAwaiting=${anyAwaiting}`)
|
|
215
|
+
mainWindow?.webContents.send('agent:state', tabId, state, anyAwaiting)
|
|
216
|
+
setAwaitingIndicator(anyAwaiting)
|
|
217
|
+
},
|
|
110
218
|
onCwdChanged: () => {
|
|
111
219
|
mainWindow?.webContents.send('app:cwdChanged')
|
|
112
220
|
updateProjectBadge(orchestrator.cwd)
|
|
@@ -146,6 +254,12 @@ function createWindow(): void {
|
|
|
146
254
|
mainWindow.loadFile(path.join(__dirname, '../renderer/index.html'))
|
|
147
255
|
}
|
|
148
256
|
|
|
257
|
+
// Focusing the window means the user is here — drop the dock/taskbar nag (the
|
|
258
|
+
// in-app dot still shows the state). Re-arm it if the agent is still awaiting
|
|
259
|
+
// when focus is lost again.
|
|
260
|
+
mainWindow.on('focus', () => applyAttention(attention.setFocused(true)))
|
|
261
|
+
mainWindow.on('blur', () => applyAttention(attention.setFocused(false)))
|
|
262
|
+
|
|
149
263
|
mainWindow.on('closed', () => {
|
|
150
264
|
mainWindow = null
|
|
151
265
|
})
|
|
@@ -496,7 +610,8 @@ app.whenReady().then(() => {
|
|
|
496
610
|
if (process.platform === 'darwin' && app.dock) {
|
|
497
611
|
const iconPath = path.join(__dirname, '../../resources/icon.png')
|
|
498
612
|
try {
|
|
499
|
-
|
|
613
|
+
baseDockIcon = nativeImage.createFromPath(iconPath)
|
|
614
|
+
app.dock.setIcon(baseDockIcon)
|
|
500
615
|
} catch { /* ignore */ }
|
|
501
616
|
}
|
|
502
617
|
|