@asobacloud/nehanda 0.7.0 → 0.9.0
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 +52 -19
- package/bin/agent.mjs +51 -37
- package/bin/nehanda-ui.mjs +256 -79
- package/lib/configureProvider.mjs +4 -3
- package/lib/explore.mjs +109 -0
- package/lib/issues.mjs +144 -0
- package/lib/laya_sidecar.mjs +11 -5
- package/lib/modelPicker.mjs +124 -0
- package/lib/openaiCompat.mjs +17 -1
- package/lib/orchestrate.mjs +160 -23
- package/lib/scripts/laya_worker.py +7 -2
- package/lib/slashCommands.mjs +128 -0
- package/lib/store.mjs +8 -0
- package/lib/ui.mjs +24 -7
- package/lib/workflow.mjs +18 -6
- package/package.json +1 -1
- package/scripts/build-artifacts.sh +81 -0
- package/scripts/install.sh +51 -0
- package/lib/scripts/__pycache__/laya_worker.cpython-314.pyc +0 -0
package/bin/nehanda-ui.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// nehanda-ui — Ink terminal UI for ona-code
|
|
3
3
|
// Connects directly to nehanda-ml.asoba.co via the in-process ona-code engine.
|
|
4
|
-
//
|
|
4
|
+
// Standalone terminal UI, no external HTTP daemon, no cross-repo resolution.
|
|
5
5
|
|
|
6
6
|
if (process.stdout.isTTY) {
|
|
7
7
|
process.stdout.write('\x1b[2J\x1b[3J\x1b[H')
|
|
@@ -19,11 +19,23 @@ import { runUserTurn } from '../lib/orchestrate.mjs'
|
|
|
19
19
|
import { loadInstructions } from '../lib/instructions.mjs'
|
|
20
20
|
import { modelConfigFromAgent } from '../lib/modelConfig.mjs'
|
|
21
21
|
import { runConfigureWizard } from '../lib/configureProvider.mjs'
|
|
22
|
+
import {
|
|
23
|
+
TUI_SLASH_COMMANDS,
|
|
24
|
+
filterSlashAutocomplete,
|
|
25
|
+
parseMcpEnvParts,
|
|
26
|
+
} from '../lib/slashCommands.mjs'
|
|
22
27
|
import { applyMcpConfig, loadMcpConfig, writeGlobalMcpConfig, readGlobalMcpConfig, globalMcpConfigPath, updateMcpServerEnv } from '../lib/mcp-config.mjs'
|
|
23
28
|
import { getMcpServer, closeAllMcpServers, listMcpServers, mcpListResources } from '../lib/mcp.mjs'
|
|
24
29
|
import { invalidateMcpToolCache } from '../lib/tools.mjs'
|
|
25
30
|
import { saveOpenAiCompatKey, resolveOpenAiCompatKey, saveSecureCredentials, resolveZhipuApiKey } from '../lib/auth.mjs'
|
|
26
|
-
import { TOOL_VERBS, verbForTool, summarizeToolArgs, formatToolResultSuffix } from '../lib/ui.mjs'
|
|
31
|
+
import { TOOL_VERBS, verbForTool, summarizeToolArgs, formatToolResultSuffix, maskSecretAnswer } from '../lib/ui.mjs'
|
|
32
|
+
import {
|
|
33
|
+
PICKER_THRESHOLD,
|
|
34
|
+
createAskIdSequencer,
|
|
35
|
+
groupModelsByVendor,
|
|
36
|
+
filterModelItems,
|
|
37
|
+
visibleWindow,
|
|
38
|
+
} from '../lib/modelPicker.mjs'
|
|
27
39
|
import Database from 'better-sqlite3'
|
|
28
40
|
import { ensureLayaReady } from '../lib/laya_sidecar.mjs'
|
|
29
41
|
|
|
@@ -41,6 +53,10 @@ const { default: TerminalRenderer }= await import(`${REPO_ROOT}/node_modules/mar
|
|
|
41
53
|
|
|
42
54
|
const e = React.createElement
|
|
43
55
|
|
|
56
|
+
// Per-question id source for AskPrompt React keys (HP-1a, #10). Module scope:
|
|
57
|
+
// ids only need to be unique for the process lifetime.
|
|
58
|
+
const askIdSeq = createAskIdSequencer()
|
|
59
|
+
|
|
44
60
|
// ── Asoba 3 Visual Identity ───────────────────────────────────
|
|
45
61
|
const BRAND = {
|
|
46
62
|
accent: '#455BF1',
|
|
@@ -53,9 +69,9 @@ const BRAND = {
|
|
|
53
69
|
// ── Config paths ─────────────────────────────────────────────
|
|
54
70
|
const NEHANDA_DIR = path.join(os.homedir(), '.config', 'nehanda')
|
|
55
71
|
const NEHANDA_DB = path.join(NEHANDA_DIR, 'ona-session.db')
|
|
56
|
-
const
|
|
57
|
-
const AGENTS_JSON = path.join(AIMEE_DIR, 'agents.json')
|
|
72
|
+
const AGENTS_JSON = path.join(NEHANDA_DIR, 'agents.json')
|
|
58
73
|
const SPLASH_SEEN = path.join(NEHANDA_DIR, '.splash-seen')
|
|
74
|
+
const LAUNCH_CWD = process.cwd()
|
|
59
75
|
|
|
60
76
|
function hasSeenSplash() { try { return fs.existsSync(SPLASH_SEEN) } catch { return false } }
|
|
61
77
|
function markSplashSeen() {
|
|
@@ -78,8 +94,15 @@ function readApiKey() {
|
|
|
78
94
|
|
|
79
95
|
function writeApiKey(key) {
|
|
80
96
|
const data = readAgents()
|
|
81
|
-
|
|
82
|
-
if (agent) {
|
|
97
|
+
let agent = data.agents?.find(a => a.name === data.default_agent) || data.agents?.[0]
|
|
98
|
+
if (!agent) {
|
|
99
|
+
agent = { name: data.default_agent || 'default' }
|
|
100
|
+
if (!Array.isArray(data.agents)) data.agents = []
|
|
101
|
+
data.agents.push(agent)
|
|
102
|
+
data.default_agent = agent.name
|
|
103
|
+
}
|
|
104
|
+
agent.api_key = key
|
|
105
|
+
fs.writeFileSync(AGENTS_JSON, JSON.stringify(data, null, 2), 'utf8')
|
|
83
106
|
}
|
|
84
107
|
|
|
85
108
|
// ── Session DB bootstrap ─────────────────────────────────────
|
|
@@ -137,16 +160,7 @@ function renderMd(text) {
|
|
|
137
160
|
|
|
138
161
|
// ── Constants ────────────────────────────────────────────────
|
|
139
162
|
const SPINNER_FRAMES = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏']
|
|
140
|
-
const SLASH_COMMANDS =
|
|
141
|
-
{ name: '/model', desc: 'Configure provider, key, host, and model (single wizard)' },
|
|
142
|
-
{ name: '/key', desc: 'Set API key then continue configure (/key openrouter|zhipu|openai)' },
|
|
143
|
-
{ name: '/config', desc: 'Show current config' },
|
|
144
|
-
{ name: '/mcp', desc: 'MCP server & tool management (/mcp list)' },
|
|
145
|
-
{ name: '/clear', desc: 'New conversation' },
|
|
146
|
-
{ name: '/retry', desc: 'Resend the last failed message' },
|
|
147
|
-
{ name: '/help', desc: 'Show commands' },
|
|
148
|
-
{ name: '/exit', desc: 'Quit' },
|
|
149
|
-
]
|
|
163
|
+
const SLASH_COMMANDS = TUI_SLASH_COMMANDS
|
|
150
164
|
|
|
151
165
|
// ── Asoba lock-up (compact) ───────────────────────────────────
|
|
152
166
|
function AsobaMark() {
|
|
@@ -217,7 +231,7 @@ function SplashScreen({ onDone }) {
|
|
|
217
231
|
e(Box, { marginTop: 1 }, e(Text, { dimColor: true }, 'press any key to continue…')),
|
|
218
232
|
)
|
|
219
233
|
}
|
|
220
|
-
function WelcomeBanner({ model, endpoint }) {
|
|
234
|
+
function WelcomeBanner({ model, endpoint, cwd = LAUNCH_CWD }) {
|
|
221
235
|
let cols = 80; try { cols = process.stdout.columns || 80 } catch {}
|
|
222
236
|
const bw = Math.min(cols - 2, 80)
|
|
223
237
|
const lw = Math.floor(bw * 0.42), rw = bw - lw - 3
|
|
@@ -229,8 +243,9 @@ function WelcomeBanner({ model, endpoint }) {
|
|
|
229
243
|
e(Box, { flexDirection: 'column', width: rw, paddingLeft: 1, paddingY: 1 },
|
|
230
244
|
e(Text, { bold: true, color: BRAND.accent }, 'Configuration'),
|
|
231
245
|
e(Box, { marginTop: 1, flexDirection: 'column' },
|
|
232
|
-
e(Text, {}, e(Text, { dimColor: true }, 'Model:
|
|
233
|
-
e(Text, {}, e(Text, { dimColor: true }, 'Endpoint:
|
|
246
|
+
e(Text, {}, e(Text, { dimColor: true }, 'Model: '), e(Text, { color: BRAND.lavender, bold: true }, model || '—')),
|
|
247
|
+
e(Text, {}, e(Text, { dimColor: true }, 'Endpoint: '), e(Text, { dimColor: true }, endpoint || '—')),
|
|
248
|
+
e(Text, {}, e(Text, { dimColor: true }, 'Directory: '), e(Text, { dimColor: true }, cwd || LAUNCH_CWD)),
|
|
234
249
|
),
|
|
235
250
|
e(Box, { marginTop: 1 }, e(Text, { dimColor: true }, 'Type /help for commands')),
|
|
236
251
|
),
|
|
@@ -268,15 +283,20 @@ function Spinner({ label }) {
|
|
|
268
283
|
return e(Box, { marginLeft: 1 }, e(Text, { color: BRAND.accent }, SPINNER_FRAMES[frame % SPINNER_FRAMES.length] + ' '), e(Text, { dimColor: true }, verb + '…'))
|
|
269
284
|
}
|
|
270
285
|
function SlashMenu({ filter }) {
|
|
271
|
-
const matches =
|
|
286
|
+
const matches = filterSlashAutocomplete(filter)
|
|
272
287
|
if (!matches.length) return null
|
|
288
|
+
const pad = Math.min(36, Math.max(...matches.map(c => c.name.length), 12) + 1)
|
|
273
289
|
return e(Box, { flexDirection: 'column', marginLeft: 2 },
|
|
274
290
|
...matches.map((c, i) => e(Box, { key: String(i) },
|
|
275
|
-
e(Text, { color: BRAND.accent }, c.name.padEnd(
|
|
291
|
+
e(Text, { color: BRAND.accent }, c.name.padEnd(pad)), e(Text, { dimColor: true }, c.desc))))
|
|
276
292
|
}
|
|
277
293
|
function InputArea({ onSubmit, isLoading }) {
|
|
278
294
|
const [value, setValue] = React.useState('')
|
|
279
|
-
|
|
295
|
+
// Show slash menu for top-level (`/m`) and while typing known parents (`/mcp `, `/key o`)
|
|
296
|
+
const showMenu = value.startsWith('/') && (
|
|
297
|
+
!value.includes(' ')
|
|
298
|
+
|| /^\/(mcp|key)(\s|$)/i.test(value)
|
|
299
|
+
)
|
|
280
300
|
const handleSubmit = React.useCallback(v => { if (!v.trim()) return; setValue(''); onSubmit(v.trim()) }, [onSubmit])
|
|
281
301
|
if (isLoading) return e(Box, { paddingX: 1 }, e(Text, { dimColor: true }, 'esc to cancel'))
|
|
282
302
|
return e(Box, { flexDirection: 'column' },
|
|
@@ -286,6 +306,9 @@ function InputArea({ onSubmit, isLoading }) {
|
|
|
286
306
|
}
|
|
287
307
|
function AskPrompt({ question, onAnswer, onCancel }) {
|
|
288
308
|
const [value, setValue] = React.useState('')
|
|
309
|
+
// Defense-in-depth for HP-1a (#10): if this instance ever re-renders with a
|
|
310
|
+
// new question (fiber reuse), the typed value must not carry over.
|
|
311
|
+
React.useEffect(() => { setValue('') }, [question])
|
|
289
312
|
useInput((_input, key) => { if (key.escape) onCancel() })
|
|
290
313
|
return e(Box, { flexDirection: 'column' },
|
|
291
314
|
e(Box, { marginLeft: 1 }, e(Text, { dimColor: true }, question)),
|
|
@@ -293,7 +316,124 @@ function AskPrompt({ question, onAnswer, onCancel }) {
|
|
|
293
316
|
e(TextInput, { value, onChange: setValue, onSubmit: React.useCallback(v => onAnswer(v.trim()), [onAnswer]) })),
|
|
294
317
|
e(Box, { marginLeft: 1 }, e(Text, { dimColor: true }, 'Esc cancel')))
|
|
295
318
|
}
|
|
319
|
+
// ── Model Picker (large lists, #10 / HP-2) ───────────────────
|
|
320
|
+
// Three views: vendor GROUPS → windowed BROWSE within a vendor →
|
|
321
|
+
// type-to-filter SEARCH across every discovered model.
|
|
322
|
+
// Digits jump rows, '/' opens search, Esc steps back.
|
|
323
|
+
function ModelPicker({ title, items, onSelect, onCancel }) {
|
|
324
|
+
const groups = React.useMemo(() => groupModelsByVendor(items), [items])
|
|
325
|
+
const [view, setView] = React.useState('groups') // 'groups' | 'browse' | 'search'
|
|
326
|
+
const [groupIdx, setGroupIdx] = React.useState(0)
|
|
327
|
+
const [browseIdx, setBrowseIdx] = React.useState(0)
|
|
328
|
+
const [filter, setFilter] = React.useState('')
|
|
329
|
+
const [searchIdx, setSearchIdx] = React.useState(0)
|
|
330
|
+
|
|
331
|
+
const browseModels = view === 'browse' && groups[groupIdx] ? groups[groupIdx].models : []
|
|
332
|
+
const searchResults = React.useMemo(() => filterModelItems(items, filter), [items, filter])
|
|
333
|
+
const prevView = React.useRef('groups')
|
|
334
|
+
|
|
335
|
+
const jump = (setter, list, n) => {
|
|
336
|
+
if (!isNaN(n) && n >= 1 && n <= list.length) setter(n - 1)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
useInput((input, key) => {
|
|
340
|
+
if (view === 'search') {
|
|
341
|
+
// TextInput owns printable chars + Enter (its onSubmit); arrows pass through.
|
|
342
|
+
if (key.escape) {
|
|
343
|
+
if (filter) { setFilter(''); setSearchIdx(0) }
|
|
344
|
+
else { setView(prevView.current); setSearchIdx(0) }
|
|
345
|
+
} else if (key.upArrow) { setSearchIdx(i => (i - 1 + searchResults.length) % searchResults.length) }
|
|
346
|
+
else if (key.downArrow) { setSearchIdx(i => (i + 1) % searchResults.length) }
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
if (key.escape) {
|
|
350
|
+
if (view === 'browse') setView('groups')
|
|
351
|
+
else onCancel()
|
|
352
|
+
return
|
|
353
|
+
}
|
|
354
|
+
if (input === '/') { prevView.current = view; setView('search'); setFilter(''); setSearchIdx(0); return }
|
|
355
|
+
if (view === 'groups') {
|
|
356
|
+
if (key.upArrow) { setGroupIdx(i => (i - 1 + groups.length) % groups.length); return }
|
|
357
|
+
if (key.downArrow) { setGroupIdx(i => (i + 1) % groups.length); return }
|
|
358
|
+
if (key.return) {
|
|
359
|
+
const g = groups[groupIdx]
|
|
360
|
+
if (!g) return
|
|
361
|
+
if (g.isCustom) { onSelect(g.models[0]); return }
|
|
362
|
+
setBrowseIdx(0); setView('browse')
|
|
363
|
+
return
|
|
364
|
+
}
|
|
365
|
+
jump(setGroupIdx, groups, parseInt(input, 10))
|
|
366
|
+
} else if (view === 'browse') {
|
|
367
|
+
if (key.upArrow) { setBrowseIdx(i => (i - 1 + browseModels.length) % browseModels.length); return }
|
|
368
|
+
if (key.downArrow) { setBrowseIdx(i => (i + 1) % browseModels.length); return }
|
|
369
|
+
if (key.return) { const m = browseModels[browseIdx]; if (m) onSelect(m); return }
|
|
370
|
+
const win = visibleWindow(browseModels, browseIdx)
|
|
371
|
+
const shown = win.visible
|
|
372
|
+
const n = parseInt(input, 10)
|
|
373
|
+
if (!isNaN(n) && n >= 1 && n <= shown.length) {
|
|
374
|
+
const item = shown[n - 1]
|
|
375
|
+
const abs = browseModels.indexOf(item)
|
|
376
|
+
if (abs !== -1) setBrowseIdx(abs)
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
const row = (item, i, cursor) => e(Box, { key: String(item.id ?? i), flexDirection: 'column' },
|
|
382
|
+
e(Box, {}, e(Text, { color: cursor ? BRAND.accent : undefined }, cursor ? '❯ ' : ' '),
|
|
383
|
+
e(Text, { color: BRAND.lavender }, `${i + 1}. `),
|
|
384
|
+
e(Text, { bold: true, color: cursor ? BRAND.accent : undefined }, item.label)),
|
|
385
|
+
e(Box, { marginLeft: 5 }, e(Text, { dimColor: true }, item.desc)))
|
|
386
|
+
const hiddenHints = win => e(Box, { key: 'hints', flexDirection: 'column' },
|
|
387
|
+
win.hiddenAbove ? e(Text, { dimColor: true }, ` ↑ ${win.hiddenAbove} more above`) : null,
|
|
388
|
+
win.hiddenBelow ? e(Text, { dimColor: true }, ` ↓ ${win.hiddenBelow} more below`) : null)
|
|
389
|
+
|
|
390
|
+
let body
|
|
391
|
+
if (view === 'groups') {
|
|
392
|
+
body = e(Box, { marginTop: 1, flexDirection: 'column' },
|
|
393
|
+
...groups.map((g, i) => row(g, i, i === groupIdx)),
|
|
394
|
+
e(Box, { marginTop: 1 }, e(Text, { dimColor: true },
|
|
395
|
+
`↑↓/1–${Math.min(9, groups.length)} vendor · Enter open · / search all · Esc cancel`)))
|
|
396
|
+
} else if (view === 'browse') {
|
|
397
|
+
const win = visibleWindow(browseModels, browseIdx)
|
|
398
|
+
body = e(Box, { marginTop: 1, flexDirection: 'column' },
|
|
399
|
+
e(Text, { dimColor: true }, `${groups[groupIdx].label} — ${browseModels.length} models`),
|
|
400
|
+
...win.visible.map((m, i) => row(m, win.start + i, win.start + i === browseIdx)),
|
|
401
|
+
hiddenHints(win),
|
|
402
|
+
e(Box, { marginTop: 1 }, e(Text, { dimColor: true },
|
|
403
|
+
'↑↓ navigate · Enter select · / search all · Esc back')))
|
|
404
|
+
} else {
|
|
405
|
+
const win = visibleWindow(searchResults, searchIdx)
|
|
406
|
+
const handleSearchSubmit = v => {
|
|
407
|
+
const item = searchResults[searchIdx]
|
|
408
|
+
if (item) onSelect(item)
|
|
409
|
+
}
|
|
410
|
+
body = e(Box, { marginTop: 1, flexDirection: 'column' },
|
|
411
|
+
e(Box, { paddingX: 1 }, e(Text, { color: BRAND.accent }, '/'),
|
|
412
|
+
e(TextInput, { value: filter, onChange: v => { setFilter(v); setSearchIdx(0) }, onSubmit: handleSearchSubmit })),
|
|
413
|
+
searchResults.length
|
|
414
|
+
? e(Box, { marginTop: 1, flexDirection: 'column' },
|
|
415
|
+
...win.visible.map((m, i) => row(m, win.start + i, win.start + i === searchIdx)),
|
|
416
|
+
hiddenHints(win))
|
|
417
|
+
: e(Text, { dimColor: true }, ' (no matches)'),
|
|
418
|
+
e(Box, { marginTop: 1 }, e(Text, { dimColor: true },
|
|
419
|
+
'type to filter · ↑↓ select · Enter confirm · Esc clear/back')))
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return e(Box, { flexDirection: 'column', marginLeft: 1 },
|
|
423
|
+
e(Text, { bold: true, color: BRAND.accent }, title),
|
|
424
|
+
body)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Wrapper (no hooks): large catalogs (e.g. OpenRouter's hundreds of models)
|
|
428
|
+
// switch to the grouped/filterable picker instead of a flat scroll list (HP-2, #10).
|
|
296
429
|
function SelectMenu({ title, items, onSelect, onCancel }) {
|
|
430
|
+
if (items.length > PICKER_THRESHOLD) {
|
|
431
|
+
return e(ModelPicker, { title, items, onSelect, onCancel })
|
|
432
|
+
}
|
|
433
|
+
return e(SelectMenuFlat, { title, items, onSelect, onCancel })
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function SelectMenuFlat({ title, items, onSelect, onCancel }) {
|
|
297
437
|
const [idx, setIdx] = React.useState(0)
|
|
298
438
|
useInput((input, key) => {
|
|
299
439
|
if (key.escape) { onCancel(); return }
|
|
@@ -355,13 +495,17 @@ function BlockConfirmMenu({ payload, onDecide }) {
|
|
|
355
495
|
e(Text, {}, ' Cancel')))
|
|
356
496
|
}
|
|
357
497
|
|
|
358
|
-
function TokenFooter({ calls, model }) {
|
|
498
|
+
function TokenFooter({ calls, model, cwd = LAUNCH_CWD }) {
|
|
359
499
|
const cols = process.stdout.columns || 80
|
|
360
500
|
return e(Box, { flexDirection: 'column' },
|
|
361
501
|
e(Text, { dimColor: true }, '─'.repeat(cols)),
|
|
362
502
|
e(Box, { flexDirection: 'row', paddingX: 1, justifyContent: 'space-between' },
|
|
363
503
|
e(Text, { dimColor: true }, `calls ${calls}`),
|
|
364
|
-
e(
|
|
504
|
+
e(Box, { flexDirection: 'row' },
|
|
505
|
+
e(Text, {}, e(Text, { dimColor: true }, 'model: '), e(Text, { color: BRAND.lavender }, model || '—')),
|
|
506
|
+
e(Text, { dimColor: true }, ' '),
|
|
507
|
+
e(Text, {}, e(Text, { dimColor: true }, 'dir: '), e(Text, { dimColor: true }, cwd || LAUNCH_CWD)),
|
|
508
|
+
),
|
|
365
509
|
e(Text, { dimColor: true }, '? /help ^C exit')))
|
|
366
510
|
}
|
|
367
511
|
|
|
@@ -383,7 +527,8 @@ function App({ bridge }) {
|
|
|
383
527
|
const ag = data.agents?.find(a => a.name === data.default_agent) || data.agents?.[0]
|
|
384
528
|
return { model: ag?.model || 'nehanda-rag-synthesis-27b', endpoint: ag?.endpoint || 'https://nehanda-ml.asoba.co/v1' }
|
|
385
529
|
})
|
|
386
|
-
const lastCtrlCRef
|
|
530
|
+
const lastCtrlCRef = React.useRef(0)
|
|
531
|
+
const lastSpinLabelRef = React.useRef(null) // #18: retain last explicit label to prevent spitfire between tool calls
|
|
387
532
|
const bannerRef = React.useRef({ kind: 'banner' })
|
|
388
533
|
const historyItems = React.useMemo(() => [bannerRef.current, ...finalized], [finalized])
|
|
389
534
|
|
|
@@ -395,8 +540,12 @@ function App({ bridge }) {
|
|
|
395
540
|
bridge.endAssistant = msg => { setFinalized(p => [...p, msg]); setStreaming(null); setCallCount(c => c + 1) }
|
|
396
541
|
bridge.clearMessages = () => { setFinalized([]); setStreaming(null); setClearGen(g => g + 1) }
|
|
397
542
|
bridge.setLoading = v => setIsLoading(v)
|
|
398
|
-
bridge.updateSpinner = text => setSpinLabel(text)
|
|
399
|
-
|
|
543
|
+
bridge.updateSpinner = text => { if (text !== null) lastSpinLabelRef.current = text; setSpinLabel(text) }
|
|
544
|
+
// HP-1a (#10): unique id per question — AskPrompt is keyed by it, so every
|
|
545
|
+
// new wizard question remounts with an EMPTY input (no key carry-over).
|
|
546
|
+
// opts.secret → the persisted transcript line is masked (HP-1b).
|
|
547
|
+
bridge.ask = (q, opts) => new Promise(resolve =>
|
|
548
|
+
setAskState({ id: askIdSeq.next(), question: q, secret: !!(opts && opts.secret), resolve }))
|
|
400
549
|
bridge.select = (t,i) => new Promise(resolve => setSelectState({ title: t, items: i, resolve }))
|
|
401
550
|
bridge.confirmBlock = payload => new Promise(resolve => setBlockState({ payload, resolve }))
|
|
402
551
|
bridge.exit = () => { exit() }
|
|
@@ -408,7 +557,10 @@ function App({ bridge }) {
|
|
|
408
557
|
}, [bridge, exit])
|
|
409
558
|
|
|
410
559
|
const handleAnswer = React.useCallback(answer => {
|
|
411
|
-
|
|
560
|
+
// HP-1b (#10): secret answers persist masked to transcript history; the
|
|
561
|
+
// wizard still receives the raw answer via resolve().
|
|
562
|
+
const shown = askState?.secret ? maskSecretAnswer(answer) : answer
|
|
563
|
+
if (askState?.resolve) { setFinalized(p => [...p, { role: 'system', text: `${askState.question} ${shown}`.trim() }]); askState.resolve(answer) }
|
|
412
564
|
setAskState(null)
|
|
413
565
|
}, [askState])
|
|
414
566
|
const handleAskCancel = React.useCallback(() => { askState?.resolve(null); setAskState(null) }, [askState])
|
|
@@ -443,18 +595,25 @@ function App({ bridge }) {
|
|
|
443
595
|
return e(Box, { flexDirection: 'column' },
|
|
444
596
|
e(Static, { items: historyItems, key: clearGen },
|
|
445
597
|
(item, i) => item.kind === 'banner'
|
|
446
|
-
? e(WelcomeBanner, { key: 'banner', model: modelInfo.model, endpoint: modelInfo.endpoint })
|
|
598
|
+
? e(WelcomeBanner, { key: 'banner', model: modelInfo.model, endpoint: modelInfo.endpoint, cwd: LAUNCH_CWD })
|
|
447
599
|
: e(MessageView, { key: String(i), msg: item })),
|
|
448
600
|
streaming ? e(MessageView, { msg: { role: 'assistant', ...streaming } }) : null,
|
|
449
|
-
|
|
601
|
+
// #18 fix 1: !askState hides Spinner while AskPrompt is shown (permission prompt visible).
|
|
602
|
+
// #18 fix 2: effectiveSpinLabel uses lastSpinLabelRef fallback when spinLabel=null during
|
|
603
|
+
// active loading, preventing snap-back to a random rotating verb between tool calls.
|
|
604
|
+
(() => {
|
|
605
|
+
const effectiveSpinLabel = spinLabel ?? (isLoading ? lastSpinLabelRef.current : null)
|
|
606
|
+
return (!askState && ((isLoading && (!streaming || !streaming.text)) || effectiveSpinLabel))
|
|
607
|
+
? e(Spinner, { label: effectiveSpinLabel || undefined }) : null
|
|
608
|
+
})(),
|
|
450
609
|
askState
|
|
451
|
-
? e(AskPrompt, { question: askState.question, onAnswer: handleAnswer, onCancel: handleAskCancel })
|
|
610
|
+
? e(AskPrompt, { key: askState.id, question: askState.question, onAnswer: handleAnswer, onCancel: handleAskCancel })
|
|
452
611
|
: blockState
|
|
453
612
|
? e(BlockConfirmMenu, { payload: blockState.payload, onDecide: handleBlock })
|
|
454
613
|
: selectState
|
|
455
614
|
? e(SelectMenu, { title: selectState.title, items: selectState.items, onSelect: handleSelect, onCancel: handleSelectCancel })
|
|
456
615
|
: e(InputArea, { onSubmit: bridge.onSubmit, isLoading }),
|
|
457
|
-
e(TokenFooter, { calls: callCount, model: modelInfo.model }))
|
|
616
|
+
e(TokenFooter, { calls: callCount, model: modelInfo.model, cwd: LAUNCH_CWD }))
|
|
458
617
|
}
|
|
459
618
|
|
|
460
619
|
// ── Diff executor ─────────────────────────────────────────────
|
|
@@ -491,7 +650,7 @@ function stripThink(text) {
|
|
|
491
650
|
async function runUiConfigureWizard(bridge, opts = {}) {
|
|
492
651
|
try {
|
|
493
652
|
const result = await runConfigureWizard({
|
|
494
|
-
ask: q => bridge.ask(q),
|
|
653
|
+
ask: (q, opts) => bridge.ask(q, opts),
|
|
495
654
|
select: (title, items) => bridge.select(title, items),
|
|
496
655
|
notify: msg => bridge.addMessage({ role: 'system', text: ` ${msg}` }),
|
|
497
656
|
resolveExistingKey: async (preset) => {
|
|
@@ -507,12 +666,16 @@ async function runUiConfigureWizard(bridge, opts = {}) {
|
|
|
507
666
|
},
|
|
508
667
|
persist: async (cfg) => {
|
|
509
668
|
const data = readAgents()
|
|
510
|
-
|
|
511
|
-
if (ag) {
|
|
512
|
-
ag
|
|
513
|
-
|
|
514
|
-
|
|
669
|
+
let ag = data.agents?.find(a => a.name === data.default_agent) || data.agents?.[0]
|
|
670
|
+
if (!ag) {
|
|
671
|
+
ag = { name: data.default_agent || 'default' }
|
|
672
|
+
if (!Array.isArray(data.agents)) data.agents = []
|
|
673
|
+
data.agents.push(ag)
|
|
674
|
+
data.default_agent = ag.name
|
|
515
675
|
}
|
|
676
|
+
ag.model = cfg.model
|
|
677
|
+
ag.endpoint = cfg.endpoint
|
|
678
|
+
if (cfg.provider === 'nehanda' && cfg.apiKey) ag.api_key = cfg.apiKey
|
|
516
679
|
fs.writeFileSync(AGENTS_JSON, JSON.stringify(data, null, 2), 'utf8')
|
|
517
680
|
runtimeSettings.model_config = cfg.model_config || modelConfigFromAgent(
|
|
518
681
|
{ model: cfg.model, endpoint: cfg.endpoint },
|
|
@@ -541,8 +704,11 @@ async function handleCommand(cmd, bridge) {
|
|
|
541
704
|
const [name] = cmd.split(/\s+/)
|
|
542
705
|
|
|
543
706
|
if (name === '/help') {
|
|
544
|
-
|
|
545
|
-
|
|
707
|
+
const pad = Math.max(...TUI_SLASH_COMMANDS.map(c => c.name.length), 12) + 2
|
|
708
|
+
const body = TUI_SLASH_COMMANDS.map(c =>
|
|
709
|
+
` ${chalk.hex(BRAND.accent)(c.name.padEnd(pad))}${chalk.dim(c.desc)}`
|
|
710
|
+
).join('\n')
|
|
711
|
+
bridge.addMessage({ role: 'system', text: `\nCommands:\n\n${body}\n` })
|
|
546
712
|
return
|
|
547
713
|
}
|
|
548
714
|
if (name === '/config') {
|
|
@@ -585,7 +751,7 @@ async function handleCommand(cmd, bridge) {
|
|
|
585
751
|
}
|
|
586
752
|
|
|
587
753
|
if (!sub || !KEY_TO_PRESET[sub]) {
|
|
588
|
-
const answer = await bridge.ask('New Nehanda API key:')
|
|
754
|
+
const answer = await bridge.ask('New Nehanda API key:', { secret: true })
|
|
589
755
|
if (!answer) return
|
|
590
756
|
try {
|
|
591
757
|
writeApiKey(answer)
|
|
@@ -597,7 +763,7 @@ async function handleCommand(cmd, bridge) {
|
|
|
597
763
|
|
|
598
764
|
const presetId = KEY_TO_PRESET[sub]
|
|
599
765
|
const inlineValue = parts.slice(2).join(' ').trim()
|
|
600
|
-
const answer = inlineValue || await bridge.ask(`New ${sub} API key
|
|
766
|
+
const answer = inlineValue || await bridge.ask(`New ${sub} API key:`, { secret: true })
|
|
601
767
|
if (!answer) return
|
|
602
768
|
|
|
603
769
|
try {
|
|
@@ -620,14 +786,19 @@ async function handleCommand(cmd, bridge) {
|
|
|
620
786
|
const serverNames = Object.keys(servers)
|
|
621
787
|
|
|
622
788
|
if (!sub || sub === 'help') {
|
|
789
|
+
const pad = 40
|
|
790
|
+
const row = (n, d) => ` ${chalk.hex(BRAND.accent)(n.padEnd(pad))}${chalk.dim(d)}`
|
|
623
791
|
const lines = [
|
|
624
792
|
'',
|
|
625
793
|
` ${chalk.bold('MCP Server & Tool Commands:')}`,
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
794
|
+
row('/mcp list', 'List tools from configured MCP servers'),
|
|
795
|
+
row('/mcp status', 'Show configured servers'),
|
|
796
|
+
row('/mcp reload [server]', 'Reload mcp.json into this session'),
|
|
797
|
+
row('/mcp add <name> <command> [args…]', 'Register a server'),
|
|
798
|
+
row('/mcp env', 'Show env vars for every server (values masked)'),
|
|
799
|
+
row('/mcp env <server>', 'Show env vars for one server'),
|
|
800
|
+
row('/mcp env <server> <NAME> <VALUE>', 'Set env var e.g. /mcp env jupyter JUPYTER_TOKEN sk-…'),
|
|
801
|
+
row('/mcp env unset <server> <NAME>', 'Remove an env var'),
|
|
631
802
|
'',
|
|
632
803
|
` ${chalk.dim(`Configured servers: ${serverNames.length ? serverNames.join(', ') : 'none'}`)}`,
|
|
633
804
|
` ${chalk.dim('Tip: Run /mcp list to inspect tools injected into the assistant.')}`,
|
|
@@ -717,24 +888,26 @@ async function handleCommand(cmd, bridge) {
|
|
|
717
888
|
}
|
|
718
889
|
|
|
719
890
|
if (sub === 'env') {
|
|
720
|
-
const
|
|
721
|
-
|
|
722
|
-
|
|
891
|
+
const parsed = parseMcpEnvParts(parts.slice(1))
|
|
892
|
+
|
|
893
|
+
if (parsed.action === 'usage_error') {
|
|
894
|
+
bridge.addMessage({ role: 'system', text: ' ' + parsed.error.replace(/\n/g, '\n ') })
|
|
895
|
+
return
|
|
896
|
+
}
|
|
723
897
|
|
|
724
|
-
if (
|
|
725
|
-
// Show env for all servers
|
|
898
|
+
if (parsed.action === 'list_all') {
|
|
726
899
|
if (!serverNames.length) {
|
|
727
900
|
bridge.addMessage({ role: 'system', text: ' No MCP servers configured.' })
|
|
728
901
|
return
|
|
729
902
|
}
|
|
730
903
|
const lines = ['\n MCP Server Environment Variables:']
|
|
731
|
-
for (const
|
|
732
|
-
const cfg = servers[
|
|
904
|
+
for (const srv of serverNames) {
|
|
905
|
+
const cfg = servers[srv]
|
|
733
906
|
const envKeys = Object.keys(cfg.env || {})
|
|
734
907
|
if (!envKeys.length) {
|
|
735
|
-
lines.push(` ${chalk.hex(BRAND.accent)(
|
|
908
|
+
lines.push(` ${chalk.hex(BRAND.accent)(srv)}: (none)`)
|
|
736
909
|
} else {
|
|
737
|
-
lines.push(` ${chalk.hex(BRAND.accent)(
|
|
910
|
+
lines.push(` ${chalk.hex(BRAND.accent)(srv)}:`)
|
|
738
911
|
for (const k of envKeys) {
|
|
739
912
|
const v = cfg.env[k]
|
|
740
913
|
const masked = v.length > 10 ? v.slice(0, 6) + '...' + v.slice(-4) : '********'
|
|
@@ -746,18 +919,17 @@ async function handleCommand(cmd, bridge) {
|
|
|
746
919
|
return
|
|
747
920
|
}
|
|
748
921
|
|
|
749
|
-
if (
|
|
750
|
-
|
|
751
|
-
const cfg = servers[targetServer]
|
|
922
|
+
if (parsed.action === 'list_server') {
|
|
923
|
+
const cfg = servers[parsed.server]
|
|
752
924
|
if (!cfg) {
|
|
753
|
-
bridge.addMessage({ role: 'system', text: ` Server "${
|
|
925
|
+
bridge.addMessage({ role: 'system', text: ` Server "${parsed.server}" not found.` })
|
|
754
926
|
return
|
|
755
927
|
}
|
|
756
928
|
const envKeys = Object.keys(cfg.env || {})
|
|
757
929
|
if (!envKeys.length) {
|
|
758
|
-
bridge.addMessage({ role: 'system', text: ` ${chalk.hex(BRAND.accent)(
|
|
930
|
+
bridge.addMessage({ role: 'system', text: ` ${chalk.hex(BRAND.accent)(parsed.server)}: (no env vars set)\n Set one with: /mcp env ${parsed.server} <NAME> <VALUE>` })
|
|
759
931
|
} else {
|
|
760
|
-
const lines = [` ${chalk.hex(BRAND.accent)(
|
|
932
|
+
const lines = [` ${chalk.hex(BRAND.accent)(parsed.server)}:`]
|
|
761
933
|
for (const k of envKeys) {
|
|
762
934
|
const v = cfg.env[k]
|
|
763
935
|
const masked = v.length > 10 ? v.slice(0, 6) + '...' + v.slice(-4) : '********'
|
|
@@ -768,25 +940,26 @@ async function handleCommand(cmd, bridge) {
|
|
|
768
940
|
return
|
|
769
941
|
}
|
|
770
942
|
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
943
|
+
if (parsed.action === 'set' || parsed.action === 'unset') {
|
|
944
|
+
const value = parsed.action === 'unset' ? '' : parsed.value
|
|
945
|
+
const result = updateMcpServerEnv(parsed.server, parsed.name, value, process.cwd())
|
|
946
|
+
if (!result.ok) {
|
|
947
|
+
bridge.addMessage({ role: 'system', text: ` ${chalk.hex(BRAND.red)('✗')} ${result.error}` })
|
|
948
|
+
return
|
|
949
|
+
}
|
|
950
|
+
runtimeSettings.mcp_servers = {}
|
|
951
|
+
applyMcpConfig(runtimeSettings, process.cwd())
|
|
952
|
+
invalidateMcpToolCache()
|
|
953
|
+
if (parsed.action === 'set') {
|
|
954
|
+
bridge.addMessage({ role: 'system', text: ` ${chalk.green('✓')} Set ${chalk.bold(parsed.name)} = ******** on ${chalk.bold(parsed.server)}\n Config file: ${result.path}` })
|
|
955
|
+
} else {
|
|
956
|
+
bridge.addMessage({ role: 'system', text: ` ${chalk.green('✓')} Unset ${chalk.bold(parsed.name)} on ${chalk.bold(parsed.server)}\n Config file: ${result.path}` })
|
|
957
|
+
}
|
|
775
958
|
return
|
|
776
959
|
}
|
|
777
|
-
// Reload runtime settings and bust cache
|
|
778
|
-
runtimeSettings.mcp_servers = {}
|
|
779
|
-
applyMcpConfig(runtimeSettings, process.cwd())
|
|
780
|
-
invalidateMcpToolCache()
|
|
781
|
-
if (result.set) {
|
|
782
|
-
bridge.addMessage({ role: 'system', text: ` ${chalk.green('✓')} Set ${chalk.bold(envKey)} = ${'********'} on ${chalk.bold(targetServer)}\n Config file: ${result.path}` })
|
|
783
|
-
} else {
|
|
784
|
-
bridge.addMessage({ role: 'system', text: ` ${chalk.green('✓')} Cleared ${chalk.bold(envKey)} on ${chalk.bold(targetServer)}\n Config file: ${result.path}` })
|
|
785
|
-
}
|
|
786
|
-
return
|
|
787
960
|
}
|
|
788
961
|
|
|
789
|
-
bridge.addMessage({ role: 'system', text: ' /mcp sub-commands: status · list · reload
|
|
962
|
+
bridge.addMessage({ role: 'system', text: ' /mcp sub-commands: status · list · reload · add · env · env unset — type /help for full list' })
|
|
790
963
|
return
|
|
791
964
|
}
|
|
792
965
|
|
|
@@ -822,8 +995,12 @@ bridge.onSubmit = async (text) => {
|
|
|
822
995
|
if (text.startsWith('/')) { await handleCommand(text, bridge); return }
|
|
823
996
|
|
|
824
997
|
bridge.setLoading(true)
|
|
998
|
+
bridge.updateSpinner('Analyzing')
|
|
825
999
|
const controller = new AbortController()
|
|
826
|
-
bridge.abortCurrent = () =>
|
|
1000
|
+
bridge.abortCurrent = () => {
|
|
1001
|
+
bridge.updateSpinner('Cancelling…') // #22: immediate UI feedback before abort propagates
|
|
1002
|
+
controller.abort()
|
|
1003
|
+
}
|
|
827
1004
|
let lastPartial = ''
|
|
828
1005
|
|
|
829
1006
|
try {
|
|
@@ -850,7 +1027,7 @@ bridge.onSubmit = async (text) => {
|
|
|
850
1027
|
bareMode: false,
|
|
851
1028
|
// Project instructions sourced from nehanda's own loadInstructions() (Ona.md),
|
|
852
1029
|
// matching bin/agent.mjs. Does NOT read agents.json exec_system_prompt — that
|
|
853
|
-
// field
|
|
1030
|
+
// legacy field was silently reintroducing conflicting tool-call
|
|
854
1031
|
// syntax (<tool_call><name>...) that fights orchestrate.mjs's [TOOL_CALL] format.
|
|
855
1032
|
onaInstructions: onaInstructionsContent || null,
|
|
856
1033
|
// Esc/Ctrl+C abort — must reach fetch / Anthropic stream (issue #7).
|
|
@@ -107,7 +107,7 @@ export async function discoverModelsForPreset(preset, opts = {}) {
|
|
|
107
107
|
* Interactive configure wizard.
|
|
108
108
|
*
|
|
109
109
|
* @param {object} deps
|
|
110
|
-
* @param {(q: string) => Promise<string>} deps.ask
|
|
110
|
+
* @param {(q: string, opts?: { secret?: boolean }) => Promise<string>} deps.ask
|
|
111
111
|
* @param {(title: string, items: object[]) => Promise<object|null>} deps.select
|
|
112
112
|
* @param {(msg: string) => void} [deps.notify]
|
|
113
113
|
* @param {(ctx: {preset, host, apiKey}) => Promise<{success, models?, error?}>} [deps.discover]
|
|
@@ -155,7 +155,8 @@ export async function runConfigureWizard(deps, opts = {}) {
|
|
|
155
155
|
])
|
|
156
156
|
if (!choice) return null
|
|
157
157
|
if (choice.id === 'replace') {
|
|
158
|
-
|
|
158
|
+
// {secret:true} → the TUI masks this answer in transcript history (HP-1b, #10)
|
|
159
|
+
const entered = await ask(`${preset.label} API key:`, { secret: true })
|
|
159
160
|
if (!entered) return null
|
|
160
161
|
apiKey = entered.trim()
|
|
161
162
|
keyDirty = true
|
|
@@ -163,7 +164,7 @@ export async function runConfigureWizard(deps, opts = {}) {
|
|
|
163
164
|
apiKey = existing
|
|
164
165
|
}
|
|
165
166
|
} else {
|
|
166
|
-
const entered = await ask(`${preset.label} API key
|
|
167
|
+
const entered = await ask(`${preset.label} API key:`, { secret: true })
|
|
167
168
|
if (!entered) return null
|
|
168
169
|
apiKey = entered.trim()
|
|
169
170
|
keyDirty = true
|