@phenx-inc/ctlsurf 0.10.0 → 0.12.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 (36) hide show
  1. package/out/headless/index.mjs +111 -5
  2. package/out/headless/index.mjs.map +3 -3
  3. package/out/main/index.js +357 -28
  4. package/out/preload/index.js +9 -0
  5. package/out/renderer/assets/{cssMode-BbCVOTaJ.js → cssMode-R8fTFLae.js} +3 -3
  6. package/out/renderer/assets/{freemarker2-DryLywQv.js → freemarker2-CyjWzWoP.js} +1 -1
  7. package/out/renderer/assets/{handlebars-CdUMtSdr.js → handlebars-CrhCGIRM.js} +1 -1
  8. package/out/renderer/assets/{html-Cwkf2GMH.js → html-Cc3dPNZr.js} +1 -1
  9. package/out/renderer/assets/{htmlMode-C7PzhXxH.js → htmlMode-BWtG6ywk.js} +3 -3
  10. package/out/renderer/assets/{index-BIceGcz3.css → index-BL9njXIL.css} +206 -46
  11. package/out/renderer/assets/{index-Dg1vMspf.js → index-kkG56aKv.js} +529 -156
  12. package/out/renderer/assets/{javascript-Cy7ROXJl.js → javascript-C8T_SdLC.js} +2 -2
  13. package/out/renderer/assets/{jsonMode-BtZtt3QW.js → jsonMode-D1zR4VVU.js} +3 -3
  14. package/out/renderer/assets/{liquid-xReAQeYs.js → liquid-COSMe9Uf.js} +1 -1
  15. package/out/renderer/assets/{lspLanguageFeatures-Dmx0L0Bw.js → lspLanguageFeatures-CXGfrtzl.js} +1 -1
  16. package/out/renderer/assets/{mdx-YMNFO1Zz.js → mdx-RfS44T-L.js} +1 -1
  17. package/out/renderer/assets/{python-BExaDVxv.js → python-DFSWKHUu.js} +1 -1
  18. package/out/renderer/assets/{razor-HL8ANmQ4.js → razor-DtH2_uvo.js} +1 -1
  19. package/out/renderer/assets/{tsMode-Dqd8vdLE.js → tsMode-BYIQ-S9Z.js} +1 -1
  20. package/out/renderer/assets/{typescript-Dysz1PTX.js → typescript-HhQUr1Rb.js} +1 -1
  21. package/out/renderer/assets/{xml-BHp5Z9nc.js → xml-KuhgdKfD.js} +1 -1
  22. package/out/renderer/assets/{yaml-CPDHxVup.js → yaml-W_kzCFH7.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 +118 -34
  28. package/src/main/orchestrator.ts +105 -4
  29. package/src/main/ssh.ts +348 -0
  30. package/src/preload/index.ts +26 -0
  31. package/src/renderer/App.tsx +29 -7
  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/TicketPanel.tsx +20 -25
  36. package/src/renderer/styles.css +206 -46
@@ -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,
@@ -14,7 +15,7 @@ import {
14
15
  } from './components/PaneLayout'
15
16
  import { StatusBar } from './components/StatusBar'
16
17
  import { SettingsDialog } from './components/SettingsDialog'
17
- import { TicketPanel } from './components/TicketPanel'
18
+ import { TicketPane } from './components/TicketPanel'
18
19
 
19
20
  interface AgentConfig {
20
21
  id: string
@@ -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>
@@ -96,11 +104,13 @@ const DEFAULT_LAYOUT: LayoutNode = {
96
104
  direction: 'horizontal',
97
105
  children: [
98
106
  { type: 'leaf', paneId: 'terminal' },
99
- { type: 'leaf', paneId: 'ctlsurf' },
107
+ { type: 'leaf', paneId: 'tickets' },
100
108
  ],
101
109
  sizes: [50, 50],
102
110
  }
103
111
 
112
+ // Panes shown as plain toggle buttons in the titlebar. `tickets` has its own
113
+ // dedicated icon button, so it's not listed here.
104
114
  const ALL_PANE_IDS = ['editor', 'terminal', 'ctlsurf']
105
115
 
106
116
  interface TabInfo {
@@ -141,7 +151,6 @@ export default function App() {
141
151
  // Per-tab agent activity ('busy' | 'awaiting_input' | 'awaiting_decision' | 'idle'),
142
152
  // driven by the main process. Powers the awaiting-input dot on each tab.
143
153
  const [agentStates, setAgentStates] = useState<Record<string, string>>({})
144
- const [showTicketPanel, setShowTicketPanel] = useState(false)
145
154
  // Draggable on-canvas push-to-talk mic; visibility persists across launches.
146
155
  const [showFloatingMic, setShowFloatingMic] = useState<boolean>(() => {
147
156
  try { return localStorage.getItem('ctlsurf.floatingMicVisible') !== 'false' } catch { return true }
@@ -154,6 +163,9 @@ export default function App() {
154
163
  // Agent picker state: which tab is being configured (null = initial picker for first tab)
155
164
  const [pickerTargetTabId, setPickerTargetTabId] = useState<string | null>(tabs[0].id)
156
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)
157
169
 
158
170
  // Track which panes are in the layout tree
159
171
  const visiblePaneIds = findPaneIds(layout)
@@ -315,6 +327,11 @@ export default function App() {
315
327
  }
316
328
  setShowAgentPicker(false)
317
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
+ }
318
335
  }, [pickerTargetTabId, tabs])
319
336
 
320
337
  const closeTab = useCallback(async (tabId: string) => {
@@ -399,6 +416,7 @@ export default function App() {
399
416
  case 'E': e.preventDefault(); togglePane('editor'); break
400
417
  case 'T': e.preventDefault(); togglePane('terminal'); break
401
418
  case 'P': e.preventDefault(); togglePane('ctlsurf'); break
419
+ case 'K': e.preventDefault(); togglePane('tickets'); break
402
420
  }
403
421
  }
404
422
  }
@@ -488,6 +506,7 @@ export default function App() {
488
506
  ),
489
507
  },
490
508
  { id: 'ctlsurf', label: 'ctlsurf', content: <CtlsurfPanel navigate={ctlsurfNav} /> },
509
+ { id: 'tickets', label: 'Tickets', content: <TicketPane /> },
491
510
  ]
492
511
 
493
512
  return (
@@ -516,9 +535,9 @@ export default function App() {
516
535
  <span className={`tracking-dot ${trackingActive ? 'on' : 'off'}`} />
517
536
  </button>
518
537
  <button
519
- className={`titlebar-btn titlebar-icon-btn ${showTicketPanel ? 'active' : ''}`}
520
- onClick={() => setShowTicketPanel(v => !v)}
521
- title="Tickets for the active project"
538
+ className={`titlebar-btn titlebar-icon-btn ${visiblePaneIds.includes('tickets') ? 'active' : ''}`}
539
+ onClick={() => togglePane('tickets')}
540
+ title="Toggle tickets pane for the active project"
522
541
  aria-label="Tickets"
523
542
  >
524
543
  <svg className="ticket-tag-icon" viewBox="0 0 24 24" width="13" height="13"
@@ -589,7 +608,6 @@ export default function App() {
589
608
 
590
609
  <StatusBar wsStatus={wsStatus} cwd={cwd} onChangeCwd={handleChangeCwd} updateInfo={updateInfo} />
591
610
  <SettingsDialog open={showSettings} onClose={() => setShowSettings(false)} />
592
- <TicketPanel open={showTicketPanel} onClose={() => setShowTicketPanel(false)} />
593
611
 
594
612
  {showAgentPicker && agents.length > 0 && (
595
613
  <AgentPicker
@@ -606,6 +624,10 @@ export default function App() {
606
624
  />
607
625
  )}
608
626
 
627
+ {showProjectPicker && (
628
+ <ProjectPicker onDone={() => setShowProjectPicker(false)} />
629
+ )}
630
+
609
631
  {showFloatingMic && (
610
632
  <FloatingMic
611
633
  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
+ }