lazyclaw 6.8.0 → 6.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/commands/chat.mjs CHANGED
@@ -220,6 +220,7 @@ export async function cmdChat(flags = {}) {
220
220
  setMessages: (next) => { _inkMessages = Array.isArray(next) ? next : []; },
221
221
  getProv: () => prov,
222
222
  setProv: (next) => { prov = wrapInteractiveProv(next); },
223
+ lookupProv,
223
224
  getActiveProvName: () => activeProvName,
224
225
  // Persist provider/model picks so they survive a restart (was
225
226
  // in-memory only — a model chosen via /model reverted to cfg.model on
@@ -13,6 +13,8 @@ import {
13
13
  import { ensureRegistry, requireRegistry, getRegistry } from '../lib/registry_boot.mjs';
14
14
  import { SUBCOMMANDS, parseArgs, AGENT_REG_SUBS } from '../lib/args.mjs';
15
15
  import { runPermissionStep } from './setup_permission.mjs';
16
+ import { runWizardSteps } from '../tui/wizard_back.mjs';
17
+ import { promptWithBack } from '../tui/prompt_back.mjs';
16
18
  import {
17
19
  _attachGhostAutocomplete, _fetchModelsForProvider, _pauseChatForSubMenu,
18
20
  _pickModelInteractive, _pickProviderInteractive, _printChatBanner,
@@ -170,13 +172,13 @@ export async function cmdSetup(_sub, _positional, flags = {}) {
170
172
  }
171
173
  process.stdout.write(`\n ${ok('✓ provider:')} ${cfgAfterOnboard.provider} ${dim('model:')} ${cfgAfterOnboard.model || '(default)'}\n\n`);
172
174
 
173
- // Context window asked right after the model pick (optional; Enter keeps
174
- // defaults). Not a numbered step: it's part of the core model setup.
175
- await runContextStep({ prompt: _quickPrompt, colors });
176
-
177
- // Tool-permission mode (cfg.chat.permissionMode) part of the core chat
178
- // setup. Extracted to ./setup_permission.mjs (file-size gate).
179
- await runPermissionStep({ prompt: _quickPrompt, colors, cfg: cfgAfterOnboard });
175
+ // Context window + tool-permission mode core chat setup, looped so Esc on
176
+ // permission goes back one step to the context window (and Esc on the custom
177
+ // entries goes back too). Part of the model setup, not numbered steps.
178
+ const _backPrompt = (label) => promptWithBack(label);
179
+ await runWizardSteps(['context', 'permission'], (id) => (id === 'context'
180
+ ? runContextStep({ prompt: _quickPrompt, backPrompt: _backPrompt, colors })
181
+ : runPermissionStep({ prompt: _quickPrompt, backPrompt: _backPrompt, colors, cfg: cfgAfterOnboard })));
180
182
  }
181
183
 
182
184
  // ── Step 2/7: Verify one clean chat works ───────────────────
@@ -303,10 +303,12 @@ export async function runWebhookStep({ prompt, colors, write = (s) => process.st
303
303
  // Context-window step (asked right after the model pick): how much past
304
304
  // conversation to keep each turn — a sliding history budget, NOT the model's
305
305
  // hard limit. Enter on both prompts keeps the defaults.
306
- export async function runContextStep({ prompt, colors, write = (s) => process.stdout.write(s) }) {
306
+ // Returns 'BACK' (Esc) / 'NEXT' for the wizard back-loop. backPrompt
307
+ // (promptWithBack) makes Esc reachable on the custom number entry.
308
+ export async function runContextStep({ prompt, backPrompt, colors, write = (s) => process.stdout.write(s) }) {
307
309
  const { dim, ok } = colors;
308
310
  const cf = await import('../config_features.mjs');
309
- const { _pickChoice } = await import('../tui/pickers.mjs');
311
+ const { _arrowMenu } = await import('../tui/pickers.mjs');
310
312
  const cur = cf.chatWindowGet(readConfig());
311
313
  write(` ${dim(`Context window — how much past conversation to send each turn (sliding budget, not the model's hard limit). Now: ${cur.turns} turns · ${cur.tokens} tokens.`)}\n\n`);
312
314
  // Pick a preset (or "custom" → type the two numbers). Presets cover the
@@ -318,12 +320,23 @@ export async function runContextStep({ prompt, colors, write = (s) => process.st
318
320
  { id: 'large', label: 'Large — 80 turns · 32k tokens', desc: 'more context, costs more', turns: 80, tokens: 32000 },
319
321
  { id: 'custom', label: 'Custom…', desc: 'type your own turns + token budget' },
320
322
  ];
321
- const choice = await _pickChoice('Context window', PRESETS, { subtitle: 'how much past conversation to send each turn', fallback: 'keep' });
322
- if (choice === 'keep') { write(` ${dim('— kept defaults —')}\n\n`); return { skipped: true }; }
323
+ // _arrowMenu (not _pickChoice) so Esc is distinguishable as BACK, not folded
324
+ // into the "keep" fallback.
325
+ const picked = await _arrowMenu({ title: 'Context window', subtitle: 'Esc to go back · how much past conversation to send each turn', items: PRESETS });
326
+ if (picked === 'BACK') return 'BACK';
327
+ const choice = (picked === 'CANCEL' || picked == null) ? 'keep' : (typeof picked === 'object' ? picked.id : picked);
328
+ if (choice === 'keep') { write(` ${dim('— kept defaults —')}\n\n`); return 'NEXT'; }
323
329
  let turns, tokens;
324
330
  if (choice === 'custom') {
325
- turns = parseInt((await prompt(` turns to keep (Enter = ${cur.turns}): `)).trim(), 10);
326
- tokens = parseInt((await prompt(` token budget (Enter = ${cur.tokens}): `)).trim(), 10);
331
+ if (typeof backPrompt === 'function') {
332
+ const t = await backPrompt(` turns to keep (Esc = back · Enter = ${cur.turns}): `); if (t && t.back) return 'BACK';
333
+ const k = await backPrompt(` token budget (Esc = back · Enter = ${cur.tokens}): `); if (k && k.back) return 'BACK';
334
+ turns = parseInt(String((t && t.value) || '').trim(), 10);
335
+ tokens = parseInt(String((k && k.value) || '').trim(), 10);
336
+ } else {
337
+ turns = parseInt((await prompt(` turns to keep (Enter = ${cur.turns}): `)).trim(), 10);
338
+ tokens = parseInt((await prompt(` token budget (Enter = ${cur.tokens}): `)).trim(), 10);
339
+ }
327
340
  } else {
328
341
  const p = PRESETS.find((x) => x.id === choice);
329
342
  turns = p ? p.turns : NaN;
@@ -338,7 +351,7 @@ export async function runContextStep({ prompt, colors, write = (s) => process.st
338
351
  const w = cf.chatWindowGet(cfg);
339
352
  write(` ${ok('✓ context window:')} ${w.turns} turns · ${w.tokens} tokens\n`);
340
353
  write(` ${dim('change later: /context turns <N> | tokens <N>')}\n\n`);
341
- return { skipped: false, ...w };
354
+ return 'NEXT';
342
355
  }
343
356
 
344
357
  // Optional orchestration step: enable the multi-agent planner+workers pipeline.
@@ -5,21 +5,34 @@
5
5
  // Asks how the claude-cli agent should handle tool permissions and stores the
6
6
  // choice at cfg.chat.permissionMode. lib/permission_mode.resolvePermissionMode
7
7
  // reads it for every claude spawn; unset defaults to bypass (no nagging).
8
+ //
9
+ // Returns 'BACK' when the user pressed Esc (the wizard re-runs the previous
10
+ // step), else 'NEXT'. A `backPrompt` (tui/prompt_back.promptWithBack) makes Esc
11
+ // reachable; without it we fall back to the plain string `prompt` (no Esc).
8
12
 
9
13
  import { readConfig, writeConfig } from '../lib/config.mjs';
10
14
  import { parsePermissionChoice } from '../lib/permission_mode.mjs';
11
15
 
12
- export async function runPermissionStep({ prompt, colors, cfg } = {}) {
16
+ export async function runPermissionStep({ prompt, backPrompt, colors, cfg } = {}) {
13
17
  const accent = (colors && colors.accent) || ((s) => s);
14
18
  const dim = (colors && colors.dim) || ((s) => s);
15
19
  const ok = (colors && colors.ok) || ((s) => s);
16
20
  const provider = String((cfg || readConfig()).provider || '');
17
21
  // Only relevant to the claude-cli agent path (the one that spawns `claude`).
18
- if (!/claude/.test(provider)) return;
22
+ if (!/claude/.test(provider)) return 'NEXT';
19
23
 
20
24
  process.stdout.write(` ${dim('Tool permissions — when the agent edits files or runs commands:')}\n`);
21
25
  process.stdout.write(` ${dim(' bypass = never ask (fast) · ask = prompt each time · acceptEdits = auto-accept edits only · plan = read-only')}\n`);
22
- const mode = parsePermissionChoice(await prompt(` ${accent('permission')} [bypass/ask/acceptEdits/plan] ${dim('(Enter = bypass)')}: `));
26
+ const label = ` ${accent('permission')} [bypass/ask/acceptEdits/plan] ${dim('(Esc = back · Enter = bypass)')}: `;
27
+ let answer;
28
+ if (typeof backPrompt === 'function') {
29
+ const r = await backPrompt(label);
30
+ if (r && r.back) return 'BACK';
31
+ answer = r ? r.value : '';
32
+ } else {
33
+ answer = await prompt(label);
34
+ }
35
+ const mode = parsePermissionChoice(answer);
23
36
  if (mode) {
24
37
  const c = readConfig();
25
38
  c.chat = { ...(c.chat || {}), permissionMode: mode };
@@ -28,4 +41,5 @@ export async function runPermissionStep({ prompt, colors, cfg } = {}) {
28
41
  } else {
29
42
  process.stdout.write(` ${dim('(unrecognised — kept the current setting)')}\n\n`);
30
43
  }
44
+ return 'NEXT';
31
45
  }
@@ -101,3 +101,22 @@ export function applyConfigCommand(intent, deps) {
101
101
  deps.writeConfig(cfg); syncCtx();
102
102
  return `✓ Workers → [\`${spec}\`].`;
103
103
  }
104
+
105
+ // After an orchestrator on/off flips cfg.provider, re-point the REPL's LIVE
106
+ // provider state from cfg — otherwise the status bar AND the next turn keep
107
+ // using the old provider (the bug: "orchestrator off" but it still replies as
108
+ // orchestrator). The HUD reads activeProvName and run_turn reads ctx.getProv(),
109
+ // neither of which follows cfg.provider on its own. Best-effort; safe to call
110
+ // from any host (missing setters are skipped).
111
+ export function refreshLiveProvider(ctx) {
112
+ if (!ctx || !ctx.cfg) return;
113
+ const name = ctx.cfg.provider;
114
+ if (!name) return;
115
+ try { if (typeof ctx.setActiveProvName === 'function') ctx.setActiveProvName(name); } catch { /* ignore */ }
116
+ try {
117
+ const lookup = ctx.lookupProv || (ctx.registryMod && ((n) => ctx.registryMod.PROVIDERS && ctx.registryMod.PROVIDERS[n]));
118
+ const p = lookup && lookup(name);
119
+ if (p && typeof ctx.setProv === 'function') ctx.setProv(p);
120
+ } catch { /* ignore */ }
121
+ try { if (typeof ctx.setActiveModel === 'function') ctx.setActiveModel(ctx.cfg.model || null); } catch { /* ignore */ }
122
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lazyclaw",
3
- "version": "6.8.0",
3
+ "version": "6.9.0",
4
4
  "description": "Lazy, elegant terminal CLI for chatting with Claude / OpenAI / Gemini / Ollama, orchestrating multi-step LLM workflows, and running multi-agent Slack teams with cross-task memory. Banner-on-launch, slash-command ghost autocomplete, persistent sessions, local HTTP gateway.",
5
5
  "keywords": [
6
6
  "claude",
package/tui/pickers.mjs CHANGED
@@ -499,84 +499,82 @@ export async function _pickProviderInteractive() {
499
499
  }
500
500
 
501
501
  // ── Step 2 — provider in that family ──────────────────────────
502
- let provider = null;
503
- while (!provider) {
504
- const memberNames = families[family.id].members;
505
- const provItems = memberNames.map((name) => {
506
- const meta = info[name] || {};
507
- const isCustom = !!meta.custom;
508
- const isBuiltinCompat = !!meta.builtinOpenAICompat;
509
- // Step-2 desc used to preview four suggested model ids per provider.
510
- // That made the row read like "gemini · models: gemini-2.5-pro ·
511
- // gemini-2.5-flash · gemini-2.0-flash · gemini-2.0-flash-thinking-exp",
512
- // which is too dense and partly redundant — step 3 already shows the
513
- // full curated list. Keep the row to a vendor label + endpoint hint.
514
- let desc = '';
515
- if (isCustom) desc = `custom · ${meta.baseUrl || ''}`;
516
- else if (isBuiltinCompat) desc = meta.label || meta.baseUrl || '';
517
- else if (meta.label && meta.label !== name) desc = meta.label;
518
- return {
519
- id: name,
520
- label: name,
521
- desc,
522
- tag: isCustom
523
- ? paint('38;5;213', '[custom]')
524
- : (meta.requiresApiKey ? paint('38;5;245', '[api key]') : paint('38;5;208', '[no key]')),
525
- };
526
- });
527
- // Surface a "+ Add a new custom endpoint…" entry inside the API-key
528
- // family. NIM, OpenRouter, vLLM, LM Studio, Together, Groq, etc. all
529
- // speak the OpenAI Chat-Completions wire format — this single hook
530
- // covers every one of them without shipping a per-vendor provider.
531
- if (family.id === 'api') {
532
- provItems.push({
533
- id: '__add_custom__',
534
- label: '+ Add a custom OpenAI-compatible endpoint…',
535
- desc: 'NVIDIA NIM · OpenRouter · Together · Groq · vLLM · LM Studio · …',
536
- tag: paint('38;5;213', '[new]'),
502
+ for (;;) { // loop Steps 2+3: Esc in Step 3 returns to Step 2, not Step 1
503
+ let provider = null;
504
+ while (!provider) {
505
+ const memberNames = families[family.id].members;
506
+ const provItems = memberNames.map((name) => {
507
+ const meta = info[name] || {};
508
+ const isCustom = !!meta.custom;
509
+ const isBuiltinCompat = !!meta.builtinOpenAICompat;
510
+ // Step-2 row: vendor label + endpoint hint only (Step 3 shows models).
511
+ let desc = '';
512
+ if (isCustom) desc = `custom · ${meta.baseUrl || ''}`;
513
+ else if (isBuiltinCompat) desc = meta.label || meta.baseUrl || '';
514
+ else if (meta.label && meta.label !== name) desc = meta.label;
515
+ return {
516
+ id: name,
517
+ label: name,
518
+ desc,
519
+ tag: isCustom
520
+ ? paint('38;5;213', '[custom]')
521
+ : (meta.requiresApiKey ? paint('38;5;245', '[api key]') : paint('38;5;208', '[no key]')),
522
+ };
537
523
  });
524
+ // Surface a "+ Add a new custom endpoint…" entry inside the API-key
525
+ // family. NIM, OpenRouter, vLLM, LM Studio, Together, Groq, etc. all
526
+ // speak the OpenAI Chat-Completions wire format — this single hook
527
+ // covers every one of them without shipping a per-vendor provider.
528
+ if (family.id === 'api') {
529
+ provItems.push({
530
+ id: '__add_custom__',
531
+ label: '+ Add a custom OpenAI-compatible endpoint…',
532
+ desc: 'NVIDIA NIM · OpenRouter · Together · Groq · vLLM · LM Studio · …',
533
+ tag: paint('38;5;213', '[new]'),
534
+ });
535
+ }
536
+ if (memberNames.length === 1 && family.id !== 'api') {
537
+ // Auto-advance — no point making the user pick from a single row,
538
+ // unless we just appended the "+ Add custom" entry above.
539
+ provider = { id: memberNames[0] };
540
+ break;
541
+ }
542
+ const picked = await _arrowMenu({
543
+ title: `LazyClaw setup — Step 2 of 3: pick a ${family.label} provider`,
544
+ subtitle: `Showing ${provItems.length} ${family.label.toLowerCase()} option(s). Type to filter.`,
545
+ items: provItems,
546
+ searchable: true,
547
+ });
548
+ if (picked === 'CANCEL') return null;
549
+ if (picked === 'BACK') { family = null; return _pickProviderInteractive(); }
550
+ if (picked && picked.id === '__add_custom__') {
551
+ const added = await _addCustomProviderInteractive();
552
+ if (!added) continue; // back to provider list
553
+ // Force the registry to pick up the new entry and recompute the
554
+ // family bucket for the next loop iteration.
555
+ await ensureRegistry();
556
+ Object.assign(families, _providerFamilies());
557
+ provider = { id: added.name };
558
+ break;
559
+ }
560
+ provider = picked;
538
561
  }
539
- if (memberNames.length === 1 && family.id !== 'api') {
540
- // Auto-advance no point making the user pick from a single row,
541
- // unless we just appended the "+ Add custom" entry above.
542
- provider = { id: memberNames[0] };
543
- break;
544
- }
545
- const picked = await _arrowMenu({
546
- title: `LazyClaw setup — Step 2 of 3: pick a ${family.label} provider`,
547
- subtitle: `Showing ${provItems.length} ${family.label.toLowerCase()} option(s). Type to filter.`,
548
- items: provItems,
549
- searchable: true,
562
+
563
+ // ── Step 3 model picker ────────────────────────────────────────
564
+ // v5.3.2 the setup wizard no longer surfaces composite providers
565
+ // (orchestrator is filtered out of _providerFamilies above), so this
566
+ // step is just the regular model picker. The orchestrator wizard
567
+ // (_setupOrchestratorInteractive) stays reachable via the dedicated
568
+ // `lazyclaw orchestrator …` subcommand and an explicit
569
+ // `--provider orchestrator` invocation.
570
+ const picked = await _pickModelInteractive(provider.id, {
571
+ titlePrefix: 'LazyClaw setup — Step 3 of 3:',
572
+ onBack: 'restart',
550
573
  });
551
574
  if (picked === 'CANCEL') return null;
552
- if (picked === 'BACK') { family = null; return _pickProviderInteractive(); }
553
- if (picked && picked.id === '__add_custom__') {
554
- const added = await _addCustomProviderInteractive();
555
- if (!added) continue; // back to provider list
556
- // Force the registry to pick up the new entry and recompute the
557
- // family bucket for the next loop iteration.
558
- await ensureRegistry();
559
- Object.assign(families, _providerFamilies());
560
- provider = { id: added.name };
561
- break;
562
- }
563
- provider = picked;
575
+ if (picked === 'BACK') continue; // Esc in Step 3 -> back to Step 2 (one step)
576
+ return { provider: provider.id, model: picked };
564
577
  }
565
-
566
- // ── Step 3 — model picker ────────────────────────────────────────
567
- // v5.3.2 — the setup wizard no longer surfaces composite providers
568
- // (orchestrator is filtered out of _providerFamilies above), so this
569
- // step is just the regular model picker. The orchestrator wizard
570
- // (_setupOrchestratorInteractive) stays reachable via the dedicated
571
- // `lazyclaw orchestrator …` subcommand and an explicit
572
- // `--provider orchestrator` invocation.
573
- const picked = await _pickModelInteractive(provider.id, {
574
- titlePrefix: 'LazyClaw setup — Step 3 of 3:',
575
- onBack: 'restart',
576
- });
577
- if (picked === 'CANCEL') return null;
578
- if (picked === 'BACK') return _pickProviderInteractive();
579
- return { provider: provider.id, model: picked };
580
578
  }
581
579
 
582
580
  // _setupOrchestratorInteractive moved to tui/orchestrator_setup.mjs (it imports
@@ -0,0 +1,85 @@
1
+ // tui/prompt_back.mjs — a raw-mode line prompt that can return BACK on Esc.
2
+ //
3
+ // The setup wizard's typed questions used cooked-mode readline, which never
4
+ // sees Esc (it only resolves on Enter), so "Esc to go back a step" did nothing.
5
+ // This reads keys in raw mode: Enter submits, Esc goes back, arrow keys (which
6
+ // are Esc-PREFIXED sequences) are ignored, Backspace edits, Ctrl-C cancels.
7
+ //
8
+ // classifyKey is pure (unit-tested); promptWithBack is driven by an injectable
9
+ // input/output so it's testable with a fake TTY (no real keyboard).
10
+
11
+ // Map a raw input chunk to an action. A LONE Esc is "back"; an Esc that begins
12
+ // an escape sequence (\x1b[ or \x1bO — arrows, function keys) is "escseq" and
13
+ // must be ignored so navigation keys don't fire "back".
14
+ export function classifyKey(chunk) {
15
+ const s = String(chunk == null ? '' : chunk);
16
+ if (s === '\x1b') return { type: 'back' };
17
+ if (/^\x1b[[O]/.test(s)) return { type: 'escseq' };
18
+ if (s === '\r' || s === '\n' || s === '\r\n') return { type: 'submit' };
19
+ if (s === '\x03') return { type: 'cancel' }; // Ctrl-C
20
+ if (s === '\x7f' || s === '\b') return { type: 'backspace' };
21
+ const text = s.replace(/[\x00-\x1f\x7f]/g, ''); // strip control bytes
22
+ return text ? { type: 'text', text } : { type: 'ignore' };
23
+ }
24
+
25
+ // Prompt for a line, resolving { value, back }. `back` is true when the user
26
+ // pressed Esc. Falls back to a plain (no-Esc) readline on a non-TTY input.
27
+ // escDelayMs disambiguates a lone Esc from an Esc-sequence that arrives split
28
+ // across two data events (rare, but some terminals do it).
29
+ export function promptWithBack(label, opts = {}) {
30
+ const input = opts.input || process.stdin;
31
+ const output = opts.output || process.stdout;
32
+ const escDelayMs = opts.escDelayMs == null ? 40 : opts.escDelayMs;
33
+
34
+ if (!(input.isTTY && typeof input.setRawMode === 'function')) {
35
+ return _plainLine(label, input, output);
36
+ }
37
+
38
+ return new Promise((resolve) => {
39
+ output.write('\n' + label);
40
+ try { input.setRawMode(true); } catch { /* ignore */ }
41
+ input.resume();
42
+ let buf = '';
43
+ let escTimer = null;
44
+ const finish = (result) => {
45
+ if (escTimer) { clearTimeout(escTimer); escTimer = null; }
46
+ input.off('data', onData);
47
+ try { input.setRawMode(false); } catch { /* ignore */ }
48
+ output.write('\n');
49
+ resolve(result);
50
+ };
51
+ const onData = (d) => {
52
+ const s = d.toString('utf8');
53
+ // A lone Esc byte: wait briefly — if a [ / O follows it was an arrow/nav
54
+ // sequence (ignore), otherwise it's a real Esc (back).
55
+ if (s === '\x1b') {
56
+ escTimer = setTimeout(() => { escTimer = null; finish({ value: '', back: true }); }, escDelayMs);
57
+ return;
58
+ }
59
+ if (escTimer) {
60
+ clearTimeout(escTimer); escTimer = null;
61
+ if (/^[[O]/.test(s)) return; // the tail of a split Esc-sequence
62
+ }
63
+ const k = classifyKey(s);
64
+ switch (k.type) {
65
+ case 'escseq': case 'ignore': return;
66
+ case 'back': return finish({ value: '', back: true });
67
+ case 'submit': return finish({ value: buf.trim(), back: false });
68
+ case 'cancel': finish({ value: '', back: false, cancel: true }); try { process.exit(130); } catch { /* tests */ } return;
69
+ case 'backspace': if (buf) { buf = buf.slice(0, -1); output.write('\b \b'); } return;
70
+ case 'text': buf += k.text; output.write(k.text); return;
71
+ default: return;
72
+ }
73
+ };
74
+ input.on('data', onData);
75
+ });
76
+ }
77
+
78
+ async function _plainLine(label, input, output) {
79
+ const readline = await import('node:readline');
80
+ output.write('\n');
81
+ const rl = readline.createInterface({ input, output });
82
+ const value = await new Promise((res) => rl.question(label, res));
83
+ rl.close();
84
+ return { value: String(value).trim(), back: false };
85
+ }
package/tui/run_turn.mjs CHANGED
@@ -29,7 +29,7 @@ import { Chalk } from 'chalk';
29
29
  import { chatAgenticGet, chatPlanModeGet, effectiveChatTools } from '../config_features.mjs';
30
30
  import { defaultSandboxSpec } from '../sandbox/index.mjs';
31
31
  import { resolvePermissionMode } from '../lib/permission_mode.mjs';
32
- import { detectConfigCommand, applyConfigCommand } from '../lib/nl_config_command.mjs';
32
+ import { detectConfigCommand, applyConfigCommand, refreshLiveProvider } from '../lib/nl_config_command.mjs';
33
33
  import { readConfig as _readCfg, writeConfig as _writeCfg } from '../lib/config.mjs';
34
34
 
35
35
  // Force ANSI on these turn-status markers regardless of stdout TTY detection:
@@ -220,8 +220,12 @@ export function makeRunTurn({ ctx, writeFn }) {
220
220
  _msgs.push({ role: 'user', content: text });
221
221
  ctx.persistTurn('user', text);
222
222
  let reply;
223
- try { reply = applyConfigCommand(_cmd, { readConfig: _readCfg, writeConfig: _writeCfg, ctxCfg: ctx.cfg }); }
224
- catch (e) { reply = `⚠ couldn't apply that change: ${e?.message || e}`; }
223
+ try {
224
+ reply = applyConfigCommand(_cmd, { readConfig: _readCfg, writeConfig: _writeCfg, ctxCfg: ctx.cfg });
225
+ // orchestrator on/off flips the active provider — re-point the live REPL
226
+ // provider so it stops replying as orchestrator + the HUD refreshes.
227
+ if (_cmd.kind === 'orchestrator') refreshLiveProvider(ctx);
228
+ } catch (e) { reply = `⚠ couldn't apply that change: ${e?.message || e}`; }
225
229
  try { writeFn(reply + '\n'); } catch { /* sink best-effort */ }
226
230
  _msgs.push({ role: 'assistant', content: reply });
227
231
  ctx.persistTurn('assistant', reply);
@@ -4,16 +4,36 @@
4
4
  // Extracted from repl.mjs so the HUD can grow without pushing repl.mjs over the
5
5
  // file-size ratchet.
6
6
 
7
- import React from 'react';
7
+ import React, { useState, useEffect } from 'react';
8
8
  import { Box, Text } from 'ink';
9
9
  import { theme } from './theme.mjs';
10
10
  import { formatHudRow, formatGauge } from './hud.mjs';
11
11
 
12
+ // How fast the streaming dot pulses, in ms. Exported so it's not a magic number.
13
+ export const BLINK_MS = 450;
14
+
15
+ // The leading status glyph. While streaming, the dot pulses (bright ↔ dim) so
16
+ // there's a live "something is happening" signal; idle is a steady hollow dot.
17
+ // Pure (takes the current blink phase) so it's unit-testable without a timer.
18
+ export function streamingIndicator(streaming, blinkOn, t = theme) {
19
+ if (!streaming) return t.dim('○ idle');
20
+ return blinkOn ? t.accent('● streaming') : t.dim('● streaming');
21
+ }
22
+
12
23
  export function StatusBar({ provider, model, streaming, ctxUsed, ctxTotal, hud }) {
24
+ // Pulse the streaming dot. The interval only runs while streaming and is torn
25
+ // down as soon as the turn ends (or the bar unmounts).
26
+ const [blinkOn, setBlinkOn] = useState(true);
27
+ useEffect(() => {
28
+ if (!streaming) { setBlinkOn(true); return undefined; }
29
+ const id = setInterval(() => setBlinkOn((b) => !b), BLINK_MS);
30
+ return () => clearInterval(id);
31
+ }, [streaming]);
32
+
13
33
  // Numbers are computed upstream (chat-history budget, not provider self-report);
14
34
  // formatGauge only changes the RENDERING — adds percent + bar + warn marker.
15
35
  const ctx = (ctxUsed != null && ctxTotal != null) ? formatGauge(ctxUsed, ctxTotal) : '--';
16
- const indicator = streaming ? theme.accent('● streaming') : theme.dim('○ idle');
36
+ const indicator = streamingIndicator(streaming, blinkOn);
17
37
  const prov = provider || '?';
18
38
  const mdl = model || '?';
19
39
  const hudRow = hud ? formatHudRow(hud) : '';
@@ -0,0 +1,19 @@
1
+ // tui/wizard_back.mjs — drive a sequence of setup-wizard steps with Esc-back.
2
+ //
3
+ // Each step runs its own I/O and returns one of:
4
+ // 'BACK' — the user pressed Esc → re-run the previous step
5
+ // 'CANCEL' — abort the whole group
6
+ // (anything else) — advance to the next step
7
+ // Esc on the FIRST step stays on it (there's nothing before it in the group).
8
+ // Pure control flow (the steps do the prompting) so it's unit-testable.
9
+
10
+ export async function runWizardSteps(stepIds, runStep) {
11
+ let i = 0;
12
+ while (i < stepIds.length) {
13
+ const r = await runStep(stepIds[i], i);
14
+ if (r === 'CANCEL') return 'CANCEL';
15
+ if (r === 'BACK') { i = i > 0 ? i - 1 : 0; continue; }
16
+ i += 1;
17
+ }
18
+ return 'DONE';
19
+ }