@qvac/core 0.1.1 → 0.1.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/tui/app.mjs ADDED
@@ -0,0 +1,4179 @@
1
+ // The bare-tui model for the interactive chat demo — split out from
2
+ // index.mjs (which just wires real Core/Client/Harness and runs it) so it
3
+ // can be driven headlessly with injected streams and a fake engine, per
4
+ // bare-tui's CLAUDE.md guidance on testing without a real TTY.
5
+ import tui from 'bare-tui'
6
+ import { spawn } from 'child_process'
7
+ import fs from 'fs'
8
+ import { settingsDimension, TOOLSETS } from '@qvac/harness'
9
+ import os from 'os'
10
+ import path from 'path'
11
+ import process from 'process'
12
+ import {
13
+ ASANA_MCP_CREDENTIAL_KEY,
14
+ ASANA_OAUTH_STATE_KEY,
15
+ connectAsana,
16
+ connectGoogle,
17
+ connectNotion,
18
+ connectSpotify,
19
+ oauthKeySpec,
20
+ NOTION_MCP_CREDENTIAL_KEY,
21
+ NOTION_OAUTH_STATE_KEY,
22
+ scopedKey,
23
+ SPOTIFY_CREDENTIAL_KEY,
24
+ SPOTIFY_OAUTH_STATE_KEY
25
+ } from '../dist-lib/lib/oauth.js'
26
+ import {
27
+ busyStatus,
28
+ findPendingApproval,
29
+ runInFlight,
30
+ runningAgentTurn,
31
+ turnsFromChunks
32
+ } from './lib/render.mjs'
33
+ import { exportMarkdown } from './lib/export.mjs'
34
+ import { paintSelection, selectionText } from './lib/select.mjs'
35
+ import { center, splash } from './lib/splash.mjs'
36
+ import { wrapBlock, wrapLine } from './lib/wrap.mjs'
37
+ import { knowledgeCommand } from './lib/knowledge-command.mjs'
38
+ import { saveUniqueFile } from './lib/save-file.mjs'
39
+ import {
40
+ configureActions,
41
+ isReady,
42
+ setupAction,
43
+ setupActions,
44
+ setupPlan,
45
+ statusLabel
46
+ } from './lib/setup.mjs'
47
+ import {
48
+ firstSelectable,
49
+ moveRows,
50
+ nextSelectable,
51
+ SIDEBAR_COLS,
52
+ sidebarRows
53
+ } from './lib/sidebar.mjs'
54
+ import { UPLOAD_FRAME_BYTES, writeFrame } from './lib/upload-frames.mjs'
55
+
56
+ const { batch, quit, key, textinput, tick, viewport, spinner, style } = tui
57
+
58
+ function filePending(file) {
59
+ return ['uploading', 'pending', 'running'].includes(file.state)
60
+ }
61
+
62
+ function fileStatusText(file) {
63
+ const processor = file.processors.find((entry) => entry.processorId === 'rag')
64
+ const progress = file.state === 'uploading' ? file.uploadProgress : processor?.progress
65
+ let text = `${file.fileName.replace(/\s+/g, ' ')} · ${file.readyForChat ? 'ready for chat' : file.state}`
66
+ if (progress && (file.state === 'uploading' || file.state === 'running')) {
67
+ const { completed, total, unit } = progress
68
+ text += total
69
+ ? ` · ${Math.round((completed / total) * 100)}% (${completed}/${total} ${unit})`
70
+ : ` · ${completed} ${unit}`
71
+ }
72
+ if (file.reason) text += ` · ${file.reason.replace(/\s+/g, ' ')}`
73
+ return text
74
+ }
75
+
76
+ // Every entry is dispatched by _command and documented in /help; `palette: true`
77
+ // is the subset the `/` autocomplete advertises. The rest still run when typed in
78
+ // full — they're reachable through the menu (ctrl+p), so they no longer clutter
79
+ // the dropdown. See command-palette.ts, which pins these three lists together.
80
+ const COMMANDS = [
81
+ { name: 'help', desc: 'list every /command with a one-liner', palette: true },
82
+ {
83
+ name: 'menu',
84
+ desc: 'open the navigable menu (Agent, Chat, Models, Voice, …) — also ctrl+p',
85
+ palette: true
86
+ },
87
+ { name: 'new-chat', desc: 'create and switch to a new chat', palette: true },
88
+ { name: 'agents', desc: 'switch between your agents (interactive picker)', palette: true },
89
+ { name: 'agent', desc: 'show, list, create, rename, or switch the active agent' },
90
+ {
91
+ name: 'activity',
92
+ desc: "list the active agent's tool-call activity (allowed/denied/auto/skip-all/blocked)"
93
+ },
94
+ { name: 'chats', desc: 'switch between your chats (interactive picker)', palette: true },
95
+ { name: 'chat', desc: 'show, create, switch, rename, delete a chat, or toggle web search / rag' },
96
+ {
97
+ name: 'group',
98
+ desc: 'list, create, rename, delete chat groups; move a chat in or out of one',
99
+ palette: true
100
+ },
101
+ { name: 'knowledge', desc: 'list, download, or remove knowledge in the active chat' },
102
+ { name: 'files', desc: 'show file readiness, progress, and failures in this chat' },
103
+ {
104
+ name: 'skills',
105
+ desc: 'browse installed skills; ↑/↓ move, space toggles each on/off',
106
+ palette: true
107
+ },
108
+ { name: 'tools', desc: 'browse installed tools; ↑/↓ move, space toggles each on/off' },
109
+ { name: 'skill', desc: 'toggle a single skill on/off' },
110
+ { name: 'tool', desc: 'toggle a single tool on/off' },
111
+ { name: 'scope', desc: 'switch skills/tools scope between all and selected' },
112
+ {
113
+ name: 'permissions',
114
+ desc: "the agent's approval mode and its toolset switches; space toggles"
115
+ },
116
+ { name: 'attach', desc: 'attach one or more local files to this chat by path', palette: true },
117
+ {
118
+ name: 'save',
119
+ desc: 'download the latest (or n-th) attachment to ~/Downloads',
120
+ palette: true
121
+ },
122
+ {
123
+ name: 'export',
124
+ desc: 'write the last run (or last n) to a markdown file — thinking, answer, tools',
125
+ palette: true
126
+ },
127
+ { name: 'join', desc: "join another device's mesh with its invite (replaces this one)" },
128
+ { name: 'cred', desc: 'set a credential value for this agent' },
129
+ { name: 'connect', desc: 'OAuth-connect a skill for this agent' },
130
+ {
131
+ name: 'my-device',
132
+ desc: 'CPU/GPU inventory for this device (manufacturer, cores, drivers, …)'
133
+ },
134
+ { name: 'models', desc: 'browse every model in the SDK registry (optional type filter)' },
135
+ { name: 'image', desc: 'show or set the agent’s image defaults (model, steps, size)' },
136
+ { name: 'speak', desc: 'synthesize speech from text to a WAV file (TTS)' },
137
+ { name: 'speak-stream', desc: 'synthesize speech per sentence over a duplex stream (TTS)' },
138
+ { name: 'transcribe', desc: 'transcribe a 16kHz mono s16 WAV file to text (STT)' },
139
+ { name: 'converse', desc: 'hands-free voice conversation via the mic (STT+LLM+TTS)' },
140
+ { name: 'record', desc: 'record N seconds from the mic (ffmpeg) and transcribe (STT)' }
141
+ ]
142
+
143
+ // whitespace-separated tokens, quoted spans intact — `/attach` paths and the `/group` names
144
+ function splitPaths(input) {
145
+ const paths = []
146
+ for (const m of input.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)) {
147
+ paths.push(m[1] ?? m[2] ?? m[3])
148
+ }
149
+ return paths
150
+ }
151
+
152
+ // @qvac/sdk's ModelRegistryEntryAddon values — kept as a set so an unknown
153
+ // `/models <type>` becomes a usage notice instead of a silent empty list.
154
+ const MODEL_TYPES = new Set([
155
+ 'embeddings',
156
+ 'diffusion',
157
+ 'tts',
158
+ 'ocr',
159
+ 'vla',
160
+ 'llm',
161
+ 'whisper',
162
+ 'bci',
163
+ 'nmt',
164
+ 'parakeet',
165
+ 'classification',
166
+ 'vad',
167
+ 'other'
168
+ ])
169
+ // Human synonyms — resolved before the SDK addon check so users don't have
170
+ // to know the plugin name.
171
+ const MODEL_TYPE_ALIASES = {
172
+ transcription: 'parakeet',
173
+ embedding: 'embeddings',
174
+ image: 'diffusion'
175
+ }
176
+
177
+ export function credKey(agentId, key) {
178
+ return scopedKey(agentId, key)
179
+ }
180
+
181
+ function openInBrowser(url) {
182
+ const command = process.platform === 'darwin' ? 'open' : 'xdg-open'
183
+ try {
184
+ spawn(command, [url], { stdio: 'ignore' }).on('error', () => {})
185
+ } catch {}
186
+ }
187
+
188
+ // Fixed vertical rows around the transcript viewport (breakdown in the resize
189
+ // handler) — the viewport height must be exactly `terminal.height - CHROME_ROWS`.
190
+ const CHROME_ROWS = 8
191
+ // Below these the styled blocks go non-positive and blow up padLine — clamp
192
+ // and let the terminal truncate instead.
193
+ // the tallest the `/` dropdown may grow — it overlays the transcript's last rows
194
+ const PALETTE_ROWS = 10
195
+ const MIN_WIDTH = 20
196
+ // the transcript's own minimum — below it the sidebar stays hidden rather than
197
+ // squeezing the conversation into a gutter
198
+ const SIDEBAR_MIN_BODY = 40
199
+ const MIN_HEIGHT = CHROME_ROWS + 1
200
+ const MAX_INPUT_ROWS = 5
201
+ const CURSOR_CELL = '\x1b[7m'
202
+ const CURSOR_OFF = '\x1b[27m'
203
+ const WHEEL_ROWS = 3
204
+ // where the transcript's first content cell sits on screen: header (1) + top
205
+ // border (1) down, border (1) + padding (1) across
206
+ const TRANSCRIPT_TOP = 2
207
+ const TRANSCRIPT_LEFT = 2
208
+ // one row per beat while a selection drag is held past the transcript's edge
209
+ const DRAG_SCROLL_MS = 60
210
+ // shift+enter as a CSI-u report; most terminals send a bare CR and cannot say it at all,
211
+ // hence alt+enter and ctrl+j (linefeed) alongside it
212
+ const SHIFT_ENTER = '\x1b[13;2u'
213
+
214
+ export const fg = {
215
+ cyan: style().foreground('brightcyan'),
216
+ dim: style().foreground('gray').italic(true),
217
+ white: style(),
218
+ amber: style().foreground('brightyellow'),
219
+ magenta: style().foreground('brightmagenta').bold(true),
220
+ err: style().foreground('brightred')
221
+ }
222
+
223
+ // wraps the engine's raw float32-LE PCM (what speak returns) in a minimal
224
+ // IEEE-float WAV header so the file plays in any player. Mono, format tag 3.
225
+ export function floatPcmToWav(pcm, sampleRate) {
226
+ const blockAlign = 4
227
+ const header = Buffer.alloc(44)
228
+ header.write('RIFF', 0, 'ascii')
229
+ header.writeUInt32LE(36 + pcm.length, 4)
230
+ header.write('WAVE', 8, 'ascii')
231
+ header.write('fmt ', 12, 'ascii')
232
+ header.writeUInt32LE(16, 16)
233
+ header.writeUInt16LE(3, 20)
234
+ header.writeUInt16LE(1, 22)
235
+ header.writeUInt32LE(sampleRate, 24)
236
+ header.writeUInt32LE(sampleRate * blockAlign, 28)
237
+ header.writeUInt16LE(blockAlign, 32)
238
+ header.writeUInt16LE(32, 34)
239
+ header.write('data', 36, 'ascii')
240
+ header.writeUInt32LE(pcm.length, 40)
241
+ return Buffer.concat([header, pcm])
242
+ }
243
+
244
+ // continuous 16kHz mono s16le raw PCM from the mic on stdout (macOS avfoundation)
245
+ function micProcess(device) {
246
+ // prettier-ignore
247
+ return spawn('ffmpeg', ['-hide_banner', '-loglevel', 'quiet', '-f', 'avfoundation', '-i', device, '-ar', '16000', '-ac', '1', '-f', 's16le', '-'], { stdio: ['ignore', 'pipe', 'ignore'] })
248
+ }
249
+
250
+ // plays float32-LE mono PCM piped on stdin (what speakStream yields). ffplay has
251
+ // no -ac; raw channel count goes through -ch_layout.
252
+ function playerProcess(sampleRate) {
253
+ // prettier-ignore
254
+ return spawn('ffplay', ['-hide_banner', '-loglevel', 'quiet', '-autoexit', '-nodisp', '-f', 'f32le', '-ar', String(sampleRate), '-ch_layout', 'mono', '-i', '-'], { stdio: ['pipe', 'ignore', 'ignore'] })
255
+ }
256
+
257
+ // energy of an s16le PCM chunk (0..32768) — the conversation VAD's speech signal
258
+ export function rmsS16(buf) {
259
+ const n = buf.length >> 1
260
+ if (!n) return 0
261
+ let sum = 0
262
+ for (let i = 0; i < n; i++) {
263
+ const s = buf.readInt16LE(i << 1)
264
+ sum += s * s
265
+ }
266
+ return Math.sqrt(sum / n)
267
+ }
268
+
269
+ // length of the leading run of COMPLETE sentences (up to the last terminator
270
+ // followed by whitespace or end) — lets conversation mode speak each sentence as
271
+ // the model produces it, leaving a half-typed trailing sentence unsent.
272
+ export function completePrefixLen(text) {
273
+ let end = 0
274
+ for (const m of text.matchAll(/[.!?…]["')\]]?(\s|$)/g)) end = m.index + m[0].trimEnd().length
275
+ return end
276
+ }
277
+
278
+ // wraps raw s16le mono PCM in a 16kHz WAV header for the (unary) transcriber
279
+ export function pcm16ToWav(pcm, sampleRate) {
280
+ const header = Buffer.alloc(44)
281
+ header.write('RIFF', 0, 'ascii')
282
+ header.writeUInt32LE(36 + pcm.length, 4)
283
+ header.write('WAVE', 8, 'ascii')
284
+ header.write('fmt ', 12, 'ascii')
285
+ header.writeUInt32LE(16, 16)
286
+ header.writeUInt16LE(1, 20)
287
+ header.writeUInt16LE(1, 22)
288
+ header.writeUInt32LE(sampleRate, 24)
289
+ header.writeUInt32LE(sampleRate * 2, 28)
290
+ header.writeUInt16LE(2, 32)
291
+ header.writeUInt16LE(16, 34)
292
+ header.write('data', 36, 'ascii')
293
+ header.writeUInt32LE(pcm.length, 40)
294
+ return Buffer.concat([header, pcm])
295
+ }
296
+
297
+ // /models overlay: status badge per row, loaded/downloaded first. `type`
298
+ // echoes the active filter in the header so an empty filtered result is
299
+ // distinguishable from an empty registry.
300
+ const rankModel = (row) => (row.isLoaded ? 0 : row.isCached ? 1 : 2)
301
+ export function sortModels(rows) {
302
+ return [...(rows ?? [])].sort(
303
+ (a, b) =>
304
+ rankModel(a) - rankModel(b) || a.type.localeCompare(b.type) || a.name.localeCompare(b.name)
305
+ )
306
+ }
307
+
308
+ // `state` (present when the overlay is interactive) carries the highlighted
309
+ // `cursor` row and the agent's `currentModel` — enter on a row makes that the
310
+ // agent's model, and the provider downloads it on the next run.
311
+ export function formatModels(rows, type, state = null) {
312
+ const filter = type ? ` (type=${type})` : ''
313
+ if (!rows?.length) {
314
+ return [fg.dim.render(`(no models in the registry${filter})`)]
315
+ }
316
+ const sorted = sortModels(rows)
317
+ const nameWidth = Math.max(...sorted.map((r) => r.name.length))
318
+ const typeWidth = Math.max(...sorted.map((r) => r.type.length))
319
+ const mb = (n) => (n / (1024 * 1024)).toFixed(1) + 'MB'
320
+ const badge = (row) => {
321
+ if (row.isLoaded) return fg.magenta.render('● loaded ')
322
+ if (row.isCached) return fg.cyan.render('● downloaded')
323
+ return fg.dim.render('○ not cached')
324
+ }
325
+ const hint = state ? '↑/↓ move · enter use (downloads on next run) · esc return' : 'esc to return'
326
+ const header = fg.dim.render(`Models on this device${filter} (${sorted.length}) — ${hint}`)
327
+ const lines = sorted.map((row, i) => {
328
+ const name = fg.white.render(row.name.padEnd(nameWidth))
329
+ const rowType = fg.dim.render(row.type.padEnd(typeWidth))
330
+ // absent actualSize arrives as 0 over the RPC (hyperschema's optional-uint
331
+ // default), so truthiness — not undefined — is the on-disk check
332
+ const size = row.actualSize
333
+ ? `${mb(row.actualSize)} / ${mb(row.expectedSize)}`
334
+ : mb(row.expectedSize)
335
+ const vision = row.supportsImages ? ` ${fg.cyan.render('◈ vision')}` : ''
336
+ const inUse = state?.currentModel === row.name ? ` ${fg.magenta.render('▸ in use')}` : ''
337
+ const point = state && i === state.cursor ? fg.magenta.render('❯ ') : ' '
338
+ return `${point}${badge(row)} ${name} ${rowType} ${fg.dim.render(size)}${vision}${inUse}`
339
+ })
340
+ return [header, '', ...lines]
341
+ }
342
+
343
+ export const MODE_ROW = 'mode'
344
+
345
+ // plain-language, one line each — PRD §3.4
346
+ const TOOLSET_HINTS = {
347
+ 'web-search': 'search the web',
348
+ knowledge: 'search your knowledge base',
349
+ files: 'read and change files',
350
+ exec: 'run commands on the device',
351
+ 'http-request': 'call web addresses and APIs',
352
+ 'mcp-call': 'use connected external tools (MCP)'
353
+ }
354
+
355
+ // full words, not a bare value: this row sits among on/off badges, and a one-word flip reads as
356
+ // "nothing happened". skip-all carries the warning PRD §3.1 requires on selection.
357
+ const MODE_LABEL = {
358
+ ask: 'ask for approval ',
359
+ 'skip-all': 'SKIP ALL APPROVALS'
360
+ }
361
+
362
+ const MODE_HINT = {
363
+ ask: 'asks before writing or editing anything',
364
+ 'skip-all': '⚠ acts on this device without ever asking'
365
+ }
366
+
367
+ // /permissions overlay: the approval mode on the first row, then one row per toolset.
368
+ export function formatPermissions(sel) {
369
+ const { rows, enabled, mode, cursor } = sel
370
+ const nameWidth = Math.max(...rows.map((name) => name.length))
371
+ const header = fg.dim.render(
372
+ 'How should QV.AC actions be approved? — ↑/↓ move · space toggle · esc return'
373
+ )
374
+ const lines = rows.map((name, i) => {
375
+ const point = i === cursor ? fg.magenta.render('❯ ') : ' '
376
+ if (name === MODE_ROW) {
377
+ const paint = mode === 'skip-all' ? fg.err : fg.cyan
378
+ const hint =
379
+ mode === 'skip-all' ? fg.err.render(MODE_HINT[mode]) : fg.dim.render(MODE_HINT[mode])
380
+ return `${point}${paint.render(MODE_LABEL[mode])} ${hint}`
381
+ }
382
+ const badge = enabled.has(name) ? fg.cyan.render('● on ') : fg.dim.render('○ off')
383
+ return `${point}${badge} ${fg.white.render(name.padEnd(nameWidth))} ${fg.dim.render(TOOLSET_HINTS[name] ?? '')}`
384
+ })
385
+ return [header, '', ...lines]
386
+ }
387
+
388
+ const SKILL_STATUS_PAINT = {
389
+ ready: 'dim',
390
+ 'needs-setup': 'amber',
391
+ disconnected: 'amber',
392
+ outdated: 'amber',
393
+ failed: 'err',
394
+ unsupported: 'err',
395
+ 'incompatible-model': 'err',
396
+ disabled: 'dim'
397
+ }
398
+
399
+ // /skills and /tools overlay: installed rows with an on/off badge; space toggles membership and
400
+ // flips scope to selected. Skills carry their setup state (`detail`, name → skill) and the
401
+ // highlighted one spells out what setting it up takes, so enabling a skill that needs an
402
+ // account says so instead of silently doing nothing.
403
+ export function formatSelector(sel) {
404
+ const { kind, rows, enabled, scope, cursor, detail } = sel
405
+ if (kind === 'permissions') return formatPermissions(sel)
406
+ const title = kind === 'skills' ? 'Skills' : 'Tools'
407
+ if (!rows.length) {
408
+ return [fg.dim.render(`(no ${kind} advertised — provider offline or pre-upgrade)`)]
409
+ }
410
+ const nameWidth = Math.max(...rows.map((n) => n.length))
411
+ const hint = detail
412
+ ? '↑/↓ move · space toggle · enter configure · esc return'
413
+ : '↑/↓ move · space toggle · esc return'
414
+ const header = fg.dim.render(
415
+ `${title} on this device — scope=${scope} (${rows.length}) — ${hint}`
416
+ )
417
+ const lines = rows.map((name, i) => {
418
+ const point = i === cursor ? fg.magenta.render('❯ ') : ' '
419
+ const badge = enabled.has(name) ? fg.cyan.render('● on ') : fg.dim.render('○ off')
420
+ const skill = detail?.get(name)
421
+ const state = skill
422
+ ? ` ${fg[SKILL_STATUS_PAINT[skill.status] ?? 'dim'].render(statusLabel(skill.status))}`
423
+ : ''
424
+ return `${point}${badge} ${fg.white.render(name.padEnd(nameWidth))}${state}`
425
+ })
426
+ return [header, '', ...lines, ...formatSkillDetail(detail?.get(rows[cursor]))]
427
+ }
428
+
429
+ // The pane under the list: what the highlighted skill is, and the command enter
430
+ // runs on it — the one that sets it up, or, once it is connected, the one that
431
+ // points it somewhere else.
432
+ function formatSkillDetail(skill) {
433
+ if (!skill) return []
434
+ const lines = ['', fg.white.render(`${skill.name} — ${skill.description}`)]
435
+ const state = statusLabel(skill.status)
436
+ const runnables = configureActions(skill)
437
+ if (isReady(skill)) {
438
+ if (!runnables.length) return [...lines, fg.dim.render(state)]
439
+ const [first, ...rest] = runnables
440
+ lines.push(fg.dim.render(`${state} — enter to change it: ${first.label}`))
441
+ lines.push(fg.cyan.render(`enter → ${first.command ?? `${first.prefill}<value>`}`))
442
+ for (const other of rest) lines.push(fg.dim.render(`or ${other.label}${runnable(other)}`))
443
+ return lines
444
+ }
445
+ const action = setupAction(skill)
446
+ if (!action) return [...lines, fg.dim.render(`${state} — nothing to set up here`)]
447
+ lines.push(fg.amber.render(`${state} — ${action.label}`))
448
+ if (action.command) lines.push(fg.cyan.render(`enter → ${action.command}`))
449
+ if (action.prefill) lines.push(fg.cyan.render(`enter → ${action.prefill}<value>`))
450
+ for (const step of action.steps) lines.push(fg.dim.render(` • ${step}`))
451
+ if (action.helpUrl) lines.push(fg.dim.render(` ${action.helpUrl}`))
452
+ for (const other of setupPlan(skill).others) {
453
+ lines.push(fg.dim.render(`or ${other.label}${runnable(other)}`))
454
+ }
455
+ return lines
456
+ }
457
+
458
+ // The navigable menu overlay: a page is { title, entries, cursor } and an entry
459
+ // is { label, hint, submenu? }. A `▸` marks the rows that open a sub-list, so
460
+ // the grouped commands read as one browsable tree instead of a flat palette.
461
+ export function formatMenu(page) {
462
+ const { title, entries, cursor } = page
463
+ const labels = entries.map((e) => e.label + (e.submenu ? ' ▸' : ''))
464
+ const nameWidth = Math.max(...labels.map((l) => l.length))
465
+ const header = fg.dim.render(`${title} — ↑/↓ move · enter select · esc back`)
466
+ const lines = entries.map((e, i) => {
467
+ const point = i === cursor ? fg.magenta.render('❯ ') : ' '
468
+ const label = fg.white.render(labels[i].padEnd(nameWidth))
469
+ return `${point}${label} ${fg.dim.render(e.hint ?? '')}`
470
+ })
471
+ return [header, '', ...lines]
472
+ }
473
+
474
+ // The sidebar pane's lines: a title, then the rows windowed around the cursor
475
+ // so a long chat list scrolls rather than overflowing the frame. Exactly
476
+ // `height` lines, each already cut to `width`.
477
+ export function formatSidebar(rows, cursor, { title, focus, width, height }) {
478
+ const lines = [fg.dim.render(title)]
479
+ const room = Math.max(1, height - 1)
480
+ const start = Math.min(Math.max(0, cursor - (room >> 1)), Math.max(0, rows.length - room))
481
+ for (const [i, row] of rows.slice(start, start + room).entries()) {
482
+ lines.push(sidebarLine(row, start + i === cursor && focus, width - 2))
483
+ }
484
+ while (lines.length < height) lines.push('')
485
+ return lines.slice(0, height).map((line) => style.truncate(line, width))
486
+ }
487
+
488
+ function sidebarLine(row, atCursor, room) {
489
+ const label =
490
+ row.label.length > room ? `${row.label.slice(0, Math.max(0, room - 1))}…` : row.label
491
+ if (row.kind === 'group') return fg.dim.render(`▸ ${label}`)
492
+ if (row.kind === 'empty') return fg.dim.render(` ${label}`)
493
+ const point = atCursor ? fg.magenta.render('❯') : ' '
494
+ if (row.kind === 'chat') {
495
+ const paint = row.active ? fg.cyan : fg.white
496
+ return `${point}${row.active ? '●' : ' '}${paint.render(label)}`
497
+ }
498
+ return `${point} ${fg.white.render(label)}`
499
+ }
500
+
501
+ // The human side is labelled by device name (fallback: short device id) —
502
+ // structurally still user-vs-agent, only the word changes per device.
503
+ // A tool call and its result are one long JSON line each — shown whole they
504
+ // bury the answer, so they render as a preview of TOOL_PREVIEW_ROWS rows with
505
+ // the rest counted. ctrl+o, or a click on the marker, expands every one.
506
+ const TOOL_PREVIEW_ROWS = 3
507
+ export const EXPAND_MARK = '⋯'
508
+
509
+ function clampTool(text, { expandTools = false, width = 80 } = {}) {
510
+ if (expandTools) return text
511
+ const room = Math.max(20, width)
512
+ const budget = room * TOOL_PREVIEW_ROWS
513
+ if (text.length <= budget) return text
514
+ const rest = Math.ceil((text.length - budget) / room)
515
+ return `${text.slice(0, budget)}… ${EXPAND_MARK} +${rest} rows · ctrl+o`
516
+ }
517
+
518
+ export function renderTurn(turn, agentName, deviceLabels, opts = {}) {
519
+ // A requested-but-not-yet-started run has a turn (so busy derives from it)
520
+ // but nothing to say — the busy line is its visible side.
521
+ if (turn.from === 'agent' && !turn.segments.length && !turn.status) return ''
522
+ const userLabel =
523
+ (turn.deviceId && deviceLabels?.get(turn.deviceId)) || turn.deviceId?.slice(0, 8) || 'you'
524
+ const label = turn.from === 'user' ? fg.cyan.render(userLabel) : fg.magenta.render(agentName)
525
+ const lines = [`${label}:`]
526
+ for (const segment of turn.segments) {
527
+ if (segment.type === 'thinking') lines.push(fg.dim.render(` (thinking: ${segment.text})`))
528
+ else if (segment.type === 'tool-call') {
529
+ const call = ` → ${segment.detail.name}(${JSON.stringify(segment.detail.arguments)})`
530
+ lines.push(fg.amber.render(clampTool(call, opts)))
531
+ } else if (segment.type === 'tool-result') {
532
+ const result = ` ← ${segment.detail.name}: ${JSON.stringify(segment.detail.result)}`
533
+ lines.push(fg.dim.render(clampTool(result, opts)))
534
+ } else if (segment.type === 'attachment') {
535
+ lines.push(
536
+ fg.cyan.render(
537
+ ` [attachment] ${segment.detail.fileName} (${segment.detail.mimeType}, ${segment.detail.byteLength}B)`
538
+ )
539
+ )
540
+ } else if (segment.type === 'sources') {
541
+ const records = segment.detail.sources
542
+ if (records.length === 0 && segment.detail.notice) {
543
+ lines.push(fg.dim.render(` [sources] ${segment.detail.notice}`))
544
+ } else {
545
+ // retrieval returns chunks, so a document repeats — count it rather than
546
+ // printing the same title over and over
547
+ const perTitle = new Map()
548
+ for (const source of records) {
549
+ perTitle.set(source.title, (perTitle.get(source.title) ?? 0) + 1)
550
+ }
551
+ const titles = [...perTitle].map(([title, n]) => (n > 1 ? `${title} ×${n}` : title))
552
+ lines.push(fg.dim.render(` [sources] ${titles.join('; ')}`))
553
+ }
554
+ } else if (segment.type === 'approval-request') {
555
+ const { name, arguments: args, resource, status } = segment.detail
556
+ // The resource descriptor is what the decision is really about — lead
557
+ // with it when the tool provided one (e.g. the request's hostname).
558
+ const call = resource
559
+ ? `${name} → ${resource.kind} ${resource.label}`
560
+ : `${name}(${JSON.stringify(args)})`
561
+ if (status === 'pending') lines.push(fg.amber.render(` allow ${call}? [y/a/n]`))
562
+ else lines.push(fg.dim.render(` [${status}] ${call}`))
563
+ } else lines.push(fg.white.render(` ${segment.text || '…'}`))
564
+ }
565
+ if (turn.status === 'failed') {
566
+ lines.push(fg.err.render(` [failed${turn.statusReason ? `: ${turn.statusReason}` : ''}]`))
567
+ }
568
+ const metrics = turn.status === 'completed' ? turn.statusDetail?.metrics : null
569
+ if (metrics) {
570
+ const parts = [`${metrics.totalTokens} tok`]
571
+ if (metrics.tokensPerSecond) parts.push(`${metrics.tokensPerSecond.toFixed(1)} tok/s`)
572
+ if (metrics.timeToFirstToken) parts.push(`ttft ${Math.round(metrics.timeToFirstToken)}ms`)
573
+ if (metrics.cacheTokens) parts.push(`${metrics.cacheTokens} ctx`)
574
+ lines.push(fg.dim.render(` ${parts.join(' · ')}`))
575
+ }
576
+ return lines.join('\n')
577
+ }
578
+
579
+ const CPU_ARCH_NAMES = { 0: 'unknown', 1: 'x86', 2: 'x86_64', 3: 'arm', 4: 'arm64' }
580
+ const GPU_TYPE_NAMES = {
581
+ 0: 'unknown',
582
+ 1: 'integrated',
583
+ 2: 'dedicated',
584
+ 3: 'virtual',
585
+ 4: 'external'
586
+ }
587
+
588
+ function bytesToGb(bytes) {
589
+ if (typeof bytes !== 'number' || bytes <= 0) return null
590
+ return `${(bytes / 1024 ** 3).toFixed(1)} GB`
591
+ }
592
+
593
+ function hzToGhz(hz) {
594
+ if (typeof hz !== 'number' || hz <= 0) return null
595
+ return `${(hz / 1e9).toFixed(2)} GHz`
596
+ }
597
+
598
+ function enabledFeatures(features) {
599
+ if (!features || typeof features !== 'object') return []
600
+ return Object.keys(features)
601
+ .filter((k) => features[k] === true)
602
+ .map((k) => k.replace(/^(arm|x86)_/, ''))
603
+ }
604
+
605
+ function enabledDrivers(drivers) {
606
+ if (!drivers || typeof drivers !== 'object') return []
607
+ return Object.keys(drivers).filter((k) => drivers[k] === true)
608
+ }
609
+
610
+ export function matchCommands(inputValue) {
611
+ const raw = inputValue ?? ''
612
+ if (!raw.startsWith('/')) return []
613
+ const query = raw.slice(1).split(/\s+/, 1)[0].toLowerCase()
614
+ // arguments after the token mean the command is chosen — exact match only,
615
+ // so `/skill on x` stops surfacing /skills
616
+ const committed = /\s/.test(raw.trim())
617
+ const hits = COMMANDS.filter((c) =>
618
+ committed ? c.name.toLowerCase() === query : c.name.toLowerCase().startsWith(query)
619
+ )
620
+ // the curated few first, then every other command — a bare `/` still opens on
621
+ // what most people want, and typing reaches everything the TUI dispatches
622
+ return [...hits.filter((c) => c.palette), ...hits.filter((c) => !c.palette)]
623
+ }
624
+
625
+ // The dropdown never grows past PALETTE_ROWS — with every command matchable, a
626
+ // bare `/` would otherwise paper over the whole transcript. The window walks
627
+ // with the selection, and a trailing count says how much is still below.
628
+ function renderPalette(matches, selected = 0) {
629
+ if (!matches.length) return []
630
+ const nameWidth = Math.max(...matches.map((c) => c.name.length + 1))
631
+ const line = (c, highlighted) => {
632
+ const label = `/${c.name}`.padEnd(nameWidth)
633
+ const name = highlighted ? fg.magenta.render(label) : fg.cyan.render(label)
634
+ return ` ${name} ${fg.dim.render(c.desc)}`
635
+ }
636
+ if (matches.length <= PALETTE_ROWS) return matches.map((c, i) => line(c, i === selected))
637
+ const room = PALETTE_ROWS - 1
638
+ const start = Math.min(Math.max(0, selected - (room >> 1)), matches.length - room)
639
+ const rows = matches.slice(start, start + room).map((c, i) => line(c, start + i === selected))
640
+ return [...rows, fg.dim.render(` +${matches.length - room - start} more`)]
641
+ }
642
+
643
+ // Whitespace-delimited, like ctrl+w and readline's word motions.
644
+ export function wordStart(value, cursor) {
645
+ let at = Math.min(cursor, value.length)
646
+ while (at > 0 && value[at - 1] === ' ') at--
647
+ while (at > 0 && value[at - 1] !== ' ') at--
648
+ return at
649
+ }
650
+
651
+ export function wordEnd(value, cursor) {
652
+ let at = Math.max(0, cursor)
653
+ while (at < value.length && value[at] === ' ') at++
654
+ while (at < value.length && value[at] !== ' ') at++
655
+ return at
656
+ }
657
+
658
+ const LABEL_WIDTH = 14
659
+ function row(label, value) {
660
+ const key = fg.cyan.render(label.padEnd(LABEL_WIDTH))
661
+ return ` ${key} ${fg.white.render(value)}`
662
+ }
663
+
664
+ function formatCpuLines(cpu) {
665
+ if (!cpu) return [fg.err.render('CPU (unavailable — bare-cpu-info addon not loaded)')]
666
+ const arch = CPU_ARCH_NAMES[cpu.arch] ?? String(cpu.arch)
667
+ const lines = [
668
+ fg.magenta.render(`CPU ${cpu.name ?? '?'}`),
669
+ row('vendor', `${cpu.vendor ?? '?'} · ${arch}`)
670
+ ]
671
+ const coreLayout =
672
+ (cpu.performanceCores ?? 0) + (cpu.efficiencyCores ?? 0) > 0
673
+ ? `${cpu.logicalCores} logical (${cpu.performanceCores}P / ${cpu.efficiencyCores}E)`
674
+ : `${cpu.logicalCores} logical · ${cpu.physicalCores} physical`
675
+ lines.push(row('cores', coreLayout))
676
+ const mem = bytesToGb(cpu.memory)
677
+ if (mem) lines.push(row('memory', mem))
678
+ const freq = hzToGhz(cpu.frequency)
679
+ if (freq) lines.push(row('frequency', freq))
680
+ if (typeof cpu.cacheLine === 'number' && cpu.cacheLine > 0) {
681
+ lines.push(row('cache line', `${cpu.cacheLine} B`))
682
+ }
683
+ const feats = enabledFeatures(cpu.features)
684
+ if (feats.length) {
685
+ const shown = feats.slice(0, 12).join(' ')
686
+ const rest = feats.length > 12 ? fg.dim.render(` (+${feats.length - 12} more)`) : ''
687
+ lines.push(row('features', shown + rest))
688
+ }
689
+ return lines
690
+ }
691
+
692
+ function formatGpuDevice(device, index) {
693
+ const type = GPU_TYPE_NAMES[device.type] ?? String(device.type)
694
+ const memory = bytesToGb(device.memory)
695
+ const badges = [device.vendor ?? '?', type]
696
+ if (device.unifiedMemory) badges.push('unified')
697
+ if (memory) badges.push(memory)
698
+ const lines = [
699
+ fg.magenta.render(`GPU ${index} ${device.name ?? '?'}`),
700
+ row('badges', badges.join(' · '))
701
+ ]
702
+ const driverBits = []
703
+ if (device.driverName) driverBits.push(device.driverName)
704
+ if (device.driverVersion) driverBits.push(`v${device.driverVersion}`)
705
+ if (driverBits.length) lines.push(row('driver', driverBits.join(' · ')))
706
+ const ids = []
707
+ if (typeof device.vendorId === 'number') ids.push(`vendorId 0x${device.vendorId.toString(16)}`)
708
+ if (typeof device.deviceId === 'number') ids.push(`deviceId 0x${device.deviceId.toString(16)}`)
709
+ if (ids.length) lines.push(row('pci', ids.join(' · ')))
710
+ const perDeviceDrivers = enabledDrivers(device.drivers)
711
+ if (perDeviceDrivers.length) lines.push(row('backends', perDeviceDrivers.join(' ')))
712
+ return lines
713
+ }
714
+
715
+ function formatGpuLines(gpu) {
716
+ if (!gpu) return [fg.err.render('GPU (unavailable — bare-gpu-info addon not loaded)')]
717
+ const systemDrivers = enabledDrivers(gpu.drivers)
718
+ const devices = gpu.devices ?? []
719
+ if (!devices.length) {
720
+ const lines = [fg.magenta.render('GPU (no devices found)')]
721
+ if (systemDrivers.length) lines.push(row('system drivers', systemDrivers.join(' ')))
722
+ return lines
723
+ }
724
+ const lines = []
725
+ devices.forEach((device, index) => {
726
+ if (index > 0) lines.push('')
727
+ lines.push(...formatGpuDevice(device, index))
728
+ })
729
+ if (systemDrivers.length) {
730
+ lines.push('')
731
+ lines.push(row('system drivers', systemDrivers.join(' ')))
732
+ }
733
+ return lines
734
+ }
735
+
736
+ export function formatDiagnostics(stats) {
737
+ if (!stats) {
738
+ return [fg.dim.render('(no diagnostics snapshot yet — try again in a moment)')]
739
+ }
740
+ const header = fg.dim.render('My Device — CPU / GPU inventory — esc to return')
741
+ return [header, '', ...formatCpuLines(stats.cpu), '', ...formatGpuLines(stats.gpu)]
742
+ }
743
+
744
+ function runnable(action) {
745
+ if (action.command) return `: ${action.command}`
746
+ return action.prefill ? `: ${action.prefill}<value>` : ''
747
+ }
748
+
749
+ // A `ready` skill is just its name+state; a non-ready one appends the one thing
750
+ // that makes it usable — the /connect or /cred to run, or the install steps —
751
+ // so the fix is in the TUI rather than somewhere in the docs.
752
+ export function renderSkill(s) {
753
+ const lines = [`${s.name}: ${s.status || 'unknown'} — ${s.description}`]
754
+ if (s.platform?.length) lines.push(`platforms: ${s.platform.join(', ')}`)
755
+ if (s.requires?.bins?.length) lines.push(`requires: ${s.requires.bins.join(', ')}`)
756
+ if (isReady(s)) {
757
+ for (const action of configureActions(s)) {
758
+ lines.push(` change it: ${action.label}${runnable(action)}`)
759
+ }
760
+ return lines.join('\n')
761
+ }
762
+ if (s.setup?.summary) lines.push(` ${s.setup.summary}`)
763
+ const { primary, others } = setupPlan(s)
764
+ if (primary) {
765
+ lines.push(` ${primary.label}`)
766
+ if (primary.command) lines.push(` run: ${primary.command}`)
767
+ if (primary.prefill) lines.push(` run: ${primary.prefill}<value>`)
768
+ for (const step of primary.steps) lines.push(` - ${step}`)
769
+ if (primary.helpUrl) lines.push(` ${primary.helpUrl}`)
770
+ }
771
+ for (const other of others) {
772
+ lines.push(` or ${other.label}${runnable(other)}${other.helpUrl ? ` (${other.helpUrl})` : ''}`)
773
+ }
774
+ return lines.join('\n')
775
+ }
776
+
777
+ // The mesh seeds its default agent at birth and a joiner waits for it to
778
+ // replicate — resolves on the first agent the mesh yields (agentsWatch fires on
779
+ // every change, no polling).
780
+ async function firstAgent(engine) {
781
+ for await (const frame of engine.agentsWatch({})) {
782
+ if (frame.agents?.length) return frame.agents[0]
783
+ }
784
+ throw new Error('agent stream ended before an agent replicated')
785
+ }
786
+
787
+ // The birth op seeds a default chat too; a joiner waits for it to replicate —
788
+ // watch-driven like firstAgent (chatsWatch), no polling. The provider mints one
789
+ // as a fallback if none shows within the timeout.
790
+ async function firstChat(engine, agent, isProvider) {
791
+ const watch = engine.chatsWatch({})[Symbol.asyncIterator]()
792
+ let timer
793
+ const timeout = new Promise((resolve) => {
794
+ timer = setTimeout(resolve, 10_000, { done: true })
795
+ })
796
+ try {
797
+ while (true) {
798
+ const { value, done } = await Promise.race([watch.next(), timeout])
799
+ if (done) break
800
+ const live = (value.chats ?? []).find((chat) => !chat.deletedAt)
801
+ if (live) return live
802
+ }
803
+ } finally {
804
+ clearTimeout(timer)
805
+ await watch.return?.()
806
+ }
807
+ if (isProvider) return engine.createChat({ agentId: agent.id, title: 'New Chat' })
808
+ throw new Error('no chat replicated before timeout')
809
+ }
810
+
811
+ // A joiner's own writes need writer admission first; the replicated mesh status
812
+ // carries `writable`, so wait for the frame that flips it (a creator is writable
813
+ // as soon as its genesis drains, so this returns on the first frame).
814
+ async function firstWritable(engine) {
815
+ for await (const frame of engine.meshStatusWatch({})) {
816
+ if (frame.writable) return
817
+ }
818
+ throw new Error('mesh status stream ended before this device became writable')
819
+ }
820
+
821
+ export class ChatApp {
822
+ constructor({
823
+ engine = null,
824
+ connect = null,
825
+ agent = null,
826
+ chatId = null,
827
+ modelName = null,
828
+ originDeviceId = null,
829
+ invite,
830
+ requestRepaint,
831
+ copyToClipboard,
832
+ resolvePastedFile,
833
+ resolveFileAttachment,
834
+ saveDir,
835
+ skills
836
+ }) {
837
+ this.engine = engine
838
+ this._skills = skills || null
839
+ this.agent = agent
840
+ this.chatId = chatId
841
+ // The provider's model override (QVAC_MODEL) — applied to the agent once it
842
+ // resolves, only if this device is its provider. Null on a joiner.
843
+ this._modelName = modelName || null
844
+ // Host-provided engine bring-up (spawn sidecar / open in-process Core, then
845
+ // read deviceId + invite) — run as init()'s first command so the whole
846
+ // connection happens inside the TUI, not as a pre-render status line. Absent
847
+ // when a caller hands a ready engine in directly (e.g. headless tests).
848
+ this._connect = connect || null
849
+ this._connecting = !!connect && !engine
850
+ // Booting until the engine is bound AND the mesh yields the seed agent +
851
+ // chat (see _connect/_bootstrap); both agent+chat arrive already-set when a
852
+ // caller resolves them up front instead.
853
+ this._booting = this._connecting || !agent || !chatId
854
+ this.originDeviceId = originDeviceId
855
+ // The mesh invite string — no longer shown in the transcript; ctrl+y copies
856
+ // it to the clipboard (see _copyInvite) for pasting into a second duck.
857
+ this.invite = invite || null
858
+ // Host-provided clipboard write (ctrl+y copies the invite) — clipboard
859
+ // access is host plumbing, not app logic; null disables the shortcut.
860
+ this._copyToClipboard = copyToClipboard || null
861
+ // Host-provided paste resolver (ctrl+v attaches a file): returns
862
+ // { fileName, mimeType, data } when the clipboard holds a usable file
863
+ // reference, null otherwise. Same plumbing rule as above.
864
+ this._resolvePastedFile = resolvePastedFile || null
865
+ // `/attach <path>` resolver: named path -> attachment, or throws.
866
+ this._resolveFileAttachment = resolveFileAttachment || null
867
+ this._selection = null // { anchor, focus, edge } in content cells, while a drag selects text
868
+ this._dragToken = 0 // stale-guards the edge auto-scroll beats
869
+ this._saveDir = saveDir || path.join(os.homedir(), 'Downloads')
870
+ this._statusToken = 0 // stale-guards the transient status clears
871
+ // Called once a run settles — llama.cpp writes some of its own logs
872
+ // straight to the real terminal fd mid-generation, bypassing the
873
+ // renderer's diff cache, so the rows they land on need a forced full
874
+ // repaint to clear. No-op by default (e.g. headless tests).
875
+ this._requestRepaint = requestRepaint || (() => {})
876
+ this._converse = null
877
+
878
+ this.input = textinput
879
+ .create({
880
+ placeholder: 'say something… (/ commands · ctrl+p menu)',
881
+ prompt: '❯ '
882
+ })
883
+ .focus()
884
+ this.width = 80
885
+ this.height = 24
886
+ // The first frame renders before the initial windowSize message lands —
887
+ // use the same CHROME_ROWS math as the resize handler so it can't overflow.
888
+ this.vp = viewport.create({ width: 0, height: Math.max(1, this.height - CHROME_ROWS) })
889
+ this.spin = spinner.create({ fps: 10 })
890
+ this.busy = false
891
+ this.modelState = null // last 'model' event from the harness (loading/downloading/ready/unloading)
892
+ this.status = ''
893
+ this.notice = null // last /command output, rendered as a trailing transcript block
894
+ this.gen = 0 // shared stale-guard for the status/devices/diagnostics watches (never bumped today)
895
+ // chunk-watch-only generation: a chat switch tears down the chunk stream and re-arms it on the
896
+ // new chatId, so a stale frame from the old chat must be dropped. Separate from `gen` so it never
897
+ // stalls the status/devices/diagnostics loops, which share `gen` and re-arm on every frame.
898
+ this._chunkGen = 0
899
+ this._chunkIterator = null
900
+ this._files = []
901
+ this._filesIterator = null
902
+ this._statusIterator = null
903
+ this.meshStatus = null // last meshStatusWatch frame — rendered in the header
904
+ this._devicesIterator = null
905
+ this.deviceLabels = new Map() // hex device id → name, from devicesWatch
906
+ this.diagnostics = null
907
+ this._diagnosticsIterator = null
908
+ this.overlay = null
909
+ this._pinnedOverlay = null
910
+ // Interactive /models overlay: { rows (sorted), filter, cursor }, else null.
911
+ // Separate from `overlay` (the static diagnostics/error panel) because its
912
+ // rows respond to ↑/↓ and enter — see _overlayKey.
913
+ this._models = null
914
+ // Interactive /skills + /tools overlay: { kind, rows (names), enabled (Set),
915
+ // scope, cursor }, else null. Same ↑/↓ nav as _models, but space toggles.
916
+ this._selector = null
917
+ // The navigable menu overlay: { pages: [page, …] } as a drill-in stack, top
918
+ // of stack is the visible page. Lowest render precedence, so a selector or
919
+ // models list opened from a menu row draws on top and esc falls back here.
920
+ this._menu = null
921
+ // The chat sidebar: null when closed, else { selected (chat id), focus,
922
+ // moving }. Its rows are derived from the live chats watch on every render,
923
+ // so a replicated change never slides the cursor out from under you.
924
+ this._sidebar = null
925
+ // Tool calls and their results render as a short preview until this is on —
926
+ // ctrl+o, or a click on a preview's marker.
927
+ this._expandTools = false
928
+ this._chats = []
929
+ this._groups = []
930
+ this._chatsIterator = null
931
+ this._turns = [] // last rendered turns — re-combined with the busy line on every render
932
+ // Derived from _turns on every _setTranscript, not bridged in from the
933
+ // harness — approval state lives entirely in the chunk stream (see
934
+ // runAgent's createApprove); the UI only ever reads/writes chunks.
935
+ this.pendingApproval = null
936
+ this._runningRunId = null // the in-flight run's id, for _requestStop()
937
+ this._awaitingRun = false // bridged busy between our submit and its first frame
938
+ this._paste = null // chars accumulating between bracketed-paste markers, null = not pasting
939
+ this._pastes = new Map() // input placeholder -> full pasted text, expanded at submit
940
+ // Submitted lines, oldest first — ↑/↓ walk them (readline-style). `_historyAt`
941
+ // is null while typing a fresh line and an index into `_history` while recalling;
942
+ // `_historyDraft` preserves the in-progress line so ↓ past the newest restores it.
943
+ this._history = []
944
+ this._historyAt = null
945
+ this._historyDraft = ''
946
+ // Highlighted row in the `/` command palette — ↑/↓ move it, tab completes it,
947
+ // enter runs it. Reset to 0 whenever the palette's matches change.
948
+ this._paletteAt = 0
949
+ // Render immediately so the first frame isn't blank before any chunk-watch
950
+ // result arrives.
951
+ this._render()
952
+ }
953
+
954
+ init() {
955
+ // The spinner ticks from the very first frame (pure TUI feedback, driven
956
+ // locally). When a connect fn is set the engine isn't up yet, so the only
957
+ // other startup command is the connection itself — the engine-backed watches
958
+ // arm on the 'connected' message. With a ready engine handed in, they arm now.
959
+ if (this._connecting) return batch(this._connectCmd(), this.spin.init())
960
+ return batch(...this._engineWatches(), this.spin.init())
961
+ }
962
+
963
+ // The engine-backed watch loops. The chunk watch is deferred until a chatId
964
+ // exists (see _bootstrap); the mesh/devices/diagnostics watches run from the
965
+ // first frame so the header narrates live progress while the mesh converges.
966
+ _engineWatches() {
967
+ return [
968
+ this.chatId ? this._watchNext() : this._bootstrap(),
969
+ this.chatId ? this._watchNextFiles() : null,
970
+ this._watchNextStatus(),
971
+ this._watchNextDevices(),
972
+ this._watchNextDiagnostics(),
973
+ this._watchNextChats()
974
+ ].filter(Boolean)
975
+ }
976
+
977
+ // init()'s first command when connecting: bring the engine up inside the TUI
978
+ // and hand the results to the 'connected' message.
979
+ _connectCmd() {
980
+ return async () => {
981
+ try {
982
+ const conn = await this._connect()
983
+ return { type: 'connected', ...conn }
984
+ } catch (error) {
985
+ return { type: 'boot-error', error }
986
+ }
987
+ }
988
+ }
989
+
990
+ // Resolves the seed agent + chat over the mesh with the TUI already up — the
991
+ // waits that used to block before the first frame, now driven inside the app
992
+ // (the header's meshStatusWatch narrates joining/peers meanwhile).
993
+ _bootstrap() {
994
+ return async () => {
995
+ try {
996
+ await firstWritable(this.engine)
997
+ let agent = await firstAgent(this.engine)
998
+ const isProvider = agent.providerDeviceId?.equals(this.originDeviceId) ?? false
999
+ if (isProvider && this._modelName) {
1000
+ // Only set the model — scopes stay as the shared seed left them
1001
+ agent = await this.engine.updateAgent({ id: agent.id, modelName: this._modelName })
1002
+ }
1003
+ const chat = await firstChat(this.engine, agent, isProvider)
1004
+ return { type: 'booted', agent, chat }
1005
+ } catch (error) {
1006
+ return { type: 'boot-error', error }
1007
+ }
1008
+ }
1009
+ }
1010
+
1011
+ _watchNextDiagnostics() {
1012
+ if (!this.engine.diagnosticsWatch) return null
1013
+ const gen = this.gen
1014
+ if (!this._diagnosticsIterator) {
1015
+ this._diagnosticsIterator = this.engine.diagnosticsWatch({})[Symbol.asyncIterator]()
1016
+ }
1017
+ const it = this._diagnosticsIterator
1018
+ return async () => {
1019
+ const { value, done } = await it.next()
1020
+ if (done) return { type: 'diagnostics.done', gen }
1021
+ return { type: 'diagnostics', gen, stats: value.stats }
1022
+ }
1023
+ }
1024
+
1025
+ _iterator() {
1026
+ if (!this._chunkIterator) {
1027
+ this._chunkIterator = this.engine.chunksWatch({ chatId: this.chatId })[Symbol.asyncIterator]()
1028
+ }
1029
+ return this._chunkIterator
1030
+ }
1031
+
1032
+ // File snapshots share the chunk generation so switching chats drops stale frames.
1033
+ _watchNextFiles() {
1034
+ if (!this.engine.fileStatusWatch || !this.chatId) return null
1035
+ const gen = this._chunkGen
1036
+ if (!this._filesIterator) {
1037
+ this._filesIterator = this.engine
1038
+ .fileStatusWatch({ chatId: this.chatId })
1039
+ [Symbol.asyncIterator]()
1040
+ }
1041
+ const it = this._filesIterator
1042
+ return async () => {
1043
+ try {
1044
+ const { value, done } = await it.next()
1045
+ if (done) return { type: 'files.done', gen }
1046
+ return { type: 'files', gen, files: value.files }
1047
+ } catch (error) {
1048
+ return { type: 'files.done', gen, error }
1049
+ }
1050
+ }
1051
+ }
1052
+
1053
+ // Re-armed after every frame — the stale-result guard (per bare-tui's
1054
+ // CLAUDE.md) is `gen`, captured here at issue time, checked in update().
1055
+ _watchNext() {
1056
+ const gen = this._chunkGen
1057
+ const it = this._iterator()
1058
+ return async () => {
1059
+ const { value, done } = await it.next()
1060
+ if (done) return { type: 'chunks.done', gen }
1061
+ return { type: 'chunks', gen, frame: value }
1062
+ }
1063
+ }
1064
+
1065
+ // The sidebar's chats and groups — same re-arm/stale-guard shape as the
1066
+ // other watches, so a chat created or moved on any device redraws the pane.
1067
+ _watchNextChats() {
1068
+ if (!this.engine.chatsWatch) return null
1069
+ const gen = this.gen
1070
+ if (!this._chatsIterator) {
1071
+ this._chatsIterator = this.engine.chatsWatch({})[Symbol.asyncIterator]()
1072
+ }
1073
+ const it = this._chatsIterator
1074
+ return async () => {
1075
+ const { value, done } = await it.next()
1076
+ if (done) return { type: 'chats.done', gen }
1077
+ return { type: 'chats', gen, frame: value }
1078
+ }
1079
+ }
1080
+
1081
+ // Device names for the per-device user labels — same re-arm/stale-guard
1082
+ // shape as the other watch loops. Null when the engine lacks the
1083
+ // capability (e.g. a headless test fake).
1084
+ _watchNextDevices() {
1085
+ if (!this.engine.devicesWatch) return null
1086
+ const gen = this.gen
1087
+ if (!this._devicesIterator) {
1088
+ this._devicesIterator = this.engine.devicesWatch({})[Symbol.asyncIterator]()
1089
+ }
1090
+ const it = this._devicesIterator
1091
+ return async () => {
1092
+ const { value, done } = await it.next()
1093
+ if (done) return { type: 'devices.done', gen }
1094
+ return { type: 'devices', gen, frame: value }
1095
+ }
1096
+ }
1097
+
1098
+ // The mesh-status counterpart to _watchNext — same re-arm/stale-guard
1099
+ // shape, its own iterator (meshStatusWatch frames arrive independently of
1100
+ // chunk frames).
1101
+ _watchNextStatus() {
1102
+ const gen = this.gen
1103
+ if (!this._statusIterator) {
1104
+ this._statusIterator = this.engine.meshStatusWatch({})[Symbol.asyncIterator]()
1105
+ }
1106
+ const it = this._statusIterator
1107
+ return async () => {
1108
+ const { value, done } = await it.next()
1109
+ if (done) return { type: 'mesh-status.done', gen }
1110
+ return { type: 'mesh-status', gen, frame: value }
1111
+ }
1112
+ }
1113
+
1114
+ _setTranscript(chunks) {
1115
+ this._turns = turnsFromChunks(chunks)
1116
+ this.pendingApproval = findPendingApproval(this._turns)
1117
+ const lastTurn = this._turns[this._turns.length - 1]
1118
+ const lastChunk = chunks[chunks.length - 1]
1119
+ // Busy derives from the replicated stream, not a local submit flag — a
1120
+ // non-terminal agent turn means a run is in flight SOMEWHERE, so every
1121
+ // peer (requester, provider, bystander) shows the same live state.
1122
+ // _awaitingRun only covers this device's own gap between submitting and
1123
+ // its run-request landing in a watch frame.
1124
+ const running = runInFlight(this._turns)
1125
+ if (lastTurn?.from === 'agent') this._awaitingRun = false
1126
+ const wasBusy = this.busy
1127
+ this.busy = running || this._awaitingRun
1128
+ // The most recent run's id, for _requestStop() below to stopRun() —
1129
+ // undefined once nothing is running.
1130
+ this._runningRunId = this.busy ? (lastChunk?.runId ?? this._runningRunId) : null
1131
+ if (!this.busy) {
1132
+ this.modelState = null
1133
+ if (wasBusy) this._requestRepaint()
1134
+ }
1135
+ this._render()
1136
+ }
1137
+
1138
+ // "Thinking" (or downloading/loading) is chat activity, not an input-box
1139
+ // state — it belongs as a transient trailing line in the transcript,
1140
+ // animated by the spinner, not swapping out the text field the user is
1141
+ // typing into. A pending approval already renders inline as part of the
1142
+ // transcript (see renderTurn's 'approval-request' branch) — nothing extra here.
1143
+ // The device a model event belongs to, as a display name: local harness
1144
+ // events are THIS device; replicated run-status progress names the device
1145
+ // that stamped the chunk (the provider).
1146
+ _deviceName(hexId) {
1147
+ return (hexId && this.deviceLabels.get(hexId)) || hexId?.slice(0, 8) || null
1148
+ }
1149
+
1150
+ _busyLine() {
1151
+ if (this.pendingApproval) return null
1152
+ if (!this.busy) return null
1153
+ const runTurn = runningAgentTurn(this._turns)
1154
+ const { text, on } = busyStatus(this.modelState, runTurn)
1155
+ const where =
1156
+ on === 'local'
1157
+ ? this._deviceName(this.originDeviceId?.toString('hex'))
1158
+ : on === 'host'
1159
+ ? this._deviceName(runTurn?.statusDeviceId)
1160
+ : null
1161
+ return fg.dim.render(`${this.spin.view()} ${text}${where ? ` (on ${where})` : ''}`)
1162
+ }
1163
+
1164
+ _bodyWidth() {
1165
+ return Math.max(1, this.width - 4 - this._sidebarCols())
1166
+ }
1167
+
1168
+ // The columns the pane takes from the frame — none when it is closed, and
1169
+ // none on a terminal too narrow to keep a readable transcript beside it.
1170
+ _sidebarCols() {
1171
+ if (!this._sidebar) return 0
1172
+ return this.width - SIDEBAR_COLS >= SIDEBAR_MIN_BODY ? SIDEBAR_COLS : 0
1173
+ }
1174
+
1175
+ // one wrap per item, so the cursor still scrolls to the item's own first row
1176
+ _setRows(rows, cursor) {
1177
+ const width = this._bodyWidth()
1178
+ const lines = []
1179
+ let at = 0
1180
+ for (let i = 0; i < rows.length; i++) {
1181
+ if (i === cursor) at = lines.length
1182
+ lines.push(...wrapLine(rows[i], width))
1183
+ }
1184
+ this.vp.setContent(lines.join('\n'))
1185
+ this._scrollToLine(at)
1186
+ }
1187
+
1188
+ _render() {
1189
+ if (this._models) {
1190
+ const rows = formatModels(this._models.rows, this._models.filter, {
1191
+ cursor: this._models.cursor,
1192
+ currentModel: this.agent?.modelName
1193
+ })
1194
+ this._setRows(rows, this._models.cursor + 2)
1195
+ return
1196
+ }
1197
+ if (this._selector) {
1198
+ this._setRows(formatSelector(this._selector), this._selector.cursor + 2)
1199
+ return
1200
+ }
1201
+ if (this.overlay) {
1202
+ this.vp.setContent(wrapBlock(this.overlay.join('\n'), this._bodyWidth()))
1203
+ if (this._pinnedOverlay !== this.overlay) {
1204
+ this.vp.gotoTop?.()
1205
+ this._pinnedOverlay = this.overlay
1206
+ }
1207
+ return
1208
+ }
1209
+ if (this._menu) {
1210
+ const page = this._menu.pages[this._menu.pages.length - 1]
1211
+ this._setRows(formatMenu(page), page.cursor + 2)
1212
+ return
1213
+ }
1214
+ this._pinnedOverlay = null
1215
+ // atBottom must be read BEFORE setContent: setContent re-clamps yOffset
1216
+ // against the new (taller) content, so checking it after would compare
1217
+ // the old offset to the new max and read as "not at bottom" for every
1218
+ // single append — defeating auto-scroll entirely.
1219
+ const wasAtBottom = this.vp.atBottom
1220
+ const lines = this._turns
1221
+ .map((t) =>
1222
+ renderTurn(t, this.agent.name, this.deviceLabels, {
1223
+ expandTools: this._expandTools,
1224
+ width: this._bodyWidth()
1225
+ })
1226
+ )
1227
+ .filter(Boolean)
1228
+ const busyLine = this._busyLine()
1229
+ if (busyLine) lines.push(busyLine)
1230
+ // A notice is chrome — a chat switch, a command's reply — not transcript, so
1231
+ // a chat with nothing said in it still counts as empty and the notice rides
1232
+ // under the art as its caption. That is what brings the ducks back on
1233
+ // /new-chat, which switches chat and leaves a notice behind.
1234
+ if (lines.length) {
1235
+ if (this.notice) lines.push(fg.dim.render(this.notice))
1236
+ } else {
1237
+ const booting = this._connecting
1238
+ ? 'starting up…'
1239
+ : this._booting
1240
+ ? 'connecting to the mesh…'
1241
+ : null
1242
+ // boot is the one wait with no busy line of its own — the spinner is the
1243
+ // only sign the frame is live rather than wedged
1244
+ const caption = this.notice || (booting ? `${this.spin.view()} ${booting}` : '(say hello)')
1245
+ const width = this._bodyWidth()
1246
+ // the join below spends a blank row and the caption's own row, which is
1247
+ // the height splash() centers around
1248
+ const art = splash(width, this.vp.height - 2)
1249
+ if (art) lines.push(art)
1250
+ // a multi-line notice is left to wrap on its own rather than centered
1251
+ lines.push(
1252
+ art && !caption.includes('\n')
1253
+ ? center(fg.dim.render(caption), width, tui.style.width(caption))
1254
+ : fg.dim.render(caption)
1255
+ )
1256
+ }
1257
+ this.vp.setContent(wrapBlock(lines.join('\n\n'), this._bodyWidth()))
1258
+ if (wasAtBottom) this.vp.gotoBottom()
1259
+ }
1260
+
1261
+ // Sending while a run is in flight is fine: the message and its
1262
+ // run-request just queue as chunks, and the provider executes them after
1263
+ // the current run — with the new message in that run's prompt.
1264
+ _command(text) {
1265
+ const input = text.slice(1)
1266
+ const [cmd, ...rest] = input.split(/\s+/)
1267
+ const rawArgs = input.slice(cmd.length).trimStart()
1268
+ const { engine, agent } = this
1269
+ const HELP = [
1270
+ 'Chat',
1271
+ ' /menu — open the navigable menu (Agent, Chat, Models, Voice, …); also ctrl+p, arrows + enter to browse',
1272
+ ' ctrl+b — the chats sidebar: ↑/↓ walks it, enter opens a chat, m moves one to a group, esc closes',
1273
+ ' esc — stop the run in flight, or clear the last command’s output',
1274
+ ' ctrl+o — expand the tool call/result previews (a click on a preview’s marker does the same)',
1275
+ ' /new-chat — create and switch to a new chat (shortcut for /chat new)',
1276
+ ' /chats — pick from your chats to switch (interactive) · /chat — show current',
1277
+ ' /chat new [title] · /chat use <title|id> · /chat rename <title> · /chat del <title|id> · /chat websearch on|off · /chat rag on|off',
1278
+ ' /group — the sidebar as core orders it (groups, then ungrouped, each most-recently-active first)',
1279
+ ' /group new <name> · /group rename <group> <name> · /group del <group> — quote any name containing spaces',
1280
+ ' /group move <group> [chat] — move a chat into a group; the chat defaults to the active one, and - as the group ungroups it',
1281
+ ' /knowledge [list] · /knowledge add <file> · /knowledge add-dir <directory> · /knowledge download <n|id> · /knowledge remove <n|id>',
1282
+ ' /files — show upload progress, local readiness, and failures for this chat',
1283
+ ' /attach <path> [<path> …] — attach local files to this chat (quote paths that contain spaces)',
1284
+ ' /save [n] — download the latest (or n-th) attachment to ~/Downloads',
1285
+ ' /export [n] — write the last run (or last n) to a markdown file: prompt, thinking, answer, tools',
1286
+ '',
1287
+ 'Agent',
1288
+ ' /agents — pick from your agents to switch (interactive) · /agent — show current · /agent list · /agent new <name> · /agent rename <name> · /agent use <name|id>',
1289
+ " /activity — list the active agent's recent tool calls, each with its decision (allowed/denied/auto/skip-all/blocked)",
1290
+ ' /skills · /tools — browse installed; ↑/↓ move, space toggles each on/off (sets scope=selected), enter sets up or reconfigures the skill, esc returns',
1291
+ ' /skill on|off <name> · /tool on|off <name> — toggle (sets scope=selected)',
1292
+ ' /permissions — approval mode + the toolset switches; ↑/↓ move, space toggles',
1293
+ ' /models [type] — browse every model in the SDK registry; type is an SDK addon (llm, tts, whisper, embeddings, diffusion, …) or an alias (transcription, embedding, image); ↑/↓ move, space selects',
1294
+ ' /image [model <choice> | steps <n> | size <WxH> | reset] — the agent’s image-generation defaults',
1295
+ '',
1296
+ 'Connections — a skill that needs an account or a token (Menu ▸ Agent ▸ Connections lists them with their state)',
1297
+ ' /connect gmail|google-calendar|google-docs|google-drive|google-sheets — connect one Google skill (needs GOOGLE_OAUTH_CLIENT_ID + GOOGLE_OAUTH_CLIENT_SECRET)',
1298
+ ' /connect spotify — OAuth-connect Spotify via PKCE (needs SPOTIFY_OAUTH_CLIENT_ID; no secret)',
1299
+ ' /connect notion — OAuth-connect Notion MCP via PKCE + dynamic registration (no env, no secret)',
1300
+ ' /connect asana — OAuth-connect Asana MCP via PKCE (needs ASANA_OAUTH_CLIENT_ID + ASANA_OAUTH_CLIENT_SECRET)',
1301
+ '',
1302
+ 'Voice',
1303
+ ' /speak <text> — synthesize speech (TTS) to a WAV file in the save dir',
1304
+ ' /speak-stream <text> — synthesize speech per sentence over a duplex stream (TTS)',
1305
+ ' /transcribe <path> — transcribe a 16kHz mono s16 WAV file to text (STT; no resampling)',
1306
+ ' /converse [device] — hands-free voice conversation via the mic (Esc or /converse to stop)',
1307
+ ' /record [seconds] [device] — record from the mic via ffmpeg (macOS avfoundation) and transcribe',
1308
+ '',
1309
+ 'Advanced',
1310
+ ' /cred <key> <value> — set a credential by hand for this agent (e.g. /cred github_access_token ghp_…); /skills, enter on the skill, fills this in for you',
1311
+ ' /scope skills|tools all|selected — switch scope',
1312
+ ' /join <qvac://mesh/…> — retire this device’s mesh and join that one (ctrl+y copies this mesh’s invite) · /join cancel — abort a join in progress',
1313
+ ' /my-device — dump this device\u2019s CPU/GPU inventory (manufacturer, cores, drivers, …)'
1314
+ ].join('\n')
1315
+
1316
+ const notice = (t) => [this, async () => ({ type: 'notice', text: t })]
1317
+ const run = (fn) => [
1318
+ this,
1319
+ async () => {
1320
+ try {
1321
+ return { type: 'notice', text: await fn() }
1322
+ } catch (error) {
1323
+ return { type: 'run.error', error }
1324
+ }
1325
+ }
1326
+ ]
1327
+
1328
+ // an overlay, not a notice: /help is a page you read and dismiss with esc,
1329
+ // and it would otherwise sit under the transcript until the next message
1330
+ if (cmd === 'help') {
1331
+ this._models = null
1332
+ this._selector = null
1333
+ this._menu = null
1334
+ this.overlay = [...HELP.split('\n'), '', fg.dim.render('esc to return')]
1335
+ this._render()
1336
+ return [this, null]
1337
+ }
1338
+
1339
+ if (cmd === 'menu') return this._openMenu()
1340
+
1341
+ if (cmd === 'new-chat') return this._newChat('New Chat')
1342
+
1343
+ if (cmd === 'my-device') return this._showDiagnostics()
1344
+
1345
+ if (cmd === 'models') {
1346
+ const raw = rest[0]?.toLowerCase()
1347
+ if (!raw) return this._showModels(null)
1348
+ const resolved = MODEL_TYPE_ALIASES[raw] ?? raw
1349
+ if (!MODEL_TYPES.has(resolved)) {
1350
+ const known = [...MODEL_TYPES].sort().join(', ')
1351
+ const aliases = Object.keys(MODEL_TYPE_ALIASES).sort().join(', ')
1352
+ return notice(`unknown model type: ${raw}\nknown types: ${known}\naliases: ${aliases}`)
1353
+ }
1354
+ return this._showModels(resolved)
1355
+ }
1356
+
1357
+ if (cmd === 'image') {
1358
+ const [field, value] = rest
1359
+ const USAGE =
1360
+ 'usage: /image [model <sd2|sdxl|flux2-klein> | steps <1..100> | size <WxH> | reset]'
1361
+ if (!field) {
1362
+ return run(async () => {
1363
+ const a = await engine.getAgent({ id: agent.id })
1364
+ const size =
1365
+ a.imageWidth || a.imageHeight
1366
+ ? `${a.imageWidth || 512}x${a.imageHeight || 512}`
1367
+ : '512x512 (default)'
1368
+ return [
1369
+ `image defaults for ${a.name}:`,
1370
+ ` model ${a.imageModelChoice || '(device default)'}`,
1371
+ ` steps ${a.imageSteps || '(model default)'}`,
1372
+ ` size ${size}`
1373
+ ].join('\n')
1374
+ })
1375
+ }
1376
+ if (field === 'model') {
1377
+ if (!['sd2', 'sdxl', 'flux2-klein'].includes(value)) return notice(USAGE)
1378
+ return run(async () => {
1379
+ await engine.updateAgent({ id: agent.id, imageModelChoice: value })
1380
+ return `image model set to ${value}`
1381
+ })
1382
+ }
1383
+ if (field === 'steps') {
1384
+ const steps = Number(value)
1385
+ if (!Number.isInteger(steps) || steps < 1 || steps > 100) return notice(USAGE)
1386
+ return run(async () => {
1387
+ await engine.updateAgent({ id: agent.id, imageSteps: steps })
1388
+ return `image steps set to ${steps}`
1389
+ })
1390
+ }
1391
+ if (field === 'reset') {
1392
+ return run(async () => {
1393
+ await engine.updateAgent({ id: agent.id, clearImageSettings: true })
1394
+ return 'image defaults reset (device default model, model-default steps, 512x512)'
1395
+ })
1396
+ }
1397
+ if (field === 'size') {
1398
+ const match = /^(\d+)x(\d+)$/.exec(value ?? '')
1399
+ if (!match) return notice(USAGE)
1400
+ // persist through the runtime's own clamp/snap so the stored size is the effective one
1401
+ const width = settingsDimension(Number(match[1]))
1402
+ const height = settingsDimension(Number(match[2]))
1403
+ if (!width || !height) return notice(USAGE)
1404
+ return run(async () => {
1405
+ await engine.updateAgent({ id: agent.id, imageWidth: width, imageHeight: height })
1406
+ return `image size set to ${width}x${height}`
1407
+ })
1408
+ }
1409
+ return notice(USAGE)
1410
+ }
1411
+
1412
+ if (cmd === 'agent') {
1413
+ const [action, ...nameParts] = rest
1414
+ const arg = nameParts.join(' ')
1415
+ if (!action) {
1416
+ return run(async () => {
1417
+ const a = await engine.getAgent({ id: agent.id })
1418
+ return `agent ${a.name} (${a.id.slice(0, 8)}) · skills=${a.skillsScope ?? 'all'} tools=${a.toolsScope ?? 'all'}`
1419
+ })
1420
+ }
1421
+ if (action === 'list') {
1422
+ return run(async () => {
1423
+ const { agents } = await engine.listAgents({})
1424
+ return agents
1425
+ .map((a) => `${a.id === agent.id ? '*' : ' '}${a.name} (${a.id.slice(0, 8)})`)
1426
+ .join('\n')
1427
+ })
1428
+ }
1429
+ if (action === 'new') {
1430
+ if (!arg) return notice('usage: /agent new <name>')
1431
+ return run(async () => {
1432
+ const created = await engine.createAgent({
1433
+ name: arg,
1434
+ bio: `${arg} — test agent`,
1435
+ providerDeviceId: this.originDeviceId,
1436
+ modelName: agent.modelName
1437
+ })
1438
+ this.agent = created
1439
+ return `created + selected ${created.name} (${created.id.slice(0, 8)}) · no skills/tools enabled`
1440
+ })
1441
+ }
1442
+ if (action === 'rename') {
1443
+ if (!arg) return notice('usage: /agent rename <name>')
1444
+ return run(async () => {
1445
+ this.agent = await engine.updateAgent({ id: agent.id, name: arg })
1446
+ return `renamed to ${this.agent.name} (${this.agent.id.slice(0, 8)})`
1447
+ })
1448
+ }
1449
+ if (action === 'use') {
1450
+ if (!arg) return notice('usage: /agent use <name|id>')
1451
+ return run(async () => {
1452
+ const { agents } = await engine.listAgents({})
1453
+ const found = agents.find((a) => a.id === arg || a.id.startsWith(arg) || a.name === arg)
1454
+ if (!found) return `no agent matching: ${arg}`
1455
+ this.agent = found
1456
+ return `selected ${found.name} (${found.id.slice(0, 8)})`
1457
+ })
1458
+ }
1459
+ return notice('usage: /agent [list|new <name>|rename <name>|use <name|id>]')
1460
+ }
1461
+
1462
+ if (cmd === 'activity') {
1463
+ return run(async () => {
1464
+ const { activities } = await engine.listToolActivity({ agentId: agent.id, limit: 30 })
1465
+ if (!activities || activities.length === 0) {
1466
+ return 'no tool activity yet for this agent'
1467
+ }
1468
+ return activities
1469
+ .map((a) => {
1470
+ const when = new Date(a.at).toISOString().slice(0, 19).replace('T', ' ')
1471
+ const args = a.arguments ? JSON.stringify(a.arguments) : ''
1472
+ const call = args.length > 60 ? `${args.slice(0, 57)}…` : args
1473
+ return `${a.decision.padEnd(8)} ${a.toolName}(${call}) · ${when}`
1474
+ })
1475
+ .join('\n')
1476
+ })
1477
+ }
1478
+
1479
+ if (cmd === 'skills' || cmd === 'tools') return this._showSelector(cmd)
1480
+
1481
+ if (cmd === 'permissions') return this._showPermissions()
1482
+
1483
+ if (cmd === 'skill' || cmd === 'tool') {
1484
+ const [action, name] = rest
1485
+ if ((action !== 'on' && action !== 'off') || !name) {
1486
+ return notice(`usage: /${cmd} on|off <name>`)
1487
+ }
1488
+ const field = cmd === 'skill' ? 'enabledSkills' : 'enabledTools'
1489
+ const scopeField = cmd === 'skill' ? 'skillsScope' : 'toolsScope'
1490
+ return run(async () => {
1491
+ const fresh = await engine.getAgent({ id: agent.id })
1492
+ const set = new Set(fresh[field] ?? [])
1493
+ if (action === 'on') set.add(name)
1494
+ else set.delete(name)
1495
+ const updated = await engine.updateAgent({
1496
+ id: agent.id,
1497
+ [field]: [...set],
1498
+ [scopeField]: 'selected'
1499
+ })
1500
+ this.agent = updated
1501
+ const line = `${cmd} ${name} ${action} · ${field}=[${[...set].join(', ')}] scope=selected`
1502
+ if (cmd !== 'skill' || action !== 'on') return line
1503
+ const skill = (await this._skillDetail())?.get(name)
1504
+ if (!skill || isReady(skill)) return line
1505
+ return `${line}\n${renderSkill(skill)}`
1506
+ })
1507
+ }
1508
+
1509
+ if (cmd === 'scope') {
1510
+ const [which, value] = rest
1511
+ if ((which !== 'skills' && which !== 'tools') || (value !== 'all' && value !== 'selected')) {
1512
+ return notice('usage: /scope skills|tools all|selected')
1513
+ }
1514
+ const scopeField = which === 'skills' ? 'skillsScope' : 'toolsScope'
1515
+ return run(async () => {
1516
+ const updated = await engine.updateAgent({ id: agent.id, [scopeField]: value })
1517
+ this.agent = updated
1518
+ return `${which} scope=${value}`
1519
+ })
1520
+ }
1521
+
1522
+ if (cmd === 'attach') {
1523
+ if (!this._resolveFileAttachment) {
1524
+ return notice('attach is unavailable (no file resolver wired)')
1525
+ }
1526
+ const paths = splitPaths(rawArgs)
1527
+ if (!paths.length) {
1528
+ return notice('usage: /attach <path> [<path> …] — quote paths that contain spaces')
1529
+ }
1530
+ // Each file uploads as its own chunk; a bad path fails only its line.
1531
+ const app = this
1532
+ return run(async () => {
1533
+ const lines = []
1534
+ for (const p of paths) {
1535
+ try {
1536
+ const file = app._resolveFileAttachment(p)
1537
+ await app._uploadAttachment(file)
1538
+ lines.push(`attached ${file.fileName} (${file.data.length}B) ✓`)
1539
+ } catch (err) {
1540
+ lines.push(`✗ ${p} — ${err.code === 'ENOENT' ? 'no such file' : err.message}`)
1541
+ }
1542
+ }
1543
+ return lines.join('\n')
1544
+ })
1545
+ }
1546
+
1547
+ if (cmd === 'export') {
1548
+ const runs = rest[0] ? Number(rest[0]) : 1
1549
+ if (!Number.isInteger(runs) || runs < 1) {
1550
+ return notice('usage: /export [n] — the last n runs (default 1)')
1551
+ }
1552
+ return run(async () => {
1553
+ const chat = engine.getChat ? await engine.getChat({ id: this.chatId }) : null
1554
+ const markdown = exportMarkdown(this._turns, runs, {
1555
+ title: chat?.title ?? 'Chat',
1556
+ chatId: this.chatId,
1557
+ agentName: this.agent?.name,
1558
+ modelName: this.agent?.modelName
1559
+ })
1560
+ if (!markdown) return 'nothing to export yet — no runs in this chat'
1561
+ const name = `${chat?.title ?? 'chat'}-${this.chatId.slice(0, 8)}.md`
1562
+ // the clipboard is where a report gets pasted; the file is the copy that keeps
1563
+ const copied = await this._copyQuietly(markdown)
1564
+ const file = await saveUniqueFile(this._saveDir, name, Buffer.from(markdown), '.md')
1565
+ const what = runs === 1 ? 'the last run' : `the last ${runs} runs`
1566
+ if (!file) {
1567
+ const where = `100 files named ${name} already exist in ${this._saveDir}`
1568
+ return copied
1569
+ ? `copied ${what} to the clipboard · not saved: ${where}`
1570
+ : `not saved: ${where}`
1571
+ }
1572
+ return `exported ${what} to ${file}${copied ? ' · copied to clipboard' : ''}`
1573
+ })
1574
+ }
1575
+
1576
+ if (cmd === 'save') {
1577
+ // blobs are encrypted at rest — the only viewable copy is a fresh download
1578
+ return run(async () => {
1579
+ const { chunks } = await engine.listChunks({ chatId: this.chatId, type: 'attachment' })
1580
+ const all = chunks ?? []
1581
+ if (!all.length) return 'no attachments in this chat'
1582
+ const picked = rest[0] ? all[Number(rest[0]) - 1] : all[all.length - 1]
1583
+ if (!picked) return `no attachment #${rest[0]} (this chat has ${all.length})`
1584
+ const att = picked.attachment
1585
+ if (!att) return `attachment chunk ${picked.id} has no attachment payload`
1586
+ if (att.deletedAt) return `${att.fileName} was deleted`
1587
+ const pieces = []
1588
+ for await (const frame of engine.downloadAttachment({ blobId: att.blobId })) {
1589
+ if (frame.data) pieces.push(frame.data)
1590
+ }
1591
+ const bytes = Buffer.concat(pieces)
1592
+ if (!bytes.length) return `empty download for ${att.fileName}`
1593
+ const ext = { 'image/jpeg': '.jpg', 'image/png': '.png' }[att.mimeType]
1594
+ const file = await saveUniqueFile(this._saveDir, att.fileName, bytes, ext ?? '')
1595
+ if (file) return `saved ${att.fileName} (${bytes.length}B) to ${file}`
1596
+ return `not saved: 100 files named ${att.fileName} already exist in ${this._saveDir}`
1597
+ })
1598
+ }
1599
+
1600
+ if (cmd === 'files') {
1601
+ if (!engine.fileStatusWatch) return notice('file status unavailable on this engine')
1602
+ return notice(this._files.map(fileStatusText).join('\n') || 'no files in this chat')
1603
+ }
1604
+
1605
+ if (cmd === 'knowledge') {
1606
+ return [
1607
+ this,
1608
+ () => knowledgeCommand(engine, { chatId: this.chatId, saveDir: this._saveDir }, rawArgs)
1609
+ ]
1610
+ }
1611
+
1612
+ if (cmd === 'speak') {
1613
+ if (!engine.speak) return notice('voice not available in this build')
1614
+ const text = rest.join(' ').trim()
1615
+ if (!text) return notice('usage: /speak <text>')
1616
+ return run(async () => {
1617
+ const { audio, sampleRate } = await engine.speak({ text })
1618
+ const file = path.join(this._saveDir, `speak-${Date.now()}.wav`)
1619
+ await fs.promises.writeFile(file, floatPcmToWav(audio, sampleRate))
1620
+ return `synthesized ${audio.length}B @ ${sampleRate}Hz → ${file}`
1621
+ })
1622
+ }
1623
+
1624
+ if (cmd === 'speak-stream') {
1625
+ if (!engine.speakStream) return notice('voice not available in this build')
1626
+ const text = rest.join(' ').trim()
1627
+ if (!text) return notice('usage: /speak-stream <text>')
1628
+ return run(async () => {
1629
+ const duplex = engine.speakStream()
1630
+ for (const fragment of text.split(/(?<=[.!?])\s+/)) duplex.write({ text: fragment })
1631
+ duplex.end()
1632
+ const parts = []
1633
+ let sampleRate = 44100
1634
+ let chunks = 0
1635
+ for await (const frame of duplex) {
1636
+ chunks++
1637
+ sampleRate = frame.sampleRate
1638
+ parts.push(frame.audio)
1639
+ }
1640
+ const audio = Buffer.concat(parts)
1641
+ const file = path.join(this._saveDir, `speak-stream-${Date.now()}.wav`)
1642
+ await fs.promises.writeFile(file, floatPcmToWav(audio, sampleRate))
1643
+ return `streamed ${chunks} chunk(s), ${audio.length}B @ ${sampleRate}Hz → ${file}`
1644
+ })
1645
+ }
1646
+
1647
+ if (cmd === 'transcribe') {
1648
+ if (!engine.transcribe) return notice('voice not available in this build')
1649
+ const arg = rest.join(' ').trim()
1650
+ if (!arg) return notice('usage: /transcribe <path-to-16kHz-mono-s16.wav>')
1651
+ return run(async () => {
1652
+ const audio = await fs.promises.readFile(arg.replace(/^~/, os.homedir()))
1653
+ // Parakeet needs 16kHz mono s16 and the SDK does not resample — a wrong
1654
+ // format transcribes to empty, so flag it here instead of silently failing.
1655
+ if (audio.length > 44 && audio.toString('ascii', 0, 4) === 'RIFF') {
1656
+ const fmt = audio.readUInt16LE(20)
1657
+ const ch = audio.readUInt16LE(22)
1658
+ const rate = audio.readUInt32LE(24)
1659
+ const bits = audio.readUInt16LE(34)
1660
+ if (fmt !== 1 || ch !== 1 || rate !== 16000 || bits !== 16) {
1661
+ return `wrong WAV format: ${rate}Hz ${ch}ch ${bits}bit (fmt ${fmt}) — need 16000Hz mono 16-bit PCM\nconvert: ffmpeg -i "${arg}" -ar 16000 -ac 1 -c:a pcm_s16le out.wav`
1662
+ }
1663
+ }
1664
+ const { text } = await engine.transcribe({ audio })
1665
+ return `transcript: ${text || '(empty — silence or unsupported audio)'}`
1666
+ })
1667
+ }
1668
+
1669
+ if (cmd === 'record') {
1670
+ if (!engine.transcribe) return notice('voice not available in this build')
1671
+ const seconds = Number(rest[0]) || 5
1672
+ const device = rest[1] || ':0'
1673
+ const file = path.join(this._saveDir, `record-${Date.now()}.wav`)
1674
+ return run(async () => {
1675
+ await new Promise((resolve, reject) => {
1676
+ const ff = spawn(
1677
+ 'ffmpeg',
1678
+ // prettier-ignore
1679
+ ['-y', '-f', 'avfoundation', '-i', device, '-ar', '16000', '-ac', '1', '-c:a', 'pcm_s16le', '-t', String(seconds), file],
1680
+ { stdio: 'ignore' }
1681
+ )
1682
+ ff.on('error', reject)
1683
+ ff.on('exit', (code) =>
1684
+ code === 0
1685
+ ? resolve()
1686
+ : reject(
1687
+ new Error(
1688
+ `ffmpeg exited ${code} (try a different device, e.g. /record ${seconds} :1)`
1689
+ )
1690
+ )
1691
+ )
1692
+ })
1693
+ const audio = await fs.promises.readFile(file)
1694
+ const { text } = await engine.transcribe({ audio })
1695
+ return `recorded ${seconds}s → transcript: ${text || '(empty — silence?)'}`
1696
+ })
1697
+ }
1698
+
1699
+ if (cmd === 'converse') {
1700
+ if (!engine.transcribe || !engine.speakStream) {
1701
+ return notice('voice not available in this build')
1702
+ }
1703
+ if (this._converse) {
1704
+ this._stopConverse('off')
1705
+ return notice('conversation mode off')
1706
+ }
1707
+ const device = rest[0] || ':0'
1708
+ this._startConverse(device)
1709
+ return notice(
1710
+ `conversation mode on (${device}) — talk; /converse or Esc to stop. use headphones (speaker echo triggers false barge-in)`
1711
+ )
1712
+ }
1713
+
1714
+ if (cmd === 'connect') {
1715
+ const [provider = ''] = rest
1716
+ // Each Google skill has its own key, scopes and app: `connect gmail`, `connect google-docs`.
1717
+ const googleSpec = oauthKeySpec(`${provider.replaceAll('-', '_')}_access_token`)
1718
+ if (googleSpec?.provider === 'google') {
1719
+ const clientId = process.env.GOOGLE_OAUTH_CLIENT_ID
1720
+ const clientSecret = process.env.GOOGLE_OAUTH_CLIENT_SECRET
1721
+ if (!clientId || !clientSecret) {
1722
+ return notice('set GOOGLE_OAUTH_CLIENT_ID and GOOGLE_OAUTH_CLIENT_SECRET, then retry')
1723
+ }
1724
+ return run(async () => {
1725
+ const tokens = await connectGoogle({
1726
+ clientId,
1727
+ clientSecret,
1728
+ credentialKey: googleSpec.credentialKey,
1729
+ openUrl: (url) => {
1730
+ this.status = fg.dim.render(`authorize in your browser: ${url}`)
1731
+ this._render()
1732
+ openInBrowser(url)
1733
+ }
1734
+ })
1735
+ await engine.configSet({
1736
+ key: credKey(agent.id, googleSpec.credentialKey),
1737
+ value: tokens.accessToken
1738
+ })
1739
+ await engine.configSet({
1740
+ key: credKey(agent.id, googleSpec.stateKey),
1741
+ value: JSON.stringify({
1742
+ refreshToken: tokens.refreshToken,
1743
+ expiresAt: tokens.expiresAt,
1744
+ clientId,
1745
+ clientSecret
1746
+ })
1747
+ })
1748
+ return `${provider} connected for agent ${agent.name} — token stored, auto-refreshes on use`
1749
+ })
1750
+ }
1751
+ if (provider === 'spotify') {
1752
+ const clientId = process.env.SPOTIFY_OAUTH_CLIENT_ID
1753
+ if (!clientId) {
1754
+ return notice('set SPOTIFY_OAUTH_CLIENT_ID, then retry')
1755
+ }
1756
+ return run(async () => {
1757
+ const tokens = await connectSpotify({
1758
+ clientId,
1759
+ openUrl: (url) => {
1760
+ this.status = fg.dim.render(`authorize in your browser: ${url}`)
1761
+ this._render()
1762
+ openInBrowser(url)
1763
+ }
1764
+ })
1765
+ await engine.configSet({
1766
+ key: credKey(agent.id, SPOTIFY_CREDENTIAL_KEY),
1767
+ value: tokens.accessToken
1768
+ })
1769
+ await engine.configSet({
1770
+ key: credKey(agent.id, SPOTIFY_OAUTH_STATE_KEY),
1771
+ value: JSON.stringify({
1772
+ refreshToken: tokens.refreshToken,
1773
+ expiresAt: tokens.expiresAt,
1774
+ clientId
1775
+ })
1776
+ })
1777
+ return `spotify connected for agent ${agent.name} — token stored, auto-refreshes on use`
1778
+ })
1779
+ }
1780
+ if (provider === 'notion') {
1781
+ return run(async () => {
1782
+ const tokens = await connectNotion({
1783
+ openUrl: (url) => {
1784
+ this.status = fg.dim.render(`authorize in your browser: ${url}`)
1785
+ this._render()
1786
+ openInBrowser(url)
1787
+ }
1788
+ })
1789
+ await engine.configSet({
1790
+ key: credKey(agent.id, NOTION_MCP_CREDENTIAL_KEY),
1791
+ value: tokens.accessToken
1792
+ })
1793
+ await engine.configSet({
1794
+ key: credKey(agent.id, NOTION_OAUTH_STATE_KEY),
1795
+ value: JSON.stringify({
1796
+ refreshToken: tokens.refreshToken,
1797
+ expiresAt: tokens.expiresAt,
1798
+ clientId: tokens.clientId
1799
+ })
1800
+ })
1801
+ return `notion connected for agent ${agent.name} — token stored, auto-refreshes on use`
1802
+ })
1803
+ }
1804
+ if (provider === 'asana') {
1805
+ const clientId = process.env.ASANA_OAUTH_CLIENT_ID
1806
+ const clientSecret = process.env.ASANA_OAUTH_CLIENT_SECRET
1807
+ if (!clientId || !clientSecret) {
1808
+ return notice('set ASANA_OAUTH_CLIENT_ID and ASANA_OAUTH_CLIENT_SECRET, then retry')
1809
+ }
1810
+ return run(async () => {
1811
+ const tokens = await connectAsana({
1812
+ clientId,
1813
+ clientSecret,
1814
+ openUrl: (url) => {
1815
+ this.status = fg.dim.render(`authorize in your browser: ${url}`)
1816
+ this._render()
1817
+ openInBrowser(url)
1818
+ }
1819
+ })
1820
+ await engine.configSet({
1821
+ key: credKey(agent.id, ASANA_MCP_CREDENTIAL_KEY),
1822
+ value: tokens.accessToken
1823
+ })
1824
+ await engine.configSet({
1825
+ key: credKey(agent.id, ASANA_OAUTH_STATE_KEY),
1826
+ value: JSON.stringify({
1827
+ refreshToken: tokens.refreshToken,
1828
+ expiresAt: tokens.expiresAt,
1829
+ clientId,
1830
+ clientSecret
1831
+ })
1832
+ })
1833
+ return `asana connected for agent ${agent.name} — token stored, auto-refreshes on use`
1834
+ })
1835
+ }
1836
+ return notice(
1837
+ 'usage: /connect gmail|google-calendar|google-docs|google-drive|google-sheets|spotify|notion|asana'
1838
+ )
1839
+ }
1840
+
1841
+ if (cmd === 'cred') {
1842
+ const [key, ...valueParts] = rest
1843
+ const value = valueParts.join(' ')
1844
+ if (!key || !value) return notice('usage: /cred <key> <value>')
1845
+ return run(async () => {
1846
+ const storageKey = credKey(agent.id, key)
1847
+ await engine.configSet({ key: storageKey, value })
1848
+ return `set ${key} for agent ${agent.name} (${value.length} chars) — scoped, not shared`
1849
+ })
1850
+ }
1851
+
1852
+ // Moving mesh is a Core-side transition: it retires this device's mesh and
1853
+ // admits it to the invited one, so every agent and chat the TUI is holding
1854
+ // becomes stale. Block sending for its duration and re-resolve both from
1855
+ // the joined mesh afterwards (see the 'mesh.joined' handler). A join parks
1856
+ // until the inviting device admits it — /join cancel gives that a way out.
1857
+ if (cmd === 'join') {
1858
+ const arg = rawArgs.trim()
1859
+ if (!arg) return notice('usage: /join <qvac://mesh/…> · /join cancel')
1860
+ if (arg === 'cancel') {
1861
+ return run(async () => {
1862
+ await engine.cancelMeshJoin({})
1863
+ return 'join canceled'
1864
+ })
1865
+ }
1866
+ // an invite pasted from a wrapped terminal arrives with embedded newlines
1867
+ const invite = arg.replace(/\s+/g, '')
1868
+ this._booting = true
1869
+ this.status = 'joining mesh…'
1870
+ this.notice = null
1871
+ return [
1872
+ this,
1873
+ async () => {
1874
+ try {
1875
+ await engine.joinMesh({ invite })
1876
+ const next = await engine.meshInvite({}).catch(() => null)
1877
+ return { type: 'mesh.joined', invite: next?.invite ?? null }
1878
+ } catch (error) {
1879
+ return { type: 'mesh.joined', error }
1880
+ }
1881
+ }
1882
+ ]
1883
+ }
1884
+
1885
+ // /chats + /agents open the same interactive pickers as the menu's Switch
1886
+ // rows — a standalone menu.page (see the message handler) you arrow through
1887
+ // and enter to switch, rather than a static list you'd then /…use by id.
1888
+ if (cmd === 'chats') return this._chatListPage()
1889
+
1890
+ if (cmd === 'agents') return this._agentListPage()
1891
+
1892
+ if (cmd === 'chat') {
1893
+ const [action, ...argParts] = rest
1894
+ const arg = argParts.join(' ')
1895
+ if (!action) {
1896
+ return run(async () => {
1897
+ const c = await engine.getChat({ id: this.chatId })
1898
+ return `chat "${c.title}" (${c.id.slice(0, 8)}) · web=${c.webSearchEnabled ? 'on' : 'off'}`
1899
+ })
1900
+ }
1901
+ if (action === 'new') return this._newChat(arg || 'New Chat')
1902
+ if (action === 'use') {
1903
+ if (!arg) return notice('usage: /chat use <id>')
1904
+ return this._useChat(arg)
1905
+ }
1906
+ if (action === 'del') {
1907
+ if (!arg) return notice('usage: /chat del <id>')
1908
+ return this._deleteChat(arg)
1909
+ }
1910
+ if (action === 'rename') {
1911
+ if (!arg) return notice('usage: /chat rename <title>')
1912
+ return run(async () => {
1913
+ const c = await engine.renameChat({ id: this.chatId, title: arg })
1914
+ return `renamed to "${c.title}"`
1915
+ })
1916
+ }
1917
+ if (action === 'websearch' || action === 'rag') {
1918
+ const state = argParts[0]
1919
+ if (state !== 'on' && state !== 'off') return notice(`usage: /chat ${action} on|off`)
1920
+ const enabled = state === 'on'
1921
+ return run(async () => {
1922
+ if (action === 'rag') await engine.setChatRagEnabled({ id: this.chatId, enabled })
1923
+ else await engine.setChatWebSearchEnabled({ id: this.chatId, enabled })
1924
+ return `${action === 'rag' ? 'RAG' : 'web search'} ${state} for this chat`
1925
+ })
1926
+ }
1927
+ return notice(
1928
+ 'usage: /chat [new <title>|use <id>|rename <title>|del <id>|websearch on|off|rag on|off]'
1929
+ )
1930
+ }
1931
+
1932
+ if (cmd === 'group') {
1933
+ const [action, ...argParts] = rest
1934
+ if (!action) return this._groupOverview()
1935
+
1936
+ // a needle matching nothing or several is a plain notice — a typo is not an engine error
1937
+ const withGroup = (needle, fn) =>
1938
+ run(async () => {
1939
+ const { group, ambiguous } = await this._resolveGroup(needle)
1940
+ if (ambiguous) return `"${needle}" matches ${ambiguous} groups — be specific`
1941
+ if (!group) return `no group matching "${needle}"`
1942
+ return fn(group)
1943
+ })
1944
+
1945
+ if (action === 'new') {
1946
+ const name = argParts.join(' ')
1947
+ if (!name) return notice('usage: /group new <name>')
1948
+ return run(async () => {
1949
+ const group = await engine.createChatGroup({ name })
1950
+ return `created group "${group.name}" (${group.id.slice(0, 8)})`
1951
+ })
1952
+ }
1953
+ if (action === 'move') {
1954
+ // group first, chat optional, so the one-argument form is a strict prefix of the two;
1955
+ // tokenized rather than joined, which is why a name with a space has to be quoted
1956
+ const [groupNeedle, chatNeedle, ...extra] = splitPaths(argParts.join(' '))
1957
+ if (!groupNeedle || extra.length > 0) {
1958
+ return notice(
1959
+ 'usage: /group move <group>|- [chat] (defaults to this chat; quote a name with spaces)'
1960
+ )
1961
+ }
1962
+ // a miss comes back as text, not a throw, so it renders as a notice like every other miss
1963
+ const target = async () => {
1964
+ if (!chatNeedle) return { id: this.chatId, what: 'this chat' }
1965
+ const { chat, ambiguous } = await this._resolveChat(chatNeedle)
1966
+ if (ambiguous) return { miss: `"${chatNeedle}" matches ${ambiguous} chats — be specific` }
1967
+ if (!chat) return { miss: `no chat matching "${chatNeedle}"` }
1968
+ return { id: chat.id, what: `"${chat.title}"` }
1969
+ }
1970
+ if (groupNeedle === '-') {
1971
+ return run(async () => {
1972
+ const { id, what, miss } = await target()
1973
+ if (miss) return miss
1974
+ await engine.setChatGroup({ id })
1975
+ return `${what} is ungrouped`
1976
+ })
1977
+ }
1978
+ return withGroup(groupNeedle, async (group) => {
1979
+ const { id, what, miss } = await target()
1980
+ if (miss) return miss
1981
+ await engine.setChatGroup({ id, groupId: group.id })
1982
+ return `moved ${what} to "${group.name}"`
1983
+ })
1984
+ }
1985
+ if (action === 'rename') {
1986
+ // quoted, or the first token is the needle and `Day job` -> `Personal` is inexpressible
1987
+ const [needle, ...nameParts] = splitPaths(argParts.join(' '))
1988
+ const name = nameParts.join(' ')
1989
+ if (!needle || !name) {
1990
+ return notice(
1991
+ 'usage: /group rename <group> <name> (quote a group whose name has a space)'
1992
+ )
1993
+ }
1994
+ return withGroup(needle, async (group) => {
1995
+ const renamed = await engine.renameChatGroup({ id: group.id, name })
1996
+ return `renamed "${group.name}" to "${renamed.name}"`
1997
+ })
1998
+ }
1999
+ if (action === 'del') {
2000
+ const needle = argParts.join(' ')
2001
+ if (!needle) return notice('usage: /group del <group>')
2002
+ return withGroup(needle, async (group) => {
2003
+ await engine.deleteChatGroup({ id: group.id })
2004
+ return `deleted "${group.name}" — its chats are now ungrouped`
2005
+ })
2006
+ }
2007
+ return notice('usage: /group [new <name>|rename <group> <name>|del <group>|move <group>|-]')
2008
+ }
2009
+
2010
+ return notice(`unknown command: /${cmd}\n${HELP}`)
2011
+ }
2012
+
2013
+ // Exact id wins; otherwise a prefix must be UNIQUE — an ambiguous prefix is reported, never
2014
+ // silently resolved to whichever chat sorts first. Returns { chat } | { ambiguous: n } | {}.
2015
+ async _resolveChat(needle) {
2016
+ const { chats } = await this.engine.listChats({})
2017
+ const live = (chats ?? []).filter((c) => !c.deletedAt)
2018
+ const lower = needle.toLowerCase()
2019
+ // id first so a needle that is a real id can never be shadowed by a chat titled the same
2020
+ const exact =
2021
+ live.find((c) => c.id === needle) ?? live.find((c) => c.title.toLowerCase() === lower)
2022
+ if (exact) return { chat: exact }
2023
+ const matches = live.filter(
2024
+ (c) => c.id.startsWith(needle) || c.title.toLowerCase().startsWith(lower)
2025
+ )
2026
+ if (matches.length > 1) return { ambiguous: matches.length }
2027
+ return { chat: matches[0] ?? null }
2028
+ }
2029
+
2030
+ // exact id, exact name, then a UNIQUE prefix of either — an ambiguous needle is reported,
2031
+ // never resolved to whichever group happens to sort first
2032
+ async _resolveGroup(needle) {
2033
+ const { groups } = await this.engine.listChatGroups({})
2034
+ const live = groups ?? []
2035
+ const lower = needle.toLowerCase()
2036
+ const exact =
2037
+ live.find((g) => g.id === needle) ?? live.find((g) => g.name.toLowerCase() === lower)
2038
+ if (exact) return { group: exact }
2039
+ const matches = live.filter(
2040
+ (g) => g.id.startsWith(needle) || g.name.toLowerCase().startsWith(lower)
2041
+ )
2042
+ if (matches.length > 1) return { ambiguous: matches.length }
2043
+ return { group: matches[0] ?? null }
2044
+ }
2045
+
2046
+ // core hands chats back oldest-activity first (app-runtime reverses to render); this does the
2047
+ // same so the listing reads newest-first, and groups already arrive in presentation order
2048
+ _groupOverview() {
2049
+ const { engine } = this
2050
+ return [
2051
+ this,
2052
+ async () => {
2053
+ try {
2054
+ const { chats, groups } = await engine.listChats({})
2055
+ const live = [...(chats ?? [])].reverse()
2056
+ const ago = (chat) =>
2057
+ `${Math.round((Date.now() - (chat.lastActivityAt || chat.createdAt)) / 1000)}s ago`
2058
+ const render = (chat) =>
2059
+ ` ${chat.id === this.chatId ? '●' : ' '} ${chat.title} · ${chat.id.slice(0, 8)} · ${ago(chat)}`
2060
+ const lines = []
2061
+ for (const group of groups ?? []) {
2062
+ const held = live.filter((chat) => chat.groupId === group.id)
2063
+ lines.push(` ▸ ${group.name} · ${group.id.slice(0, 8)}`)
2064
+ lines.push(...(held.length ? held.map(render) : [' (empty)']))
2065
+ }
2066
+ const loose = live.filter((chat) => !chat.groupId)
2067
+ if (loose.length) {
2068
+ lines.push(' ▸ (ungrouped)')
2069
+ lines.push(...loose.map(render))
2070
+ }
2071
+ return {
2072
+ type: 'notice',
2073
+ text: lines.length
2074
+ ? `chats by group — most recently active first\n${lines.join('\n')}`
2075
+ : 'no chats yet'
2076
+ }
2077
+ } catch (error) {
2078
+ return { type: 'run.error', error }
2079
+ }
2080
+ }
2081
+ ]
2082
+ }
2083
+
2084
+ // Any surviving chat other than `excludeId` — the delete-active fallback. Deliberately not prefix
2085
+ // resolution: an empty needle must not read as an ambiguous match.
2086
+ async _anyLiveChat(excludeId) {
2087
+ const { chats } = await this.engine.listChats({})
2088
+ return (chats ?? []).find((c) => !c.deletedAt && c.id !== excludeId) ?? null
2089
+ }
2090
+
2091
+ // The chat's agent is authoritative — a chat owned by a different agent must run under that agent,
2092
+ // not whichever one happens to be active (else _submit() runs the wrong provider).
2093
+ async _agentFor(chat) {
2094
+ if (chat.agentId === this.agent.id) return this.agent
2095
+ return (await this.engine.getAgent({ id: chat.agentId })) ?? this.agent
2096
+ }
2097
+
2098
+ _newChat(title) {
2099
+ return [
2100
+ this,
2101
+ async () => {
2102
+ try {
2103
+ const chat = await this.engine.createChat({ agentId: this.agent.id, title })
2104
+ return { type: 'chat.switch', chat, agent: this.agent }
2105
+ } catch (error) {
2106
+ return { type: 'run.error', error }
2107
+ }
2108
+ }
2109
+ ]
2110
+ }
2111
+
2112
+ _useChat(needle) {
2113
+ return [
2114
+ this,
2115
+ async () => {
2116
+ try {
2117
+ const { chat, ambiguous } = await this._resolveChat(needle)
2118
+ if (ambiguous) {
2119
+ return {
2120
+ type: 'notice',
2121
+ text: `"${needle}" matches ${ambiguous} chats — be more specific`
2122
+ }
2123
+ }
2124
+ if (!chat) return { type: 'notice', text: `no chat matching "${needle}"` }
2125
+ return { type: 'chat.switch', chat, agent: await this._agentFor(chat) }
2126
+ } catch (error) {
2127
+ return { type: 'run.error', error }
2128
+ }
2129
+ }
2130
+ ]
2131
+ }
2132
+
2133
+ _deleteChat(needle) {
2134
+ return [
2135
+ this,
2136
+ async () => {
2137
+ try {
2138
+ const { chat, ambiguous } = await this._resolveChat(needle)
2139
+ if (ambiguous) {
2140
+ return {
2141
+ type: 'notice',
2142
+ text: `"${needle}" matches ${ambiguous} chats — be more specific`
2143
+ }
2144
+ }
2145
+ if (!chat) return { type: 'notice', text: `no chat matching "${needle}"` }
2146
+ await this.engine.deleteChat({ id: chat.id })
2147
+ if (chat.id !== this.chatId) {
2148
+ return { type: 'notice', text: `deleted "${chat.title}" (${chat.id.slice(0, 8)})` }
2149
+ }
2150
+ // deleted the active chat — switch to any survivor, else a fresh one
2151
+ const next =
2152
+ (await this._anyLiveChat(chat.id)) ??
2153
+ (await this.engine.createChat({ agentId: this.agent.id, title: 'New Chat' }))
2154
+ return { type: 'chat.switch', chat: next, agent: await this._agentFor(next) }
2155
+ } catch (error) {
2156
+ return { type: 'run.error', error }
2157
+ }
2158
+ }
2159
+ ]
2160
+ }
2161
+
2162
+ // Tear down the chat-scoped watches so the next arm rebuilds them, bumping the
2163
+ // chunk-only gen so a stale frame from the old chat is dropped. The cached
2164
+ // iterators are bound to a chat that is no longer the one on screen — after a
2165
+ // switch, or after a join replaced every chat in the mesh.
2166
+ _resetChatWatches() {
2167
+ this._chunkIterator?.return?.().catch(() => {})
2168
+ this._chunkIterator = null
2169
+ this._filesIterator?.return?.().catch(() => {})
2170
+ this._filesIterator = null
2171
+ this._files = []
2172
+ this._chunkGen += 1
2173
+ }
2174
+
2175
+ // Swap the active chat: drop the old chat's watches, adopt the chat's owning
2176
+ // agent, clear the transcript + run state, and re-arm on the new chatId.
2177
+ _switchTo(chat, agent) {
2178
+ this._resetChatWatches()
2179
+ this.chatId = chat.id
2180
+ if (agent) this.agent = agent
2181
+ this._turns = []
2182
+ this.busy = false
2183
+ this._awaitingRun = false
2184
+ this._runningRunId = null
2185
+ this.pendingApproval = null
2186
+ this.notice = `switched to "${chat.title}" (${chat.id.slice(0, 8)})`
2187
+ this._render()
2188
+ }
2189
+
2190
+ _accumulatePaste(msg) {
2191
+ if (msg.name === 'return' || msg.name === 'linefeed') {
2192
+ this._paste += '\n'
2193
+ return
2194
+ }
2195
+ if (msg.name === 'tab') {
2196
+ this._paste += '\t'
2197
+ return
2198
+ }
2199
+ const ch = msg.sequence
2200
+ if (typeof ch === 'string' && !msg.ctrl && !msg.meta && ch >= ' ') this._paste += ch
2201
+ }
2202
+
2203
+ // single-line pastes type straight in; multi-line becomes a one-line
2204
+ // placeholder ("[pasted #1: 12 lines]") expanded back at submit
2205
+ _insertPaste() {
2206
+ const text = this._paste ?? ''
2207
+ this._paste = null
2208
+ if (!text) return
2209
+ const lines = text.split('\n').length
2210
+ if (lines === 1) return this._insertText(text)
2211
+ const placeholder = `[pasted #${this._pastes.size + 1}: ${lines} lines]`
2212
+ this._pastes.set(placeholder, text)
2213
+ this._insertText(placeholder)
2214
+ }
2215
+
2216
+ _insertText(text) {
2217
+ const input = this.input
2218
+ input.value = input.value.slice(0, input.cursor) + text + input.value.slice(input.cursor)
2219
+ input.cursor += text.length
2220
+ }
2221
+
2222
+ _expandPastes(text) {
2223
+ for (const [placeholder, full] of this._pastes) text = text.split(placeholder).join(full)
2224
+ this._pastes.clear()
2225
+ return text
2226
+ }
2227
+
2228
+ _setInput(text) {
2229
+ this.input.value = text
2230
+ this.input.cursor = text.length
2231
+ }
2232
+
2233
+ // Claimed before textinput, whose is('left'/'backspace') matches the bare name
2234
+ // and would swallow the chord as a single-character move.
2235
+ _editKey(msg) {
2236
+ const { value, cursor } = this.input
2237
+ if (key.matches(msg, 'alt+enter', 'linefeed') || msg.sequence === SHIFT_ENTER) {
2238
+ this._insertText('\n')
2239
+ return true
2240
+ }
2241
+ if (key.matches(msg, 'alt+left', 'alt+b', 'ctrl+left')) {
2242
+ this.input.cursor = wordStart(value, cursor)
2243
+ return true
2244
+ }
2245
+ if (key.matches(msg, 'alt+right', 'alt+f', 'ctrl+right')) {
2246
+ this.input.cursor = wordEnd(value, cursor)
2247
+ return true
2248
+ }
2249
+ if (key.matches(msg, 'alt+backspace', 'ctrl+w')) {
2250
+ const at = wordStart(value, cursor)
2251
+ this.input.value = value.slice(0, at) + value.slice(cursor)
2252
+ this.input.cursor = at
2253
+ return true
2254
+ }
2255
+ return false
2256
+ }
2257
+
2258
+ // Records a submitted line for ↑/↓ recall (dropping blanks and consecutive
2259
+ // duplicates) and leaves recall mode.
2260
+ _pushHistory(line) {
2261
+ this._historyAt = null
2262
+ this._historyDraft = ''
2263
+ const value = line.trim()
2264
+ if (!value || this._history[this._history.length - 1] === value) return
2265
+ this._history.push(value)
2266
+ }
2267
+
2268
+ // ↑ (delta -1) walks toward older entries, ↓ (delta +1) toward newer; the
2269
+ // first ↑ stashes the in-progress line so ↓ past the newest restores it.
2270
+ _recall(delta) {
2271
+ if (!this._history.length) return
2272
+ if (this._historyAt === null) {
2273
+ if (delta > 0) return
2274
+ this._historyDraft = this.input.value
2275
+ this._historyAt = this._history.length
2276
+ }
2277
+ const next = this._historyAt + delta
2278
+ if (next >= this._history.length) {
2279
+ this._historyAt = null
2280
+ this._setInput(this._historyDraft)
2281
+ return
2282
+ }
2283
+ this._historyAt = Math.max(0, next)
2284
+ this._setInput(this._history[this._historyAt])
2285
+ }
2286
+
2287
+ _showDiagnostics() {
2288
+ const { engine } = this
2289
+ return [
2290
+ this,
2291
+ async () => {
2292
+ try {
2293
+ const { stats } = await engine.diagnostics({})
2294
+ return { type: 'diagnostics.result', stats }
2295
+ } catch (error) {
2296
+ return { type: 'diagnostics.result', error }
2297
+ }
2298
+ }
2299
+ ]
2300
+ }
2301
+
2302
+ // /models [type]: fetch the full SDK registry via the Engine RPC (both
2303
+ // topologies wire harness.list into Core.listModels) and overlay it. Always
2304
+ // fullRegistry — the curated subset was a second command (/all-sdk-models);
2305
+ // one browsable list plus the type filter replaces both.
2306
+ _showModels(type) {
2307
+ const { engine } = this
2308
+ return [
2309
+ this,
2310
+ async () => {
2311
+ try {
2312
+ const { models } = await engine.listModels({
2313
+ fullRegistry: true,
2314
+ ...(type ? { type } : {})
2315
+ })
2316
+ return { type: 'models.result', filter: type, rows: models ?? [] }
2317
+ } catch (error) {
2318
+ return { type: 'models.result', filter: type, rows: null, error }
2319
+ }
2320
+ }
2321
+ ]
2322
+ }
2323
+
2324
+ // The provider device's advertised inventory — the installed skills/tools
2325
+ // the active agent may enable (empty when the provider is offline).
2326
+ async _providerCaps() {
2327
+ const { devices } = await this.engine.listDevices({})
2328
+ const hex = this.agent.providerDeviceId?.toString('hex')
2329
+ const dev = devices.find((d) => d.id.toString('hex') === hex)
2330
+ return { skills: dev?.installedSkillNames ?? [], tools: dev?.installedToolNames ?? [] }
2331
+ }
2332
+
2333
+ // /skills + /tools: overlay the provider's installed list against the agent's
2334
+ // enabled set. scope=all opens with everything on (see selector.result); a
2335
+ // toggle then flips the agent to an explicit selected set.
2336
+ _showSelector(kind) {
2337
+ const { engine, agent } = this
2338
+ return [
2339
+ this,
2340
+ async () => {
2341
+ try {
2342
+ const fresh = await engine.getAgent({ id: agent.id })
2343
+ const caps = await this._providerCaps()
2344
+ const scope = (kind === 'skills' ? fresh.skillsScope : fresh.toolsScope) ?? 'selected'
2345
+ const enabled = (kind === 'skills' ? fresh.enabledSkills : fresh.enabledTools) ?? []
2346
+ const detail = kind === 'skills' ? await this._skillDetail() : null
2347
+ return { type: 'selector.result', kind, installed: caps[kind], scope, enabled, detail }
2348
+ } catch (error) {
2349
+ return { type: 'selector.result', kind, error }
2350
+ }
2351
+ }
2352
+ ]
2353
+ }
2354
+
2355
+ // Per-skill setup state, keyed by name. Null when the engine cannot report it
2356
+ // (pre-upgrade provider) — the selector then renders as it always did.
2357
+ async _skillDetail() {
2358
+ if (!this._skills) return null
2359
+ const skills = await this._skills(this.agent?.id, this.agent?.modelName).catch(() => null)
2360
+ return skills ? new Map(skills.map((skill) => [skill.name, skill])) : null
2361
+ }
2362
+
2363
+ // The PRD's Tools screen: the approval mode on top, then the toolset switches. Rows come
2364
+ // from TOOLSETS, not the provider catalog — a toolset with nothing installed here still shows,
2365
+ // so it can be switched off before the agent ever runs somewhere that has it.
2366
+ _showPermissions() {
2367
+ const { engine, agent } = this
2368
+ return [
2369
+ this,
2370
+ async () => {
2371
+ try {
2372
+ const fresh = await engine.getAgent({ id: agent.id })
2373
+ return {
2374
+ type: 'permissions.result',
2375
+ mode: fresh.approvalMode === 'skip-all' ? 'skip-all' : 'ask',
2376
+ disabled: fresh.disabledToolsets ?? []
2377
+ }
2378
+ } catch (error) {
2379
+ return { type: 'permissions.result', error }
2380
+ }
2381
+ }
2382
+ ]
2383
+ }
2384
+
2385
+ // ctrl+p / /menu: open the menu at its root, clearing any other overlay first.
2386
+ _openMenu() {
2387
+ this._models = null
2388
+ this._selector = null
2389
+ this.overlay = null
2390
+ this._menu = { pages: [this._rootMenu()] }
2391
+ this._render()
2392
+ return [this, null]
2393
+ }
2394
+
2395
+ // The command groups as one browsable tree. An entry carries exactly one of:
2396
+ // submenu (push a page), overlay (open an interactive list, menu stays under
2397
+ // it), command (dispatch a /command, close), prefill (stub the input, close).
2398
+ _rootMenu() {
2399
+ return {
2400
+ title: 'Menu',
2401
+ cursor: 0,
2402
+ entries: [
2403
+ { label: 'Agent', hint: 'skills, tools, permissions, model', submenu: true },
2404
+ { label: 'Chat', hint: 'this chat, knowledge, attachments', submenu: true },
2405
+ { label: 'Models', hint: "browse and pick the agent's model", overlay: 'models' },
2406
+ { label: 'Voice', hint: 'speak, transcribe, converse', submenu: true },
2407
+ { label: 'My device', hint: 'CPU / GPU inventory', overlay: 'diagnostics' },
2408
+ { label: 'Mesh', hint: 'join another device’s mesh', submenu: true },
2409
+ { label: 'Advanced', hint: 'credentials, scope, skill setup status', submenu: true },
2410
+ { label: 'Help', hint: 'list every command', command: '/help' }
2411
+ ]
2412
+ }
2413
+ }
2414
+
2415
+ _agentMenu() {
2416
+ return {
2417
+ title: `Agent · ${this.agent?.name ?? '?'}`,
2418
+ cursor: 0,
2419
+ entries: [
2420
+ { label: 'Skills', hint: 'toggle skills on/off', overlay: 'skills' },
2421
+ { label: 'Tools', hint: 'toggle tools on/off', overlay: 'tools' },
2422
+ { label: 'Permissions', hint: 'approval mode & toolsets', overlay: 'permissions' },
2423
+ { label: 'Model', hint: "pick the agent's model", overlay: 'models' },
2424
+ { label: 'Image defaults', hint: 'model, steps, size', command: '/image' },
2425
+ { label: 'Activity', hint: 'recent tool calls', command: '/activity' },
2426
+ {
2427
+ label: 'Connections',
2428
+ hint: 'set up skills that need an account',
2429
+ submenu: true,
2430
+ connections: true
2431
+ },
2432
+ { label: 'Switch agent', hint: 'pick from your agents', submenu: true, agents: true },
2433
+ { label: 'New agent…', hint: '/agent new <name>', prefill: '/agent new ' },
2434
+ { label: 'Rename agent…', hint: '/agent rename <name>', prefill: '/agent rename ' }
2435
+ ]
2436
+ }
2437
+ }
2438
+
2439
+ _connectMenu() {
2440
+ return {
2441
+ title: 'Connections',
2442
+ cursor: 0,
2443
+ entries: [
2444
+ { label: 'Connect Gmail', hint: 'OAuth Gmail', command: '/connect gmail' },
2445
+ {
2446
+ label: 'Connect Google Calendar',
2447
+ hint: 'OAuth Calendar',
2448
+ command: '/connect google-calendar'
2449
+ },
2450
+ { label: 'Connect Google Docs', hint: 'OAuth Docs', command: '/connect google-docs' },
2451
+ { label: 'Connect Google Drive', hint: 'OAuth Drive', command: '/connect google-drive' },
2452
+ { label: 'Connect Google Sheets', hint: 'OAuth Sheets', command: '/connect google-sheets' },
2453
+ { label: 'Connect Spotify', hint: 'OAuth Spotify (PKCE)', command: '/connect spotify' },
2454
+ { label: 'Connect Notion', hint: 'OAuth Notion MCP', command: '/connect notion' },
2455
+ { label: 'Connect Asana', hint: 'OAuth Asana MCP', command: '/connect asana' }
2456
+ ]
2457
+ }
2458
+ }
2459
+
2460
+ // The live Connections page: every skill that needs an account or a token,
2461
+ // with its current state and the one command that sets it up — so a pasted-token
2462
+ // skill (github) sits beside the OAuth ones instead of being invisible here.
2463
+ // Falls back to the static OAuth list when the engine cannot report skills.
2464
+ _connectPage() {
2465
+ return [
2466
+ this,
2467
+ async () => {
2468
+ const detail = await this._skillDetail()
2469
+ const rows = [...(detail?.values() ?? [])].flatMap((skill) =>
2470
+ setupActions(skill)
2471
+ .filter((action) => action.command || action.prefill)
2472
+ .map((action) => ({ skill, action }))
2473
+ )
2474
+ if (!rows.length) return { type: 'menu.page', page: this._connectMenu() }
2475
+ const entries = rows.map(({ skill, action }) => ({
2476
+ label: `${isReady(skill) ? '●' : '○'} ${action.label}`,
2477
+ hint: `${skill.name} · ${statusLabel(skill.status)}`,
2478
+ command: action.command,
2479
+ prefill: action.prefill
2480
+ }))
2481
+ return { type: 'menu.page', page: { title: 'Connections', cursor: 0, entries } }
2482
+ }
2483
+ ]
2484
+ }
2485
+
2486
+ // The rarely-needed switches, kept off the main paths: a raw credential write,
2487
+ // the scope flip, and the full setup dump for every skill.
2488
+ _advancedMenu() {
2489
+ return {
2490
+ title: 'Advanced',
2491
+ cursor: 0,
2492
+ entries: [
2493
+ { label: 'Set credential…', hint: '/cred <key> <value>', prefill: '/cred ' },
2494
+ { label: 'Skills scope…', hint: '/scope skills all|selected', prefill: '/scope skills ' },
2495
+ { label: 'Tools scope…', hint: '/scope tools all|selected', prefill: '/scope tools ' }
2496
+ ]
2497
+ }
2498
+ }
2499
+
2500
+ _chatMenu() {
2501
+ return {
2502
+ title: 'Chat',
2503
+ cursor: 0,
2504
+ entries: [
2505
+ { label: 'Chats sidebar', hint: 'ctrl+b — browse chats, m moves one', sidebar: true },
2506
+ { label: 'Switch chat', hint: 'pick from your chats', submenu: true, chats: true },
2507
+ { label: 'New chat', hint: 'create & switch to a new chat', command: '/chat new' },
2508
+ { label: 'Rename chat…', hint: '/chat rename <title>', prefill: '/chat rename ' },
2509
+ { label: 'Web search…', hint: '/chat websearch on|off', prefill: '/chat websearch ' },
2510
+ { label: 'RAG…', hint: '/chat rag on|off', prefill: '/chat rag ' },
2511
+ { label: 'Groups', hint: 'chats by group, in sidebar order', command: '/group' },
2512
+ { label: 'Move to group…', hint: '/group move <group>|-', prefill: '/group move ' },
2513
+ { label: 'New group…', hint: '/group new <name>', prefill: '/group new ' },
2514
+ { label: 'Knowledge', hint: 'list chat knowledge', command: '/knowledge' },
2515
+ { label: 'Files', hint: 'readiness, progress, and failures', command: '/files' },
2516
+ { label: 'Attach file…', hint: '/attach <path>', prefill: '/attach ' },
2517
+ { label: 'Save attachment', hint: 'latest → save dir', command: '/save' },
2518
+ { label: 'Export last run', hint: 'markdown → save dir', command: '/export' }
2519
+ ]
2520
+ }
2521
+ }
2522
+
2523
+ _meshMenu() {
2524
+ return {
2525
+ title: 'Mesh',
2526
+ cursor: 0,
2527
+ entries: [
2528
+ { label: 'Join mesh…', hint: '/join <qvac://mesh/…>', prefill: '/join ' },
2529
+ { label: 'Cancel join', hint: 'abort a join in progress', command: '/join cancel' }
2530
+ ]
2531
+ }
2532
+ }
2533
+
2534
+ _voiceMenu() {
2535
+ return {
2536
+ title: 'Voice',
2537
+ cursor: 0,
2538
+ entries: [
2539
+ { label: 'Speak…', hint: '/speak <text>', prefill: '/speak ' },
2540
+ { label: 'Transcribe…', hint: '/transcribe <path>', prefill: '/transcribe ' },
2541
+ { label: 'Record & transcribe', hint: '/record [seconds]', command: '/record' },
2542
+ { label: 'Converse (hands-free)', hint: 'mic conversation', command: '/converse' }
2543
+ ]
2544
+ }
2545
+ }
2546
+
2547
+ // The submenu a `submenu` row opens, keyed by label — kept next to the page
2548
+ // builders so adding a branch is a one-line entry plus its page.
2549
+ _submenuFor(label) {
2550
+ if (label === 'Agent') return this._agentMenu()
2551
+ if (label === 'Chat') return this._chatMenu()
2552
+ if (label === 'Advanced') return this._advancedMenu()
2553
+ if (label === 'Mesh') return this._meshMenu()
2554
+ if (label === 'Voice') return this._voiceMenu()
2555
+ return null
2556
+ }
2557
+
2558
+ // The agents list as a menu page, fetched fresh — a `useAgent` row per agent,
2559
+ // the active one marked and pre-cursored. Pushed via a menu.page message once
2560
+ // the fetch resolves, so it opens like any other submenu.
2561
+ _agentListPage() {
2562
+ const { engine } = this
2563
+ return [
2564
+ this,
2565
+ async () => {
2566
+ try {
2567
+ const { agents } = await engine.listAgents({})
2568
+ const list = agents ?? []
2569
+ const active = this.agent?.id
2570
+ const entries = list.length
2571
+ ? list.map((a) => ({
2572
+ label: `${a.id === active ? '●' : ' '} ${a.name}`,
2573
+ hint: a.id.slice(0, 8),
2574
+ useAgent: a.id
2575
+ }))
2576
+ : [{ label: '(no agents)', hint: '' }]
2577
+ const cursor = Math.max(
2578
+ 0,
2579
+ list.findIndex((a) => a.id === active)
2580
+ )
2581
+ return { type: 'menu.page', page: { title: 'Switch agent', cursor, entries } }
2582
+ } catch (error) {
2583
+ return {
2584
+ type: 'menu.page',
2585
+ page: {
2586
+ title: 'Switch agent',
2587
+ cursor: 0,
2588
+ entries: [{ label: "(couldn't load agents)", hint: error.message }]
2589
+ }
2590
+ }
2591
+ }
2592
+ }
2593
+ ]
2594
+ }
2595
+
2596
+ // The live chats as a menu page — a `useChat` row per chat (title + short id,
2597
+ // with web/rag badges), the active one marked and pre-cursored. Same async
2598
+ // push as _agentListPage.
2599
+ _chatListPage() {
2600
+ const { engine } = this
2601
+ return [
2602
+ this,
2603
+ async () => {
2604
+ try {
2605
+ const { chats, groups } = await engine.listChats({})
2606
+ const live = [...(chats ?? [])].reverse().filter((c) => !c.deletedAt)
2607
+ const active = this.chatId
2608
+ // reversed for the same reason as /group: core's list is oldest-activity first
2609
+ const groupName = new Map((groups ?? []).map((g) => [g.id, g.name]))
2610
+ const entries = live.length
2611
+ ? live.map((c) => ({
2612
+ label: `${c.id === active ? '●' : ' '} ${c.title}`,
2613
+ hint:
2614
+ `${c.id.slice(0, 8)}` +
2615
+ `${c.groupId ? ` [${groupName.get(c.groupId)}]` : ''}` +
2616
+ `${c.webSearchEnabled ? ' [web]' : ''}${c.ragEnabled ? ' [rag]' : ''}`,
2617
+ useChat: c.id
2618
+ }))
2619
+ : [{ label: '(no chats)', hint: '' }]
2620
+ const cursor = Math.max(
2621
+ 0,
2622
+ live.findIndex((c) => c.id === active)
2623
+ )
2624
+ return { type: 'menu.page', page: { title: 'Switch chat', cursor, entries } }
2625
+ } catch (error) {
2626
+ return {
2627
+ type: 'menu.page',
2628
+ page: {
2629
+ title: 'Switch chat',
2630
+ cursor: 0,
2631
+ entries: [{ label: "(couldn't load chats)", hint: error.message }]
2632
+ }
2633
+ }
2634
+ }
2635
+ }
2636
+ ]
2637
+ }
2638
+
2639
+ // Enter/→ on the highlighted row. A submenu pushes a page (the agents/chats
2640
+ // lists are fetched first); an overlay opens the matching interactive list
2641
+ // (menu stays set, so esc falls back to it); useAgent/useChat switches the
2642
+ // active agent/chat; a command dispatches and closes; a prefill hands the
2643
+ // input a ready stub.
2644
+ _menuActivate() {
2645
+ const page = this._menu.pages[this._menu.pages.length - 1]
2646
+ const entry = page.entries[page.cursor]
2647
+ if (!entry) return [this, null]
2648
+ if (entry.agents) return this._agentListPage()
2649
+ if (entry.chats) return this._chatListPage()
2650
+ if (entry.connections) return this._connectPage()
2651
+ if (entry.sidebar) {
2652
+ this._menu = null
2653
+ return this._toggleSidebar()
2654
+ }
2655
+ if (entry.useAgent) {
2656
+ this._menu = null
2657
+ return this._command(`/agent use ${entry.useAgent}`)
2658
+ }
2659
+ if (entry.useChat) {
2660
+ this._menu = null
2661
+ return this._useChat(entry.useChat)
2662
+ }
2663
+ if (entry.submenu) {
2664
+ const next = this._submenuFor(entry.label)
2665
+ if (next) this._menu.pages.push(next)
2666
+ this._render()
2667
+ return [this, null]
2668
+ }
2669
+ if (entry.overlay) {
2670
+ if (entry.overlay === 'models') return this._showModels(null)
2671
+ if (entry.overlay === 'diagnostics') return this._showDiagnostics()
2672
+ if (entry.overlay === 'permissions') return this._showPermissions()
2673
+ return this._showSelector(entry.overlay)
2674
+ }
2675
+ if (entry.prefill) {
2676
+ this._menu = null
2677
+ this._setInput(entry.prefill)
2678
+ this._render()
2679
+ return [this, null]
2680
+ }
2681
+ if (entry.command) {
2682
+ this._menu = null
2683
+ return this._command(entry.command)
2684
+ }
2685
+ return [this, null]
2686
+ }
2687
+
2688
+ // The menu's own keys: ↑/↓ move, enter/→ activate, esc/←/backspace step back a
2689
+ // page (or close at the root). Returns null for anything else so update() can
2690
+ // dismiss the menu into the input, like the other overlays.
2691
+ _menuKey(msg) {
2692
+ const page = this._menu.pages[this._menu.pages.length - 1]
2693
+ if (key.matches(msg, 'escape', 'left', 'backspace')) {
2694
+ if (this._menu.pages.length > 1) this._menu.pages.pop()
2695
+ else this._menu = null
2696
+ this._render()
2697
+ return [this, null]
2698
+ }
2699
+ if (key.matches(msg, 'up')) {
2700
+ page.cursor = Math.max(0, page.cursor - 1)
2701
+ this._render()
2702
+ return [this, null]
2703
+ }
2704
+ if (key.matches(msg, 'down')) {
2705
+ page.cursor = Math.min(page.entries.length - 1, page.cursor + 1)
2706
+ this._render()
2707
+ return [this, null]
2708
+ }
2709
+ if (key.matches(msg, 'enter', 'right')) return this._menuActivate()
2710
+ return null
2711
+ }
2712
+
2713
+ // Keep the given row inside the visible window as the cursor walks past an edge.
2714
+ _scrollToLine(line) {
2715
+ const h = this.vp.height
2716
+ if (line < this.vp.yOffset) this.vp.setYOffset(line)
2717
+ else if (line >= this.vp.yOffset + h) this.vp.setYOffset(line - h + 1)
2718
+ }
2719
+
2720
+ _moveCursor(delta) {
2721
+ const m = this._models ?? this._selector
2722
+ m.cursor = Math.max(0, Math.min(m.rows.length - 1, m.cursor + delta))
2723
+ this._render()
2724
+ return [this, null]
2725
+ }
2726
+
2727
+ // An overlay's own controls (arrows move/scroll, esc closes, enter adopts a
2728
+ // model). Returns null for any other key, which update() reads as "not mine".
2729
+ _overlayKey(msg) {
2730
+ if (this._models) {
2731
+ if (key.matches(msg, 'escape')) {
2732
+ this._models = null
2733
+ this._render()
2734
+ return [this, null]
2735
+ }
2736
+ if (key.matches(msg, 'up')) return this._moveCursor(-1)
2737
+ if (key.matches(msg, 'down')) return this._moveCursor(1)
2738
+ if (key.matches(msg, 'pageup')) return this._moveCursor(-this.vp.height)
2739
+ if (key.matches(msg, 'pagedown')) return this._moveCursor(this.vp.height)
2740
+ if (key.matches(msg, 'enter')) return this._useModel()
2741
+ return null
2742
+ }
2743
+ if (this._selector) {
2744
+ if (key.matches(msg, 'escape')) {
2745
+ this._selector = null
2746
+ this._render()
2747
+ return [this, null]
2748
+ }
2749
+ if (key.matches(msg, 'up')) return this._moveCursor(-1)
2750
+ if (key.matches(msg, 'down')) return this._moveCursor(1)
2751
+ if (key.matches(msg, 'pageup')) return this._moveCursor(-this.vp.height)
2752
+ if (key.matches(msg, 'pagedown')) return this._moveCursor(this.vp.height)
2753
+ if (key.matches(msg, 'space')) return this._toggleSelector()
2754
+ if (key.matches(msg, 'enter')) return this._setupSelected()
2755
+ return null
2756
+ }
2757
+ if (this.overlay) {
2758
+ if (key.matches(msg, 'escape')) {
2759
+ this.overlay = null
2760
+ this._render()
2761
+ return [this, null]
2762
+ }
2763
+ if (key.matches(msg, 'up')) this.vp.scrollUp(1)
2764
+ else if (key.matches(msg, 'down')) this.vp.scrollDown(1)
2765
+ else if (key.matches(msg, 'pageup')) this.vp.scrollUp(this.vp.height)
2766
+ else if (key.matches(msg, 'pagedown')) this.vp.scrollDown(this.vp.height)
2767
+ else return null
2768
+ return [this, null]
2769
+ }
2770
+ // Lowest precedence: a selector/models/static overlay opened from a menu row
2771
+ // keeps _menu set underneath, and its own branch above wins until it closes.
2772
+ if (this._menu) return this._menuKey(msg)
2773
+ return null
2774
+ }
2775
+
2776
+ // Matches bare-tui textinput's own printable test — the keys that would type
2777
+ // into the field. Typing one is how you "click back into the text box".
2778
+ _isTypingKey(msg) {
2779
+ const ch = msg.sequence
2780
+ return (
2781
+ !msg.ctrl &&
2782
+ !msg.meta &&
2783
+ typeof ch === 'string' &&
2784
+ ch.length === 1 &&
2785
+ ch >= ' ' &&
2786
+ ch !== '\x7f'
2787
+ )
2788
+ }
2789
+
2790
+ // Enter on a row makes it the agent's model. Nothing downloads here — the
2791
+ // provider pulls the model when the agent next runs (the run path is the
2792
+ // download trigger), so the notice says exactly that.
2793
+ _useModel() {
2794
+ const row = this._models.rows[this._models.cursor]
2795
+ this._models = null
2796
+ if (!row) {
2797
+ this._render()
2798
+ return [this, null]
2799
+ }
2800
+ const { engine, agent } = this
2801
+ return [
2802
+ this,
2803
+ async () => {
2804
+ try {
2805
+ this.agent = await engine.updateAgent({ id: agent.id, modelName: row.name })
2806
+ return {
2807
+ type: 'notice',
2808
+ text: `${agent.name} now uses ${row.name} — downloads on next run`
2809
+ }
2810
+ } catch (error) {
2811
+ return { type: 'run.error', error }
2812
+ }
2813
+ }
2814
+ ]
2815
+ }
2816
+
2817
+ // Space on a row flips its enabled membership and persists it — the same
2818
+ // write as /skill|/tool on|off, so any toggle moves the agent to scope=selected.
2819
+ // Optimistic: the row updates now, the engine call syncs this.agent after.
2820
+ _toggleSelector() {
2821
+ const s = this._selector
2822
+ const name = s.rows[s.cursor]
2823
+ if (!name) return [this, null]
2824
+ if (s.kind === 'permissions') return this._togglePermission(s, name)
2825
+ if (s.enabled.has(name)) s.enabled.delete(name)
2826
+ else s.enabled.add(name)
2827
+ s.scope = 'selected'
2828
+ this._render()
2829
+ const field = s.kind === 'skills' ? 'enabledSkills' : 'enabledTools'
2830
+ const scopeField = s.kind === 'skills' ? 'skillsScope' : 'toolsScope'
2831
+ const enabled = [...s.enabled]
2832
+ const { engine, agent } = this
2833
+ return [
2834
+ this,
2835
+ async () => {
2836
+ try {
2837
+ this.agent = await engine.updateAgent({
2838
+ id: agent.id,
2839
+ [field]: enabled,
2840
+ [scopeField]: 'selected'
2841
+ })
2842
+ return { type: 'selector.saved' }
2843
+ } catch (error) {
2844
+ return { type: 'run.error', error }
2845
+ }
2846
+ }
2847
+ ]
2848
+ }
2849
+
2850
+ // Enter on a skill row runs its setup: an OAuth skill connects there and then,
2851
+ // a token skill hands the input a ready `/cred <key> ` stub, and anything else
2852
+ // prints its steps. The overlay closes either way — the setup is the next step.
2853
+ _setupSelected() {
2854
+ const s = this._selector
2855
+ if (s.kind !== 'skills' || !s.detail) return [this, null]
2856
+ const skill = s.detail.get(s.rows[s.cursor])
2857
+ if (!skill) return [this, null]
2858
+ const action = configureActions(skill)[0]
2859
+ this._selector = null
2860
+ if (action?.command) return this._command(action.command)
2861
+ if (action?.prefill) this._setInput(action.prefill)
2862
+ this._render()
2863
+ return [this, async () => ({ type: 'notice', text: renderSkill(skill) })]
2864
+ }
2865
+
2866
+ // ctrl+b: open the chat sidebar focused, focus it again when typing has handed
2867
+ // focus back, and close it from there. It draws from the chats watch, so it
2868
+ // opens on what has already replicated — no fetch, no flicker.
2869
+ _toggleSidebar() {
2870
+ const s = this._sidebar
2871
+ if (!s) this._sidebar = { selected: this.chatId, focus: true, moving: null }
2872
+ else if (s.focus) this._sidebar = null
2873
+ else s.focus = true
2874
+ this._render()
2875
+ return [this, null]
2876
+ }
2877
+
2878
+ _toggleToolOutput() {
2879
+ this._expandTools = !this._expandTools
2880
+ this._render()
2881
+ return [this, null]
2882
+ }
2883
+
2884
+ // The pane's rows: the chat tree, or the group picker while a chat is moving.
2885
+ _sidebarRows() {
2886
+ const moving = this._sidebar?.moving
2887
+ if (moving) return moveRows(this._groups, moving.groupId)
2888
+ return sidebarRows(this._chats, this._groups, this.chatId)
2889
+ }
2890
+
2891
+ // The move picker tracks its own index; the chat tree tracks the selected
2892
+ // chat by id, so rows arriving from another device can't move the cursor.
2893
+ _sidebarCursor(rows) {
2894
+ const s = this._sidebar
2895
+ if (s.moving) return Math.min(Math.max(s.moving.cursor, 0), rows.length - 1)
2896
+ const at = rows.findIndex((row) => row.chatId === s.selected)
2897
+ return at === -1 ? firstSelectable(rows) : at
2898
+ }
2899
+
2900
+ _moveSidebar(delta) {
2901
+ const s = this._sidebar
2902
+ const rows = this._sidebarRows()
2903
+ const at = nextSelectable(rows, this._sidebarCursor(rows), delta)
2904
+ if (s.moving) s.moving.cursor = at
2905
+ else s.selected = rows[at]?.chatId ?? s.selected
2906
+ this._render()
2907
+ return [this, null]
2908
+ }
2909
+
2910
+ // m on a chat row: the pane becomes the group picker for that chat, enter
2911
+ // lands it. The chat's own group is left out — moving there is a no-op.
2912
+ _startMove() {
2913
+ const rows = this._sidebarRows()
2914
+ const row = rows[this._sidebarCursor(rows)]
2915
+ if (row?.kind !== 'chat') return [this, null]
2916
+ const groupId = this._chats.find((chat) => chat.id === row.chatId)?.groupId ?? null
2917
+ this._sidebar.moving = {
2918
+ chatId: row.chatId,
2919
+ groupId,
2920
+ cursor: firstSelectable(moveRows(this._groups, groupId))
2921
+ }
2922
+ this._render()
2923
+ return [this, null]
2924
+ }
2925
+
2926
+ _sidebarActivate() {
2927
+ const s = this._sidebar
2928
+ const rows = this._sidebarRows()
2929
+ const row = rows[this._sidebarCursor(rows)]
2930
+ if (!row) return [this, null]
2931
+ if (row.kind === 'chat') return this._useChat(row.chatId)
2932
+ const { chatId } = s.moving
2933
+ s.moving = null
2934
+ if (row.kind === 'new-group') {
2935
+ this._setInput('/group new ')
2936
+ s.focus = false
2937
+ this._render()
2938
+ return [this, null]
2939
+ }
2940
+ this._render()
2941
+ const { engine } = this
2942
+ const token = ++this._statusToken
2943
+ return [
2944
+ this,
2945
+ async () => {
2946
+ try {
2947
+ await engine.setChatGroup({ id: chatId, groupId: row.groupId ?? undefined })
2948
+ return { type: 'transient-status', token, text: `moved to ${row.label}` }
2949
+ } catch (error) {
2950
+ return { type: 'run.error', error }
2951
+ }
2952
+ }
2953
+ ]
2954
+ }
2955
+
2956
+ // The sidebar's own keys while it has focus. Returns null for anything else,
2957
+ // which update() reads as "type into the input" — the pane stays open, it
2958
+ // just hands focus back.
2959
+ _sidebarKey(msg) {
2960
+ const s = this._sidebar
2961
+ if (key.matches(msg, 'up')) return this._moveSidebar(-1)
2962
+ if (key.matches(msg, 'down')) return this._moveSidebar(1)
2963
+ if (key.matches(msg, 'enter')) return this._sidebarActivate()
2964
+ if (!s.moving && key.matches(msg, 'm')) return this._startMove()
2965
+ if (key.matches(msg, 'escape')) {
2966
+ if (s.moving) s.moving = null
2967
+ else this._sidebar = null
2968
+ this._render()
2969
+ return [this, null]
2970
+ }
2971
+ return null
2972
+ }
2973
+
2974
+ // Space on the mode row flips ask <-> skip-all; on any other row it flips that toolset. Same
2975
+ // optimistic write as the skills/tools selector: the row moves now, the engine call follows.
2976
+ _togglePermission(s, name) {
2977
+ if (name === MODE_ROW) s.mode = s.mode === 'skip-all' ? 'ask' : 'skip-all'
2978
+ else if (s.enabled.has(name)) s.enabled.delete(name)
2979
+ else s.enabled.add(name)
2980
+ this._render()
2981
+ const patch =
2982
+ name === MODE_ROW
2983
+ ? { approvalMode: s.mode }
2984
+ : {
2985
+ disabledToolsets: [
2986
+ ...TOOLSETS.filter((toolset) => !s.enabled.has(toolset)),
2987
+ ...s.unknown
2988
+ ]
2989
+ }
2990
+ const { engine, agent } = this
2991
+ return [
2992
+ this,
2993
+ async () => {
2994
+ try {
2995
+ this.agent = await engine.updateAgent({ id: agent.id, ...patch })
2996
+ return { type: 'selector.saved' }
2997
+ } catch (error) {
2998
+ return { type: 'run.error', error }
2999
+ }
3000
+ }
3001
+ ]
3002
+ }
3003
+
3004
+ // Voice conversation loop, mirroring v1's design: an energy-VAD silence recorder
3005
+ // captures ONE utterance from the mic, then unary transcribe (reliable, unlike
3006
+ // live duplex STT) -> sendMessage -> speakStream -> ffplay, looping. During playback
3007
+ // the VAD threshold rises to reject the assistant's own audio (echo); real speech
3008
+ // over it is a barge-in that cuts playback. Runs in the background, painting
3009
+ // status via _requestRepaint; Esc or a second /converse stops it. Needs
3010
+ // headphones for clean barge-in; the RMS thresholds below are tunable.
3011
+ _startConverse(device) {
3012
+ const state = {
3013
+ device,
3014
+ mic: null,
3015
+ player: null,
3016
+ ttsDuplex: null,
3017
+ chunkWatch: null,
3018
+ runId: null,
3019
+ speaking: false,
3020
+ playing: false,
3021
+ turnBusy: false,
3022
+ stopped: false
3023
+ }
3024
+ this._converse = state
3025
+ this._converseListen(state)
3026
+ }
3027
+
3028
+ _stopConverse(reason) {
3029
+ const state = this._converse
3030
+ if (!state) return
3031
+ state.stopped = true
3032
+ try {
3033
+ state.mic?.kill('SIGKILL')
3034
+ } catch {}
3035
+ try {
3036
+ state.player?.kill('SIGKILL')
3037
+ } catch {}
3038
+ try {
3039
+ state.ttsDuplex?.destroy?.()
3040
+ } catch {}
3041
+ this._converse = null
3042
+ this.status = reason ? `conversation ${reason}` : ''
3043
+ this._requestRepaint()
3044
+ }
3045
+
3046
+ _converseListen(state) {
3047
+ const LISTEN_RMS = 350 // speech vs a quiet room
3048
+ const BARGE_RMS = 1600 // higher during playback to reject the TTS echo
3049
+ const SILENCE_MS = 700 // trailing quiet that ends an utterance
3050
+ const MIN_SPEECH_MS = 300 // ignore short noise blips
3051
+ const mic = micProcess(state.device)
3052
+ state.mic = mic
3053
+ mic.on('error', () => this._stopConverse('mic error'))
3054
+ mic.stdout.on('error', () => {}) // killed mic leaves a dead pipe; never let it abort
3055
+ mic.on('close', () => {
3056
+ if (!state.stopped) this._stopConverse('mic ended')
3057
+ })
3058
+ let recording = false
3059
+ let parts = []
3060
+ let speechMs = 0
3061
+ let silenceMs = 0
3062
+ this.status = '🎤 listening…'
3063
+ this._requestRepaint()
3064
+ mic.stdout.on('data', (chunk) => {
3065
+ if (state.stopped) return
3066
+ const chunkMs = (chunk.length / 2 / 16000) * 1000
3067
+ // low bar while the assistant is silent (generating), high bar once audio
3068
+ // is playing so the TTS echo doesn't self-interrupt.
3069
+ const loud = rmsS16(chunk) > (state.playing ? BARGE_RMS : LISTEN_RMS)
3070
+ // barge-in spans the whole assistant turn (generation AND playback): cut
3071
+ // the run, the TTS, and the audio, and clear turnBusy so this same chunk
3072
+ // falls through and starts capturing the interrupting utterance below.
3073
+ if (state.speaking) {
3074
+ if (!loud) return
3075
+ state.speaking = false
3076
+ state.turnBusy = false
3077
+ if (state.runId) {
3078
+ void this.engine.stopRun({ chatId: this.chatId, runId: state.runId }).catch(() => {})
3079
+ }
3080
+ try {
3081
+ state.player?.kill('SIGKILL')
3082
+ } catch {}
3083
+ try {
3084
+ state.ttsDuplex?.destroy?.()
3085
+ } catch {}
3086
+ this.status = '🎤 listening…'
3087
+ this._requestRepaint()
3088
+ }
3089
+ if (state.turnBusy) return // transcribing — don't capture (unary, not interruptible)
3090
+ if (loud) {
3091
+ if (!recording) {
3092
+ recording = true
3093
+ parts = []
3094
+ speechMs = 0
3095
+ }
3096
+ parts.push(chunk)
3097
+ speechMs += chunkMs
3098
+ silenceMs = 0
3099
+ } else if (recording) {
3100
+ parts.push(chunk)
3101
+ silenceMs += chunkMs
3102
+ if (silenceMs >= SILENCE_MS) {
3103
+ recording = false
3104
+ const pcm = Buffer.concat(parts)
3105
+ parts = []
3106
+ if (speechMs >= MIN_SPEECH_MS) void this._converseTurn(state, pcm)
3107
+ }
3108
+ }
3109
+ })
3110
+ }
3111
+
3112
+ async _converseTurn(state, pcm) {
3113
+ state.turnBusy = true
3114
+ try {
3115
+ this.status = '📝 transcribing…'
3116
+ this._requestRepaint()
3117
+ const { text } = await this.engine.transcribe({ audio: pcm16ToWav(pcm, 16000) })
3118
+ const clean = (text || '').trim()
3119
+ if (state.stopped) return
3120
+ if (!clean) return
3121
+ this.status = `💬 ${clean}`
3122
+ this._requestRepaint()
3123
+ // sendMessage names the run it just enqueued, so the producer below can
3124
+ // follow that exact turn instead of diffing against a pre-turn snapshot
3125
+ const { runId } = await this.engine.sendMessage({
3126
+ chatId: this.chatId,
3127
+ agentId: this.agent.id,
3128
+ text: clean
3129
+ })
3130
+ await this._converseRespond(state, runId)
3131
+ } catch (err) {
3132
+ if (!state.stopped) {
3133
+ this.status = `turn error: ${err?.message ?? err}`
3134
+ this._requestRepaint()
3135
+ }
3136
+ } finally {
3137
+ state.turnBusy = false
3138
+ if (!state.stopped) {
3139
+ this.status = '🎤 listening…'
3140
+ this._requestRepaint()
3141
+ }
3142
+ }
3143
+ }
3144
+
3145
+ // Interleaves reply generation with TTS: a producer follows the new run's turn
3146
+ // over chunksWatch and writes each newly-complete sentence into speakStream as
3147
+ // the model emits it; the consumer plays the audio frames as they arrive, so the
3148
+ // assistant starts talking at the first sentence instead of after the whole reply.
3149
+ // Event-driven off the watch (no polling); `runId` is the run sendMessage just
3150
+ // enqueued, so a prior reply — or a concurrent run from another device — is
3151
+ // never mistaken for this turn.
3152
+ async _converseRespond(state, runId) {
3153
+ const duplex = this.engine.speakStream()
3154
+ state.ttsDuplex = duplex
3155
+ state.speaking = true
3156
+ let player = null
3157
+ const writePcm = (bytes) => {
3158
+ // a killed ffplay leaves a dead pipe; an unhandled EPIPE here aborts the
3159
+ // whole process (SIGABRT), so swallow every write/close error.
3160
+ if (!player || !player.stdin.writable) return
3161
+ try {
3162
+ player.stdin.write(bytes)
3163
+ } catch {}
3164
+ }
3165
+
3166
+ const watch = this.engine.chunksWatch({ chatId: this.chatId })[Symbol.asyncIterator]()
3167
+ state.chunkWatch = watch
3168
+ state.runId = runId // for barge-in stopRun, armed before the turn materializes
3169
+ const producer = (async () => {
3170
+ let spoken = 0
3171
+ let content = ''
3172
+ while (!state.stopped && state.speaking) {
3173
+ const { value, done } = await watch.next()
3174
+ if (done) break
3175
+ const turns = turnsFromChunks(value.chunks)
3176
+ // this turn is the agent turn carrying the run sendMessage named — until
3177
+ // it materializes, keep waiting rather than speaking a prior reply
3178
+ const target = turns.find((turn) => turn.from === 'agent' && turn.runId === runId)
3179
+ if (!target) continue
3180
+ content = target.segments
3181
+ .filter((segment) => segment.type === 'content')
3182
+ .map((segment) => segment.text)
3183
+ .join('')
3184
+ this.status = `🔊 ${content}`
3185
+ this._requestRepaint()
3186
+ const ready = spoken + completePrefixLen(content.slice(spoken))
3187
+ if (ready > spoken) {
3188
+ try {
3189
+ duplex.write({ text: content.slice(spoken, ready) })
3190
+ } catch {}
3191
+ spoken = ready
3192
+ }
3193
+ // break as soon as the new run is terminal — one that fails right after
3194
+ // enqueue still ends the loop instead of waiting on a run that never streams.
3195
+ if (!runInFlight(turns)) break
3196
+ }
3197
+ const tail = content.slice(spoken).trim()
3198
+ if (tail && !state.stopped) {
3199
+ try {
3200
+ duplex.write({ text: tail })
3201
+ } catch {}
3202
+ }
3203
+ try {
3204
+ duplex.end()
3205
+ } catch {}
3206
+ })()
3207
+
3208
+ try {
3209
+ for await (const frame of duplex) {
3210
+ if (state.stopped || !state.speaking) break
3211
+ if (!player) {
3212
+ player = playerProcess(frame.sampleRate)
3213
+ state.player = player
3214
+ state.playing = true // audio is now audible → raise the barge-in bar
3215
+ player.on('error', () => {})
3216
+ player.stdin.on('error', () => {})
3217
+ }
3218
+ writePcm(frame.audio)
3219
+ }
3220
+ if (player && player.stdin.writable) {
3221
+ try {
3222
+ player.stdin.end()
3223
+ } catch {}
3224
+ await new Promise((resolve) => player.once('close', resolve))
3225
+ }
3226
+ } catch {
3227
+ // duplex destroyed / errored (barge-in or stop)
3228
+ } finally {
3229
+ state.speaking = false
3230
+ state.playing = false
3231
+ // close the watch so a producer parked on watch.next() unblocks and exits
3232
+ try {
3233
+ await watch.return?.()
3234
+ } catch {}
3235
+ await producer.catch(() => {})
3236
+ try {
3237
+ duplex.destroy?.()
3238
+ } catch {}
3239
+ try {
3240
+ player?.kill('SIGKILL')
3241
+ } catch {}
3242
+ state.ttsDuplex = null
3243
+ state.chunkWatch = null
3244
+ state.player = null
3245
+ state.runId = null
3246
+ }
3247
+ }
3248
+
3249
+ _submit() {
3250
+ // Still resolving the agent/chat — the input takes text but can't send yet.
3251
+ if (this._booting) return [this, null]
3252
+ const raw = this.input.value
3253
+ const text = this._expandPastes(this.input.value).trim()
3254
+ if (!text) return [this, null]
3255
+ this._pushHistory(raw)
3256
+ if (text.startsWith('/')) {
3257
+ this.input.reset()
3258
+ return this._command(text)
3259
+ }
3260
+ this.input.reset()
3261
+ this.busy = true
3262
+ this._awaitingRun = true
3263
+ this.status = ''
3264
+ this.notice = null
3265
+ const { chatId, agent, engine } = this
3266
+ return [
3267
+ this,
3268
+ async () => {
3269
+ try {
3270
+ // One batched append: the user message and its run-request land in a
3271
+ // single drain, so no peer can see the request before the message it
3272
+ // answers. No device id either — the executing Core stamps every
3273
+ // chunk with its own key; the host identity is never caller-supplied.
3274
+ await engine.sendMessage({ chatId, agentId: agent.id, text })
3275
+ return { type: 'run.done' }
3276
+ } catch (error) {
3277
+ return { type: 'run.error', error }
3278
+ }
3279
+ }
3280
+ ]
3281
+ }
3282
+
3283
+ // Resolving is the dedicated resolve-approval op on the approval-request
3284
+ // chunk — a decision, not a chunk edit, so ANY admitted device may answer
3285
+ // (first decision wins). The provider's assistant is the one actually
3286
+ // waiting (its chunk watch); there is no callback or resolver here at all.
3287
+ // scope 'always' also mints a standing approval-rule from the request's
3288
+ // tool + resource, so matching future calls never prompt again — on any
3289
+ // device (the rule replicates like everything else).
3290
+ _resolveApproval(status, scope = 'once') {
3291
+ const { id } = this.pendingApproval
3292
+ const { engine } = this
3293
+ return [
3294
+ this,
3295
+ async () => {
3296
+ try {
3297
+ await engine.resolveApproval({ id, approved: status === 'approved', scope })
3298
+ return { type: 'approval.done' }
3299
+ } catch (error) {
3300
+ return { type: 'run.error', error }
3301
+ }
3302
+ }
3303
+ ]
3304
+ }
3305
+
3306
+ // Asks the engine to stop the currently running turn — the stop-native
3307
+ // counterpart to _resolveApproval above; the executing assistant's own
3308
+ // stop watcher is what actually aborts the harness.
3309
+ _requestStop() {
3310
+ if (!this.busy || !this._runningRunId) return [this, null]
3311
+ const { chatId, engine } = this
3312
+ const runId = this._runningRunId
3313
+ const token = ++this._statusToken
3314
+ this.status = fg.dim.render('stopping…')
3315
+ this._render()
3316
+ return [
3317
+ this,
3318
+ async () => {
3319
+ try {
3320
+ await engine.stopRun({ chatId, runId })
3321
+ return { type: 'transient-status', token, text: 'run stopped' }
3322
+ } catch (error) {
3323
+ return { type: 'run.error', error }
3324
+ }
3325
+ }
3326
+ ]
3327
+ }
3328
+
3329
+ update(msg) {
3330
+ if (this._converse && key.matches(msg, 'escape')) {
3331
+ this._stopConverse('off')
3332
+ return [this, null]
3333
+ }
3334
+ if (msg.type === 'resize') {
3335
+ if (!msg.width || !msg.height) return [this, null] // a size-less pty reports 0×0
3336
+ // Clamp geometry to something view() can actually render — below the
3337
+ // minimums the styled blocks go non-positive and blow up padLine.
3338
+ this.width = Math.max(MIN_WIDTH, msg.width)
3339
+ this.height = Math.max(MIN_HEIGHT, msg.height)
3340
+ // CHROME_ROWS = header (1) + transcript border (2) + skills line (1) +
3341
+ // input (3) + footer (1); the frame must match the terminal height exactly
3342
+ // or the alt-screen buffer scrolls and clips the header.
3343
+ // A fresh viewport starts with no content — re-render immediately or
3344
+ // the transcript goes blank until the next chunk/model event.
3345
+ this.vp = viewport.create({ width: 0, height: Math.max(1, this.height - CHROME_ROWS) })
3346
+ this._render()
3347
+ return [this, null]
3348
+ }
3349
+
3350
+ if (msg.type === 'chunks') {
3351
+ if (msg.gen !== this._chunkGen) return [this, null] // stale: chat switched, old stream
3352
+ this._setTranscript(msg.frame.chunks)
3353
+ return [this, this._watchNext()]
3354
+ }
3355
+ if (msg.type === 'chunks.done') {
3356
+ if (msg.gen !== this._chunkGen) return [this, null]
3357
+ this.status = 'chunk stream ended'
3358
+ return [this, null]
3359
+ }
3360
+
3361
+ if (msg.type === 'files') {
3362
+ if (msg.gen !== this._chunkGen) return [this, null] // stale: chat switched
3363
+ const settled = this._settledFiles(msg.files)
3364
+ this._files = msg.files
3365
+ this._render()
3366
+ return [this, batch(this._watchNextFiles(), this._fileStatus(settled))]
3367
+ }
3368
+ if (msg.type === 'files.done') {
3369
+ if (msg.gen !== this._chunkGen) return [this, null]
3370
+ this._files = []
3371
+ this.status = `file status unavailable: ${msg.error?.message ?? 'stream ended'}`
3372
+ this._render()
3373
+ return [this, null]
3374
+ }
3375
+
3376
+ if (msg.type === 'chat.switch') {
3377
+ this._switchTo(msg.chat, msg.agent)
3378
+ return [this, batch(this._watchNext(), this._watchNextFiles())]
3379
+ }
3380
+
3381
+ // /join settled. The joined mesh brings its own agents and chats, so this
3382
+ // drops everything chat-scoped and re-runs the same bootstrap the first
3383
+ // boot uses — which waits for the new mesh to go writable before resolving.
3384
+ if (msg.type === 'mesh.joined') {
3385
+ if (msg.error) {
3386
+ // the old mesh is still the active one — release the send block
3387
+ this._booting = false
3388
+ this.status = fg.err.render(`join failed: ${msg.error.message}`)
3389
+ this._render()
3390
+ return [this, null]
3391
+ }
3392
+ this._resetChatWatches()
3393
+ this.chatId = null
3394
+ this.agent = null
3395
+ this._turns = []
3396
+ this.busy = false
3397
+ this._awaitingRun = false
3398
+ this._runningRunId = null
3399
+ this.pendingApproval = null
3400
+ this.invite = msg.invite ?? this.invite
3401
+ this.status = ''
3402
+ this.notice = 'joined — resolving the mesh’s agent and chat…'
3403
+ this._render()
3404
+ return [this, this._bootstrap()]
3405
+ }
3406
+
3407
+ // _connect resolved: bind the engine + its identity, then arm the watch
3408
+ // loops init() couldn't start while the engine was still coming up. Still
3409
+ // booting — _bootstrap (armed here) resolves the agent + chat next.
3410
+ if (msg.type === 'connected') {
3411
+ this.engine = msg.engine
3412
+ if (msg.skills) this._skills = msg.skills
3413
+ this.originDeviceId = msg.deviceId ?? this.originDeviceId
3414
+ this.invite = msg.meshInvite ?? this.invite
3415
+ this._modelName = msg.modelName ?? this._modelName
3416
+ this._connecting = false
3417
+ this._render()
3418
+ return [this, batch(...this._engineWatches())]
3419
+ }
3420
+
3421
+ // _bootstrap resolved: adopt the seed agent + chat and arm the chunk watch
3422
+ // that init() deferred while there was no chatId.
3423
+ if (msg.type === 'booted') {
3424
+ this.agent = msg.agent
3425
+ this.chatId = msg.chat.id
3426
+ this._booting = false
3427
+ this._render()
3428
+ return [this, batch(this._watchNext(), this._watchNextFiles())]
3429
+ }
3430
+ if (msg.type === 'boot-error') {
3431
+ this.status = fg.err.render(`startup failed: ${msg.error.message}`)
3432
+ this._render()
3433
+ return [this, null]
3434
+ }
3435
+
3436
+ if (msg.type === 'mesh-status') {
3437
+ if (msg.gen !== this.gen) return [this, null]
3438
+ this.meshStatus = msg.frame
3439
+ return [this, this._watchNextStatus()]
3440
+ }
3441
+ if (msg.type === 'mesh-status.done') {
3442
+ if (msg.gen !== this.gen) return [this, null]
3443
+ this.meshStatus = null
3444
+ return [this, null]
3445
+ }
3446
+
3447
+ if (msg.type === 'devices') {
3448
+ if (msg.gen !== this.gen) return [this, null]
3449
+ this.deviceLabels = new Map(
3450
+ (msg.frame.devices ?? []).map((d) => [d.id.toString('hex'), d.name])
3451
+ )
3452
+ this._render()
3453
+ return [this, this._watchNextDevices()]
3454
+ }
3455
+ if (msg.type === 'devices.done') return [this, null]
3456
+
3457
+ if (msg.type === 'diagnostics') {
3458
+ if (msg.gen !== this.gen) return [this, null]
3459
+ this.diagnostics = msg.stats
3460
+ this._render()
3461
+ return [this, this._watchNextDiagnostics()]
3462
+ }
3463
+ if (msg.type === 'diagnostics.done') return [this, null]
3464
+
3465
+ if (msg.type === 'diagnostics.result') {
3466
+ this._models = null
3467
+ this._selector = null
3468
+ this.overlay = msg.error
3469
+ ? [fg.err.render(`/my-device failed: ${msg.error.message}`)]
3470
+ : formatDiagnostics(msg.stats)
3471
+ this._render()
3472
+ return [this, null]
3473
+ }
3474
+
3475
+ // /models resolved: an interactive overlay (↑/↓ + enter to adopt a model)
3476
+ // until esc; a rejection is a static err line instead of a silent dismiss.
3477
+ if (msg.type === 'models.result') {
3478
+ if (msg.error) {
3479
+ this._models = null
3480
+ this.overlay = [fg.err.render(`/models failed: ${msg.error.message}`)]
3481
+ } else {
3482
+ this.overlay = null
3483
+ this._selector = null
3484
+ this._models = { rows: sortModels(msg.rows), filter: msg.filter ?? null, cursor: 0 }
3485
+ }
3486
+ this._render()
3487
+ return [this, null]
3488
+ }
3489
+
3490
+ // /skills + /tools resolved: an interactive on/off overlay (space toggles a
3491
+ // row, persisting immediately) until esc; scope=all opens with all enabled.
3492
+ if (msg.type === 'selector.result') {
3493
+ if (msg.error) {
3494
+ this._selector = null
3495
+ this.overlay = [fg.err.render(`/${msg.kind} failed: ${msg.error.message}`)]
3496
+ } else {
3497
+ this._models = null
3498
+ this.overlay = null
3499
+ const enabled = msg.scope === 'all' ? new Set(msg.installed) : new Set(msg.enabled)
3500
+ this._selector = {
3501
+ kind: msg.kind,
3502
+ rows: msg.installed,
3503
+ enabled,
3504
+ scope: msg.scope,
3505
+ detail: msg.detail ?? null,
3506
+ cursor: 0
3507
+ }
3508
+ }
3509
+ this._render()
3510
+ return [this, null]
3511
+ }
3512
+ if (msg.type === 'permissions.result') {
3513
+ if (msg.error) {
3514
+ this._selector = null
3515
+ this.overlay = [fg.err.render(`/permissions failed: ${msg.error.message}`)]
3516
+ } else {
3517
+ this._models = null
3518
+ this.overlay = null
3519
+ const off = new Set(msg.disabled)
3520
+ this._selector = {
3521
+ kind: 'permissions',
3522
+ rows: [MODE_ROW, ...TOOLSETS],
3523
+ enabled: new Set(TOOLSETS.filter((name) => !off.has(name))),
3524
+ // ids this build cannot render still have to survive the write, or opening this screen
3525
+ // on an older device switches a newer build's toolset back on
3526
+ unknown: msg.disabled.filter((name) => !TOOLSETS.includes(name)),
3527
+ mode: msg.mode,
3528
+ cursor: 0
3529
+ }
3530
+ }
3531
+ this._render()
3532
+ return [this, null]
3533
+ }
3534
+ if (msg.type === 'selector.saved') return [this, null] // the toggle already re-rendered; sync only
3535
+
3536
+ // A fetched menu page resolved. From a menu row (menu open) it drills in as a
3537
+ // pushed page; from a bare /chats or /agents (no menu open) it opens as the
3538
+ // root of a fresh menu, so esc closes straight back to the chat.
3539
+ if (msg.type === 'menu.page') {
3540
+ if (this._menu) this._menu.pages.push(msg.page)
3541
+ else this._menu = { pages: [msg.page] }
3542
+ this._render()
3543
+ return [this, null]
3544
+ }
3545
+
3546
+ if (msg.type === 'run.error') {
3547
+ this.busy = false
3548
+ this.modelState = null
3549
+ this.status = fg.err.render(`error: ${msg.error.message}`)
3550
+ return [this, null]
3551
+ }
3552
+ if (msg.type === 'run.done') return [this, null] // completion arrives via the chunk stream itself
3553
+
3554
+ if (msg.type === 'notice') {
3555
+ // transcript, not the footer — the footer is one truncated line and any
3556
+ // status tick overwrites it, so /skills and /help output vanished there
3557
+ this.notice = msg.text
3558
+ this._render()
3559
+ return [this, null]
3560
+ }
3561
+
3562
+ // Bridged in from the harness's own EventEmitter (see index.mjs) —
3563
+ // loading/downloading/ready/unloading. Ephemeral and local; never part of
3564
+ // the chunk stream (see Harness's doc comment for why).
3565
+ if (msg.type === 'model') {
3566
+ this.modelState = msg.state
3567
+ this._render()
3568
+ return [this, null]
3569
+ }
3570
+
3571
+ if (msg.type === 'spinner.tick') {
3572
+ const [s, cmd] = this.spin.update(msg)
3573
+ this.spin = s
3574
+ if (this.busy || this._booting) this._render()
3575
+ return [this, cmd]
3576
+ }
3577
+
3578
+ if (msg.type === 'chats') {
3579
+ this._chats = msg.frame.chats ?? []
3580
+ this._groups = msg.frame.groups ?? []
3581
+ if (this._sidebar) this._render()
3582
+ return [this, this._watchNextChats()]
3583
+ }
3584
+ if (msg.type === 'chats.done') return [this, null]
3585
+
3586
+ if (msg.type === 'transient-status') {
3587
+ if (msg.token !== this._statusToken) return [this, null] // superseded
3588
+ this.status = msg.text
3589
+ this._render()
3590
+ if (!msg.text) return [this, null]
3591
+ // Schedule the clear; a newer status bumps the token and orphans it.
3592
+ const token = msg.token
3593
+ return [
3594
+ this,
3595
+ async () => {
3596
+ await new Promise((resolve) => setTimeout(resolve, 2000))
3597
+ return { type: 'transient-status', token, text: '' }
3598
+ }
3599
+ ]
3600
+ }
3601
+
3602
+ if (msg.type === 'mouse') {
3603
+ if (msg.action === 'wheel') {
3604
+ this._selection = null
3605
+ if (msg.button === 'wheelup') this.vp.scrollUp(WHEEL_ROWS)
3606
+ else if (msg.button === 'wheeldown') this.vp.scrollDown(WHEEL_ROWS)
3607
+ return [this, null]
3608
+ }
3609
+ if (msg.button === 'left') return this._select(msg)
3610
+ return [this, null]
3611
+ }
3612
+
3613
+ if (msg.type === 'drag-scroll') return this._scrollSelection(msg.token)
3614
+
3615
+ if (msg.type === 'key') {
3616
+ this._selection = null
3617
+ // bracketed paste (index.mjs enables ?2004h): everything between the
3618
+ // markers lands as ONE block — multi-line becomes an input placeholder
3619
+ if (msg.name === 'paste-start') {
3620
+ this._paste = ''
3621
+ return [this, null]
3622
+ }
3623
+ if (msg.name === 'paste-end') {
3624
+ this._insertPaste()
3625
+ this._render()
3626
+ return [this, null]
3627
+ }
3628
+ if (this._paste !== null) {
3629
+ // a hung paste (no end marker) must not eat ctrl+c — drop it and fall through
3630
+ if (msg.ctrl && msg.name === 'c') this._paste = null
3631
+ else {
3632
+ this._accumulatePaste(msg)
3633
+ return [this, null]
3634
+ }
3635
+ }
3636
+ if (key.matches(msg, 'ctrl+c')) return [this, quit]
3637
+ // Work in every mode (modifier keys — never collide with typing or
3638
+ // the y/n approval answers).
3639
+ if (key.matches(msg, 'ctrl+y')) return this._copyInvite()
3640
+ if (key.matches(msg, 'ctrl+v')) return this._pasteAttachment()
3641
+ if (key.matches(msg, 'ctrl+p')) return this._openMenu()
3642
+ if (key.matches(msg, 'ctrl+b')) return this._toggleSidebar()
3643
+ if (key.matches(msg, 'ctrl+o')) return this._toggleToolOutput()
3644
+
3645
+ // An overlay owns its nav keys, but typing dismisses it into the input —
3646
+ // the keyboard analog of clicking back into the text box. Non-typing keys
3647
+ // (function keys, etc.) are swallowed so they can't leak through.
3648
+ if (this._models || this._selector || this.overlay || this._menu) {
3649
+ const handled = this._overlayKey(msg)
3650
+ if (handled) return handled
3651
+ if (!this._isTypingKey(msg)) return [this, null]
3652
+ this._models = null
3653
+ this._selector = null
3654
+ this.overlay = null
3655
+ this._menu = null
3656
+ this._render()
3657
+ }
3658
+
3659
+ // The sidebar is a pane, not a modal: it owns its nav keys while focused,
3660
+ // and typing hands focus back to the input without closing it.
3661
+ if (this._sidebar?.focus) {
3662
+ const handled = this._sidebarKey(msg)
3663
+ if (handled) return handled
3664
+ if (!this._isTypingKey(msg)) return [this, null]
3665
+ this._sidebar.focus = false
3666
+ this._render()
3667
+ }
3668
+
3669
+ // esc with nothing open stops the run in flight — the same stopRun the
3670
+ // executing device's watcher acts on — and otherwise clears the last
3671
+ // command's output, which is chrome the transcript would else keep.
3672
+ if (key.matches(msg, 'escape')) {
3673
+ if (this.busy) return this._requestStop()
3674
+ if (this.notice) {
3675
+ this.notice = null
3676
+ this._render()
3677
+ return [this, null]
3678
+ }
3679
+ }
3680
+
3681
+ if (this.pendingApproval) {
3682
+ if (key.matches(msg, 'y')) return this._resolveApproval('approved')
3683
+ if (key.matches(msg, 'a')) return this._resolveApproval('approved', 'always')
3684
+ if (key.matches(msg, 'n')) return this._resolveApproval('denied')
3685
+ return [this, null] // no other key does anything while a call awaits an answer
3686
+ }
3687
+
3688
+ // The palette drives the arrows only when it's open AND we're not mid-recall
3689
+ // — a recalled command line starts with '/' too, but browsing history must
3690
+ // keep browsing history until the user edits or submits (shell behaviour).
3691
+ const matches = this.input.value.startsWith('/') ? matchCommands(this.input.value) : []
3692
+ const inPalette = matches.length > 0 && this._historyAt === null
3693
+
3694
+ if (inPalette && key.matches(msg, 'tab')) {
3695
+ const pick = matches[Math.min(this._paletteAt, matches.length - 1)]
3696
+ this._setInput(`/${pick.name} `)
3697
+ return [this, null]
3698
+ }
3699
+
3700
+ // Enter runs the highlighted palette row when the user is still choosing a
3701
+ // command (no argument typed yet); once an argument follows — or the line
3702
+ // came from history — the typed line is authoritative and submits verbatim.
3703
+ if (key.matches(msg, 'enter')) {
3704
+ if (inPalette && !/\s/.test(this.input.value.trim())) {
3705
+ this._setInput(`/${matches[Math.min(this._paletteAt, matches.length - 1)].name}`)
3706
+ }
3707
+ return this._submit()
3708
+ }
3709
+
3710
+ // ↑/↓ move the palette selection when it's open, otherwise walk input
3711
+ // history. Transcript scrolling lives on PgUp/PgDn so the arrows are free
3712
+ // for both. Typing stays live during a run — a message sent while busy
3713
+ // queues as chunks and runs right after the current turn.
3714
+ if (key.matches(msg, 'up') || key.matches(msg, 'down')) {
3715
+ const delta = key.matches(msg, 'up') ? -1 : 1
3716
+ if (inPalette) {
3717
+ const max = matches.length - 1
3718
+ this._paletteAt = Math.max(0, Math.min(max, Math.min(this._paletteAt, max) + delta))
3719
+ } else {
3720
+ this._recall(delta)
3721
+ }
3722
+ return [this, null]
3723
+ }
3724
+ if (key.matches(msg, 'pageup')) {
3725
+ this.vp.scrollUp(this.vp.height)
3726
+ return [this, null]
3727
+ }
3728
+ if (key.matches(msg, 'pagedown')) {
3729
+ this.vp.scrollDown(this.vp.height)
3730
+ return [this, null]
3731
+ }
3732
+ const before = this.input.value
3733
+ let cmd = null
3734
+ if (!this._editKey(msg)) {
3735
+ const [f, c] = this.input.update(msg)
3736
+ this.input = f
3737
+ cmd = c
3738
+ }
3739
+ // A real edit (not a bare cursor move) re-opens the choice: reset the
3740
+ // palette highlight and drop out of history recall.
3741
+ if (this.input.value !== before) {
3742
+ this._paletteAt = 0
3743
+ this._historyAt = null
3744
+ }
3745
+ return [this, cmd]
3746
+ }
3747
+
3748
+ return [this, null]
3749
+ }
3750
+
3751
+ // Mouse reporting takes click+drag away from the terminal, so the transcript
3752
+ // does its own selection: drag highlights cells, release copies them. Cells
3753
+ // are content lines, not screen rows, so a scroll carries the selection.
3754
+ _select(msg) {
3755
+ if (msg.action === 'press') {
3756
+ const region = this._regionAt(msg)
3757
+ // a click on a preview's marker is the expand button, not a selection
3758
+ if (region === 'transcript' && this._markerAt(msg.y)) return this._toggleToolOutput()
3759
+ const at = this._cellAt(msg, region)
3760
+ this._selection = region ? { region, anchor: at, focus: at, edge: 0 } : null
3761
+ return [this, null]
3762
+ }
3763
+ if (!this._selection) return [this, null]
3764
+ const { region } = this._selection
3765
+ this._selection.focus = this._cellAt(msg, region)
3766
+ if (msg.action !== 'motion') {
3767
+ this._selection.edge = 0 // release: whatever beat is in flight stops here
3768
+ return this._copySelection()
3769
+ }
3770
+ const armed = this._selection.edge
3771
+ this._selection.edge = region === 'transcript' ? this._edgeAt(msg) : 0
3772
+ return [this, this._selection.edge && !armed ? this._dragScroll() : null]
3773
+ }
3774
+
3775
+ // Held past the top or bottom edge, the transcript keeps scrolling under the
3776
+ // pointer — motion reports stop once the pointer does, so this beats on.
3777
+ _dragScroll() {
3778
+ const token = ++this._dragToken
3779
+ return tick(DRAG_SCROLL_MS, () => ({ type: 'drag-scroll', token }))
3780
+ }
3781
+
3782
+ _scrollSelection(token) {
3783
+ const sel = this._selection
3784
+ if (!sel?.edge || token !== this._dragToken) return [this, null]
3785
+ if (sel.edge < 0) this.vp.scrollUp(1)
3786
+ else this.vp.scrollDown(1)
3787
+ const row = sel.edge < 0 ? 0 : this.vp.height - 1
3788
+ sel.focus = { line: this.vp.yOffset + row, col: sel.focus.col }
3789
+ return [this, tick(DRAG_SCROLL_MS, () => ({ type: 'drag-scroll', token }))]
3790
+ }
3791
+
3792
+ // true when the clicked transcript row is a collapsed preview's marker
3793
+ _markerAt(y) {
3794
+ if (this._expandTools) return false
3795
+ const row = y - TRANSCRIPT_TOP
3796
+ return (this._viewLines()[row] ?? '').includes(EXPAND_MARK)
3797
+ }
3798
+
3799
+ _edgeAt(msg) {
3800
+ if (msg.y < TRANSCRIPT_TOP) return -1
3801
+ return msg.y >= TRANSCRIPT_TOP + this.vp.height ? 1 : 0
3802
+ }
3803
+
3804
+ // The transcript and the input box are both selectable; a press outside both
3805
+ // selects nothing.
3806
+ _regionAt(msg) {
3807
+ if (msg.y >= TRANSCRIPT_TOP && msg.y < TRANSCRIPT_TOP + this.vp.height) return 'transcript'
3808
+ const top = this._inputTop()
3809
+ return msg.y >= top && msg.y < top + this._inputRows().length ? 'input' : null
3810
+ }
3811
+
3812
+ // the input box's first content row: the transcript, its bottom border, the
3813
+ // skills strip and the box's own top border sit above it
3814
+ _inputTop() {
3815
+ return TRANSCRIPT_TOP + this.vp.height + 3
3816
+ }
3817
+
3818
+ _cellAt(msg, region) {
3819
+ const col = Math.max(
3820
+ 0,
3821
+ msg.x - TRANSCRIPT_LEFT - (region === 'input' ? 0 : this._sidebarCols())
3822
+ )
3823
+ if (region === 'input') {
3824
+ const rows = this._inputRows().length
3825
+ return { line: Math.min(Math.max(0, msg.y - this._inputTop()), rows - 1), col }
3826
+ }
3827
+ const row = Math.min(Math.max(0, msg.y - TRANSCRIPT_TOP), this.vp.height - 1)
3828
+ return { line: this.vp.yOffset + row, col }
3829
+ }
3830
+
3831
+ // The selection as screen cells, `offset` rows above the first content line.
3832
+ _selectionCells(offset) {
3833
+ const { anchor, focus } = this._selection
3834
+ return [
3835
+ { row: anchor.line - offset, col: anchor.col },
3836
+ { row: focus.line - offset, col: focus.col }
3837
+ ]
3838
+ }
3839
+
3840
+ // Every content line, with the visible window replaced by what is actually
3841
+ // drawn there — a selection copies exactly the rows the user dragged over.
3842
+ _contentLines() {
3843
+ const lines = this.vp.lines.slice()
3844
+ const view = this._viewLines()
3845
+ for (let i = 0; i < view.length; i++) lines[this.vp.yOffset + i] = view[i]
3846
+ return lines
3847
+ }
3848
+
3849
+ _copySelection() {
3850
+ const { anchor, focus } = this._selection
3851
+ if (anchor.line === focus.line && anchor.col === focus.col) {
3852
+ this._selection = null // a click, not a drag
3853
+ return [this, null]
3854
+ }
3855
+ const lines = this._selection.region === 'input' ? this._inputRows() : this._contentLines()
3856
+ const text = selectionText(lines, ...this._selectionCells(0))
3857
+ if (!text) this._selection = null
3858
+ if (!text || !this._copyToClipboard) return [this, null]
3859
+ const copy = this._copyToClipboard
3860
+ const token = ++this._statusToken
3861
+ return [
3862
+ this,
3863
+ async () => {
3864
+ try {
3865
+ await copy(text)
3866
+ return {
3867
+ type: 'transient-status',
3868
+ token,
3869
+ text: `copied ${text.length} chars to clipboard`
3870
+ }
3871
+ } catch (err) {
3872
+ return { type: 'transient-status', token, text: `copy failed: ${err.message}` }
3873
+ }
3874
+ }
3875
+ ]
3876
+ }
3877
+
3878
+ // A clipboard that isn't there (or fails) must not fail the thing that wanted it.
3879
+ async _copyQuietly(text) {
3880
+ if (!this._copyToClipboard) return false
3881
+ try {
3882
+ await this._copyToClipboard(text)
3883
+ return true
3884
+ } catch {
3885
+ return false
3886
+ }
3887
+ }
3888
+
3889
+ // ctrl+y: the invite string goes straight to the host clipboard — the only
3890
+ // way to copy it now that the transcript no longer renders it.
3891
+ _copyInvite() {
3892
+ if (!this.invite || !this._copyToClipboard) return [this, null]
3893
+ const invite = this.invite
3894
+ const copy = this._copyToClipboard
3895
+ const token = ++this._statusToken
3896
+ return [
3897
+ this,
3898
+ async () => {
3899
+ try {
3900
+ await copy(invite)
3901
+ return { type: 'transient-status', token, text: 'invite copied ✓' }
3902
+ } catch (err) {
3903
+ return { type: 'transient-status', token, text: `copy failed: ${err.message}` }
3904
+ }
3905
+ }
3906
+ ]
3907
+ }
3908
+
3909
+ // ctrl+v: whatever file the clipboard references becomes an attachment
3910
+ // chunk — uploaded over the hrpc duplex, so the record (fileName, mime,
3911
+ // size) replicates and every peer's transcript renders it.
3912
+ _pasteAttachment() {
3913
+ if (!this._resolvePastedFile) return [this, null]
3914
+ const app = this
3915
+ const token = ++this._statusToken
3916
+ return [
3917
+ this,
3918
+ async () => {
3919
+ try {
3920
+ const file = await app._resolvePastedFile()
3921
+ if (!file) return { type: 'transient-status', token, text: 'clipboard has no file' }
3922
+ await app._uploadAttachment(file)
3923
+ return { type: 'transient-status', token, text: `attached ${file.fileName} ✓` }
3924
+ } catch (err) {
3925
+ return { type: 'transient-status', token, text: `attach failed: ${err.message}` }
3926
+ }
3927
+ }
3928
+ ]
3929
+ }
3930
+
3931
+ async _uploadAttachment({ fileName, mimeType, data }) {
3932
+ const duplex = this.engine.uploadAttachment()
3933
+ await writeFrame(duplex, {
3934
+ chatId: this.chatId,
3935
+ originDeviceId: this.originDeviceId,
3936
+ fileName,
3937
+ mimeType,
3938
+ totalBytes: data.length
3939
+ })
3940
+ for (let at = 0; at < data.length; at += UPLOAD_FRAME_BYTES) {
3941
+ await writeFrame(duplex, { data: data.subarray(at, at + UPLOAD_FRAME_BYTES) })
3942
+ }
3943
+ duplex.end()
3944
+ // Drain to completion — the final frame carries the attachment record,
3945
+ // but the transcript picks it up from the chunk watch like everything else.
3946
+ let final = null
3947
+ for await (const frame of duplex) final = frame
3948
+ return final
3949
+ }
3950
+
3951
+ // Live network/membership state from meshStatusWatch, appended to the
3952
+ // footer — joining/joined/leaving plus how many distinct peer devices are
3953
+ // connected right now. Empty until the first frame arrives.
3954
+ _meshLine() {
3955
+ const parts = []
3956
+ const s = this.meshStatus
3957
+ if (s) {
3958
+ const peers = s.peerCount === 1 ? '1 peer' : `${s.peerCount} peers`
3959
+ parts.push(`mesh: ${s.state}`, peers)
3960
+ }
3961
+ const proc = this.diagnostics?.process
3962
+ if (proc) {
3963
+ const cpu = Math.round((proc.cpu ?? 0) * 100)
3964
+ const mem = Math.round((proc.memory?.rss ?? 0) / (1024 * 1024))
3965
+ parts.push(`proc ${cpu}%/${mem}MB`)
3966
+ }
3967
+ const cpuCompute = this.diagnostics?.cpu?.usage?.compute
3968
+ if (typeof cpuCompute === 'number') parts.push(`cpu ${Math.round(cpuCompute * 100)}%`)
3969
+ const gpuCompute = this.diagnostics?.gpu?.devices?.[0]?.usage?.compute
3970
+ if (typeof gpuCompute === 'number') parts.push(`gpu ${Math.round(gpuCompute * 100)}%`)
3971
+ return parts.length ? ` · ${parts.join(' · ')}` : ''
3972
+ }
3973
+
3974
+ // The footer key legend, one line per active mode — an overlay owns its own
3975
+ // keys, a pending approval its y/a/n, the palette its picker, else the default.
3976
+ _hints(always, paletteOpen, copyHint, pasteHint) {
3977
+ if (this._sidebar?.focus) {
3978
+ return this._sidebar.moving
3979
+ ? '↑/↓: group · enter: move here · esc: cancel · ctrl+c: quit'
3980
+ : '↑/↓: chat · enter: open · m: move to group · esc: close · ctrl+c: quit'
3981
+ }
3982
+ if (this._models) return '↑/↓: move · enter: use · esc: back · ctrl+c: quit'
3983
+ if (this._selector) {
3984
+ const setup = this._selector.detail ? ' · enter: configure' : ''
3985
+ return `↑/↓: move · space: toggle${setup} · esc: back · ctrl+c: quit`
3986
+ }
3987
+ if (this.overlay) return 'esc: back · ↑/↓: scroll · ctrl+c: quit'
3988
+ if (this._menu) return '↑/↓: move · enter: select · esc: back · ctrl+c: quit'
3989
+ if (this.pendingApproval) return `y: allow · a: ${always} · n: deny · ctrl+c: quit`
3990
+ if (paletteOpen) return '↑/↓: pick · tab: complete · enter: run · ctrl+c: quit'
3991
+ return (
3992
+ this.status ||
3993
+ `/: commands · ctrl+p: menu · ctrl+b: chats${copyHint}${pasteHint} · ctrl+c: quit`
3994
+ )
3995
+ }
3996
+
3997
+ // textinput draws one line: a cursor sitting on a newline would render as a
3998
+ // reverse line break and vanish, so multi-line values are drawn here instead
3999
+ _inputView() {
4000
+ const { value, cursor, prompt } = this.input
4001
+ if (!value.includes('\n')) return this.input.view()
4002
+ const at = value[cursor]
4003
+ const cell = at === undefined || at === '\n' ? ' ' : at
4004
+ const tail = at === '\n' ? value.slice(cursor) : value.slice(cursor + 1)
4005
+ return prompt + value.slice(0, cursor) + CURSOR_CELL + cell + CURSOR_OFF + tail
4006
+ }
4007
+
4008
+ _inputRows() {
4009
+ const width = this._bodyWidth()
4010
+ const rows = this._inputView()
4011
+ .split('\n')
4012
+ .flatMap((line) => wrapLine(line, width))
4013
+ const max = Math.max(1, Math.min(MAX_INPUT_ROWS, this.height - CHROME_ROWS))
4014
+ if (rows.length <= max) return rows
4015
+ const at = rows.findIndex((row) => row.includes(CURSOR_CELL))
4016
+ const start = Math.min(Math.max(0, (at < 0 ? rows.length : at + 1) - max), rows.length - max)
4017
+ return rows.slice(start, start + max)
4018
+ }
4019
+
4020
+ // every extra input row costs the transcript one — the frame must stay exactly as tall as the terminal
4021
+ _fitViewport(inputRows) {
4022
+ const height = Math.max(1, this.height - CHROME_ROWS - (inputRows - 1))
4023
+ if (this.vp.height === height) return
4024
+ const wasAtBottom = this.vp.atBottom
4025
+ this.vp.height = height
4026
+ if (wasAtBottom) this.vp.gotoBottom()
4027
+ else this.vp.setYOffset(this.vp.yOffset)
4028
+ }
4029
+
4030
+ // The strip above the input: the active agent's enabled skills as pills. In
4031
+ // scope=all every installed skill is on (a single "all skills" pill, since the
4032
+ // names aren't held locally); scope=selected shows one pill per enabled skill,
4033
+ // trimmed to the width with a dim "+N" when they don't all fit. Pills are
4034
+ // measured by their PLAIN width and appended whole, so an escape is never cut.
4035
+ _skillsLine() {
4036
+ const active = this._files.filter(filePending)
4037
+ if (active.length) return this._filesLine(active)
4038
+ // Indent to the input box's content column (its border + padding), so the
4039
+ // first pill lines up with the prompt instead of the frame's left edge.
4040
+ const indent = ' '
4041
+ const line = (body) => indent + body
4042
+ const room = Math.max(0, this.width - indent.length - 1)
4043
+ const pill = (text) => style().foreground('black').background('brightcyan').render(` ${text} `)
4044
+ // a pill's visible width is the name plus its two padding spaces, plus a gap
4045
+ // once it isn't the first — the frame breaks if the strip ever wraps.
4046
+ const span = (name, first) => name.length + 2 + (first ? 0 : 1)
4047
+ if (!this.agent) return ''
4048
+ if ((this.agent.skillsScope ?? 'all') === 'all') return line(pill('all skills'))
4049
+ const names = this.agent.enabledSkills ?? []
4050
+ if (!names.length) return line(fg.dim.render('no skills enabled'))
4051
+ const shown = []
4052
+ let used = 0
4053
+ for (const name of names) {
4054
+ const width = span(name, !shown.length)
4055
+ if (used + width > room) break
4056
+ used += width
4057
+ shown.push(name)
4058
+ }
4059
+ let hidden = names.length - shown.length
4060
+ // make room for the "+N" itself, dropping trailing pills until it fits
4061
+ while (hidden > 0 && shown.length && used + ` +${hidden}`.length > room) {
4062
+ const wasFirst = shown.length === 1
4063
+ used -= span(shown.pop(), wasFirst)
4064
+ hidden = names.length - shown.length
4065
+ }
4066
+ const more = hidden > 0 ? fg.dim.render(` +${hidden}`) : ''
4067
+ if (!shown.length) return line(fg.dim.render(`+${hidden} skills`.slice(0, room)))
4068
+ return line(shown.map(pill).join(' ') + more)
4069
+ }
4070
+
4071
+ _settledFiles(files) {
4072
+ const before = new Set(this._files.filter(filePending).map((file) => file.fileId))
4073
+ return files.filter((file) => !filePending(file) && before.has(file.fileId))
4074
+ }
4075
+
4076
+ _fileStatus(settled) {
4077
+ if (!settled.length) return null
4078
+ const failed = settled.find((file) => file.state === 'failed' || file.state === 'cancelled')
4079
+ const token = ++this._statusToken
4080
+ const text = failed ? fg.err.render(fileStatusText(failed)) : fileStatusText(settled[0])
4081
+ return async () => ({ type: 'transient-status', token, text })
4082
+ }
4083
+
4084
+ _filesLine(files) {
4085
+ const more = files.length > 1 ? ` (+${files.length - 1} files)` : ''
4086
+ const body = `${this.spin.view()} ${fileStatusText(files[0])}${more}`
4087
+ return ' ' + fg.dim.render(body.slice(0, Math.max(0, this.width - 3)))
4088
+ }
4089
+
4090
+ // The pane beside the transcript, exactly as tall as it is so the two frames
4091
+ // line up row for row.
4092
+ _sidebarBox(cols, height) {
4093
+ const s = this._sidebar
4094
+ const rows = this._sidebarRows()
4095
+ const inner = cols - 5 // border + padding, and a column of air before the transcript
4096
+ const lines = formatSidebar(rows, this._sidebarCursor(rows), {
4097
+ title: s.moving ? 'Move to…' : 'Chats',
4098
+ focus: s.focus,
4099
+ width: inner,
4100
+ height
4101
+ })
4102
+ return style()
4103
+ .border(style.borders.thick)
4104
+ .borderForeground(s.focus ? 'brightcyan' : 'gray')
4105
+ .width(inner)
4106
+ .padding(0, 1)
4107
+ .margin(0, 1, 0, 0)
4108
+ .render(lines.join('\n'))
4109
+ }
4110
+
4111
+ // The transcript rows as drawn: the viewport, with the command palette
4112
+ // overlaid on its last rows. Selection reads the same lines the user sees.
4113
+ _viewLines() {
4114
+ const lines = this.vp.view().split('\n')
4115
+ const matches = matchCommands(this.input.value)
4116
+ const paletteLines = renderPalette(matches, Math.min(this._paletteAt, matches.length - 1))
4117
+ const start = Math.max(0, lines.length - paletteLines.length)
4118
+ for (let i = 0; i < paletteLines.length && start + i < lines.length; i++) {
4119
+ lines[start + i] = paletteLines[i]
4120
+ }
4121
+ return lines
4122
+ }
4123
+
4124
+ view() {
4125
+ const title = `◤◢◤ ASSISTANT :: ${(this.agent?.name ?? 'connecting').toUpperCase()} ◣◥◣${this._meshLine()}`
4126
+ const header = style()
4127
+ .foreground('black')
4128
+ .background('brightcyan')
4129
+ .bold(true)
4130
+ .width(this.width - 2)
4131
+ .align(0.5)
4132
+ .padding(0, 1)
4133
+ .render(title.slice(0, Math.max(0, this.width - 4)))
4134
+
4135
+ const inputRows = this._inputRows()
4136
+ this._fitViewport(inputRows.length)
4137
+ const lines = this._viewLines()
4138
+ const vpLines =
4139
+ this._selection?.region === 'transcript'
4140
+ ? paintSelection(lines, ...this._selectionCells(this.vp.yOffset))
4141
+ : lines
4142
+ const cols = this._sidebarCols()
4143
+ const transcript = style()
4144
+ .border(style.borders.thick)
4145
+ .borderForeground('cyan')
4146
+ .width(this.width - 4 - cols)
4147
+ .padding(0, 1)
4148
+ .render(vpLines.join('\n'))
4149
+ const body = cols
4150
+ ? style.joinHorizontal(0, this._sidebarBox(cols, vpLines.length), transcript)
4151
+ : transcript
4152
+
4153
+ // Busy state shows in the transcript (_busyLine()), not here — the input
4154
+ // box always shows the actual input field so it never appears to move.
4155
+ const inputBox = style()
4156
+ .border(style.borders.rounded)
4157
+ .borderForeground(this.pendingApproval ? 'brightyellow' : this.busy ? 'gray' : 'brightcyan')
4158
+ .width(this.width - 4)
4159
+ .padding(0, 1)
4160
+ .render(
4161
+ (this._selection?.region === 'input'
4162
+ ? paintSelection(inputRows, ...this._selectionCells(0))
4163
+ : inputRows
4164
+ ).join('\n')
4165
+ )
4166
+
4167
+ const copyHint = this.invite && this._copyToClipboard ? ' · ctrl+y: copy invite' : ''
4168
+ const pasteHint = this._resolvePastedFile ? ' · ctrl+v: attach' : ''
4169
+ const always = this.pendingApproval?.detail.resource
4170
+ ? `allow ${this.pendingApproval.detail.resource.label} for 1h`
4171
+ : 'allow for 1h'
4172
+ const paletteOpen = this.input.value.startsWith('/')
4173
+ const hints = this._hints(always, paletteOpen, copyHint, pasteHint)
4174
+ // a wrapped footer row shifts the whole frame and scrolls the header off — never let it wrap
4175
+ const footer = fg.dim.render(hints.slice(0, Math.max(0, this.width - 1)))
4176
+
4177
+ return [header, body, this._skillsLine(), inputBox, footer].join('\n')
4178
+ }
4179
+ }