@phenx-inc/ctlsurf 0.17.2 → 0.17.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +25 -2
  2. package/bin/ctlsurf-worker.js +243 -44
  3. package/out/headless/index.mjs +1033 -128
  4. package/out/headless/index.mjs.map +4 -4
  5. package/out/main/index.js +933 -17
  6. package/out/preload/index.js +15 -0
  7. package/out/renderer/assets/{cssMode-BtQxI2Eb.js → cssMode-BJfoToLX.js} +3 -3
  8. package/out/renderer/assets/{freemarker2-DRW5dmY5.js → freemarker2-T0eMtP1Z.js} +1 -1
  9. package/out/renderer/assets/{handlebars-CFLF3cP7.js → handlebars-Dbbib8fi.js} +1 -1
  10. package/out/renderer/assets/{html-DtnBOkwp.js → html-BRHT_oxc.js} +1 -1
  11. package/out/renderer/assets/{htmlMode-DMVysDCg.js → htmlMode-BRlZTxME.js} +3 -3
  12. package/out/renderer/assets/{index-CEjwKjtd.css → index-CBWAeUYW.css} +91 -0
  13. package/out/renderer/assets/{index-Dm70hk5s.js → index-RCAeuJTr.js} +476 -411
  14. package/out/renderer/assets/{javascript-ChlyA1sf.js → javascript-BbOZOxq9.js} +2 -2
  15. package/out/renderer/assets/{jsonMode-D1_oHzeN.js → jsonMode-CUvAeYcT.js} +3 -3
  16. package/out/renderer/assets/{liquid-D4ftdy1G.js → liquid-DRIACo_s.js} +1 -1
  17. package/out/renderer/assets/{lspLanguageFeatures-Ckqu5ubG.js → lspLanguageFeatures-Ds-3nH9p.js} +1 -1
  18. package/out/renderer/assets/{mdx-B9wsVYSC.js → mdx-D76jlpLO.js} +1 -1
  19. package/out/renderer/assets/{python-e23tD6IS.js → python-CMuRPKJi.js} +1 -1
  20. package/out/renderer/assets/{razor-CrDyXj3d.js → razor-DRoH5mal.js} +1 -1
  21. package/out/renderer/assets/{tsMode-BwGC5o4O.js → tsMode-dPJzgFD7.js} +1 -1
  22. package/out/renderer/assets/{typescript-Do-98d_O.js → typescript-D5USKnLj.js} +1 -1
  23. package/out/renderer/assets/{xml-Dpdu5R3m.js → xml-qYg1wt2P.js} +1 -1
  24. package/out/renderer/assets/{yaml-B0Zt7LCJ.js → yaml-Uc-CvnvL.js} +1 -1
  25. package/out/renderer/index.html +2 -2
  26. package/package.json +4 -2
  27. package/scripts/check-work-map-gpu.cjs +62 -0
  28. package/scripts/check-work-map-gpu.d.cts +13 -0
  29. package/scripts/postinstall.cjs +44 -0
  30. package/scripts/prepare-work-map.py +49 -0
  31. package/scripts/setup-ui.cjs +161 -0
  32. package/scripts/test-work-map-capability.cjs +45 -0
  33. package/scripts/test-work-map-ui.cjs +130 -0
  34. package/scripts/test-work-map.cjs +352 -0
  35. package/scripts/work-map-clef.py +81 -0
  36. package/scripts/work-map-setup.cjs +127 -0
  37. package/src/main/index.ts +43 -1
  38. package/src/main/observer/observer.ts +2 -0
  39. package/src/main/orchestrator.ts +39 -4
  40. package/src/main/transcripts.ts +35 -16
  41. package/src/main/workMap/clef.ts +156 -0
  42. package/src/main/workMap/normalize.ts +172 -0
  43. package/src/main/workMap/source.ts +28 -0
  44. package/src/main/workMap/tree.ts +199 -0
  45. package/src/main/workMap/workMap.ts +244 -0
  46. package/src/preload/index.ts +17 -0
  47. package/src/renderer/App.tsx +15 -16
  48. package/src/renderer/components/ObserverPanel.tsx +3 -0
  49. package/src/renderer/components/SettingsDialog.tsx +0 -104
  50. package/src/renderer/components/WorkMapPanel.tsx +235 -0
  51. package/src/renderer/components/WorkMapToggle.tsx +50 -0
  52. package/src/renderer/lib/workMapHighlights.ts +41 -0
  53. package/src/renderer/styles.css +91 -0
  54. package/src/shared/workMap.d.ts +70 -0
@@ -1,5 +1,5 @@
1
- import { conf as conf$1, language as language$1 } from "./typescript-Do-98d_O.js";
2
- import "./index-Dm70hk5s.js";
1
+ import { conf as conf$1, language as language$1 } from "./typescript-D5USKnLj.js";
2
+ import "./index-RCAeuJTr.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-Dm70hk5s.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-Ckqu5ubG.js";
3
- import { a, D, h, R, c, i, j, t, k } from "./lspLanguageFeatures-Ckqu5ubG.js";
1
+ import { c as createWebWorker, l as languages, e as editor } from "./index-RCAeuJTr.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-Ds-3nH9p.js";
3
+ import { a, D, h, R, c, i, j, t, k } from "./lspLanguageFeatures-Ds-3nH9p.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-Dm70hk5s.js";
1
+ import { l as languages } from "./index-RCAeuJTr.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-Dm70hk5s.js";
1
+ import { R as Range$1, l as languages, e as editor, U as Uri, M as MarkerSeverity } from "./index-RCAeuJTr.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-Dm70hk5s.js";
1
+ import { l as languages } from "./index-RCAeuJTr.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  blockComment: ["{/*", "*/}"]
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-Dm70hk5s.js";
1
+ import { l as languages } from "./index-RCAeuJTr.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  lineComment: "#",
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-Dm70hk5s.js";
1
+ import { l as languages } from "./index-RCAeuJTr.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-Dm70hk5s.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-RCAeuJTr.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-Dm70hk5s.js";
1
+ import { l as languages } from "./index-RCAeuJTr.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-Dm70hk5s.js";
1
+ import { l as languages } from "./index-RCAeuJTr.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  blockComment: ["<!--", "-->"]
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-Dm70hk5s.js";
1
+ import { l as languages } from "./index-RCAeuJTr.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  lineComment: "#"
@@ -106,8 +106,8 @@
106
106
  :root[data-theme='catppuccin-latte'] .boot-splash-title { color: #2f3145; }
107
107
  :root[data-theme='catppuccin-latte'] .boot-splash-label { color: #7c7f93; }
108
108
  </style>
109
- <script type="module" crossorigin src="./assets/index-Dm70hk5s.js"></script>
110
- <link rel="stylesheet" crossorigin href="./assets/index-CEjwKjtd.css">
109
+ <script type="module" crossorigin src="./assets/index-RCAeuJTr.js"></script>
110
+ <link rel="stylesheet" crossorigin href="./assets/index-CBWAeUYW.css">
111
111
  </head>
112
112
  <body>
113
113
  <div id="boot-splash">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phenx-inc/ctlsurf",
3
- "version": "0.17.2",
3
+ "version": "0.17.4",
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": {
@@ -19,6 +19,8 @@
19
19
  "THIRD-PARTY-NOTICES.md"
20
20
  ],
21
21
  "scripts": {
22
+ "test:workmap": "node scripts/test-work-map-capability.cjs && node scripts/test-work-map.cjs",
23
+ "test:workmap:ui": "electron scripts/test-work-map-ui.cjs",
22
24
  "dev": "electron-vite dev",
23
25
  "dev:terminal": "esbuild src/main/headless.ts --bundle --platform=node --target=node18 --format=esm --outfile=out/headless/index.mjs --external:node-pty --external:ws --sourcemap && node out/headless/index.mjs",
24
26
  "build": "electron-vite build && npm run build:headless",
@@ -26,7 +28,7 @@
26
28
  "prepublishOnly": "npm run build",
27
29
  "preview": "electron-vite preview",
28
30
  "package": "electron-builder",
29
- "postinstall": "node-gyp rebuild --directory=node_modules/node-pty 2>/dev/null || true"
31
+ "postinstall": "node scripts/postinstall.cjs"
30
32
  },
31
33
  "keywords": [
32
34
  "ctlsurf",
@@ -0,0 +1,62 @@
1
+ // Shared by npm postinstall and the desktop startup check. No model/runtime downloads.
2
+
3
+ function initialCapability({ desktop = false, platform = process.platform, arch = process.arch } = {}) {
4
+ if (!desktop) return { state: 'unavailable', reason: 'Work map is available only in the desktop app.' }
5
+ if (platform !== 'darwin' || arch !== 'arm64') return { state: 'unavailable', reason: 'Work map currently requires an Apple Silicon Mac with Metal GPU support.' }
6
+ return { state: 'checking', reason: 'Checking Apple GPU availability…' }
7
+ }
8
+
9
+ function appleGpuFromReport(report) {
10
+ if (!Array.isArray(report?.SPDisplaysDataType)) return null
11
+ return report.SPDisplaysDataType.find(gpu => {
12
+ const apple = gpu.spdisplays_vendor === 'sppci_vendor_Apple' || /^Apple M\d/i.test(gpu.sppci_model || gpu._name || '')
13
+ const metal = String(gpu.spdisplays_metal || gpu.spdisplays_metalsupport || gpu.spdisplays_mtlgpufamilysupport || '')
14
+ return apple && /supported|metal/i.test(metal) && !/unsupported|not.supported|unavailable/i.test(metal)
15
+ }) || null
16
+ }
17
+
18
+ function usableElectronGpu(info) {
19
+ if (info?.auxAttributes?.softwareRendering === true) return false
20
+ return Array.isArray(info?.gpuDevice) && info.gpuDevice.some(gpu => gpu.active === true
21
+ && (gpu.vendorId === 0x106b || /Apple/i.test(gpu.deviceString || gpu.vendorString || '')))
22
+ }
23
+
24
+ function readGpuReport() {
25
+ return new Promise((resolve, reject) => {
26
+ const { execFile } = require('node:child_process')
27
+ execFile('/usr/sbin/system_profiler', ['SPDisplaysDataType', '-json'], { timeout: 5000, maxBuffer: 1024 * 1024 }, (error, output) => {
28
+ if (error) { reject(error); return }
29
+ try { resolve(JSON.parse(output)) } catch (error) { reject(error) }
30
+ })
31
+ })
32
+ }
33
+
34
+ async function detectCapability(options = {}) {
35
+ const initial = initialCapability(options)
36
+ if (initial.state !== 'checking') return initial
37
+ try {
38
+ const gpu = appleGpuFromReport(await (options.readGpuReport || readGpuReport)())
39
+ if (!gpu) return { state: 'unavailable', reason: 'No Apple GPU with Metal support was detected.' }
40
+ if (options.verifyGpu) {
41
+ let timer
42
+ const usable = await Promise.race([
43
+ options.verifyGpu(),
44
+ new Promise(resolve => { timer = setTimeout(() => resolve(false), 5000) }),
45
+ ]).finally(() => clearTimeout(timer))
46
+ if (!usable) return { state: 'unavailable', reason: 'The Apple GPU is unavailable to the desktop app.' }
47
+ }
48
+ return { state: 'available', reason: null, gpuName: String(gpu.sppci_model || gpu._name || 'Apple GPU') }
49
+ } catch {
50
+ return { state: 'unavailable', reason: 'Apple GPU availability could not be verified. Work map is disabled for this launch.' }
51
+ }
52
+ }
53
+
54
+ module.exports = { initialCapability, appleGpuFromReport, usableElectronGpu, detectCapability }
55
+
56
+ if (require.main === module) {
57
+ detectCapability({ desktop: true }).then(capability => {
58
+ console.log(capability.state === 'available'
59
+ ? `[ctlsurf] Work map hardware detected: ${capability.gpuName}. The desktop app checks GPU access again at startup.`
60
+ : `[ctlsurf] ${capability.reason} Other app features remain available.`)
61
+ }).catch(() => {}) // Optional feature detection never fails package installation.
62
+ }
@@ -0,0 +1,13 @@
1
+ import type { MapCapability } from '../src/shared/workMap'
2
+
3
+ interface CapabilityOptions {
4
+ desktop?: boolean
5
+ platform?: string
6
+ arch?: string
7
+ readGpuReport?: () => Promise<unknown>
8
+ verifyGpu?: () => Promise<boolean>
9
+ }
10
+ export function initialCapability(options?: CapabilityOptions): MapCapability
11
+ export function appleGpuFromReport(report: unknown): Record<string, unknown> | null
12
+ export function usableElectronGpu(info: unknown): boolean
13
+ export function detectCapability(options?: CapabilityOptions): Promise<MapCapability>
@@ -0,0 +1,44 @@
1
+ const fs = require('node:fs')
2
+ const { spawnSync } = require('node:child_process')
3
+ const { detectCapability } = require('./check-work-map-gpu.cjs')
4
+ const { banner, formatBytes, colors: { R, D, G, C } } = require('./setup-ui.cjs')
5
+ const { MODEL_BYTES } = require('./work-map-setup.cjs')
6
+ const { version } = require('../package.json')
7
+
8
+ // Keep the existing best-effort native rebuild, including on Windows.
9
+ try {
10
+ spawnSync(process.platform === 'win32' ? 'node-gyp.cmd' : 'node-gyp', ['rebuild', '--directory=node_modules/node-pty'], {
11
+ stdio: 'ignore', shell: process.platform === 'win32',
12
+ })
13
+ } catch { /* The launcher also handles missing native builds. */ }
14
+
15
+ // npm hides lifecycle-script output (foreground-scripts=false), so the banner is written
16
+ // straight to the terminal. No terminal (CI, Windows, detached) → the plain log line only.
17
+ function openTty() {
18
+ if (process.platform === 'win32' || process.env.CI) return null
19
+ try { return fs.openSync('/dev/tty', 'w') } catch { return null }
20
+ }
21
+
22
+ detectCapability({ desktop: true }).then(capability => {
23
+ const available = capability.state === 'available'
24
+ const tty = openTty()
25
+ if (tty === null) {
26
+ console.log(available
27
+ ? `[ctlsurf] Work map hardware detected: ${capability.gpuName}. GPU access is checked again by the desktop app.`
28
+ : `[ctlsurf] ${capability.reason} Other app features remain available.`)
29
+ return
30
+ }
31
+ const gpu = available
32
+ ? `${G}✓${R} ${capability.gpuName} · Metal ${D}— work map capable${R}`
33
+ : `${D}– ${capability.reason}${R}`
34
+ const next = available
35
+ ? `Electron runtime + optional work map model ${D}(Clef-Flash, ${formatBytes(MODEL_BYTES)})${R}`
36
+ : 'Electron runtime'
37
+ try {
38
+ fs.writeSync(tty, '\r\x1b[2K' + banner(version)
39
+ + ` ${G}✓${R} installed\n ${gpu}\n\n`
40
+ + ` Run ${C}ctlsurf${R} to finish setup: ${next}\n\n`)
41
+ } finally {
42
+ fs.closeSync(tty)
43
+ }
44
+ }).catch(() => {})
@@ -0,0 +1,49 @@
1
+ """Prepare only the local Clef-Flash engine. Requires uv and Python 3.12 on Apple Silicon."""
2
+ import argparse
3
+ import json
4
+ from pathlib import Path
5
+ import os
6
+ import platform
7
+ import subprocess
8
+ import sys
9
+
10
+
11
+ def main():
12
+ parser = argparse.ArgumentParser(description=__doc__)
13
+ parser.add_argument('--directory', type=Path, default=Path.home() / '.ctlsurf' / 'clef-flash')
14
+ parser.add_argument('--progress', action='store_true', help='print ::phase markers for the ctlsurf launcher')
15
+ args = parser.parse_args()
16
+
17
+ def phase(name):
18
+ if args.progress:
19
+ print(f'::phase {name}', flush=True)
20
+
21
+ if sys.platform != 'darwin' or platform.machine() != 'arm64':
22
+ parser.error('Local Clef-Flash requires an Apple Silicon Mac')
23
+ root = args.directory.resolve()
24
+ root.mkdir(parents=True, exist_ok=True)
25
+ python = root / 'env' / 'bin' / 'python'
26
+ model = root / 'model'
27
+ env = {**os.environ, 'UV_CACHE_DIR': str(root / 'uv'), 'HF_HOME': str(root / 'huggingface'),
28
+ 'HF_HUB_DISABLE_TELEMETRY': '1', 'TOKENIZERS_PARALLELISM': 'false'}
29
+ if args.progress:
30
+ # The launcher measures bytes on disk; hf-xet only writes each file once it is complete.
31
+ env.update(HF_HUB_DISABLE_PROGRESS_BARS='1', HF_HUB_DISABLE_XET='1')
32
+ phase('env')
33
+ if not python.exists():
34
+ subprocess.run(['uv', 'venv', '--python', '3.12', str(root / 'env')], env=env, check=True)
35
+ phase('deps')
36
+ subprocess.run(['uv', 'pip', 'install', '--python', str(python), 'huggingface_hub',
37
+ 'mlx==0.32.3', 'mlx-lm==0.32.0', 'mlx-vlm==0.7.4'], env=env, check=True)
38
+ phase('model')
39
+ subprocess.run([str(python), '-c',
40
+ 'from huggingface_hub import snapshot_download; import sys; '
41
+ 'snapshot_download("mlx-community/clef-flash-8bit", '
42
+ 'revision="5a195dc147926b63b1d139691ed4480ca9f92c0e", local_dir=sys.argv[1], '
43
+ 'ignore_patterns=["*.png","*.jpg","*.parquet","*.csv","*.ipynb"])', str(model)], env=env, check=True)
44
+ print('\nUse these paths in Work map → Local model setup:')
45
+ print(json.dumps({'pythonPath': str(python), 'modelPath': str(model)}, indent=2))
46
+
47
+
48
+ if __name__ == '__main__':
49
+ main()
@@ -0,0 +1,161 @@
1
+ // Terminal UI shared by the npm postinstall banner and the launcher's first-run setup.
2
+ // Live redraw only on a TTY; otherwise each step prints one plain line when it settles.
3
+
4
+ const R = '\x1b[0m'
5
+ const D = '\x1b[90m'
6
+ const G = '\x1b[32m'
7
+ const Y = '\x1b[33m'
8
+ const C = '\x1b[36m'
9
+
10
+ const LOGO = [
11
+ '╔═╗╔╦╗╦ ╔═╗╦ ╦╦═╗╔═╗',
12
+ '║ ║ ║ ╚═╗║ ║╠╦╝╠╣ ',
13
+ '╚═╝ ╩ ╩═╝╚═╝╚═╝╩╚═╚ ',
14
+ ]
15
+ const LOGO_COLORS = [51, 45, 39] // 256-color cyan → blue, one per row
16
+
17
+ function banner(version) {
18
+ const logo = LOGO.map((line, i) => ` \x1b[38;5;${LOGO_COLORS[i]}m${line}${R}`)
19
+ return ['', ...logo, ` ${D}the terminal that remembers · v${version}${R}`, '', ''].join('\n')
20
+ }
21
+
22
+ function formatBytes(n) {
23
+ if (n >= 1e9) return `${(n / 1e9).toFixed(1)} GB`
24
+ if (n >= 1e6) return `${Math.round(n / 1e6)} MB`
25
+ return `${Math.round(n / 1e3)} KB`
26
+ }
27
+
28
+ function formatDuration(ms) {
29
+ const s = Math.max(0, Math.round(ms / 1000))
30
+ const m = Math.floor(s / 60)
31
+ return m ? `${m}m${String(s % 60).padStart(2, '0')}s` : `${s}s`
32
+ }
33
+
34
+ const SPIN = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
35
+ const ICONS = {
36
+ pending: `${D}·${R}`, done: `${G}✓${R}`, fail: `${Y}✗${R}`,
37
+ warn: `${Y}!${R}`, skip: `${D}–${R}`, ask: `${C}?${R}`,
38
+ }
39
+ const SETTLED = new Set(['done', 'fail', 'warn', 'skip'])
40
+ const BAR = 16
41
+ const LABEL = 23
42
+
43
+ // progress: undefined → no bar, null → indeterminate sweep, 0..1 → filled bar with percent
44
+ function bar(progress, frame) {
45
+ if (progress === null) {
46
+ const span = BAR - 4
47
+ const pos = Math.abs((frame % (span * 2)) - span)
48
+ return `${D}▕${'░'.repeat(pos)}${C}▓██▓${D}${'░'.repeat(span - pos)}▏${R}`
49
+ }
50
+ const p = Math.min(1, Math.max(0, progress))
51
+ const n = Math.round(p * BAR)
52
+ return `${D}▕${C}${'█'.repeat(n)}${D}${'░'.repeat(BAR - n)}▏${R} ${String(Math.floor(p * 100)).padStart(3)}%`
53
+ }
54
+
55
+ // Pad or truncate to a visible width, skipping ANSI escapes.
56
+ function fit(text, width) {
57
+ const parts = text.match(/\x1b\[[0-9;?]*[A-Za-z]|[\s\S]/gu) || []
58
+ const visible = parts.filter(p => p[0] !== '\x1b').length
59
+ let out = ''
60
+ let n = 0
61
+ for (const p of parts) {
62
+ if (p[0] === '\x1b') { out += p; continue }
63
+ if (visible > width && n === width - 1) { out += '…'; n++; break }
64
+ out += p
65
+ n++
66
+ }
67
+ return out + R + ' '.repeat(Math.max(0, width - n))
68
+ }
69
+
70
+ class Steps {
71
+ constructor(title, labels, out = process.stdout) {
72
+ this.title = title
73
+ this.out = out
74
+ this.live = !!out.isTTY
75
+ this.steps = labels.map(label => ({ label, state: 'pending', note: '', detail: '', progress: undefined, printed: false }))
76
+ this.drawn = 0
77
+ this.frame = 0
78
+ this.timer = null
79
+ this.onSigint = () => { this.finish(); this.out.write('\n'); process.exit(130) }
80
+ }
81
+
82
+ start() {
83
+ if (this.live) {
84
+ this.out.write('\x1b[?25l')
85
+ this.timer = setInterval(() => { this.frame++; this.render() }, 80)
86
+ process.on('SIGINT', this.onSigint)
87
+ }
88
+ this.render()
89
+ return this
90
+ }
91
+
92
+ set(i, patch) {
93
+ const step = this.steps[i]
94
+ Object.assign(step, patch)
95
+ if (!SETTLED.has(step.state)) step.printed = false
96
+ if (!this.live && SETTLED.has(step.state) && !step.printed) {
97
+ step.printed = true
98
+ const note = step.note ? ` ${D}${step.note}${R}` : ''
99
+ const detail = step.detail ? `\n ${D}${step.detail}${R}` : ''
100
+ this.out.write(` ${ICONS[step.state]} ${step.label}${note}${detail}\n`)
101
+ }
102
+ this.render()
103
+ }
104
+
105
+ row(step) {
106
+ const icon = step.state === 'run' ? `${C}${SPIN[this.frame % SPIN.length]}${R}` : ICONS[step.state]
107
+ const label = step.state === 'pending' ? `${D}${step.label}${R}` : step.label
108
+ let right = step.state === 'run' && step.progress !== undefined ? bar(step.progress, this.frame) + ' ' : ''
109
+ if (step.note) right += `${D}${step.note}${R}`
110
+ return `${icon} ${fit(label, LABEL)} ${right}`
111
+ }
112
+
113
+ render() {
114
+ if (!this.live) return
115
+ const width = Math.max(44, Math.min(68, (this.out.columns || 80) - 6))
116
+ const lines = [`${D}┌─ ${R}${this.title}${D} ${'─'.repeat(Math.max(0, width - 1 - this.title.length))}┐${R}`]
117
+ for (const step of this.steps) {
118
+ lines.push(`${D}│${R} ${fit(this.row(step), width)} ${D}│${R}`)
119
+ if (step.detail) lines.push(`${D}│${R} ${fit(` ${D}${step.detail}`, width)} ${D}│${R}`)
120
+ }
121
+ lines.push(`${D}└${'─'.repeat(width + 2)}┘${R}`)
122
+ const up = this.drawn ? `\x1b[${this.drawn}A\r\x1b[J` : ''
123
+ this.out.write(up + lines.map(l => ` ${l}`).join('\n') + '\n')
124
+ this.drawn = lines.length
125
+ }
126
+
127
+ // Ask below the box, then fold the prompt lines back into the next redraw.
128
+ async confirm(lines, question, defaultYes = true) {
129
+ if (this.timer) { clearInterval(this.timer); this.timer = null }
130
+ this.render()
131
+ const text = lines.map(l => ` ${l}`).join('\n') + '\n'
132
+ if (this.live) this.out.write('\x1b[?25h')
133
+ this.out.write('\n' + text)
134
+ const answer = await new Promise(resolve => {
135
+ const readline = require('readline')
136
+ const rl = readline.createInterface({ input: process.stdin, output: this.out })
137
+ rl.on('SIGINT', () => { rl.close(); process.kill(process.pid, 'SIGINT') })
138
+ rl.question(` ${question} ${D}${defaultYes ? '(Y/n)' : '(y/N)'}${R} `, a => { rl.close(); resolve(a.trim()) })
139
+ })
140
+ if (this.live) {
141
+ this.drawn += lines.length + 2
142
+ this.out.write('\x1b[?25l')
143
+ this.timer = setInterval(() => { this.frame++; this.render() }, 80)
144
+ this.render()
145
+ } else {
146
+ this.out.write('\n')
147
+ }
148
+ return answer ? /^y(es)?$/i.test(answer) : defaultYes
149
+ }
150
+
151
+ finish() {
152
+ if (this.timer) { clearInterval(this.timer); this.timer = null }
153
+ this.render()
154
+ if (this.live) {
155
+ this.out.write('\x1b[?25h')
156
+ process.removeListener('SIGINT', this.onSigint)
157
+ }
158
+ }
159
+ }
160
+
161
+ module.exports = { banner, formatBytes, formatDuration, Steps, colors: { R, D, G, Y, C } }
@@ -0,0 +1,45 @@
1
+ const assert = require('node:assert/strict')
2
+ const { initialCapability, appleGpuFromReport, usableElectronGpu, detectCapability } = require('./check-work-map-gpu.cjs')
3
+
4
+ async function main() {
5
+ const report = { SPDisplaysDataType: [{ _name: 'Apple M5 Max', spdisplays_vendor: 'sppci_vendor_Apple', spdisplays_metal: 'spdisplays_supported' }] }
6
+ assert(appleGpuFromReport(report))
7
+ assert(appleGpuFromReport({ SPDisplaysDataType: [{ _name: 'Apple M5 Max', spdisplays_mtlgpufamilysupport: 'spdisplays_metal4' }] }))
8
+ assert.equal(appleGpuFromReport({ SPDisplaysDataType: [{ ...report.SPDisplaysDataType[0], spdisplays_metal: 'spdisplays_unsupported' }] }), null)
9
+ assert.equal(appleGpuFromReport({ SPDisplaysDataType: [{ _name: 'Intel GPU', spdisplays_metal: 'spdisplays_supported' }] }), null)
10
+ assert.equal(appleGpuFromReport(null), null)
11
+ assert.equal(usableElectronGpu({ gpuDevice: [{ active: true, vendorId: 0x106b }] }), true)
12
+ assert.equal(usableElectronGpu({ gpuDevice: [{ active: true, vendorId: 0x106b }], auxAttributes: { softwareRendering: true } }), false)
13
+ assert.equal(usableElectronGpu({ gpuDevice: [{ active: false, vendorId: 0x106b }] }), false)
14
+
15
+ let probes = 0
16
+ const readGpuReport = async () => { probes++; return report }
17
+ for (const options of [
18
+ { desktop: false, platform: 'darwin', arch: 'arm64' },
19
+ { desktop: true, platform: 'darwin', arch: 'x64' },
20
+ { desktop: true, platform: 'win32', arch: 'arm64' },
21
+ { desktop: true, platform: 'linux', arch: 'x64' },
22
+ ]) {
23
+ assert.equal(initialCapability(options).state, 'unavailable')
24
+ assert.equal((await detectCapability({ ...options, readGpuReport })).state, 'unavailable')
25
+ }
26
+ assert.equal(probes, 0, 'terminal and unsupported platforms never launch a GPU probe')
27
+ const options = { desktop: true, platform: 'darwin', arch: 'arm64', readGpuReport }
28
+ assert.equal(initialCapability(options).state, 'checking')
29
+ assert.equal((await detectCapability({ ...options, verifyGpu: async () => true })).state, 'available')
30
+ assert.equal((await detectCapability({ ...options, verifyGpu: async () => false })).state, 'unavailable')
31
+ assert.equal((await detectCapability({ ...options, readGpuReport: async () => { throw new Error('probe failed') } })).state, 'unavailable')
32
+ // The headless output is ESM: importing shared detection must not execute CJS-only GPU code.
33
+ const fs = require('node:fs/promises')
34
+ const path = require('node:path')
35
+ const root = await fs.mkdtemp(path.join(require('node:os').tmpdir(), 'ctlsurf-gpu-esm-'))
36
+ try {
37
+ const file = path.join(root, 'probe.mjs')
38
+ await require('esbuild').build({ stdin: { contents: "export { initialCapability } from './scripts/check-work-map-gpu.cjs'", resolveDir: path.resolve(__dirname, '..') },
39
+ bundle: true, platform: 'node', format: 'esm', outfile: file })
40
+ const probe = await import(require('node:url').pathToFileURL(file).href)
41
+ assert.equal(probe.initialCapability({ desktop: false }).state, 'unavailable')
42
+ } finally { await fs.rm(root, { recursive: true, force: true }) }
43
+ console.log('PASS Apple Metal detection, desktop GPU access, software rendering, platform and terminal exclusions')
44
+ }
45
+ main().catch(error => { console.error(error); process.exitCode = 1 })
@@ -0,0 +1,130 @@
1
+ // Render the real panel in an isolated Electron window, with a deterministic IPC fixture.
2
+ const { app, BrowserWindow } = require('electron')
3
+ const { build } = require('esbuild')
4
+ const fs = require('node:fs/promises')
5
+ const path = require('node:path')
6
+ const os = require('node:os')
7
+ const assert = require('node:assert/strict')
8
+ app.on('window-all-closed', () => {})
9
+
10
+ async function main() {
11
+ const repo = path.resolve(__dirname, '..')
12
+ const temp = await fs.mkdtemp(path.join(os.tmpdir(), 'ctlsurf-map-ui-'))
13
+ app.setPath('userData', path.join(temp, 'profile'))
14
+ let win
15
+ try {
16
+ await build({ stdin: { resolveDir: repo, loader: 'tsx', contents: String.raw`
17
+ import React from 'react'; import {createRoot} from 'react-dom/client';
18
+ import {WorkMapPanel} from './src/renderer/components/WorkMapPanel';
19
+ import {WorkMapToggle} from './src/renderer/components/WorkMapToggle';
20
+ import './src/renderer/styles.css';
21
+ const ts = new Date().toISOString();
22
+ const evidence = (i) => [{id:'event-'+i,ts,kind:'tool',tool:'shell',toolId:'call-'+i,source:'rollout-example.jsonl',text:'npm run test -- terminal-scroll\nProcess exited with code 1'}];
23
+ const nodes = [
24
+ {id:'goal',parentId:null,kind:'goal',title:'Keep the terminal steady while reading older output',activity:'planning',status:'unknown',ts,evidence:[]},
25
+ {id:'branch',parentId:'goal',kind:'branch',title:'Terminal scrolling',activity:'planning',status:'unknown',ts,evidence:[]},
26
+ {id:'thought',parentId:'branch',kind:'step',title:'Preserve the reader’s position instead of forcing the viewport down.',focus:{text:'Preserve the reader’s position instead of forcing the viewport down',reason:'so new output does not interrupt reading older activity.'},activity:'thinking',intent:'decision',status:'unknown',ts,evidence:[{id:'thought-event',ts,kind:'thinking',source:'rollout-example.jsonl',text:'Preserve the reader’s position instead of forcing the viewport down, so new output does not interrupt reading older activity.'}],placement:'clef-flash',confidence:.93},
27
+ ...Array.from({length:28},(_,i)=>({id:'step-'+i,parentId:'branch',contextId:'thought',kind:'step',title:['Inspect the viewport and scroll listeners','Preserve the reader’s position during new output','Check terminal scroll regression'][i%3],activity:['investigating','editing','testing'][i%3],status:i===2?'failed':i===27?'running':'succeeded',ts,evidence:evidence(i),placement:'clef-flash',confidence:.93}))
28
+ ];
29
+ window.exampleNodes = nodes;
30
+ let capability={state:'available',reason:null,gpuName:'Test GPU'};
31
+ let capabilityListener;
32
+ window.emitCapability = (next) => {capability=next; capabilityListener?.(next);window.emitMap({capability:next,settings:{...snapshot.settings,enabled:next.state==='available'}})};
33
+ let snapshot = {revision:1,tabId:'test',agentName:'Codex',supported:true,ended:false,state:'busy',settings:{enabled:true,engine:'clef-flash',pythonPath:'/local/python',modelPath:'/local/clef-flash'},runtime:'ready',error:null,nodes,dropped:0,thinkingStatus:'available',capability};
34
+ let listener;
35
+ window.emitMap = (update) => { snapshot={...snapshot,...update,revision:snapshot.revision+1}; listener?.(snapshot); };
36
+ window.mapSnapshot = () => snapshot;
37
+ window.worker = {
38
+ getWorkMap:async()=>snapshot,
39
+ getWorkMapCapability:async()=>capability,
40
+ onWorkMapCapability:callback=>{capabilityListener=callback;return()=>{capabilityListener=null}},
41
+ getWorkMapSource:async(_,eventId)=>({source:'rollout-example.jsonl',text:'Earlier context before the choice.\n\nPreserve the reader’s position instead of forcing the viewport down, so new output does not interrupt reading older activity.\n\nLater context after the choice, which is not highlighted.'}),
42
+ setWorkMap:async(_,settings)=>{window.emitMap({settings:{...snapshot.settings,...settings}});return snapshot},
43
+ clearWorkMap:async()=>{window.emitMap({nodes:[]});return snapshot},
44
+ onWorkMapUpdate:callback=>{listener=callback;return()=>{listener=null}},
45
+ };
46
+ createRoot(document.getElementById('root')).render(<><WorkMapToggle visible onToggle={()=>{}} onUnavailable={()=>{}} /><div style={{flex:1,minHeight:0}}><WorkMapPanel tabId="test" /></div></>);
47
+ ` }, bundle: true, platform: 'browser', jsx: 'automatic', outfile: path.join(temp, 'panel.js') })
48
+ await fs.writeFile(path.join(temp, 'index.html'), `<!doctype html><html data-theme="tokyo-night"><head><meta charset="utf-8"><link rel="stylesheet" href="panel.css"><style>html,body,#root{height:100%;width:100%;margin:0;overflow:hidden}#root{display:flex;flex-direction:column}body{font-family:-apple-system,BlinkMacSystemFont,sans-serif}</style></head><body><div id="root"></div><script src="panel.js"></script></body></html>`)
49
+ await app.whenReady()
50
+ if (process.platform === 'darwin' && process.arch === 'arm64') {
51
+ const { detectCapability, usableElectronGpu } = require('./check-work-map-gpu.cjs')
52
+ const actual = await detectCapability({ desktop: true, verifyGpu: async () => usableElectronGpu(await app.getGPUInfo('complete')) })
53
+ assert.equal(actual.state, 'available', JSON.stringify(actual))
54
+ console.log('PASS actual desktop Apple GPU access:', actual.gpuName)
55
+ }
56
+ win = new BrowserWindow({ show: false, width: 430, height: 860, webPreferences: { backgroundThrottling: false } })
57
+ await win.loadFile(path.join(temp, 'index.html'))
58
+ const run = code => win.webContents.executeJavaScript(code)
59
+ await run(`new Promise(resolve=>setTimeout(resolve,150))`)
60
+ assert.equal(await run(`document.querySelectorAll('.work-map-step').length`), 29, 'choices and actions must share one thread')
61
+ assert.equal(await run(`document.querySelector('.work-map-view')`), null, 'there are no separate decision/activity tabs')
62
+ assert.match(await run(`document.querySelector('.work-map-reason').textContent`), /Why.*does not interrupt/)
63
+ assert.equal(await run(`document.querySelectorAll('.work-map-step.follows-intent').length`), 28)
64
+ const order = await run(`[...document.querySelectorAll('.work-map-step-title')].map(e=>e.textContent)`)
65
+ await run(`window.emitMap({nodes:[...window.mapSnapshot().nodes.map(n=>n.id==='step-1'?{...n,parentId:'other-topic'}:n),{id:'other-topic',kind:'branch',parentId:'goal',title:'Another topic',activity:'planning',status:'unknown',ts:new Date().toISOString(),evidence:[]}]})`)
66
+ await run(`new Promise(resolve=>setTimeout(resolve,30))`)
67
+ assert.deepEqual(await run(`[...document.querySelectorAll('.work-map-step-title')].map(e=>e.textContent)`), order, 'model topic changes cannot reorder the shared thread')
68
+ await run(`document.querySelector('.work-map-step-button').click()`)
69
+ await run(`new Promise(resolve=>setTimeout(resolve,30))`)
70
+ assert.match(await run(`document.querySelector('.work-map-evidence').textContent`), /Full thinking/)
71
+ assert.match(await run(`document.querySelector('.work-map-source-text').textContent`), /Earlier context.*\n[\s\S]*Later context/)
72
+ assert.equal(await run(`document.querySelectorAll('.work-map-source-text mark').length`), 2)
73
+ assert.match(await run(`document.querySelector('mark.choice').textContent`), /Preserve the reader/)
74
+ assert.match(await run(`document.querySelector('mark.reason').textContent`), /does not interrupt/)
75
+ assert.match(await run(`document.querySelector('.work-map-step.follows-intent').textContent`), /Follows thinking/)
76
+ assert.equal(await run(`document.querySelectorAll('.work-map-step').length`), 29)
77
+ assert.equal(await run(`document.querySelector('select').value`), 'clef-flash')
78
+ await run(`document.querySelector('.work-map-step-button').click()`)
79
+ await run(`new Promise(resolve=>setTimeout(resolve,30))`)
80
+ assert(await run(`(()=>{const el=document.querySelector('.work-map-scroll');return el.scrollHeight-el.scrollTop-el.clientHeight<5})()`))
81
+ await run(`document.querySelector('.work-map-scroll').scrollTop=0`)
82
+ await run(`new Promise(resolve=>setTimeout(resolve,80))`)
83
+ await run(`window.emitMap({nodes:[...window.mapSnapshot().nodes,{...window.mapSnapshot().nodes.filter(n=>n.kind==='step').at(-1),id:'new-step'}]})`)
84
+ await run(`new Promise(resolve=>setTimeout(resolve,80))`)
85
+ assert.equal(await run(`document.querySelector('.work-map-scroll').scrollTop`), 0, 'updates preserve scroll position while reading history')
86
+ assert(await run(`!!document.querySelector('.work-map-jump')`))
87
+ await run(`document.querySelector('.work-map-branch-heading').click()`)
88
+ await run(`new Promise(resolve=>setTimeout(resolve,30))`)
89
+ assert.equal(await run(`document.querySelectorAll('.work-map-step').length`), 0)
90
+ await run(`document.querySelector('.work-map-branch-heading').click()`)
91
+ await run(`new Promise(resolve=>setTimeout(resolve,30))`)
92
+ await run(`document.querySelector('.work-map-step-button').click()`)
93
+ await run(`new Promise(resolve=>setTimeout(resolve,30))`)
94
+ assert.match(await run(`document.querySelector('.work-map-evidence').textContent`), /rollout-example\.jsonl/)
95
+ assert.equal(await run(`document.querySelectorAll('.work-map-step.failed').length`), 1)
96
+ await run(`document.querySelector('[role=switch]').click()`)
97
+ await run(`new Promise(resolve=>setTimeout(resolve,30))`)
98
+ assert.equal(await run(`document.querySelector('[role=switch]').getAttribute('aria-checked')`), 'false')
99
+ await run(`document.querySelector('[role=switch]').click()`)
100
+ await run(`new Promise(resolve=>setTimeout(resolve,30))`)
101
+ await run(`document.querySelector('.work-map-scroll').scrollTop=0`)
102
+ await run(`new Promise(resolve=>setTimeout(resolve,30))`)
103
+ assert(await run(`document.documentElement.scrollWidth<=window.innerWidth`), 'no horizontal overflow at panel width')
104
+ const screenshot = process.env.WORK_MAP_SCREENSHOT || path.join(repo, 'experiments/live-map/.cache/work-map-panel.png')
105
+ await fs.mkdir(path.dirname(screenshot), { recursive: true })
106
+ await fs.writeFile(screenshot, (await win.webContents.capturePage()).toPNG())
107
+ await run(`document.querySelector('.work-map-footer button').click()`)
108
+ await run(`new Promise(resolve=>setTimeout(resolve,30))`)
109
+ assert.equal(await run(`document.querySelectorAll('.work-map-goal').length`), 0)
110
+ assert(await run(`!!document.querySelector('.work-map-empty')`))
111
+ await run(`window.emitMap({thinkingStatus:'unavailable',nodes:window.exampleNodes.filter(n=>n.id!=='thought')})`)
112
+ await run(`new Promise(resolve=>setTimeout(resolve,30))`)
113
+ assert.match(await run(`document.querySelector('.work-map-thinking-status').textContent`), /omits readable thinking/)
114
+ assert.equal(await run(`document.querySelectorAll('.work-map-step').length`), 28, 'activity remains visible when thinking is omitted')
115
+ assert.equal(await run(`document.querySelectorAll('.work-map-intent').length`), 0, 'tool activity must not become invented decisions when thinking is omitted')
116
+ for (const state of ['checking', 'unavailable']) {
117
+ await run(`window.emitCapability({state:${JSON.stringify(state)},reason:'GPU unavailable'})`)
118
+ await run(`new Promise(resolve=>setTimeout(resolve,30))`)
119
+ assert(await run(`document.querySelector('[aria-label="Work map"]').disabled`))
120
+ assert(await run(`document.querySelector('[role=switch]').disabled`))
121
+ assert(await run(`document.querySelector('select').disabled`))
122
+ }
123
+ console.log('PASS unified chronological thread, salient excerpts and reasons, source receipts, action links, omitted-thinking notice, controls and scroll')
124
+ console.log('Screenshot:', screenshot)
125
+ } finally {
126
+ win?.destroy()
127
+ await fs.rm(temp, { recursive: true, force: true, maxRetries: 3 })
128
+ }
129
+ }
130
+ main().then(() => app.exit(0), err => { console.error(err); app.exit(1) })