@phenx-inc/ctlsurf 0.15.2 → 0.16.1

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-CPfLk04b.js → cssMode-DwHD6JSn.js} +3 -3
  6. package/out/renderer/assets/{freemarker2-CDHpuFqv.js → freemarker2-Na9-bXYw.js} +1 -1
  7. package/out/renderer/assets/{handlebars-CgX9Jz36.js → handlebars-D7gzKLS8.js} +1 -1
  8. package/out/renderer/assets/{html-DdgFccDG.js → html-DDK7xN27.js} +1 -1
  9. package/out/renderer/assets/{htmlMode-CRHIwtvP.js → htmlMode-BfbLv-NB.js} +3 -3
  10. package/out/renderer/assets/{index-3sFJEz7i.css → index-DID1LlMU.css} +88 -0
  11. package/out/renderer/assets/{index-DVdWgw7p.js → index-HoDaBul3.js} +340 -96
  12. package/out/renderer/assets/{javascript-CHhxL0s7.js → javascript-DJjWOxID.js} +2 -2
  13. package/out/renderer/assets/{jsonMode-Cy7-eWai.js → jsonMode-BileWbig.js} +3 -3
  14. package/out/renderer/assets/{liquid-vMkF9pfk.js → liquid-DvNU_dI9.js} +1 -1
  15. package/out/renderer/assets/{lspLanguageFeatures-nAygNzuG.js → lspLanguageFeatures-Bb27EY8c.js} +1 -1
  16. package/out/renderer/assets/{mdx-C4UVz5dW.js → mdx-CLkNPVgD.js} +1 -1
  17. package/out/renderer/assets/{python-BHemzN9g.js → python-DOJdL4kC.js} +1 -1
  18. package/out/renderer/assets/{razor-DH6NPzbo.js → razor-CSr8UlJc.js} +1 -1
  19. package/out/renderer/assets/{tsMode-DxBdlGZB.js → tsMode-sbsAvk21.js} +1 -1
  20. package/out/renderer/assets/{typescript-BRCOaBQ5.js → typescript-r5QB4yRW.js} +1 -1
  21. package/out/renderer/assets/{xml-DVv9gDP1.js → xml-CYhKh3Pi.js} +1 -1
  22. package/out/renderer/assets/{yaml-jB-C3CdS.js → yaml-B8DsoaYW.js} +1 -1
  23. package/out/renderer/index.html +2 -2
  24. package/package.json +1 -1
  25. package/scripts/test-terminal-scroll.cjs +64 -0
  26. package/src/main/index.ts +52 -1
  27. package/src/main/orchestrator.ts +18 -3
  28. package/src/preload/index.ts +4 -0
  29. package/src/renderer/App.tsx +179 -50
  30. package/src/renderer/components/AgentPicker.tsx +61 -10
  31. package/src/renderer/components/ProjectPicker.tsx +44 -13
  32. package/src/renderer/components/TerminalPanel.tsx +88 -27
  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
@@ -126,7 +127,7 @@ type TermState = {
126
127
  // (neither down to the bottom, nor up to the top — the old bug).
127
128
  anchorLine: number | null
128
129
  // One-shot flag set by a real user scroll gesture (wheel / scroll keys) and
129
- // consumed by the very next onScroll, so a redraw firing onScroll right after
130
+ // consumed by the next terminal/viewport scroll, so a redraw right after
130
131
  // a gesture isn't mistaken for one. Spinner redraws (~10x/s while "thinking")
131
132
  // would otherwise be misread as user intent and re-pin to the bottom.
132
133
  userScrollPending: boolean
@@ -189,6 +190,22 @@ function markGesture(tabId: string): void {
189
190
  if (s) s.userScrollPending = true
190
191
  }
191
192
 
193
+ function followLatest(state: TermState): void {
194
+ state.userScrollPending = false
195
+ setPinned(state, true)
196
+ reassert(state)
197
+ }
198
+
199
+ function rememberScrollPosition(state: TermState): void {
200
+ state.userScrollPending = false
201
+ if (isAtBottom(state.terminal)) {
202
+ setPinned(state, true)
203
+ } else {
204
+ state.anchorLine = state.terminal.buffer.active.viewportY
205
+ setPinned(state, false)
206
+ }
207
+ }
208
+
192
209
  function getOrCreateTerminal(tabId: string, onExit: (tabId: string) => void): { terminal: Terminal; fitAddon: FitAddon } {
193
210
  const existing = _terminals.get(tabId)
194
211
  if (existing) {
@@ -215,7 +232,9 @@ function getOrCreateTerminal(tabId: string, onExit: (tabId: string) => void): {
215
232
  fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', monospace",
216
233
  theme: currentTheme().terminal,
217
234
  scrollback: 10000,
218
- scrollOnUserInput: true,
235
+ // Follow explicit keyboard/paste input below. xterm also counts mouse
236
+ // reports as user input, which must not pull someone out of scrollback.
237
+ scrollOnUserInput: false,
219
238
  allowTransparency: false
220
239
  })
221
240
 
@@ -280,7 +299,7 @@ function getOrCreateTerminal(tabId: string, onExit: (tabId: string) => void): {
280
299
  fitAddon,
281
300
  unsubData: null,
282
301
  unsubExit: null,
283
- spawnedAgentId: null,
302
+ spawnedSessionKey: null,
284
303
  pinnedToBottom: true,
285
304
  anchorLine: null,
286
305
  userScrollPending: false,
@@ -301,19 +320,14 @@ function getOrCreateTerminal(tabId: string, onExit: (tabId: string) => void): {
301
320
  reassert(state)
302
321
  return
303
322
  }
304
- state.userScrollPending = false
305
- if (isAtBottom(terminal)) {
306
- setPinned(state, true)
307
- } else {
308
- state.anchorLine = terminal.buffer.active.viewportY
309
- setPinned(state, false)
310
- }
323
+ rememberScrollPosition(state)
311
324
  })
312
325
 
313
- // Send keystrokes to pty — typing always returns to (and follows) the bottom.
326
+ // onData includes automatic cursor/device/color replies and focus/mouse
327
+ // reports, not just keystrokes. Codex's terminal queries used to re-enable
328
+ // follow mode here, snapping the viewport down while reading old output.
329
+ terminal.onKey(() => followLatest(state))
314
330
  terminal.onData((data) => {
315
- setPinned(state, true)
316
- terminal.scrollToBottom()
317
331
  window.worker.writePty(tabId, data)
318
332
  })
319
333
 
@@ -343,6 +357,29 @@ export function focusTerminal(tabId: string): void {
343
357
  _terminals.get(tabId)?.terminal.focus()
344
358
  }
345
359
 
360
+ // Capture the rendered transcript before a live session is replaced. This is
361
+ // intentionally sourced from the terminal the user actually saw, so it also
362
+ // works for shells, custom agents, and remote SSH sessions whose native JSONL
363
+ // transcripts are unavailable on this machine.
364
+ const MAX_HANDOFF_CHARS = 1_000_000
365
+ export function captureTerminalSession(tabId: string): string {
366
+ const terminal = _terminals.get(tabId)?.terminal
367
+ if (!terminal) return ''
368
+ const buffer = terminal.buffer.active
369
+ const lines: string[] = []
370
+ for (let i = 0; i < buffer.length; i++) {
371
+ const line = buffer.getLine(i)
372
+ if (!line) continue
373
+ const text = line.translateToString(true)
374
+ if (line.isWrapped && lines.length > 0) lines[lines.length - 1] += text
375
+ else lines.push(text)
376
+ }
377
+ while (lines.length && lines[lines.length - 1].trim() === '') lines.pop()
378
+ const text = lines.join('\n').trim()
379
+ if (text.length <= MAX_HANDOFF_CHARS) return text
380
+ return `[Earlier terminal output omitted; showing the last ${MAX_HANDOFF_CHARS.toLocaleString()} characters.]\n${text.slice(-MAX_HANDOFF_CHARS)}`
381
+ }
382
+
346
383
  export function destroyTerminal(tabId: string): void {
347
384
  const state = _terminals.get(tabId)
348
385
  if (!state) return
@@ -354,7 +391,7 @@ export function destroyTerminal(tabId: string): void {
354
391
 
355
392
  // ─── Component ───────────────────────────────────────
356
393
 
357
- export function TerminalPanel({ tabId, agent, onSpawn, onExit, isActive }: TerminalPanelProps) {
394
+ export function TerminalPanel({ tabId, agent, sessionVersion, onSpawn, onExit, isActive }: TerminalPanelProps) {
358
395
  const containerRef = useRef<HTMLDivElement>(null)
359
396
  const [menu, setMenu] = useState<{ x: number; y: number } | null>(null)
360
397
  // Snapshot panel: the ⋮ button freezes the visible screen text into a static,
@@ -370,8 +407,7 @@ export function TerminalPanel({ tabId, agent, onSpawn, onExit, isActive }: Termi
370
407
  const jumpToLatest = () => {
371
408
  const s = _terminals.get(tabId)
372
409
  if (!s) return
373
- setPinned(s, true)
374
- s.terminal.scrollToBottom()
410
+ followLatest(s)
375
411
  s.terminal.focus()
376
412
  }
377
413
 
@@ -472,15 +508,33 @@ export function TerminalPanel({ tabId, agent, onSpawn, onExit, isActive }: Termi
472
508
  const attached = _terminals.get(tabId)
473
509
  if (attached) reassert(attached)
474
510
 
475
- // Wheel over the terminal is an explicit user scroll — stamp it so onScroll
476
- // treats the resulting move as intentional (not an involuntary redraw jump).
477
- // Capture is important: xterm handles wheel events on a descendant and
478
- // emits onScroll synchronously. A bubbling listener runs too late, causing
479
- // streamed Codex output to mistake the wheel scroll for a redraw and snap
480
- // the viewport straight back to the bottom.
511
+ // Wheel handling starts inside xterm, so record intent in capture phase.
481
512
  const onWheel = () => markGesture(tabId)
482
513
  terminal.element?.addEventListener('wheel', onWheel, { capture: true, passive: true })
483
514
 
515
+ // xterm 5.5 suppresses its public onScroll event for native viewport
516
+ // scrolling. Observe the DOM event after xterm updates buffer.viewportY,
517
+ // otherwise a wheel gesture never pauses follow mode before the next write.
518
+ const viewport = terminal.element?.querySelector('.xterm-viewport')
519
+ const onViewportScroll = () => {
520
+ const state = _terminals.get(tabId)
521
+ if (state && !state.holding && state.userScrollPending) rememberScrollPosition(state)
522
+ }
523
+ viewport?.addEventListener('scroll', onViewportScroll)
524
+
525
+ // Paste and IME/emoji input can bypass onKey. Capture them before xterm
526
+ // handles the event (paste stops propagation inside xterm).
527
+ const onTextInput = () => {
528
+ const state = _terminals.get(tabId)
529
+ if (state) followLatest(state)
530
+ }
531
+ const onPaste = (event: ClipboardEvent) => {
532
+ if (event.clipboardData?.getData('text/plain')) onTextInput()
533
+ }
534
+ terminal.textarea?.addEventListener('paste', onPaste, true)
535
+ terminal.textarea?.addEventListener('input', onTextInput, true)
536
+ terminal.textarea?.addEventListener('compositionend', onTextInput, true)
537
+
484
538
  // Resize handling — skip when the container is hidden (display:none → 0x0),
485
539
  // otherwise fit() reports a tiny size and the PTY gets stuck at ~4 cols
486
540
  // until the user switches back.
@@ -509,18 +563,25 @@ export function TerminalPanel({ tabId, agent, onSpawn, onExit, isActive }: Termi
509
563
  window.removeEventListener('resize', handleResize)
510
564
  observer.disconnect()
511
565
  terminal.element?.removeEventListener('wheel', onWheel, true)
566
+ viewport?.removeEventListener('scroll', onViewportScroll)
567
+ terminal.textarea?.removeEventListener('paste', onPaste, true)
568
+ terminal.textarea?.removeEventListener('input', onTextInput, true)
569
+ terminal.textarea?.removeEventListener('compositionend', onTextInput, true)
512
570
  // Do NOT dispose terminal or unsub PTY listeners — they persist across remounts
513
571
  }
514
572
  }, [tabId, onExit])
515
573
 
516
- // Spawn agent when it changes for this tab
574
+ // Spawn whenever the selected agent or session version changes. The version
575
+ // makes choosing a new directory (or explicitly restarting the same agent)
576
+ // observable even though the agent id itself did not change.
517
577
  useEffect(() => {
518
578
  if (!agent) return
519
579
  const state = _terminals.get(tabId)
520
580
  if (!state) return
521
- if (state.spawnedAgentId === agent.id) return
581
+ const sessionKey = `${agent.id}:${sessionVersion}`
582
+ if (state.spawnedSessionKey === sessionKey) return
522
583
 
523
- state.spawnedAgentId = agent.id
584
+ state.spawnedSessionKey = sessionKey
524
585
  state.terminal.clear()
525
586
 
526
587
  onSpawn(tabId, agent).then(() => {
@@ -534,7 +595,7 @@ export function TerminalPanel({ tabId, agent, onSpawn, onExit, isActive }: Termi
534
595
  }
535
596
  state.terminal.focus()
536
597
  })
537
- }, [tabId, agent, onSpawn])
598
+ }, [tabId, agent, sessionVersion, onSpawn])
538
599
 
539
600
  // Focus terminal when tab becomes active — delay fit to let the container get its layout dimensions
540
601
  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 {