@phenx-inc/ctlsurf 0.1.2 → 0.1.4

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 (29) hide show
  1. package/out/headless/index.mjs +37 -78
  2. package/out/headless/index.mjs.map +2 -2
  3. package/out/main/index.js +3743 -112
  4. package/out/renderer/assets/{cssMode-DL0XItGB.js → cssMode-CY6x0qXW.js} +3 -3
  5. package/out/renderer/assets/{freemarker2-CrOEuDcF.js → freemarker2-BXSW9BAX.js} +1 -1
  6. package/out/renderer/assets/{handlebars-D4QYaBof.js → handlebars-BYUZ1IOs.js} +1 -1
  7. package/out/renderer/assets/{html-B2Dqk2ai.js → html-DPocQM4t.js} +1 -1
  8. package/out/renderer/assets/{htmlMode-CdZ0Prhd.js → htmlMode-CsPinKYA.js} +3 -3
  9. package/out/renderer/assets/{index-pZmE1QXB.js → index-Bml7oDn9.js} +84 -36
  10. package/out/renderer/assets/{index-CJ6RsQWP.css → index-DK9wLFFm.css} +146 -0
  11. package/out/renderer/assets/{javascript-CK8zNQXj.js → javascript-_HVGB-lj.js} +2 -2
  12. package/out/renderer/assets/{jsonMode-Cewaellc.js → jsonMode-JbrRQBOU.js} +3 -3
  13. package/out/renderer/assets/{liquid-Bd3GPNs2.js → liquid-B7izKdqo.js} +1 -1
  14. package/out/renderer/assets/{lspLanguageFeatures-DSDH7BnA.js → lspLanguageFeatures-DzxH499X.js} +1 -1
  15. package/out/renderer/assets/{mdx-CCPVCrXC.js → mdx-CmvUeYLw.js} +1 -1
  16. package/out/renderer/assets/{python-34jOtlcC.js → python-DJqYTFoi.js} +1 -1
  17. package/out/renderer/assets/{razor-DXRw694z.js → razor-CGEA5nUK.js} +1 -1
  18. package/out/renderer/assets/{tsMode-CmND5_wB.js → tsMode-CN0FOHMy.js} +1 -1
  19. package/out/renderer/assets/{typescript-BNNI0Euv.js → typescript-CIn-DSfY.js} +1 -1
  20. package/out/renderer/assets/{xml-CgdndrNB.js → xml-C5t3U2jS.js} +1 -1
  21. package/out/renderer/assets/{yaml-DNWPIf1s.js → yaml-n-Jb6xf1.js} +1 -1
  22. package/out/renderer/index.html +2 -2
  23. package/package.json +6 -4
  24. package/src/main/bridge.ts +25 -74
  25. package/src/main/orchestrator.ts +9 -11
  26. package/src/main/workerWs.ts +10 -2
  27. package/src/renderer/App.tsx +38 -12
  28. package/src/renderer/components/AgentPicker.tsx +49 -0
  29. package/src/renderer/styles.css +146 -0
@@ -1,66 +1,31 @@
1
- import { CtlsurfApi } from './ctlsurfApi'
1
+ import { WorkerWsClient } from './workerWs'
2
2
 
3
3
  /**
4
4
  * Conversation Bridge
5
5
  *
6
- * Taps the pty output stream and logs chunks to a ctlsurf log block.
7
- * Generic approach: buffers terminal output and flushes periodically.
6
+ * Taps the pty output stream, strips ANSI codes, buffers output,
7
+ * and sends cleaned chunks to the backend via WebSocket for S3-backed chat logging.
8
8
  */
9
9
  export class ConversationBridge {
10
- private api: CtlsurfApi
11
- private logBlockId: string | null = null
12
- private pageId: string | null = null
10
+ private wsClient: WorkerWsClient | null = null
13
11
  private buffer: string = ''
14
12
  private flushTimer: ReturnType<typeof setTimeout> | null = null
15
13
  private flushIntervalMs: number = 3000 // flush every 3 seconds
16
- private agentName: string = 'shell'
17
14
  private sessionActive: boolean = false
18
15
  private inputBuffer: string = ''
19
16
 
20
- constructor(api: CtlsurfApi) {
21
- this.api = api
17
+ setWsClient(ws: WorkerWsClient): void {
18
+ this.wsClient = ws
22
19
  }
23
20
 
24
21
  /**
25
22
  * Start a new logging session.
26
- * Creates a log block on the given dataspace page.
27
23
  */
28
- async startSession(dataspacePageId: string, agentName: string, cwd: string): Promise<void> {
29
- if (!this.api.getApiKey()) {
30
- console.log('[bridge] No API key set, skipping session logging')
31
- return
32
- }
33
-
34
- this.pageId = dataspacePageId
35
- this.agentName = agentName
24
+ startSession(): void {
36
25
  this.buffer = ''
37
26
  this.inputBuffer = ''
38
-
39
- try {
40
- const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19)
41
- const block = await this.api.createBlock(dataspacePageId, {
42
- type: 'log',
43
- title: `${agentName} — ${timestamp} — ${cwd}`,
44
- props: {
45
- entries: [],
46
- max_entries: 1000
47
- }
48
- })
49
- this.logBlockId = block.id
50
- this.sessionActive = true
51
-
52
- // Log session start
53
- await this.api.appendLog(this.logBlockId, 'session_start', `Started ${agentName} session`, {
54
- agent: agentName,
55
- cwd,
56
- timestamp
57
- })
58
-
59
- console.log(`[bridge] Session started, log block: ${this.logBlockId}`)
60
- } catch (err: any) {
61
- console.error(`[bridge] Failed to start session:`, err.message)
62
- this.sessionActive = false
63
- }
27
+ this.sessionActive = true
28
+ console.log('[bridge] Session started')
64
29
  }
65
30
 
66
31
  /**
@@ -90,17 +55,17 @@ export class ConversationBridge {
90
55
  if (data.includes('\r') || data.includes('\n')) {
91
56
  const input = this.inputBuffer.trim()
92
57
  if (input.length > 0) {
93
- this.logEntry('user_input', input)
58
+ this.sendEntry('user_input', input)
94
59
  }
95
60
  this.inputBuffer = ''
96
61
  }
97
62
  }
98
63
 
99
64
  /**
100
- * Flush buffered output to ctlsurf.
65
+ * Flush buffered output via WebSocket.
101
66
  */
102
- private async flush(): Promise<void> {
103
- if (!this.logBlockId || this.buffer.length === 0) return
67
+ private flush(): void {
68
+ if (this.buffer.length === 0) return
104
69
 
105
70
  const chunk = this.buffer
106
71
  this.buffer = ''
@@ -109,42 +74,29 @@ export class ConversationBridge {
109
74
  const cleaned = stripAnsi(chunk)
110
75
  if (cleaned.trim().length === 0) return
111
76
 
112
- try {
113
- await this.api.appendLog(this.logBlockId, 'terminal_output', cleaned)
114
- } catch (err: any) {
115
- console.error(`[bridge] Failed to append log:`, err.message)
116
- }
77
+ this.sendEntry('terminal_output', cleaned)
117
78
  }
118
79
 
119
80
  /**
120
- * Log a specific entry immediately.
81
+ * Send an entry to the backend via WebSocket.
121
82
  */
122
- private async logEntry(action: string, message: string, data?: Record<string, unknown>): Promise<void> {
123
- if (!this.logBlockId) return
124
- try {
125
- await this.api.appendLog(this.logBlockId, action, message, data)
126
- } catch (err: any) {
127
- console.error(`[bridge] Failed to log entry:`, err.message)
128
- }
83
+ private sendEntry(type: string, content: string): void {
84
+ if (!this.wsClient) return
85
+ this.wsClient.sendChatLog({
86
+ ts: new Date().toISOString(),
87
+ type,
88
+ content,
89
+ })
129
90
  }
130
91
 
131
92
  /**
132
93
  * End the current session.
133
94
  */
134
- async endSession(exitCode?: number): Promise<void> {
135
- if (!this.sessionActive || !this.logBlockId) return
95
+ endSession(): void {
96
+ if (!this.sessionActive) return
136
97
 
137
98
  // Flush remaining buffer
138
- await this.flush()
139
-
140
- try {
141
- await this.api.appendLog(this.logBlockId, 'session_end', `Session ended (exit code: ${exitCode ?? 'unknown'})`, {
142
- agent: this.agentName,
143
- exitCode
144
- })
145
- } catch (err: any) {
146
- console.error(`[bridge] Failed to log session end:`, err.message)
147
- }
99
+ this.flush()
148
100
 
149
101
  if (this.flushTimer) {
150
102
  clearTimeout(this.flushTimer)
@@ -152,7 +104,6 @@ export class ConversationBridge {
152
104
  }
153
105
 
154
106
  this.sessionActive = false
155
- this.logBlockId = null
156
107
  this.buffer = ''
157
108
  this.inputBuffer = ''
158
109
  console.log('[bridge] Session ended')
@@ -57,7 +57,7 @@ export class Orchestrator {
57
57
 
58
58
  // Core services
59
59
  readonly ctlsurfApi = new CtlsurfApi()
60
- readonly bridge = new ConversationBridge(this.ctlsurfApi)
60
+ readonly bridge = new ConversationBridge()
61
61
  readonly workerWs: WorkerWsClient
62
62
 
63
63
  // State
@@ -105,6 +105,8 @@ export class Orchestrator {
105
105
  this.ptyManager?.write(data)
106
106
  },
107
107
  })
108
+
109
+ this.bridge.setWsClient(this.workerWs)
108
110
  }
109
111
 
110
112
  // ─── Settings ───────────────────────────────────
@@ -282,7 +284,7 @@ export class Orchestrator {
282
284
 
283
285
  async spawnAgent(agent: AgentConfig, cwd: string): Promise<void> {
284
286
  if (this.ptyManager) {
285
- await this.bridge.endSession()
287
+ this.bridge.endSession()
286
288
  this.ptyManager.kill()
287
289
  }
288
290
 
@@ -303,19 +305,15 @@ export class Orchestrator {
303
305
 
304
306
  const thisPtyManager = this.ptyManager
305
307
 
306
- this.ptyManager.onExit(async (exitCode: number) => {
308
+ this.ptyManager.onExit((exitCode: number) => {
307
309
  this.events.onPtyExit(exitCode)
308
- await this.bridge.endSession(exitCode)
310
+ this.bridge.endSession()
309
311
  if (thisPtyManager === this.ptyManager && this.currentAgent && isCodingAgent(this.currentAgent)) {
310
312
  this.workerWs.disconnect()
311
313
  }
312
314
  })
313
315
 
314
- const profile = this.getActiveProfile()
315
- const dataspacePageId = profile.dataspacePageId || process.env.CTLSURF_DATASPACE_PAGE_ID || ''
316
- if (dataspacePageId && this.ctlsurfApi.getApiKey()) {
317
- await this.bridge.startSession(dataspacePageId, agent.name, cwd)
318
- }
316
+ this.bridge.startSession()
319
317
 
320
318
  if (isCodingAgent(agent)) {
321
319
  this.connectWorkerWs(agent, cwd)
@@ -336,7 +334,7 @@ export class Orchestrator {
336
334
  }
337
335
 
338
336
  async killAgent(): Promise<void> {
339
- await this.bridge.endSession()
337
+ this.bridge.endSession()
340
338
  this.ptyManager?.kill()
341
339
  this.ptyManager = null
342
340
  if (this.currentAgent && isCodingAgent(this.currentAgent)) {
@@ -392,7 +390,7 @@ export class Orchestrator {
392
390
  // ─── Shutdown ───────────────────────────────────
393
391
 
394
392
  async shutdown(): Promise<void> {
395
- await this.bridge.endSession()
393
+ this.bridge.endSession()
396
394
  this.ptyManager?.kill()
397
395
  this.ptyManager = null
398
396
  this.workerWs.disconnect()
@@ -1,5 +1,9 @@
1
1
  import os from 'os'
2
2
  import crypto from 'crypto'
3
+ import WsModule from 'ws'
4
+
5
+ // Use native WebSocket if available (Node 22+), otherwise fall back to ws package
6
+ const WS: typeof WebSocket = typeof WebSocket !== 'undefined' ? WebSocket : WsModule as any
3
7
 
4
8
  function log(...args: unknown[]): void {
5
9
  try { console.log(...args) } catch { /* EPIPE safe */ }
@@ -130,6 +134,10 @@ export class WorkerWsClient {
130
134
  this.send({ type: 'terminal_resize', cols, rows })
131
135
  }
132
136
 
137
+ sendChatLog(entry: { type: string; content: string; ts?: string }): void {
138
+ this.send({ type: 'chat_log', entry })
139
+ }
140
+
133
141
  private doConnect(): void {
134
142
  if (!this.apiKey || !this.registration) {
135
143
  log('[worker-ws] No API key or registration, skipping connect')
@@ -169,7 +177,7 @@ export class WorkerWsClient {
169
177
  log(`[worker-ws] Connecting to ${url.replace(/token=.*/, 'token=***')}...`)
170
178
 
171
179
  try {
172
- this.ws = new WebSocket(url)
180
+ this.ws = new WS(url) as unknown as WebSocket
173
181
  } catch (err) {
174
182
  log('[worker-ws] Failed to create WebSocket:', err)
175
183
  this.scheduleReconnect()
@@ -272,7 +280,7 @@ export class WorkerWsClient {
272
280
  }
273
281
 
274
282
  private send(data: Record<string, unknown>): void {
275
- if (this.ws && this.ws.readyState === WebSocket.OPEN) {
283
+ if (this.ws && this.ws.readyState === WS.OPEN) {
276
284
  this.ws.send(JSON.stringify(data))
277
285
  }
278
286
  }
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'
2
2
  import { TerminalPanel } from './components/TerminalPanel'
3
3
  import { CtlsurfPanel } from './components/CtlsurfPanel'
4
4
  import { EditorPanel } from './components/EditorPanel'
5
+ import { AgentPicker } from './components/AgentPicker'
5
6
  import {
6
7
  PaneLayout,
7
8
  type LayoutNode,
@@ -63,11 +64,10 @@ const DEFAULT_LAYOUT: LayoutNode = {
63
64
  type: 'split',
64
65
  direction: 'horizontal',
65
66
  children: [
66
- { type: 'leaf', paneId: 'editor' },
67
67
  { type: 'leaf', paneId: 'terminal' },
68
68
  { type: 'leaf', paneId: 'ctlsurf' },
69
69
  ],
70
- sizes: [33.33, 33.33, 33.34],
70
+ sizes: [50, 50],
71
71
  }
72
72
 
73
73
  const ALL_PANE_IDS = ['editor', 'terminal', 'ctlsurf']
@@ -86,14 +86,18 @@ export default function App() {
86
86
  // Track which panes are in the layout tree
87
87
  const visiblePaneIds = findPaneIds(layout)
88
88
 
89
+ const [showAgentPicker, setShowAgentPicker] = useState(true)
90
+
89
91
  useEffect(() => {
90
92
  async function init() {
91
93
  const list = await window.worker.listAgents()
92
- const defaultAgent = await window.worker.getDefaultAgent()
93
94
  setAgents(list)
94
- setSelectedAgent(defaultAgent)
95
95
  const status = await window.worker.getWorkerStatus()
96
96
  setWsStatus(status)
97
+ // Load initial cwd for the picker
98
+ const initialCwd = await window.worker.getCwd().catch(() => window.worker.getHomePath())
99
+ setCwd(initialCwd)
100
+ cwdRef.current = initialCwd
97
101
  }
98
102
  init()
99
103
  }, [])
@@ -232,6 +236,18 @@ export default function App() {
232
236
  <button className="titlebar-btn" onClick={() => setShowSettings(true)} title="Settings">
233
237
  Settings
234
238
  </button>
239
+ <span className="titlebar-separator" />
240
+ {agents.map(a => (
241
+ <button
242
+ key={a.id}
243
+ className={`titlebar-agent-btn ${selectedAgent?.id === a.id ? 'active' : ''}`}
244
+ onClick={() => handleAgentChange(a.id)}
245
+ title={a.description}
246
+ >
247
+ {a.name}
248
+ </button>
249
+ ))}
250
+ <span className="titlebar-separator" />
235
251
  {ALL_PANE_IDS.map(id => (
236
252
  <button
237
253
  key={id}
@@ -242,14 +258,6 @@ export default function App() {
242
258
  {id === 'editor' ? 'Editor' : id === 'terminal' ? 'Terminal' : 'ctlsurf'}
243
259
  </button>
244
260
  ))}
245
- <select
246
- value={selectedAgent?.id || ''}
247
- onChange={(e) => handleAgentChange(e.target.value)}
248
- >
249
- {agents.map(a => (
250
- <option key={a.id} value={a.id}>{a.name}</option>
251
- ))}
252
- </select>
253
261
  </div>
254
262
  </div>
255
263
 
@@ -270,6 +278,24 @@ export default function App() {
270
278
 
271
279
  <StatusBar wsStatus={wsStatus} cwd={cwd} onChangeCwd={handleChangeCwd} />
272
280
  <SettingsDialog open={showSettings} onClose={() => setShowSettings(false)} />
281
+
282
+ {showAgentPicker && agents.length > 0 && (
283
+ <AgentPicker
284
+ agents={agents}
285
+ cwd={cwd || ''}
286
+ onSelect={(agent) => {
287
+ setSelectedAgent(agent)
288
+ setShowAgentPicker(false)
289
+ }}
290
+ onChangeCwd={async () => {
291
+ const newCwd = await window.worker.browseCwd()
292
+ if (newCwd) {
293
+ setCwd(newCwd)
294
+ cwdRef.current = newCwd
295
+ }
296
+ }}
297
+ />
298
+ )}
273
299
  </div>
274
300
  )
275
301
  }
@@ -0,0 +1,49 @@
1
+ interface AgentConfig {
2
+ id: string
3
+ name: string
4
+ command: string
5
+ args: string[]
6
+ description: string
7
+ }
8
+
9
+ interface AgentPickerProps {
10
+ agents: AgentConfig[]
11
+ cwd: string
12
+ onSelect: (agent: AgentConfig) => void
13
+ onChangeCwd: () => void
14
+ }
15
+
16
+ export function AgentPicker({ agents, cwd, onSelect, onChangeCwd }: AgentPickerProps) {
17
+ const home = typeof window !== 'undefined' ? '' : ''
18
+ // Shorten home dir for display
19
+ const displayPath = cwd.replace(/^\/Users\/[^/]+/, '~')
20
+
21
+ return (
22
+ <div className="agent-picker-overlay">
23
+ <div className="agent-picker-modal">
24
+ <div className="agent-picker-brand">ctlsurf</div>
25
+
26
+ <div className="agent-picker-cwd" onClick={onChangeCwd} title="Click to change directory">
27
+ <span className="agent-picker-cwd-icon">&#x1F4C2;</span>
28
+ <span className="agent-picker-cwd-path">{displayPath}</span>
29
+ <span className="agent-picker-cwd-change">change</span>
30
+ </div>
31
+
32
+ <div className="agent-picker-title">SELECT AGENT</div>
33
+ <div className="agent-picker-list">
34
+ {agents.map(a => (
35
+ <button
36
+ key={a.id}
37
+ className="agent-picker-item"
38
+ onClick={() => onSelect(a)}
39
+ >
40
+ <span className="agent-picker-name">{a.name}</span>
41
+ <span className="agent-picker-desc">{a.description}</span>
42
+ </button>
43
+ ))}
44
+ </div>
45
+ <div className="agent-picker-hint">Select an agent to start</div>
46
+ </div>
47
+ </div>
48
+ )
49
+ }
@@ -82,6 +82,36 @@ html, body, #root {
82
82
  background: #1f2335;
83
83
  }
84
84
 
85
+ .titlebar-separator {
86
+ width: 1px;
87
+ height: 16px;
88
+ background: #3b3d57;
89
+ margin: 0 4px;
90
+ }
91
+
92
+ .titlebar-agent-btn {
93
+ background: #2a2b3d;
94
+ color: #565f89;
95
+ border: 1px solid #3b3d57;
96
+ border-radius: 4px;
97
+ padding: 2px 12px;
98
+ font-size: 11px;
99
+ cursor: pointer;
100
+ transition: all 0.15s;
101
+ }
102
+
103
+ .titlebar-agent-btn:hover {
104
+ color: #a9b1d6;
105
+ border-color: #565f89;
106
+ }
107
+
108
+ .titlebar-agent-btn.active {
109
+ color: #1a1b26;
110
+ background: #7aa2f7;
111
+ border-color: #7aa2f7;
112
+ font-weight: 600;
113
+ }
114
+
85
115
  /* Main content area */
86
116
  .main-content {
87
117
  flex: 1;
@@ -530,6 +560,122 @@ html, body, #root {
530
560
  font-size: 13px;
531
561
  }
532
562
 
563
+ /* Agent picker modal */
564
+ .agent-picker-overlay {
565
+ position: fixed;
566
+ inset: 0;
567
+ background: rgba(0, 0, 0, 0.7);
568
+ display: flex;
569
+ align-items: center;
570
+ justify-content: center;
571
+ z-index: 200;
572
+ }
573
+
574
+ .agent-picker-modal {
575
+ background: #1f2335;
576
+ border: 1px solid #3b3d57;
577
+ border-radius: 12px;
578
+ padding: 32px 40px;
579
+ min-width: 360px;
580
+ text-align: center;
581
+ }
582
+
583
+ .agent-picker-brand {
584
+ font-size: 22px;
585
+ font-weight: 700;
586
+ color: #7aa2f7;
587
+ margin-bottom: 20px;
588
+ letter-spacing: 0.5px;
589
+ }
590
+
591
+ .agent-picker-cwd {
592
+ display: flex;
593
+ align-items: center;
594
+ justify-content: center;
595
+ gap: 8px;
596
+ padding: 10px 16px;
597
+ margin-bottom: 20px;
598
+ background: #16161e;
599
+ border: 1px solid #2a2b3d;
600
+ border-radius: 8px;
601
+ cursor: pointer;
602
+ transition: border-color 0.15s;
603
+ }
604
+
605
+ .agent-picker-cwd:hover {
606
+ border-color: #7aa2f7;
607
+ }
608
+
609
+ .agent-picker-cwd-icon {
610
+ font-size: 14px;
611
+ }
612
+
613
+ .agent-picker-cwd-path {
614
+ font-family: 'JetBrains Mono', 'Fira Code', monospace;
615
+ font-size: 12px;
616
+ color: #a9b1d6;
617
+ flex: 1;
618
+ text-align: left;
619
+ overflow: hidden;
620
+ text-overflow: ellipsis;
621
+ white-space: nowrap;
622
+ }
623
+
624
+ .agent-picker-cwd-change {
625
+ font-size: 11px;
626
+ color: #7aa2f7;
627
+ flex-shrink: 0;
628
+ }
629
+
630
+ .agent-picker-title {
631
+ font-size: 11px;
632
+ color: #565f89;
633
+ margin-bottom: 12px;
634
+ text-transform: uppercase;
635
+ letter-spacing: 1px;
636
+ }
637
+
638
+ .agent-picker-list {
639
+ display: flex;
640
+ flex-direction: column;
641
+ gap: 8px;
642
+ }
643
+
644
+ .agent-picker-item {
645
+ display: flex;
646
+ align-items: center;
647
+ justify-content: space-between;
648
+ padding: 14px 18px;
649
+ background: #16161e;
650
+ border: 1px solid #2a2b3d;
651
+ border-radius: 8px;
652
+ cursor: pointer;
653
+ transition: all 0.15s;
654
+ text-align: left;
655
+ }
656
+
657
+ .agent-picker-item:hover {
658
+ border-color: #7aa2f7;
659
+ background: #1a1b2e;
660
+ }
661
+
662
+ .agent-picker-name {
663
+ font-size: 14px;
664
+ font-weight: 600;
665
+ color: #c0caf5;
666
+ }
667
+
668
+ .agent-picker-desc {
669
+ font-size: 11px;
670
+ color: #565f89;
671
+ }
672
+
673
+ .agent-picker-hint {
674
+ margin-top: 20px;
675
+ font-size: 11px;
676
+ color: #414868;
677
+ }
678
+
533
679
  /* Settings dialog */
534
680
  .settings-overlay {
535
681
  position: fixed;