@phenx-inc/ctlsurf 0.11.0 → 0.12.1

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 +115 -5
  2. package/out/headless/index.mjs.map +3 -3
  3. package/out/main/index.js +316 -8
  4. package/out/preload/index.js +9 -0
  5. package/out/renderer/assets/{cssMode-Bmzy9hKX.js → cssMode-D7r66wvO.js} +3 -3
  6. package/out/renderer/assets/{freemarker2-CDAsExTa.js → freemarker2-jSWJIjPG.js} +1 -1
  7. package/out/renderer/assets/{handlebars-CvpBYG1L.js → handlebars-BnFyz5RH.js} +1 -1
  8. package/out/renderer/assets/{html-CNhEQqr8.js → html-Bl5UFavp.js} +1 -1
  9. package/out/renderer/assets/{htmlMode-DD7YPgga.js → htmlMode-D4VByBT6.js} +3 -3
  10. package/out/renderer/assets/{index-CGHeDwZa.css → index-Cc_TqOCL.css} +318 -0
  11. package/out/renderer/assets/{index-BDlCPzCG.js → index-DxhW1emk.js} +558 -85
  12. package/out/renderer/assets/{javascript-CsGLsswK.js → javascript-CMN1OmwG.js} +2 -2
  13. package/out/renderer/assets/{jsonMode-CTuGgRvC.js → jsonMode-CJ5AYSPV.js} +3 -3
  14. package/out/renderer/assets/{liquid-BrrN4uul.js → liquid-BGMIDTEm.js} +1 -1
  15. package/out/renderer/assets/{lspLanguageFeatures-Bhdn2Eqy.js → lspLanguageFeatures-DGifVYB5.js} +1 -1
  16. package/out/renderer/assets/{mdx-DJo-gax4.js → mdx-B2Z7R10Q.js} +1 -1
  17. package/out/renderer/assets/{python-DYHeg5b2.js → python-LMDMSSdn.js} +1 -1
  18. package/out/renderer/assets/{razor-Cqcks14K.js → razor-BZSrziPu.js} +1 -1
  19. package/out/renderer/assets/{tsMode-YTLgCayh.js → tsMode-DF9m6_3n.js} +1 -1
  20. package/out/renderer/assets/{typescript-oxBn_Vcw.js → typescript-rZeCMf0a.js} +1 -1
  21. package/out/renderer/assets/{xml-BEqGMCTc.js → xml-fNSZ-7so.js} +1 -1
  22. package/out/renderer/assets/{yaml-BGSrk5HC.js → yaml-1wxIuvzh.js} +1 -1
  23. package/out/renderer/index.html +2 -2
  24. package/package.json +1 -1
  25. package/src/main/agents.ts +9 -1
  26. package/src/main/ctlsurfApi.ts +7 -0
  27. package/src/main/index.ts +62 -4
  28. package/src/main/orchestrator.ts +112 -4
  29. package/src/main/ssh.ts +348 -0
  30. package/src/preload/index.ts +26 -0
  31. package/src/renderer/App.tsx +25 -1
  32. package/src/renderer/components/AgentPicker.tsx +18 -0
  33. package/src/renderer/components/ProjectPicker.tsx +121 -0
  34. package/src/renderer/components/RemoteConnect.tsx +315 -0
  35. package/src/renderer/components/SpeakControls.tsx +8 -2
  36. package/src/renderer/components/TerminalPanel.tsx +134 -23
  37. package/src/renderer/components/TicketPanel.tsx +67 -26
  38. package/src/renderer/styles.css +318 -0
@@ -0,0 +1,348 @@
1
+ /**
2
+ * Remote SSH support
3
+ *
4
+ * Lets a tab run a coding agent on a remote machine by spawning `ssh` in the
5
+ * local PTY (see pty.ts) instead of the agent binary directly. Because every
6
+ * byte still flows through the PTY, the agent-state monitor (agentState.ts)
7
+ * keeps driving the dock "awaiting input" indicator for remote agents.
8
+ *
9
+ * Design notes:
10
+ * - Hosts come from the user's own ~/.ssh/config (key-based auth). A short MRU
11
+ * list of recently-used targets is kept for quick reconnect.
12
+ * - We never store passwords. For hosts that need a password, ssh's own prompt
13
+ * collects it over the PTY — it never passes through our code/args/env/disk.
14
+ * That also means we can't enumerate tmux sessions non-interactively for such
15
+ * hosts (BatchMode is rejected), so we fall back to an attach-or-create
16
+ * bootstrap that runs entirely on the remote.
17
+ * - tmux keeps the remote agent alive across SSH drops; sessions are named
18
+ * `ctlsurf_N` so several can coexist and be reattached.
19
+ */
20
+
21
+ import fs from 'fs'
22
+ import os from 'os'
23
+ import path from 'path'
24
+ import { spawn } from 'child_process'
25
+ import { AgentConfig } from './agents'
26
+
27
+ /** Prefix for tmux sessions this app creates, so reconnects can find them. */
28
+ export const TMUX_SESSION_PREFIX = 'ctlsurf_'
29
+
30
+ export interface SshHost {
31
+ /** The `Host` alias as written in ~/.ssh/config (what you pass to `ssh`). */
32
+ alias: string
33
+ /** Resolved HostName, if the config specifies one (for display only). */
34
+ hostName?: string
35
+ /** Configured User, if any (for display only). */
36
+ user?: string
37
+ }
38
+
39
+ export interface SshHostList {
40
+ configHosts: SshHost[]
41
+ recents: string[]
42
+ }
43
+
44
+ export interface ProbeResult {
45
+ /** A usable, key-authenticated connection was established. */
46
+ reachable: boolean
47
+ /**
48
+ * Reachable network-wise but key auth was refused (host likely wants a
49
+ * password). We can't enumerate tmux here; the UI should offer the
50
+ * interactive attach-or-create path instead.
51
+ */
52
+ authRequired: boolean
53
+ /** Human-readable reason when reachable is false. */
54
+ error?: string
55
+ /** Whether tmux is installed on the remote. */
56
+ tmuxInstalled: boolean
57
+ /** All tmux session names found on the remote. */
58
+ sessions: string[]
59
+ /** Sessions created by this app (prefix `ctlsurf_`). */
60
+ ctlsurfSessions: string[]
61
+ }
62
+
63
+ // ─── ~/.ssh/config parsing ─────────────────────────
64
+
65
+ /**
66
+ * Parse ~/.ssh/config into concrete Host aliases. Wildcard patterns (`*`, `?`)
67
+ * are skipped — they're match rules, not connectable targets. Negated tokens
68
+ * (`!pattern`) are ignored too. Best-effort: unknown directives are skipped and
69
+ * a malformed file yields an empty list rather than throwing.
70
+ */
71
+ export function listSshHosts(configPath?: string): SshHost[] {
72
+ const file = configPath || path.join(os.homedir(), '.ssh', 'config')
73
+ let raw: string
74
+ try {
75
+ raw = fs.readFileSync(file, 'utf-8')
76
+ } catch {
77
+ return []
78
+ }
79
+
80
+ const hosts: SshHost[] = []
81
+ // Aliases declared on the current `Host` line; later HostName/User apply to all.
82
+ let current: SshHost[] = []
83
+
84
+ const flush = () => {
85
+ for (const h of current) hosts.push(h)
86
+ current = []
87
+ }
88
+
89
+ for (const line of raw.split('\n')) {
90
+ const trimmed = line.trim()
91
+ if (!trimmed || trimmed.startsWith('#')) continue
92
+
93
+ // Directives are "Keyword value..." separated by whitespace or '='.
94
+ const m = trimmed.match(/^(\S+)[\s=]+(.+)$/)
95
+ if (!m) continue
96
+ const keyword = m[1].toLowerCase()
97
+ const value = m[2].trim()
98
+
99
+ if (keyword === 'host') {
100
+ flush()
101
+ const aliases = value
102
+ .split(/\s+/)
103
+ .filter((a) => a && !a.includes('*') && !a.includes('?') && !a.startsWith('!'))
104
+ current = aliases.map((alias) => ({ alias }))
105
+ } else if (keyword === 'hostname') {
106
+ for (const h of current) h.hostName = value
107
+ } else if (keyword === 'user') {
108
+ for (const h of current) h.user = value
109
+ }
110
+ }
111
+ flush()
112
+
113
+ // De-dupe by alias, preserving first occurrence (ssh's own precedence).
114
+ const seen = new Set<string>()
115
+ return hosts.filter((h) => (seen.has(h.alias) ? false : (seen.add(h.alias), true)))
116
+ }
117
+
118
+ // ─── Recent hosts (MRU) ────────────────────────────
119
+
120
+ const RECENTS_FILE = 'ssh-recents.json'
121
+ const MAX_RECENTS = 8
122
+
123
+ function recentsPath(settingsDir: string): string {
124
+ return path.join(settingsDir, RECENTS_FILE)
125
+ }
126
+
127
+ export function getRecentHosts(settingsDir: string): string[] {
128
+ try {
129
+ const raw = JSON.parse(fs.readFileSync(recentsPath(settingsDir), 'utf-8'))
130
+ if (Array.isArray(raw)) return raw.filter((x) => typeof x === 'string')
131
+ } catch {
132
+ /* no recents yet */
133
+ }
134
+ return []
135
+ }
136
+
137
+ /** Record `target` as most-recently-used (newest first, de-duped, capped). */
138
+ export function addRecentHost(settingsDir: string, target: string): void {
139
+ const t = target.trim()
140
+ if (!t) return
141
+ const next = [t, ...getRecentHosts(settingsDir).filter((x) => x !== t)].slice(0, MAX_RECENTS)
142
+ try {
143
+ fs.mkdirSync(settingsDir, { recursive: true })
144
+ fs.writeFileSync(recentsPath(settingsDir), JSON.stringify(next, null, 2))
145
+ } catch {
146
+ /* non-fatal: recents are a convenience */
147
+ }
148
+ }
149
+
150
+ // ─── Non-interactive probe ─────────────────────────
151
+
152
+ // Remote one-liner: prove tmux presence, then list sessions. Markers let us
153
+ // parse a single combined stdout stream regardless of shell chrome.
154
+ const PROBE_REMOTE_CMD =
155
+ 'command -v tmux >/dev/null 2>&1 && echo __TMUX_OK__; tmux ls 2>/dev/null; echo __DONE__'
156
+
157
+ /**
158
+ * Probe a remote host without any interaction. Uses BatchMode so it fails fast
159
+ * (rather than hanging on a password prompt) when key auth isn't available —
160
+ * that case is reported as `authRequired`, signalling the UI to use the
161
+ * interactive path. Only meaningful for key-authenticated hosts.
162
+ */
163
+ export function probeRemote(target: string): Promise<ProbeResult> {
164
+ return new Promise((resolve) => {
165
+ const args = [
166
+ '-o', 'BatchMode=yes',
167
+ '-o', 'ConnectTimeout=8',
168
+ '-o', 'StrictHostKeyChecking=accept-new',
169
+ target,
170
+ PROBE_REMOTE_CMD,
171
+ ]
172
+ let stdout = ''
173
+ let stderr = ''
174
+ let settled = false
175
+ const done = (r: ProbeResult) => {
176
+ if (settled) return
177
+ settled = true
178
+ resolve(r)
179
+ }
180
+
181
+ let child: ReturnType<typeof spawn>
182
+ try {
183
+ child = spawn('ssh', args, { stdio: ['ignore', 'pipe', 'pipe'] })
184
+ } catch (err: any) {
185
+ done({ reachable: false, authRequired: false, error: err?.message || 'spawn failed', tmuxInstalled: false, sessions: [], ctlsurfSessions: [] })
186
+ return
187
+ }
188
+
189
+ // Hard ceiling in case ssh wedges despite ConnectTimeout.
190
+ const killTimer = setTimeout(() => {
191
+ try { child.kill() } catch { /* already gone */ }
192
+ done({ reachable: false, authRequired: false, error: 'Timed out connecting', tmuxInstalled: false, sessions: [], ctlsurfSessions: [] })
193
+ }, 12_000)
194
+
195
+ child.stdout?.on('data', (d) => { stdout += d.toString() })
196
+ child.stderr?.on('data', (d) => { stderr += d.toString() })
197
+
198
+ child.on('error', (err) => {
199
+ clearTimeout(killTimer)
200
+ done({ reachable: false, authRequired: false, error: err.message, tmuxInstalled: false, sessions: [], ctlsurfSessions: [] })
201
+ })
202
+
203
+ child.on('close', (code) => {
204
+ clearTimeout(killTimer)
205
+ if (!stdout.includes('__DONE__')) {
206
+ // The remote command never ran → couldn't connect/authenticate.
207
+ const authRequired = /permission denied|password|publickey/i.test(stderr)
208
+ const error = authRequired
209
+ ? 'Key auth refused (password required)'
210
+ : (stderr.trim().split('\n').pop() || `ssh exited ${code}`)
211
+ done({ reachable: false, authRequired, error, tmuxInstalled: false, sessions: [], ctlsurfSessions: [] })
212
+ return
213
+ }
214
+ const tmuxInstalled = stdout.includes('__TMUX_OK__')
215
+ const sessions = parseTmuxSessions(stdout)
216
+ done({
217
+ reachable: true,
218
+ authRequired: false,
219
+ tmuxInstalled,
220
+ sessions,
221
+ ctlsurfSessions: sessions.filter((s) => s.startsWith(TMUX_SESSION_PREFIX)),
222
+ })
223
+ })
224
+ })
225
+ }
226
+
227
+ // ─── Password prompt detection (for one-shot injection) ────
228
+
229
+ /** Remove ANSI escape sequences so a colon-terminated prompt matches cleanly. */
230
+ export function stripAnsiSeq(s: string): string {
231
+ return s.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '')
232
+ }
233
+
234
+ // ssh's password/passphrase prompts, e.g. "user@host's password: " or
235
+ // "Enter passphrase for key '/path': ". Anchored to end-of-buffer so it only
236
+ // fires on the live prompt, not on the word appearing mid-output.
237
+ const PASSWORD_PROMPT_RE = /(?:password|passphrase)[^\n]*:\s*$/i
238
+
239
+ /** Whether the (ANSI-stripped) trailing output is sitting at a password prompt. */
240
+ export function isPasswordPrompt(tail: string): boolean {
241
+ return PASSWORD_PROMPT_RE.test(tail)
242
+ }
243
+
244
+ /** Extract session names from `tmux ls` output ("name: 1 windows ..." per line). */
245
+ export function parseTmuxSessions(stdout: string): string[] {
246
+ const out: string[] = []
247
+ for (const line of stdout.split('\n')) {
248
+ const t = line.trim()
249
+ if (!t || t.startsWith('__')) continue
250
+ const m = t.match(/^([^:\s]+):/)
251
+ if (m) out.push(m[1])
252
+ }
253
+ return out
254
+ }
255
+
256
+ /** Lowest free `ctlsurf_N` (N >= 1) given the sessions already on the host. */
257
+ export function nextSessionName(existing: string[]): string {
258
+ const used = new Set(
259
+ existing
260
+ .filter((s) => s.startsWith(TMUX_SESSION_PREFIX))
261
+ .map((s) => parseInt(s.slice(TMUX_SESSION_PREFIX.length), 10))
262
+ .filter((n) => Number.isInteger(n) && n > 0),
263
+ )
264
+ let n = 1
265
+ while (used.has(n)) n++
266
+ return `${TMUX_SESSION_PREFIX}${n}`
267
+ }
268
+
269
+ // ─── Remote command builder ────────────────────────
270
+
271
+ export type RemoteAction =
272
+ /** Reattach an existing tmux session. */
273
+ | { kind: 'attach'; session: string }
274
+ /** Create (or attach, via -A) a tmux session running the agent. */
275
+ | { kind: 'new-tmux'; session: string }
276
+ /** Run the agent directly, no tmux (no persistence). */
277
+ | { kind: 'no-tmux' }
278
+ /** Password hosts: let the remote decide attach-or-create vs bare agent. */
279
+ | { kind: 'auto'; session: string }
280
+
281
+ export interface BuildRemoteOptions {
282
+ /** SSH target: a ~/.ssh/config alias or `user@host`. */
283
+ target: string
284
+ action: RemoteAction
285
+ /** Remote command to run as the agent. Empty string = remote login shell. */
286
+ agentCmd?: string
287
+ }
288
+
289
+ // Single-quote a string for safe embedding in a remote sh command.
290
+ function shQuote(s: string): string {
291
+ return `'${s.replace(/'/g, `'\\''`)}'`
292
+ }
293
+
294
+ function remoteCommandFor(action: RemoteAction, agentCmd: string): string {
295
+ const inner = agentCmd.trim() // e.g. "claude"; empty → shell
296
+ switch (action.kind) {
297
+ case 'attach':
298
+ return `tmux attach -t ${shQuote(action.session)}`
299
+ case 'new-tmux':
300
+ // -A: attach if the session somehow already exists, else create it.
301
+ return inner
302
+ ? `tmux new-session -A -s ${shQuote(action.session)} ${shQuote(inner)}`
303
+ : `tmux new-session -A -s ${shQuote(action.session)}`
304
+ case 'no-tmux':
305
+ return inner
306
+ case 'auto':
307
+ // Password host: probe + branch entirely on the remote so we never need
308
+ // a second (password-prompting) connection.
309
+ return inner
310
+ ? `if command -v tmux >/dev/null 2>&1; then tmux new-session -A -s ${shQuote(action.session)} ${shQuote(inner)}; else ${inner}; fi`
311
+ : `if command -v tmux >/dev/null 2>&1; then tmux new-session -A -s ${shQuote(action.session)}; else exec "$SHELL" -l; fi`
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Build an AgentConfig that runs the chosen remote action via `ssh` in the PTY.
317
+ * The id is prefixed `remote-` so isRemoteAgent()/isCodingAgent() keep the tab
318
+ * out of local-only plumbing (transcripts, ctlsurf worker socket) while the
319
+ * always-on state monitor still drives the dock indicator.
320
+ */
321
+ export function buildRemoteAgent(opts: BuildRemoteOptions): AgentConfig {
322
+ const { target, action } = opts
323
+ const agentCmd = opts.agentCmd ?? 'claude'
324
+ const remoteCmd = remoteCommandFor(action, agentCmd)
325
+
326
+ // `-t` forces a pseudo-terminal so the remote TUI (tmux/claude) renders.
327
+ const args = remoteCmd ? ['-t', target, remoteCmd] : ['-t', target]
328
+
329
+ const safeTarget = target.replace(/[^a-zA-Z0-9_.@-]/g, '_')
330
+ const label =
331
+ action.kind === 'attach' ? action.session
332
+ : action.kind === 'new-tmux' || action.kind === 'auto' ? action.session
333
+ : 'direct'
334
+
335
+ const agentLabel = agentCmd.trim() || 'shell'
336
+ const desc =
337
+ action.kind === 'attach' ? `SSH ${target} → attach tmux ${action.session}`
338
+ : action.kind === 'no-tmux' ? `SSH ${target} → ${agentLabel}`
339
+ : `SSH ${target} → tmux ${('session' in action) ? action.session : ''} → ${agentLabel}`
340
+
341
+ return {
342
+ id: `remote-${safeTarget}-${label}`,
343
+ name: `${target} · ${label}`,
344
+ command: 'ssh',
345
+ args,
346
+ description: desc,
347
+ }
348
+ }
@@ -39,6 +39,32 @@ const api = {
39
39
  getDefaultAgent: (): Promise<AgentConfig> =>
40
40
  ipcRenderer.invoke('agents:default'),
41
41
 
42
+ // Remote (SSH)
43
+ sshListHosts: (): Promise<{
44
+ configHosts: Array<{ alias: string; hostName?: string; user?: string }>
45
+ recents: string[]
46
+ }> => ipcRenderer.invoke('ssh:listHosts'),
47
+ sshProbe: (target: string): Promise<{
48
+ reachable: boolean
49
+ authRequired: boolean
50
+ error?: string
51
+ tmuxInstalled: boolean
52
+ sessions: string[]
53
+ ctlsurfSessions: string[]
54
+ }> => ipcRenderer.invoke('ssh:probe', target),
55
+ sshBuildAgent: (target: string, action: unknown, agentCmd?: string): Promise<AgentConfig> =>
56
+ ipcRenderer.invoke('ssh:buildAgent', target, action, agentCmd),
57
+ sshAddRecent: (target: string): Promise<{ ok: boolean }> =>
58
+ ipcRenderer.invoke('ssh:addRecent', target),
59
+ sshSetPassword: (target: string, password: string): Promise<{ ok: boolean }> =>
60
+ ipcRenderer.invoke('ssh:setPassword', target, password),
61
+
62
+ // ctlsurf project selection (for remote sessions)
63
+ listProjects: (): Promise<{ ok: boolean; projects: Array<{ id: string; name: string; root_path: string }>; error?: string }> =>
64
+ ipcRenderer.invoke('ctlsurf:listProjects'),
65
+ bindProject: (rootPath: string): Promise<{ ok: boolean; error?: string }> =>
66
+ ipcRenderer.invoke('ctlsurf:bindProject', rootPath),
67
+
42
68
  // App info
43
69
  getHomePath: (): Promise<string> =>
44
70
  ipcRenderer.invoke('app:homePath'),
@@ -5,6 +5,7 @@ import { SpeakControls } from './components/SpeakControls'
5
5
  import { CtlsurfPanel } from './components/CtlsurfPanel'
6
6
  import { EditorPanel } from './components/EditorPanel'
7
7
  import { AgentPicker } from './components/AgentPicker'
8
+ import { ProjectPicker } from './components/ProjectPicker'
8
9
  import {
9
10
  PaneLayout,
10
11
  type LayoutNode,
@@ -36,6 +37,13 @@ declare global {
36
37
  onPtyExit: (callback: (tabId: string, code: number) => void) => () => void
37
38
  listAgents: () => Promise<AgentConfig[]>
38
39
  getDefaultAgent: () => Promise<AgentConfig>
40
+ sshListHosts: () => Promise<{ configHosts: Array<{ alias: string; hostName?: string; user?: string }>; recents: string[] }>
41
+ sshProbe: (target: string) => Promise<{ reachable: boolean; authRequired: boolean; error?: string; tmuxInstalled: boolean; sessions: string[]; ctlsurfSessions: string[] }>
42
+ sshBuildAgent: (target: string, action: unknown, agentCmd?: string) => Promise<AgentConfig>
43
+ sshAddRecent: (target: string) => Promise<{ ok: boolean }>
44
+ sshSetPassword: (target: string, password: string) => Promise<{ ok: boolean }>
45
+ listProjects: () => Promise<{ ok: boolean; projects: Array<{ id: string; name: string; root_path: string }>; error?: string }>
46
+ bindProject: (rootPath: string) => Promise<{ ok: boolean; error?: string }>
39
47
  getHomePath: () => Promise<string>
40
48
  getCwd: () => Promise<string>
41
49
  getProjectName: () => Promise<string | null>
@@ -155,6 +163,9 @@ export default function App() {
155
163
  // Agent picker state: which tab is being configured (null = initial picker for first tab)
156
164
  const [pickerTargetTabId, setPickerTargetTabId] = useState<string | null>(tabs[0].id)
157
165
  const [showAgentPicker, setShowAgentPicker] = useState(true)
166
+ // Project picker: shown right after a remote (SSH) session connects, to bind
167
+ // it to a ctlsurf project.
168
+ const [showProjectPicker, setShowProjectPicker] = useState(false)
158
169
 
159
170
  // Track which panes are in the layout tree
160
171
  const visiblePaneIds = findPaneIds(layout)
@@ -316,6 +327,11 @@ export default function App() {
316
327
  }
317
328
  setShowAgentPicker(false)
318
329
  setPickerTargetTabId(null)
330
+ // Remote (SSH) sessions have no meaningful local cwd, so prompt the user to
331
+ // bind this session to a ctlsurf project once the terminal is up.
332
+ if (agent.id.startsWith('remote-')) {
333
+ setShowProjectPicker(true)
334
+ }
319
335
  }, [pickerTargetTabId, tabs])
320
336
 
321
337
  const closeTab = useCallback(async (tabId: string) => {
@@ -422,6 +438,10 @@ export default function App() {
422
438
  return () => window.removeEventListener('ctlsurf-open-url', onOpen)
423
439
  }, [layout, togglePane])
424
440
 
441
+ // The active tab's agent — remote SSH sessions (id `remote-…`) can't have
442
+ // their replies spoken, so the speaker toggle is disabled for them.
443
+ const activeAgentIsRemote = !!tabs.find(t => t.id === activeTabId)?.agent?.id?.startsWith('remote-')
444
+
425
445
  // Build pane contents (always rendered, layout controls visibility)
426
446
  const panes: PaneContent[] = [
427
447
  { id: 'editor', label: 'Editor', content: <EditorPanel cwd={cwd} /> },
@@ -546,7 +566,7 @@ export default function App() {
546
566
  <line x1="8" y1="23" x2="16" y2="23" />
547
567
  </svg>
548
568
  </button>
549
- <SpeakControls />
569
+ <SpeakControls remote={activeAgentIsRemote} />
550
570
  <span className="titlebar-separator" />
551
571
  {agents.map(a => {
552
572
  const activeTab = tabs.find(t => t.id === activeTabId)
@@ -608,6 +628,10 @@ export default function App() {
608
628
  />
609
629
  )}
610
630
 
631
+ {showProjectPicker && (
632
+ <ProjectPicker onDone={() => setShowProjectPicker(false)} />
633
+ )}
634
+
611
635
  {showFloatingMic && (
612
636
  <FloatingMic
613
637
  onTranscript={handleVoiceTranscript}
@@ -1,4 +1,5 @@
1
1
  import { useEffect, useState } from 'react'
2
+ import { RemoteConnect } from './RemoteConnect'
2
3
 
3
4
  interface AgentConfig {
4
5
  id: string
@@ -19,6 +20,7 @@ export function AgentPicker({ agents, cwd, onSelect, onChangeCwd }: AgentPickerP
19
20
  const displayPath = cwd.replace(/^\/Users\/[^/]+/, '~')
20
21
  const [logChat, setLogChat] = useState(false)
21
22
  const [loaded, setLoaded] = useState(false)
23
+ const [remoteMode, setRemoteMode] = useState(false)
22
24
 
23
25
  useEffect(() => {
24
26
  let cancelled = false
@@ -43,6 +45,15 @@ export function AgentPicker({ agents, cwd, onSelect, onChangeCwd }: AgentPickerP
43
45
  onSelect(agent)
44
46
  }
45
47
 
48
+ if (remoteMode) {
49
+ return (
50
+ <RemoteConnect
51
+ onConnect={(agent) => { handleSelect(agent) }}
52
+ onBack={() => setRemoteMode(false)}
53
+ />
54
+ )
55
+ }
56
+
46
57
  return (
47
58
  <div className="agent-picker-overlay">
48
59
  <div className="agent-picker-modal">
@@ -66,6 +77,13 @@ export function AgentPicker({ agents, cwd, onSelect, onChangeCwd }: AgentPickerP
66
77
  <span className="agent-picker-desc">{a.description}</span>
67
78
  </button>
68
79
  ))}
80
+ <button
81
+ className="agent-picker-item"
82
+ onClick={() => setRemoteMode(true)}
83
+ >
84
+ <span className="agent-picker-name">🌐 Remote (SSH)</span>
85
+ <span className="agent-picker-desc">Run an agent on a remote machine</span>
86
+ </button>
69
87
  </div>
70
88
 
71
89
  <label className="agent-picker-option" onClick={(e) => e.stopPropagation()}>
@@ -0,0 +1,121 @@
1
+ import { useEffect, useState } from 'react'
2
+
3
+ interface Project {
4
+ id: string
5
+ name: string
6
+ root_path: string
7
+ }
8
+
9
+ interface ProjectPickerProps {
10
+ /** Called when the user picks a project or skips — the overlay should close. */
11
+ onDone: () => void
12
+ }
13
+
14
+ type State = 'loading' | 'ready' | 'error'
15
+
16
+ // Shown right after a remote (SSH) session connects: lets the user bind it to a
17
+ // ctlsurf project so chat logging, tickets, and remote-control attach there.
18
+ export function ProjectPicker({ onDone }: ProjectPickerProps) {
19
+ const [state, setState] = useState<State>('loading')
20
+ const [projects, setProjects] = useState<Project[]>([])
21
+ const [errorMsg, setErrorMsg] = useState('')
22
+ const [binding, setBinding] = useState(false)
23
+ const [filter, setFilter] = useState('')
24
+
25
+ useEffect(() => {
26
+ let cancelled = false
27
+ window.worker.listProjects().then((r) => {
28
+ if (cancelled) return
29
+ if (r.ok) {
30
+ setProjects(r.projects || [])
31
+ setState('ready')
32
+ } else {
33
+ setErrorMsg(r.error || 'Could not load projects')
34
+ setState('error')
35
+ }
36
+ }).catch((err) => {
37
+ if (cancelled) return
38
+ setErrorMsg(err?.message || 'Could not load projects')
39
+ setState('error')
40
+ })
41
+ return () => { cancelled = true }
42
+ }, [])
43
+
44
+ const pick = async (p: Project) => {
45
+ setBinding(true)
46
+ try {
47
+ await window.worker.bindProject(p.root_path)
48
+ } catch { /* non-fatal — session still runs unbound */ }
49
+ onDone()
50
+ }
51
+
52
+ const shown = filter.trim()
53
+ ? projects.filter((p) =>
54
+ p.name.toLowerCase().includes(filter.toLowerCase()) ||
55
+ p.root_path.toLowerCase().includes(filter.toLowerCase()))
56
+ : projects
57
+
58
+ return (
59
+ <div className="agent-picker-overlay">
60
+ <div className="agent-picker-modal">
61
+ <div className="agent-picker-brand">ctlsurf</div>
62
+ <div className="agent-picker-title">SELECT CTLSURF PROJECT</div>
63
+ <div className="remote-target-line">Bind this remote session to a project</div>
64
+
65
+ {state === 'loading' && (
66
+ <div className="remote-status">
67
+ <div className="remote-spinner" />
68
+ <div>Loading projects…</div>
69
+ </div>
70
+ )}
71
+
72
+ {state === 'error' && (
73
+ <>
74
+ <div className="remote-note">{errorMsg}</div>
75
+ </>
76
+ )}
77
+
78
+ {state === 'ready' && (
79
+ <>
80
+ {projects.length === 0 && (
81
+ <div className="remote-note">No ctlsurf projects found. You can skip and bind one later.</div>
82
+ )}
83
+
84
+ {projects.length > 6 && (
85
+ <input
86
+ className="remote-input"
87
+ style={{ width: '100%', boxSizing: 'border-box', margin: '4px 0 8px' }}
88
+ type="text"
89
+ placeholder="Filter projects…"
90
+ value={filter}
91
+ onChange={(e) => setFilter(e.target.value)}
92
+ autoCapitalize="off"
93
+ autoCorrect="off"
94
+ spellCheck={false}
95
+ />
96
+ )}
97
+
98
+ <div className="agent-picker-list remote-host-scroll">
99
+ {shown.map((p) => (
100
+ <button
101
+ key={p.id}
102
+ className="agent-picker-item project-item"
103
+ disabled={binding}
104
+ onClick={() => pick(p)}
105
+ title={p.root_path}
106
+ >
107
+ <span className="agent-picker-name">{p.name}</span>
108
+ <span className="agent-picker-desc">{p.root_path}</span>
109
+ </button>
110
+ ))}
111
+ </div>
112
+ </>
113
+ )}
114
+
115
+ <button className="remote-link" disabled={binding} onClick={onDone}>
116
+ Skip — no project
117
+ </button>
118
+ </div>
119
+ </div>
120
+ )
121
+ }