@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.
- 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-CPfLk04b.js → cssMode-DwHD6JSn.js} +3 -3
- package/out/renderer/assets/{freemarker2-CDHpuFqv.js → freemarker2-Na9-bXYw.js} +1 -1
- package/out/renderer/assets/{handlebars-CgX9Jz36.js → handlebars-D7gzKLS8.js} +1 -1
- package/out/renderer/assets/{html-DdgFccDG.js → html-DDK7xN27.js} +1 -1
- package/out/renderer/assets/{htmlMode-CRHIwtvP.js → htmlMode-BfbLv-NB.js} +3 -3
- package/out/renderer/assets/{index-3sFJEz7i.css → index-DID1LlMU.css} +88 -0
- package/out/renderer/assets/{index-DVdWgw7p.js → index-HoDaBul3.js} +340 -96
- package/out/renderer/assets/{javascript-CHhxL0s7.js → javascript-DJjWOxID.js} +2 -2
- package/out/renderer/assets/{jsonMode-Cy7-eWai.js → jsonMode-BileWbig.js} +3 -3
- package/out/renderer/assets/{liquid-vMkF9pfk.js → liquid-DvNU_dI9.js} +1 -1
- package/out/renderer/assets/{lspLanguageFeatures-nAygNzuG.js → lspLanguageFeatures-Bb27EY8c.js} +1 -1
- package/out/renderer/assets/{mdx-C4UVz5dW.js → mdx-CLkNPVgD.js} +1 -1
- package/out/renderer/assets/{python-BHemzN9g.js → python-DOJdL4kC.js} +1 -1
- package/out/renderer/assets/{razor-DH6NPzbo.js → razor-CSr8UlJc.js} +1 -1
- package/out/renderer/assets/{tsMode-DxBdlGZB.js → tsMode-sbsAvk21.js} +1 -1
- package/out/renderer/assets/{typescript-BRCOaBQ5.js → typescript-r5QB4yRW.js} +1 -1
- package/out/renderer/assets/{xml-DVv9gDP1.js → xml-CYhKh3Pi.js} +1 -1
- package/out/renderer/assets/{yaml-jB-C3CdS.js → yaml-B8DsoaYW.js} +1 -1
- package/out/renderer/index.html +2 -2
- package/package.json +1 -1
- package/scripts/test-terminal-scroll.cjs +64 -0
- 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/ProjectPicker.tsx +44 -13
- package/src/renderer/components/TerminalPanel.tsx +88 -27
- 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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
581
|
+
const sessionKey = `${agent.id}:${sessionVersion}`
|
|
582
|
+
if (state.spawnedSessionKey === sessionKey) return
|
|
522
583
|
|
|
523
|
-
state.
|
|
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(() => {
|
package/src/renderer/styles.css
CHANGED
|
@@ -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 {
|