@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
@@ -104,6 +104,7 @@ interface AgentConfig {
104
104
  interface TerminalPanelProps {
105
105
  tabId: string
106
106
  agent: AgentConfig | null
107
+ sessionVersion: number
107
108
  onSpawn: (tabId: string, agent: AgentConfig) => Promise<void>
108
109
  onExit: (tabId: string) => void
109
110
  isActive: boolean
@@ -118,7 +119,7 @@ type TermState = {
118
119
  fitAddon: FitAddon
119
120
  unsubData: (() => void) | null
120
121
  unsubExit: (() => void) | null
121
- spawnedAgentId: string | null
122
+ spawnedSessionKey: string | null
122
123
  // Follow mode: while true, new output keeps the view pinned to the bottom.
123
124
  pinnedToBottom: boolean
124
125
  // When NOT following, the absolute buffer line held at the top of the
@@ -197,6 +198,11 @@ function getOrCreateTerminal(tabId: string, onExit: (tabId: string) => void): {
197
198
 
198
199
  const terminal = new Terminal({
199
200
  cursorBlink: true,
201
+ // Codex and other modern CLIs often render links as OSC 8 hyperlinks
202
+ // instead of plain URL text. OSC 8 links are handled by xterm core (not
203
+ // WebLinksAddon), whose default activation calls window.open(). Route them
204
+ // through the same embedded-panel handler as detected plain-text URLs.
205
+ linkHandler: { activate: handleTerminalLink },
200
206
  // Off by default on macOS; without this a right-click would reselect the
201
207
  // word under the cursor and clobber a multi-line table selection.
202
208
  rightClickSelectsWord: false,
@@ -275,7 +281,7 @@ function getOrCreateTerminal(tabId: string, onExit: (tabId: string) => void): {
275
281
  fitAddon,
276
282
  unsubData: null,
277
283
  unsubExit: null,
278
- spawnedAgentId: null,
284
+ spawnedSessionKey: null,
279
285
  pinnedToBottom: true,
280
286
  anchorLine: null,
281
287
  userScrollPending: false,
@@ -338,6 +344,29 @@ export function focusTerminal(tabId: string): void {
338
344
  _terminals.get(tabId)?.terminal.focus()
339
345
  }
340
346
 
347
+ // Capture the rendered transcript before a live session is replaced. This is
348
+ // intentionally sourced from the terminal the user actually saw, so it also
349
+ // works for shells, custom agents, and remote SSH sessions whose native JSONL
350
+ // transcripts are unavailable on this machine.
351
+ const MAX_HANDOFF_CHARS = 1_000_000
352
+ export function captureTerminalSession(tabId: string): string {
353
+ const terminal = _terminals.get(tabId)?.terminal
354
+ if (!terminal) return ''
355
+ const buffer = terminal.buffer.active
356
+ const lines: string[] = []
357
+ for (let i = 0; i < buffer.length; i++) {
358
+ const line = buffer.getLine(i)
359
+ if (!line) continue
360
+ const text = line.translateToString(true)
361
+ if (line.isWrapped && lines.length > 0) lines[lines.length - 1] += text
362
+ else lines.push(text)
363
+ }
364
+ while (lines.length && lines[lines.length - 1].trim() === '') lines.pop()
365
+ const text = lines.join('\n').trim()
366
+ if (text.length <= MAX_HANDOFF_CHARS) return text
367
+ return `[Earlier terminal output omitted; showing the last ${MAX_HANDOFF_CHARS.toLocaleString()} characters.]\n${text.slice(-MAX_HANDOFF_CHARS)}`
368
+ }
369
+
341
370
  export function destroyTerminal(tabId: string): void {
342
371
  const state = _terminals.get(tabId)
343
372
  if (!state) return
@@ -349,7 +378,7 @@ export function destroyTerminal(tabId: string): void {
349
378
 
350
379
  // ─── Component ───────────────────────────────────────
351
380
 
352
- export function TerminalPanel({ tabId, agent, onSpawn, onExit, isActive }: TerminalPanelProps) {
381
+ export function TerminalPanel({ tabId, agent, sessionVersion, onSpawn, onExit, isActive }: TerminalPanelProps) {
353
382
  const containerRef = useRef<HTMLDivElement>(null)
354
383
  const [menu, setMenu] = useState<{ x: number; y: number } | null>(null)
355
384
  // Snapshot panel: the ⋮ button freezes the visible screen text into a static,
@@ -469,8 +498,12 @@ export function TerminalPanel({ tabId, agent, onSpawn, onExit, isActive }: Termi
469
498
 
470
499
  // Wheel over the terminal is an explicit user scroll — stamp it so onScroll
471
500
  // treats the resulting move as intentional (not an involuntary redraw jump).
501
+ // Capture is important: xterm handles wheel events on a descendant and
502
+ // emits onScroll synchronously. A bubbling listener runs too late, causing
503
+ // streamed Codex output to mistake the wheel scroll for a redraw and snap
504
+ // the viewport straight back to the bottom.
472
505
  const onWheel = () => markGesture(tabId)
473
- terminal.element?.addEventListener('wheel', onWheel, { passive: true })
506
+ terminal.element?.addEventListener('wheel', onWheel, { capture: true, passive: true })
474
507
 
475
508
  // Resize handling — skip when the container is hidden (display:none → 0x0),
476
509
  // otherwise fit() reports a tiny size and the PTY gets stuck at ~4 cols
@@ -499,19 +532,22 @@ export function TerminalPanel({ tabId, agent, onSpawn, onExit, isActive }: Termi
499
532
  clearTimeout(resizeTimeout)
500
533
  window.removeEventListener('resize', handleResize)
501
534
  observer.disconnect()
502
- terminal.element?.removeEventListener('wheel', onWheel)
535
+ terminal.element?.removeEventListener('wheel', onWheel, true)
503
536
  // Do NOT dispose terminal or unsub PTY listeners — they persist across remounts
504
537
  }
505
538
  }, [tabId, onExit])
506
539
 
507
- // Spawn agent when it changes for this tab
540
+ // Spawn whenever the selected agent or session version changes. The version
541
+ // makes choosing a new directory (or explicitly restarting the same agent)
542
+ // observable even though the agent id itself did not change.
508
543
  useEffect(() => {
509
544
  if (!agent) return
510
545
  const state = _terminals.get(tabId)
511
546
  if (!state) return
512
- if (state.spawnedAgentId === agent.id) return
547
+ const sessionKey = `${agent.id}:${sessionVersion}`
548
+ if (state.spawnedSessionKey === sessionKey) return
513
549
 
514
- state.spawnedAgentId = agent.id
550
+ state.spawnedSessionKey = sessionKey
515
551
  state.terminal.clear()
516
552
 
517
553
  onSpawn(tabId, agent).then(() => {
@@ -525,7 +561,7 @@ export function TerminalPanel({ tabId, agent, onSpawn, onExit, isActive }: Termi
525
561
  }
526
562
  state.terminal.focus()
527
563
  })
528
- }, [tabId, agent, onSpawn])
564
+ }, [tabId, agent, sessionVersion, onSpawn])
529
565
 
530
566
  // Focus terminal when tab becomes active — delay fit to let the container get its layout dimensions
531
567
  useEffect(() => {
@@ -1604,6 +1604,73 @@ html, body, #root {
1604
1604
  letter-spacing: 1px;
1605
1605
  }
1606
1606
 
1607
+ .agent-picker-warning {
1608
+ margin: -2px 0 14px;
1609
+ padding: 9px 11px;
1610
+ border: 1px solid color-mix(in srgb, var(--warning) 45%, transparent);
1611
+ border-radius: 7px;
1612
+ background: color-mix(in srgb, var(--warning) 9%, transparent);
1613
+ color: var(--text-muted);
1614
+ font-size: 11px;
1615
+ line-height: 1.45;
1616
+ text-align: left;
1617
+ }
1618
+
1619
+ .agent-handoff-options {
1620
+ display: grid;
1621
+ gap: 7px;
1622
+ margin: 0 0 14px;
1623
+ padding: 0;
1624
+ border: 0;
1625
+ text-align: left;
1626
+ }
1627
+
1628
+ .agent-handoff-options legend {
1629
+ margin-bottom: 7px;
1630
+ color: var(--text-muted);
1631
+ font-size: 10px;
1632
+ letter-spacing: 0.8px;
1633
+ }
1634
+
1635
+ .agent-handoff-options label {
1636
+ display: flex;
1637
+ align-items: flex-start;
1638
+ gap: 9px;
1639
+ padding: 9px 10px;
1640
+ border: 1px solid var(--border-subtle);
1641
+ border-radius: 7px;
1642
+ background: var(--bg-chrome);
1643
+ cursor: pointer;
1644
+ }
1645
+
1646
+ .agent-handoff-options label.selected {
1647
+ border-color: var(--accent);
1648
+ background: var(--bg-chrome-hi);
1649
+ }
1650
+
1651
+ .agent-handoff-options input {
1652
+ margin-top: 2px;
1653
+ accent-color: var(--accent);
1654
+ }
1655
+
1656
+ .agent-handoff-options span,
1657
+ .agent-handoff-options strong,
1658
+ .agent-handoff-options small {
1659
+ display: block;
1660
+ }
1661
+
1662
+ .agent-handoff-options strong {
1663
+ color: var(--text-bright);
1664
+ font-size: 12px;
1665
+ }
1666
+
1667
+ .agent-handoff-options small {
1668
+ margin-top: 2px;
1669
+ color: var(--text-muted);
1670
+ font-size: 10px;
1671
+ line-height: 1.35;
1672
+ }
1673
+
1607
1674
  .agent-picker-list {
1608
1675
  display: flex;
1609
1676
  flex-direction: column;
@@ -1664,6 +1731,27 @@ html, body, #root {
1664
1731
  accent-color: var(--accent);
1665
1732
  }
1666
1733
 
1734
+ .project-notify-option {
1735
+ align-items: flex-start;
1736
+ text-align: left;
1737
+ }
1738
+
1739
+ .project-notify-option input {
1740
+ margin-top: 2px;
1741
+ }
1742
+
1743
+ .project-notify-option span,
1744
+ .project-notify-option small {
1745
+ display: block;
1746
+ }
1747
+
1748
+ .project-notify-option small {
1749
+ margin-top: 2px;
1750
+ color: var(--text-muted);
1751
+ font-size: 10px;
1752
+ line-height: 1.35;
1753
+ }
1754
+
1667
1755
  /* ─── Remote (SSH) connect ───────────────────────── */
1668
1756
 
1669
1757
  .remote-section-label {