@qvac/core 0.1.0 → 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/README.md +2 -2
- package/dist/assistant.bundle +6 -5
- package/dist-lib/schema/helpers.d.ts +28 -0
- package/dist-lib/schema/helpers.js +176 -0
- package/dist-lib/schema/vendor.d.ts +5 -0
- package/package.json +11 -4
- package/tui/README.md +133 -0
- package/tui/app.mjs +4179 -0
- package/tui/index.mjs +650 -0
- package/tui/lib/boot-timing.mjs +41 -0
- package/tui/lib/busy-words.mjs +64 -0
- package/tui/lib/export.mjs +125 -0
- package/tui/lib/knowledge-command.mjs +144 -0
- package/tui/lib/knowledge-import.mjs +135 -0
- package/tui/lib/prebuilds.mjs +81 -0
- package/tui/lib/projection-env.mjs +24 -0
- package/tui/lib/render.mjs +136 -0
- package/tui/lib/save-file.mjs +71 -0
- package/tui/lib/select.mjs +63 -0
- package/tui/lib/setup.mjs +109 -0
- package/tui/lib/sidebar.mjs +52 -0
- package/tui/lib/splash-art.mjs +107 -0
- package/tui/lib/splash.mjs +19 -0
- package/tui/lib/upload-frames.mjs +10 -0
- package/tui/lib/wrap.mjs +114 -0
- package/ducks.png +0 -0
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
|
+
}
|