@phenx-inc/ctlsurf 0.11.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 (35) 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 +311 -7
  4. package/out/preload/index.js +9 -0
  5. package/out/renderer/assets/{cssMode-Bmzy9hKX.js → cssMode-R8fTFLae.js} +3 -3
  6. package/out/renderer/assets/{freemarker2-CDAsExTa.js → freemarker2-CyjWzWoP.js} +1 -1
  7. package/out/renderer/assets/{handlebars-CvpBYG1L.js → handlebars-CrhCGIRM.js} +1 -1
  8. package/out/renderer/assets/{html-CNhEQqr8.js → html-Cc3dPNZr.js} +1 -1
  9. package/out/renderer/assets/{htmlMode-DD7YPgga.js → htmlMode-BWtG6ywk.js} +3 -3
  10. package/out/renderer/assets/{index-CGHeDwZa.css → index-BL9njXIL.css} +201 -0
  11. package/out/renderer/assets/{index-BDlCPzCG.js → index-kkG56aKv.js} +407 -34
  12. package/out/renderer/assets/{javascript-CsGLsswK.js → javascript-C8T_SdLC.js} +2 -2
  13. package/out/renderer/assets/{jsonMode-CTuGgRvC.js → jsonMode-D1zR4VVU.js} +3 -3
  14. package/out/renderer/assets/{liquid-BrrN4uul.js → liquid-COSMe9Uf.js} +1 -1
  15. package/out/renderer/assets/{lspLanguageFeatures-Bhdn2Eqy.js → lspLanguageFeatures-CXGfrtzl.js} +1 -1
  16. package/out/renderer/assets/{mdx-DJo-gax4.js → mdx-RfS44T-L.js} +1 -1
  17. package/out/renderer/assets/{python-DYHeg5b2.js → python-DFSWKHUu.js} +1 -1
  18. package/out/renderer/assets/{razor-Cqcks14K.js → razor-DtH2_uvo.js} +1 -1
  19. package/out/renderer/assets/{tsMode-YTLgCayh.js → tsMode-BYIQ-S9Z.js} +1 -1
  20. package/out/renderer/assets/{typescript-oxBn_Vcw.js → typescript-HhQUr1Rb.js} +1 -1
  21. package/out/renderer/assets/{xml-BEqGMCTc.js → xml-KuhgdKfD.js} +1 -1
  22. package/out/renderer/assets/{yaml-BGSrk5HC.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 +58 -3
  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 +20 -0
  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/styles.css +201 -0
@@ -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
+ }
@@ -1150,6 +1150,9 @@ html, body, #root {
1150
1150
  border-radius: 12px;
1151
1151
  padding: 32px 40px;
1152
1152
  min-width: 360px;
1153
+ max-width: 460px;
1154
+ max-height: 88vh;
1155
+ overflow-y: auto;
1153
1156
  text-align: center;
1154
1157
  }
1155
1158
 
@@ -1218,6 +1221,7 @@ html, body, #root {
1218
1221
  display: flex;
1219
1222
  align-items: center;
1220
1223
  justify-content: space-between;
1224
+ gap: 16px;
1221
1225
  padding: 14px 18px;
1222
1226
  background: #16161e;
1223
1227
  border: 1px solid #2a2b3d;
@@ -1236,11 +1240,13 @@ html, body, #root {
1236
1240
  font-size: 14px;
1237
1241
  font-weight: 600;
1238
1242
  color: #c0caf5;
1243
+ flex-shrink: 0;
1239
1244
  }
1240
1245
 
1241
1246
  .agent-picker-desc {
1242
1247
  font-size: 11px;
1243
1248
  color: #565f89;
1249
+ text-align: right;
1244
1250
  }
1245
1251
 
1246
1252
  .agent-picker-hint {
@@ -1265,6 +1271,201 @@ html, body, #root {
1265
1271
  accent-color: #7aa2f7;
1266
1272
  }
1267
1273
 
1274
+ /* ─── Remote (SSH) connect ───────────────────────── */
1275
+
1276
+ .remote-section-label {
1277
+ font-size: 10px;
1278
+ color: #565f89;
1279
+ text-transform: uppercase;
1280
+ letter-spacing: 1px;
1281
+ text-align: left;
1282
+ margin: 16px 0 8px;
1283
+ }
1284
+
1285
+ /* Scroll the host list rather than letting it grow the whole modal. */
1286
+ .remote-host-scroll {
1287
+ max-height: 40vh;
1288
+ overflow-y: auto;
1289
+ padding-right: 4px;
1290
+ }
1291
+
1292
+ /* Project rows: title on top, path muted + truncated below (paths are long). */
1293
+ .project-item {
1294
+ flex-direction: column;
1295
+ align-items: flex-start;
1296
+ gap: 4px;
1297
+ }
1298
+
1299
+ .project-item .agent-picker-name {
1300
+ max-width: 100%;
1301
+ overflow: hidden;
1302
+ text-overflow: ellipsis;
1303
+ white-space: nowrap;
1304
+ }
1305
+
1306
+ .project-item .agent-picker-desc {
1307
+ text-align: left;
1308
+ font-family: 'JetBrains Mono', 'Fira Code', monospace;
1309
+ font-size: 10px;
1310
+ max-width: 100%;
1311
+ overflow: hidden;
1312
+ text-overflow: ellipsis;
1313
+ white-space: nowrap;
1314
+ }
1315
+
1316
+ /* Compact host rows so more fit before scrolling. */
1317
+ .remote-host-scroll .agent-picker-item {
1318
+ padding: 10px 14px;
1319
+ }
1320
+
1321
+ .remote-toggle {
1322
+ display: flex;
1323
+ align-items: center;
1324
+ gap: 8px;
1325
+ margin-bottom: 4px;
1326
+ }
1327
+
1328
+ .remote-toggle-label {
1329
+ font-size: 12px;
1330
+ color: #565f89;
1331
+ }
1332
+
1333
+ .remote-toggle-btn {
1334
+ flex: 1;
1335
+ padding: 8px 10px;
1336
+ background: #16161e;
1337
+ border: 1px solid #2a2b3d;
1338
+ border-radius: 8px;
1339
+ color: #a9b1d6;
1340
+ font-size: 12px;
1341
+ cursor: pointer;
1342
+ transition: all 0.15s;
1343
+ }
1344
+
1345
+ .remote-toggle-btn:hover {
1346
+ border-color: #7aa2f7;
1347
+ }
1348
+
1349
+ .remote-toggle-btn.is-active {
1350
+ border-color: #7aa2f7;
1351
+ background: #1a1b2e;
1352
+ color: #c0caf5;
1353
+ font-weight: 600;
1354
+ }
1355
+
1356
+ .remote-manual {
1357
+ display: flex;
1358
+ gap: 8px;
1359
+ }
1360
+
1361
+ .remote-input {
1362
+ flex: 1;
1363
+ padding: 10px 12px;
1364
+ background: #16161e;
1365
+ border: 1px solid #2a2b3d;
1366
+ border-radius: 8px;
1367
+ color: #c0caf5;
1368
+ font-family: 'JetBrains Mono', 'Fira Code', monospace;
1369
+ font-size: 12px;
1370
+ outline: none;
1371
+ }
1372
+
1373
+ .remote-input:focus {
1374
+ border-color: #7aa2f7;
1375
+ }
1376
+
1377
+ .remote-btn {
1378
+ padding: 10px 16px;
1379
+ background: #7aa2f7;
1380
+ border: none;
1381
+ border-radius: 8px;
1382
+ color: #1f2335;
1383
+ font-size: 12px;
1384
+ font-weight: 600;
1385
+ cursor: pointer;
1386
+ }
1387
+
1388
+ .remote-btn:disabled {
1389
+ opacity: 0.4;
1390
+ cursor: default;
1391
+ }
1392
+
1393
+ .remote-link {
1394
+ margin-top: 18px;
1395
+ background: none;
1396
+ border: none;
1397
+ color: #7aa2f7;
1398
+ font-size: 12px;
1399
+ cursor: pointer;
1400
+ }
1401
+
1402
+ .remote-status {
1403
+ padding: 24px 8px;
1404
+ color: #a9b1d6;
1405
+ font-size: 13px;
1406
+ }
1407
+
1408
+ .remote-status-sub {
1409
+ margin-top: 6px;
1410
+ font-size: 11px;
1411
+ color: #565f89;
1412
+ word-break: break-word;
1413
+ }
1414
+
1415
+ .remote-spinner {
1416
+ width: 22px;
1417
+ height: 22px;
1418
+ margin: 0 auto 14px;
1419
+ border: 2px solid #2a2b3d;
1420
+ border-top-color: #7aa2f7;
1421
+ border-radius: 50%;
1422
+ animation: remote-spin 0.8s linear infinite;
1423
+ }
1424
+
1425
+ @keyframes remote-spin {
1426
+ to { transform: rotate(360deg); }
1427
+ }
1428
+
1429
+ .remote-target-line {
1430
+ font-size: 13px;
1431
+ color: #a9b1d6;
1432
+ margin-bottom: 4px;
1433
+ }
1434
+
1435
+ .remote-note {
1436
+ font-size: 11px;
1437
+ color: #e0af68;
1438
+ background: rgba(224, 175, 104, 0.08);
1439
+ border: 1px solid rgba(224, 175, 104, 0.25);
1440
+ border-radius: 8px;
1441
+ padding: 8px 12px;
1442
+ margin: 8px 0;
1443
+ text-align: left;
1444
+ line-height: 1.4;
1445
+ }
1446
+
1447
+ .remote-error {
1448
+ font-size: 13px;
1449
+ color: #f7768e;
1450
+ margin-bottom: 4px;
1451
+ }
1452
+
1453
+ .remote-pw {
1454
+ margin: 8px 0 4px;
1455
+ }
1456
+
1457
+ .remote-pw .remote-input {
1458
+ width: 100%;
1459
+ box-sizing: border-box;
1460
+ }
1461
+
1462
+ .remote-pw-hint {
1463
+ margin-top: 6px;
1464
+ font-size: 10px;
1465
+ color: #565f89;
1466
+ text-align: left;
1467
+ }
1468
+
1268
1469
  /* Settings dialog */
1269
1470
  .settings-overlay {
1270
1471
  position: fixed;