@phenx-inc/ctlsurf 0.9.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 (30) hide show
  1. package/out/headless/index.mjs +196 -2
  2. package/out/headless/index.mjs.map +4 -4
  3. package/out/main/index.js +300 -23
  4. package/out/preload/index.js +7 -0
  5. package/out/renderer/assets/{cssMode-BdYp0fp9.js → cssMode-BbCVOTaJ.js} +3 -3
  6. package/out/renderer/assets/{freemarker2-Cya-9zr-.js → freemarker2-DryLywQv.js} +1 -1
  7. package/out/renderer/assets/{handlebars-EJs9W5Ub.js → handlebars-CdUMtSdr.js} +1 -1
  8. package/out/renderer/assets/{html-Ex4qy2lh.js → html-Cwkf2GMH.js} +1 -1
  9. package/out/renderer/assets/{htmlMode-CCm0aJ6C.js → htmlMode-C7PzhXxH.js} +3 -3
  10. package/out/renderer/assets/{index-nvsNYP5c.css → index-BIceGcz3.css} +18 -0
  11. package/out/renderer/assets/{index-Cj7K9-Ba.js → index-Dg1vMspf.js} +31 -24
  12. package/out/renderer/assets/{javascript-B2niCouI.js → javascript-Cy7ROXJl.js} +2 -2
  13. package/out/renderer/assets/{jsonMode-BgCUHaFo.js → jsonMode-BtZtt3QW.js} +3 -3
  14. package/out/renderer/assets/{liquid-DOQmSo4G.js → liquid-xReAQeYs.js} +1 -1
  15. package/out/renderer/assets/{lspLanguageFeatures-8SIcRAMh.js → lspLanguageFeatures-Dmx0L0Bw.js} +1 -1
  16. package/out/renderer/assets/{mdx-DuKdGNKE.js → mdx-YMNFO1Zz.js} +1 -1
  17. package/out/renderer/assets/{python-BUa4XFG-.js → python-BExaDVxv.js} +1 -1
  18. package/out/renderer/assets/{razor-BUw8jho0.js → razor-HL8ANmQ4.js} +1 -1
  19. package/out/renderer/assets/{tsMode-8FLRnobw.js → tsMode-Dqd8vdLE.js} +1 -1
  20. package/out/renderer/assets/{typescript-rdwAjCi4.js → typescript-Dysz1PTX.js} +1 -1
  21. package/out/renderer/assets/{xml-HOzT59Qg.js → xml-BHp5Z9nc.js} +1 -1
  22. package/out/renderer/assets/{yaml-B81JfLsW.js → yaml-CPDHxVup.js} +1 -1
  23. package/out/renderer/index.html +2 -2
  24. package/package.json +1 -1
  25. package/src/main/agentState.ts +329 -0
  26. package/src/main/index.ts +105 -19
  27. package/src/main/orchestrator.ts +21 -1
  28. package/src/preload/index.ts +8 -0
  29. package/src/renderer/App.tsx +23 -2
  30. package/src/renderer/styles.css +18 -0
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
- function createOverlayIcon(text: string): Electron.NativeImage {
54
- // Create a 32x32 overlay with text for Windows/Linux
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
- const dist = Math.sqrt(cx * cx + cy * cy)
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] = 122 // R (blue-ish)
67
- canvas[idx + 1] = 162 // G
68
- canvas[idx + 2] = 247 // B
69
- canvas[idx + 3] = 220 // A
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
- function updateProjectBadge(cwdPath: string | null): void {
78
- if (!cwdPath) return
79
- const abbrev = getProjectAbbrev(cwdPath)
76
+ // Last project abbreviation, so the steady badge can be restored after the
77
+ // "awaiting input" indicator clears.
78
+ let projectAbbrev = ''
80
79
 
81
- // macOS: dock badge shows text directly
80
+ function applySteadyBadge(): void {
82
81
  if (process.platform === 'darwin' && app.dock) {
83
- app.dock.setBadge(abbrev)
82
+ app.dock.setBadge(projectAbbrev)
84
83
  }
85
-
86
- // Windows: overlay icon on taskbar
87
84
  if (process.platform === 'win32' && mainWindow) {
88
- mainWindow.setOverlayIcon(createOverlayIcon(abbrev), abbrev)
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
  })
@@ -7,6 +7,7 @@ import { AgentConfig, isCodingAgent } from './agents'
7
7
  import { CtlsurfApi } from './ctlsurfApi'
8
8
  import { ConversationBridge } from './bridge'
9
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
  },
@@ -463,10 +475,14 @@ export class Orchestrator {
463
475
  const ptyManager = new PtyManager(agent, cwd)
464
476
  const tab: TabState = { ptyManager, agent, cwd, termStreamBuffer: '', termStreamTimer: null }
465
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)
466
481
 
467
482
  ptyManager.onData((data: string) => {
468
483
  this.events.onPtyData(tabId, data)
469
484
  this.timeTracker.recordActivity(tabId)
485
+ this.stateMonitor.feed(tabId, data)
470
486
  if (tabId === this.activeTabId) {
471
487
  this.bridge.feedOutput(data)
472
488
  this.streamTerminalData(tabId, data)
@@ -485,6 +501,7 @@ export class Orchestrator {
485
501
  // Clean up tab state
486
502
  const t = this.tabs.get(tabId)
487
503
  if (t?.termStreamTimer) clearTimeout(t.termStreamTimer)
504
+ this.stateMonitor.removeTab(tabId)
488
505
  })
489
506
 
490
507
  this.startChatLogging(agent, cwd)
@@ -511,6 +528,7 @@ export class Orchestrator {
511
528
 
512
529
  writePty(tabId: string, data: string): void {
513
530
  this.tabs.get(tabId)?.ptyManager.write(data)
531
+ this.stateMonitor.noteInput(tabId, data)
514
532
  if (tabId === this.activeTabId) {
515
533
  this.bridge.feedInput(data)
516
534
  }
@@ -531,6 +549,7 @@ export class Orchestrator {
531
549
  await this.timeTracker.endSession(tabId)
532
550
  tab.ptyManager.kill()
533
551
  this.tabs.delete(tabId)
552
+ this.stateMonitor.removeTab(tabId)
534
553
  if (tabId === this.activeTabId) {
535
554
  this.stopChatLogging()
536
555
  if (isCodingAgent(tab.agent)) {
@@ -702,6 +721,7 @@ export class Orchestrator {
702
721
  async shutdown(): Promise<void> {
703
722
  this.stopNoProjectPolling()
704
723
  this.stopChatLogging()
724
+ this.stateMonitor.stop()
705
725
  await this.timeTracker.endAll()
706
726
  for (const [, tab] of this.tabs) {
707
727
  if (tab.termStreamTimer) clearTimeout(tab.termStreamTimer)
@@ -119,6 +119,14 @@ const api = {
119
119
  ipcRenderer.on('agent:message', listener)
120
120
  return () => ipcRenderer.removeListener('agent:message', listener)
121
121
  },
122
+ // Real-time agent activity state for an in-app indicator dot.
123
+ // state: 'idle' | 'busy' | 'awaiting_input' | 'awaiting_decision'
124
+ onAgentState: (callback: (tabId: string, state: string, anyAwaiting: boolean) => void) => {
125
+ const listener = (_event: Electron.IpcRendererEvent, tabId: string, state: string, anyAwaiting: boolean) =>
126
+ callback(tabId, state, anyAwaiting)
127
+ ipcRenderer.on('agent:state', listener)
128
+ return () => ipcRenderer.removeListener('agent:state', listener)
129
+ },
122
130
  // Latest assistant reply from the active tab's transcript (null if none).
123
131
  getLastReply: (): Promise<string | null> =>
124
132
  ipcRenderer.invoke('agent:getLastReply'),
@@ -60,6 +60,7 @@ declare global {
60
60
  getSpeakReplies: () => Promise<{ enabled: boolean }>
61
61
  setSpeakReplies: (enabled: boolean) => Promise<{ enabled: boolean }>
62
62
  onAgentMessage: (callback: (text: string) => void) => () => void
63
+ onAgentState: (callback: (tabId: string, state: string, anyAwaiting: boolean) => void) => () => void
63
64
  getLastReply: () => Promise<string | null>
64
65
  createProject: () => Promise<{ ok: boolean; folder_id?: string; error?: string }>
65
66
  getWebviewInfo: () => Promise<{
@@ -137,6 +138,9 @@ export default function App() {
137
138
  })
138
139
  const [activeTabId, setActiveTabId] = useState<string>(tabs[0].id)
139
140
  const [trackingActive, setTrackingActive] = useState(false)
141
+ // Per-tab agent activity ('busy' | 'awaiting_input' | 'awaiting_decision' | 'idle'),
142
+ // driven by the main process. Powers the awaiting-input dot on each tab.
143
+ const [agentStates, setAgentStates] = useState<Record<string, string>>({})
140
144
  const [showTicketPanel, setShowTicketPanel] = useState(false)
141
145
  // Draggable on-canvas push-to-talk mic; visibility persists across launches.
142
146
  const [showFloatingMic, setShowFloatingMic] = useState<boolean>(() => {
@@ -202,6 +206,16 @@ export default function App() {
202
206
  return unsub
203
207
  }, [])
204
208
 
209
+ // Track each tab's agent activity for the awaiting-input dot. The aggregate-
210
+ // only signal arrives with an empty tabId, which we ignore here.
211
+ useEffect(() => {
212
+ const unsub = window.worker.onAgentState((tabId, state) => {
213
+ if (!tabId) return
214
+ setAgentStates(prev => (prev[tabId] === state ? prev : { ...prev, [tabId]: state }))
215
+ })
216
+ return unsub
217
+ }, [])
218
+
205
219
  // Reflect tracking state for the active tab; refresh on tab switch and poll
206
220
  // periodically so the UI catches changes (e.g. a session ending naturally).
207
221
  useEffect(() => {
@@ -424,8 +438,15 @@ export default function App() {
424
438
  >
425
439
  <span className="terminal-tab-label">
426
440
  {tab.agent ? tab.agent.name : tab.label}
427
- {tab.agentStatus === 'active' && <span className="terminal-tab-dot active" />}
428
- {tab.agentStatus === 'exited' && <span className="terminal-tab-dot exited" />}
441
+ {tab.agentStatus === 'exited' ? (
442
+ <span className="terminal-tab-dot exited" />
443
+ ) : agentStates[tab.id] === 'awaiting_decision' ? (
444
+ <span className="terminal-tab-dot decision" title="Awaiting your approval" />
445
+ ) : agentStates[tab.id] === 'awaiting_input' ? (
446
+ <span className="terminal-tab-dot awaiting" title="Awaiting your input" />
447
+ ) : tab.agentStatus === 'active' ? (
448
+ <span className="terminal-tab-dot active" />
449
+ ) : null}
429
450
  </span>
430
451
  {tabs.length > 1 && (
431
452
  <button
@@ -202,6 +202,24 @@ html, body, #root {
202
202
  background: #565f89;
203
203
  }
204
204
 
205
+ /* Agent is waiting on the user — pulse to draw the eye across tabs. */
206
+ .terminal-tab-dot.awaiting {
207
+ background: #e0af68; /* amber: waiting for next prompt */
208
+ color: #e0af68; /* drives the box-shadow glow via currentColor */
209
+ animation: tab-dot-pulse 1.1s ease-in-out infinite;
210
+ }
211
+
212
+ .terminal-tab-dot.decision {
213
+ background: #f7768e; /* red: blocked on an approval/permission prompt */
214
+ color: #f7768e;
215
+ animation: tab-dot-pulse 1.1s ease-in-out infinite;
216
+ }
217
+
218
+ @keyframes tab-dot-pulse {
219
+ 0%, 100% { opacity: 1; box-shadow: 0 0 0 0 currentColor; }
220
+ 50% { opacity: 0.35; box-shadow: 0 0 4px 1px currentColor; }
221
+ }
222
+
205
223
  .terminal-tab-close {
206
224
  background: none;
207
225
  border: none;