@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.
Files changed (33) hide show
  1. package/out/headless/index.mjs +10 -3
  2. package/out/headless/index.mjs.map +2 -2
  3. package/out/main/index.js +54 -4
  4. package/out/preload/index.js +2 -0
  5. package/out/renderer/assets/{cssMode-B77M9HlN.js → cssMode-Xx1-SozN.js} +3 -3
  6. package/out/renderer/assets/{freemarker2-oUT2zvyQ.js → freemarker2-DM49yPw1.js} +1 -1
  7. package/out/renderer/assets/{handlebars-jCckoi5j.js → handlebars-DJJBblpV.js} +1 -1
  8. package/out/renderer/assets/{html-kAgrAaKW.js → html-DnMnJOxw.js} +1 -1
  9. package/out/renderer/assets/{htmlMode-DIWkIawy.js → htmlMode-BG5u2CSH.js} +3 -3
  10. package/out/renderer/assets/{index-CN3UN7Tm.js → index-D65QIRev.js} +318 -95
  11. package/out/renderer/assets/{index-3sFJEz7i.css → index-DID1LlMU.css} +88 -0
  12. package/out/renderer/assets/{javascript-B9GYUE-I.js → javascript-Dci_phmN.js} +2 -2
  13. package/out/renderer/assets/{jsonMode-BzFKTZjJ.js → jsonMode-Dmp3JJSm.js} +3 -3
  14. package/out/renderer/assets/{liquid-Ccw-S_Y6.js → liquid-C9KVysk1.js} +1 -1
  15. package/out/renderer/assets/{lspLanguageFeatures-BE0g7ubH.js → lspLanguageFeatures-IDD0-KWa.js} +1 -1
  16. package/out/renderer/assets/{mdx-oXeAA0qU.js → mdx-ascmBFXX.js} +1 -1
  17. package/out/renderer/assets/{python-DlSX3TvA.js → python-DcbM4Hy5.js} +1 -1
  18. package/out/renderer/assets/{razor-ButO9S7r.js → razor-XJROxpYK.js} +1 -1
  19. package/out/renderer/assets/{tsMode-CTCpE-67.js → tsMode-EXbzul3n.js} +1 -1
  20. package/out/renderer/assets/{typescript-9o62WOnS.js → typescript-DhTtFBLt.js} +1 -1
  21. package/out/renderer/assets/{xml-C4nOH2_D.js → xml-BLfEkVl1.js} +1 -1
  22. package/out/renderer/assets/{yaml-mXYgKoWW.js → yaml-DpzsqvWa.js} +1 -1
  23. package/out/renderer/index.html +2 -2
  24. package/package.json +1 -1
  25. package/src/main/index.ts +52 -1
  26. package/src/main/orchestrator.ts +18 -3
  27. package/src/preload/index.ts +4 -0
  28. package/src/renderer/App.tsx +179 -50
  29. package/src/renderer/components/AgentPicker.tsx +61 -10
  30. package/src/renderer/components/FloatingMic.tsx +15 -11
  31. package/src/renderer/components/ProjectPicker.tsx +44 -13
  32. package/src/renderer/components/TerminalPanel.tsx +45 -9
  33. package/src/renderer/styles.css +88 -0
@@ -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
- await window.worker.spawnAgent(tabId, agent, spawnCwd)
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
- const handleAgentChange = useCallback(async (agentId: string) => {
306
- const agent = agents.find(a => a.id === agentId)
307
- if (agent) {
308
- await window.worker.killPty(activeTabId)
309
- setTabs(prev => prev.map(t => t.id === activeTabId ? { ...t, agent, agentStatus: 'idle' } : t))
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
- }, [agents, activeTabId])
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 || !activeTab?.agent) return
398
+ if (!newCwd) return
323
399
  cwdRef.current = newCwd
324
400
  setCwd(newCwd)
325
- await window.worker.killPty(activeTabId)
326
- setTabs(prev => prev.map(t => t.id === activeTabId ? { ...t, agentStatus: 'active' } : t))
327
- setSessionStart(new Date())
328
- await window.worker.spawnAgent(activeTabId, activeTab.agent, newCwd)
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
- setTabs(prev => prev.map(t =>
347
- t.id === tabId ? { ...t, agent, label: `${agent.name} ${tabs.filter(x => x.agent?.id === agent.id).length + 1}` } : t
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, tabs])
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
- {agents.map(a => {
609
- const activeTab = tabs.find(t => t.id === activeTabId)
610
- return (
611
- <button
612
- key={a.id}
613
- className={`titlebar-agent-btn ${activeTab?.agent?.id === a.id ? 'active' : ''}`}
614
- onClick={() => handleAgentChange(a.id)}
615
- title={a.description}
616
- >
617
- {a.name}
618
- </button>
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={async () => {
659
- const newCwd = await window.worker.browseCwd()
660
- if (newCwd) {
661
- setCwd(newCwd)
662
- cwdRef.current = newCwd
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={() => setShowProjectPicker(false)} />
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: () => void
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 displayPath = cwd.replace(/^\/Users\/[^/]+/, '~')
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
- onSelect(agent)
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={onChangeCwd} title="Click to change directory">
78
+ <div className="agent-picker-cwd" onClick={() => void changeCwd()} title="Click to change directory">
63
79
  <span className="agent-picker-cwd-icon">&#x1F4C2;</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
- onClick={() => handleSelect(a)}
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">Select an agent to start</div>
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 button clear of the 38px titlebar and 24px status bar.
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 = 36
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
- // First mount with no saved position: default to bottom-right.
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
- if (pos) return
77
- const el = elRef.current
78
- const w = el?.offsetWidth ?? 64
79
- const h = el?.offsetHeight ?? 90
80
- setPos({
81
- x: window.innerWidth - w - EDGE,
82
- y: window.innerHeight - h - BOTTOM_GAP,
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
- }, [pos])
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 skips — the overlay should close. */
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
- // 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) {
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
- } catch { /* non-fatal — session still runs unbound */ }
49
- onDone()
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 this remote session to a project</div>
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. You can skip and bind one later.</div>
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
- <button className="remote-link" disabled={binding} onClick={onDone}>
116
- Skip — no project
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>