@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.
Files changed (41) hide show
  1. package/LICENSE +22 -0
  2. package/out/headless/index.mjs +33 -36
  3. package/out/headless/index.mjs.map +3 -3
  4. package/out/main/index.js +47 -40
  5. package/out/preload/index.js +1 -0
  6. package/out/renderer/assets/{cssMode-D7r66wvO.js → cssMode-CAEObGNt.js} +3 -3
  7. package/out/renderer/assets/{freemarker2-jSWJIjPG.js → freemarker2-D63hnnDq.js} +1 -1
  8. package/out/renderer/assets/{handlebars-BnFyz5RH.js → handlebars-UYo_IYpi.js} +1 -1
  9. package/out/renderer/assets/{html-Bl5UFavp.js → html-2dvYDi3g.js} +1 -1
  10. package/out/renderer/assets/{htmlMode-D4VByBT6.js → htmlMode-B6fxDDPF.js} +3 -3
  11. package/out/renderer/assets/{index-Cc_TqOCL.css → index-BEekQe3-.css} +232 -8
  12. package/out/renderer/assets/{index-DxhW1emk.js → index-CMb4AqkZ.js} +786 -473
  13. package/out/renderer/assets/{javascript-CMN1OmwG.js → javascript-8RyFDDdk.js} +2 -2
  14. package/out/renderer/assets/{jsonMode-CJ5AYSPV.js → jsonMode-CqspkWCb.js} +3 -3
  15. package/out/renderer/assets/{liquid-BGMIDTEm.js → liquid-DaEnAsYY.js} +1 -1
  16. package/out/renderer/assets/{lspLanguageFeatures-DGifVYB5.js → lspLanguageFeatures-Czkp5sZb.js} +1 -1
  17. package/out/renderer/assets/{mdx-B2Z7R10Q.js → mdx-Q-qSZsFg.js} +1 -1
  18. package/out/renderer/assets/{python-LMDMSSdn.js → python-Sw0S0BJV.js} +1 -1
  19. package/out/renderer/assets/{razor-BZSrziPu.js → razor-C7zQmabz.js} +1 -1
  20. package/out/renderer/assets/{tsMode-DF9m6_3n.js → tsMode-BWdOxk6v.js} +1 -1
  21. package/out/renderer/assets/{typescript-rZeCMf0a.js → typescript-BrdIiKZf.js} +1 -1
  22. package/out/renderer/assets/{xml-fNSZ-7so.js → xml-DbKLNPOq.js} +1 -1
  23. package/out/renderer/assets/{yaml-1wxIuvzh.js → yaml-ehupJBsZ.js} +1 -1
  24. package/out/renderer/index.html +2 -2
  25. package/package.json +2 -2
  26. package/src/main/bridge.ts +3 -2
  27. package/src/main/index.ts +15 -0
  28. package/src/main/orchestrator.ts +1 -1
  29. package/src/main/pty.ts +2 -5
  30. package/src/main/ticketStore.ts +28 -21
  31. package/src/main/timeTracker.ts +4 -1
  32. package/src/main/transcripts.ts +3 -2
  33. package/src/preload/index.ts +2 -0
  34. package/src/renderer/App.tsx +15 -2
  35. package/src/renderer/components/CtlsurfPanel.tsx +169 -35
  36. package/src/renderer/components/SettingsDialog.tsx +13 -0
  37. package/src/renderer/components/SpeakControls.tsx +5 -0
  38. package/src/renderer/components/TerminalPanel.tsx +155 -48
  39. package/src/renderer/components/TicketPanel.tsx +80 -24
  40. package/src/renderer/lib/speech.ts +2 -1
  41. 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
- // An authenticated ctlsurf URL to load (e.g. from a terminal link click).
5
- // `n` bumps per request so repeat navigations to the same URL still apply.
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<HTMLWebViewElement>(null)
11
- const [url, setUrl] = useState<string | null>(null)
12
- const [key, setKey] = useState(0) // force remount on cwd change / navigation
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
- const loadUrl = async () => {
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
- setUrl(info.pageUrl || info.frontendUrl)
75
+ load(info.pageUrl || info.frontendUrl)
18
76
  } catch (err) {
19
77
  console.error('[ctlsurf] Failed to get webview info:', err)
20
- setUrl('https://app.ctlsurf.com')
78
+ load('https://app.ctlsurf.com')
21
79
  }
22
- }
80
+ }, [load])
23
81
 
24
- // Default-load the project page on mount, unless a navigation is already
25
- // queued (e.g. the pane was just revealed to show a clicked link).
26
- useEffect(() => { if (!navigate?.url) loadUrl() }, [])
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 authenticated URL when requested.
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 webview when cwd changes
91
+ // Reload (remount) when the cwd changes — new project context.
37
92
  useEffect(() => {
38
93
  const unsub = window.worker.onCwdChanged(() => {
39
- setUrl(null)
94
+ hasSrcRef.current = false
95
+ readyRef.current = false
96
+ pendingRef.current = null
97
+ setSrc(null)
40
98
  setKey(k => k + 1)
41
- loadUrl()
99
+ loadProject()
42
100
  })
43
101
  return unsub
44
- }, [])
102
+ }, [loadProject])
45
103
 
46
- if (!url) {
47
- return (
48
- <div className="ctlsurf-panel" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
49
- <span style={{ color: '#565f89', fontSize: '13px' }}>Loading ctlsurf...</span>
50
- </div>
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
- <webview
57
- key={key}
58
- ref={webviewRef as any}
59
- src={url}
60
- style={{ width: '100%', height: '100%' }}
61
- allowpopups={'true' as any}
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
- // Convert the terminal's current selection into a rich HTML table on the
9
- // clipboard. Broadcasts an `email-table-result` event so the active panel can
10
- // show a toast. Reused by both the hotkey and the right-click menu.
11
- function copySelectionAsEmailTable(terminal: Terminal): void {
12
- const result = convertToEmailTable(terminal.getSelection())
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
- // Open a URL in the system browser, mirroring WebLinksAddon's safe default
26
- // (clear the opener so the new page can't reach back into this window).
27
- function openExternal(uri: string): void {
28
- const win = window.open()
29
- if (win) {
30
- try { (win as { opener: unknown }).opener = null } catch { /* ignore */ }
31
- win.location.href = uri
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. ctlsurf page links are
36
- // routed into the embedded (authenticated) ctlsurf panel via a window event;
37
- // everything else opens in the default browser as before.
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
- if (embedUrl) {
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 → convert the current selection to an email table. Handled
181
- // here (not as a DOM listener) so it works even while a mouse-tracking TUI
182
- // is running, where right-click is forwarded to the program instead.
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 isCombo = (e.metaKey || e.ctrlKey) && e.shiftKey && e.code === 'KeyE'
190
- if (!isCombo) return true
191
- e.preventDefault()
192
- copySelectionAsEmailTable(terminal)
193
- return false // consume — don't forward to the pty
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 → offer "Copy as email table", but only when the current
292
- // selection actually parses as a table; otherwise fall through to default.
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 (!convertToEmailTable(sel)) return
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
- // Show a transient toast when a conversion happens (hotkey or menu).
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 onResult = (e: Event) => {
309
- const detail = (e as CustomEvent).detail as { ok: boolean; message: string } | null
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('email-table-result', onResult)
313
- return () => window.removeEventListener('email-table-result', onResult)
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
- Copy as email table
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
  )}