@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.
Files changed (31) 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 +326 -24
  4. package/out/preload/index.js +7 -0
  5. package/out/renderer/assets/{cssMode-BdYp0fp9.js → cssMode-Bmzy9hKX.js} +3 -3
  6. package/out/renderer/assets/{freemarker2-Cya-9zr-.js → freemarker2-CDAsExTa.js} +1 -1
  7. package/out/renderer/assets/{handlebars-EJs9W5Ub.js → handlebars-CvpBYG1L.js} +1 -1
  8. package/out/renderer/assets/{html-Ex4qy2lh.js → html-CNhEQqr8.js} +1 -1
  9. package/out/renderer/assets/{htmlMode-CCm0aJ6C.js → htmlMode-DD7YPgga.js} +3 -3
  10. package/out/renderer/assets/{index-Cj7K9-Ba.js → index-BDlCPzCG.js} +153 -146
  11. package/out/renderer/assets/{index-nvsNYP5c.css → index-CGHeDwZa.css} +23 -46
  12. package/out/renderer/assets/{javascript-B2niCouI.js → javascript-CsGLsswK.js} +2 -2
  13. package/out/renderer/assets/{jsonMode-BgCUHaFo.js → jsonMode-CTuGgRvC.js} +3 -3
  14. package/out/renderer/assets/{liquid-DOQmSo4G.js → liquid-BrrN4uul.js} +1 -1
  15. package/out/renderer/assets/{lspLanguageFeatures-8SIcRAMh.js → lspLanguageFeatures-Bhdn2Eqy.js} +1 -1
  16. package/out/renderer/assets/{mdx-DuKdGNKE.js → mdx-DJo-gax4.js} +1 -1
  17. package/out/renderer/assets/{python-BUa4XFG-.js → python-DYHeg5b2.js} +1 -1
  18. package/out/renderer/assets/{razor-BUw8jho0.js → razor-Cqcks14K.js} +1 -1
  19. package/out/renderer/assets/{tsMode-8FLRnobw.js → tsMode-YTLgCayh.js} +1 -1
  20. package/out/renderer/assets/{typescript-rdwAjCi4.js → typescript-oxBn_Vcw.js} +1 -1
  21. package/out/renderer/assets/{xml-HOzT59Qg.js → xml-BEqGMCTc.js} +1 -1
  22. package/out/renderer/assets/{yaml-B81JfLsW.js → yaml-BGSrk5HC.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 +135 -20
  27. package/src/main/orchestrator.ts +21 -1
  28. package/src/preload/index.ts +8 -0
  29. package/src/renderer/App.tsx +32 -9
  30. package/src/renderer/components/TicketPanel.tsx +20 -25
  31. package/src/renderer/styles.css +23 -46
@@ -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'),
@@ -14,7 +14,7 @@ import {
14
14
  } from './components/PaneLayout'
15
15
  import { StatusBar } from './components/StatusBar'
16
16
  import { SettingsDialog } from './components/SettingsDialog'
17
- import { TicketPanel } from './components/TicketPanel'
17
+ import { TicketPane } from './components/TicketPanel'
18
18
 
19
19
  interface AgentConfig {
20
20
  id: string
@@ -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<{
@@ -95,11 +96,13 @@ const DEFAULT_LAYOUT: LayoutNode = {
95
96
  direction: 'horizontal',
96
97
  children: [
97
98
  { type: 'leaf', paneId: 'terminal' },
98
- { type: 'leaf', paneId: 'ctlsurf' },
99
+ { type: 'leaf', paneId: 'tickets' },
99
100
  ],
100
101
  sizes: [50, 50],
101
102
  }
102
103
 
104
+ // Panes shown as plain toggle buttons in the titlebar. `tickets` has its own
105
+ // dedicated icon button, so it's not listed here.
103
106
  const ALL_PANE_IDS = ['editor', 'terminal', 'ctlsurf']
104
107
 
105
108
  interface TabInfo {
@@ -137,7 +140,9 @@ export default function App() {
137
140
  })
138
141
  const [activeTabId, setActiveTabId] = useState<string>(tabs[0].id)
139
142
  const [trackingActive, setTrackingActive] = useState(false)
140
- const [showTicketPanel, setShowTicketPanel] = useState(false)
143
+ // Per-tab agent activity ('busy' | 'awaiting_input' | 'awaiting_decision' | 'idle'),
144
+ // driven by the main process. Powers the awaiting-input dot on each tab.
145
+ const [agentStates, setAgentStates] = useState<Record<string, string>>({})
141
146
  // Draggable on-canvas push-to-talk mic; visibility persists across launches.
142
147
  const [showFloatingMic, setShowFloatingMic] = useState<boolean>(() => {
143
148
  try { return localStorage.getItem('ctlsurf.floatingMicVisible') !== 'false' } catch { return true }
@@ -202,6 +207,16 @@ export default function App() {
202
207
  return unsub
203
208
  }, [])
204
209
 
210
+ // Track each tab's agent activity for the awaiting-input dot. The aggregate-
211
+ // only signal arrives with an empty tabId, which we ignore here.
212
+ useEffect(() => {
213
+ const unsub = window.worker.onAgentState((tabId, state) => {
214
+ if (!tabId) return
215
+ setAgentStates(prev => (prev[tabId] === state ? prev : { ...prev, [tabId]: state }))
216
+ })
217
+ return unsub
218
+ }, [])
219
+
205
220
  // Reflect tracking state for the active tab; refresh on tab switch and poll
206
221
  // periodically so the UI catches changes (e.g. a session ending naturally).
207
222
  useEffect(() => {
@@ -385,6 +400,7 @@ export default function App() {
385
400
  case 'E': e.preventDefault(); togglePane('editor'); break
386
401
  case 'T': e.preventDefault(); togglePane('terminal'); break
387
402
  case 'P': e.preventDefault(); togglePane('ctlsurf'); break
403
+ case 'K': e.preventDefault(); togglePane('tickets'); break
388
404
  }
389
405
  }
390
406
  }
@@ -424,8 +440,15 @@ export default function App() {
424
440
  >
425
441
  <span className="terminal-tab-label">
426
442
  {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" />}
443
+ {tab.agentStatus === 'exited' ? (
444
+ <span className="terminal-tab-dot exited" />
445
+ ) : agentStates[tab.id] === 'awaiting_decision' ? (
446
+ <span className="terminal-tab-dot decision" title="Awaiting your approval" />
447
+ ) : agentStates[tab.id] === 'awaiting_input' ? (
448
+ <span className="terminal-tab-dot awaiting" title="Awaiting your input" />
449
+ ) : tab.agentStatus === 'active' ? (
450
+ <span className="terminal-tab-dot active" />
451
+ ) : null}
429
452
  </span>
430
453
  {tabs.length > 1 && (
431
454
  <button
@@ -467,6 +490,7 @@ export default function App() {
467
490
  ),
468
491
  },
469
492
  { id: 'ctlsurf', label: 'ctlsurf', content: <CtlsurfPanel navigate={ctlsurfNav} /> },
493
+ { id: 'tickets', label: 'Tickets', content: <TicketPane /> },
470
494
  ]
471
495
 
472
496
  return (
@@ -495,9 +519,9 @@ export default function App() {
495
519
  <span className={`tracking-dot ${trackingActive ? 'on' : 'off'}`} />
496
520
  </button>
497
521
  <button
498
- className={`titlebar-btn titlebar-icon-btn ${showTicketPanel ? 'active' : ''}`}
499
- onClick={() => setShowTicketPanel(v => !v)}
500
- title="Tickets for the active project"
522
+ className={`titlebar-btn titlebar-icon-btn ${visiblePaneIds.includes('tickets') ? 'active' : ''}`}
523
+ onClick={() => togglePane('tickets')}
524
+ title="Toggle tickets pane for the active project"
501
525
  aria-label="Tickets"
502
526
  >
503
527
  <svg className="ticket-tag-icon" viewBox="0 0 24 24" width="13" height="13"
@@ -568,7 +592,6 @@ export default function App() {
568
592
 
569
593
  <StatusBar wsStatus={wsStatus} cwd={cwd} onChangeCwd={handleChangeCwd} updateInfo={updateInfo} />
570
594
  <SettingsDialog open={showSettings} onClose={() => setShowSettings(false)} />
571
- <TicketPanel open={showTicketPanel} onClose={() => setShowTicketPanel(false)} />
572
595
 
573
596
  {showAgentPicker && agents.length > 0 && (
574
597
  <AgentPicker
@@ -26,11 +26,6 @@ interface Ticket {
26
26
  created: string | null
27
27
  }
28
28
 
29
- interface TicketPanelProps {
30
- open: boolean
31
- onClose: () => void
32
- }
33
-
34
29
  type SaveState = { kind: 'idle' } | { kind: 'saving' } | { kind: 'error'; message: string }
35
30
  type View = 'list' | 'form'
36
31
 
@@ -79,7 +74,10 @@ function Segmented({ options, value, onChange }: SegmentedProps) {
79
74
  )
80
75
  }
81
76
 
82
- export function TicketPanel({ open, onClose }: TicketPanelProps) {
77
+ // Docked side-pane variant of the ticket UI: header + list/form body + footer.
78
+ // It only mounts when its pane is visible, so it loads on mount and refreshes
79
+ // when the connected project (cwd) changes. Closed via the pane's own header.
80
+ export function TicketPane() {
83
81
  const [project, setProject] = useState<string | null>(null)
84
82
  const [view, setView] = useState<View>('list')
85
83
 
@@ -111,17 +109,18 @@ export function TicketPanel({ open, onClose }: TicketPanelProps) {
111
109
  }
112
110
  }, [])
113
111
 
114
- // On open: reset to the list and (re)load project + tickets.
112
+ // Load project + tickets on mount, and refresh whenever the connected project
113
+ // (cwd) changes so the list always tracks the active project.
115
114
  useEffect(() => {
116
- if (!open) return
117
- setView('list')
118
115
  let cancelled = false
119
- window.worker.getTicketProject()
116
+ const loadProject = () => window.worker.getTicketProject()
120
117
  .then(r => { if (!cancelled) setProject(r?.name ?? null) })
121
118
  .catch(() => { if (!cancelled) setProject(null) })
119
+ loadProject()
122
120
  void refreshList()
123
- return () => { cancelled = true }
124
- }, [open, refreshList])
121
+ const unsub = window.worker.onCwdChanged(() => { loadProject(); void refreshList() })
122
+ return () => { cancelled = true; unsub() }
123
+ }, [refreshList])
125
124
 
126
125
  const openNewForm = useCallback(() => {
127
126
  setEditingId(null)
@@ -162,28 +161,26 @@ export function TicketPanel({ open, onClose }: TicketPanelProps) {
162
161
  }
163
162
  }, [title, description, status, priority, editingId, save.kind, refreshList])
164
163
 
165
- // Esc: form → back to list, list → close. Cmd/Ctrl+Enter saves in the form.
164
+ // Only capture keys while editing a ticket, so list-view typing in the terminal
165
+ // isn't hijacked. Esc returns to the list; Cmd/Ctrl+Enter saves.
166
166
  useEffect(() => {
167
- if (!open) return
167
+ if (view !== 'form') return
168
168
  const onKey = (e: KeyboardEvent) => {
169
169
  if (e.key === 'Escape') {
170
170
  e.preventDefault()
171
- if (view === 'form') setView('list')
172
- else onClose()
173
- } else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && view === 'form') {
171
+ setView('list')
172
+ } else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
174
173
  e.preventDefault()
175
174
  void handleSave()
176
175
  }
177
176
  }
178
177
  window.addEventListener('keydown', onKey)
179
178
  return () => window.removeEventListener('keydown', onKey)
180
- }, [open, view, onClose, handleSave])
179
+ }, [view, handleSave])
181
180
 
182
181
  return (
183
- <>
184
- <div className={`ticket-scrim ${open ? 'open' : ''}`} onClick={onClose} />
185
- <div className={`ticket-drawer ${open ? 'open' : ''}`} role="dialog" aria-label="Tickets">
186
- <div className="ticket-drawer-header">
182
+ <div className="ticket-pane" role="region" aria-label="Tickets">
183
+ <div className="ticket-drawer-header">
187
184
  <div className="ticket-drawer-heading">
188
185
  {view === 'form' ? (
189
186
  <button className="ticket-drawer-back" onClick={() => setView('list')} title="Back (Esc)">‹</button>
@@ -201,7 +198,6 @@ export function TicketPanel({ open, onClose }: TicketPanelProps) {
201
198
  </span>
202
199
  </div>
203
200
  </div>
204
- <button className="ticket-drawer-close" onClick={onClose} title="Close">×</button>
205
201
  </div>
206
202
 
207
203
  {view === 'list' ? (
@@ -302,7 +298,6 @@ export function TicketPanel({ open, onClose }: TicketPanelProps) {
302
298
  </div>
303
299
  </>
304
300
  )}
305
- </div>
306
- </>
301
+ </div>
307
302
  )
308
303
  }
@@ -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;
@@ -1440,38 +1458,13 @@ html, body, #root {
1440
1458
 
1441
1459
  /* ─── Ticket drawer ──────────────────────────────── */
1442
1460
 
1443
- .ticket-scrim {
1444
- position: fixed;
1445
- inset: 0;
1446
- background: rgba(13, 14, 22, 0.55);
1447
- backdrop-filter: blur(2px);
1448
- opacity: 0;
1449
- pointer-events: none;
1450
- transition: opacity 0.2s ease;
1451
- z-index: 90;
1452
- }
1453
- .ticket-scrim.open {
1454
- opacity: 1;
1455
- pointer-events: auto;
1456
- }
1457
-
1458
- .ticket-drawer {
1459
- position: fixed;
1460
- top: 0;
1461
- right: 0;
1462
- bottom: 0;
1463
- width: 380px;
1464
- background: linear-gradient(180deg, #20243a 0%, #1b1e2e 100%);
1465
- border-left: 1px solid #343860;
1466
- box-shadow: -16px 0 48px rgba(0, 0, 0, 0.5);
1461
+ /* Docked side-pane variant: fills its pane (header/body/footer flex column). */
1462
+ .ticket-pane {
1467
1463
  display: flex;
1468
1464
  flex-direction: column;
1469
- transform: translateX(100%);
1470
- transition: transform 0.24s cubic-bezier(0.32, 0.72, 0, 1);
1471
- z-index: 100;
1472
- }
1473
- .ticket-drawer.open {
1474
- transform: translateX(0);
1465
+ height: 100%;
1466
+ min-height: 0;
1467
+ background: linear-gradient(180deg, #20243a 0%, #1b1e2e 100%);
1475
1468
  }
1476
1469
 
1477
1470
  /* ── header ── */
@@ -1521,22 +1514,6 @@ html, body, #root {
1521
1514
  .ticket-drawer-warn {
1522
1515
  color: #e0af68;
1523
1516
  }
1524
- .ticket-drawer-close {
1525
- background: none;
1526
- border: none;
1527
- color: #565f89;
1528
- font-size: 20px;
1529
- line-height: 1;
1530
- cursor: pointer;
1531
- padding: 2px 6px;
1532
- border-radius: 6px;
1533
- transition: background 0.12s ease, color 0.12s ease;
1534
- }
1535
- .ticket-drawer-close:hover {
1536
- color: #d5dbf5;
1537
- background: rgba(255, 255, 255, 0.06);
1538
- }
1539
-
1540
1517
  /* ── body ── */
1541
1518
  .ticket-drawer-body {
1542
1519
  flex: 1;