@phenx-inc/ctlsurf 0.3.16 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/out/headless/index.mjs +309 -38
- package/out/headless/index.mjs.map +4 -4
- package/out/main/index.js +277 -7
- package/out/preload/index.js +2 -0
- package/out/renderer/assets/{cssMode-D5dPwEy5.js → cssMode-DkmdBgO7.js} +3 -3
- package/out/renderer/assets/{freemarker2-c5jJjQ9s.js → freemarker2-CI-gkP-3.js} +1 -1
- package/out/renderer/assets/{handlebars-BTbmOxx9.js → handlebars-D5tEqanR.js} +1 -1
- package/out/renderer/assets/{html-3cIIQcxO.js → html-fH93EYfn.js} +1 -1
- package/out/renderer/assets/{htmlMode-DYbpW1yY.js → htmlMode-CRicxcwK.js} +3 -3
- package/out/renderer/assets/{index-D2MUZin7.js → index-BOOvUI7u.js} +192 -23
- package/out/renderer/assets/{index-6KvOnYL1.css → index-ezC-iarf.css} +40 -0
- package/out/renderer/assets/{javascript-CDuCMm-6.js → javascript-D1Baz4fV.js} +2 -2
- package/out/renderer/assets/{jsonMode-COLqbq0s.js → jsonMode-Bquqf3QN.js} +3 -3
- package/out/renderer/assets/{liquid-BFcqZizB.js → liquid-ByOcPjBF.js} +1 -1
- package/out/renderer/assets/{lspLanguageFeatures-CbkEcL-z.js → lspLanguageFeatures-BxPLl0yy.js} +1 -1
- package/out/renderer/assets/{mdx-DyK93oEE.js → mdx-yuNgx0rM.js} +1 -1
- package/out/renderer/assets/{python-D4lCwSVr.js → python-2OakgLlA.js} +1 -1
- package/out/renderer/assets/{razor-DdkE9XVt.js → razor-DnIVMSwa.js} +1 -1
- package/out/renderer/assets/{tsMode-BrQ4Fsc-.js → tsMode-CRIrHuii.js} +1 -1
- package/out/renderer/assets/{typescript-BakbYMnC.js → typescript-DJ3C8Yly.js} +1 -1
- package/out/renderer/assets/{xml-DHDW9Xhp.js → xml-CalvD5_C.js} +1 -1
- package/out/renderer/assets/{yaml-1Ayv_J3q.js → yaml-Cgs8pdVp.js} +1 -1
- package/out/renderer/index.html +2 -2
- package/package.json +1 -1
- package/src/main/index.ts +8 -1
- package/src/main/orchestrator.ts +35 -7
- package/src/main/transcripts.ts +341 -0
- package/src/preload/index.ts +4 -0
- package/src/renderer/App.tsx +1 -0
- package/src/renderer/components/TerminalPanel.tsx +95 -2
- package/src/renderer/lib/tableToHtml.ts +146 -0
- package/src/renderer/styles.css +40 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import os from 'os'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Transcript Tailer
|
|
7
|
+
*
|
|
8
|
+
* Primary chat-log source for agents that write native session transcripts
|
|
9
|
+
* (Claude Code, Codex CLI). Instead of scraping the TUI screen — which hard-
|
|
10
|
+
* wraps paragraphs at terminal width and leaks chrome — we tail the agent's
|
|
11
|
+
* own JSONL transcript and forward exact user/assistant messages.
|
|
12
|
+
*
|
|
13
|
+
* The ConversationBridge screen-scraper remains the fallback for agents
|
|
14
|
+
* without a known transcript format (plain shell, unknown CLIs).
|
|
15
|
+
*
|
|
16
|
+
* Formats:
|
|
17
|
+
* - Claude Code: ~/.claude/projects/<cwd-slug>/<sessionId>.jsonl
|
|
18
|
+
* One JSON object per line: { type: 'user'|'assistant', message, timestamp, cwd, ... }
|
|
19
|
+
* - Codex CLI: ~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<id>.jsonl
|
|
20
|
+
* First line is { type: 'session_meta', payload: { cwd, ... } }; chat content
|
|
21
|
+
* arrives as { type: 'event_msg', payload: { type: 'user_message'|'agent_message', message } }
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export interface ChatLogEntry {
|
|
25
|
+
ts: string
|
|
26
|
+
type: 'user_input' | 'terminal_output'
|
|
27
|
+
content: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type ChatLogSink = (entry: ChatLogEntry) => void
|
|
31
|
+
|
|
32
|
+
export type TranscriptAgentId = 'claude' | 'codex'
|
|
33
|
+
|
|
34
|
+
export function supportsTranscriptLogging(agentId: string): agentId is TranscriptAgentId {
|
|
35
|
+
return agentId === 'claude' || agentId === 'codex'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface TranscriptTailerOptions {
|
|
39
|
+
agentId: TranscriptAgentId
|
|
40
|
+
cwd: string
|
|
41
|
+
sink: ChatLogSink
|
|
42
|
+
/** Override transcript roots (for tests). */
|
|
43
|
+
claudeProjectsDir?: string
|
|
44
|
+
codexSessionsDir?: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const POLL_INTERVAL_MS = 1_000
|
|
48
|
+
const DISCOVERY_SLACK_MS = 10_000
|
|
49
|
+
const READ_CHUNK_BYTES = 64 * 1024
|
|
50
|
+
const MAX_ENTRY_CHARS = 20_000
|
|
51
|
+
|
|
52
|
+
interface FileTail {
|
|
53
|
+
offset: number
|
|
54
|
+
remainder: string
|
|
55
|
+
/** Codex file whose session_meta cwd belongs to a different project. */
|
|
56
|
+
excluded: boolean
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export class TranscriptTailer {
|
|
60
|
+
private readonly agentId: TranscriptAgentId
|
|
61
|
+
private readonly cwd: string
|
|
62
|
+
private readonly sink: ChatLogSink
|
|
63
|
+
private readonly claudeProjectsDir: string
|
|
64
|
+
private readonly codexSessionsDir: string
|
|
65
|
+
|
|
66
|
+
private files = new Map<string, FileTail>()
|
|
67
|
+
private pollTimer: ReturnType<typeof setInterval> | null = null
|
|
68
|
+
private sinceMs = 0
|
|
69
|
+
|
|
70
|
+
constructor(options: TranscriptTailerOptions) {
|
|
71
|
+
this.agentId = options.agentId
|
|
72
|
+
this.cwd = stripTrailingSep(options.cwd)
|
|
73
|
+
this.sink = options.sink
|
|
74
|
+
this.claudeProjectsDir = options.claudeProjectsDir || path.join(os.homedir(), '.claude', 'projects')
|
|
75
|
+
this.codexSessionsDir = options.codexSessionsDir || path.join(os.homedir(), '.codex', 'sessions')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
start(): void {
|
|
79
|
+
if (this.pollTimer) return
|
|
80
|
+
this.sinceMs = Date.now()
|
|
81
|
+
this.files.clear()
|
|
82
|
+
this.pollTimer = setInterval(() => this.poll(), POLL_INTERVAL_MS)
|
|
83
|
+
console.log(`[transcripts] Tailing ${this.agentId} transcripts for ${this.cwd}`)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
stop(): void {
|
|
87
|
+
if (!this.pollTimer) return
|
|
88
|
+
clearInterval(this.pollTimer)
|
|
89
|
+
this.pollTimer = null
|
|
90
|
+
// Final drain so messages written just before exit aren't lost.
|
|
91
|
+
this.poll()
|
|
92
|
+
this.files.clear()
|
|
93
|
+
console.log('[transcripts] Stopped')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private poll(): void {
|
|
97
|
+
try {
|
|
98
|
+
this.discover()
|
|
99
|
+
for (const [filePath, tail] of this.files) {
|
|
100
|
+
if (!tail.excluded) this.drainFile(filePath, tail)
|
|
101
|
+
}
|
|
102
|
+
} catch (err) {
|
|
103
|
+
console.error('[transcripts] Poll error:', err)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ─── Discovery ──────────────────────────────────
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Track every transcript file with recent activity, not just the first
|
|
111
|
+
* match: /clear (Claude) or /new (Codex) starts a new session file in the
|
|
112
|
+
* middle of one PTY run, and tailing all active candidates handles the
|
|
113
|
+
* switch without special cases. Old idle files never match (stale mtime).
|
|
114
|
+
*/
|
|
115
|
+
private discover(): void {
|
|
116
|
+
const dirs = this.agentId === 'claude' ? [this.claudeProjectsDirForCwd()] : this.codexDateDirs()
|
|
117
|
+
|
|
118
|
+
for (const dir of dirs) {
|
|
119
|
+
let names: string[]
|
|
120
|
+
try {
|
|
121
|
+
names = fs.readdirSync(dir)
|
|
122
|
+
} catch {
|
|
123
|
+
continue // Directory may not exist until the agent writes its first message
|
|
124
|
+
}
|
|
125
|
+
for (const name of names) {
|
|
126
|
+
if (!name.endsWith('.jsonl')) continue
|
|
127
|
+
if (this.agentId === 'codex' && !name.startsWith('rollout-')) continue
|
|
128
|
+
const filePath = path.join(dir, name)
|
|
129
|
+
if (this.files.has(filePath)) continue
|
|
130
|
+
try {
|
|
131
|
+
const stat = fs.statSync(filePath)
|
|
132
|
+
if (stat.mtimeMs >= this.sinceMs - DISCOVERY_SLACK_MS) {
|
|
133
|
+
this.files.set(filePath, { offset: 0, remainder: '', excluded: false })
|
|
134
|
+
}
|
|
135
|
+
} catch { /* ignore — file may have vanished */ }
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private claudeProjectsDirForCwd(): string {
|
|
141
|
+
// Claude Code slugs the cwd by replacing every non-alphanumeric char with '-'
|
|
142
|
+
// e.g. /Users/me/Code/quick_apps/app → -Users-me-Code-quick-apps-app
|
|
143
|
+
const slug = this.cwd.replace(/[^a-zA-Z0-9]/g, '-')
|
|
144
|
+
return path.join(this.claudeProjectsDir, slug)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private codexDateDirs(): string[] {
|
|
148
|
+
// Codex groups sessions by local date; check the start date and today to
|
|
149
|
+
// cover sessions running across midnight.
|
|
150
|
+
const dirs = new Set<string>()
|
|
151
|
+
for (const ms of [this.sinceMs, Date.now()]) {
|
|
152
|
+
const d = new Date(ms)
|
|
153
|
+
const yyyy = String(d.getFullYear())
|
|
154
|
+
const mm = String(d.getMonth() + 1).padStart(2, '0')
|
|
155
|
+
const dd = String(d.getDate()).padStart(2, '0')
|
|
156
|
+
dirs.add(path.join(this.codexSessionsDir, yyyy, mm, dd))
|
|
157
|
+
}
|
|
158
|
+
return [...dirs]
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ─── Tailing ────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
private drainFile(filePath: string, tail: FileTail): void {
|
|
164
|
+
let size: number
|
|
165
|
+
try {
|
|
166
|
+
size = fs.statSync(filePath).size
|
|
167
|
+
} catch {
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
if (size <= tail.offset) return
|
|
171
|
+
|
|
172
|
+
let fd: number
|
|
173
|
+
try {
|
|
174
|
+
fd = fs.openSync(filePath, 'r')
|
|
175
|
+
} catch {
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
const buf = Buffer.alloc(READ_CHUNK_BYTES)
|
|
180
|
+
while (tail.offset < size && !tail.excluded) {
|
|
181
|
+
const bytesRead = fs.readSync(fd, buf, 0, READ_CHUNK_BYTES, tail.offset)
|
|
182
|
+
if (bytesRead <= 0) break
|
|
183
|
+
tail.offset += bytesRead
|
|
184
|
+
tail.remainder += buf.toString('utf-8', 0, bytesRead)
|
|
185
|
+
|
|
186
|
+
const lines = tail.remainder.split('\n')
|
|
187
|
+
tail.remainder = lines.pop() || ''
|
|
188
|
+
for (const line of lines) {
|
|
189
|
+
this.handleLine(line, tail)
|
|
190
|
+
if (tail.excluded) break
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
} catch (err) {
|
|
194
|
+
console.error(`[transcripts] Read error for ${filePath}:`, err)
|
|
195
|
+
} finally {
|
|
196
|
+
try { fs.closeSync(fd) } catch { /* ignore */ }
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private handleLine(line: string, tail: FileTail): void {
|
|
201
|
+
const trimmed = line.trim()
|
|
202
|
+
if (!trimmed) return
|
|
203
|
+
|
|
204
|
+
let obj: any
|
|
205
|
+
try {
|
|
206
|
+
obj = JSON.parse(trimmed)
|
|
207
|
+
} catch {
|
|
208
|
+
return // tolerate partial/corrupt lines
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const entry = this.agentId === 'claude' ? this.parseClaudeLine(obj) : this.parseCodexLine(obj, tail)
|
|
212
|
+
if (!entry) return
|
|
213
|
+
|
|
214
|
+
// Skip history replayed into resumed/forked session files.
|
|
215
|
+
const ms = Date.parse(entry.ts)
|
|
216
|
+
if (Number.isFinite(ms) && ms < this.sinceMs - DISCOVERY_SLACK_MS) return
|
|
217
|
+
|
|
218
|
+
this.sink({ ...entry, content: capLength(entry.content) })
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ─── Claude Code format ─────────────────────────
|
|
222
|
+
|
|
223
|
+
private parseClaudeLine(obj: any): ChatLogEntry | null {
|
|
224
|
+
if (!obj || typeof obj !== 'object') return null
|
|
225
|
+
if (obj.isMeta) return null
|
|
226
|
+
if (obj.type !== 'user' && obj.type !== 'assistant') return null
|
|
227
|
+
// Belt-and-braces: the slug dir already scopes to this cwd, but lines
|
|
228
|
+
// carry the cwd too (subagent/sidechain lines can differ).
|
|
229
|
+
if (typeof obj.cwd === 'string' && stripTrailingSep(obj.cwd) !== this.cwd) return null
|
|
230
|
+
if (obj.isSidechain) return null
|
|
231
|
+
|
|
232
|
+
const message = obj.message
|
|
233
|
+
if (!message) return null
|
|
234
|
+
|
|
235
|
+
const text = extractClaudeText(message.content, obj.type === 'user')
|
|
236
|
+
if (!text) return null
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
ts: typeof obj.timestamp === 'string' ? obj.timestamp : new Date().toISOString(),
|
|
240
|
+
type: obj.type === 'user' ? 'user_input' : 'terminal_output',
|
|
241
|
+
content: text,
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ─── Codex CLI format ───────────────────────────
|
|
246
|
+
|
|
247
|
+
private parseCodexLine(obj: any, tail: FileTail): ChatLogEntry | null {
|
|
248
|
+
if (!obj || typeof obj !== 'object') return null
|
|
249
|
+
const payload = obj.payload
|
|
250
|
+
|
|
251
|
+
if (obj.type === 'session_meta') {
|
|
252
|
+
const metaCwd = payload?.cwd
|
|
253
|
+
if (typeof metaCwd === 'string' && stripTrailingSep(metaCwd) !== this.cwd) {
|
|
254
|
+
tail.excluded = true // another project's session sharing the date dir
|
|
255
|
+
}
|
|
256
|
+
return null
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (obj.type !== 'event_msg' || !payload || typeof payload !== 'object') return null
|
|
260
|
+
|
|
261
|
+
let type: ChatLogEntry['type']
|
|
262
|
+
if (payload.type === 'user_message') {
|
|
263
|
+
type = 'user_input'
|
|
264
|
+
} else if (payload.type === 'agent_message') {
|
|
265
|
+
type = 'terminal_output'
|
|
266
|
+
} else {
|
|
267
|
+
return null
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const text = typeof payload.message === 'string' ? payload.message.trim() : ''
|
|
271
|
+
if (!text || isCodexNoise(text)) return null
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
ts: typeof obj.timestamp === 'string' ? obj.timestamp : new Date().toISOString(),
|
|
275
|
+
type,
|
|
276
|
+
content: text,
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ─── Text extraction & filters ────────────────────
|
|
282
|
+
|
|
283
|
+
/** Prefixes of injected/meta user content that isn't something the user typed. */
|
|
284
|
+
const CLAUDE_NOISE_PREFIXES = [
|
|
285
|
+
'<local-command-caveat>',
|
|
286
|
+
'<command-name>',
|
|
287
|
+
'<command-message>',
|
|
288
|
+
'<local-command-stdout>',
|
|
289
|
+
'<bash-input>',
|
|
290
|
+
'<bash-stdout>',
|
|
291
|
+
'<bash-stderr>',
|
|
292
|
+
'<system-reminder>',
|
|
293
|
+
'<task-notification>',
|
|
294
|
+
'caveat: the messages below',
|
|
295
|
+
'[request interrupted',
|
|
296
|
+
]
|
|
297
|
+
|
|
298
|
+
const CODEX_NOISE_PREFIXES = [
|
|
299
|
+
'<environment_context>',
|
|
300
|
+
'<user_instructions>',
|
|
301
|
+
'<permissions instructions>',
|
|
302
|
+
'<turn_aborted>',
|
|
303
|
+
]
|
|
304
|
+
|
|
305
|
+
function isClaudeNoise(text: string): boolean {
|
|
306
|
+
const lower = text.trimStart().toLowerCase()
|
|
307
|
+
return CLAUDE_NOISE_PREFIXES.some((p) => lower.startsWith(p))
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function isCodexNoise(text: string): boolean {
|
|
311
|
+
const lower = text.trimStart().toLowerCase()
|
|
312
|
+
return CODEX_NOISE_PREFIXES.some((p) => lower.startsWith(p))
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function extractClaudeText(content: unknown, isUser: boolean): string {
|
|
316
|
+
if (typeof content === 'string') {
|
|
317
|
+
const text = content.trim()
|
|
318
|
+
return text && !isClaudeNoise(text) ? text : ''
|
|
319
|
+
}
|
|
320
|
+
if (!Array.isArray(content)) return ''
|
|
321
|
+
|
|
322
|
+
// Tool results come back as user-role lines; they're not typed input.
|
|
323
|
+
if (isUser && content.some((b: any) => b?.type === 'tool_result')) return ''
|
|
324
|
+
|
|
325
|
+
const parts: string[] = []
|
|
326
|
+
for (const block of content) {
|
|
327
|
+
if (block?.type !== 'text' || typeof block.text !== 'string') continue
|
|
328
|
+
const text = block.text.trim()
|
|
329
|
+
if (text && !isClaudeNoise(text)) parts.push(text)
|
|
330
|
+
}
|
|
331
|
+
return parts.join('\n\n')
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function stripTrailingSep(p: string): string {
|
|
335
|
+
return p.length > 1 ? p.replace(/[/\\]+$/, '') : p
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function capLength(str: string): string {
|
|
339
|
+
if (str.length <= MAX_ENTRY_CHARS) return str
|
|
340
|
+
return str.slice(0, MAX_ENTRY_CHARS) + `… [truncated, ${str.length} total chars]`
|
|
341
|
+
}
|
package/src/preload/index.ts
CHANGED
|
@@ -53,6 +53,10 @@ const api = {
|
|
|
53
53
|
},
|
|
54
54
|
browseCwd: (): Promise<string | null> =>
|
|
55
55
|
ipcRenderer.invoke('app:browseCwd'),
|
|
56
|
+
|
|
57
|
+
// Clipboard — write a rich-HTML table (+ plain-text fallback) for email paste.
|
|
58
|
+
copyEmailTable: (html: string, text: string): Promise<{ ok: boolean }> =>
|
|
59
|
+
ipcRenderer.invoke('clipboard:writeTable', html, text),
|
|
56
60
|
getVersion: (): Promise<string> =>
|
|
57
61
|
ipcRenderer.invoke('app:getVersion'),
|
|
58
62
|
getUpdateInfo: (): Promise<{ current: string; latest: string | null; hasUpdate: boolean; checkedAt: number | null }> =>
|
package/src/renderer/App.tsx
CHANGED
|
@@ -1,8 +1,26 @@
|
|
|
1
|
-
import { useEffect, useRef } from 'react'
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
2
|
import { Terminal } from '@xterm/xterm'
|
|
3
3
|
import { FitAddon } from '@xterm/addon-fit'
|
|
4
4
|
import { WebLinksAddon } from '@xterm/addon-web-links'
|
|
5
5
|
import '@xterm/xterm/css/xterm.css'
|
|
6
|
+
import { convertToEmailTable } from '../lib/tableToHtml'
|
|
7
|
+
|
|
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())
|
|
13
|
+
if (result) {
|
|
14
|
+
window.worker.copyEmailTable(result.html, result.text)
|
|
15
|
+
}
|
|
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
|
+
}
|
|
6
24
|
|
|
7
25
|
interface AgentConfig {
|
|
8
26
|
id: string
|
|
@@ -53,6 +71,9 @@ function getOrCreateTerminal(tabId: string, onExit: (tabId: string) => void): {
|
|
|
53
71
|
|
|
54
72
|
const terminal = new Terminal({
|
|
55
73
|
cursorBlink: true,
|
|
74
|
+
// Off by default on macOS; without this a right-click would reselect the
|
|
75
|
+
// word under the cursor and clobber a multi-line table selection.
|
|
76
|
+
rightClickSelectsWord: false,
|
|
56
77
|
fontSize: 14,
|
|
57
78
|
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', monospace",
|
|
58
79
|
theme: {
|
|
@@ -86,6 +107,18 @@ function getOrCreateTerminal(tabId: string, onExit: (tabId: string) => void): {
|
|
|
86
107
|
terminal.loadAddon(fitAddon)
|
|
87
108
|
terminal.loadAddon(new WebLinksAddon())
|
|
88
109
|
|
|
110
|
+
// ⌘/Ctrl+Shift+E → convert the current selection to an email table. Handled
|
|
111
|
+
// here (not as a DOM listener) so it works even while a mouse-tracking TUI
|
|
112
|
+
// is running, where right-click is forwarded to the program instead.
|
|
113
|
+
terminal.attachCustomKeyEventHandler((e) => {
|
|
114
|
+
if (e.type !== 'keydown') return true
|
|
115
|
+
const isCombo = (e.metaKey || e.ctrlKey) && e.shiftKey && e.code === 'KeyE'
|
|
116
|
+
if (!isCombo) return true
|
|
117
|
+
e.preventDefault()
|
|
118
|
+
copySelectionAsEmailTable(terminal)
|
|
119
|
+
return false // consume — don't forward to the pty
|
|
120
|
+
})
|
|
121
|
+
|
|
89
122
|
const state = {
|
|
90
123
|
terminal,
|
|
91
124
|
fitAddon,
|
|
@@ -140,6 +173,56 @@ export function destroyTerminal(tabId: string): void {
|
|
|
140
173
|
|
|
141
174
|
export function TerminalPanel({ tabId, agent, onSpawn, onExit, isActive }: TerminalPanelProps) {
|
|
142
175
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
176
|
+
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null)
|
|
177
|
+
const [toast, setToast] = useState<string | null>(null)
|
|
178
|
+
|
|
179
|
+
// Right-click → offer "Copy as email table", but only when the current
|
|
180
|
+
// selection actually parses as a table; otherwise fall through to default.
|
|
181
|
+
const handleContextMenu = (e: React.MouseEvent) => {
|
|
182
|
+
const sel = _terminals.get(tabId)?.terminal.getSelection() ?? ''
|
|
183
|
+
if (!convertToEmailTable(sel)) return
|
|
184
|
+
e.preventDefault()
|
|
185
|
+
setMenu({ x: e.clientX, y: e.clientY })
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const runConvert = () => {
|
|
189
|
+
const term = _terminals.get(tabId)?.terminal
|
|
190
|
+
if (term) copySelectionAsEmailTable(term)
|
|
191
|
+
setMenu(null)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Show a transient toast when a conversion happens (hotkey or menu).
|
|
195
|
+
useEffect(() => {
|
|
196
|
+
const onResult = (e: Event) => {
|
|
197
|
+
const detail = (e as CustomEvent).detail as { ok: boolean; message: string } | null
|
|
198
|
+
if (detail) setToast(detail.message)
|
|
199
|
+
}
|
|
200
|
+
window.addEventListener('email-table-result', onResult)
|
|
201
|
+
return () => window.removeEventListener('email-table-result', onResult)
|
|
202
|
+
}, [])
|
|
203
|
+
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
if (!toast) return
|
|
206
|
+
const t = setTimeout(() => setToast(null), 2200)
|
|
207
|
+
return () => clearTimeout(t)
|
|
208
|
+
}, [toast])
|
|
209
|
+
|
|
210
|
+
// Dismiss the context menu on click-away, scroll, blur, or Escape.
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
if (!menu) return
|
|
213
|
+
const close = () => setMenu(null)
|
|
214
|
+
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') close() }
|
|
215
|
+
window.addEventListener('click', close)
|
|
216
|
+
window.addEventListener('blur', close)
|
|
217
|
+
window.addEventListener('wheel', close, { passive: true })
|
|
218
|
+
document.addEventListener('keydown', onKey)
|
|
219
|
+
return () => {
|
|
220
|
+
window.removeEventListener('click', close)
|
|
221
|
+
window.removeEventListener('blur', close)
|
|
222
|
+
window.removeEventListener('wheel', close)
|
|
223
|
+
document.removeEventListener('keydown', onKey)
|
|
224
|
+
}
|
|
225
|
+
}, [menu])
|
|
143
226
|
|
|
144
227
|
// Attach terminal to DOM container
|
|
145
228
|
useEffect(() => {
|
|
@@ -233,6 +316,16 @@ export function TerminalPanel({ tabId, agent, onSpawn, onExit, isActive }: Termi
|
|
|
233
316
|
}, [isActive, tabId])
|
|
234
317
|
|
|
235
318
|
return (
|
|
236
|
-
|
|
319
|
+
<>
|
|
320
|
+
<div className="terminal-container" ref={containerRef} onContextMenu={handleContextMenu} />
|
|
321
|
+
{menu && (
|
|
322
|
+
<div className="term-context-menu" style={{ position: 'fixed', left: menu.x, top: menu.y }}>
|
|
323
|
+
<button type="button" onMouseDown={(e) => { e.preventDefault(); runConvert() }}>
|
|
324
|
+
Copy as email table
|
|
325
|
+
</button>
|
|
326
|
+
</div>
|
|
327
|
+
)}
|
|
328
|
+
{toast && <div className="term-toast">{toast}</div>}
|
|
329
|
+
</>
|
|
237
330
|
)
|
|
238
331
|
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Converts a terminal box-drawing or markdown table (e.g. a selection copied
|
|
2
|
+
// from the terminal) into an inline-styled HTML table that survives a paste
|
|
3
|
+
// into an email client, plus a plain-text fallback.
|
|
4
|
+
//
|
|
5
|
+
// Ported from the standalone "console-to-email-table" app. Pure & dependency
|
|
6
|
+
// free so it can run directly in the renderer.
|
|
7
|
+
|
|
8
|
+
export interface ParsedTable {
|
|
9
|
+
rows: string[][]
|
|
10
|
+
hasHeader: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface EmailTable {
|
|
14
|
+
html: string
|
|
15
|
+
text: string
|
|
16
|
+
rows: number
|
|
17
|
+
cols: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Vertical cell separators: box-drawing verticals + ASCII pipe.
|
|
21
|
+
const VERTICAL = /[│┃┆┇┊┋|]/
|
|
22
|
+
// Any box-drawing glyph (U+2500–U+257F): lines, corners, junctions.
|
|
23
|
+
const BOX_CHARS = /[─-╿]/g
|
|
24
|
+
|
|
25
|
+
// ESC-initiated ANSI/VT sequences (color/SGR, cursor moves) from terminals.
|
|
26
|
+
const ANSI = /\x1B(?:\[[0-9;?]*[ -\/]*[@-~]|[@-_])/g
|
|
27
|
+
// Zero-width space/joiners, word joiner, BOM — invisible paste gunk.
|
|
28
|
+
const INVISIBLE = /[-]/g
|
|
29
|
+
// Non-breaking / unusual spaces → normalize to a plain space.
|
|
30
|
+
const ODD_SPACE = /[ - ]/g
|
|
31
|
+
|
|
32
|
+
function cleanInput(input: string): string {
|
|
33
|
+
return input.replace(ANSI, '').replace(INVISIBLE, '').replace(ODD_SPACE, ' ')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function splitRow(line: string): string[] {
|
|
37
|
+
const parts = line.split(VERTICAL).map((c) => c.trim())
|
|
38
|
+
if (parts.length && parts[0] === '') parts.shift()
|
|
39
|
+
if (parts.length && parts[parts.length - 1] === '') parts.pop()
|
|
40
|
+
return parts
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isBorderLine(line: string): boolean {
|
|
44
|
+
return line.replace(BOX_CHARS, '').trim() === ''
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isMarkdownSeparator(line: string): boolean {
|
|
48
|
+
return /-/.test(line) && /^[\s|:\-]+$/.test(line.trim())
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function looksLikeBox(input: string): boolean {
|
|
52
|
+
return /[─-╿]/.test(input)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function normalizeWidth(rows: string[][]): string[][] {
|
|
56
|
+
const cols = rows.reduce((m, r) => Math.max(m, r.length), 0)
|
|
57
|
+
return rows.map((r) => {
|
|
58
|
+
const copy = r.slice()
|
|
59
|
+
while (copy.length < cols) copy.push('')
|
|
60
|
+
return copy
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseBox(lines: string[]): ParsedTable {
|
|
65
|
+
const rows: string[][] = []
|
|
66
|
+
for (const line of lines) {
|
|
67
|
+
if (line.trim() === '') continue
|
|
68
|
+
if (isBorderLine(line)) continue
|
|
69
|
+
if (!VERTICAL.test(line)) continue
|
|
70
|
+
rows.push(splitRow(line))
|
|
71
|
+
}
|
|
72
|
+
return { rows: normalizeWidth(rows), hasHeader: rows.length > 1 }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function parseMarkdown(lines: string[]): ParsedTable {
|
|
76
|
+
const rows: string[][] = []
|
|
77
|
+
let separatorIndex = -1
|
|
78
|
+
for (const line of lines) {
|
|
79
|
+
if (line.trim() === '') continue
|
|
80
|
+
if (!line.includes('|') && !isMarkdownSeparator(line)) continue
|
|
81
|
+
if (isMarkdownSeparator(line)) {
|
|
82
|
+
separatorIndex = rows.length
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
rows.push(splitRow(line))
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
rows: normalizeWidth(rows),
|
|
89
|
+
hasHeader: separatorIndex === 1 || (separatorIndex === -1 && rows.length > 1),
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function parseTable(input: string): ParsedTable {
|
|
94
|
+
if (!input || !input.trim()) return { rows: [], hasHeader: false }
|
|
95
|
+
const cleaned = cleanInput(input)
|
|
96
|
+
const lines = cleaned.replace(/\r\n?/g, '\n').split('\n')
|
|
97
|
+
if (looksLikeBox(cleaned)) return parseBox(lines)
|
|
98
|
+
if (cleaned.includes('|')) return parseMarkdown(lines)
|
|
99
|
+
return parseBox(lines)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ─── HTML rendering (inline styles only — email clients strip <style>) ───
|
|
103
|
+
|
|
104
|
+
const TABLE_STYLE =
|
|
105
|
+
'border-collapse:collapse;border:1px solid #d0d7de;' +
|
|
106
|
+
'font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Arial,sans-serif;' +
|
|
107
|
+
'font-size:14px;color:#1f2328;'
|
|
108
|
+
const TH_STYLE =
|
|
109
|
+
'border:1px solid #d0d7de;padding:8px 12px;text-align:left;' +
|
|
110
|
+
'background:#f0f3f6;font-weight:600;'
|
|
111
|
+
const TD_STYLE = 'border:1px solid #d0d7de;padding:8px 12px;text-align:left;vertical-align:top;'
|
|
112
|
+
|
|
113
|
+
function escapeHtml(s: string): string {
|
|
114
|
+
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function buildHtml(rows: string[][], hasHeader: boolean): string {
|
|
118
|
+
const out: string[] = ['<table cellspacing="0" cellpadding="0" style="' + TABLE_STYLE + '">']
|
|
119
|
+
rows.forEach((row, i) => {
|
|
120
|
+
const isHeader = hasHeader && i === 0
|
|
121
|
+
const tag = isHeader ? 'th' : 'td'
|
|
122
|
+
const style = isHeader ? TH_STYLE : TD_STYLE
|
|
123
|
+
out.push('<tr>')
|
|
124
|
+
for (const cell of row) {
|
|
125
|
+
out.push('<' + tag + ' style="' + style + '">' + escapeHtml(cell) + '</' + tag + '>')
|
|
126
|
+
}
|
|
127
|
+
out.push('</tr>')
|
|
128
|
+
})
|
|
129
|
+
out.push('</table>')
|
|
130
|
+
return out.join('')
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Parse a raw terminal/markdown table selection and produce an email-ready
|
|
135
|
+
* HTML table plus plain-text fallback. Returns null if no table is detected.
|
|
136
|
+
*/
|
|
137
|
+
export function convertToEmailTable(raw: string): EmailTable | null {
|
|
138
|
+
const { rows, hasHeader } = parseTable(raw)
|
|
139
|
+
if (!rows.length || rows[0].length < 2) return null
|
|
140
|
+
return {
|
|
141
|
+
html: buildHtml(rows, hasHeader),
|
|
142
|
+
text: rows.map((r) => r.join('\t')).join('\n'),
|
|
143
|
+
rows: rows.length,
|
|
144
|
+
cols: rows[0].length,
|
|
145
|
+
}
|
|
146
|
+
}
|
package/src/renderer/styles.css
CHANGED
|
@@ -1431,3 +1431,43 @@ html, body, #root {
|
|
|
1431
1431
|
padding: 2px 8px;
|
|
1432
1432
|
flex-shrink: 0;
|
|
1433
1433
|
}
|
|
1434
|
+
|
|
1435
|
+
/* ─── Terminal "Copy as email table" context menu + toast ─── */
|
|
1436
|
+
.term-context-menu {
|
|
1437
|
+
z-index: 1000;
|
|
1438
|
+
background: #1f2335;
|
|
1439
|
+
border: 1px solid #414868;
|
|
1440
|
+
border-radius: 6px;
|
|
1441
|
+
padding: 4px;
|
|
1442
|
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
|
1443
|
+
min-width: 180px;
|
|
1444
|
+
}
|
|
1445
|
+
.term-context-menu button {
|
|
1446
|
+
display: block;
|
|
1447
|
+
width: 100%;
|
|
1448
|
+
text-align: left;
|
|
1449
|
+
background: transparent;
|
|
1450
|
+
border: none;
|
|
1451
|
+
border-radius: 4px;
|
|
1452
|
+
color: #c0caf5;
|
|
1453
|
+
font-size: 13px;
|
|
1454
|
+
padding: 7px 10px;
|
|
1455
|
+
cursor: pointer;
|
|
1456
|
+
}
|
|
1457
|
+
.term-context-menu button:hover {
|
|
1458
|
+
background: #2a2b3d;
|
|
1459
|
+
}
|
|
1460
|
+
.term-toast {
|
|
1461
|
+
position: fixed;
|
|
1462
|
+
bottom: 24px;
|
|
1463
|
+
right: 24px;
|
|
1464
|
+
z-index: 1000;
|
|
1465
|
+
background: #1f2335;
|
|
1466
|
+
border: 1px solid #7aa2f7;
|
|
1467
|
+
color: #c0caf5;
|
|
1468
|
+
font-size: 13px;
|
|
1469
|
+
padding: 9px 14px;
|
|
1470
|
+
border-radius: 6px;
|
|
1471
|
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
|
1472
|
+
pointer-events: none;
|
|
1473
|
+
}
|