@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.
- package/out/headless/index.mjs +111 -5
- package/out/headless/index.mjs.map +3 -3
- package/out/main/index.js +357 -28
- package/out/preload/index.js +9 -0
- package/out/renderer/assets/{cssMode-BbCVOTaJ.js → cssMode-R8fTFLae.js} +3 -3
- package/out/renderer/assets/{freemarker2-DryLywQv.js → freemarker2-CyjWzWoP.js} +1 -1
- package/out/renderer/assets/{handlebars-CdUMtSdr.js → handlebars-CrhCGIRM.js} +1 -1
- package/out/renderer/assets/{html-Cwkf2GMH.js → html-Cc3dPNZr.js} +1 -1
- package/out/renderer/assets/{htmlMode-C7PzhXxH.js → htmlMode-BWtG6ywk.js} +3 -3
- package/out/renderer/assets/{index-BIceGcz3.css → index-BL9njXIL.css} +206 -46
- package/out/renderer/assets/{index-Dg1vMspf.js → index-kkG56aKv.js} +529 -156
- package/out/renderer/assets/{javascript-Cy7ROXJl.js → javascript-C8T_SdLC.js} +2 -2
- package/out/renderer/assets/{jsonMode-BtZtt3QW.js → jsonMode-D1zR4VVU.js} +3 -3
- package/out/renderer/assets/{liquid-xReAQeYs.js → liquid-COSMe9Uf.js} +1 -1
- package/out/renderer/assets/{lspLanguageFeatures-Dmx0L0Bw.js → lspLanguageFeatures-CXGfrtzl.js} +1 -1
- package/out/renderer/assets/{mdx-YMNFO1Zz.js → mdx-RfS44T-L.js} +1 -1
- package/out/renderer/assets/{python-BExaDVxv.js → python-DFSWKHUu.js} +1 -1
- package/out/renderer/assets/{razor-HL8ANmQ4.js → razor-DtH2_uvo.js} +1 -1
- package/out/renderer/assets/{tsMode-Dqd8vdLE.js → tsMode-BYIQ-S9Z.js} +1 -1
- package/out/renderer/assets/{typescript-Dysz1PTX.js → typescript-HhQUr1Rb.js} +1 -1
- package/out/renderer/assets/{xml-BHp5Z9nc.js → xml-KuhgdKfD.js} +1 -1
- package/out/renderer/assets/{yaml-CPDHxVup.js → yaml-W_kzCFH7.js} +1 -1
- package/out/renderer/index.html +2 -2
- package/package.json +1 -1
- package/src/main/agents.ts +9 -1
- package/src/main/ctlsurfApi.ts +7 -0
- package/src/main/index.ts +118 -34
- package/src/main/orchestrator.ts +105 -4
- package/src/main/ssh.ts +348 -0
- package/src/preload/index.ts +26 -0
- package/src/renderer/App.tsx +29 -7
- package/src/renderer/components/AgentPicker.tsx +18 -0
- package/src/renderer/components/ProjectPicker.tsx +121 -0
- package/src/renderer/components/RemoteConnect.tsx +315 -0
- package/src/renderer/components/TicketPanel.tsx +20 -25
- package/src/renderer/styles.css +206 -46
package/src/main/ssh.ts
ADDED
|
@@ -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
|
+
}
|
package/src/preload/index.ts
CHANGED
|
@@ -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'),
|
package/src/renderer/App.tsx
CHANGED
|
@@ -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 {
|
|
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: '
|
|
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 ${
|
|
520
|
-
onClick={() =>
|
|
521
|
-
title="
|
|
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
|
+
}
|