cld-pi 0.0.0-stage → 0.0.2

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/js/rpc.js ADDED
@@ -0,0 +1,772 @@
1
+ // JSON-lines RPC between the Zig binary and this sidecar, the `pi` object
2
+ // plugins get, and the plugin registry. See goal-v1.ts for the API.
3
+ import { AsyncLocalStorage } from 'node:async_hooks'
4
+
5
+ const hookNames = { Stop: 'stop', Notification: 'notification', SessionStart: 'sessionStart' }
6
+
7
+ // Captured before installTimerHooks replaces the globals: host-internal
8
+ // timers (dialog timeouts) must not be charged to whichever plugin called.
9
+ const orig = { setTimeout, setInterval, clearTimeout, clearInterval }
10
+
11
+ /** Split a byte stream into lines; calls onLine(str) for each complete one. */
12
+ export function lineSplitter(onLine) {
13
+ let buf = ''
14
+ return chunk => {
15
+ buf += chunk
16
+ let nl
17
+ while ((nl = buf.indexOf('\n')) >= 0) {
18
+ const line = buf.slice(0, nl)
19
+ buf = buf.slice(nl + 1)
20
+ if (line.trim()) onLine(line)
21
+ }
22
+ }
23
+ }
24
+
25
+ const dialogText = new Set(['title', 'body', 'footer'])
26
+
27
+ /** "2s" / "1.5s" for a non-modal dialog's timeout. */
28
+ export const timeoutLabel = ms => `${Math.round(Number(ms) / 1000)}s`
29
+
30
+ /** SGR parameters for a snack background (see pi.notify); undefined: default. */
31
+ export function snackSgr(bg) {
32
+ if (bg == null) return undefined
33
+ if (bg === 'none') return '2;38;5;212;49'
34
+ const hex = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(String(bg))
35
+ if (hex) return `1;38;5;230;48;2;${hex.slice(1).map(h => parseInt(h, 16)).join(';')}`
36
+ const n = Number(bg)
37
+ if (Number.isInteger(n) && n >= 0 && n < 256) return `1;38;5;230;48;5;${n}`
38
+ return undefined
39
+ }
40
+
41
+ /** Single-quote for a POSIX shell. */
42
+ export const shQuote = v => `'${String(v).replace(/'/g, `'\\''`)}'`
43
+
44
+ /**
45
+ * @param {(msg: object) => void} send writes one message to the Zig side
46
+ * @param {(...a: unknown[]) => void} log
47
+ * @param {{ notify?: (msg: string) => void, sh?: (line: string) => Promise<{code: number, stdout: string, stderr: string}>, tab?: Promise<{ get(): Promise<string|null>, set(t: string): Promise<unknown> } | null>, openTab?: (cmd: string) => Promise<boolean>, stubs?: (names: string[]) => void }} [io]
48
+ * side effects the host provides; `tab` is the tab-title backend
49
+ * (js/tab.js), `openTab` opens a terminal tab (Yakuake / Guake), `stubs`
50
+ * mirrors pi.command names into CC's command autocomplete
51
+ */
52
+ export function createHost(send, log = () => {}, io = {}) {
53
+ // focused: from the terminal's focus reports (CSI I / O). Starts true:
54
+ // you just launched it from that terminal.
55
+ // transcript: the session's JSONL, from the latest hook payload.
56
+ const state = { init: null, cols: 0, rows: 0, focused: true, scr: null, transcript: null }
57
+
58
+ // ---- plugin ownership ----
59
+ // Plugin code runs inside als.run(plugin): its setup, its listeners, its
60
+ // timer callbacks (ALS follows async continuations). Every registration
61
+ // records a disposer on the current plugin; turning it off runs them.
62
+ const als = new AsyncLocalStorage()
63
+ const plugins = new Map() // name -> { name, enabled, init, timers, disposers }
64
+ const owner = () => als.getStore()
65
+ const inOwner = (p, fn) => (p ? als.run(p, fn) : fn())
66
+ const track = dispose => {
67
+ owner()?.disposers.add(dispose)
68
+ return dispose
69
+ }
70
+
71
+ // ---- events ----
72
+ const listeners = new Map() // event -> Set<{ fn, p }>
73
+ const emit = (event, payload) => {
74
+ let handled = true
75
+ for (const l of [...(listeners.get(event) ?? [])]) {
76
+ try {
77
+ if (inOwner(l.p, () => l.fn(payload)) === false) handled = false
78
+ } catch (e) {
79
+ log(`listener ${event} threw`, e)
80
+ }
81
+ }
82
+ return handled
83
+ }
84
+ // Key events (CtrlP, ShiftTab, …) cost Zig a round trip per press, so
85
+ // it only asks for the keys someone listens to.
86
+ const isKey = ev => /^(Ctrl|Shift)[A-Z]/.test(ev)
87
+ let lastKeys = '[]'
88
+ const syncKeys = () => {
89
+ const names = [...listeners].filter(([ev, set]) => isKey(ev) && set.size).map(([ev]) => ev)
90
+ const json = JSON.stringify(names)
91
+ if (json !== lastKeys) {
92
+ lastKeys = json
93
+ send({ t: 'keys', names })
94
+ }
95
+ }
96
+ const addListener = (event, fn) => {
97
+ if (!listeners.has(event)) listeners.set(event, new Set())
98
+ const entry = { fn, p: owner() }
99
+ listeners.get(event).add(entry)
100
+ if (isKey(event)) syncKeys()
101
+ return track(() => {
102
+ listeners.get(event).delete(entry)
103
+ if (isKey(event)) syncKeys()
104
+ })
105
+ }
106
+
107
+ // pi.on({ stop() {} }) registers; `await pi.on.stop` waits for the next one.
108
+ const on = new Proxy(
109
+ handlers => {
110
+ const offs = Object.entries(handlers).map(([ev, fn]) => addListener(ev, fn))
111
+ return () => offs.forEach(off => off())
112
+ },
113
+ {
114
+ get(target, ev) {
115
+ if (typeof ev !== 'string' || ev in target) return target[ev]
116
+ return new Promise(resolve => {
117
+ const off = addListener(ev, p => {
118
+ off()
119
+ resolve(p)
120
+ })
121
+ })
122
+ },
123
+ },
124
+ )
125
+
126
+ // Requests Zig acknowledges with {t:'done', id} once queued keys are typed.
127
+ let nextReq = 1
128
+ const acks = new Map()
129
+ const request = msg =>
130
+ new Promise(resolve => {
131
+ const id = nextReq++
132
+ acks.set(id, resolve)
133
+ send({ ...msg, id })
134
+ })
135
+
136
+ // pi`/add-dir ${dir}` replaces the input box text with the line, then
137
+ // Enter; resolves once it has been typed. String.raw for now: backslashes
138
+ // reach the pty as typed. A leading `!` switches CC to bash mode.
139
+ const pi = (strings, ...values) => request({ t: 'submit', data: String.raw(strings, ...values) })
140
+ pi.on = on
141
+ // Terminal-side helpers: pi.tui.wait / press / dispose / title / state.
142
+ const tui = (pi.tui = { state })
143
+
144
+ // pi.BUFFER = 'text' replaces the input box text, no Enter. Write-only
145
+ // for now (reading it back would need the grid).
146
+ Object.defineProperty(pi, 'BUFFER', {
147
+ set: v => send({ t: 'buffer', data: String(v) }),
148
+ enumerable: true,
149
+ })
150
+
151
+ // pi.arg.fzf = '...' : settings the Zig side uses. Defaults arrive with
152
+ // the init message; assignments are pushed back.
153
+ const args = {}
154
+ pi.arg = new Proxy(args, {
155
+ set(target, key, value) {
156
+ target[key] = String(value)
157
+ send({ t: 'arg', key, value: String(value) })
158
+ return true
159
+ },
160
+ })
161
+
162
+ // pi.tui.press('enter'): one key, queued after earlier pi`…` lines.
163
+ // enter esc tab shift-tab backspace up down left right ctrl-c ctrl-o ctrl-r
164
+ tui.press = key => request({ t: 'press', key })
165
+
166
+ // pi.command('ad', args => …): `/ad x y` + Enter runs it with ['x', 'y'];
167
+ // CC never sees the line.
168
+ const commands = new Map() // name -> { run, p }
169
+ const syncCommands = () => {
170
+ const names = [...commands.keys()]
171
+ send({ t: 'commands', names })
172
+ io.stubs?.(names)
173
+ }
174
+ pi.command = (name, run) => {
175
+ const entry = { run, p: owner() }
176
+ commands.set(name, entry)
177
+ syncCommands()
178
+ return track(() => {
179
+ if (commands.get(name) === entry) commands.delete(name)
180
+ syncCommands()
181
+ })
182
+ }
183
+
184
+ // pi.complete('//', tok => [...]): Tab on a word starting with the prefix
185
+ // offers the returned strings in fzf; the pick replaces the word as-is.
186
+ // pi.complete('//', source, (item, tok) => text): the pick goes through
187
+ // that first (null / '' types nothing), e.g. a tab title → `@shot.png`.
188
+ const completers = new Map() // prefix -> { source, pick, p }
189
+ const picks = new Map() // complete id -> { pick, token, p }
190
+ const syncCompleters = () => send({ t: 'completions', prefixes: [...completers.keys()] })
191
+ pi.complete = (prefix, source, pick) => {
192
+ const entry = { source, pick, p: owner() }
193
+ completers.set(prefix, entry)
194
+ syncCompleters()
195
+ return track(() => {
196
+ if (completers.get(prefix) === entry) completers.delete(prefix)
197
+ syncCompleters()
198
+ })
199
+ }
200
+
201
+ // pi.notify(msg): desktop notification. pi.notify(msg, 'snack') puts msg
202
+ // where the ◆ plugins badge is (top-right), 'snackRD' bottom-right; both
203
+ // open the plugin list when clicked, '' takes the plugin's snack down.
204
+ // One snack per plugin and corner; the latest one shows. { bg }: its
205
+ // background, a 256-colour number, '#rrggbb', or 'none' (no background,
206
+ // dim text); default the badge's pink.
207
+ const snacks = { snack: new Map(), snackRD: new Map() } // owner -> { text, sgr }
208
+ pi.notify = (msg, where, { bg } = {}) => {
209
+ if (!(where in snacks)) return io.notify?.(String(msg))
210
+ const p = owner() ?? null
211
+ const slot = snacks[where]
212
+ slot.delete(p) // re-insert: the latest goes last
213
+ if (msg !== '' && msg != null) {
214
+ slot.set(p, { text: String(msg), sgr: snackSgr(bg) })
215
+ if (p && !p.snackDisposers?.has(where)) {
216
+ ;(p.snackDisposers ??= new Set()).add(where)
217
+ track(() => {
218
+ p.snackDisposers.delete(where)
219
+ snacks[where].delete(p)
220
+ updateBadge()
221
+ })
222
+ }
223
+ }
224
+ updateBadge()
225
+ }
226
+
227
+ // pi.sh`cmd ${value}`: run a shell line; interpolated values are quoted,
228
+ // so they can't inject. Resolves { code, stdout, stderr } when the shell
229
+ // exits (end a line with `&` for things that keep running).
230
+ pi.sh = (strings, ...values) => {
231
+ const line = strings.reduce((acc, str, i) => acc + str + (i < values.length ? shQuote(values[i]) : ''), '')
232
+ return io.sh ? io.sh(line) : Promise.resolve({ code: 127, stdout: '', stderr: 'no shell' })
233
+ }
234
+
235
+ // pi.ext.*: helpers plugins publish for each other (webpane.js → pi.ext.webpane).
236
+ pi.ext = {}
237
+
238
+ // pi.tui.dispose(fn): run fn when the calling plugin is turned off
239
+ // (restore whatever it changed outside the registry, like a tab title).
240
+ tui.dispose = fn => track(fn)
241
+
242
+ // pi.tui.title(text): this terminal's tab title — over D-Bus where the
243
+ // terminal names tabs itself (Yakuake, Konsole, Guake), else the window
244
+ // title (OSC 2). pi.tui.title(null) puts the original back.
245
+ const tabP = Promise.resolve(io.tab ?? null).catch(() => null)
246
+ let savedTitle // Promise of the title before our first change
247
+ tui.title = async text => {
248
+ const tab = await tabP
249
+ if (text == null) {
250
+ if (!savedTitle) return
251
+ const saved = await savedTitle
252
+ savedTitle = undefined
253
+ if (!tab) send({ t: 'title', text: null })
254
+ else if (saved != null) await tab.set(saved)
255
+ return
256
+ }
257
+ if (!tab) {
258
+ savedTitle ??= Promise.resolve(null)
259
+ return send({ t: 'title', text: String(text) })
260
+ }
261
+ savedTitle ??= tab.get()
262
+ await savedTitle
263
+ await tab.set(String(text))
264
+ }
265
+
266
+ // ---- dialogs ----
267
+ // pi.dialog({ title, body, footer, OK() {}, ... }): every other
268
+ // function-valued key is a button (no arguments). A callback returning a
269
+ // string relabels its button and keeps the dialog open; anything else
270
+ // closes it. Resolves with the pressed label, or null when dismissed.
271
+ // { timeout: ms, or: 'OK' } makes it non-modal (top-right, keys stay
272
+ // with CC): after `timeout` the `or` button is pressed for you.
273
+ // { eachRow: [2, 2] } lays buttons out in rows of 2; 'vertical' is one
274
+ // per row (lists). { hints: { label: text } } shows the focused button's
275
+ // hint in the footer. A non-modal dialog borrows ←/→ to pick a button;
276
+ // its timeout presses the picked one (starting at `or`).
277
+ let nextId = 1
278
+ const dialogs = new Map() // id -> { fns, labels, resolve, p }
279
+ const closeDialog = (id, label) => {
280
+ const dlg = dialogs.get(id)
281
+ if (!dlg) return
282
+ dialogs.delete(id)
283
+ orig.clearTimeout(dlg.timer)
284
+ orig.clearInterval(dlg.ticker)
285
+ send({ t: 'dialog_close', id })
286
+ dlg.resolve(label)
287
+ }
288
+ pi.dialog = d => {
289
+ const id = nextId++
290
+ const buttons = Object.entries(d).filter(([k, v]) => !dialogText.has(k) && typeof v === 'function')
291
+ const labels = buttons.map(([k]) => k)
292
+ const modal = !(d.timeout > 0)
293
+ const hints = labels.map(l => d.hints?.[l])
294
+ const active = Math.max(0, labels.indexOf(d.or))
295
+ const footer = d.footer ?? ''
296
+ // A non-modal dialog says how long it stays: body + " (2s)", kept live.
297
+ // Touching it stops the countdown and drops the note (it stays up until
298
+ // a button or Esc closes it).
299
+ const base = d.body == null ? '' : String(d.body)
300
+ const withLeft = (text, ms) => (ms == null ? text : text ? `${text} (${timeoutLabel(ms)})` : `(${timeoutLabel(ms)})`)
301
+ const stamp = modal ? '' : withLeft('', d.timeout)
302
+ const body = base + (stamp ? (base ? ` ${stamp}` : stamp) : '')
303
+ const msg = { t: 'dialog', id, title: d.title ?? '', body, footer: hints[active] ?? footer, buttons: labels, modal, active }
304
+ if (d.eachRow != null) msg.eachRow = d.eachRow
305
+ if (d.fadeX != null) msg.fadeX = d.fadeX
306
+ send(msg)
307
+ const done = new Promise(resolve =>
308
+ dialogs.set(id, {
309
+ fns: buttons.map(([, v]) => v), labels, resolve, p: owner(), hints, footer, active, moved: false,
310
+ base, remaining: modal ? null : d.timeout,
311
+ }),
312
+ )
313
+ // The handle: relabel(from, to) any button, text({ title, body, footer }).
314
+ done.relabel = (from, to) => {
315
+ const dlg = dialogs.get(id)
316
+ const index = typeof from === 'number' ? from : dlg?.labels.indexOf(from)
317
+ if (!dlg || index == null || index < 0) return
318
+ dlg.labels[index] = String(to)
319
+ send({ t: 'dialog_update', id, index, label: String(to) })
320
+ }
321
+ done.text = texts => {
322
+ const dlg = dialogs.get(id)
323
+ if (!dlg) return
324
+ if (texts.footer != null) dlg.footer = String(texts.footer)
325
+ const out = { ...texts }
326
+ if (texts.body != null) dlg.base = String(texts.body)
327
+ if (!modal) out.body = withLeft(dlg.base, dlg.remaining)
328
+ send({ t: 'dialog_update', id, ...out })
329
+ }
330
+ // The hint line under the buttons: what the focused button does. A
331
+ // plugin can replace it (e.g. a panel that toggled a checkbox clears
332
+ // its hint); only the button in focus shows it.
333
+ done.hint = (from, text) => {
334
+ const dlg = dialogs.get(id)
335
+ if (!dlg) return
336
+ const index = typeof from === 'number' ? from : dlg.labels.indexOf(from)
337
+ if (index == null || index < 0) return
338
+ dlg.hints[index] = text == null || text === '' ? '' : String(text)
339
+ if (dlg.active === index) send({ t: 'dialog_update', id, footer: dlg.hints[index] ?? dlg.footer })
340
+ }
341
+ if (!modal) {
342
+ const dlg = dialogs.get(id)
343
+ const fire = async () => {
344
+ if (dialogs.get(id) !== dlg) return
345
+ const i = dlg.moved || dlg.labels.includes(d.or) ? dlg.active : -1
346
+ if (i >= 0) {
347
+ try {
348
+ await inOwner(dlg.p, () => dlg.fns[i]())
349
+ } catch (e) {
350
+ log('dialog button threw', e)
351
+ }
352
+ }
353
+ closeDialog(id, i >= 0 ? dlg.labels[i] : null)
354
+ }
355
+ // Any interaction (←/→, a click, Enter) stops the countdown.
356
+ dlg.stop = () => {
357
+ if (dlg.stopped) return
358
+ dlg.stopped = true
359
+ dlg.remaining = null
360
+ orig.clearInterval(dlg.ticker)
361
+ dlg.ticker = null
362
+ send({ t: 'dialog_update', id, body: withLeft(dlg.base, null) })
363
+ }
364
+ dlg.endAt = Date.now() + d.timeout
365
+ let shown = timeoutLabel(d.timeout)
366
+ // Tenths are what the label shows, so 10 updates a second.
367
+ dlg.ticker = orig.setInterval(() => {
368
+ if (dialogs.get(id) !== dlg || dlg.stopped) return
369
+ dlg.remaining = Math.max(0, dlg.endAt - Date.now())
370
+ if (dlg.remaining <= 0) {
371
+ orig.clearInterval(dlg.ticker)
372
+ dlg.ticker = null
373
+ fire()
374
+ return
375
+ }
376
+ const label = timeoutLabel(dlg.remaining)
377
+ if (label !== shown) {
378
+ shown = label
379
+ send({ t: 'dialog_update', id, body: withLeft(dlg.base, dlg.remaining) })
380
+ }
381
+ }, 100)
382
+ // A toast must not keep the process alive on its own.
383
+ dlg.ticker.unref?.()
384
+ }
385
+ return done
386
+ }
387
+ const pressButton = async ({ id, index }) => {
388
+ const dlg = dialogs.get(id)
389
+ if (!dlg) return
390
+ dlg.active = index
391
+ dlg.stop?.()
392
+ let r
393
+ try {
394
+ r = await inOwner(dlg.p, () => dlg.fns[index]?.())
395
+ } catch (e) {
396
+ log('dialog button threw', e)
397
+ }
398
+ if (typeof r === 'string') {
399
+ dlg.labels[index] = r
400
+ send({ t: 'dialog_update', id, index, label: r })
401
+ return
402
+ }
403
+ closeDialog(id, dlg.labels[index])
404
+ }
405
+
406
+ // pi.menu(items): dropdown above the input box. Resolves with the picked
407
+ // item, or null on Esc.
408
+ const menus = new Map()
409
+ pi.menu = items => {
410
+ const id = nextId++
411
+ const list = [...items].map(String)
412
+ send({ t: 'menu', id, items: list })
413
+ return new Promise(resolve => menus.set(id, { list, resolve }))
414
+ }
415
+
416
+ // ---- sed ----
417
+ // pi.sed((ln, i, rows) => string | null): a string replaces the row (''
418
+ // blanks it); null means "unchanged" and lets us skip the row. `i` and
419
+ // `rows` give context (e.g. "inside a Bash output block"). Chained in order; runs
420
+ // on every frame dump; only paint sets that changed are sent.
421
+ const seds = [] // { fn, p }
422
+ const waiters = new Set() // pi.tui.wait conditions, checked per frame
423
+ const clickers = new Set() // { fn, p }: pi.click handlers
424
+ let lastPaint = '[]'
425
+ let subscribed = null
426
+ const syncSubscribe = () => {
427
+ // A click handler needs the frame it clicked on, so both go together.
428
+ const want = { scr: seds.length > 0 || waiters.size > 0 || clickers.size > 0, click: clickers.size > 0 }
429
+ const json = JSON.stringify(want)
430
+ if (json !== subscribed) {
431
+ subscribed = json
432
+ send({ t: 'subscribe', ...want })
433
+ }
434
+ }
435
+ pi.sed = fn => {
436
+ const entry = { fn, p: owner() }
437
+ seds.push(entry)
438
+ syncSubscribe()
439
+ return track(() => {
440
+ const i = seds.indexOf(entry)
441
+ if (i < 0) return
442
+ seds.splice(i, 1)
443
+ syncSubscribe()
444
+ // Repaint without it now rather than on the next frame.
445
+ runSed(state.scr?.rows ?? [])
446
+ })
447
+ }
448
+
449
+ // pi.click(fn): a plain click on grid row `row` (0-based). Clicks on the
450
+ // input box and the status line below it stay CC's, so they never arrive.
451
+ // The row's text is tui.state.scr.rows[row].
452
+ pi.click = fn => {
453
+ const entry = { fn, p: owner() }
454
+ clickers.add(entry)
455
+ syncSubscribe()
456
+ return track(() => {
457
+ clickers.delete(entry)
458
+ syncSubscribe()
459
+ })
460
+ }
461
+
462
+ // pi.tui.wait('text' | scr => bool, ms = 3000): resolves true once the
463
+ // screen matches, false on timeout.
464
+ tui.wait = (cond, ms = 3000) => {
465
+ const test = typeof cond === 'function' ? cond : scr => scr.text.includes(cond)
466
+ return new Promise(resolve => {
467
+ const w = { test, resolve }
468
+ const finish = ok => {
469
+ waiters.delete(w)
470
+ orig.clearTimeout(w.timer)
471
+ syncSubscribe()
472
+ resolve(ok)
473
+ }
474
+ w.finish = finish
475
+ w.timer = orig.setTimeout(() => finish(false), ms)
476
+ waiters.add(w)
477
+ syncSubscribe()
478
+ if (state.scr && test(state.scr)) finish(true)
479
+ })
480
+ }
481
+ const runSed = rows => {
482
+ const paints = []
483
+ rows.forEach((row, i) => {
484
+ let ln = row
485
+ for (const s of seds) {
486
+ try {
487
+ const r = inOwner(s.p, () => s.fn(ln, i, rows))
488
+ if (typeof r === 'string') ln = r
489
+ } catch (e) {
490
+ log('sed threw', e)
491
+ }
492
+ }
493
+ if (ln !== row) paints.push([i, ln])
494
+ })
495
+ const json = JSON.stringify(paints)
496
+ if (json !== lastPaint) {
497
+ lastPaint = json
498
+ send({ t: 'paint', rows: paints })
499
+ }
500
+ }
501
+
502
+ // ---- plugins ----
503
+ const start = async p => {
504
+ try {
505
+ await als.run(p, () => p.init(pi))
506
+ } catch (e) {
507
+ log(`plugin ${p.name} failed`, e)
508
+ }
509
+ }
510
+ const stop = p => {
511
+ for (const d of p.disposers) {
512
+ try {
513
+ d()
514
+ } catch (e) {
515
+ log(`plugin ${p.name} dispose threw`, e)
516
+ }
517
+ }
518
+ p.disposers.clear()
519
+ for (const h of p.timers) orig.clearTimeout(h)
520
+ p.timers.clear()
521
+ }
522
+ const lastSnack = slot => [...slot.values()].at(-1)
523
+ let shown = {}
524
+ const updateBadge = () => {
525
+ const on = [...plugins.values()].filter(p => p.enabled).length
526
+ const top = lastSnack(snacks.snack)
527
+ const rd = lastSnack(snacks.snackRD)
528
+ const msgs = [
529
+ { t: 'badge', text: ` ${top?.text ?? `◆ plugins ${on}/${plugins.size}`} `, ...(top?.sgr && { sgr: top.sgr }) },
530
+ { t: 'badge', pos: 'rd', text: rd ? ` ${rd.text} ` : null, ...(rd?.sgr && { sgr: rd.sgr }) },
531
+ ]
532
+ for (const m of msgs) {
533
+ const key = m.pos ?? 'tr'
534
+ const json = JSON.stringify(m)
535
+ if (json !== (shown[key] ?? (key === 'rd' ? JSON.stringify({ t: 'badge', pos: 'rd', text: null }) : ''))) send(m)
536
+ shown[key] = json
537
+ }
538
+ }
539
+ const label = p => `${p.enabled ? '[x]' : '[ ]'} ${p.name}`
540
+
541
+ /** Register and start a plugin: `init(pi)` is its default export. */
542
+ const loadPlugin = async (name, init) => {
543
+ const p = { name, enabled: true, init, timers: new Set(), disposers: new Set() }
544
+ plugins.set(name, p)
545
+ await start(p)
546
+ updateBadge()
547
+ }
548
+
549
+ /** Turn a plugin off (drop everything it registered) or back on. */
550
+ const setEnabled = async (name, enabled) => {
551
+ const p = plugins.get(name)
552
+ if (!p || p.enabled === enabled) return
553
+ p.enabled = enabled
554
+ if (enabled) await start(p)
555
+ else stop(p)
556
+ updateBadge()
557
+ }
558
+
559
+ // The badge's click: a list of plugins, Enter toggles one.
560
+ const showPluginList = () => {
561
+ const d = { title: 'Plugins', body: 'Enter turns a plugin on / off', footer: 'esc to close', eachRow: 'vertical' }
562
+ for (const p of plugins.values()) {
563
+ d[label(p)] = async () => {
564
+ await setEnabled(p.name, !p.enabled)
565
+ return label(p)
566
+ }
567
+ }
568
+ d.Done = () => {}
569
+ return pi.dialog(d)
570
+ }
571
+
572
+ /**
573
+ * Replace target's timer functions so timers created by plugin code are
574
+ * charged to that plugin (and cleared when it is turned off).
575
+ */
576
+ const installTimerHooks = (target = globalThis) => {
577
+ const saved = {
578
+ setTimeout: target.setTimeout,
579
+ setInterval: target.setInterval,
580
+ clearTimeout: target.clearTimeout,
581
+ clearInterval: target.clearInterval,
582
+ }
583
+ const disown = h => {
584
+ for (const p of plugins.values()) p.timers.delete(h)
585
+ }
586
+ target.setInterval = (fn, ms, ...a) => {
587
+ const h = orig.setInterval(fn, ms, ...a)
588
+ owner()?.timers.add(h)
589
+ return h
590
+ }
591
+ target.setTimeout = (fn, ms, ...a) => {
592
+ const p = owner()
593
+ const h = orig.setTimeout((...x) => {
594
+ p?.timers.delete(h)
595
+ return fn(...x)
596
+ }, ms, ...a)
597
+ p?.timers.add(h)
598
+ return h
599
+ }
600
+ target.clearTimeout = target.clearInterval = h => {
601
+ disown(h)
602
+ orig.clearTimeout(h)
603
+ }
604
+ return () => Object.assign(target, saved)
605
+ }
606
+
607
+ /** Handle one message from the Zig side. */
608
+ const dispatch = msg => {
609
+ switch (msg.t) {
610
+ case 'init':
611
+ Object.assign(args, msg.arg)
612
+ state.init = msg
613
+ state.cols = msg.cols
614
+ state.rows = msg.rows
615
+ emit('init', msg)
616
+ break
617
+ case 'resize':
618
+ state.cols = msg.cols
619
+ state.rows = msg.rows
620
+ emit('resize', msg)
621
+ break
622
+ case 'hook':
623
+ if (msg.payload?.transcript_path) state.transcript = msg.payload.transcript_path
624
+ emit(hookNames[msg.event] ?? msg.event, msg.payload)
625
+ break
626
+ case 'focus':
627
+ state.focused = msg.focused
628
+ emit('focus', msg.focused)
629
+ break
630
+ case 'wheel':
631
+ emit('wheel', msg.dir)
632
+ break
633
+ case 'scr': {
634
+ const scr = { rows: msg.rows, text: msg.rows.join('\n'), cursor: msg.cursor }
635
+ state.scr = scr
636
+ emit('scrcpy', scr)
637
+ if (seds.length) runSed(msg.rows)
638
+ for (const w of [...waiters]) {
639
+ try {
640
+ if (w.test(scr)) w.finish(true)
641
+ } catch (e) {
642
+ log('wait condition threw', e)
643
+ w.finish(false)
644
+ }
645
+ }
646
+ break
647
+ }
648
+ case 'done':
649
+ acks.get(msg.id)?.()
650
+ acks.delete(msg.id)
651
+ break
652
+ case 'key':
653
+ send({ t: 'key_result', id: msg.id, prevent: emit(msg.name) === false })
654
+ break
655
+ case 'launch': {
656
+ // /~/name + Enter. The Zig side already copied the launch command;
657
+ // a dropdown terminal gets a new tab, everything else a paste dialog.
658
+ const cmd = String(msg.cmd ?? '')
659
+ const title = String(msg.title ?? '')
660
+ const fallback = () =>
661
+ pi
662
+ .dialog({
663
+ title,
664
+ body: `${msg.copied === false ? 'Run this in a new terminal (clipboard unavailable):' : 'Launch command copied, paste it in a new terminal:'}\n${cmd}`,
665
+ footer: 'enter / esc to close',
666
+ OK() {},
667
+ })
668
+ .catch(() => {})
669
+ Promise.resolve()
670
+ .then(() => io.openTab?.(cmd))
671
+ .catch(e => (log('openTab threw', e), false))
672
+ .then(ok =>
673
+ ok
674
+ ? pi
675
+ .dialog({
676
+ title,
677
+ body: 'Opened a new tab in this terminal.',
678
+ footer: 'enter / esc to close',
679
+ timeout: 2500,
680
+ or: 'OK',
681
+ OK() {},
682
+ })
683
+ .catch(() => {})
684
+ : fallback(),
685
+ )
686
+ break
687
+ }
688
+ case 'command': {
689
+ const c = commands.get(msg.name)
690
+ const args = String(msg.args ?? '').split(/\s+/).filter(Boolean)
691
+ if (c) {
692
+ Promise.resolve()
693
+ .then(() => inOwner(c.p, () => c.run(args)))
694
+ .catch(e => log(`command /${msg.name} threw`, e))
695
+ }
696
+ break
697
+ }
698
+ case 'complete': {
699
+ const c = completers.get(msg.prefix)
700
+ picks.clear() // Zig waits on one Tab at a time; an fzf Esc never answers
701
+ Promise.resolve()
702
+ .then(() => (c ? inOwner(c.p, () => c.source(msg.token)) : []))
703
+ .catch(e => (log(`completion ${msg.prefix} threw`, e), []))
704
+ .then(items => {
705
+ const reply = { t: 'complete_result', id: msg.id, items: (items ?? []).map(String) }
706
+ if (c?.pick && reply.items.length) {
707
+ reply.pick = true
708
+ picks.set(msg.id, { pick: c.pick, token: msg.token, p: c.p })
709
+ }
710
+ send(reply)
711
+ })
712
+ break
713
+ }
714
+ case 'picked': {
715
+ const k = picks.get(msg.id)
716
+ picks.delete(msg.id)
717
+ if (!k) break
718
+ Promise.resolve()
719
+ .then(() => inOwner(k.p, () => k.pick(msg.item, k.token)))
720
+ .catch(e => (log('completion pick threw', e), null))
721
+ .then(text => send({ t: 'pick_result', id: msg.id, text: text ? String(text) : null }))
722
+ break
723
+ }
724
+ case 'dialog_button':
725
+ pressButton(msg)
726
+ break
727
+ case 'dialog_active': {
728
+ const dlg = dialogs.get(msg.id)
729
+ if (!dlg) break
730
+ dlg.active = msg.index
731
+ dlg.moved = true
732
+ dlg.stop?.()
733
+ if (dlg.hints.some(h => h != null)) send({ t: 'dialog_update', id: msg.id, footer: dlg.hints[msg.index] ?? dlg.footer })
734
+ break
735
+ }
736
+ case 'dialog_closed': {
737
+ const dlg = dialogs.get(msg.id)
738
+ dialogs.delete(msg.id)
739
+ if (dlg) {
740
+ orig.clearTimeout(dlg.timer)
741
+ orig.clearInterval(dlg.ticker)
742
+ }
743
+ dlg?.resolve(null)
744
+ break
745
+ }
746
+ case 'menu_result': {
747
+ const m = menus.get(msg.id)
748
+ menus.delete(msg.id)
749
+ m?.resolve(msg.index == null ? null : m.list[msg.index] ?? null)
750
+ break
751
+ }
752
+ case 'click': {
753
+ const row = Number(msg.row)
754
+ for (const c of [...clickers]) {
755
+ try {
756
+ inOwner(c.p, () => c.fn(row))
757
+ } catch (e) {
758
+ log('click handler threw', e)
759
+ }
760
+ }
761
+ break
762
+ }
763
+ case 'badge_click':
764
+ showPluginList()
765
+ break
766
+ default:
767
+ log('unknown message', msg)
768
+ }
769
+ }
770
+
771
+ return { pi, dispatch, emit, loadPlugin, setEnabled, plugins, installTimerHooks, showPluginList }
772
+ }