@phenx-inc/ctlsurf 0.17.3 → 0.17.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/README.md +5 -5
- package/bin/ctlsurf-worker.js +243 -44
- package/out/headless/index.mjs +1 -1
- package/out/headless/index.mjs.map +1 -1
- package/out/main/index.js +5 -1
- package/out/renderer/assets/{cssMode-BJfoToLX.js → cssMode-CRJW4vYa.js} +3 -3
- package/out/renderer/assets/{freemarker2-T0eMtP1Z.js → freemarker2-IgsORiC6.js} +1 -1
- package/out/renderer/assets/{handlebars-Dbbib8fi.js → handlebars-pDu2AR4b.js} +1 -1
- package/out/renderer/assets/{html-BRHT_oxc.js → html-DNF7NUE5.js} +1 -1
- package/out/renderer/assets/{htmlMode-BRlZTxME.js → htmlMode-gbfJICtW.js} +3 -3
- package/out/renderer/assets/{index-RCAeuJTr.js → index-CbuISbRh.js} +39 -31
- package/out/renderer/assets/{javascript-BbOZOxq9.js → javascript-CzPWOwBB.js} +2 -2
- package/out/renderer/assets/{jsonMode-CUvAeYcT.js → jsonMode-CWOEvILb.js} +3 -3
- package/out/renderer/assets/{liquid-DRIACo_s.js → liquid-CP-HUzpK.js} +1 -1
- package/out/renderer/assets/{lspLanguageFeatures-Ds-3nH9p.js → lspLanguageFeatures-DsiaqQq_.js} +1 -1
- package/out/renderer/assets/{mdx-D76jlpLO.js → mdx-DerBk5VK.js} +1 -1
- package/out/renderer/assets/{python-CMuRPKJi.js → python-DkUMRAVL.js} +1 -1
- package/out/renderer/assets/{razor-DRoH5mal.js → razor-C6ynuc0X.js} +1 -1
- package/out/renderer/assets/{tsMode-dPJzgFD7.js → tsMode-DXrOkNVu.js} +1 -1
- package/out/renderer/assets/{typescript-D5USKnLj.js → typescript-BmhhpI43.js} +1 -1
- package/out/renderer/assets/{xml-qYg1wt2P.js → xml-CTMRAgAB.js} +1 -1
- package/out/renderer/assets/{yaml-Uc-CvnvL.js → yaml-DYNNafNI.js} +1 -1
- package/out/renderer/index.html +1 -1
- package/package.json +1 -1
- package/scripts/postinstall.cjs +32 -3
- package/scripts/prepare-work-map.py +12 -0
- package/scripts/setup-ui.cjs +161 -0
- package/scripts/test-work-map-ui.cjs +9 -1
- package/scripts/work-map-setup.cjs +127 -0
- package/src/main/index.ts +9 -1
- package/src/renderer/components/WorkMapPanel.tsx +17 -9
|
@@ -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 } }
|
|
@@ -57,6 +57,11 @@ async function main() {
|
|
|
57
57
|
await win.loadFile(path.join(temp, 'index.html'))
|
|
58
58
|
const run = code => win.webContents.executeJavaScript(code)
|
|
59
59
|
await run(`new Promise(resolve=>setTimeout(resolve,150))`)
|
|
60
|
+
assert.equal(await run(`document.querySelector('.work-map-goal-heading').getAttribute('aria-expanded')`), 'true')
|
|
61
|
+
assert.equal(await run(`document.querySelector('.work-map-branch-heading').getAttribute('aria-expanded')`), 'false')
|
|
62
|
+
assert.equal(await run(`document.querySelectorAll('.work-map-step').length`), 0, 'event groups start collapsed')
|
|
63
|
+
await run(`document.querySelector('.work-map-branch-heading').click()`)
|
|
64
|
+
await run(`new Promise(resolve=>setTimeout(resolve,30))`)
|
|
60
65
|
assert.equal(await run(`document.querySelectorAll('.work-map-step').length`), 29, 'choices and actions must share one thread')
|
|
61
66
|
assert.equal(await run(`document.querySelector('.work-map-view')`), null, 'there are no separate decision/activity tabs')
|
|
62
67
|
assert.match(await run(`document.querySelector('.work-map-reason').textContent`), /Why.*does not interrupt/)
|
|
@@ -111,6 +116,9 @@ async function main() {
|
|
|
111
116
|
await run(`window.emitMap({thinkingStatus:'unavailable',nodes:window.exampleNodes.filter(n=>n.id!=='thought')})`)
|
|
112
117
|
await run(`new Promise(resolve=>setTimeout(resolve,30))`)
|
|
113
118
|
assert.match(await run(`document.querySelector('.work-map-thinking-status').textContent`), /omits readable thinking/)
|
|
119
|
+
assert.equal(await run(`document.querySelectorAll('.work-map-step').length`), 0, 'events collected after clearing also start collapsed')
|
|
120
|
+
await run(`document.querySelector('.work-map-branch-heading').click()`)
|
|
121
|
+
await run(`new Promise(resolve=>setTimeout(resolve,30))`)
|
|
114
122
|
assert.equal(await run(`document.querySelectorAll('.work-map-step').length`), 28, 'activity remains visible when thinking is omitted')
|
|
115
123
|
assert.equal(await run(`document.querySelectorAll('.work-map-intent').length`), 0, 'tool activity must not become invented decisions when thinking is omitted')
|
|
116
124
|
for (const state of ['checking', 'unavailable']) {
|
|
@@ -120,7 +128,7 @@ async function main() {
|
|
|
120
128
|
assert(await run(`document.querySelector('[role=switch]').disabled`))
|
|
121
129
|
assert(await run(`document.querySelector('select').disabled`))
|
|
122
130
|
}
|
|
123
|
-
console.log('PASS unified chronological thread, salient excerpts and reasons, source receipts, action links, omitted-thinking notice, controls and scroll')
|
|
131
|
+
console.log('PASS collapsed event groups, unified chronological thread, salient excerpts and reasons, source receipts, action links, omitted-thinking notice, controls and scroll')
|
|
124
132
|
console.log('Screenshot:', screenshot)
|
|
125
133
|
} finally {
|
|
126
134
|
win?.destroy()
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Launcher side of the Work map model: find an existing Clef-Flash runtime, or prepare one
|
|
2
|
+
// with prepare-work-map.py while reporting phases and downloaded bytes.
|
|
3
|
+
// The runtime lives outside node_modules, so `npm update` never removes or re-downloads it.
|
|
4
|
+
|
|
5
|
+
const fs = require('fs')
|
|
6
|
+
const path = require('path')
|
|
7
|
+
const os = require('os')
|
|
8
|
+
const { spawn } = require('child_process')
|
|
9
|
+
|
|
10
|
+
// mlx-community/clef-flash-8bit @ the revision pinned in prepare-work-map.py
|
|
11
|
+
const MODEL_BYTES = 10_690_311_092
|
|
12
|
+
// Weights + Python/MLX env (~0.6 GB) + uv cache, with headroom
|
|
13
|
+
const DISK_NEEDED = 12.5e9
|
|
14
|
+
const DEFAULT_DIR = path.join(os.homedir(), '.ctlsurf', 'clef-flash')
|
|
15
|
+
|
|
16
|
+
function defaultPaths() {
|
|
17
|
+
return { pythonPath: path.join(DEFAULT_DIR, 'env', 'bin', 'python'), modelPath: path.join(DEFAULT_DIR, 'model') }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Files of the pinned revision besides the indexed weight shards. An interrupted
|
|
21
|
+
// download can have clef_mlx.py (what ClefWorker checks) without the weights.
|
|
22
|
+
const REQUIRED_FILES = ['clef_mlx.py', 'config.json', 'tokenizer.json', 'joint_head.safetensors', 'model.safetensors.index.json']
|
|
23
|
+
|
|
24
|
+
function modelReady({ pythonPath, modelPath } = {}) {
|
|
25
|
+
if (!pythonPath || !modelPath || !fs.existsSync(pythonPath)) return false
|
|
26
|
+
try {
|
|
27
|
+
const index = JSON.parse(fs.readFileSync(path.join(modelPath, 'model.safetensors.index.json'), 'utf-8'))
|
|
28
|
+
return [...REQUIRED_FILES, ...Object.values(index.weight_map)].every(f => fs.existsSync(path.join(modelPath, f)))
|
|
29
|
+
} catch {
|
|
30
|
+
return false
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function findModel(saved = {}) {
|
|
35
|
+
return [
|
|
36
|
+
{ pythonPath: saved.pythonPath, modelPath: saved.modelPath },
|
|
37
|
+
{ pythonPath: process.env.CTLSURF_CLEF_PYTHON, modelPath: process.env.CTLSURF_CLEF_MODEL },
|
|
38
|
+
defaultPaths(),
|
|
39
|
+
].find(modelReady) || null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function findUv() {
|
|
43
|
+
const dirs = (process.env.PATH || '').split(path.delimiter)
|
|
44
|
+
.concat([path.join(os.homedir(), '.local', 'bin'), path.join(os.homedir(), '.cargo', 'bin'), '/opt/homebrew/bin', '/usr/local/bin'])
|
|
45
|
+
for (const dir of dirs) {
|
|
46
|
+
const uv = path.join(dir, 'uv')
|
|
47
|
+
try { fs.accessSync(uv, fs.constants.X_OK); return uv } catch { /* keep looking */ }
|
|
48
|
+
}
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function freeBytes() {
|
|
53
|
+
let dir = DEFAULT_DIR
|
|
54
|
+
while (!fs.existsSync(dir)) dir = path.dirname(dir)
|
|
55
|
+
try {
|
|
56
|
+
const s = fs.statfsSync(dir)
|
|
57
|
+
return s.bavail * s.bsize
|
|
58
|
+
} catch {
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Includes huggingface_hub's in-flight files under model/.cache, so the count grows during a download.
|
|
64
|
+
function dirBytes(dir) {
|
|
65
|
+
let total = 0
|
|
66
|
+
let entries
|
|
67
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return 0 }
|
|
68
|
+
for (const e of entries) {
|
|
69
|
+
const p = path.join(dir, e.name)
|
|
70
|
+
if (e.isDirectory()) total += dirBytes(p)
|
|
71
|
+
else if (e.isFile()) { try { total += fs.statSync(p).size } catch { /* moved mid-scan */ } }
|
|
72
|
+
}
|
|
73
|
+
return total
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// onPhase('env' | 'deps' | 'model'), onBytes(downloadedBytes). Resolves with the ready paths.
|
|
77
|
+
function prepareModel({ uv, onPhase = () => {}, onBytes = () => {} }) {
|
|
78
|
+
fs.mkdirSync(DEFAULT_DIR, { recursive: true })
|
|
79
|
+
const logPath = path.join(DEFAULT_DIR, 'prepare.log')
|
|
80
|
+
const log = fs.createWriteStream(logPath)
|
|
81
|
+
const script = path.join(__dirname, 'prepare-work-map.py')
|
|
82
|
+
const { modelPath } = defaultPaths()
|
|
83
|
+
|
|
84
|
+
return new Promise((resolve, reject) => {
|
|
85
|
+
// uv supplies Python 3.12, so no system python3 (or Xcode CLT prompt) is involved.
|
|
86
|
+
const child = spawn(uv, ['run', '--no-project', '--python', '3.12', script, '--progress', '--directory', DEFAULT_DIR], {
|
|
87
|
+
cwd: DEFAULT_DIR,
|
|
88
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
89
|
+
env: {
|
|
90
|
+
...process.env,
|
|
91
|
+
PATH: `${path.dirname(uv)}${path.delimiter}${process.env.PATH || ''}`,
|
|
92
|
+
UV_CACHE_DIR: path.join(DEFAULT_DIR, 'uv'),
|
|
93
|
+
},
|
|
94
|
+
})
|
|
95
|
+
let poll = null
|
|
96
|
+
let buffer = ''
|
|
97
|
+
child.stdout.on('data', chunk => {
|
|
98
|
+
buffer += String(chunk)
|
|
99
|
+
let newline
|
|
100
|
+
while ((newline = buffer.indexOf('\n')) >= 0) {
|
|
101
|
+
const line = buffer.slice(0, newline)
|
|
102
|
+
buffer = buffer.slice(newline + 1)
|
|
103
|
+
const phase = /^::phase (\w+)/.exec(line)
|
|
104
|
+
if (!phase) { log.write(line + '\n'); continue }
|
|
105
|
+
onPhase(phase[1])
|
|
106
|
+
if (phase[1] === 'model' && !poll) {
|
|
107
|
+
onBytes(dirBytes(modelPath))
|
|
108
|
+
poll = setInterval(() => onBytes(dirBytes(modelPath)), 500)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
child.stderr.pipe(log, { end: false })
|
|
113
|
+
child.on('error', err => { clearInterval(poll); log.end(); reject(err) })
|
|
114
|
+
child.on('close', code => {
|
|
115
|
+
clearInterval(poll)
|
|
116
|
+
log.end(() => {
|
|
117
|
+
const paths = defaultPaths()
|
|
118
|
+
if (code === 0 && modelReady(paths)) { resolve(paths); return }
|
|
119
|
+
let tail = ''
|
|
120
|
+
try { tail = fs.readFileSync(logPath, 'utf-8').trim().split('\n').slice(-1)[0] } catch { /* no log */ }
|
|
121
|
+
reject(new Error(tail || `prepare-work-map.py exited with code ${code}`))
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
module.exports = { MODEL_BYTES, DISK_NEEDED, DEFAULT_DIR, modelReady, findModel, findUv, freeBytes, prepareModel }
|
package/src/main/index.ts
CHANGED
|
@@ -247,9 +247,17 @@ const orchestrator = new Orchestrator(
|
|
|
247
247
|
updateProjectBadge(orchestrator.getActiveTabProjectPath() || orchestrator.cwd)
|
|
248
248
|
},
|
|
249
249
|
},
|
|
250
|
-
{ appRoot:
|
|
250
|
+
{ appRoot: workMapRoot(), desktop: true },
|
|
251
251
|
)
|
|
252
252
|
|
|
253
|
+
// app.isPackaged can't tell the two apart: the npm launcher renames Electron's
|
|
254
|
+
// binary to "ctlsurf", which makes it true there too. Only an electron-builder
|
|
255
|
+
// build ships the Clef script under resources/ (extraResources).
|
|
256
|
+
function workMapRoot(): string {
|
|
257
|
+
const resources = process.resourcesPath
|
|
258
|
+
return fs.existsSync(path.join(resources, 'scripts', 'work-map-clef.py')) ? resources : app.getAppPath()
|
|
259
|
+
}
|
|
260
|
+
|
|
253
261
|
// ─── Window ───────────────────────────────────────
|
|
254
262
|
|
|
255
263
|
function createWindow(): void {
|
|
@@ -10,7 +10,8 @@ export function WorkMapPanel({ tabId }: { tabId: string }) {
|
|
|
10
10
|
const [snapshot, setSnapshot] = useState<MapSnapshot | null>(null)
|
|
11
11
|
const [error, setError] = useState<string | null>(null)
|
|
12
12
|
const [saving, setSaving] = useState(false)
|
|
13
|
-
const [
|
|
13
|
+
const [collapsedGoals, setCollapsedGoals] = useState<Set<string>>(new Set())
|
|
14
|
+
const [expandedThreads, setExpandedThreads] = useState<Set<string>>(new Set())
|
|
14
15
|
const [selected, setSelected] = useState<string | null>(null)
|
|
15
16
|
const [following, setFollowing] = useState(true)
|
|
16
17
|
const [paths, setPaths] = useState({ pythonPath: '', modelPath: '' })
|
|
@@ -62,7 +63,14 @@ export function WorkMapPanel({ tabId }: { tabId: string }) {
|
|
|
62
63
|
finally { if (alive.current) setSaving(false) }
|
|
63
64
|
}
|
|
64
65
|
|
|
65
|
-
const
|
|
66
|
+
const toggleGoal = (id: string) => setCollapsedGoals(prev => {
|
|
67
|
+
const next = new Set(prev)
|
|
68
|
+
if (next.has(id)) next.delete(id)
|
|
69
|
+
else next.add(id)
|
|
70
|
+
return next
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
const toggleThread = (id: string) => setExpandedThreads(prev => {
|
|
66
74
|
const next = new Set(prev)
|
|
67
75
|
if (next.has(id)) next.delete(id)
|
|
68
76
|
else next.add(id)
|
|
@@ -188,20 +196,20 @@ export function WorkMapPanel({ tabId }: { tabId: string }) {
|
|
|
188
196
|
: 'Stated decisions, readable thinking, actions, and results appear in one continuous thread. Open any card to inspect its source.'}</p>
|
|
189
197
|
</div> : <div className="work-map-forest">
|
|
190
198
|
{goals.map((goal, i) => <article className="work-map-goal" key={goal.id}>
|
|
191
|
-
<button className="work-map-goal-heading" onClick={() =>
|
|
199
|
+
<button className="work-map-goal-heading" onClick={() => toggleGoal(goal.id)} aria-expanded={!collapsedGoals.has(goal.id)} title={goal.evidence[0]?.text}>
|
|
192
200
|
<span className="work-map-goal-icon" aria-hidden="true">◎</span><span><span className="work-map-kicker">GOAL {String(i + 1).padStart(2, '0')}</span><strong>{goal.title}</strong></span>
|
|
193
|
-
<span className="work-map-caret">{
|
|
201
|
+
<span className="work-map-caret">{collapsedGoals.has(goal.id) ? '▸' : '▾'}</span>
|
|
194
202
|
</button>
|
|
195
|
-
{!
|
|
203
|
+
{!collapsedGoals.has(goal.id) && <div className="work-map-branches">
|
|
196
204
|
{threadRuns(goal.id).map(run => {
|
|
197
205
|
const failed = run.nodes.some(n => n.status === 'failed')
|
|
198
206
|
return <div className="work-map-branch" key={run.id}>
|
|
199
|
-
<button className="work-map-branch-heading" onClick={() =>
|
|
207
|
+
<button className="work-map-branch-heading" onClick={() => toggleThread(run.id)} aria-expanded={expandedThreads.has(run.id)}>
|
|
200
208
|
<span className={`work-map-branch-point ${failed ? 'failed' : ''}`} />
|
|
201
209
|
<span className="work-map-branch-label">{run.label}</span><span className="work-map-count">{run.nodes.length}</span>
|
|
202
|
-
<span className="work-map-caret">{
|
|
210
|
+
<span className="work-map-caret">{expandedThreads.has(run.id) ? '▾' : '▸'}</span>
|
|
203
211
|
</button>
|
|
204
|
-
{
|
|
212
|
+
{expandedThreads.has(run.id) && <ul className="work-map-steps" aria-label="Activity thread">{run.nodes.map(renderStep)}</ul>}
|
|
205
213
|
</div>
|
|
206
214
|
})}
|
|
207
215
|
</div>}
|
|
@@ -213,7 +221,7 @@ export function WorkMapPanel({ tabId }: { tabId: string }) {
|
|
|
213
221
|
<footer className="work-map-footer"><span>{decisions.length} {decisions.length === 1 ? 'choice' : 'choices'} · {steps.length} events · local</span>
|
|
214
222
|
<button disabled={!steps.length || saving} onClick={async () => {
|
|
215
223
|
setError(null)
|
|
216
|
-
try { accept(await window.worker.clearWorkMap(tabId));
|
|
224
|
+
try { accept(await window.worker.clearWorkMap(tabId)); setCollapsedGoals(new Set()); setExpandedThreads(new Set()); setSelected(null); setFollowing(true) }
|
|
217
225
|
catch (err) { if (alive.current) setError(String(err)) }
|
|
218
226
|
}}>Clear map</button>
|
|
219
227
|
</footer>
|