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/LICENSE +22 -0
- package/README.md +179 -2
- package/README.zh-CN.md +166 -0
- package/bin/claude-p.js +57 -0
- package/js/cdp.js +247 -0
- package/js/host.js +131 -0
- package/js/package.json +1 -0
- package/js/plugins/add-dir.js +15 -0
- package/js/plugins/f12.js +296 -0
- package/js/plugins/herdry.js +52 -0
- package/js/plugins/img.js +35 -0
- package/js/plugins/magpie.js +14 -0
- package/js/plugins/notify.js +13 -0
- package/js/plugins/pace.js +75 -0
- package/js/plugins/panel.js +230 -0
- package/js/plugins/webpane.js +108 -0
- package/js/rpc.js +772 -0
- package/js/session.js +186 -0
- package/js/stubs.js +72 -0
- package/js/tab.js +94 -0
- package/package.json +55 -4
- package/scripts/install.js +212 -0
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
|
+
}
|