@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.
@@ -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
- // No aimee-server, no HTTP daemon, no cross-repo resolution.
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 AIMEE_DIR = path.join(os.homedir(), '.config', 'aimee')
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
- const agent = data.agents?.find(a => a.name === data.default_agent) || data.agents?.[0]
82
- if (agent) { agent.api_key = key; fs.writeFileSync(AGENTS_JSON, JSON.stringify(data, null, 2), 'utf8') }
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: '), e(Text, { color: BRAND.lavender, bold: true }, model || '—')),
233
- e(Text, {}, e(Text, { dimColor: true }, 'Endpoint: '), 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 = SLASH_COMMANDS.filter(c => c.name.startsWith(filter) || c.name.includes(filter.slice(1)))
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(12)), e(Text, { dimColor: true }, c.desc))))
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
- const showMenu = value.startsWith('/') && !value.includes(' ')
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(Text, {}, e(Text, { dimColor: true }, 'model: '), e(Text, { color: BRAND.lavender }, model || '—')),
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 = React.useRef(0)
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
- bridge.ask = q => new Promise(resolve => setAskState({ question: q, resolve }))
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
- if (askState?.resolve) { setFinalized(p => [...p, { role: 'system', text: askState.question + ' ' + answer }]); askState.resolve(answer) }
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
- (isLoading && (!streaming || !streaming.text)) || spinLabel ? e(Spinner, { label: spinLabel || undefined }) : null,
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
- const ag = data.agents?.find(a => a.name === data.default_agent) || data.agents?.[0]
511
- if (ag) {
512
- ag.model = cfg.model
513
- ag.endpoint = cfg.endpoint
514
- if (cfg.provider === 'nehanda' && cfg.apiKey) ag.api_key = cfg.apiKey
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
- bridge.addMessage({ role: 'system', text: '\nCommands:\n' +
545
- SLASH_COMMANDS.map(c => ` ${chalk.hex(BRAND.accent)(c.name.padEnd(12))} ${chalk.dim(c.desc)}`).join('\n') + '\n' })
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
- ` ${chalk.hex(BRAND.accent)('/mcp list'.padEnd(28))} ${chalk.dim('Discover and list all available MCP tools')}`,
627
- ` ${chalk.hex(BRAND.accent)('/mcp status'.padEnd(28))} ${chalk.dim('Show configured servers and command paths')}`,
628
- ` ${chalk.hex(BRAND.accent)('/mcp reload [server]'.padEnd(28))} ${chalk.dim('Reload mcp.json configs into session')}`,
629
- ` ${chalk.hex(BRAND.accent)('/mcp add <name> <cmd> […]'.padEnd(28))} ${chalk.dim('Register a new MCP server')}`,
630
- ` ${chalk.hex(BRAND.accent)('/mcp env [server] [K] [V]'.padEnd(28))} ${chalk.dim('View or set server environment variables')}`,
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 targetServer = parts[2]
721
- const envKey = parts[3]
722
- const envVal = parts[4]
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 (!targetServer) {
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 name of serverNames) {
732
- const cfg = servers[name]
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)(name)}: (none)`)
908
+ lines.push(` ${chalk.hex(BRAND.accent)(srv)}: (none)`)
736
909
  } else {
737
- lines.push(` ${chalk.hex(BRAND.accent)(name)}:`)
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 (!envKey) {
750
- // Show env for a specific server
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 "${targetServer}" not found.` })
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)(targetServer)}: (no env vars set)` })
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)(targetServer)}:`]
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
- // Set or clear env var
772
- const result = updateMcpServerEnv(targetServer, envKey, envVal || '', process.cwd())
773
- if (!result.ok) {
774
- bridge.addMessage({ role: 'system', text: ` ${chalk.hex(BRAND.red)('✗')} ${result.error}` })
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 [server] · add <name> <command> [args…] · env [server] [KEY [VALUE]]' })
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 = () => controller.abort()
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 belongs to aimee and was silently reintroducing conflicting tool-call
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
- const entered = await ask(`${preset.label} API key:`)
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