@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.
- package/out/headless/index.mjs +115 -5
- package/out/headless/index.mjs.map +3 -3
- package/out/main/index.js +316 -8
- package/out/preload/index.js +9 -0
- package/out/renderer/assets/{cssMode-Bmzy9hKX.js → cssMode-D7r66wvO.js} +3 -3
- package/out/renderer/assets/{freemarker2-CDAsExTa.js → freemarker2-jSWJIjPG.js} +1 -1
- package/out/renderer/assets/{handlebars-CvpBYG1L.js → handlebars-BnFyz5RH.js} +1 -1
- package/out/renderer/assets/{html-CNhEQqr8.js → html-Bl5UFavp.js} +1 -1
- package/out/renderer/assets/{htmlMode-DD7YPgga.js → htmlMode-D4VByBT6.js} +3 -3
- package/out/renderer/assets/{index-CGHeDwZa.css → index-Cc_TqOCL.css} +318 -0
- package/out/renderer/assets/{index-BDlCPzCG.js → index-DxhW1emk.js} +558 -85
- package/out/renderer/assets/{javascript-CsGLsswK.js → javascript-CMN1OmwG.js} +2 -2
- package/out/renderer/assets/{jsonMode-CTuGgRvC.js → jsonMode-CJ5AYSPV.js} +3 -3
- package/out/renderer/assets/{liquid-BrrN4uul.js → liquid-BGMIDTEm.js} +1 -1
- package/out/renderer/assets/{lspLanguageFeatures-Bhdn2Eqy.js → lspLanguageFeatures-DGifVYB5.js} +1 -1
- package/out/renderer/assets/{mdx-DJo-gax4.js → mdx-B2Z7R10Q.js} +1 -1
- package/out/renderer/assets/{python-DYHeg5b2.js → python-LMDMSSdn.js} +1 -1
- package/out/renderer/assets/{razor-Cqcks14K.js → razor-BZSrziPu.js} +1 -1
- package/out/renderer/assets/{tsMode-YTLgCayh.js → tsMode-DF9m6_3n.js} +1 -1
- package/out/renderer/assets/{typescript-oxBn_Vcw.js → typescript-rZeCMf0a.js} +1 -1
- package/out/renderer/assets/{xml-BEqGMCTc.js → xml-fNSZ-7so.js} +1 -1
- package/out/renderer/assets/{yaml-BGSrk5HC.js → yaml-1wxIuvzh.js} +1 -1
- package/out/renderer/index.html +2 -2
- package/package.json +1 -1
- package/src/main/agents.ts +9 -1
- package/src/main/ctlsurfApi.ts +7 -0
- package/src/main/index.ts +62 -4
- package/src/main/orchestrator.ts +112 -4
- package/src/main/ssh.ts +348 -0
- package/src/preload/index.ts +26 -0
- package/src/renderer/App.tsx +25 -1
- package/src/renderer/components/AgentPicker.tsx +18 -0
- package/src/renderer/components/ProjectPicker.tsx +121 -0
- package/src/renderer/components/RemoteConnect.tsx +315 -0
- package/src/renderer/components/SpeakControls.tsx +8 -2
- package/src/renderer/components/TerminalPanel.tsx +134 -23
- package/src/renderer/components/TicketPanel.tsx +67 -26
- 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
|
-
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
99
|
+
return buf.baseY - buf.viewportY <= 2
|
|
82
100
|
}
|
|
83
101
|
|
|
84
|
-
function
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
151
|
-
unsubExit: null
|
|
152
|
-
spawnedAgentId: 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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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-
|
|
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() }}>
|