@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.
Files changed (38) hide show
  1. package/out/headless/index.mjs +339 -2
  2. package/out/headless/index.mjs.map +4 -4
  3. package/out/main/index.js +441 -20
  4. package/out/preload/index.js +9 -0
  5. package/out/renderer/assets/{cssMode-BQN8v2ok.js → cssMode-BbCVOTaJ.js} +3 -3
  6. package/out/renderer/assets/{freemarker2-DbxGYYVp.js → freemarker2-DryLywQv.js} +1 -1
  7. package/out/renderer/assets/{handlebars-3auU1CAd.js → handlebars-CdUMtSdr.js} +1 -1
  8. package/out/renderer/assets/{html-D8xFiRmI.js → html-Cwkf2GMH.js} +1 -1
  9. package/out/renderer/assets/{htmlMode-M3MApZ4n.js → htmlMode-C7PzhXxH.js} +3 -3
  10. package/out/renderer/assets/{index-B-iM7dFC.css → index-BIceGcz3.css} +52 -0
  11. package/out/renderer/assets/{index---H6cxNl.js → index-Dg1vMspf.js} +274 -244
  12. package/out/renderer/assets/{javascript-BO_ViZM5.js → javascript-Cy7ROXJl.js} +2 -2
  13. package/out/renderer/assets/{jsonMode-CKp2zvZu.js → jsonMode-BtZtt3QW.js} +3 -3
  14. package/out/renderer/assets/{liquid-C1eHcrht.js → liquid-xReAQeYs.js} +1 -1
  15. package/out/renderer/assets/{lspLanguageFeatures-CHWJx_Tl.js → lspLanguageFeatures-Dmx0L0Bw.js} +1 -1
  16. package/out/renderer/assets/{mdx-Qqdtk7fL.js → mdx-YMNFO1Zz.js} +1 -1
  17. package/out/renderer/assets/{python-DKu7rNbs.js → python-BExaDVxv.js} +1 -1
  18. package/out/renderer/assets/{razor-BOMpCo6z.js → razor-HL8ANmQ4.js} +1 -1
  19. package/out/renderer/assets/{transformers.web-DtSCnG36.js → transformers.web-_PuZoIho.js} +824 -19
  20. package/out/renderer/assets/{tsMode-yAjlPR-D.js → tsMode-Dqd8vdLE.js} +1 -1
  21. package/out/renderer/assets/{typescript-BiJRCUcL.js → typescript-Dysz1PTX.js} +1 -1
  22. package/out/renderer/assets/{xml-D4PvYeQq.js → xml-BHp5Z9nc.js} +1 -1
  23. package/out/renderer/assets/{yaml-BeHVkmnS.js → yaml-CPDHxVup.js} +1 -1
  24. package/out/renderer/index.html +64 -2
  25. package/out/repro-toggle.mjs +305 -0
  26. package/package.json +1 -1
  27. package/src/main/agentState.ts +329 -0
  28. package/src/main/index.ts +107 -19
  29. package/src/main/orchestrator.ts +34 -2
  30. package/src/main/transcripts.ts +134 -0
  31. package/src/preload/index.ts +11 -0
  32. package/src/renderer/App.tsx +48 -13
  33. package/src/renderer/components/FloatingMic.tsx +48 -0
  34. package/src/renderer/components/VoiceInput.tsx +36 -149
  35. package/src/renderer/index.html +62 -0
  36. package/src/renderer/lib/localWhisper.ts +46 -2
  37. package/src/renderer/lib/speech.ts +18 -0
  38. package/src/renderer/styles.css +52 -0
package/out/main/index.js CHANGED
@@ -5948,7 +5948,7 @@ class TerminalCapture {
5948
5948
  return lines.join("\n");
5949
5949
  }
5950
5950
  }
5951
- function stripAnsi(str) {
5951
+ function stripAnsi$1(str) {
5952
5952
  return str.replace(/\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]/g, "").replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "").replace(/\x1b[PX^_][^\x1b]*\x1b\\/g, "").replace(/\x1b[^[\]PX^_](.|$)/g, "").replace(/\x1b/g, "");
5953
5953
  }
5954
5954
  function processBackspaces(str) {
@@ -5963,13 +5963,13 @@ function processBackspaces(str) {
5963
5963
  return result.join("");
5964
5964
  }
5965
5965
  function cleanInput(str) {
5966
- let cleaned = stripAnsi(str);
5966
+ let cleaned = stripAnsi$1(str);
5967
5967
  cleaned = processBackspaces(cleaned);
5968
5968
  cleaned = cleaned.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "");
5969
5969
  return cleaned.trim();
5970
5970
  }
5971
5971
  function normalizeTerminalText(str) {
5972
- let cleaned = stripAnsi(str);
5972
+ let cleaned = stripAnsi$1(str);
5973
5973
  cleaned = cleaned.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
5974
5974
  cleaned = cleaned.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "");
5975
5975
  cleaned = cleaned.replace(/[ \t]+\n/g, "\n");
@@ -6058,7 +6058,7 @@ class TranscriptTailer {
6058
6058
  sinceMs = 0;
6059
6059
  constructor(options) {
6060
6060
  this.agentId = options.agentId;
6061
- this.cwd = stripTrailingSep(options.cwd);
6061
+ this.cwd = stripTrailingSep$1(options.cwd);
6062
6062
  this.sink = options.sink;
6063
6063
  this.claudeProjectsDir = options.claudeProjectsDir || path.join(os.homedir(), ".claude", "projects");
6064
6064
  this.codexSessionsDir = options.codexSessionsDir || path.join(os.homedir(), ".codex", "sessions");
@@ -6192,7 +6192,7 @@ class TranscriptTailer {
6192
6192
  if (!obj || typeof obj !== "object") return null;
6193
6193
  if (obj.isMeta) return null;
6194
6194
  if (obj.type !== "user" && obj.type !== "assistant") return null;
6195
- if (typeof obj.cwd === "string" && stripTrailingSep(obj.cwd) !== this.cwd) return null;
6195
+ if (typeof obj.cwd === "string" && stripTrailingSep$1(obj.cwd) !== this.cwd) return null;
6196
6196
  if (obj.isSidechain) return null;
6197
6197
  const message = obj.message;
6198
6198
  if (!message) return null;
@@ -6210,7 +6210,7 @@ class TranscriptTailer {
6210
6210
  const payload = obj.payload;
6211
6211
  if (obj.type === "session_meta") {
6212
6212
  const metaCwd = payload?.cwd;
6213
- if (typeof metaCwd === "string" && stripTrailingSep(metaCwd) !== this.cwd) {
6213
+ if (typeof metaCwd === "string" && stripTrailingSep$1(metaCwd) !== this.cwd) {
6214
6214
  tail.excluded = true;
6215
6215
  }
6216
6216
  return null;
@@ -6275,13 +6275,345 @@ function extractClaudeText(content, isUser) {
6275
6275
  }
6276
6276
  return parts.join("\n\n");
6277
6277
  }
6278
- function stripTrailingSep(p2) {
6278
+ function stripTrailingSep$1(p2) {
6279
6279
  return p2.length > 1 ? p2.replace(/[/\\]+$/, "") : p2;
6280
6280
  }
6281
6281
  function capLength(str) {
6282
6282
  if (str.length <= MAX_ENTRY_CHARS) return str;
6283
6283
  return str.slice(0, MAX_ENTRY_CHARS) + `… [truncated, ${str.length} total chars]`;
6284
6284
  }
6285
+ function readLatestAssistantReply(opts) {
6286
+ try {
6287
+ const cwd = stripTrailingSep$1(opts.cwd);
6288
+ if (opts.agentId === "claude") {
6289
+ const file = mostRecentClaudeFile(cwd, opts.claudeProjectsDir);
6290
+ if (!file) return null;
6291
+ const text = lastClaudeAssistant(file, cwd);
6292
+ return text ? capLength(text) : null;
6293
+ }
6294
+ const codexRoot = opts.codexSessionsDir || path.join(os.homedir(), ".codex", "sessions");
6295
+ for (const file of recentCodexRollouts(codexRoot)) {
6296
+ const text = lastCodexAgentMessage(file, cwd);
6297
+ if (text) return capLength(text);
6298
+ }
6299
+ return null;
6300
+ } catch {
6301
+ return null;
6302
+ }
6303
+ }
6304
+ function readLines(file) {
6305
+ try {
6306
+ return fs.readFileSync(file, "utf-8").split("\n");
6307
+ } catch {
6308
+ return [];
6309
+ }
6310
+ }
6311
+ function mostRecentClaudeFile(cwd, root) {
6312
+ const projectsRoot = root || path.join(os.homedir(), ".claude", "projects");
6313
+ const slug = cwd.replace(/[^a-zA-Z0-9]/g, "-");
6314
+ const dir = path.join(projectsRoot, slug);
6315
+ let names;
6316
+ try {
6317
+ names = fs.readdirSync(dir);
6318
+ } catch {
6319
+ return null;
6320
+ }
6321
+ let best = null;
6322
+ let bestMs = -Infinity;
6323
+ for (const name of names) {
6324
+ if (!name.endsWith(".jsonl")) continue;
6325
+ const fp = path.join(dir, name);
6326
+ try {
6327
+ const ms = fs.statSync(fp).mtimeMs;
6328
+ if (ms > bestMs) {
6329
+ bestMs = ms;
6330
+ best = fp;
6331
+ }
6332
+ } catch {
6333
+ }
6334
+ }
6335
+ return best;
6336
+ }
6337
+ function recentCodexRollouts(root) {
6338
+ const dirs = /* @__PURE__ */ new Set();
6339
+ const now = Date.now();
6340
+ for (const ms of [now, now - 24 * 60 * 60 * 1e3]) {
6341
+ const d = new Date(ms);
6342
+ const yyyy = String(d.getFullYear());
6343
+ const mm = String(d.getMonth() + 1).padStart(2, "0");
6344
+ const dd = String(d.getDate()).padStart(2, "0");
6345
+ dirs.add(path.join(root, yyyy, mm, dd));
6346
+ }
6347
+ const files = [];
6348
+ for (const dir of dirs) {
6349
+ let names;
6350
+ try {
6351
+ names = fs.readdirSync(dir);
6352
+ } catch {
6353
+ continue;
6354
+ }
6355
+ for (const name of names) {
6356
+ if (!name.startsWith("rollout-") || !name.endsWith(".jsonl")) continue;
6357
+ const fp = path.join(dir, name);
6358
+ try {
6359
+ files.push({ fp, ms: fs.statSync(fp).mtimeMs });
6360
+ } catch {
6361
+ }
6362
+ }
6363
+ }
6364
+ return files.sort((a, b2) => b2.ms - a.ms).map((f) => f.fp);
6365
+ }
6366
+ function lastClaudeAssistant(file, cwd) {
6367
+ const lines = readLines(file);
6368
+ for (let i = lines.length - 1; i >= 0; i--) {
6369
+ const trimmed = lines[i].trim();
6370
+ if (!trimmed) continue;
6371
+ let obj;
6372
+ try {
6373
+ obj = JSON.parse(trimmed);
6374
+ } catch {
6375
+ continue;
6376
+ }
6377
+ if (!obj || typeof obj !== "object") continue;
6378
+ if (obj.isMeta || obj.isSidechain || obj.type !== "assistant") continue;
6379
+ if (typeof obj.cwd === "string" && stripTrailingSep$1(obj.cwd) !== cwd) continue;
6380
+ const text = extractClaudeText(obj.message?.content, false);
6381
+ if (text) return text;
6382
+ }
6383
+ return null;
6384
+ }
6385
+ function lastCodexAgentMessage(file, cwd) {
6386
+ const lines = readLines(file);
6387
+ for (const line of lines) {
6388
+ const trimmed = line.trim();
6389
+ if (!trimmed) continue;
6390
+ let obj;
6391
+ try {
6392
+ obj = JSON.parse(trimmed);
6393
+ } catch {
6394
+ continue;
6395
+ }
6396
+ if (obj?.type === "session_meta") {
6397
+ const metaCwd = obj.payload?.cwd;
6398
+ if (typeof metaCwd === "string" && stripTrailingSep$1(metaCwd) !== cwd) return null;
6399
+ break;
6400
+ }
6401
+ }
6402
+ for (let i = lines.length - 1; i >= 0; i--) {
6403
+ const trimmed = lines[i].trim();
6404
+ if (!trimmed) continue;
6405
+ let obj;
6406
+ try {
6407
+ obj = JSON.parse(trimmed);
6408
+ } catch {
6409
+ continue;
6410
+ }
6411
+ if (obj?.type !== "event_msg" || obj.payload?.type !== "agent_message") continue;
6412
+ const text = typeof obj.payload.message === "string" ? obj.payload.message.trim() : "";
6413
+ if (text && !isCodexNoise(text)) return text;
6414
+ }
6415
+ return null;
6416
+ }
6417
+ const BUSY_GRACE_MS = 2500;
6418
+ const SETTLE_MS = 1200;
6419
+ const DECISION_TTL_MS = 4e3;
6420
+ const TICK_MS = 400;
6421
+ const HOOK_AUTHORITY_MS = 6e3;
6422
+ const TAIL_KEEP_CHARS = 240;
6423
+ const CLAUDE_SPINNER = /[✶✻✳✺✢✷✸✹❋][^\n]{0,60}(?:…|\.\.\.)/;
6424
+ function hasBusyMarker(text) {
6425
+ return /esc to interrupt/i.test(text) || /\bWorking \(\d+s/i.test(text) || CLAUDE_SPINNER.test(text);
6426
+ }
6427
+ const APPROVAL_PATTERNS = [
6428
+ /\bDo you want to (proceed|continue|create|make this edit|run)\b/i,
6429
+ // Claude Code
6430
+ /❯\s*\d+\.\s*Yes\b/i,
6431
+ // Claude numbered-choice prompt
6432
+ /\bAllow command\b/i,
6433
+ // Codex
6434
+ /\bRun this command\?/i,
6435
+ // Codex
6436
+ /\bApprove\b[^\n]*\?/i,
6437
+ /\(y\/n\)/i,
6438
+ /\[y\/N\]/i
6439
+ ];
6440
+ function hasApprovalMarker(text) {
6441
+ return APPROVAL_PATTERNS.some((re) => re.test(text));
6442
+ }
6443
+ function stripAnsi(str) {
6444
+ return str.replace(/\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]/g, "").replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "").replace(/\x1b/g, "");
6445
+ }
6446
+ class AgentStateMonitor {
6447
+ sink;
6448
+ now;
6449
+ tabs = /* @__PURE__ */ new Map();
6450
+ timer = null;
6451
+ lastAnyAwaiting = false;
6452
+ constructor(sink, now = Date.now) {
6453
+ this.sink = sink;
6454
+ this.now = now;
6455
+ }
6456
+ start() {
6457
+ if (this.timer) return;
6458
+ this.timer = setInterval(() => this.tick(), TICK_MS);
6459
+ }
6460
+ stop() {
6461
+ if (this.timer) {
6462
+ clearInterval(this.timer);
6463
+ this.timer = null;
6464
+ }
6465
+ this.tabs.clear();
6466
+ this.lastAnyAwaiting = false;
6467
+ }
6468
+ /** Register a tab so its raw PTY output can be classified. */
6469
+ trackTab(tabId, agentId, cwd) {
6470
+ const now = this.now();
6471
+ this.tabs.set(tabId, {
6472
+ agentId,
6473
+ cwd,
6474
+ state: "idle",
6475
+ everBusy: false,
6476
+ lastBusyAt: 0,
6477
+ lastOutputAt: now,
6478
+ lastDecisionAt: 0,
6479
+ hookState: null,
6480
+ hookAt: 0,
6481
+ tail: ""
6482
+ });
6483
+ }
6484
+ removeTab(tabId) {
6485
+ if (this.tabs.delete(tabId)) this.emitAggregate();
6486
+ }
6487
+ /** Feed a raw PTY output chunk for a tab. */
6488
+ feed(tabId, data) {
6489
+ const rec = this.tabs.get(tabId);
6490
+ if (!rec) return;
6491
+ const now = this.now();
6492
+ rec.lastOutputAt = now;
6493
+ const text = rec.tail + stripAnsi(data);
6494
+ rec.tail = text.length > TAIL_KEEP_CHARS ? text.slice(-TAIL_KEEP_CHARS) : text;
6495
+ if (hasBusyMarker(text)) {
6496
+ rec.lastBusyAt = now;
6497
+ rec.everBusy = true;
6498
+ }
6499
+ if (hasApprovalMarker(text)) {
6500
+ rec.lastDecisionAt = now;
6501
+ }
6502
+ }
6503
+ /**
6504
+ * Note input written to a tab's PTY. Only a *submit* (data containing a
6505
+ * newline) counts as the user engaging — clearing any awaiting state and
6506
+ * resetting the "has worked" flag so a leftover idle box doesn't re-flag.
6507
+ *
6508
+ * Individual keystrokes and, crucially, the terminal's own auto-responses
6509
+ * (cursor-position / device-attribute replies that TUIs constantly trigger,
6510
+ * which xterm.js writes straight back to the PTY) are ignored — otherwise
6511
+ * the state would churn busy↔idle on every control-sequence handshake.
6512
+ */
6513
+ noteInput(tabId, data) {
6514
+ if (!/[\r\n]/.test(data)) return;
6515
+ const rec = this.tabs.get(tabId);
6516
+ if (!rec) return;
6517
+ const now = this.now();
6518
+ rec.everBusy = false;
6519
+ rec.lastBusyAt = now;
6520
+ rec.lastDecisionAt = 0;
6521
+ rec.hookState = null;
6522
+ this.setState(rec, tabId, "busy");
6523
+ }
6524
+ /**
6525
+ * Apply an authoritative state from an agent hook, addressed by cwd. Overrides
6526
+ * the heuristic for HOOK_AUTHORITY_MS. `kind`:
6527
+ * 'awaiting_input' — turn done / idle prompt (Stop, idle_prompt, agent-turn-complete)
6528
+ * 'awaiting_decision' — permission/approval prompt (PermissionRequest, permission_prompt)
6529
+ * 'busy' — user submitted a prompt (UserPromptSubmit)
6530
+ */
6531
+ applyHookEvent(cwd, kind) {
6532
+ const target = stripTrailingSep(cwd);
6533
+ const now = this.now();
6534
+ for (const [tabId, rec] of this.tabs) {
6535
+ if (stripTrailingSep(rec.cwd) !== target) continue;
6536
+ rec.hookState = kind;
6537
+ rec.hookAt = now;
6538
+ if (kind === "busy") {
6539
+ rec.everBusy = false;
6540
+ rec.lastBusyAt = now;
6541
+ }
6542
+ this.setState(rec, tabId, kind);
6543
+ }
6544
+ }
6545
+ tick() {
6546
+ const now = this.now();
6547
+ for (const [tabId, rec] of this.tabs) {
6548
+ if (rec.hookState && now - rec.hookAt < HOOK_AUTHORITY_MS) {
6549
+ this.setState(rec, tabId, rec.hookState);
6550
+ continue;
6551
+ }
6552
+ const busyRecently = rec.lastBusyAt > 0 && now - rec.lastBusyAt < BUSY_GRACE_MS;
6553
+ const settled = now - rec.lastOutputAt >= SETTLE_MS;
6554
+ const decisionRecent = rec.lastDecisionAt > 0 && now - rec.lastDecisionAt < DECISION_TTL_MS;
6555
+ let next = null;
6556
+ if (busyRecently) {
6557
+ next = "busy";
6558
+ } else if (settled && rec.everBusy) {
6559
+ next = rec.state === "awaiting_decision" || decisionRecent ? "awaiting_decision" : "awaiting_input";
6560
+ } else if (settled && !rec.everBusy) {
6561
+ next = "idle";
6562
+ }
6563
+ if (next) this.setState(rec, tabId, next);
6564
+ }
6565
+ this.emitAggregate();
6566
+ }
6567
+ setState(rec, tabId, state) {
6568
+ if (rec.state === state) return;
6569
+ rec.state = state;
6570
+ this.sink({ tabId, state, anyAwaiting: this.computeAnyAwaiting() });
6571
+ this.lastAnyAwaiting = this.computeAnyAwaiting();
6572
+ }
6573
+ emitAggregate() {
6574
+ const anyAwaiting = this.computeAnyAwaiting();
6575
+ if (anyAwaiting === this.lastAnyAwaiting) return;
6576
+ this.lastAnyAwaiting = anyAwaiting;
6577
+ const tabId = this.firstAwaitingTabId() ?? "";
6578
+ const state = tabId ? this.tabs.get(tabId).state : "idle";
6579
+ this.sink({ tabId, state, anyAwaiting });
6580
+ }
6581
+ computeAnyAwaiting() {
6582
+ for (const rec of this.tabs.values()) {
6583
+ if (rec.state === "awaiting_input" || rec.state === "awaiting_decision") return true;
6584
+ }
6585
+ return false;
6586
+ }
6587
+ firstAwaitingTabId() {
6588
+ for (const [tabId, rec] of this.tabs) {
6589
+ if (rec.state === "awaiting_input" || rec.state === "awaiting_decision") return tabId;
6590
+ }
6591
+ return null;
6592
+ }
6593
+ }
6594
+ function stripTrailingSep(p2) {
6595
+ return p2.length > 1 ? p2.replace(/[/\\]+$/, "") : p2;
6596
+ }
6597
+ class AttentionController {
6598
+ awaiting = false;
6599
+ focused = false;
6600
+ acknowledged = false;
6601
+ /** Update awaiting state; returns whether attention should be active. */
6602
+ setAwaiting(on) {
6603
+ if (on && !this.awaiting) this.acknowledged = this.focused;
6604
+ this.awaiting = on;
6605
+ return this.active();
6606
+ }
6607
+ /** Update window focus; returns whether attention should be active. */
6608
+ setFocused(focused) {
6609
+ this.focused = focused;
6610
+ if (focused && this.awaiting) this.acknowledged = true;
6611
+ return this.active();
6612
+ }
6613
+ active() {
6614
+ return this.awaiting && !this.focused && !this.acknowledged;
6615
+ }
6616
+ }
6285
6617
  var bufferUtil = { exports: {} };
6286
6618
  var constants;
6287
6619
  var hasRequiredConstants;
@@ -10962,9 +11294,14 @@ class Orchestrator {
10962
11294
  noProjectPollCwd = null;
10963
11295
  currentProjectName = null;
10964
11296
  transcriptTailer = null;
11297
+ stateMonitor;
10965
11298
  constructor(settingsDir, events) {
10966
11299
  this.settingsDir = settingsDir;
10967
11300
  this.events = events;
11301
+ this.stateMonitor = new AgentStateMonitor(({ tabId, state, anyAwaiting }) => {
11302
+ this.events.onAgentStateChange?.(tabId, state, anyAwaiting);
11303
+ });
11304
+ this.stateMonitor.start();
10968
11305
  this.workerWs = new WorkerWsClient({
10969
11306
  onStatusChange: (status) => {
10970
11307
  log$3(`[worker-ws] Status: ${status}`);
@@ -10976,9 +11313,10 @@ class Orchestrator {
10976
11313
  this.workerWs.sendAck(message.id);
10977
11314
  if (message.type === "prompt" || message.type === "task_dispatch") {
10978
11315
  const activeTab = this.activeTabId ? this.tabs.get(this.activeTabId) : null;
10979
- if (activeTab) {
11316
+ if (activeTab && this.activeTabId) {
10980
11317
  activeTab.ptyManager.write(message.content + "\r");
10981
11318
  this.bridge.feedInput(message.content + "\r");
11319
+ this.stateMonitor.noteInput(this.activeTabId, message.content + "\r");
10982
11320
  }
10983
11321
  }
10984
11322
  },
@@ -11011,6 +11349,17 @@ class Orchestrator {
11011
11349
  get speakRepliesEnabled() {
11012
11350
  return !!this.settings.speakReplies;
11013
11351
  }
11352
+ // Latest assistant reply from a tab's transcript, for on-demand "read aloud".
11353
+ // Defaults to the active tab. Returns null when the tab's agent has no native
11354
+ // transcript (only Claude Code / Codex do) or nothing has been said yet. Reads
11355
+ // the transcript directly, so it works regardless of the speakReplies setting.
11356
+ getLastReply(tabId) {
11357
+ const id = tabId || this.activeTabId;
11358
+ if (!id) return null;
11359
+ const tab = this.tabs.get(id);
11360
+ if (!tab || !supportsTranscriptLogging(tab.agent.id)) return null;
11361
+ return readLatestAssistantReply({ agentId: tab.agent.id, cwd: tab.cwd });
11362
+ }
11014
11363
  setLogChat(enabled) {
11015
11364
  this.settings.logChat = enabled;
11016
11365
  this.saveSettings();
@@ -11258,9 +11607,11 @@ class Orchestrator {
11258
11607
  const ptyManager = new PtyManager(agent, cwd);
11259
11608
  const tab = { ptyManager, agent, cwd, termStreamBuffer: "", termStreamTimer: null };
11260
11609
  this.tabs.set(tabId, tab);
11610
+ this.stateMonitor.trackTab(tabId, agent.id, cwd);
11261
11611
  ptyManager.onData((data) => {
11262
11612
  this.events.onPtyData(tabId, data);
11263
11613
  this.timeTracker.recordActivity(tabId);
11614
+ this.stateMonitor.feed(tabId, data);
11264
11615
  if (tabId === this.activeTabId) {
11265
11616
  this.bridge.feedOutput(data);
11266
11617
  this.streamTerminalData(tabId, data);
@@ -11277,6 +11628,7 @@ class Orchestrator {
11277
11628
  }
11278
11629
  const t = this.tabs.get(tabId);
11279
11630
  if (t?.termStreamTimer) clearTimeout(t.termStreamTimer);
11631
+ this.stateMonitor.removeTab(tabId);
11280
11632
  });
11281
11633
  this.startChatLogging(agent, cwd);
11282
11634
  const profile = this.getActiveProfile();
@@ -11299,6 +11651,7 @@ class Orchestrator {
11299
11651
  }
11300
11652
  writePty(tabId, data) {
11301
11653
  this.tabs.get(tabId)?.ptyManager.write(data);
11654
+ this.stateMonitor.noteInput(tabId, data);
11302
11655
  if (tabId === this.activeTabId) {
11303
11656
  this.bridge.feedInput(data);
11304
11657
  }
@@ -11317,6 +11670,7 @@ class Orchestrator {
11317
11670
  await this.timeTracker.endSession(tabId);
11318
11671
  tab.ptyManager.kill();
11319
11672
  this.tabs.delete(tabId);
11673
+ this.stateMonitor.removeTab(tabId);
11320
11674
  if (tabId === this.activeTabId) {
11321
11675
  this.stopChatLogging();
11322
11676
  if (isCodingAgent(tab.agent)) {
@@ -11470,6 +11824,7 @@ class Orchestrator {
11470
11824
  async shutdown() {
11471
11825
  this.stopNoProjectPolling();
11472
11826
  this.stopChatLogging();
11827
+ this.stateMonitor.stop();
11473
11828
  await this.timeTracker.endAll();
11474
11829
  for (const [, tab] of this.tabs) {
11475
11830
  if (tab.termStreamTimer) clearTimeout(tab.termStreamTimer);
@@ -11510,39 +11865,97 @@ function getProjectAbbrev(cwdPath) {
11510
11865
  if (word.length <= 3) return word.toUpperCase();
11511
11866
  return word.slice(0, 2).toUpperCase();
11512
11867
  }
11513
- function createOverlayIcon(text) {
11868
+ function createDotOverlay(red) {
11514
11869
  const size = 32;
11515
11870
  const canvas = Buffer.alloc(size * size * 4, 0);
11871
+ const [r, g2, b2] = red ? [235, 64, 52] : [122, 162, 247];
11516
11872
  for (let y2 = 0; y2 < size; y2++) {
11517
11873
  for (let x = 0; x < size; x++) {
11518
11874
  const cx = x - size / 2;
11519
11875
  const cy = y2 - size / 2;
11520
- const dist = Math.sqrt(cx * cx + cy * cy);
11521
- if (dist <= size / 2) {
11876
+ if (Math.sqrt(cx * cx + cy * cy) <= size / 2) {
11522
11877
  const idx = (y2 * size + x) * 4;
11523
- canvas[idx] = 122;
11524
- canvas[idx + 1] = 162;
11525
- canvas[idx + 2] = 247;
11526
- canvas[idx + 3] = 220;
11878
+ canvas[idx] = r;
11879
+ canvas[idx + 1] = g2;
11880
+ canvas[idx + 2] = b2;
11881
+ canvas[idx + 3] = 235;
11527
11882
  }
11528
11883
  }
11529
11884
  }
11530
11885
  return electron.nativeImage.createFromBuffer(canvas, { width: size, height: size });
11531
11886
  }
11532
- function updateProjectBadge(cwdPath) {
11533
- if (!cwdPath) return;
11534
- const abbrev = getProjectAbbrev(cwdPath);
11887
+ let projectAbbrev = "";
11888
+ function applySteadyBadge() {
11535
11889
  if (process.platform === "darwin" && electron.app.dock) {
11536
- electron.app.dock.setBadge(abbrev);
11890
+ electron.app.dock.setBadge(projectAbbrev);
11537
11891
  }
11538
11892
  if (process.platform === "win32" && mainWindow) {
11539
- mainWindow.setOverlayIcon(createOverlayIcon(), abbrev);
11893
+ mainWindow.setOverlayIcon(projectAbbrev ? createDotOverlay(false) : null, projectAbbrev);
11540
11894
  }
11895
+ }
11896
+ function updateProjectBadge(cwdPath) {
11897
+ if (!cwdPath) return;
11898
+ projectAbbrev = getProjectAbbrev(cwdPath);
11899
+ if (!blinkTimer) applySteadyBadge();
11541
11900
  if (mainWindow) {
11542
11901
  const folderName = cwdPath.split("/").filter(Boolean).pop() || "ctlsurf-worker";
11543
11902
  mainWindow.setTitle(`ctlsurf-worker — ${folderName}`);
11544
11903
  }
11545
11904
  }
11905
+ const attention = new AttentionController();
11906
+ let blinkTimer = null;
11907
+ let blinkOn = false;
11908
+ let bounceId = -1;
11909
+ function setAwaitingIndicator(on) {
11910
+ applyAttention(attention.setAwaiting(on));
11911
+ }
11912
+ function applyAttention(active) {
11913
+ if (active) startAttention();
11914
+ else stopAttention();
11915
+ }
11916
+ function startAttention() {
11917
+ if (blinkTimer) return;
11918
+ log("[agent-state] attention START (blink + bounce)");
11919
+ if (process.platform === "darwin" && electron.app.dock) {
11920
+ bounceId = electron.app.dock.bounce("informational");
11921
+ }
11922
+ if (process.platform === "win32" && mainWindow) {
11923
+ mainWindow.setOverlayIcon(createDotOverlay(true), "Awaiting input");
11924
+ mainWindow.flashFrame(true);
11925
+ }
11926
+ if (process.platform === "linux") {
11927
+ if (typeof electron.app.badgeCount === "number") electron.app.badgeCount = 1;
11928
+ mainWindow?.flashFrame(true);
11929
+ }
11930
+ blinkOn = true;
11931
+ blinkTimer = setInterval(() => {
11932
+ blinkOn = !blinkOn;
11933
+ if (process.platform === "darwin" && electron.app.dock) {
11934
+ electron.app.dock.setBadge(blinkOn ? projectAbbrev || "●" : "");
11935
+ }
11936
+ }, 600);
11937
+ }
11938
+ function stopAttention() {
11939
+ if (blinkTimer) {
11940
+ clearInterval(blinkTimer);
11941
+ blinkTimer = null;
11942
+ log("[agent-state] attention STOP");
11943
+ }
11944
+ if (process.platform === "darwin" && electron.app.dock) {
11945
+ if (bounceId >= 0) {
11946
+ electron.app.dock.cancelBounce(bounceId);
11947
+ bounceId = -1;
11948
+ }
11949
+ }
11950
+ if (process.platform === "win32" && mainWindow) {
11951
+ mainWindow.flashFrame(false);
11952
+ }
11953
+ if (process.platform === "linux") {
11954
+ if (typeof electron.app.badgeCount === "number") electron.app.badgeCount = 0;
11955
+ mainWindow?.flashFrame(false);
11956
+ }
11957
+ applySteadyBadge();
11958
+ }
11546
11959
  const orchestrator = new Orchestrator(
11547
11960
  getSettingsDir(),
11548
11961
  {
@@ -11553,6 +11966,11 @@ const orchestrator = new Orchestrator(
11553
11966
  onWorkerRegistered: (data) => mainWindow?.webContents.send("worker:registered", data),
11554
11967
  onProjectChanged: (name) => mainWindow?.webContents.send("app:projectChanged", name),
11555
11968
  onAgentMessage: (text) => mainWindow?.webContents.send("agent:message", text),
11969
+ onAgentStateChange: (tabId, state, anyAwaiting) => {
11970
+ log(`[agent-state] tab=${tabId} state=${state} anyAwaiting=${anyAwaiting}`);
11971
+ mainWindow?.webContents.send("agent:state", tabId, state, anyAwaiting);
11972
+ setAwaitingIndicator(anyAwaiting);
11973
+ },
11556
11974
  onCwdChanged: () => {
11557
11975
  mainWindow?.webContents.send("app:cwdChanged");
11558
11976
  updateProjectBadge(orchestrator.cwd);
@@ -11583,6 +12001,8 @@ function createWindow() {
11583
12001
  } else {
11584
12002
  mainWindow.loadFile(path.join(__dirname, "../renderer/index.html"));
11585
12003
  }
12004
+ mainWindow.on("focus", () => applyAttention(attention.setFocused(true)));
12005
+ mainWindow.on("blur", () => applyAttention(attention.setFocused(false)));
11586
12006
  mainWindow.on("closed", () => {
11587
12007
  mainWindow = null;
11588
12008
  });
@@ -11793,6 +12213,7 @@ electron.ipcMain.handle("speak:set", (_event, enabled) => {
11793
12213
  orchestrator.setSpeakReplies(!!enabled);
11794
12214
  return { enabled: orchestrator.speakRepliesEnabled };
11795
12215
  });
12216
+ electron.ipcMain.handle("agent:getLastReply", () => orchestrator.getLastReply());
11796
12217
  electron.ipcMain.handle("tracking:get", () => ({ active: orchestrator.isActiveTabTracking() }));
11797
12218
  electron.ipcMain.handle("tracking:set", async (_event, enabled) => {
11798
12219
  await orchestrator.setActiveTabTracking(enabled);
@@ -69,6 +69,15 @@ const api = {
69
69
  electron.ipcRenderer.on("agent:message", listener);
70
70
  return () => electron.ipcRenderer.removeListener("agent:message", listener);
71
71
  },
72
+ // Real-time agent activity state for an in-app indicator dot.
73
+ // state: 'idle' | 'busy' | 'awaiting_input' | 'awaiting_decision'
74
+ onAgentState: (callback) => {
75
+ const listener = (_event, tabId, state, anyAwaiting) => callback(tabId, state, anyAwaiting);
76
+ electron.ipcRenderer.on("agent:state", listener);
77
+ return () => electron.ipcRenderer.removeListener("agent:state", listener);
78
+ },
79
+ // Latest assistant reply from the active tab's transcript (null if none).
80
+ getLastReply: () => electron.ipcRenderer.invoke("agent:getLastReply"),
72
81
  // Filesystem
73
82
  readDir: (dirPath) => electron.ipcRenderer.invoke("fs:readDir", dirPath),
74
83
  readFile: (filePath) => electron.ipcRenderer.invoke("fs:readFile", filePath),
@@ -1,6 +1,6 @@
1
- import { c as createWebWorker, l as languages } from "./index---H6cxNl.js";
2
- import { C as CompletionAdapter, H as HoverAdapter, D as DocumentHighlightAdapter, a as DefinitionAdapter, R as ReferenceAdapter, b as DocumentSymbolAdapter, c as RenameAdapter, d as DocumentColorAdapter, F as FoldingRangeAdapter, e as DiagnosticsAdapter, S as SelectionRangeAdapter, f as DocumentFormattingEditProvider, g as DocumentRangeFormattingEditProvider } from "./lspLanguageFeatures-CHWJx_Tl.js";
3
- import { h, i, j, t, k } from "./lspLanguageFeatures-CHWJx_Tl.js";
1
+ import { c as createWebWorker, l as languages } from "./index-Dg1vMspf.js";
2
+ import { C as CompletionAdapter, H as HoverAdapter, D as DocumentHighlightAdapter, a as DefinitionAdapter, R as ReferenceAdapter, b as DocumentSymbolAdapter, c as RenameAdapter, d as DocumentColorAdapter, F as FoldingRangeAdapter, e as DiagnosticsAdapter, S as SelectionRangeAdapter, f as DocumentFormattingEditProvider, g as DocumentRangeFormattingEditProvider } from "./lspLanguageFeatures-Dmx0L0Bw.js";
3
+ import { h, i, j, t, k } from "./lspLanguageFeatures-Dmx0L0Bw.js";
4
4
  const STOP_WHEN_IDLE_FOR = 2 * 60 * 1e3;
5
5
  class WorkerManager {
6
6
  constructor(defaults) {
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index---H6cxNl.js";
1
+ import { l as languages } from "./index-Dg1vMspf.js";
2
2
  const EMPTY_ELEMENTS = [
3
3
  "assign",
4
4
  "flush",
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index---H6cxNl.js";
1
+ import { l as languages } from "./index-Dg1vMspf.js";
2
2
  const EMPTY_ELEMENTS = [
3
3
  "area",
4
4
  "base",
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index---H6cxNl.js";
1
+ import { l as languages } from "./index-Dg1vMspf.js";
2
2
  const EMPTY_ELEMENTS = [
3
3
  "area",
4
4
  "base",
@@ -1,6 +1,6 @@
1
- import { c as createWebWorker, l as languages } from "./index---H6cxNl.js";
2
- import { H as HoverAdapter, D as DocumentHighlightAdapter, h as DocumentLinkAdapter, F as FoldingRangeAdapter, b as DocumentSymbolAdapter, S as SelectionRangeAdapter, c as RenameAdapter, f as DocumentFormattingEditProvider, g as DocumentRangeFormattingEditProvider, C as CompletionAdapter } from "./lspLanguageFeatures-CHWJx_Tl.js";
3
- import { a, e, d, R, i, j, t, k } from "./lspLanguageFeatures-CHWJx_Tl.js";
1
+ import { c as createWebWorker, l as languages } from "./index-Dg1vMspf.js";
2
+ import { H as HoverAdapter, D as DocumentHighlightAdapter, h as DocumentLinkAdapter, F as FoldingRangeAdapter, b as DocumentSymbolAdapter, S as SelectionRangeAdapter, c as RenameAdapter, f as DocumentFormattingEditProvider, g as DocumentRangeFormattingEditProvider, C as CompletionAdapter } from "./lspLanguageFeatures-Dmx0L0Bw.js";
3
+ import { a, e, d, R, i, j, t, k } from "./lspLanguageFeatures-Dmx0L0Bw.js";
4
4
  const STOP_WHEN_IDLE_FOR = 2 * 60 * 1e3;
5
5
  class WorkerManager {
6
6
  constructor(defaults) {