@phenx-inc/ctlsurf 0.12.1 → 0.13.4
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/LICENSE +22 -0
- package/out/headless/index.mjs +33 -36
- package/out/headless/index.mjs.map +3 -3
- package/out/main/index.js +47 -40
- package/out/preload/index.js +1 -0
- package/out/renderer/assets/{cssMode-D7r66wvO.js → cssMode-CAEObGNt.js} +3 -3
- package/out/renderer/assets/{freemarker2-jSWJIjPG.js → freemarker2-D63hnnDq.js} +1 -1
- package/out/renderer/assets/{handlebars-BnFyz5RH.js → handlebars-UYo_IYpi.js} +1 -1
- package/out/renderer/assets/{html-Bl5UFavp.js → html-2dvYDi3g.js} +1 -1
- package/out/renderer/assets/{htmlMode-D4VByBT6.js → htmlMode-B6fxDDPF.js} +3 -3
- package/out/renderer/assets/{index-Cc_TqOCL.css → index-BEekQe3-.css} +232 -8
- package/out/renderer/assets/{index-DxhW1emk.js → index-CMb4AqkZ.js} +786 -473
- package/out/renderer/assets/{javascript-CMN1OmwG.js → javascript-8RyFDDdk.js} +2 -2
- package/out/renderer/assets/{jsonMode-CJ5AYSPV.js → jsonMode-CqspkWCb.js} +3 -3
- package/out/renderer/assets/{liquid-BGMIDTEm.js → liquid-DaEnAsYY.js} +1 -1
- package/out/renderer/assets/{lspLanguageFeatures-DGifVYB5.js → lspLanguageFeatures-Czkp5sZb.js} +1 -1
- package/out/renderer/assets/{mdx-B2Z7R10Q.js → mdx-Q-qSZsFg.js} +1 -1
- package/out/renderer/assets/{python-LMDMSSdn.js → python-Sw0S0BJV.js} +1 -1
- package/out/renderer/assets/{razor-BZSrziPu.js → razor-C7zQmabz.js} +1 -1
- package/out/renderer/assets/{tsMode-DF9m6_3n.js → tsMode-BWdOxk6v.js} +1 -1
- package/out/renderer/assets/{typescript-rZeCMf0a.js → typescript-BrdIiKZf.js} +1 -1
- package/out/renderer/assets/{xml-fNSZ-7so.js → xml-DbKLNPOq.js} +1 -1
- package/out/renderer/assets/{yaml-1wxIuvzh.js → yaml-ehupJBsZ.js} +1 -1
- package/out/renderer/index.html +2 -2
- package/package.json +2 -2
- package/src/main/bridge.ts +3 -2
- package/src/main/index.ts +15 -0
- package/src/main/orchestrator.ts +1 -1
- package/src/main/pty.ts +2 -5
- package/src/main/ticketStore.ts +28 -21
- package/src/main/timeTracker.ts +4 -1
- package/src/main/transcripts.ts +3 -2
- package/src/preload/index.ts +2 -0
- package/src/renderer/App.tsx +15 -2
- package/src/renderer/components/CtlsurfPanel.tsx +169 -35
- package/src/renderer/components/SettingsDialog.tsx +13 -0
- package/src/renderer/components/SpeakControls.tsx +5 -0
- package/src/renderer/components/TerminalPanel.tsx +155 -48
- package/src/renderer/components/TicketPanel.tsx +80 -24
- package/src/renderer/lib/speech.ts +2 -1
- package/src/renderer/styles.css +232 -8
|
@@ -1,65 +1,199 @@
|
|
|
1
|
-
import { useRef, useEffect, useState } from 'react'
|
|
1
|
+
import { useRef, useEffect, useState, useCallback } from 'react'
|
|
2
2
|
|
|
3
3
|
interface CtlsurfPanelProps {
|
|
4
|
-
//
|
|
5
|
-
//
|
|
4
|
+
// A URL to load (e.g. from a terminal link click). `n` bumps per request so
|
|
5
|
+
// repeat navigations to the same URL still apply.
|
|
6
6
|
navigate?: { url: string; n: number } | null
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
// The <webview> element exposes navigation methods at runtime that aren't in
|
|
10
|
+
// React's typings — narrow the ref to the bits we use.
|
|
11
|
+
type WebviewEl = HTMLElement & {
|
|
12
|
+
loadURL: (url: string) => void
|
|
13
|
+
goBack: () => void
|
|
14
|
+
goForward: () => void
|
|
15
|
+
reload: () => void
|
|
16
|
+
stop: () => void
|
|
17
|
+
canGoBack: () => boolean
|
|
18
|
+
canGoForward: () => boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Strip the noisy auth/embed params so the address bar shows a clean URL.
|
|
22
|
+
function displayUrl(raw: string): string {
|
|
23
|
+
try {
|
|
24
|
+
const u = new URL(raw)
|
|
25
|
+
u.searchParams.delete('_code')
|
|
26
|
+
u.searchParams.delete('embed')
|
|
27
|
+
return u.toString()
|
|
28
|
+
} catch {
|
|
29
|
+
return raw
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
9
33
|
export function CtlsurfPanel({ navigate }: CtlsurfPanelProps) {
|
|
10
|
-
const webviewRef = useRef<
|
|
11
|
-
const [
|
|
12
|
-
const [
|
|
34
|
+
const webviewRef = useRef<WebviewEl | null>(null)
|
|
35
|
+
const [src, setSrc] = useState<string | null>(null) // initial webview src
|
|
36
|
+
const [address, setAddress] = useState('') // editable address-bar text
|
|
37
|
+
const [loading, setLoading] = useState(false)
|
|
38
|
+
const [nav, setNav] = useState({ back: false, forward: false })
|
|
39
|
+
const [key, setKey] = useState(0) // remount on cwd change
|
|
40
|
+
|
|
41
|
+
const hasSrcRef = useRef(false) // has the webview been seeded?
|
|
42
|
+
const readyRef = useRef(false) // is the webview dom-ready?
|
|
43
|
+
const pendingRef = useRef<string | null>(null) // url queued before dom-ready
|
|
44
|
+
|
|
45
|
+
// Resolve a raw URL to an authenticated ctlsurf embed URL when it points at
|
|
46
|
+
// ctlsurf, else return it unchanged.
|
|
47
|
+
const resolve = useCallback(async (raw: string): Promise<string> => {
|
|
48
|
+
try {
|
|
49
|
+
return (await window.worker.getEmbedUrl(raw)) || raw
|
|
50
|
+
} catch {
|
|
51
|
+
return raw
|
|
52
|
+
}
|
|
53
|
+
}, [])
|
|
54
|
+
|
|
55
|
+
// Load a URL into the webview. The first load seeds the `src` attribute (the
|
|
56
|
+
// element doesn't exist yet); later loads call loadURL so in-webview history
|
|
57
|
+
// stays intact for the back/forward buttons.
|
|
58
|
+
const load = useCallback((url: string) => {
|
|
59
|
+
if (!url) return
|
|
60
|
+
setAddress(displayUrl(url))
|
|
61
|
+
if (!hasSrcRef.current) {
|
|
62
|
+
hasSrcRef.current = true
|
|
63
|
+
setSrc(url)
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
const wv = webviewRef.current
|
|
67
|
+
if (wv && readyRef.current) wv.loadURL(url)
|
|
68
|
+
else pendingRef.current = url
|
|
69
|
+
}, [])
|
|
13
70
|
|
|
14
|
-
|
|
71
|
+
// Default-load the project page, preferring the authenticated page URL.
|
|
72
|
+
const loadProject = useCallback(async () => {
|
|
15
73
|
try {
|
|
16
74
|
const info = await window.worker.getWebviewInfo()
|
|
17
|
-
|
|
75
|
+
load(info.pageUrl || info.frontendUrl)
|
|
18
76
|
} catch (err) {
|
|
19
77
|
console.error('[ctlsurf] Failed to get webview info:', err)
|
|
20
|
-
|
|
78
|
+
load('https://app.ctlsurf.com')
|
|
21
79
|
}
|
|
22
|
-
}
|
|
80
|
+
}, [load])
|
|
23
81
|
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
useEffect(() => { if (!navigate?.url)
|
|
82
|
+
// On mount, load the project page unless a navigation is already queued (e.g.
|
|
83
|
+
// the pane was just revealed to show a clicked link).
|
|
84
|
+
useEffect(() => { if (!navigate?.url) loadProject() }, [])
|
|
27
85
|
|
|
28
|
-
// Navigate to a specific
|
|
86
|
+
// Navigate to a specific URL when requested (terminal / external link click).
|
|
29
87
|
useEffect(() => {
|
|
30
|
-
if (navigate?.url)
|
|
31
|
-
setUrl(navigate.url)
|
|
32
|
-
setKey(k => k + 1)
|
|
33
|
-
}
|
|
88
|
+
if (navigate?.url) resolve(navigate.url).then(load)
|
|
34
89
|
}, [navigate?.n])
|
|
35
90
|
|
|
36
|
-
// Reload
|
|
91
|
+
// Reload (remount) when the cwd changes — new project context.
|
|
37
92
|
useEffect(() => {
|
|
38
93
|
const unsub = window.worker.onCwdChanged(() => {
|
|
39
|
-
|
|
94
|
+
hasSrcRef.current = false
|
|
95
|
+
readyRef.current = false
|
|
96
|
+
pendingRef.current = null
|
|
97
|
+
setSrc(null)
|
|
40
98
|
setKey(k => k + 1)
|
|
41
|
-
|
|
99
|
+
loadProject()
|
|
42
100
|
})
|
|
43
101
|
return unsub
|
|
44
|
-
}, [])
|
|
102
|
+
}, [loadProject])
|
|
45
103
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
104
|
+
// Wire webview lifecycle + navigation events to the address bar.
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
const wv = webviewRef.current
|
|
107
|
+
if (!wv) return
|
|
108
|
+
const syncNav = () =>
|
|
109
|
+
setNav({ back: wv.canGoBack?.() ?? false, forward: wv.canGoForward?.() ?? false })
|
|
110
|
+
const onReady = () => {
|
|
111
|
+
readyRef.current = true
|
|
112
|
+
if (pendingRef.current) {
|
|
113
|
+
wv.loadURL(pendingRef.current)
|
|
114
|
+
pendingRef.current = null
|
|
115
|
+
}
|
|
116
|
+
syncNav()
|
|
117
|
+
}
|
|
118
|
+
const onNavigate = (e: Event) => {
|
|
119
|
+
const url = (e as Event & { url?: string }).url
|
|
120
|
+
if (url) setAddress(displayUrl(url))
|
|
121
|
+
syncNav()
|
|
122
|
+
}
|
|
123
|
+
const onStart = () => setLoading(true)
|
|
124
|
+
const onStop = () => { setLoading(false); syncNav() }
|
|
125
|
+
wv.addEventListener('dom-ready', onReady)
|
|
126
|
+
wv.addEventListener('did-navigate', onNavigate)
|
|
127
|
+
wv.addEventListener('did-navigate-in-page', onNavigate)
|
|
128
|
+
wv.addEventListener('did-start-loading', onStart)
|
|
129
|
+
wv.addEventListener('did-stop-loading', onStop)
|
|
130
|
+
return () => {
|
|
131
|
+
wv.removeEventListener('dom-ready', onReady)
|
|
132
|
+
wv.removeEventListener('did-navigate', onNavigate)
|
|
133
|
+
wv.removeEventListener('did-navigate-in-page', onNavigate)
|
|
134
|
+
wv.removeEventListener('did-start-loading', onStart)
|
|
135
|
+
wv.removeEventListener('did-stop-loading', onStop)
|
|
136
|
+
}
|
|
137
|
+
}, [src, key])
|
|
138
|
+
|
|
139
|
+
const onSubmit = (e: React.FormEvent) => {
|
|
140
|
+
e.preventDefault()
|
|
141
|
+
let target = address.trim()
|
|
142
|
+
if (!target) return
|
|
143
|
+
// Bare hostnames (no scheme) default to https.
|
|
144
|
+
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(target)) target = 'https://' + target
|
|
145
|
+
resolve(target).then(load)
|
|
52
146
|
}
|
|
53
147
|
|
|
54
148
|
return (
|
|
55
149
|
<div className="ctlsurf-panel">
|
|
56
|
-
<
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
150
|
+
<div className="ctlsurf-addressbar">
|
|
151
|
+
<button
|
|
152
|
+
type="button"
|
|
153
|
+
className="ctlsurf-nav-btn"
|
|
154
|
+
title="Back"
|
|
155
|
+
disabled={!nav.back}
|
|
156
|
+
onClick={() => webviewRef.current?.goBack?.()}
|
|
157
|
+
>‹</button>
|
|
158
|
+
<button
|
|
159
|
+
type="button"
|
|
160
|
+
className="ctlsurf-nav-btn"
|
|
161
|
+
title="Forward"
|
|
162
|
+
disabled={!nav.forward}
|
|
163
|
+
onClick={() => webviewRef.current?.goForward?.()}
|
|
164
|
+
>›</button>
|
|
165
|
+
<button
|
|
166
|
+
type="button"
|
|
167
|
+
className="ctlsurf-nav-btn"
|
|
168
|
+
title={loading ? 'Stop' : 'Reload'}
|
|
169
|
+
onClick={() => (loading ? webviewRef.current?.stop?.() : webviewRef.current?.reload?.())}
|
|
170
|
+
>{loading ? '×' : '⟳'}</button>
|
|
171
|
+
<form className="ctlsurf-address-form" onSubmit={onSubmit}>
|
|
172
|
+
<input
|
|
173
|
+
className="ctlsurf-address-input"
|
|
174
|
+
value={address}
|
|
175
|
+
spellCheck={false}
|
|
176
|
+
placeholder="Enter a URL"
|
|
177
|
+
onChange={e => setAddress(e.target.value)}
|
|
178
|
+
onFocus={e => e.currentTarget.select()}
|
|
179
|
+
/>
|
|
180
|
+
</form>
|
|
181
|
+
</div>
|
|
182
|
+
<div className="ctlsurf-webview-host">
|
|
183
|
+
{src ? (
|
|
184
|
+
<webview
|
|
185
|
+
key={key}
|
|
186
|
+
ref={webviewRef as any}
|
|
187
|
+
src={src}
|
|
188
|
+
style={{ width: '100%', height: '100%' }}
|
|
189
|
+
allowpopups={'true' as any}
|
|
190
|
+
/>
|
|
191
|
+
) : (
|
|
192
|
+
<div className="ctlsurf-webview-loading">
|
|
193
|
+
<span style={{ color: '#565f89', fontSize: '13px' }}>Loading ctlsurf...</span>
|
|
194
|
+
</div>
|
|
195
|
+
)}
|
|
196
|
+
</div>
|
|
63
197
|
</div>
|
|
64
198
|
)
|
|
65
199
|
}
|
|
@@ -33,6 +33,7 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
|
|
|
33
33
|
const [trackTime, setTrackTime] = useState(false)
|
|
34
34
|
const [idleTimeoutMin, setIdleTimeoutMin] = useState(15)
|
|
35
35
|
const [saved, setSaved] = useState(false)
|
|
36
|
+
const [version, setVersion] = useState('')
|
|
36
37
|
|
|
37
38
|
const loadProfiles = async () => {
|
|
38
39
|
const data = await window.worker.listProfiles() as ProfileListResponse
|
|
@@ -45,6 +46,7 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
|
|
|
45
46
|
loadProfiles()
|
|
46
47
|
setEditingId(null)
|
|
47
48
|
setSaved(false)
|
|
49
|
+
window.worker.getVersion().then(setVersion).catch(() => { /* ignore */ })
|
|
48
50
|
}
|
|
49
51
|
}, [open])
|
|
50
52
|
|
|
@@ -235,6 +237,17 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
|
|
|
235
237
|
</div>
|
|
236
238
|
</div>
|
|
237
239
|
)}
|
|
240
|
+
|
|
241
|
+
<div className="settings-section settings-about">
|
|
242
|
+
<h4>About</h4>
|
|
243
|
+
<div className="about-name">ctlsurf{version ? ` · v${version}` : ''}</div>
|
|
244
|
+
<p className="about-copyright">© 2026 Phenx, Inc. All rights reserved.</p>
|
|
245
|
+
<p className="settings-hint">
|
|
246
|
+
Proprietary and confidential. No license to use, copy, modify, or
|
|
247
|
+
distribute is granted except under a separate written agreement with
|
|
248
|
+
Phenx, Inc.
|
|
249
|
+
</p>
|
|
250
|
+
</div>
|
|
238
251
|
</div>
|
|
239
252
|
</div>
|
|
240
253
|
)
|
|
@@ -40,6 +40,11 @@ export function SpeakControls({ remote = false }: { remote?: boolean }) {
|
|
|
40
40
|
return () => { alive = false }
|
|
41
41
|
}, [])
|
|
42
42
|
|
|
43
|
+
// Neural is the default engine; preload its model in the background on launch
|
|
44
|
+
// so the first spoken text isn't stalled by the one-time download. No-op when
|
|
45
|
+
// the user has explicitly switched to the system voice.
|
|
46
|
+
useEffect(() => { speech.warmup() }, [])
|
|
47
|
+
|
|
43
48
|
// Speak replies as they arrive. Main only forwards when the setting is on, so
|
|
44
49
|
// this is a no-op while disabled.
|
|
45
50
|
useEffect(() => {
|
|
@@ -4,47 +4,69 @@ import { FitAddon } from '@xterm/addon-fit'
|
|
|
4
4
|
import { WebLinksAddon } from '@xterm/addon-web-links'
|
|
5
5
|
import '@xterm/xterm/css/xterm.css'
|
|
6
6
|
import { convertToEmailTable } from '../lib/tableToHtml'
|
|
7
|
+
import { speech } from '../lib/speech'
|
|
7
8
|
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
// Show a transient toast in the active terminal panel. Broadcast as a window
|
|
10
|
+
// event so module-level helpers (which have no component scope) can trigger it.
|
|
11
|
+
function showTerminalToast(message: string): void {
|
|
12
|
+
window.dispatchEvent(new CustomEvent('terminal-toast', { detail: { message } }))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// ─── Text actions — operate on a plain string, which may be an xterm
|
|
16
|
+
// selection (menu/shortcuts) or a DOM selection from the snapshot panel ───
|
|
17
|
+
|
|
18
|
+
function copyTextValue(text: string): void {
|
|
19
|
+
if (!text) return
|
|
20
|
+
void window.worker.copyText(text)
|
|
21
|
+
showTerminalToast('Copied to clipboard')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function convertTextToTable(text: string): void {
|
|
25
|
+
const result = convertToEmailTable(text)
|
|
13
26
|
if (result) {
|
|
14
27
|
window.worker.copyEmailTable(result.html, result.text)
|
|
28
|
+
showTerminalToast(`Copied ${result.rows}×${result.cols} table to clipboard`)
|
|
29
|
+
} else {
|
|
30
|
+
showTerminalToast('No table found in selection')
|
|
15
31
|
}
|
|
16
|
-
window.dispatchEvent(
|
|
17
|
-
new CustomEvent('email-table-result', {
|
|
18
|
-
detail: result
|
|
19
|
-
? { ok: true, message: `Copied ${result.rows}×${result.cols} table to clipboard` }
|
|
20
|
-
: { ok: false, message: 'No table found in selection' },
|
|
21
|
-
})
|
|
22
|
-
)
|
|
23
32
|
}
|
|
24
33
|
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
function
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
34
|
+
// unlock() is called from the triggering gesture so the neural voice isn't
|
|
35
|
+
// autoplay-blocked.
|
|
36
|
+
function speakTextValue(text: string): void {
|
|
37
|
+
if (!text.trim()) return
|
|
38
|
+
speech.unlock()
|
|
39
|
+
speech.enqueue(text)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// xterm-selection wrappers for the right-click menu and keyboard shortcuts.
|
|
43
|
+
function copySelectionPlain(terminal: Terminal): void { copyTextValue(terminal.getSelection()) }
|
|
44
|
+
function copySelectionAsEmailTable(terminal: Terminal): void { convertTextToTable(terminal.getSelection()) }
|
|
45
|
+
function speakSelection(terminal: Terminal): void { speakTextValue(terminal.getSelection()) }
|
|
46
|
+
|
|
47
|
+
// Snapshot the visible screen as plain text. xterm selections don't survive a
|
|
48
|
+
// repainting TUI (Claude Code), so the snapshot panel freezes this text into a
|
|
49
|
+
// static, normally-selectable surface instead.
|
|
50
|
+
function captureVisibleText(terminal: Terminal): string {
|
|
51
|
+
const buf = terminal.buffer.active
|
|
52
|
+
const lines: string[] = []
|
|
53
|
+
for (let i = 0; i < terminal.rows; i++) {
|
|
54
|
+
const line = buf.getLine(buf.viewportY + i)
|
|
55
|
+
lines.push(line ? line.translateToString(true) : '')
|
|
32
56
|
}
|
|
57
|
+
while (lines.length && lines[lines.length - 1].trim() === '') lines.pop()
|
|
58
|
+
return lines.join('\n')
|
|
33
59
|
}
|
|
34
60
|
|
|
35
|
-
// Custom handler for links clicked in the terminal.
|
|
36
|
-
//
|
|
37
|
-
//
|
|
61
|
+
// Custom handler for links clicked in the terminal. Every clicked URL is routed
|
|
62
|
+
// into the embedded ctlsurf browser panel via a window event: ctlsurf links are
|
|
63
|
+
// first resolved to an authenticated embed URL, anything else loads as-is.
|
|
38
64
|
function handleTerminalLink(_event: MouseEvent, uri: string): void {
|
|
65
|
+
const openInPanel = (url: string) =>
|
|
66
|
+
window.dispatchEvent(new CustomEvent('ctlsurf-open-url', { detail: { url } }))
|
|
39
67
|
window.worker.getEmbedUrl(uri)
|
|
40
|
-
.then((embedUrl) =>
|
|
41
|
-
|
|
42
|
-
window.dispatchEvent(new CustomEvent('ctlsurf-open-url', { detail: { url: embedUrl } }))
|
|
43
|
-
} else {
|
|
44
|
-
openExternal(uri)
|
|
45
|
-
}
|
|
46
|
-
})
|
|
47
|
-
.catch(() => openExternal(uri))
|
|
68
|
+
.then((embedUrl) => openInPanel(embedUrl || uri))
|
|
69
|
+
.catch(() => openInPanel(uri))
|
|
48
70
|
}
|
|
49
71
|
|
|
50
72
|
interface AgentConfig {
|
|
@@ -177,20 +199,33 @@ function getOrCreateTerminal(tabId: string, onExit: (tabId: string) => void): {
|
|
|
177
199
|
terminal.loadAddon(fitAddon)
|
|
178
200
|
terminal.loadAddon(new WebLinksAddon(handleTerminalLink))
|
|
179
201
|
|
|
180
|
-
// ⌘/Ctrl+Shift+E →
|
|
181
|
-
// here (not as
|
|
182
|
-
// is running, where right-click
|
|
202
|
+
// Selection actions via ⌘/Ctrl+Shift+[C|E|S] → copy / convert-to-table /
|
|
203
|
+
// read-aloud. Handled here (not as DOM listeners) so they work even while a
|
|
204
|
+
// mouse-tracking TUI is running, where right-click and mouse selection are
|
|
205
|
+
// forwarded to the program instead.
|
|
183
206
|
terminal.attachCustomKeyEventHandler((e) => {
|
|
184
207
|
if (e.type !== 'keydown') return true
|
|
185
208
|
// Buffer-scroll keys count as a user gesture (see onScroll).
|
|
186
209
|
if (e.key === 'PageUp' || e.key === 'PageDown' || e.key === 'Home' || e.key === 'End') {
|
|
187
210
|
markGesture(tabId)
|
|
188
211
|
}
|
|
189
|
-
const
|
|
190
|
-
if (
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
212
|
+
const mod = (e.metaKey || e.ctrlKey) && e.shiftKey
|
|
213
|
+
if (mod && e.code === 'KeyE') {
|
|
214
|
+
e.preventDefault()
|
|
215
|
+
copySelectionAsEmailTable(terminal)
|
|
216
|
+
return false // consume — don't forward to the pty
|
|
217
|
+
}
|
|
218
|
+
if (mod && e.code === 'KeyC' && terminal.hasSelection()) {
|
|
219
|
+
e.preventDefault()
|
|
220
|
+
copySelectionPlain(terminal)
|
|
221
|
+
return false
|
|
222
|
+
}
|
|
223
|
+
if (mod && e.code === 'KeyS' && terminal.getSelection().trim()) {
|
|
224
|
+
e.preventDefault()
|
|
225
|
+
speakSelection(terminal)
|
|
226
|
+
return false
|
|
227
|
+
}
|
|
228
|
+
return true
|
|
194
229
|
})
|
|
195
230
|
|
|
196
231
|
const state: TermState = {
|
|
@@ -275,6 +310,11 @@ export function destroyTerminal(tabId: string): void {
|
|
|
275
310
|
export function TerminalPanel({ tabId, agent, onSpawn, onExit, isActive }: TerminalPanelProps) {
|
|
276
311
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
277
312
|
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null)
|
|
313
|
+
// Snapshot panel: the ⋮ button freezes the visible screen text into a static,
|
|
314
|
+
// selectable overlay where Copy/Table/Speak work even over a repainting TUI
|
|
315
|
+
// (which wipes live xterm selections). snapTextRef holds the frozen text.
|
|
316
|
+
const [snapOpen, setSnapOpen] = useState(false)
|
|
317
|
+
const snapTextRef = useRef('')
|
|
278
318
|
const [toast, setToast] = useState<string | null>(null)
|
|
279
319
|
// Mirrors the terminal's follow-mode; drives the "jump to latest" overlay,
|
|
280
320
|
// which only shows while the user has scrolled up.
|
|
@@ -288,29 +328,60 @@ export function TerminalPanel({ tabId, agent, onSpawn, onExit, isActive }: Termi
|
|
|
288
328
|
s.terminal.focus()
|
|
289
329
|
}
|
|
290
330
|
|
|
291
|
-
// Right-click
|
|
292
|
-
//
|
|
331
|
+
// Right-click on a non-empty selection → show Copy / Convert to table /
|
|
332
|
+
// Read it. With nothing selected we let the native menu through.
|
|
293
333
|
const handleContextMenu = (e: React.MouseEvent) => {
|
|
294
334
|
const sel = _terminals.get(tabId)?.terminal.getSelection() ?? ''
|
|
295
|
-
if (!
|
|
335
|
+
if (!sel.trim()) return
|
|
296
336
|
e.preventDefault()
|
|
297
337
|
setMenu({ x: e.clientX, y: e.clientY })
|
|
298
338
|
}
|
|
299
339
|
|
|
340
|
+
// Right-click menu actions (act on the live xterm selection — only reliable
|
|
341
|
+
// over static output / a plain shell, not a repainting TUI).
|
|
342
|
+
const runCopy = () => {
|
|
343
|
+
const term = _terminals.get(tabId)?.terminal
|
|
344
|
+
if (term) copySelectionPlain(term)
|
|
345
|
+
setMenu(null)
|
|
346
|
+
}
|
|
347
|
+
|
|
300
348
|
const runConvert = () => {
|
|
301
349
|
const term = _terminals.get(tabId)?.terminal
|
|
302
350
|
if (term) copySelectionAsEmailTable(term)
|
|
303
351
|
setMenu(null)
|
|
304
352
|
}
|
|
305
353
|
|
|
306
|
-
|
|
354
|
+
const runSpeak = () => {
|
|
355
|
+
const term = _terminals.get(tabId)?.terminal
|
|
356
|
+
if (term) speakSelection(term)
|
|
357
|
+
setMenu(null)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Snapshot panel: freeze the visible screen, then act on the user's DOM
|
|
361
|
+
// selection within it (or the whole snapshot if nothing is selected).
|
|
362
|
+
const openSnapshot = () => {
|
|
363
|
+
const term = _terminals.get(tabId)?.terminal
|
|
364
|
+
snapTextRef.current = term ? captureVisibleText(term) : ''
|
|
365
|
+
setSnapOpen(true)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const snapAction = (kind: 'copy' | 'table' | 'speak') => {
|
|
369
|
+
const domSel = window.getSelection()?.toString() ?? ''
|
|
370
|
+
const text = domSel.trim() ? domSel : snapTextRef.current
|
|
371
|
+
if (kind === 'copy') copyTextValue(text)
|
|
372
|
+
else if (kind === 'table') convertTextToTable(text)
|
|
373
|
+
else speakTextValue(text)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Show a transient toast on a terminal action (copy / table), fired by the
|
|
377
|
+
// module-level helpers as a window event.
|
|
307
378
|
useEffect(() => {
|
|
308
|
-
const
|
|
309
|
-
const detail = (e as CustomEvent).detail as {
|
|
310
|
-
if (detail) setToast(detail.message)
|
|
379
|
+
const onToast = (e: Event) => {
|
|
380
|
+
const detail = (e as CustomEvent).detail as { message?: string } | null
|
|
381
|
+
if (detail?.message) setToast(detail.message)
|
|
311
382
|
}
|
|
312
|
-
window.addEventListener('
|
|
313
|
-
return () => window.removeEventListener('
|
|
383
|
+
window.addEventListener('terminal-toast', onToast)
|
|
384
|
+
return () => window.removeEventListener('terminal-toast', onToast)
|
|
314
385
|
}, [])
|
|
315
386
|
|
|
316
387
|
useEffect(() => {
|
|
@@ -459,11 +530,47 @@ export function TerminalPanel({ tabId, agent, onSpawn, onExit, isActive }: Termi
|
|
|
459
530
|
↓ Latest
|
|
460
531
|
</button>
|
|
461
532
|
)}
|
|
533
|
+
<button
|
|
534
|
+
type="button"
|
|
535
|
+
className="term-util-btn"
|
|
536
|
+
onMouseDown={(e) => { e.preventDefault(); openSnapshot() }}
|
|
537
|
+
title="Snapshot screen → copy / convert to table / read aloud"
|
|
538
|
+
>
|
|
539
|
+
⋮
|
|
540
|
+
</button>
|
|
541
|
+
{snapOpen && (
|
|
542
|
+
<div className="term-snapshot">
|
|
543
|
+
<div className="term-snapshot-head">
|
|
544
|
+
<span>Select text below (or select nothing to use everything), then pick an action.</span>
|
|
545
|
+
<button
|
|
546
|
+
type="button"
|
|
547
|
+
className="term-snapshot-x"
|
|
548
|
+
onMouseDown={(e) => { e.preventDefault(); setSnapOpen(false) }}
|
|
549
|
+
title="Close"
|
|
550
|
+
>
|
|
551
|
+
✕
|
|
552
|
+
</button>
|
|
553
|
+
</div>
|
|
554
|
+
<pre className="term-snapshot-text">{snapTextRef.current}</pre>
|
|
555
|
+
<div className="term-snapshot-actions">
|
|
556
|
+
<button type="button" onMouseDown={(e) => { e.preventDefault(); snapAction('copy') }}>⧉ Copy</button>
|
|
557
|
+
<button type="button" onMouseDown={(e) => { e.preventDefault(); snapAction('table') }}>▦ Convert to table</button>
|
|
558
|
+
<button type="button" onMouseDown={(e) => { e.preventDefault(); snapAction('speak') }}>🔊 Read aloud</button>
|
|
559
|
+
<button type="button" className="term-snapshot-done" onMouseDown={(e) => { e.preventDefault(); setSnapOpen(false) }}>Close</button>
|
|
560
|
+
</div>
|
|
561
|
+
</div>
|
|
562
|
+
)}
|
|
462
563
|
</div>
|
|
463
564
|
{menu && (
|
|
464
565
|
<div className="term-context-menu" style={{ position: 'fixed', left: menu.x, top: menu.y }}>
|
|
566
|
+
<button type="button" onMouseDown={(e) => { e.preventDefault(); runCopy() }}>
|
|
567
|
+
Copy
|
|
568
|
+
</button>
|
|
465
569
|
<button type="button" onMouseDown={(e) => { e.preventDefault(); runConvert() }}>
|
|
466
|
-
|
|
570
|
+
Convert to table
|
|
571
|
+
</button>
|
|
572
|
+
<button type="button" onMouseDown={(e) => { e.preventDefault(); runSpeak() }}>
|
|
573
|
+
Read it
|
|
467
574
|
</button>
|
|
468
575
|
</div>
|
|
469
576
|
)}
|