@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,315 @@
1
+ import { useEffect, useState } from 'react'
2
+
3
+ interface AgentConfig {
4
+ id: string
5
+ name: string
6
+ command: string
7
+ args: string[]
8
+ description: string
9
+ }
10
+
11
+ interface SshHost {
12
+ alias: string
13
+ hostName?: string
14
+ user?: string
15
+ }
16
+
17
+ interface ProbeResult {
18
+ reachable: boolean
19
+ authRequired: boolean
20
+ error?: string
21
+ tmuxInstalled: boolean
22
+ sessions: string[]
23
+ ctlsurfSessions: string[]
24
+ }
25
+
26
+ interface RemoteConnectProps {
27
+ /** Called with the built ssh-backed agent once an action is chosen. */
28
+ onConnect: (agent: AgentConfig) => void
29
+ /** Return to the normal agent list. */
30
+ onBack: () => void
31
+ }
32
+
33
+ type Step = 'hosts' | 'probing' | 'action' | 'error'
34
+
35
+ const TMUX_PREFIX = 'ctlsurf_'
36
+
37
+ // Lowest free ctlsurf_N given the sessions already on the host (mirrors
38
+ // nextSessionName in src/main/ssh.ts so the label is right before we connect).
39
+ function nextSessionName(sessions: string[]): string {
40
+ const used = new Set(
41
+ sessions
42
+ .filter((s) => s.startsWith(TMUX_PREFIX))
43
+ .map((s) => parseInt(s.slice(TMUX_PREFIX.length), 10))
44
+ .filter((n) => Number.isInteger(n) && n > 0),
45
+ )
46
+ let n = 1
47
+ while (used.has(n)) n++
48
+ return `${TMUX_PREFIX}${n}`
49
+ }
50
+
51
+ export function RemoteConnect({ onConnect, onBack }: RemoteConnectProps) {
52
+ const [step, setStep] = useState<Step>('hosts')
53
+ const [configHosts, setConfigHosts] = useState<SshHost[]>([])
54
+ const [recents, setRecents] = useState<string[]>([])
55
+ const [manual, setManual] = useState('')
56
+ const [agentCmd, setAgentCmd] = useState<'claude' | ''>('claude') // '' = remote shell
57
+ const [target, setTarget] = useState('')
58
+ const [probe, setProbe] = useState<ProbeResult | null>(null)
59
+ const [errorMsg, setErrorMsg] = useState('')
60
+ const [password, setPassword] = useState('')
61
+
62
+ useEffect(() => {
63
+ let cancelled = false
64
+ window.worker.sshListHosts().then((r) => {
65
+ if (cancelled) return
66
+ setConfigHosts(r.configHosts || [])
67
+ setRecents(r.recents || [])
68
+ }).catch(() => { /* no hosts */ })
69
+ return () => { cancelled = true }
70
+ }, [])
71
+
72
+ const startProbe = async (t: string) => {
73
+ const dest = t.trim()
74
+ if (!dest) return
75
+ setTarget(dest)
76
+ setStep('probing')
77
+ try {
78
+ const result = await window.worker.sshProbe(dest)
79
+ setProbe(result)
80
+ if (result.reachable || result.authRequired) {
81
+ setStep('action')
82
+ } else {
83
+ setErrorMsg(result.error || 'Could not connect')
84
+ setStep('error')
85
+ }
86
+ } catch (err: any) {
87
+ setErrorMsg(err?.message || 'Probe failed')
88
+ setStep('error')
89
+ }
90
+ }
91
+
92
+ const connect = async (action: unknown) => {
93
+ try {
94
+ const agent = await window.worker.sshBuildAgent(target, action, agentCmd)
95
+ // Hand the typed password (if any) to the main process for one-shot
96
+ // injection into ssh's prompt, then wipe it from component state. Blank =
97
+ // let ssh prompt in the terminal instead.
98
+ if (password) {
99
+ await window.worker.sshSetPassword(target, password).catch(() => { /* falls back to terminal prompt */ })
100
+ }
101
+ setPassword('')
102
+ window.worker.sshAddRecent(target).catch(() => { /* convenience only */ })
103
+ onConnect(agent)
104
+ } catch (err: any) {
105
+ setErrorMsg(err?.message || 'Failed to build connection')
106
+ setStep('error')
107
+ }
108
+ }
109
+
110
+ const passwordField = (
111
+ <div className="remote-pw">
112
+ <input
113
+ className="remote-input"
114
+ type="password"
115
+ placeholder="Password (optional — blank prompts in terminal)"
116
+ value={password}
117
+ onChange={(e) => setPassword(e.target.value)}
118
+ autoCapitalize="off"
119
+ autoCorrect="off"
120
+ spellCheck={false}
121
+ />
122
+ <div className="remote-pw-hint">Used once for this connection — never stored.</div>
123
+ </div>
124
+ )
125
+
126
+ const agentLabel = agentCmd === 'claude' ? 'Claude Code' : 'shell'
127
+
128
+ // ─── Agent toggle (Claude Code vs shell), shared across steps ───
129
+ const agentToggle = (
130
+ <div className="remote-toggle">
131
+ <span className="remote-toggle-label">Run:</span>
132
+ <button
133
+ className={`remote-toggle-btn${agentCmd === 'claude' ? ' is-active' : ''}`}
134
+ onClick={() => setAgentCmd('claude')}
135
+ >Claude Code</button>
136
+ <button
137
+ className={`remote-toggle-btn${agentCmd === '' ? ' is-active' : ''}`}
138
+ onClick={() => setAgentCmd('')}
139
+ >Shell</button>
140
+ </div>
141
+ )
142
+
143
+ return (
144
+ <div className="agent-picker-overlay">
145
+ <div className="agent-picker-modal">
146
+ <div className="agent-picker-brand">ctlsurf</div>
147
+ <div className="agent-picker-title">CONNECT TO REMOTE</div>
148
+
149
+ {step === 'hosts' && (
150
+ <>
151
+ {agentToggle}
152
+
153
+ {recents.length > 0 && (
154
+ <>
155
+ <div className="remote-section-label">RECENT</div>
156
+ <div className="agent-picker-list">
157
+ {recents.map((r) => (
158
+ <button key={r} className="agent-picker-item" onClick={() => startProbe(r)}>
159
+ <span className="agent-picker-name">{r}</span>
160
+ <span className="agent-picker-desc">reconnect</span>
161
+ </button>
162
+ ))}
163
+ </div>
164
+ </>
165
+ )}
166
+
167
+ {configHosts.length > 0 && (
168
+ <>
169
+ <div className="remote-section-label">~/.ssh/config</div>
170
+ <div className="agent-picker-list remote-host-scroll">
171
+ {configHosts.map((h) => (
172
+ <button key={h.alias} className="agent-picker-item" onClick={() => startProbe(h.alias)}>
173
+ <span className="agent-picker-name">{h.alias}</span>
174
+ <span className="agent-picker-desc">
175
+ {h.user ? `${h.user}@` : ''}{h.hostName || ''}
176
+ </span>
177
+ </button>
178
+ ))}
179
+ </div>
180
+ </>
181
+ )}
182
+
183
+ <div className="remote-section-label">OR TYPE A HOST</div>
184
+ <form
185
+ className="remote-manual"
186
+ onSubmit={(e) => { e.preventDefault(); startProbe(manual) }}
187
+ >
188
+ <input
189
+ className="remote-input"
190
+ type="text"
191
+ placeholder="user@host"
192
+ value={manual}
193
+ onChange={(e) => setManual(e.target.value)}
194
+ autoCapitalize="off"
195
+ autoCorrect="off"
196
+ spellCheck={false}
197
+ />
198
+ <button className="remote-btn" type="submit" disabled={!manual.trim()}>Connect</button>
199
+ </form>
200
+
201
+ <button className="remote-link" onClick={onBack}>← back to agents</button>
202
+ </>
203
+ )}
204
+
205
+ {step === 'probing' && (
206
+ <div className="remote-status">
207
+ <div className="remote-spinner" />
208
+ <div>Connecting to <b>{target}</b>…</div>
209
+ <div className="remote-status-sub">Checking for tmux sessions</div>
210
+ </div>
211
+ )}
212
+
213
+ {step === 'action' && probe && (
214
+ <>
215
+ <div className="remote-target-line">
216
+ <b>{target}</b> · run {agentLabel}
217
+ </div>
218
+
219
+ {probe.authRequired && (
220
+ <>
221
+ <div className="remote-note">
222
+ This host needs a password. Enter it below, or leave blank to
223
+ type it at ssh's prompt in the terminal. Either way it is never stored.
224
+ </div>
225
+ {passwordField}
226
+ </>
227
+ )}
228
+
229
+ {/* Reattach existing sessions (only when we could enumerate them). */}
230
+ {probe.reachable && probe.sessions.length > 0 && (
231
+ <>
232
+ <div className="remote-section-label">REATTACH</div>
233
+ <div className="agent-picker-list">
234
+ {[...probe.sessions]
235
+ .sort((a, b) => (a.startsWith(TMUX_PREFIX) === b.startsWith(TMUX_PREFIX) ? 0 : a.startsWith(TMUX_PREFIX) ? -1 : 1))
236
+ .map((s) => (
237
+ <button
238
+ key={s}
239
+ className="agent-picker-item"
240
+ onClick={() => connect({ kind: 'attach', session: s })}
241
+ >
242
+ <span className="agent-picker-name">{s}</span>
243
+ <span className="agent-picker-desc">attach running tmux session</span>
244
+ </button>
245
+ ))}
246
+ </div>
247
+ </>
248
+ )}
249
+
250
+ <div className="remote-section-label">START</div>
251
+ <div className="agent-picker-list">
252
+ {/* New tmux session — when reachable+tmux, or password host (auto). */}
253
+ {((probe.reachable && probe.tmuxInstalled) || probe.authRequired) && (
254
+ <button
255
+ className="agent-picker-item"
256
+ onClick={() => {
257
+ const session = probe.reachable ? nextSessionName(probe.sessions) : `${TMUX_PREFIX}1`
258
+ connect(probe.reachable
259
+ ? { kind: 'new-tmux', session }
260
+ : { kind: 'auto', session })
261
+ }}
262
+ >
263
+ <span className="agent-picker-name">
264
+ New tmux session ({probe.reachable ? nextSessionName(probe.sessions) : `${TMUX_PREFIX}1`})
265
+ </span>
266
+ <span className="agent-picker-desc">persists across disconnects (recommended)</span>
267
+ </button>
268
+ )}
269
+
270
+ {probe.reachable && !probe.tmuxInstalled && (
271
+ <div className="remote-note">tmux isn't installed on this host — running without it.</div>
272
+ )}
273
+
274
+ <button
275
+ className="agent-picker-item"
276
+ onClick={() => connect({ kind: 'no-tmux' })}
277
+ >
278
+ <span className="agent-picker-name">Run without tmux</span>
279
+ <span className="agent-picker-desc">{agentLabel} directly — won't survive a dropped connection</span>
280
+ </button>
281
+ </div>
282
+
283
+ <button className="remote-link" onClick={() => setStep('hosts')}>← choose another host</button>
284
+ </>
285
+ )}
286
+
287
+ {step === 'error' && (
288
+ <>
289
+ <div className="remote-error">Couldn't connect to <b>{target}</b></div>
290
+ <div className="remote-status-sub">{errorMsg}</div>
291
+ {passwordField}
292
+ <div className="remote-section-label">TRY ANYWAY (INTERACTIVE)</div>
293
+ <div className="agent-picker-list">
294
+ <button
295
+ className="agent-picker-item"
296
+ onClick={() => connect({ kind: 'auto', session: `${TMUX_PREFIX}1` })}
297
+ >
298
+ <span className="agent-picker-name">Connect with tmux</span>
299
+ <span className="agent-picker-desc">ssh will prompt if a password / key passphrase is needed</span>
300
+ </button>
301
+ <button
302
+ className="agent-picker-item"
303
+ onClick={() => connect({ kind: 'no-tmux' })}
304
+ >
305
+ <span className="agent-picker-name">Connect without tmux</span>
306
+ <span className="agent-picker-desc">run {agentLabel} directly</span>
307
+ </button>
308
+ </div>
309
+ <button className="remote-link" onClick={() => setStep('hosts')}>← choose another host</button>
310
+ </>
311
+ )}
312
+ </div>
313
+ </div>
314
+ )
315
+ }
@@ -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
  }