lazyclaw 6.7.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 +4 -5
- package/commands/setup.mjs +10 -10
- package/commands/setup_channels.mjs +20 -7
- package/commands/setup_permission.mjs +45 -0
- package/lib/nl_config_command.mjs +122 -0
- package/lib/permission_mode.mjs +36 -0
- package/package.json +1 -1
- package/providers/claude_cli.mjs +5 -1
- package/providers/claude_cli_session.mjs +1 -0
- package/tui/pickers.mjs +71 -73
- package/tui/prompt_back.mjs +85 -0
- package/tui/run_turn.mjs +29 -0
- package/tui/status_bar.mjs +22 -2
- package/tui/wizard_back.mjs +19 -0
package/commands/chat.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from '../lib/config.mjs';
|
|
12
12
|
import { ensureRegistry, requireRegistry, getRegistry } from '../lib/registry_boot.mjs';
|
|
13
13
|
import { SUBCOMMANDS, parseArgs, AGENT_REG_SUBS } from '../lib/args.mjs';
|
|
14
|
+
import { LAZYCLAW_META_GUARD } from '../lib/nl_config_command.mjs';
|
|
14
15
|
import {
|
|
15
16
|
_attachGhostAutocomplete, _fetchModelsForProvider,
|
|
16
17
|
_pickProviderInteractive, _printChatBanner,
|
|
@@ -176,7 +177,7 @@ export async function cmdChat(flags = {}) {
|
|
|
176
177
|
} catch (err) { console.error(`skill error: ${err.message}`); process.exit(2); }
|
|
177
178
|
}
|
|
178
179
|
if (_inkSysParts.length && !_inkMessages.some((m) => m.role === 'system')) {
|
|
179
|
-
const merged = _inkSysParts.join('\n\n---\n\n');
|
|
180
|
+
const merged = [..._inkSysParts, LAZYCLAW_META_GUARD].join('\n\n---\n\n');
|
|
180
181
|
_inkMessages.unshift({ role: 'system', content: merged });
|
|
181
182
|
if (_inkSessionId) sessionsMod.appendTurn(_inkSessionId, 'system', merged, _inkCfgDir);
|
|
182
183
|
}
|
|
@@ -219,6 +220,7 @@ export async function cmdChat(flags = {}) {
|
|
|
219
220
|
setMessages: (next) => { _inkMessages = Array.isArray(next) ? next : []; },
|
|
220
221
|
getProv: () => prov,
|
|
221
222
|
setProv: (next) => { prov = wrapInteractiveProv(next); },
|
|
223
|
+
lookupProv,
|
|
222
224
|
getActiveProvName: () => activeProvName,
|
|
223
225
|
// Persist provider/model picks so they survive a restart (was
|
|
224
226
|
// in-memory only — a model chosen via /model reverted to cfg.model on
|
|
@@ -455,7 +457,7 @@ export async function cmdChat(flags = {}) {
|
|
|
455
457
|
}
|
|
456
458
|
}
|
|
457
459
|
if (sysParts.length && !messages.some(m => m.role === 'system')) {
|
|
458
|
-
const merged = sysParts.join('\n\n---\n\n');
|
|
460
|
+
const merged = [...sysParts, LAZYCLAW_META_GUARD].join('\n\n---\n\n');
|
|
459
461
|
messages.unshift({ role: 'system', content: merged });
|
|
460
462
|
if (sessionId) sessionsMod.appendTurn(sessionId, 'system', merged, cfgDir);
|
|
461
463
|
}
|
|
@@ -651,11 +653,8 @@ export async function cmdChat(flags = {}) {
|
|
|
651
653
|
// listener from a previous run doesn't crash the launch with EADDRINUSE.
|
|
652
654
|
// Mirrors the Python server's auto-kill behaviour described in CLAUDE.md.
|
|
653
655
|
|
|
654
|
-
|
|
655
|
-
|
|
656
656
|
// sandbox subcommands — list/test/add/use (Phase D).
|
|
657
657
|
|
|
658
|
-
|
|
659
658
|
// Interactive launcher — fired when the user types `lazyclaw` with
|
|
660
659
|
// no subcommand AND we're attached to a TTY. OpenClaw's launcher
|
|
661
660
|
// pattern: ASCII banner + provider/model status + arrow-key menu of
|
package/commands/setup.mjs
CHANGED
|
@@ -12,6 +12,9 @@ import {
|
|
|
12
12
|
} from '../lib/config.mjs';
|
|
13
13
|
import { ensureRegistry, requireRegistry, getRegistry } from '../lib/registry_boot.mjs';
|
|
14
14
|
import { SUBCOMMANDS, parseArgs, AGENT_REG_SUBS } from '../lib/args.mjs';
|
|
15
|
+
import { runPermissionStep } from './setup_permission.mjs';
|
|
16
|
+
import { runWizardSteps } from '../tui/wizard_back.mjs';
|
|
17
|
+
import { promptWithBack } from '../tui/prompt_back.mjs';
|
|
15
18
|
import {
|
|
16
19
|
_attachGhostAutocomplete, _fetchModelsForProvider, _pauseChatForSubMenu,
|
|
17
20
|
_pickModelInteractive, _pickProviderInteractive, _printChatBanner,
|
|
@@ -94,10 +97,6 @@ export async function cmdOnboard(flags) {
|
|
|
94
97
|
console.log(JSON.stringify({ ok: true, written: configPath(), provider: next.provider, model: next.model || null, hasApiKey: !!next['api-key'] }));
|
|
95
98
|
}
|
|
96
99
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
100
|
export function cmdHelp(name) {
|
|
102
101
|
if (!name) {
|
|
103
102
|
process.stdout.write('lazyclaw — terminal AI assistant + workflow engine\n\n');
|
|
@@ -118,8 +117,6 @@ export function cmdHelp(name) {
|
|
|
118
117
|
process.stdout.write(detail + '\n');
|
|
119
118
|
}
|
|
120
119
|
|
|
121
|
-
|
|
122
|
-
|
|
123
120
|
export async function cmdSetup(_sub, _positional, flags = {}) {
|
|
124
121
|
await ensureRegistry();
|
|
125
122
|
const accent = (s) => `\x1b[38;2;217;179;90m${s}\x1b[0m`;
|
|
@@ -175,9 +172,13 @@ export async function cmdSetup(_sub, _positional, flags = {}) {
|
|
|
175
172
|
}
|
|
176
173
|
process.stdout.write(`\n ${ok('✓ provider:')} ${cfgAfterOnboard.provider} ${dim('model:')} ${cfgAfterOnboard.model || '(default)'}\n\n`);
|
|
177
174
|
|
|
178
|
-
// Context window
|
|
179
|
-
//
|
|
180
|
-
|
|
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 })));
|
|
181
182
|
}
|
|
182
183
|
|
|
183
184
|
// ── Step 2/7: Verify one clean chat works ───────────────────
|
|
@@ -703,4 +704,3 @@ export async function cmdLauncher() {
|
|
|
703
704
|
process.exit(0);
|
|
704
705
|
}
|
|
705
706
|
|
|
706
|
-
|
|
@@ -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.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// commands/setup_permission.mjs — the setup-wizard "tool permissions" step,
|
|
2
|
+
// extracted from commands/setup.mjs to stay under the file-size gate (D8) and
|
|
3
|
+
// to match the other modular steps (runContextStep / runChannelStep / …).
|
|
4
|
+
//
|
|
5
|
+
// Asks how the claude-cli agent should handle tool permissions and stores the
|
|
6
|
+
// choice at cfg.chat.permissionMode. lib/permission_mode.resolvePermissionMode
|
|
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).
|
|
12
|
+
|
|
13
|
+
import { readConfig, writeConfig } from '../lib/config.mjs';
|
|
14
|
+
import { parsePermissionChoice } from '../lib/permission_mode.mjs';
|
|
15
|
+
|
|
16
|
+
export async function runPermissionStep({ prompt, backPrompt, colors, cfg } = {}) {
|
|
17
|
+
const accent = (colors && colors.accent) || ((s) => s);
|
|
18
|
+
const dim = (colors && colors.dim) || ((s) => s);
|
|
19
|
+
const ok = (colors && colors.ok) || ((s) => s);
|
|
20
|
+
const provider = String((cfg || readConfig()).provider || '');
|
|
21
|
+
// Only relevant to the claude-cli agent path (the one that spawns `claude`).
|
|
22
|
+
if (!/claude/.test(provider)) return 'NEXT';
|
|
23
|
+
|
|
24
|
+
process.stdout.write(` ${dim('Tool permissions — when the agent edits files or runs commands:')}\n`);
|
|
25
|
+
process.stdout.write(` ${dim(' bypass = never ask (fast) · ask = prompt each time · acceptEdits = auto-accept edits only · plan = read-only')}\n`);
|
|
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);
|
|
36
|
+
if (mode) {
|
|
37
|
+
const c = readConfig();
|
|
38
|
+
c.chat = { ...(c.chat || {}), permissionMode: mode };
|
|
39
|
+
writeConfig(c);
|
|
40
|
+
process.stdout.write(` ${ok('✓ permission mode:')} ${mode}\n\n`);
|
|
41
|
+
} else {
|
|
42
|
+
process.stdout.write(` ${dim('(unrecognised — kept the current setting)')}\n\n`);
|
|
43
|
+
}
|
|
44
|
+
return 'NEXT';
|
|
45
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// lib/nl_config_command.mjs — turn a few high-signal natural-language requests
|
|
2
|
+
// into REAL lazyclaw config changes, so the chat model can't just reply
|
|
3
|
+
// "done" without anything happening (the user's bug: orchestrator on/off and
|
|
4
|
+
// planner/worker model swaps typed in chat were hallucinated as success).
|
|
5
|
+
//
|
|
6
|
+
// Deliberately CONSERVATIVE — it must never hijack a genuine chat turn. It only
|
|
7
|
+
// fires on short, imperative messages that clearly name a setting + an action,
|
|
8
|
+
// and bails on anything that looks like a question. Everything it doesn't catch
|
|
9
|
+
// falls through to the model (where the system-prompt honesty guard tells it to
|
|
10
|
+
// point at the real command instead of claiming success).
|
|
11
|
+
|
|
12
|
+
import { resolveModelAlias } from '../providers/claude_cli.mjs';
|
|
13
|
+
import { orchestratorGet, orchestratorSet, orchestratorEnable } from '../config_features.mjs';
|
|
14
|
+
|
|
15
|
+
// Appended to the chat system prompt (when one exists) so the model stops
|
|
16
|
+
// claiming it changed a setting it can't reach. The common orchestrator on/off
|
|
17
|
+
// and planner/worker swaps ARE applied for real by detectConfigCommand above;
|
|
18
|
+
// everything else must be redirected to a command, not faked.
|
|
19
|
+
export const LAZYCLAW_META_GUARD =
|
|
20
|
+
'You are running inside the lazyclaw CLI. You CANNOT change lazyclaw\'s own configuration ' +
|
|
21
|
+
'(provider, orchestrator planner/workers, models, agents, teams) from this chat — you have no tool for it. ' +
|
|
22
|
+
'If the user asks you to change a setting, do NOT claim you did it. Instead give the exact command: ' +
|
|
23
|
+
'`/orchestrator off | planner <provider:model> | worker add <provider:model>`, `lazyclaw agent add …`, ' +
|
|
24
|
+
'`lazyclaw team …`, or `lazyclaw config set <key> <value>`. ' +
|
|
25
|
+
'(Plain-language orchestrator on/off and planner/worker model changes are applied automatically — others are not.)';
|
|
26
|
+
|
|
27
|
+
const ORCH = /오케스트라|오케스트레이터|오케스트레이션|orchestrat/i;
|
|
28
|
+
const OFF = /\boff\b|\bdisable\b|끄|꺼|비활성/i;
|
|
29
|
+
const ON = /\bon\b|\benable\b|켜|활성/i;
|
|
30
|
+
const KO_MODEL = [['소넷', 'sonnet'], ['쏘넷', 'sonnet'], ['하이쿠', 'haiku'], ['오퍼스', 'opus'], ['오푸스', 'opus']];
|
|
31
|
+
const MODEL_TOKEN = /\b(opus|sonnet|haiku|gpt-?[0-9][\w.-]*|gemini-?[0-9][\w.-]*|o[0-9]|claude-[a-z0-9-]+)\b/i;
|
|
32
|
+
|
|
33
|
+
// Questions / explanations must pass straight through to the model.
|
|
34
|
+
function looksLikeQuestion(t) {
|
|
35
|
+
return /[??]/.test(t) ||
|
|
36
|
+
/(법|방법|어떻게|어케|뭐|무엇|설명|알려|차이|왜|how\b|what\b|why\b|explain|difference|\bvs\b)/i.test(t);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function extractModel(t) {
|
|
40
|
+
for (const [ko, en] of KO_MODEL) if (t.includes(ko)) return en;
|
|
41
|
+
const m = MODEL_TOKEN.exec(t);
|
|
42
|
+
if (!m) return null;
|
|
43
|
+
const tok = m[1].toLowerCase();
|
|
44
|
+
return resolveModelAlias(tok) || tok;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Returns an intent object or null. Intents:
|
|
48
|
+
// { kind: 'orchestrator', enable: boolean }
|
|
49
|
+
// { kind: 'planner'|'worker', model: string }
|
|
50
|
+
export function detectConfigCommand(text) {
|
|
51
|
+
const t = String(text || '').trim();
|
|
52
|
+
if (!t || t.length > 80) return null; // long → a real task, not a command
|
|
53
|
+
if (looksLikeQuestion(t)) return null;
|
|
54
|
+
|
|
55
|
+
if (ORCH.test(t)) {
|
|
56
|
+
if (OFF.test(t)) return { kind: 'orchestrator', enable: false }; // 비활성 → off (checked first)
|
|
57
|
+
if (ON.test(t)) return { kind: 'orchestrator', enable: true };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const wantsPlanner = /플래너|planner/i.test(t);
|
|
61
|
+
const wantsWorker = /워커|worker/i.test(t);
|
|
62
|
+
if (wantsPlanner && wantsWorker) return null; // ambiguous — do them in separate messages
|
|
63
|
+
if (wantsPlanner || wantsWorker) {
|
|
64
|
+
const model = extractModel(t);
|
|
65
|
+
if (model) return { kind: wantsPlanner ? 'planner' : 'worker', model };
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const provOf = (spec) => {
|
|
71
|
+
const s = String(spec || '');
|
|
72
|
+
return s.includes(':') ? s.split(':')[0] : (s || 'claude-cli');
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// Apply a detected intent for real. deps: { readConfig, writeConfig, ctxCfg? }.
|
|
76
|
+
// Returns a short confirmation string to show the user.
|
|
77
|
+
export function applyConfigCommand(intent, deps) {
|
|
78
|
+
const cfg = deps.readConfig();
|
|
79
|
+
const syncCtx = () => {
|
|
80
|
+
if (deps.ctxCfg) { deps.ctxCfg.provider = cfg.provider; deps.ctxCfg.orchestrator = cfg.orchestrator; }
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
if (intent.kind === 'orchestrator') {
|
|
84
|
+
orchestratorEnable(cfg, intent.enable);
|
|
85
|
+
deps.writeConfig(cfg); syncCtx();
|
|
86
|
+
return intent.enable
|
|
87
|
+
? '✓ Orchestrator ON — chats route through planner + workers. Set models with `/orchestrator planner <provider:model>` and `/orchestrator worker add <provider:model>`.'
|
|
88
|
+
: `✓ Orchestrator off — chats route to \`${cfg.provider}\`.`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const o = orchestratorGet(cfg);
|
|
92
|
+
if (intent.kind === 'planner') {
|
|
93
|
+
const spec = `${provOf(o.planner)}:${intent.model}`;
|
|
94
|
+
orchestratorSet(cfg, { planner: spec });
|
|
95
|
+
deps.writeConfig(cfg); syncCtx();
|
|
96
|
+
return `✓ Planner → \`${spec}\`.`;
|
|
97
|
+
}
|
|
98
|
+
// worker — set the workers list to the chosen model (preserves the provider).
|
|
99
|
+
const spec = `${provOf((o.workers && o.workers[0]) || o.planner)}:${intent.model}`;
|
|
100
|
+
orchestratorSet(cfg, { workers: [spec] });
|
|
101
|
+
deps.writeConfig(cfg); syncCtx();
|
|
102
|
+
return `✓ Workers → [\`${spec}\`].`;
|
|
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
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// lib/permission_mode.mjs — the claude-cli permission mode lazyclaw passes to
|
|
2
|
+
// the spawned `claude` (`--permission-mode <mode>`).
|
|
3
|
+
//
|
|
4
|
+
// lazyclaw is an autonomous-agent CLI: the agentic / team path already runs the
|
|
5
|
+
// agent with bypassPermissions so it doesn't stop to ask before every tool. The
|
|
6
|
+
// interactive chat path historically passed no flag, so claude fell back to its
|
|
7
|
+
// own "default" mode and prompted on each tool — annoying when you just want it
|
|
8
|
+
// to run. This centralises the choice: `cfg.chat.permissionMode` (asked at
|
|
9
|
+
// setup) drives every claude spawn; unset defaults to bypassPermissions so the
|
|
10
|
+
// out-of-the-box experience doesn't nag. Cautious users pick 'default' (prompt
|
|
11
|
+
// each time), 'acceptEdits' (auto-accept edits, prompt for the rest), or 'plan'.
|
|
12
|
+
|
|
13
|
+
export const PERMISSION_MODES = ['default', 'acceptEdits', 'bypassPermissions', 'plan'];
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_PERMISSION_MODE = 'bypassPermissions';
|
|
16
|
+
|
|
17
|
+
// Resolve the effective mode from config. An unset or unrecognised value falls
|
|
18
|
+
// back to the default rather than handing claude an invalid --permission-mode.
|
|
19
|
+
export function resolvePermissionMode(cfg) {
|
|
20
|
+
const m = cfg && cfg.chat && cfg.chat.permissionMode;
|
|
21
|
+
return PERMISSION_MODES.includes(m) ? m : DEFAULT_PERMISSION_MODE;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Map a setup-wizard answer to a permission mode. Empty (Enter) → the default
|
|
25
|
+
// (bypass). Returns null for an unrecognised answer so the caller can keep the
|
|
26
|
+
// current value rather than guess.
|
|
27
|
+
const CHOICE_MAP = {
|
|
28
|
+
'': DEFAULT_PERMISSION_MODE, bypass: 'bypassPermissions', b: 'bypassPermissions',
|
|
29
|
+
ask: 'default', default: 'default', d: 'default', prompt: 'default',
|
|
30
|
+
acceptedits: 'acceptEdits', accept: 'acceptEdits', edits: 'acceptEdits', e: 'acceptEdits',
|
|
31
|
+
plan: 'plan', p: 'plan',
|
|
32
|
+
};
|
|
33
|
+
export function parsePermissionChoice(answer) {
|
|
34
|
+
const k = String(answer ?? '').trim().toLowerCase();
|
|
35
|
+
return Object.prototype.hasOwnProperty.call(CHOICE_MAP, k) ? CHOICE_MAP[k] : null;
|
|
36
|
+
}
|
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/providers/claude_cli.mjs
CHANGED
|
@@ -142,6 +142,10 @@ export function buildArgs(prompt, opts = {}) {
|
|
|
142
142
|
const maxTurns = opts.maxTurns == null ? 1 : opts.maxTurns;
|
|
143
143
|
if (maxTurns > 0) args.push('--max-turns', String(maxTurns));
|
|
144
144
|
args.push('--tools', opts.tools == null ? '' : String(opts.tools));
|
|
145
|
+
// Permission mode (e.g. bypassPermissions) so the agent doesn't stop to ask
|
|
146
|
+
// before every tool. Only emitted when set — the caller centralises the
|
|
147
|
+
// default via lib/permission_mode.resolvePermissionMode.
|
|
148
|
+
if (opts.permissionMode) args.push('--permission-mode', String(opts.permissionMode));
|
|
145
149
|
const modelAlias = resolveModelAlias(opts.model);
|
|
146
150
|
if (modelAlias) args.push('--model', modelAlias);
|
|
147
151
|
return args;
|
|
@@ -174,7 +178,7 @@ export const claudeCliProvider = {
|
|
|
174
178
|
const { getSession } = await import('./claude_cli_session.mjs');
|
|
175
179
|
const session = getSession(opts.sessionKey, {
|
|
176
180
|
bin, model: opts.model, cwd: opts.cwd, lean: opts.lean,
|
|
177
|
-
maxTurns: opts.maxTurns, tools: opts.tools,
|
|
181
|
+
maxTurns: opts.maxTurns, tools: opts.tools, permissionMode: opts.permissionMode,
|
|
178
182
|
system: opts.sessionSystem || opts.system || messages.find((m) => m.role === 'system')?.content,
|
|
179
183
|
});
|
|
180
184
|
let yielded = false;
|
|
@@ -52,6 +52,7 @@ class ClaudeSession {
|
|
|
52
52
|
const mt = o.maxTurns == null ? 1 : o.maxTurns;
|
|
53
53
|
if (mt > 0) args.push('--max-turns', String(mt));
|
|
54
54
|
args.push('--tools', o.tools == null ? '' : String(o.tools));
|
|
55
|
+
if (o.permissionMode) args.push('--permission-mode', String(o.permissionMode));
|
|
55
56
|
const model = resolveModelAlias(o.model);
|
|
56
57
|
if (model) args.push('--model', model);
|
|
57
58
|
if (o.system && String(o.system).trim()) args.push('--append-system-prompt', String(o.system));
|
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
|
@@ -28,6 +28,9 @@
|
|
|
28
28
|
import { Chalk } from 'chalk';
|
|
29
29
|
import { chatAgenticGet, chatPlanModeGet, effectiveChatTools } from '../config_features.mjs';
|
|
30
30
|
import { defaultSandboxSpec } from '../sandbox/index.mjs';
|
|
31
|
+
import { resolvePermissionMode } from '../lib/permission_mode.mjs';
|
|
32
|
+
import { detectConfigCommand, applyConfigCommand, refreshLiveProvider } from '../lib/nl_config_command.mjs';
|
|
33
|
+
import { readConfig as _readCfg, writeConfig as _writeCfg } from '../lib/config.mjs';
|
|
31
34
|
|
|
32
35
|
// Force ANSI on these turn-status markers regardless of stdout TTY detection:
|
|
33
36
|
// the Ink path routes them through React state (Ink preserves embedded SGR
|
|
@@ -206,6 +209,29 @@ async function _runAgenticTurn({ ctx, messages, sysMsg, activeProvName, activeMo
|
|
|
206
209
|
export function makeRunTurn({ ctx, writeFn }) {
|
|
207
210
|
return async function runTurn(text, signal) {
|
|
208
211
|
if (signal?.aborted) return;
|
|
212
|
+
|
|
213
|
+
// Natural-language config commands (orchestrator on/off, planner/worker
|
|
214
|
+
// model) are applied for REAL here — otherwise the model just replies
|
|
215
|
+
// "done" while nothing changes (the reported bug). Conservative matcher;
|
|
216
|
+
// anything it doesn't catch falls through to the model below.
|
|
217
|
+
const _cmd = detectConfigCommand(text);
|
|
218
|
+
if (_cmd) {
|
|
219
|
+
const _msgs = ctx.getMessages();
|
|
220
|
+
_msgs.push({ role: 'user', content: text });
|
|
221
|
+
ctx.persistTurn('user', text);
|
|
222
|
+
let reply;
|
|
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}`; }
|
|
229
|
+
try { writeFn(reply + '\n'); } catch { /* sink best-effort */ }
|
|
230
|
+
_msgs.push({ role: 'assistant', content: reply });
|
|
231
|
+
ctx.persistTurn('assistant', reply);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
209
235
|
const messages = ctx.getMessages();
|
|
210
236
|
messages.push({ role: 'user', content: text });
|
|
211
237
|
try { ctx.onCharsSent && ctx.onCharsSent(text.length); }
|
|
@@ -296,6 +322,9 @@ export function makeRunTurn({ ctx, writeFn }) {
|
|
|
296
322
|
// a one-shot spawn on any session failure, and the session respawns if
|
|
297
323
|
// the system prompt changes (plan-mode toggle) so it can't go stale.
|
|
298
324
|
persistent: ctx.cfg?.chat?.persistentSession !== false,
|
|
325
|
+
// claude-cli permission mode (cfg.chat.permissionMode, asked at setup;
|
|
326
|
+
// defaults to bypassPermissions). claude-cli reads it; others ignore it.
|
|
327
|
+
permissionMode: resolvePermissionMode(ctx.cfg),
|
|
299
328
|
sessionKey: (ctx.getSessionId && ctx.getSessionId()) || ctx.syntheticChatSessionId,
|
|
300
329
|
...(sysMsg ? { sessionSystem: sysMsg.content } : {}),
|
|
301
330
|
...(maxTokens !== undefined ? { maxTokens } : {}),
|
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
|
+
}
|