@phenx-inc/ctlsurf 0.3.15 → 0.4.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.
Files changed (31) hide show
  1. package/out/headless/index.mjs +9 -6
  2. package/out/headless/index.mjs.map +2 -2
  3. package/out/main/index.js +12 -5
  4. package/out/preload/index.js +2 -0
  5. package/out/renderer/assets/{cssMode-D5dPwEy5.js → cssMode-DkmdBgO7.js} +3 -3
  6. package/out/renderer/assets/{freemarker2-c5jJjQ9s.js → freemarker2-CI-gkP-3.js} +1 -1
  7. package/out/renderer/assets/{handlebars-BTbmOxx9.js → handlebars-D5tEqanR.js} +1 -1
  8. package/out/renderer/assets/{html-3cIIQcxO.js → html-fH93EYfn.js} +1 -1
  9. package/out/renderer/assets/{htmlMode-DYbpW1yY.js → htmlMode-CRicxcwK.js} +3 -3
  10. package/out/renderer/assets/{index-D2MUZin7.js → index-BOOvUI7u.js} +192 -23
  11. package/out/renderer/assets/{index-6KvOnYL1.css → index-ezC-iarf.css} +40 -0
  12. package/out/renderer/assets/{javascript-CDuCMm-6.js → javascript-D1Baz4fV.js} +2 -2
  13. package/out/renderer/assets/{jsonMode-COLqbq0s.js → jsonMode-Bquqf3QN.js} +3 -3
  14. package/out/renderer/assets/{liquid-BFcqZizB.js → liquid-ByOcPjBF.js} +1 -1
  15. package/out/renderer/assets/{lspLanguageFeatures-CbkEcL-z.js → lspLanguageFeatures-BxPLl0yy.js} +1 -1
  16. package/out/renderer/assets/{mdx-DyK93oEE.js → mdx-yuNgx0rM.js} +1 -1
  17. package/out/renderer/assets/{python-D4lCwSVr.js → python-2OakgLlA.js} +1 -1
  18. package/out/renderer/assets/{razor-DdkE9XVt.js → razor-DnIVMSwa.js} +1 -1
  19. package/out/renderer/assets/{tsMode-BrQ4Fsc-.js → tsMode-CRIrHuii.js} +1 -1
  20. package/out/renderer/assets/{typescript-BakbYMnC.js → typescript-DJ3C8Yly.js} +1 -1
  21. package/out/renderer/assets/{xml-DHDW9Xhp.js → xml-CalvD5_C.js} +1 -1
  22. package/out/renderer/assets/{yaml-1Ayv_J3q.js → yaml-Cgs8pdVp.js} +1 -1
  23. package/out/renderer/index.html +2 -2
  24. package/package.json +1 -1
  25. package/src/main/index.ts +8 -1
  26. package/src/main/workerWs.ts +8 -6
  27. package/src/preload/index.ts +4 -0
  28. package/src/renderer/App.tsx +1 -0
  29. package/src/renderer/components/TerminalPanel.tsx +95 -2
  30. package/src/renderer/lib/tableToHtml.ts +146 -0
  31. package/src/renderer/styles.css +40 -0
package/src/main/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { app, BrowserWindow, ipcMain, dialog, nativeImage } from 'electron'
1
+ import { app, BrowserWindow, ipcMain, dialog, nativeImage, clipboard } from 'electron'
2
2
  import path from 'path'
3
3
  import fs from 'fs'
4
4
 
@@ -145,6 +145,13 @@ function createWindow(): void {
145
145
 
146
146
  // ─── IPC Handlers ──────────────────────────────────
147
147
 
148
+ // Write a rich-HTML table (plus plain-text fallback) to the clipboard so it
149
+ // can be pasted as a formatted table into an email client.
150
+ ipcMain.handle('clipboard:writeTable', (_event, html: string, text: string) => {
151
+ clipboard.write({ html, text })
152
+ return { ok: true }
153
+ })
154
+
148
155
  ipcMain.handle('pty:spawn', async (_event, tabId: string, agent: AgentConfig, cwd: string) => {
149
156
  await orchestrator.spawnAgent(tabId, agent, cwd)
150
157
  return { ok: true }
@@ -47,13 +47,10 @@ export class WorkerWsClient {
47
47
  private workerId: string | null = null
48
48
  private _status: WorkerWsStatus = 'disconnected'
49
49
  private shouldReconnect = false
50
- private fingerprint: string
51
50
 
52
51
  constructor(events: WorkerWsEvents, baseUrl?: string) {
53
52
  this.events = events
54
53
  this.baseUrl = baseUrl || 'wss://app.ctlsurf.com'
55
- // Generate a stable machine fingerprint
56
- this.fingerprint = this.generateFingerprint()
57
54
  }
58
55
 
59
56
  get status(): WorkerWsStatus {
@@ -72,8 +69,12 @@ export class WorkerWsClient {
72
69
  this.baseUrl = url
73
70
  }
74
71
 
75
- private generateFingerprint(): string {
76
- const data = `${os.hostname()}:${os.userInfo().username}:${os.platform()}:${os.arch()}`
72
+ // Per-directory fingerprint: each working directory is a distinct worker, so
73
+ // multiple instances on the same machine (one per project) don't collide as a
74
+ // single worker server-side. cwd is included so the same folder maps to the
75
+ // same worker across restarts.
76
+ private generateFingerprint(cwd: string): string {
77
+ const data = `${os.hostname()}:${os.userInfo().username}:${os.platform()}:${os.arch()}:${cwd}`
77
78
  return crypto.createHash('sha256').update(data).digest('hex').slice(0, 32)
78
79
  }
79
80
 
@@ -85,7 +86,8 @@ export class WorkerWsClient {
85
86
  }
86
87
 
87
88
  connect(registration: WorkerRegistration): void {
88
- this.registration = { ...registration, fingerprint: this.fingerprint }
89
+ const fingerprint = this.generateFingerprint(registration.cwd)
90
+ this.registration = { ...registration, fingerprint }
89
91
  this.shouldReconnect = true
90
92
  this.doConnect()
91
93
  }
@@ -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 }> =>
@@ -71,6 +71,7 @@ declare global {
71
71
  getVersion: () => Promise<string>
72
72
  getUpdateInfo: () => Promise<UpdateInfo>
73
73
  onUpdateInfo: (callback: (info: UpdateInfo) => void) => () => void
74
+ copyEmailTable: (html: string, text: string) => Promise<{ ok: boolean }>
74
75
  }
75
76
  }
76
77
  }
@@ -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
- <div className="terminal-container" ref={containerRef} />
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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
+ }
@@ -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
+ }