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 +1 -0
- package/commands/setup.mjs +9 -7
- package/commands/setup_channels.mjs +20 -7
- package/commands/setup_permission.mjs +17 -3
- package/lib/nl_config_command.mjs +19 -0
- package/package.json +1 -1
- package/tui/pickers.mjs +71 -73
- package/tui/prompt_back.mjs +85 -0
- package/tui/run_turn.mjs +7 -3
- package/tui/status_bar.mjs +22 -2
- package/tui/wizard_back.mjs +19 -0
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
|
package/commands/setup.mjs
CHANGED
|
@@ -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
|
|
174
|
-
//
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
322
|
-
|
|
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
|
-
|
|
326
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
const
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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')
|
|
553
|
-
|
|
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 {
|
|
224
|
-
|
|
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);
|
package/tui/status_bar.mjs
CHANGED
|
@@ -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 =
|
|
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
|
+
}
|