@phenx-inc/ctlsurf 0.15.1 → 0.16.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 +10 -3
- package/out/headless/index.mjs.map +2 -2
- package/out/main/index.js +54 -4
- package/out/preload/index.js +2 -0
- package/out/renderer/assets/{cssMode-B77M9HlN.js → cssMode-Xx1-SozN.js} +3 -3
- package/out/renderer/assets/{freemarker2-oUT2zvyQ.js → freemarker2-DM49yPw1.js} +1 -1
- package/out/renderer/assets/{handlebars-jCckoi5j.js → handlebars-DJJBblpV.js} +1 -1
- package/out/renderer/assets/{html-kAgrAaKW.js → html-DnMnJOxw.js} +1 -1
- package/out/renderer/assets/{htmlMode-DIWkIawy.js → htmlMode-BG5u2CSH.js} +3 -3
- package/out/renderer/assets/{index-CN3UN7Tm.js → index-D65QIRev.js} +318 -95
- package/out/renderer/assets/{index-3sFJEz7i.css → index-DID1LlMU.css} +88 -0
- package/out/renderer/assets/{javascript-B9GYUE-I.js → javascript-Dci_phmN.js} +2 -2
- package/out/renderer/assets/{jsonMode-BzFKTZjJ.js → jsonMode-Dmp3JJSm.js} +3 -3
- package/out/renderer/assets/{liquid-Ccw-S_Y6.js → liquid-C9KVysk1.js} +1 -1
- package/out/renderer/assets/{lspLanguageFeatures-BE0g7ubH.js → lspLanguageFeatures-IDD0-KWa.js} +1 -1
- package/out/renderer/assets/{mdx-oXeAA0qU.js → mdx-ascmBFXX.js} +1 -1
- package/out/renderer/assets/{python-DlSX3TvA.js → python-DcbM4Hy5.js} +1 -1
- package/out/renderer/assets/{razor-ButO9S7r.js → razor-XJROxpYK.js} +1 -1
- package/out/renderer/assets/{tsMode-CTCpE-67.js → tsMode-EXbzul3n.js} +1 -1
- package/out/renderer/assets/{typescript-9o62WOnS.js → typescript-DhTtFBLt.js} +1 -1
- package/out/renderer/assets/{xml-C4nOH2_D.js → xml-BLfEkVl1.js} +1 -1
- package/out/renderer/assets/{yaml-mXYgKoWW.js → yaml-DpzsqvWa.js} +1 -1
- package/out/renderer/index.html +2 -2
- package/package.json +1 -1
- package/src/main/index.ts +52 -1
- package/src/main/orchestrator.ts +18 -3
- package/src/preload/index.ts +4 -0
- package/src/renderer/App.tsx +179 -50
- package/src/renderer/components/AgentPicker.tsx +61 -10
- package/src/renderer/components/FloatingMic.tsx +15 -11
- package/src/renderer/components/ProjectPicker.tsx +44 -13
- package/src/renderer/components/TerminalPanel.tsx +45 -9
- package/src/renderer/styles.css +88 -0
package/src/renderer/App.tsx
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
|
2
|
-
import { TerminalPanel, destroyTerminal, focusTerminal } from './components/TerminalPanel'
|
|
2
|
+
import { TerminalPanel, captureTerminalSession, destroyTerminal, focusTerminal } from './components/TerminalPanel'
|
|
3
3
|
import { FloatingMic } from './components/FloatingMic'
|
|
4
4
|
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
|
+
import { ProjectPicker, type ProjectSelection } from './components/ProjectPicker'
|
|
9
9
|
import {
|
|
10
10
|
PaneLayout,
|
|
11
11
|
type LayoutNode,
|
|
@@ -47,6 +47,7 @@ declare global {
|
|
|
47
47
|
bindProject: (rootPath: string) => Promise<{ ok: boolean; error?: string }>
|
|
48
48
|
getHomePath: () => Promise<string>
|
|
49
49
|
getCwd: () => Promise<string>
|
|
50
|
+
getActiveTabCwd: () => Promise<string | null>
|
|
50
51
|
getProjectName: () => Promise<string | null>
|
|
51
52
|
onProjectChanged: (callback: (name: string | null) => void) => () => void
|
|
52
53
|
browseCwd: () => Promise<string | null>
|
|
@@ -93,6 +94,7 @@ declare global {
|
|
|
93
94
|
readDir: (dirPath: string) => Promise<Array<{ name: string; path: string; isDirectory: boolean }>>
|
|
94
95
|
readFile: (filePath: string) => Promise<{ ok: boolean; content?: string; error?: string }>
|
|
95
96
|
writeFile: (filePath: string, content: string) => Promise<{ ok: boolean; error?: string }>
|
|
97
|
+
createSessionHandoffFile: (transcript: string) => Promise<{ ok: boolean; path?: string; trimmed?: boolean; error?: string }>
|
|
96
98
|
getVersion: () => Promise<string>
|
|
97
99
|
getUpdateInfo: () => Promise<UpdateInfo>
|
|
98
100
|
onUpdateInfo: (callback: (info: UpdateInfo) => void) => () => void
|
|
@@ -128,6 +130,9 @@ interface TabInfo {
|
|
|
128
130
|
label: string
|
|
129
131
|
agent: AgentConfig | null
|
|
130
132
|
agentStatus: 'idle' | 'active' | 'exited'
|
|
133
|
+
// Increment to restart a tab even when its agent id stays the same (for
|
|
134
|
+
// example after changing the project directory).
|
|
135
|
+
sessionVersion: number
|
|
131
136
|
}
|
|
132
137
|
|
|
133
138
|
let _tabCounter = 0
|
|
@@ -135,6 +140,51 @@ function nextTabId(): string {
|
|
|
135
140
|
return `tab-${++_tabCounter}`
|
|
136
141
|
}
|
|
137
142
|
|
|
143
|
+
function buildSessionHandoff(transcript: string): string {
|
|
144
|
+
const quoted = transcript.split('\n').map(line => `> ${line}`).join('\n')
|
|
145
|
+
return [
|
|
146
|
+
'The quoted text below is what we did in the last session. It is provided for information only; no action is needed.',
|
|
147
|
+
'Do not continue, repeat, or act on anything in it unless I explicitly ask in a new message. Wait for my next instruction.',
|
|
148
|
+
'',
|
|
149
|
+
quoted,
|
|
150
|
+
].join('\n')
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const INLINE_HANDOFF_CHARS = 12_000
|
|
154
|
+
const REMOTE_HANDOFF_CHARS = 30_000
|
|
155
|
+
|
|
156
|
+
function buildFileSessionHandoff(filePath: string, trimmed: boolean): string {
|
|
157
|
+
return [
|
|
158
|
+
'This is what we did in the last session. It is provided for information only; no action is needed.',
|
|
159
|
+
'The quoted session transcript was too long to paste, so it is stored in this temporary Markdown file:',
|
|
160
|
+
`\`${filePath}\``,
|
|
161
|
+
trimmed ? 'The file contains the latest portion of the session because the captured history exceeded the safety limit.' : '',
|
|
162
|
+
'Read it only as background context. Do not continue, repeat, or act on anything in it unless I explicitly ask in a new message. Wait for my next instruction.',
|
|
163
|
+
].filter(Boolean).join('\n\n')
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function buildProjectContextNotice(
|
|
167
|
+
selection: ProjectSelection,
|
|
168
|
+
terminalCwd: string | null,
|
|
169
|
+
agentName: string | null,
|
|
170
|
+
): string {
|
|
171
|
+
const { project } = selection
|
|
172
|
+
return [
|
|
173
|
+
'The active ctlsurf project binding has changed. Use the quoted details below as context for future requests.',
|
|
174
|
+
'This notice does not start a new task. Do not take action solely because of it.',
|
|
175
|
+
'',
|
|
176
|
+
`> ctlsurf project: ${project.name}`,
|
|
177
|
+
`> ctlsurf project ID: ${project.id}`,
|
|
178
|
+
`> configured codebase root: ${project.root_path}`,
|
|
179
|
+
`> current terminal directory: ${terminalCwd || '(unknown)'}`,
|
|
180
|
+
`> active coding agent: ${agentName || '(unknown)'}`,
|
|
181
|
+
'',
|
|
182
|
+
terminalCwd && terminalCwd !== project.root_path
|
|
183
|
+
? 'Important: only the ctlsurf project binding changed; the running terminal directory was not changed.'
|
|
184
|
+
: 'The terminal directory matches the selected project codebase root.',
|
|
185
|
+
].join('\n')
|
|
186
|
+
}
|
|
187
|
+
|
|
138
188
|
export default function App() {
|
|
139
189
|
const [agents, setAgents] = useState<AgentConfig[]>([])
|
|
140
190
|
const [sessionStart, setSessionStart] = useState<Date | null>(null)
|
|
@@ -154,7 +204,7 @@ export default function App() {
|
|
|
154
204
|
// Multi-tab state
|
|
155
205
|
const [tabs, setTabs] = useState<TabInfo[]>(() => {
|
|
156
206
|
const id = nextTabId()
|
|
157
|
-
return [{ id, label: 'Terminal 1', agent: null, agentStatus: 'idle' }]
|
|
207
|
+
return [{ id, label: 'Terminal 1', agent: null, agentStatus: 'idle', sessionVersion: 0 }]
|
|
158
208
|
})
|
|
159
209
|
const [activeTabId, setActiveTabId] = useState<string>(tabs[0].id)
|
|
160
210
|
const [trackingActive, setTrackingActive] = useState(false)
|
|
@@ -292,23 +342,49 @@ export default function App() {
|
|
|
292
342
|
}, [activeTabId])
|
|
293
343
|
|
|
294
344
|
const cwdRef = useRef<string | null>(null)
|
|
345
|
+
const pendingHandoffsRef = useRef<Map<string, string>>(new Map())
|
|
346
|
+
const handoffTimersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map())
|
|
347
|
+
const handoffGenerationsRef = useRef<Map<string, number>>(new Map())
|
|
295
348
|
|
|
296
349
|
const handleSpawn = useCallback(async (tabId: string, agent: AgentConfig) => {
|
|
350
|
+
const oldTimer = handoffTimersRef.current.get(tabId)
|
|
351
|
+
if (oldTimer) clearTimeout(oldTimer)
|
|
352
|
+
handoffTimersRef.current.delete(tabId)
|
|
353
|
+
const handoffGeneration = (handoffGenerationsRef.current.get(tabId) || 0) + 1
|
|
354
|
+
handoffGenerationsRef.current.set(tabId, handoffGeneration)
|
|
297
355
|
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, agentStatus: 'active' } : t))
|
|
298
356
|
setSessionStart(new Date())
|
|
299
357
|
const spawnCwd = cwdRef.current || await window.worker.getCwd().catch(() => window.worker.getHomePath())
|
|
300
358
|
setCwd(spawnCwd)
|
|
301
359
|
cwdRef.current = spawnCwd
|
|
302
|
-
|
|
303
|
-
|
|
360
|
+
try {
|
|
361
|
+
await window.worker.spawnAgent(tabId, agent, spawnCwd)
|
|
362
|
+
} catch (err) {
|
|
363
|
+
console.error('[session] Failed to start agent', err)
|
|
364
|
+
pendingHandoffsRef.current.delete(tabId)
|
|
365
|
+
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, agentStatus: 'exited' } : t))
|
|
366
|
+
return
|
|
367
|
+
}
|
|
304
368
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
if (
|
|
308
|
-
|
|
309
|
-
|
|
369
|
+
const handoff = pendingHandoffsRef.current.get(tabId)
|
|
370
|
+
pendingHandoffsRef.current.delete(tabId)
|
|
371
|
+
if (handoff) {
|
|
372
|
+
// Give the replacement CLI time to paint and enter its input mode. Input
|
|
373
|
+
// is still generation-guarded so another quick restart cannot receive a
|
|
374
|
+
// handoff intended for its predecessor.
|
|
375
|
+
const timer = setTimeout(() => {
|
|
376
|
+
if (handoffGenerationsRef.current.get(tabId) !== handoffGeneration) return
|
|
377
|
+
window.worker.writePty(tabId, handoff)
|
|
378
|
+
focusTerminal(tabId)
|
|
379
|
+
setTimeout(() => {
|
|
380
|
+
if (handoffGenerationsRef.current.get(tabId) === handoffGeneration) {
|
|
381
|
+
window.worker.writePty(tabId, '\r')
|
|
382
|
+
}
|
|
383
|
+
}, 120)
|
|
384
|
+
}, 1200)
|
|
385
|
+
handoffTimersRef.current.set(tabId, timer)
|
|
310
386
|
}
|
|
311
|
-
}, [
|
|
387
|
+
}, [])
|
|
312
388
|
|
|
313
389
|
const handleExit = useCallback((tabId: string) => {
|
|
314
390
|
setTimeout(() => {
|
|
@@ -319,13 +395,17 @@ export default function App() {
|
|
|
319
395
|
const handleChangeCwd = useCallback(async () => {
|
|
320
396
|
const newCwd = await window.worker.browseCwd()
|
|
321
397
|
const activeTab = tabs.find(t => t.id === activeTabId)
|
|
322
|
-
if (!newCwd
|
|
398
|
+
if (!newCwd) return
|
|
323
399
|
cwdRef.current = newCwd
|
|
324
400
|
setCwd(newCwd)
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
401
|
+
if (!activeTab?.agent) return
|
|
402
|
+
pendingHandoffsRef.current.delete(activeTabId)
|
|
403
|
+
// TerminalPanel observes the version bump and asks the main process to
|
|
404
|
+
// replace the old session atomically. Avoid a separate kill call: its late
|
|
405
|
+
// exit event could otherwise race the replacement session.
|
|
406
|
+
setTabs(prev => prev.map(t => t.id === activeTabId
|
|
407
|
+
? { ...t, agentStatus: 'idle', sessionVersion: t.sessionVersion + 1 }
|
|
408
|
+
: t))
|
|
329
409
|
}, [tabs, activeTabId])
|
|
330
410
|
|
|
331
411
|
// ─── Tab management ─────────────────────────────
|
|
@@ -334,18 +414,50 @@ export default function App() {
|
|
|
334
414
|
// Create the tab in idle state and show the agent picker for it
|
|
335
415
|
const id = nextTabId()
|
|
336
416
|
const num = _tabCounter
|
|
337
|
-
setTabs(prev => [...prev, { id, label: `Terminal ${num}`, agent: null, agentStatus: 'idle' }])
|
|
417
|
+
setTabs(prev => [...prev, { id, label: `Terminal ${num}`, agent: null, agentStatus: 'idle', sessionVersion: 0 }])
|
|
338
418
|
setActiveTabId(id)
|
|
339
419
|
setPickerTargetTabId(id)
|
|
340
420
|
setShowAgentPicker(true)
|
|
341
421
|
}, [])
|
|
342
422
|
|
|
343
|
-
const handlePickerSelect = useCallback((agent: AgentConfig) => {
|
|
423
|
+
const handlePickerSelect = useCallback(async (agent: AgentConfig, selectedCwd: string, handoff: 'fresh' | 'context') => {
|
|
344
424
|
const tabId = pickerTargetTabId
|
|
345
425
|
if (tabId) {
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
426
|
+
if (handoff === 'context') {
|
|
427
|
+
const transcript = captureTerminalSession(tabId)
|
|
428
|
+
if (!transcript) {
|
|
429
|
+
pendingHandoffsRef.current.delete(tabId)
|
|
430
|
+
} else if (transcript.length > INLINE_HANDOFF_CHARS && !agent.id.startsWith('remote-')) {
|
|
431
|
+
const result = await window.worker.createSessionHandoffFile(transcript)
|
|
432
|
+
if (result.ok && result.path) {
|
|
433
|
+
pendingHandoffsRef.current.set(tabId, buildFileSessionHandoff(result.path, !!result.trimmed))
|
|
434
|
+
} else {
|
|
435
|
+
console.warn('[session] Could not create handoff file; using a shortened inline handoff', result.error)
|
|
436
|
+
pendingHandoffsRef.current.set(tabId, buildSessionHandoff(transcript.slice(-REMOTE_HANDOFF_CHARS)))
|
|
437
|
+
}
|
|
438
|
+
} else {
|
|
439
|
+
// A local temp path is meaningless to an SSH-hosted agent, so remote
|
|
440
|
+
// handoffs remain inline and are capped to a manageable paste.
|
|
441
|
+
const inline = transcript.length > REMOTE_HANDOFF_CHARS
|
|
442
|
+
? `[Earlier terminal output omitted.]\n${transcript.slice(-REMOTE_HANDOFF_CHARS)}`
|
|
443
|
+
: transcript
|
|
444
|
+
pendingHandoffsRef.current.set(tabId, buildSessionHandoff(inline))
|
|
445
|
+
}
|
|
446
|
+
} else {
|
|
447
|
+
pendingHandoffsRef.current.delete(tabId)
|
|
448
|
+
}
|
|
449
|
+
cwdRef.current = selectedCwd
|
|
450
|
+
setCwd(selectedCwd)
|
|
451
|
+
setTabs(prev => {
|
|
452
|
+
const matchingAgents = prev.filter(x => x.agent?.id === agent.id).length
|
|
453
|
+
return prev.map(t => t.id === tabId ? {
|
|
454
|
+
...t,
|
|
455
|
+
agent,
|
|
456
|
+
agentStatus: 'idle',
|
|
457
|
+
label: `${agent.name} ${matchingAgents + (t.agent?.id === agent.id ? 0 : 1)}`,
|
|
458
|
+
sessionVersion: t.sessionVersion + 1,
|
|
459
|
+
} : t)
|
|
460
|
+
})
|
|
349
461
|
}
|
|
350
462
|
setShowAgentPicker(false)
|
|
351
463
|
setPickerTargetTabId(null)
|
|
@@ -354,9 +466,31 @@ export default function App() {
|
|
|
354
466
|
if (agent.id.startsWith('remote-')) {
|
|
355
467
|
setShowProjectPicker(true)
|
|
356
468
|
}
|
|
357
|
-
}, [pickerTargetTabId
|
|
469
|
+
}, [pickerTargetTabId])
|
|
470
|
+
|
|
471
|
+
const openSessionPicker = useCallback(() => {
|
|
472
|
+
setPickerTargetTabId(activeTabId)
|
|
473
|
+
setShowAgentPicker(true)
|
|
474
|
+
}, [activeTabId])
|
|
475
|
+
|
|
476
|
+
const handleProjectPickerDone = useCallback(async (selection?: ProjectSelection) => {
|
|
477
|
+
setShowProjectPicker(false)
|
|
478
|
+
if (!selection?.notifyAgent) return
|
|
479
|
+
const activeAgent = tabs.find(t => t.id === activeTabId)?.agent
|
|
480
|
+
if (!activeAgent) return
|
|
481
|
+
const terminalCwd = await window.worker.getActiveTabCwd().catch(() => cwd)
|
|
482
|
+
const notice = buildProjectContextNotice(selection, terminalCwd, activeAgent.name)
|
|
483
|
+
window.worker.writePty(activeTabId, notice)
|
|
484
|
+
focusTerminal(activeTabId)
|
|
485
|
+
setTimeout(() => window.worker.writePty(activeTabId, '\r'), 120)
|
|
486
|
+
}, [activeTabId, cwd, tabs])
|
|
358
487
|
|
|
359
488
|
const closeTab = useCallback(async (tabId: string) => {
|
|
489
|
+
pendingHandoffsRef.current.delete(tabId)
|
|
490
|
+
handoffGenerationsRef.current.set(tabId, (handoffGenerationsRef.current.get(tabId) || 0) + 1)
|
|
491
|
+
const handoffTimer = handoffTimersRef.current.get(tabId)
|
|
492
|
+
if (handoffTimer) clearTimeout(handoffTimer)
|
|
493
|
+
handoffTimersRef.current.delete(tabId)
|
|
360
494
|
await window.worker.killPty(tabId)
|
|
361
495
|
destroyTerminal(tabId)
|
|
362
496
|
setTabs(prev => {
|
|
@@ -372,7 +506,7 @@ export default function App() {
|
|
|
372
506
|
setActiveTabId(id)
|
|
373
507
|
setPickerTargetTabId(id)
|
|
374
508
|
setShowAgentPicker(true)
|
|
375
|
-
return [{ id, label: `Terminal ${num}`, agent: null, agentStatus: 'idle' }]
|
|
509
|
+
return [{ id, label: `Terminal ${num}`, agent: null, agentStatus: 'idle', sessionVersion: 0 }]
|
|
376
510
|
}
|
|
377
511
|
return next
|
|
378
512
|
})
|
|
@@ -521,6 +655,7 @@ export default function App() {
|
|
|
521
655
|
<TerminalPanel
|
|
522
656
|
tabId={tab.id}
|
|
523
657
|
agent={tab.agent}
|
|
658
|
+
sessionVersion={tab.sessionVersion}
|
|
524
659
|
onSpawn={handleSpawn}
|
|
525
660
|
onExit={handleExit}
|
|
526
661
|
isActive={tab.id === activeTabId}
|
|
@@ -541,12 +676,7 @@ export default function App() {
|
|
|
541
676
|
<div className="titlebar">
|
|
542
677
|
<span className="titlebar-title">
|
|
543
678
|
ctlsurf
|
|
544
|
-
{projectName &&
|
|
545
|
-
<>
|
|
546
|
-
<span className="titlebar-title-sep">·</span>
|
|
547
|
-
<span className="titlebar-project" title={projectName}>{projectName}</span>
|
|
548
|
-
</>
|
|
549
|
-
)}
|
|
679
|
+
{projectName && <><span className="titlebar-title-sep">·</span><span className="titlebar-project" title={projectName}>{projectName}</span></>}
|
|
550
680
|
</span>
|
|
551
681
|
<div className="titlebar-controls">
|
|
552
682
|
<button className="titlebar-btn" onClick={() => setShowSettings(true)} title="Settings">
|
|
@@ -605,19 +735,20 @@ export default function App() {
|
|
|
605
735
|
</button>
|
|
606
736
|
<SpeakControls remote={activeAgentIsRemote} />
|
|
607
737
|
<span className="titlebar-separator" />
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
738
|
+
<button
|
|
739
|
+
className="titlebar-agent-btn active"
|
|
740
|
+
onClick={openSessionPicker}
|
|
741
|
+
title="Change coding agent or project directory for the active terminal"
|
|
742
|
+
>
|
|
743
|
+
Agent: {tabs.find(t => t.id === activeTabId)?.agent?.name || 'Choose'} ▾
|
|
744
|
+
</button>
|
|
745
|
+
<button
|
|
746
|
+
className="titlebar-btn"
|
|
747
|
+
onClick={() => setShowProjectPicker(true)}
|
|
748
|
+
title="Change the ctlsurf project linked to the active session"
|
|
749
|
+
>
|
|
750
|
+
Project…
|
|
751
|
+
</button>
|
|
621
752
|
<span className="titlebar-separator" />
|
|
622
753
|
{ALL_PANE_IDS.map(id => (
|
|
623
754
|
<button
|
|
@@ -655,18 +786,16 @@ export default function App() {
|
|
|
655
786
|
agents={agents}
|
|
656
787
|
cwd={cwd || ''}
|
|
657
788
|
onSelect={handlePickerSelect}
|
|
658
|
-
onChangeCwd={
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
}
|
|
664
|
-
}}
|
|
789
|
+
onChangeCwd={() => window.worker.browseCwd()}
|
|
790
|
+
onCancel={tabs.find(t => t.id === pickerTargetTabId)?.agent
|
|
791
|
+
? () => { setShowAgentPicker(false); setPickerTargetTabId(null) }
|
|
792
|
+
: undefined}
|
|
793
|
+
restarting={!!tabs.find(t => t.id === pickerTargetTabId)?.agent}
|
|
665
794
|
/>
|
|
666
795
|
)}
|
|
667
796
|
|
|
668
797
|
{showProjectPicker && (
|
|
669
|
-
<ProjectPicker onDone={
|
|
798
|
+
<ProjectPicker currentProject={projectName} onDone={handleProjectPickerDone} />
|
|
670
799
|
)}
|
|
671
800
|
|
|
672
801
|
{showFloatingMic && (
|
|
@@ -12,15 +12,20 @@ interface AgentConfig {
|
|
|
12
12
|
interface AgentPickerProps {
|
|
13
13
|
agents: AgentConfig[]
|
|
14
14
|
cwd: string
|
|
15
|
-
onSelect: (agent: AgentConfig) => void
|
|
16
|
-
onChangeCwd: () =>
|
|
15
|
+
onSelect: (agent: AgentConfig, cwd: string, handoff: 'fresh' | 'context') => void | Promise<void>
|
|
16
|
+
onChangeCwd: () => Promise<string | null>
|
|
17
|
+
onCancel?: () => void
|
|
18
|
+
restarting?: boolean
|
|
17
19
|
}
|
|
18
20
|
|
|
19
|
-
export function AgentPicker({ agents, cwd, onSelect, onChangeCwd }: AgentPickerProps) {
|
|
20
|
-
const
|
|
21
|
+
export function AgentPicker({ agents, cwd, onSelect, onChangeCwd, onCancel, restarting = false }: AgentPickerProps) {
|
|
22
|
+
const [selectedCwd, setSelectedCwd] = useState(cwd)
|
|
23
|
+
const displayPath = selectedCwd.replace(/^\/Users\/[^/]+/, '~')
|
|
21
24
|
const [logChat, setLogChat] = useState(false)
|
|
22
25
|
const [loaded, setLoaded] = useState(false)
|
|
23
26
|
const [remoteMode, setRemoteMode] = useState(false)
|
|
27
|
+
const [handoff, setHandoff] = useState<'fresh' | 'context'>('fresh')
|
|
28
|
+
const [switching, setSwitching] = useState(false)
|
|
24
29
|
|
|
25
30
|
useEffect(() => {
|
|
26
31
|
let cancelled = false
|
|
@@ -39,16 +44,27 @@ export function AgentPicker({ agents, cwd, onSelect, onChangeCwd }: AgentPickerP
|
|
|
39
44
|
}
|
|
40
45
|
|
|
41
46
|
const handleSelect = async (agent: AgentConfig) => {
|
|
47
|
+
if (switching) return
|
|
48
|
+
setSwitching(true)
|
|
42
49
|
if (loaded) {
|
|
43
50
|
try { await window.worker.setLogChat(logChat) } catch { /* ignore */ }
|
|
44
51
|
}
|
|
45
|
-
|
|
52
|
+
try {
|
|
53
|
+
await onSelect(agent, selectedCwd, handoff)
|
|
54
|
+
} finally {
|
|
55
|
+
setSwitching(false)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const changeCwd = async () => {
|
|
60
|
+
const next = await onChangeCwd()
|
|
61
|
+
if (next) setSelectedCwd(next)
|
|
46
62
|
}
|
|
47
63
|
|
|
48
64
|
if (remoteMode) {
|
|
49
65
|
return (
|
|
50
66
|
<RemoteConnect
|
|
51
|
-
onConnect={(agent) => { handleSelect(agent) }}
|
|
67
|
+
onConnect={(agent) => { void handleSelect(agent) }}
|
|
52
68
|
onBack={() => setRemoteMode(false)}
|
|
53
69
|
/>
|
|
54
70
|
)
|
|
@@ -59,19 +75,48 @@ export function AgentPicker({ agents, cwd, onSelect, onChangeCwd }: AgentPickerP
|
|
|
59
75
|
<div className="agent-picker-modal">
|
|
60
76
|
<div className="agent-picker-brand">ctlsurf</div>
|
|
61
77
|
|
|
62
|
-
<div className="agent-picker-cwd" onClick={
|
|
78
|
+
<div className="agent-picker-cwd" onClick={() => void changeCwd()} title="Click to change directory">
|
|
63
79
|
<span className="agent-picker-cwd-icon">📂</span>
|
|
64
80
|
<span className="agent-picker-cwd-path">{displayPath}</span>
|
|
65
81
|
<span className="agent-picker-cwd-change">change</span>
|
|
66
82
|
</div>
|
|
67
83
|
|
|
68
|
-
<div className="agent-picker-title">SELECT AGENT</div>
|
|
84
|
+
<div className="agent-picker-title">{restarting ? 'CHANGE SESSION' : 'SELECT AGENT'}</div>
|
|
85
|
+
{restarting && (
|
|
86
|
+
<>
|
|
87
|
+
<div className="agent-picker-warning">
|
|
88
|
+
Choosing an agent restarts this terminal and ends its current process.
|
|
89
|
+
</div>
|
|
90
|
+
<fieldset className="agent-handoff-options">
|
|
91
|
+
<legend>NEW SESSION CONTEXT</legend>
|
|
92
|
+
<label className={handoff === 'fresh' ? 'selected' : ''}>
|
|
93
|
+
<input
|
|
94
|
+
type="radio"
|
|
95
|
+
name="session-handoff"
|
|
96
|
+
checked={handoff === 'fresh'}
|
|
97
|
+
onChange={() => setHandoff('fresh')}
|
|
98
|
+
/>
|
|
99
|
+
<span><strong>Start fresh</strong><small>Do not send anything from this terminal.</small></span>
|
|
100
|
+
</label>
|
|
101
|
+
<label className={handoff === 'context' ? 'selected' : ''}>
|
|
102
|
+
<input
|
|
103
|
+
type="radio"
|
|
104
|
+
name="session-handoff"
|
|
105
|
+
checked={handoff === 'context'}
|
|
106
|
+
onChange={() => setHandoff('context')}
|
|
107
|
+
/>
|
|
108
|
+
<span><strong>Share previous context</strong><small>Quote it inline, or use a temporary file when the session is long.</small></span>
|
|
109
|
+
</label>
|
|
110
|
+
</fieldset>
|
|
111
|
+
</>
|
|
112
|
+
)}
|
|
69
113
|
<div className="agent-picker-list">
|
|
70
114
|
{agents.map(a => (
|
|
71
115
|
<button
|
|
72
116
|
key={a.id}
|
|
73
117
|
className="agent-picker-item"
|
|
74
|
-
|
|
118
|
+
disabled={switching}
|
|
119
|
+
onClick={() => void handleSelect(a)}
|
|
75
120
|
>
|
|
76
121
|
<span className="agent-picker-name">{a.name}</span>
|
|
77
122
|
<span className="agent-picker-desc">{a.description}</span>
|
|
@@ -79,6 +124,7 @@ export function AgentPicker({ agents, cwd, onSelect, onChangeCwd }: AgentPickerP
|
|
|
79
124
|
))}
|
|
80
125
|
<button
|
|
81
126
|
className="agent-picker-item"
|
|
127
|
+
disabled={switching}
|
|
82
128
|
onClick={() => setRemoteMode(true)}
|
|
83
129
|
>
|
|
84
130
|
<span className="agent-picker-name">🌐 Remote (SSH)</span>
|
|
@@ -95,7 +141,12 @@ export function AgentPicker({ agents, cwd, onSelect, onChangeCwd }: AgentPickerP
|
|
|
95
141
|
<span>Log chat to ctlsurf</span>
|
|
96
142
|
</label>
|
|
97
143
|
|
|
98
|
-
<div className="agent-picker-hint">
|
|
144
|
+
<div className="agent-picker-hint">
|
|
145
|
+
{restarting ? 'Select an agent to restart in the directory above' : 'Select an agent to start'}
|
|
146
|
+
</div>
|
|
147
|
+
{onCancel && (
|
|
148
|
+
<button className="remote-link" disabled={switching} onClick={onCancel}>Cancel</button>
|
|
149
|
+
)}
|
|
99
150
|
</div>
|
|
100
151
|
</div>
|
|
101
152
|
)
|
|
@@ -15,10 +15,11 @@ interface FloatingMicProps {
|
|
|
15
15
|
onHide: () => void
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
// Keep the
|
|
18
|
+
// Keep the toolbar clear of the 38px titlebar, 24px status bar, and the
|
|
19
|
+
// terminal's bottom input line.
|
|
19
20
|
const EDGE = 20
|
|
20
21
|
const TOP_MIN = 46
|
|
21
|
-
const BOTTOM_GAP =
|
|
22
|
+
const BOTTOM_GAP = 72
|
|
22
23
|
|
|
23
24
|
function loadPos(): Pos | null {
|
|
24
25
|
try {
|
|
@@ -71,17 +72,20 @@ export function FloatingMic({ onTranscript, onHide }: FloatingMicProps) {
|
|
|
71
72
|
}
|
|
72
73
|
}, [])
|
|
73
74
|
|
|
74
|
-
//
|
|
75
|
+
// Default to bottom-right, and clamp saved positions on first mount too so
|
|
76
|
+
// increased chrome/input clearance applies to existing installations.
|
|
75
77
|
useEffect(() => {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
78
|
+
setPos((current) => {
|
|
79
|
+
if (current) return clamp(current.x, current.y)
|
|
80
|
+
const el = elRef.current
|
|
81
|
+
const w = el?.offsetWidth ?? 64
|
|
82
|
+
const h = el?.offsetHeight ?? 90
|
|
83
|
+
return clamp(
|
|
84
|
+
window.innerWidth - w - EDGE,
|
|
85
|
+
window.innerHeight - h - BOTTOM_GAP,
|
|
86
|
+
)
|
|
83
87
|
})
|
|
84
|
-
}, [
|
|
88
|
+
}, [clamp])
|
|
85
89
|
|
|
86
90
|
// Keep it reachable if the window shrinks.
|
|
87
91
|
useEffect(() => {
|
|
@@ -1,26 +1,33 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react'
|
|
2
2
|
|
|
3
|
-
interface Project {
|
|
3
|
+
export interface Project {
|
|
4
4
|
id: string
|
|
5
5
|
name: string
|
|
6
6
|
root_path: string
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
export interface ProjectSelection {
|
|
10
|
+
project: Project
|
|
11
|
+
notifyAgent: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
9
14
|
interface ProjectPickerProps {
|
|
10
|
-
/** Called when the user picks a project or
|
|
11
|
-
onDone: () => void
|
|
15
|
+
/** Called when the user picks a project or closes without changing it. */
|
|
16
|
+
onDone: (selection?: ProjectSelection) => void
|
|
17
|
+
currentProject?: string | null
|
|
12
18
|
}
|
|
13
19
|
|
|
14
20
|
type State = 'loading' | 'ready' | 'error'
|
|
15
21
|
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
export function ProjectPicker({ onDone }: ProjectPickerProps) {
|
|
22
|
+
// Lets a running session bind (or rebind) to a ctlsurf project so chat logging,
|
|
23
|
+
// tickets, remote-control, and the embedded project view follow that selection.
|
|
24
|
+
export function ProjectPicker({ onDone, currentProject }: ProjectPickerProps) {
|
|
19
25
|
const [state, setState] = useState<State>('loading')
|
|
20
26
|
const [projects, setProjects] = useState<Project[]>([])
|
|
21
27
|
const [errorMsg, setErrorMsg] = useState('')
|
|
22
28
|
const [binding, setBinding] = useState(false)
|
|
23
29
|
const [filter, setFilter] = useState('')
|
|
30
|
+
const [notifyAgent, setNotifyAgent] = useState(true)
|
|
24
31
|
|
|
25
32
|
useEffect(() => {
|
|
26
33
|
let cancelled = false
|
|
@@ -44,9 +51,19 @@ export function ProjectPicker({ onDone }: ProjectPickerProps) {
|
|
|
44
51
|
const pick = async (p: Project) => {
|
|
45
52
|
setBinding(true)
|
|
46
53
|
try {
|
|
47
|
-
await window.worker.bindProject(p.root_path)
|
|
48
|
-
|
|
49
|
-
|
|
54
|
+
const result = await window.worker.bindProject(p.root_path)
|
|
55
|
+
if (!result.ok) {
|
|
56
|
+
setErrorMsg(result.error || 'Could not bind project')
|
|
57
|
+
setState('error')
|
|
58
|
+
setBinding(false)
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
onDone({ project: p, notifyAgent })
|
|
62
|
+
} catch (err) {
|
|
63
|
+
setErrorMsg(err instanceof Error ? err.message : 'Could not bind project')
|
|
64
|
+
setState('error')
|
|
65
|
+
setBinding(false)
|
|
66
|
+
}
|
|
50
67
|
}
|
|
51
68
|
|
|
52
69
|
const shown = filter.trim()
|
|
@@ -60,7 +77,8 @@ export function ProjectPicker({ onDone }: ProjectPickerProps) {
|
|
|
60
77
|
<div className="agent-picker-modal">
|
|
61
78
|
<div className="agent-picker-brand">ctlsurf</div>
|
|
62
79
|
<div className="agent-picker-title">SELECT CTLSURF PROJECT</div>
|
|
63
|
-
<div className="remote-target-line">Bind
|
|
80
|
+
<div className="remote-target-line">Bind the active session to a project</div>
|
|
81
|
+
{currentProject && <div className="remote-note">Currently linked to {currentProject}</div>}
|
|
64
82
|
|
|
65
83
|
{state === 'loading' && (
|
|
66
84
|
<div className="remote-status">
|
|
@@ -78,7 +96,7 @@ export function ProjectPicker({ onDone }: ProjectPickerProps) {
|
|
|
78
96
|
{state === 'ready' && (
|
|
79
97
|
<>
|
|
80
98
|
{projects.length === 0 && (
|
|
81
|
-
<div className="remote-note">No ctlsurf projects found
|
|
99
|
+
<div className="remote-note">No ctlsurf projects found.</div>
|
|
82
100
|
)}
|
|
83
101
|
|
|
84
102
|
{projects.length > 6 && (
|
|
@@ -112,8 +130,21 @@ export function ProjectPicker({ onDone }: ProjectPickerProps) {
|
|
|
112
130
|
</>
|
|
113
131
|
)}
|
|
114
132
|
|
|
115
|
-
<
|
|
116
|
-
|
|
133
|
+
<label className="agent-picker-option project-notify-option">
|
|
134
|
+
<input
|
|
135
|
+
type="checkbox"
|
|
136
|
+
checked={notifyAgent}
|
|
137
|
+
disabled={binding}
|
|
138
|
+
onChange={(e) => setNotifyAgent(e.target.checked)}
|
|
139
|
+
/>
|
|
140
|
+
<span>
|
|
141
|
+
Tell the active agent about this project
|
|
142
|
+
<small>Includes the ctlsurf project and codebase details as context.</small>
|
|
143
|
+
</span>
|
|
144
|
+
</label>
|
|
145
|
+
|
|
146
|
+
<button className="remote-link" disabled={binding} onClick={() => onDone()}>
|
|
147
|
+
Close without changing
|
|
117
148
|
</button>
|
|
118
149
|
</div>
|
|
119
150
|
</div>
|