@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.
- package/LICENSE +22 -0
- package/out/headless/index.mjs +9 -23
- package/out/headless/index.mjs.map +3 -3
- package/out/main/index.js +17 -22
- package/out/preload/index.js +1 -0
- package/out/renderer/assets/{cssMode-KhYyY-BM.js → cssMode-CAEObGNt.js} +3 -3
- package/out/renderer/assets/{freemarker2-BFsSgcWk.js → freemarker2-D63hnnDq.js} +1 -1
- package/out/renderer/assets/{handlebars-Be6_aBsm.js → handlebars-UYo_IYpi.js} +1 -1
- package/out/renderer/assets/{html-G8mvsyfc.js → html-2dvYDi3g.js} +1 -1
- package/out/renderer/assets/{htmlMode-Cfd5VCjw.js → htmlMode-B6fxDDPF.js} +3 -3
- package/out/renderer/assets/{index-Bi_MjBqs.css → index-BEekQe3-.css} +116 -0
- package/out/renderer/assets/{index-MqgBcJzy.js → index-CMb4AqkZ.js} +552 -411
- package/out/renderer/assets/{javascript-XyDWi6dC.js → javascript-8RyFDDdk.js} +2 -2
- package/out/renderer/assets/{jsonMode-DZT5gBVW.js → jsonMode-CqspkWCb.js} +3 -3
- package/out/renderer/assets/{liquid-BuajCNJ9.js → liquid-DaEnAsYY.js} +1 -1
- package/out/renderer/assets/{lspLanguageFeatures-BYkg2Yc-.js → lspLanguageFeatures-Czkp5sZb.js} +1 -1
- package/out/renderer/assets/{mdx-D0Qx2GMS.js → mdx-Q-qSZsFg.js} +1 -1
- package/out/renderer/assets/{python-ChncNy6S.js → python-Sw0S0BJV.js} +1 -1
- package/out/renderer/assets/{razor-C6MUtfaS.js → razor-C7zQmabz.js} +1 -1
- package/out/renderer/assets/{tsMode-C2aMdzdU.js → tsMode-BWdOxk6v.js} +1 -1
- package/out/renderer/assets/{typescript-CB71AtAT.js → typescript-BrdIiKZf.js} +1 -1
- package/out/renderer/assets/{xml-tHS1Ec9w.js → xml-DbKLNPOq.js} +1 -1
- package/out/renderer/assets/{yaml-CZcp5ueF.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/headless.ts +9 -13
- package/src/main/index.ts +6 -0
- package/src/main/pty.ts +2 -5
- package/src/main/ticketStore.ts +2 -1
- 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 +1 -0
- package/src/renderer/components/SettingsDialog.tsx +13 -0
- package/src/renderer/components/SpeakControls.tsx +5 -0
- package/src/renderer/components/TerminalPanel.tsx +151 -30
- package/src/renderer/lib/speech.ts +2 -1
- package/src/renderer/styles.css +116 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { conf as conf$1, language as language$1 } from "./typescript-
|
|
2
|
-
import "./index-
|
|
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-
|
|
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-
|
|
3
|
-
import { a, D, h, R, c, i, j, t, k } from "./lspLanguageFeatures-
|
|
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) {
|
package/out/renderer/assets/{lspLanguageFeatures-BYkg2Yc-.js → lspLanguageFeatures-Czkp5sZb.js}
RENAMED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { R as Range$1, l as languages, e as editor, U as Uri, M as MarkerSeverity } from "./index-
|
|
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 { 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-
|
|
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;
|
package/out/renderer/index.html
CHANGED
|
@@ -49,8 +49,8 @@
|
|
|
49
49
|
color: #565f89;
|
|
50
50
|
}
|
|
51
51
|
</style>
|
|
52
|
-
<script type="module" crossorigin src="./assets/index-
|
|
53
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
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.
|
|
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": "
|
|
41
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
42
42
|
"engines": {
|
|
43
43
|
"node": ">=18"
|
|
44
44
|
},
|
package/src/main/bridge.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
141
|
+
log('[bridge] Session ended')
|
|
141
142
|
}
|
|
142
143
|
}
|
|
143
144
|
|
package/src/main/headless.ts
CHANGED
|
@@ -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
|
-
//
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
//
|
|
192
|
-
//
|
|
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
|
-
|
|
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',
|
package/src/main/ticketStore.ts
CHANGED
|
@@ -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
|
-
|
|
51
|
+
baseLog('[ticket-store]', ...args)
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
function findPageByTitle(pages: any[], title: string): any | null {
|
package/src/main/timeTracker.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
package/src/main/transcripts.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
94
|
+
log('[transcripts] Stopped')
|
|
94
95
|
}
|
|
95
96
|
|
|
96
97
|
private poll(): void {
|
package/src/preload/index.ts
CHANGED
|
@@ -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 }> =>
|
package/src/renderer/App.tsx
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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 →
|
|
167
|
-
// here (not as
|
|
168
|
-
// 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.
|
|
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
|
|
176
|
-
if (
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
278
|
-
//
|
|
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 (!
|
|
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
|
-
|
|
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
|
|
295
|
-
const detail = (e as CustomEvent).detail as {
|
|
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('
|
|
299
|
-
return () => window.removeEventListener('
|
|
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
|
-
|
|
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
|
-
|
|
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 */ }
|