@phenx-inc/ctlsurf 0.11.0 → 0.12.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 (38) hide show
  1. package/out/headless/index.mjs +115 -5
  2. package/out/headless/index.mjs.map +3 -3
  3. package/out/main/index.js +316 -8
  4. package/out/preload/index.js +9 -0
  5. package/out/renderer/assets/{cssMode-Bmzy9hKX.js → cssMode-D7r66wvO.js} +3 -3
  6. package/out/renderer/assets/{freemarker2-CDAsExTa.js → freemarker2-jSWJIjPG.js} +1 -1
  7. package/out/renderer/assets/{handlebars-CvpBYG1L.js → handlebars-BnFyz5RH.js} +1 -1
  8. package/out/renderer/assets/{html-CNhEQqr8.js → html-Bl5UFavp.js} +1 -1
  9. package/out/renderer/assets/{htmlMode-DD7YPgga.js → htmlMode-D4VByBT6.js} +3 -3
  10. package/out/renderer/assets/{index-CGHeDwZa.css → index-Cc_TqOCL.css} +318 -0
  11. package/out/renderer/assets/{index-BDlCPzCG.js → index-DxhW1emk.js} +558 -85
  12. package/out/renderer/assets/{javascript-CsGLsswK.js → javascript-CMN1OmwG.js} +2 -2
  13. package/out/renderer/assets/{jsonMode-CTuGgRvC.js → jsonMode-CJ5AYSPV.js} +3 -3
  14. package/out/renderer/assets/{liquid-BrrN4uul.js → liquid-BGMIDTEm.js} +1 -1
  15. package/out/renderer/assets/{lspLanguageFeatures-Bhdn2Eqy.js → lspLanguageFeatures-DGifVYB5.js} +1 -1
  16. package/out/renderer/assets/{mdx-DJo-gax4.js → mdx-B2Z7R10Q.js} +1 -1
  17. package/out/renderer/assets/{python-DYHeg5b2.js → python-LMDMSSdn.js} +1 -1
  18. package/out/renderer/assets/{razor-Cqcks14K.js → razor-BZSrziPu.js} +1 -1
  19. package/out/renderer/assets/{tsMode-YTLgCayh.js → tsMode-DF9m6_3n.js} +1 -1
  20. package/out/renderer/assets/{typescript-oxBn_Vcw.js → typescript-rZeCMf0a.js} +1 -1
  21. package/out/renderer/assets/{xml-BEqGMCTc.js → xml-fNSZ-7so.js} +1 -1
  22. package/out/renderer/assets/{yaml-BGSrk5HC.js → yaml-1wxIuvzh.js} +1 -1
  23. package/out/renderer/index.html +2 -2
  24. package/package.json +1 -1
  25. package/src/main/agents.ts +9 -1
  26. package/src/main/ctlsurfApi.ts +7 -0
  27. package/src/main/index.ts +62 -4
  28. package/src/main/orchestrator.ts +112 -4
  29. package/src/main/ssh.ts +348 -0
  30. package/src/preload/index.ts +26 -0
  31. package/src/renderer/App.tsx +25 -1
  32. package/src/renderer/components/AgentPicker.tsx +18 -0
  33. package/src/renderer/components/ProjectPicker.tsx +121 -0
  34. package/src/renderer/components/RemoteConnect.tsx +315 -0
  35. package/src/renderer/components/SpeakControls.tsx +8 -2
  36. package/src/renderer/components/TerminalPanel.tsx +134 -23
  37. package/src/renderer/components/TicketPanel.tsx +67 -26
  38. package/src/renderer/styles.css +318 -0
@@ -0,0 +1,315 @@
1
+ import { useEffect, useState } from 'react'
2
+
3
+ interface AgentConfig {
4
+ id: string
5
+ name: string
6
+ command: string
7
+ args: string[]
8
+ description: string
9
+ }
10
+
11
+ interface SshHost {
12
+ alias: string
13
+ hostName?: string
14
+ user?: string
15
+ }
16
+
17
+ interface ProbeResult {
18
+ reachable: boolean
19
+ authRequired: boolean
20
+ error?: string
21
+ tmuxInstalled: boolean
22
+ sessions: string[]
23
+ ctlsurfSessions: string[]
24
+ }
25
+
26
+ interface RemoteConnectProps {
27
+ /** Called with the built ssh-backed agent once an action is chosen. */
28
+ onConnect: (agent: AgentConfig) => void
29
+ /** Return to the normal agent list. */
30
+ onBack: () => void
31
+ }
32
+
33
+ type Step = 'hosts' | 'probing' | 'action' | 'error'
34
+
35
+ const TMUX_PREFIX = 'ctlsurf_'
36
+
37
+ // Lowest free ctlsurf_N given the sessions already on the host (mirrors
38
+ // nextSessionName in src/main/ssh.ts so the label is right before we connect).
39
+ function nextSessionName(sessions: string[]): string {
40
+ const used = new Set(
41
+ sessions
42
+ .filter((s) => s.startsWith(TMUX_PREFIX))
43
+ .map((s) => parseInt(s.slice(TMUX_PREFIX.length), 10))
44
+ .filter((n) => Number.isInteger(n) && n > 0),
45
+ )
46
+ let n = 1
47
+ while (used.has(n)) n++
48
+ return `${TMUX_PREFIX}${n}`
49
+ }
50
+
51
+ export function RemoteConnect({ onConnect, onBack }: RemoteConnectProps) {
52
+ const [step, setStep] = useState<Step>('hosts')
53
+ const [configHosts, setConfigHosts] = useState<SshHost[]>([])
54
+ const [recents, setRecents] = useState<string[]>([])
55
+ const [manual, setManual] = useState('')
56
+ const [agentCmd, setAgentCmd] = useState<'claude' | ''>('claude') // '' = remote shell
57
+ const [target, setTarget] = useState('')
58
+ const [probe, setProbe] = useState<ProbeResult | null>(null)
59
+ const [errorMsg, setErrorMsg] = useState('')
60
+ const [password, setPassword] = useState('')
61
+
62
+ useEffect(() => {
63
+ let cancelled = false
64
+ window.worker.sshListHosts().then((r) => {
65
+ if (cancelled) return
66
+ setConfigHosts(r.configHosts || [])
67
+ setRecents(r.recents || [])
68
+ }).catch(() => { /* no hosts */ })
69
+ return () => { cancelled = true }
70
+ }, [])
71
+
72
+ const startProbe = async (t: string) => {
73
+ const dest = t.trim()
74
+ if (!dest) return
75
+ setTarget(dest)
76
+ setStep('probing')
77
+ try {
78
+ const result = await window.worker.sshProbe(dest)
79
+ setProbe(result)
80
+ if (result.reachable || result.authRequired) {
81
+ setStep('action')
82
+ } else {
83
+ setErrorMsg(result.error || 'Could not connect')
84
+ setStep('error')
85
+ }
86
+ } catch (err: any) {
87
+ setErrorMsg(err?.message || 'Probe failed')
88
+ setStep('error')
89
+ }
90
+ }
91
+
92
+ const connect = async (action: unknown) => {
93
+ try {
94
+ const agent = await window.worker.sshBuildAgent(target, action, agentCmd)
95
+ // Hand the typed password (if any) to the main process for one-shot
96
+ // injection into ssh's prompt, then wipe it from component state. Blank =
97
+ // let ssh prompt in the terminal instead.
98
+ if (password) {
99
+ await window.worker.sshSetPassword(target, password).catch(() => { /* falls back to terminal prompt */ })
100
+ }
101
+ setPassword('')
102
+ window.worker.sshAddRecent(target).catch(() => { /* convenience only */ })
103
+ onConnect(agent)
104
+ } catch (err: any) {
105
+ setErrorMsg(err?.message || 'Failed to build connection')
106
+ setStep('error')
107
+ }
108
+ }
109
+
110
+ const passwordField = (
111
+ <div className="remote-pw">
112
+ <input
113
+ className="remote-input"
114
+ type="password"
115
+ placeholder="Password (optional — blank prompts in terminal)"
116
+ value={password}
117
+ onChange={(e) => setPassword(e.target.value)}
118
+ autoCapitalize="off"
119
+ autoCorrect="off"
120
+ spellCheck={false}
121
+ />
122
+ <div className="remote-pw-hint">Used once for this connection — never stored.</div>
123
+ </div>
124
+ )
125
+
126
+ const agentLabel = agentCmd === 'claude' ? 'Claude Code' : 'shell'
127
+
128
+ // ─── Agent toggle (Claude Code vs shell), shared across steps ───
129
+ const agentToggle = (
130
+ <div className="remote-toggle">
131
+ <span className="remote-toggle-label">Run:</span>
132
+ <button
133
+ className={`remote-toggle-btn${agentCmd === 'claude' ? ' is-active' : ''}`}
134
+ onClick={() => setAgentCmd('claude')}
135
+ >Claude Code</button>
136
+ <button
137
+ className={`remote-toggle-btn${agentCmd === '' ? ' is-active' : ''}`}
138
+ onClick={() => setAgentCmd('')}
139
+ >Shell</button>
140
+ </div>
141
+ )
142
+
143
+ return (
144
+ <div className="agent-picker-overlay">
145
+ <div className="agent-picker-modal">
146
+ <div className="agent-picker-brand">ctlsurf</div>
147
+ <div className="agent-picker-title">CONNECT TO REMOTE</div>
148
+
149
+ {step === 'hosts' && (
150
+ <>
151
+ {agentToggle}
152
+
153
+ {recents.length > 0 && (
154
+ <>
155
+ <div className="remote-section-label">RECENT</div>
156
+ <div className="agent-picker-list">
157
+ {recents.map((r) => (
158
+ <button key={r} className="agent-picker-item" onClick={() => startProbe(r)}>
159
+ <span className="agent-picker-name">{r}</span>
160
+ <span className="agent-picker-desc">reconnect</span>
161
+ </button>
162
+ ))}
163
+ </div>
164
+ </>
165
+ )}
166
+
167
+ {configHosts.length > 0 && (
168
+ <>
169
+ <div className="remote-section-label">~/.ssh/config</div>
170
+ <div className="agent-picker-list remote-host-scroll">
171
+ {configHosts.map((h) => (
172
+ <button key={h.alias} className="agent-picker-item" onClick={() => startProbe(h.alias)}>
173
+ <span className="agent-picker-name">{h.alias}</span>
174
+ <span className="agent-picker-desc">
175
+ {h.user ? `${h.user}@` : ''}{h.hostName || ''}
176
+ </span>
177
+ </button>
178
+ ))}
179
+ </div>
180
+ </>
181
+ )}
182
+
183
+ <div className="remote-section-label">OR TYPE A HOST</div>
184
+ <form
185
+ className="remote-manual"
186
+ onSubmit={(e) => { e.preventDefault(); startProbe(manual) }}
187
+ >
188
+ <input
189
+ className="remote-input"
190
+ type="text"
191
+ placeholder="user@host"
192
+ value={manual}
193
+ onChange={(e) => setManual(e.target.value)}
194
+ autoCapitalize="off"
195
+ autoCorrect="off"
196
+ spellCheck={false}
197
+ />
198
+ <button className="remote-btn" type="submit" disabled={!manual.trim()}>Connect</button>
199
+ </form>
200
+
201
+ <button className="remote-link" onClick={onBack}>← back to agents</button>
202
+ </>
203
+ )}
204
+
205
+ {step === 'probing' && (
206
+ <div className="remote-status">
207
+ <div className="remote-spinner" />
208
+ <div>Connecting to <b>{target}</b>…</div>
209
+ <div className="remote-status-sub">Checking for tmux sessions</div>
210
+ </div>
211
+ )}
212
+
213
+ {step === 'action' && probe && (
214
+ <>
215
+ <div className="remote-target-line">
216
+ <b>{target}</b> · run {agentLabel}
217
+ </div>
218
+
219
+ {probe.authRequired && (
220
+ <>
221
+ <div className="remote-note">
222
+ This host needs a password. Enter it below, or leave blank to
223
+ type it at ssh's prompt in the terminal. Either way it is never stored.
224
+ </div>
225
+ {passwordField}
226
+ </>
227
+ )}
228
+
229
+ {/* Reattach existing sessions (only when we could enumerate them). */}
230
+ {probe.reachable && probe.sessions.length > 0 && (
231
+ <>
232
+ <div className="remote-section-label">REATTACH</div>
233
+ <div className="agent-picker-list">
234
+ {[...probe.sessions]
235
+ .sort((a, b) => (a.startsWith(TMUX_PREFIX) === b.startsWith(TMUX_PREFIX) ? 0 : a.startsWith(TMUX_PREFIX) ? -1 : 1))
236
+ .map((s) => (
237
+ <button
238
+ key={s}
239
+ className="agent-picker-item"
240
+ onClick={() => connect({ kind: 'attach', session: s })}
241
+ >
242
+ <span className="agent-picker-name">{s}</span>
243
+ <span className="agent-picker-desc">attach running tmux session</span>
244
+ </button>
245
+ ))}
246
+ </div>
247
+ </>
248
+ )}
249
+
250
+ <div className="remote-section-label">START</div>
251
+ <div className="agent-picker-list">
252
+ {/* New tmux session — when reachable+tmux, or password host (auto). */}
253
+ {((probe.reachable && probe.tmuxInstalled) || probe.authRequired) && (
254
+ <button
255
+ className="agent-picker-item"
256
+ onClick={() => {
257
+ const session = probe.reachable ? nextSessionName(probe.sessions) : `${TMUX_PREFIX}1`
258
+ connect(probe.reachable
259
+ ? { kind: 'new-tmux', session }
260
+ : { kind: 'auto', session })
261
+ }}
262
+ >
263
+ <span className="agent-picker-name">
264
+ New tmux session ({probe.reachable ? nextSessionName(probe.sessions) : `${TMUX_PREFIX}1`})
265
+ </span>
266
+ <span className="agent-picker-desc">persists across disconnects (recommended)</span>
267
+ </button>
268
+ )}
269
+
270
+ {probe.reachable && !probe.tmuxInstalled && (
271
+ <div className="remote-note">tmux isn't installed on this host — running without it.</div>
272
+ )}
273
+
274
+ <button
275
+ className="agent-picker-item"
276
+ onClick={() => connect({ kind: 'no-tmux' })}
277
+ >
278
+ <span className="agent-picker-name">Run without tmux</span>
279
+ <span className="agent-picker-desc">{agentLabel} directly — won't survive a dropped connection</span>
280
+ </button>
281
+ </div>
282
+
283
+ <button className="remote-link" onClick={() => setStep('hosts')}>← choose another host</button>
284
+ </>
285
+ )}
286
+
287
+ {step === 'error' && (
288
+ <>
289
+ <div className="remote-error">Couldn't connect to <b>{target}</b></div>
290
+ <div className="remote-status-sub">{errorMsg}</div>
291
+ {passwordField}
292
+ <div className="remote-section-label">TRY ANYWAY (INTERACTIVE)</div>
293
+ <div className="agent-picker-list">
294
+ <button
295
+ className="agent-picker-item"
296
+ onClick={() => connect({ kind: 'auto', session: `${TMUX_PREFIX}1` })}
297
+ >
298
+ <span className="agent-picker-name">Connect with tmux</span>
299
+ <span className="agent-picker-desc">ssh will prompt if a password / key passphrase is needed</span>
300
+ </button>
301
+ <button
302
+ className="agent-picker-item"
303
+ onClick={() => connect({ kind: 'no-tmux' })}
304
+ >
305
+ <span className="agent-picker-name">Connect without tmux</span>
306
+ <span className="agent-picker-desc">run {agentLabel} directly</span>
307
+ </button>
308
+ </div>
309
+ <button className="remote-link" onClick={() => setStep('hosts')}>← choose another host</button>
310
+ </>
311
+ )}
312
+ </div>
313
+ </div>
314
+ )
315
+ }
@@ -18,7 +18,10 @@ import {
18
18
 
19
19
  const SAMPLE = 'This is how spoken agent replies will sound.'
20
20
 
21
- export function SpeakControls() {
21
+ // `remote` is true while the active tab is a remote SSH session, where spoken
22
+ // replies can't work: the agent's transcript is written on the remote machine,
23
+ // so there's nothing local to read aloud. The toggle is disabled in that case.
24
+ export function SpeakControls({ remote = false }: { remote?: boolean }) {
22
25
  const [enabled, setEnabled] = useState(false)
23
26
  const [engine, setEngineState] = useState<TtsEngineId>(getEngine)
24
27
  const [voices, setVoices] = useState<SpeechSynthesisVoice[]>([])
@@ -124,7 +127,9 @@ export function SpeakControls() {
124
127
  speech.enqueue(SAMPLE)
125
128
  }, [])
126
129
 
127
- const title = enabled ? 'Spoken replies on — click to mute' : 'Speak agent replies (off)'
130
+ const title = remote
131
+ ? "Spoken replies aren't available for remote SSH sessions — the agent's transcript lives on the remote machine"
132
+ : enabled ? 'Spoken replies on — click to mute' : 'Speak agent replies (off)'
128
133
 
129
134
  return (
130
135
  <div className="speak-controls" ref={wrapRef}>
@@ -133,6 +138,7 @@ export function SpeakControls() {
133
138
  className={`titlebar-btn titlebar-icon-btn speak-btn ${enabled ? 'active' : ''}`}
134
139
  onClick={toggle}
135
140
  title={title}
141
+ disabled={remote}
136
142
  aria-label="Toggle spoken agent replies"
137
143
  aria-pressed={enabled}
138
144
  >
@@ -67,27 +67,72 @@ interface TerminalPanelProps {
67
67
  // Keyed by tabId so each tab has its own xterm + pty listeners.
68
68
  // Survives React re-renders but cleaned up when tab is closed.
69
69
 
70
- const _terminals = new Map<string, {
70
+ type TermState = {
71
71
  terminal: Terminal
72
72
  fitAddon: FitAddon
73
73
  unsubData: (() => void) | null
74
74
  unsubExit: (() => void) | null
75
75
  spawnedAgentId: string | null
76
+ // Follow mode: while true, new output keeps the view pinned to the bottom.
76
77
  pinnedToBottom: boolean
77
- }>()
78
+ // When NOT following, the absolute buffer line held at the top of the
79
+ // viewport — restored after each write so redraws can't drag the view
80
+ // (neither down to the bottom, nor up to the top — the old bug).
81
+ anchorLine: number | null
82
+ // One-shot flag set by a real user scroll gesture (wheel / scroll keys) and
83
+ // consumed by the very next onScroll, so a redraw firing onScroll right after
84
+ // a gesture isn't mistaken for one. Spinner redraws (~10x/s while "thinking")
85
+ // would otherwise be misread as user intent and re-pin to the bottom.
86
+ userScrollPending: boolean
87
+ // Guard so our own programmatic scrolls don't feed back through onScroll.
88
+ holding: boolean
89
+ // Subscribers (the jump-to-latest button) notified when follow mode flips.
90
+ pinListeners: Set<(pinned: boolean) => void>
91
+ }
92
+
93
+ const _terminals = new Map<string, TermState>()
78
94
 
95
+ // Within a couple of lines of the bottom still counts as "at bottom", so a
96
+ // small redraw delta doesn't strand the view just shy of the end.
79
97
  function isAtBottom(terminal: Terminal): boolean {
80
98
  const buf = terminal.buffer.active
81
- return buf.viewportY >= buf.baseY
99
+ return buf.baseY - buf.viewportY <= 2
82
100
  }
83
101
 
84
- function scrollIfPinned(tabId: string, terminal: Terminal) {
85
- const state = _terminals.get(tabId)
86
- if (state?.pinnedToBottom) {
87
- terminal.scrollToBottom()
102
+ function notifyPin(state: TermState): void {
103
+ state.pinListeners.forEach((fn) => fn(state.pinnedToBottom))
104
+ }
105
+
106
+ function setPinned(state: TermState, pinned: boolean): void {
107
+ if (pinned) state.anchorLine = null
108
+ if (state.pinnedToBottom === pinned) return
109
+ state.pinnedToBottom = pinned
110
+ notifyPin(state)
111
+ }
112
+
113
+ // Re-assert the intended scroll position: follow the bottom while pinned,
114
+ // otherwise hold the user's anchored line. Guarded so the resulting onScroll
115
+ // is recognised as ours and ignored. This both keeps output pinned AND stops
116
+ // involuntary redraw jumps (the old "scrolls to the top" bug) from sticking.
117
+ function reassert(state: TermState): void {
118
+ const { terminal } = state
119
+ state.holding = true
120
+ try {
121
+ if (state.pinnedToBottom) {
122
+ terminal.scrollToBottom()
123
+ } else if (state.anchorLine != null && terminal.buffer.active.viewportY !== state.anchorLine) {
124
+ terminal.scrollToLine(state.anchorLine)
125
+ }
126
+ } finally {
127
+ state.holding = false
88
128
  }
89
129
  }
90
130
 
131
+ function markGesture(tabId: string): void {
132
+ const s = _terminals.get(tabId)
133
+ if (s) s.userScrollPending = true
134
+ }
135
+
91
136
  function getOrCreateTerminal(tabId: string, onExit: (tabId: string) => void): { terminal: Terminal; fitAddon: FitAddon } {
92
137
  const existing = _terminals.get(tabId)
93
138
  if (existing) {
@@ -137,6 +182,10 @@ function getOrCreateTerminal(tabId: string, onExit: (tabId: string) => void): {
137
182
  // is running, where right-click is forwarded to the program instead.
138
183
  terminal.attachCustomKeyEventHandler((e) => {
139
184
  if (e.type !== 'keydown') return true
185
+ // Buffer-scroll keys count as a user gesture (see onScroll).
186
+ if (e.key === 'PageUp' || e.key === 'PageDown' || e.key === 'Home' || e.key === 'End') {
187
+ markGesture(tabId)
188
+ }
140
189
  const isCombo = (e.metaKey || e.ctrlKey) && e.shiftKey && e.code === 'KeyE'
141
190
  if (!isCombo) return true
142
191
  e.preventDefault()
@@ -144,32 +193,53 @@ function getOrCreateTerminal(tabId: string, onExit: (tabId: string) => void): {
144
193
  return false // consume — don't forward to the pty
145
194
  })
146
195
 
147
- const state = {
196
+ const state: TermState = {
148
197
  terminal,
149
198
  fitAddon,
150
- unsubData: null as (() => void) | null,
151
- unsubExit: null as (() => void) | null,
152
- spawnedAgentId: null as string | null,
199
+ unsubData: null,
200
+ unsubExit: null,
201
+ spawnedAgentId: null,
153
202
  pinnedToBottom: true,
203
+ anchorLine: null,
204
+ userScrollPending: false,
205
+ holding: false,
206
+ pinListeners: new Set(),
154
207
  }
155
208
 
156
- // Track scroll position
209
+ // Track scroll position. Distinguish a real user scroll (recent wheel/key
210
+ // gesture) from the involuntary viewport jumps TUI redraws cause: trust the
211
+ // former to set follow/anchor; for the latter, re-assert our target so the
212
+ // view neither snaps to the bottom nor jumps to the top.
157
213
  terminal.onScroll(() => {
158
- state.pinnedToBottom = isAtBottom(terminal)
214
+ if (state.holding) return
215
+ if (!state.userScrollPending) {
216
+ // Not tied to a user gesture → an involuntary scroll from a redraw.
217
+ // Hold our target instead of trusting it (this is the spinner case:
218
+ // every screen update would otherwise drag the view back to the bottom).
219
+ reassert(state)
220
+ return
221
+ }
222
+ state.userScrollPending = false
223
+ if (isAtBottom(terminal)) {
224
+ setPinned(state, true)
225
+ } else {
226
+ state.anchorLine = terminal.buffer.active.viewportY
227
+ setPinned(state, false)
228
+ }
159
229
  })
160
230
 
161
- // Send keystrokes to pty
231
+ // Send keystrokes to pty — typing always returns to (and follows) the bottom.
162
232
  terminal.onData((data) => {
163
- state.pinnedToBottom = true
233
+ setPinned(state, true)
164
234
  terminal.scrollToBottom()
165
235
  window.worker.writePty(tabId, data)
166
236
  })
167
237
 
168
- // Receive pty output for this tab
238
+ // Receive pty output for this tab. Re-assert AFTER the write is parsed (the
239
+ // write callback) so we read settled buffer coordinates.
169
240
  state.unsubData = window.worker.onPtyData((eventTabId, data) => {
170
241
  if (eventTabId === tabId) {
171
- terminal.write(data)
172
- scrollIfPinned(tabId, terminal)
242
+ terminal.write(data, () => reassert(state))
173
243
  }
174
244
  })
175
245
 
@@ -206,6 +276,17 @@ export function TerminalPanel({ tabId, agent, onSpawn, onExit, isActive }: Termi
206
276
  const containerRef = useRef<HTMLDivElement>(null)
207
277
  const [menu, setMenu] = useState<{ x: number; y: number } | null>(null)
208
278
  const [toast, setToast] = useState<string | null>(null)
279
+ // Mirrors the terminal's follow-mode; drives the "jump to latest" overlay,
280
+ // which only shows while the user has scrolled up.
281
+ const [followBottom, setFollowBottom] = useState(true)
282
+
283
+ const jumpToLatest = () => {
284
+ const s = _terminals.get(tabId)
285
+ if (!s) return
286
+ setPinned(s, true)
287
+ s.terminal.scrollToBottom()
288
+ s.terminal.focus()
289
+ }
209
290
 
210
291
  // Right-click → offer "Copy as email table", but only when the current
211
292
  // selection actually parses as a table; otherwise fall through to default.
@@ -270,7 +351,13 @@ export function TerminalPanel({ tabId, agent, onSpawn, onExit, isActive }: Termi
270
351
  }
271
352
 
272
353
  fitAddon.fit()
273
- scrollIfPinned(tabId, terminal)
354
+ const attached = _terminals.get(tabId)
355
+ if (attached) reassert(attached)
356
+
357
+ // Wheel over the terminal is an explicit user scroll — stamp it so onScroll
358
+ // treats the resulting move as intentional (not an involuntary redraw jump).
359
+ const onWheel = () => markGesture(tabId)
360
+ terminal.element?.addEventListener('wheel', onWheel, { passive: true })
274
361
 
275
362
  // Resize handling — skip when the container is hidden (display:none → 0x0),
276
363
  // otherwise fit() reports a tiny size and the PTY gets stuck at ~4 cols
@@ -286,7 +373,7 @@ export function TerminalPanel({ tabId, agent, onSpawn, onExit, isActive }: Termi
286
373
  state.fitAddon.fit()
287
374
  const { cols, rows } = state.terminal
288
375
  if (cols < 10 || rows < 5) return
289
- scrollIfPinned(tabId, state.terminal)
376
+ reassert(state)
290
377
  window.worker.resizePty(tabId, cols, rows)
291
378
  }, 150)
292
379
  }
@@ -299,6 +386,7 @@ export function TerminalPanel({ tabId, agent, onSpawn, onExit, isActive }: Termi
299
386
  clearTimeout(resizeTimeout)
300
387
  window.removeEventListener('resize', handleResize)
301
388
  observer.disconnect()
389
+ terminal.element?.removeEventListener('wheel', onWheel)
302
390
  // Do NOT dispose terminal or unsub PTY listeners — they persist across remounts
303
391
  }
304
392
  }, [tabId, onExit])
@@ -317,7 +405,7 @@ export function TerminalPanel({ tabId, agent, onSpawn, onExit, isActive }: Termi
317
405
  const s = _terminals.get(tabId)
318
406
  if (s) {
319
407
  s.fitAddon.fit()
320
- s.pinnedToBottom = true
408
+ setPinned(s, true)
321
409
  s.terminal.scrollToBottom()
322
410
  const { cols, rows } = s.terminal
323
411
  window.worker.resizePty(tabId, cols, rows)
@@ -337,7 +425,7 @@ export function TerminalPanel({ tabId, agent, onSpawn, onExit, isActive }: Termi
337
425
  if (state) {
338
426
  state.fitAddon.fit()
339
427
  state.terminal.focus()
340
- scrollIfPinned(tabId, state.terminal)
428
+ reassert(state)
341
429
  const { cols, rows } = state.terminal
342
430
  window.worker.resizePty(tabId, cols, rows)
343
431
  }
@@ -346,9 +434,32 @@ export function TerminalPanel({ tabId, agent, onSpawn, onExit, isActive }: Termi
346
434
  }
347
435
  }, [isActive, tabId])
348
436
 
437
+ // Subscribe to follow-mode flips so the jump button shows/hides. Runs after
438
+ // the attach effect has created the terminal for this tab.
439
+ useEffect(() => {
440
+ const s = _terminals.get(tabId)
441
+ if (!s) return
442
+ setFollowBottom(s.pinnedToBottom)
443
+ const fn = (p: boolean) => setFollowBottom(p)
444
+ s.pinListeners.add(fn)
445
+ return () => { s.pinListeners.delete(fn) }
446
+ }, [tabId])
447
+
349
448
  return (
350
449
  <>
351
- <div className="terminal-container" ref={containerRef} onContextMenu={handleContextMenu} />
450
+ <div className="terminal-wrap">
451
+ <div className="terminal-container" ref={containerRef} onContextMenu={handleContextMenu} />
452
+ {!followBottom && (
453
+ <button
454
+ type="button"
455
+ className="term-jump-latest"
456
+ onMouseDown={(e) => { e.preventDefault(); jumpToLatest() }}
457
+ title="Jump to latest output"
458
+ >
459
+ ↓ Latest
460
+ </button>
461
+ )}
462
+ </div>
352
463
  {menu && (
353
464
  <div className="term-context-menu" style={{ position: 'fixed', left: menu.x, top: menu.y }}>
354
465
  <button type="button" onMouseDown={(e) => { e.preventDefault(); runConvert() }}>