@phenx-inc/ctlsurf 0.13.1 → 0.13.5

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 (39) hide show
  1. package/LICENSE +22 -0
  2. package/out/headless/index.mjs +9 -23
  3. package/out/headless/index.mjs.map +3 -3
  4. package/out/main/index.js +17 -22
  5. package/out/preload/index.js +1 -0
  6. package/out/renderer/assets/{cssMode-KhYyY-BM.js → cssMode-CAEObGNt.js} +3 -3
  7. package/out/renderer/assets/{freemarker2-BFsSgcWk.js → freemarker2-D63hnnDq.js} +1 -1
  8. package/out/renderer/assets/{handlebars-Be6_aBsm.js → handlebars-UYo_IYpi.js} +1 -1
  9. package/out/renderer/assets/{html-G8mvsyfc.js → html-2dvYDi3g.js} +1 -1
  10. package/out/renderer/assets/{htmlMode-Cfd5VCjw.js → htmlMode-B6fxDDPF.js} +3 -3
  11. package/out/renderer/assets/{index-Bi_MjBqs.css → index-BEekQe3-.css} +116 -0
  12. package/out/renderer/assets/{index-MqgBcJzy.js → index-CMb4AqkZ.js} +552 -411
  13. package/out/renderer/assets/{javascript-XyDWi6dC.js → javascript-8RyFDDdk.js} +2 -2
  14. package/out/renderer/assets/{jsonMode-DZT5gBVW.js → jsonMode-CqspkWCb.js} +3 -3
  15. package/out/renderer/assets/{liquid-BuajCNJ9.js → liquid-DaEnAsYY.js} +1 -1
  16. package/out/renderer/assets/{lspLanguageFeatures-BYkg2Yc-.js → lspLanguageFeatures-Czkp5sZb.js} +1 -1
  17. package/out/renderer/assets/{mdx-D0Qx2GMS.js → mdx-Q-qSZsFg.js} +1 -1
  18. package/out/renderer/assets/{python-ChncNy6S.js → python-Sw0S0BJV.js} +1 -1
  19. package/out/renderer/assets/{razor-C6MUtfaS.js → razor-C7zQmabz.js} +1 -1
  20. package/out/renderer/assets/{tsMode-C2aMdzdU.js → tsMode-BWdOxk6v.js} +1 -1
  21. package/out/renderer/assets/{typescript-CB71AtAT.js → typescript-BrdIiKZf.js} +1 -1
  22. package/out/renderer/assets/{xml-tHS1Ec9w.js → xml-DbKLNPOq.js} +1 -1
  23. package/out/renderer/assets/{yaml-CZcp5ueF.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/headless.ts +9 -13
  28. package/src/main/index.ts +6 -0
  29. package/src/main/pty.ts +2 -5
  30. package/src/main/ticketStore.ts +2 -1
  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 +1 -0
  35. package/src/renderer/components/SettingsDialog.tsx +13 -0
  36. package/src/renderer/components/SpeakControls.tsx +5 -0
  37. package/src/renderer/components/TerminalPanel.tsx +151 -30
  38. package/src/renderer/lib/speech.ts +2 -1
  39. package/src/renderer/styles.css +116 -0
@@ -1,5 +1,5 @@
1
- import { conf as conf$1, language as language$1 } from "./typescript-CB71AtAT.js";
2
- import "./index-MqgBcJzy.js";
1
+ import { conf as conf$1, language as language$1 } from "./typescript-BrdIiKZf.js";
2
+ import "./index-CMb4AqkZ.js";
3
3
  const conf = conf$1;
4
4
  const language = {
5
5
  // Set defaultToken to invalid to see what you do not tokenize yet
@@ -1,6 +1,6 @@
1
- import { c as createWebWorker, l as languages, e as editor } from "./index-MqgBcJzy.js";
2
- import { f as DocumentFormattingEditProvider, g as DocumentRangeFormattingEditProvider, C as CompletionAdapter, H as HoverAdapter, b as DocumentSymbolAdapter, d as DocumentColorAdapter, F as FoldingRangeAdapter, S as SelectionRangeAdapter, e as DiagnosticsAdapter } from "./lspLanguageFeatures-BYkg2Yc-.js";
3
- import { a, D, h, R, c, i, j, t, k } from "./lspLanguageFeatures-BYkg2Yc-.js";
1
+ import { c as createWebWorker, l as languages, e as editor } from "./index-CMb4AqkZ.js";
2
+ import { f as DocumentFormattingEditProvider, g as DocumentRangeFormattingEditProvider, C as CompletionAdapter, H as HoverAdapter, b as DocumentSymbolAdapter, d as DocumentColorAdapter, F as FoldingRangeAdapter, S as SelectionRangeAdapter, e as DiagnosticsAdapter } from "./lspLanguageFeatures-Czkp5sZb.js";
3
+ import { a, D, h, R, c, i, j, t, k } from "./lspLanguageFeatures-Czkp5sZb.js";
4
4
  const STOP_WHEN_IDLE_FOR = 2 * 60 * 1e3;
5
5
  class WorkerManager {
6
6
  constructor(defaults) {
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-MqgBcJzy.js";
1
+ import { l as languages } from "./index-CMb4AqkZ.js";
2
2
  const EMPTY_ELEMENTS = [
3
3
  "area",
4
4
  "base",
@@ -1,4 +1,4 @@
1
- import { R as Range$1, l as languages, e as editor, U as Uri, M as MarkerSeverity } from "./index-MqgBcJzy.js";
1
+ import { R as Range$1, l as languages, e as editor, U as Uri, M as MarkerSeverity } from "./index-CMb4AqkZ.js";
2
2
  var DocumentUri;
3
3
  (function(DocumentUri2) {
4
4
  function is(value) {
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-MqgBcJzy.js";
1
+ import { l as languages } from "./index-CMb4AqkZ.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  blockComment: ["{/*", "*/}"]
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-MqgBcJzy.js";
1
+ import { l as languages } from "./index-CMb4AqkZ.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  lineComment: "#",
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-MqgBcJzy.js";
1
+ import { l as languages } from "./index-CMb4AqkZ.js";
2
2
  const EMPTY_ELEMENTS = [
3
3
  "area",
4
4
  "base",
@@ -1,4 +1,4 @@
1
- import { c as createWebWorker, e as editor, U as Uri, a as MarkerTag, M as MarkerSeverity, l as languages, t as typescriptDefaults, R as Range } from "./index-MqgBcJzy.js";
1
+ import { c as createWebWorker, e as editor, U as Uri, a as MarkerTag, M as MarkerSeverity, l as languages, t as typescriptDefaults, R as Range } from "./index-CMb4AqkZ.js";
2
2
  class WorkerManager {
3
3
  constructor(_modeId, _defaults) {
4
4
  this._modeId = _modeId;
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-MqgBcJzy.js";
1
+ import { l as languages } from "./index-CMb4AqkZ.js";
2
2
  const conf = {
3
3
  wordPattern: /(-?\d*\.\d\w*)|([^\`\~\!\@\#\%\^\&\*\(\)\-\=\+\[\{\]\}\\\|\;\:\'\"\,\.\<\>\/\?\s]+)/g,
4
4
  comments: {
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-MqgBcJzy.js";
1
+ import { l as languages } from "./index-CMb4AqkZ.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  blockComment: ["<!--", "-->"]
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-MqgBcJzy.js";
1
+ import { l as languages } from "./index-CMb4AqkZ.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  lineComment: "#"
@@ -49,8 +49,8 @@
49
49
  color: #565f89;
50
50
  }
51
51
  </style>
52
- <script type="module" crossorigin src="./assets/index-MqgBcJzy.js"></script>
53
- <link rel="stylesheet" crossorigin href="./assets/index-Bi_MjBqs.css">
52
+ <script type="module" crossorigin src="./assets/index-CMb4AqkZ.js"></script>
53
+ <link rel="stylesheet" crossorigin href="./assets/index-BEekQe3-.css">
54
54
  </head>
55
55
  <body>
56
56
  <div id="boot-splash">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phenx-inc/ctlsurf",
3
- "version": "0.13.1",
3
+ "version": "0.13.5",
4
4
  "description": "Agent-agnostic terminal and desktop app for ctlsurf — run Claude Code, Codex, or any coding agent with live session logging and remote control",
5
5
  "main": "out/main/index.js",
6
6
  "bin": {
@@ -38,7 +38,7 @@
38
38
  "node-pty",
39
39
  "xterm"
40
40
  ],
41
- "license": "MIT",
41
+ "license": "SEE LICENSE IN LICENSE",
42
42
  "engines": {
43
43
  "node": ">=18"
44
44
  },
@@ -1,6 +1,7 @@
1
1
  import { WorkerWsClient } from './workerWs'
2
2
  import { Terminal as HeadlessTerminal } from '@xterm/headless/lib-headless/xterm-headless.js'
3
3
  import { SerializeAddon } from '@xterm/addon-serialize'
4
+ import { log } from './logger'
4
5
 
5
6
  /**
6
7
  * Conversation Bridge
@@ -36,7 +37,7 @@ export class ConversationBridge {
36
37
  this.inputBuffer = ''
37
38
  this.terminalCapture.reset()
38
39
  this.sessionActive = true
39
- console.log('[bridge] Session started')
40
+ log('[bridge] Session started')
40
41
  }
41
42
 
42
43
  feedOutput(data: string): void {
@@ -137,7 +138,7 @@ export class ConversationBridge {
137
138
  this.sessionActive = false
138
139
  this.inputBuffer = ''
139
140
  this.outputBuffer = ''
140
- console.log('[bridge] Session ended')
141
+ log('[bridge] Session ended')
141
142
  }
142
143
  }
143
144
 
@@ -184,13 +184,15 @@ async function main() {
184
184
  }, 1000)
185
185
  }
186
186
 
187
- // Pipe stdin to PTY, with Ctrl+\ as the exit key
188
- // SGR mouse wheel: \x1b[<64;col;rowM = scroll up, \x1b[<65;col;rowM = scroll down
189
- const SCROLL_UP_RE = /\x1b\[<64;\d+;\d+M/
190
- const SCROLL_DOWN_RE = /\x1b\[<65;\d+;\d+M/
191
- // Use mouse wheel events directly — forward as SGR mouse to the PTY
192
- // so the inner app can handle them natively
193
-
187
+ // Pipe stdin to PTY, with Ctrl+\ as the exit key.
188
+ // The inner agent enables mouse tracking on startup, and that enable
189
+ // sequence is passed through to the host terminal — so the terminal forwards
190
+ // every wheel tick to us as an SGR mouse event (\x1b[<64;col;rowM up,
191
+ // \x1b[<65;col;rowM down) instead of scrolling its own scrollback. Forward
192
+ // those (and all other mouse input) to the PTY so the agent that asked for
193
+ // them can scroll its transcript natively. Dropping them severed scrolling
194
+ // entirely: the terminal won't scroll (it thinks the app owns the mouse) and
195
+ // the app never received the events.
194
196
  if (process.stdin.isTTY) {
195
197
  process.stdin.setRawMode(true)
196
198
  process.stdin.resume()
@@ -201,12 +203,6 @@ async function main() {
201
203
  shutdown()
202
204
  return
203
205
  }
204
- // Drop mouse wheel events — Claude Code doesn't support mouse scrolling.
205
- // Scrolling is handled by the terminal emulator natively when not in
206
- // alternate screen, or not at all in alternate screen (Claude Code's TUI).
207
- if (SCROLL_UP_RE.test(str) || SCROLL_DOWN_RE.test(str)) {
208
- return
209
- }
210
206
  orchestrator.writePty(HEADLESS_TAB, str)
211
207
  })
212
208
  }
package/src/main/index.ts CHANGED
@@ -289,6 +289,12 @@ ipcMain.handle('clipboard:writeTable', (_event, html: string, text: string) => {
289
289
  return { ok: true }
290
290
  })
291
291
 
292
+ // Plain-text clipboard write, for the terminal right-click "Copy" item.
293
+ ipcMain.handle('clipboard:writeText', (_event, text: string) => {
294
+ clipboard.writeText(text)
295
+ return { ok: true }
296
+ })
297
+
292
298
  ipcMain.handle('pty:spawn', async (_event, tabId: string, agent: AgentConfig, cwd: string) => {
293
299
  await orchestrator.spawnAgent(tabId, agent, cwd)
294
300
  return { ok: true }
package/src/main/pty.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createRequire } from 'module'
2
2
  import { AgentConfig } from './agents'
3
+ import { log } from './logger'
3
4
 
4
5
  // Use createRequire to load native module at runtime, bypassing bundler
5
6
  const require = createRequire(import.meta.url)
@@ -14,11 +15,7 @@ export class PtyManager {
14
15
  const shell = agent.command
15
16
  const args = agent.args || []
16
17
 
17
- try {
18
- console.log(`[pty] Spawning: ${shell} ${args.join(' ')} in ${cwd}`)
19
- } catch {
20
- // Ignore EPIPE errors when stdout is closed
21
- }
18
+ log(`[pty] Spawning: ${shell} ${args.join(' ')} in ${cwd}`)
22
19
 
23
20
  this.process = pty.spawn(shell, args, {
24
21
  name: 'xterm-256color',
@@ -1,4 +1,5 @@
1
1
  import type { CtlsurfApi } from './ctlsurfApi'
2
+ import { log as baseLog } from './logger'
2
3
 
3
4
  const DATASTORE_TITLE = 'Tickets'
4
5
  const AGENT_DATASTORE_PAGE_TITLE = 'Agent Datastore'
@@ -47,7 +48,7 @@ export interface Ticket {
47
48
  }
48
49
 
49
50
  function log(...args: unknown[]): void {
50
- try { console.log('[ticket-store]', ...args) } catch { /* EPIPE safe */ }
51
+ baseLog('[ticket-store]', ...args)
51
52
  }
52
53
 
53
54
  function findPageByTitle(pages: any[], title: string): any | null {
@@ -1,6 +1,7 @@
1
1
  import os from 'os'
2
2
  import { randomUUID } from 'crypto'
3
3
  import type { CtlsurfApi } from './ctlsurfApi'
4
+ import { log as baseLog } from './logger'
4
5
 
5
6
  const DATASTORE_TITLE = 'Time Tracking'
6
7
  const AGENT_DATASTORE_PAGE_TITLE = 'Agent Datastore'
@@ -56,7 +57,9 @@ function isSameLocalDay(a: number, b: number): boolean {
56
57
  }
57
58
 
58
59
  function log(...args: unknown[]): void {
59
- try { console.log('[time-tracker]', ...args) } catch { /* EPIPE safe */ }
60
+ // Route through the shared logger so terminal/TUI mode (setSilent) suppresses
61
+ // these — they'd otherwise corrupt the PTY display the agent owns.
62
+ baseLog('[time-tracker]', ...args)
60
63
  }
61
64
 
62
65
  function findPageByTitle(pages: any[], title: string): any | null {
@@ -1,6 +1,7 @@
1
1
  import fs from 'fs'
2
2
  import path from 'path'
3
3
  import os from 'os'
4
+ import { log } from './logger'
4
5
 
5
6
  /**
6
7
  * Transcript Tailer
@@ -80,7 +81,7 @@ export class TranscriptTailer {
80
81
  this.sinceMs = Date.now()
81
82
  this.files.clear()
82
83
  this.pollTimer = setInterval(() => this.poll(), POLL_INTERVAL_MS)
83
- console.log(`[transcripts] Tailing ${this.agentId} transcripts for ${this.cwd}`)
84
+ log(`[transcripts] Tailing ${this.agentId} transcripts for ${this.cwd}`)
84
85
  }
85
86
 
86
87
  stop(): void {
@@ -90,7 +91,7 @@ export class TranscriptTailer {
90
91
  // Final drain so messages written just before exit aren't lost.
91
92
  this.poll()
92
93
  this.files.clear()
93
- console.log('[transcripts] Stopped')
94
+ log('[transcripts] Stopped')
94
95
  }
95
96
 
96
97
  private poll(): void {
@@ -83,6 +83,8 @@ const api = {
83
83
  // Clipboard — write a rich-HTML table (+ plain-text fallback) for email paste.
84
84
  copyEmailTable: (html: string, text: string): Promise<{ ok: boolean }> =>
85
85
  ipcRenderer.invoke('clipboard:writeTable', html, text),
86
+ copyText: (text: string): Promise<{ ok: boolean }> =>
87
+ ipcRenderer.invoke('clipboard:writeText', text),
86
88
  getVersion: (): Promise<string> =>
87
89
  ipcRenderer.invoke('app:getVersion'),
88
90
  getUpdateInfo: (): Promise<{ current: string; latest: string | null; hasUpdate: boolean; checkedAt: number | null }> =>
@@ -88,6 +88,7 @@ declare global {
88
88
  getUpdateInfo: () => Promise<UpdateInfo>
89
89
  onUpdateInfo: (callback: (info: UpdateInfo) => void) => () => void
90
90
  copyEmailTable: (html: string, text: string) => Promise<{ ok: boolean }>
91
+ copyText: (text: string) => Promise<{ ok: boolean }>
91
92
  }
92
93
  }
93
94
  }
@@ -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,22 +4,58 @@ 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
- )
32
+ }
33
+
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) : '')
56
+ }
57
+ while (lines.length && lines[lines.length - 1].trim() === '') lines.pop()
58
+ return lines.join('\n')
23
59
  }
24
60
 
25
61
  // Custom handler for links clicked in the terminal. Every clicked URL is routed
@@ -163,20 +199,33 @@ function getOrCreateTerminal(tabId: string, onExit: (tabId: string) => void): {
163
199
  terminal.loadAddon(fitAddon)
164
200
  terminal.loadAddon(new WebLinksAddon(handleTerminalLink))
165
201
 
166
- // ⌘/Ctrl+Shift+E → convert the current selection to an email table. Handled
167
- // here (not as a DOM listener) so it works even while a mouse-tracking TUI
168
- // 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.
169
206
  terminal.attachCustomKeyEventHandler((e) => {
170
207
  if (e.type !== 'keydown') return true
171
208
  // Buffer-scroll keys count as a user gesture (see onScroll).
172
209
  if (e.key === 'PageUp' || e.key === 'PageDown' || e.key === 'Home' || e.key === 'End') {
173
210
  markGesture(tabId)
174
211
  }
175
- const isCombo = (e.metaKey || e.ctrlKey) && e.shiftKey && e.code === 'KeyE'
176
- if (!isCombo) return true
177
- e.preventDefault()
178
- copySelectionAsEmailTable(terminal)
179
- 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
180
229
  })
181
230
 
182
231
  const state: TermState = {
@@ -261,6 +310,11 @@ export function destroyTerminal(tabId: string): void {
261
310
  export function TerminalPanel({ tabId, agent, onSpawn, onExit, isActive }: TerminalPanelProps) {
262
311
  const containerRef = useRef<HTMLDivElement>(null)
263
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('')
264
318
  const [toast, setToast] = useState<string | null>(null)
265
319
  // Mirrors the terminal's follow-mode; drives the "jump to latest" overlay,
266
320
  // which only shows while the user has scrolled up.
@@ -274,29 +328,60 @@ export function TerminalPanel({ tabId, agent, onSpawn, onExit, isActive }: Termi
274
328
  s.terminal.focus()
275
329
  }
276
330
 
277
- // Right-click → offer "Copy as email table", but only when the current
278
- // 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.
279
333
  const handleContextMenu = (e: React.MouseEvent) => {
280
334
  const sel = _terminals.get(tabId)?.terminal.getSelection() ?? ''
281
- if (!convertToEmailTable(sel)) return
335
+ if (!sel.trim()) return
282
336
  e.preventDefault()
283
337
  setMenu({ x: e.clientX, y: e.clientY })
284
338
  }
285
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
+
286
348
  const runConvert = () => {
287
349
  const term = _terminals.get(tabId)?.terminal
288
350
  if (term) copySelectionAsEmailTable(term)
289
351
  setMenu(null)
290
352
  }
291
353
 
292
- // 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.
293
378
  useEffect(() => {
294
- const onResult = (e: Event) => {
295
- const detail = (e as CustomEvent).detail as { ok: boolean; message: string } | null
296
- 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)
297
382
  }
298
- window.addEventListener('email-table-result', onResult)
299
- return () => window.removeEventListener('email-table-result', onResult)
383
+ window.addEventListener('terminal-toast', onToast)
384
+ return () => window.removeEventListener('terminal-toast', onToast)
300
385
  }, [])
301
386
 
302
387
  useEffect(() => {
@@ -445,11 +530,47 @@ export function TerminalPanel({ tabId, agent, onSpawn, onExit, isActive }: Termi
445
530
  ↓ Latest
446
531
  </button>
447
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
+ )}
448
563
  </div>
449
564
  {menu && (
450
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>
451
569
  <button type="button" onMouseDown={(e) => { e.preventDefault(); runConvert() }}>
452
- Copy as email table
570
+ Convert to table
571
+ </button>
572
+ <button type="button" onMouseDown={(e) => { e.preventDefault(); runSpeak() }}>
573
+ Read it
453
574
  </button>
454
575
  </div>
455
576
  )}
@@ -78,7 +78,8 @@ function splitIntoChunks(text: string): string[] {
78
78
  // ─── Preferences ─────────────────────────────────────
79
79
 
80
80
  export function getEngine(): TtsEngineId {
81
- try { return localStorage.getItem(ENGINE_KEY) === 'neural' ? 'neural' : 'web' } catch { return 'web' }
81
+ // Default to the neural voice; only an explicit 'web' choice opts back out.
82
+ try { return localStorage.getItem(ENGINE_KEY) === 'web' ? 'web' : 'neural' } catch { return 'neural' }
82
83
  }
83
84
  export function setEngine(id: TtsEngineId): void {
84
85
  try { localStorage.setItem(ENGINE_KEY, id) } catch { /* ignore */ }