lazyclaw 5.4.3 → 6.0.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/channels/handoff.mjs +36 -0
- package/cli.mjs +73 -7399
- package/daemon.mjs +23 -2085
- package/mas/agent_turn.mjs +2 -1
- package/mas/index_db.mjs +82 -0
- package/mas/learning.mjs +17 -1
- package/mas/mention_router.mjs +38 -10
- package/mas/provider_adapters.mjs +28 -4
- package/mas/scrub_env.mjs +34 -0
- package/mas/tool_runner.mjs +23 -7
- package/mas/tools/bash.mjs +10 -5
- package/mas/tools/browser.mjs +18 -0
- package/mas/tools/learning.mjs +24 -14
- package/mas/tools/recall.mjs +5 -1
- package/mas/tools/web.mjs +47 -11
- package/mas/trajectory_store.mjs +7 -4
- package/package.json +3 -2
- package/providers/auth_store.mjs +22 -0
- package/providers/claude_cli.mjs +28 -2
- package/providers/claude_cli_detect.mjs +46 -0
- package/providers/custom_provider.mjs +70 -0
- package/providers/model_catalogue.mjs +86 -0
- package/providers/orchestrator.mjs +30 -9
- package/providers/rates.mjs +12 -2
- package/providers/registry.mjs +10 -7
- package/sandbox/confiners/landlock.mjs +14 -8
- package/sandbox/confiners/seatbelt.mjs +18 -2
- package/scripts/loop-worker.mjs +18 -7
- package/scripts/migrate-v5.mjs +5 -61
- package/sessions.mjs +0 -0
- package/tui/editor.mjs +44 -0
- package/tui/modal_filter.mjs +59 -0
- package/tui/modal_picker.mjs +12 -37
- package/tui/pickers.mjs +917 -0
- package/tui/provider_families.mjs +41 -0
- package/tui/repl.mjs +67 -36
- package/tui/slash_commands.mjs +8 -7
- package/tui/slash_dispatcher.mjs +923 -118
- package/tui/splash.mjs +5 -12
- package/tui/subcommands.mjs +17 -0
- package/tui/terminal_approve.mjs +37 -0
- package/web/dashboard.css +275 -0
- package/web/dashboard.html +2 -1685
- package/web/dashboard.js +1406 -0
- package/workflow/persistent.mjs +13 -6
- package/mas/tools/skill_view.mjs +0 -43
package/tui/pickers.mjs
ADDED
|
@@ -0,0 +1,917 @@
|
|
|
1
|
+
// Interactive TUI helpers — readline pickers, banner/mascot renderers,
|
|
2
|
+
// arrow-key menu, provider/model selection, and the _quickPrompt line reader.
|
|
3
|
+
// Extracted from cli.mjs in Phase D4. Lives in tui/ so banner asset imports
|
|
4
|
+
// are siblings (./banner.generated.mjs, ./wordmark.mjs).
|
|
5
|
+
import { readConfig, writeConfig, _resolveAuthKey } from '../lib/config.mjs';
|
|
6
|
+
import { ensureRegistry, getRegistry } from '../lib/registry_boot.mjs';
|
|
7
|
+
import { bucketProviders as _bucketProviders } from './provider_families.mjs';
|
|
8
|
+
import { addCustomProvider } from '../providers/custom_provider.mjs';
|
|
9
|
+
import {
|
|
10
|
+
modelCatalogueFor as _modelCatalogueResolve,
|
|
11
|
+
fetchModelsForProvider as _fetchModelsResolve,
|
|
12
|
+
supportsLiveFetch as _supportsLiveFetch,
|
|
13
|
+
} from '../providers/model_catalogue.mjs';
|
|
14
|
+
|
|
15
|
+
export function _attachGhostAutocomplete(rl) {
|
|
16
|
+
// Returns `{ dispose, suspend, resume }`. Dispose detaches the
|
|
17
|
+
// keypress + rl 'line' listeners (failure to do so leaks the
|
|
18
|
+
// event-loop ref, which is exactly the slow-exit bug v3.92
|
|
19
|
+
// fixed). Suspend / resume gate the keypress handler so the
|
|
20
|
+
// streaming chat output isn't interleaved with `\x1b[s\x1b[K\x1b[u`
|
|
21
|
+
// ghost-render escapes — that interleaving is what surfaces as
|
|
22
|
+
// visible gaps between Korean characters in long replies.
|
|
23
|
+
const noop = () => {};
|
|
24
|
+
if (!process.stdout.isTTY) return { dispose: noop, suspend: noop, resume: noop };
|
|
25
|
+
const cmds = SLASH_COMMANDS.map((c) => c.cmd);
|
|
26
|
+
let lastGhost = '';
|
|
27
|
+
let suspended = false;
|
|
28
|
+
// Find the longest match for the current input. Returns '' when
|
|
29
|
+
// nothing matches or when the input already equals a command.
|
|
30
|
+
const findMatch = () => {
|
|
31
|
+
const buf = rl.line || '';
|
|
32
|
+
if (!buf.startsWith('/')) return '';
|
|
33
|
+
const exact = cmds.find((c) => c === buf);
|
|
34
|
+
if (exact) return '';
|
|
35
|
+
const hits = cmds.filter((c) => c.startsWith(buf) && c.length > buf.length);
|
|
36
|
+
if (!hits.length) return '';
|
|
37
|
+
return hits[0]; // first match is the shortest matching command
|
|
38
|
+
};
|
|
39
|
+
// Render the ghost after the user's cursor. We use ANSI save/restore
|
|
40
|
+
// (\x1b[s / \x1b[u) so writing the suggestion doesn't move readline's
|
|
41
|
+
// notion of where the cursor is; we just paint the dim text and snap
|
|
42
|
+
// back. \x1b[K clears any leftover ghost from the previous keystroke.
|
|
43
|
+
const render = () => {
|
|
44
|
+
if (!process.stdout.isTTY) return;
|
|
45
|
+
const match = findMatch();
|
|
46
|
+
const buf = rl.line || '';
|
|
47
|
+
// Always clear leftover ghost first.
|
|
48
|
+
process.stdout.write('\x1b[s\x1b[K');
|
|
49
|
+
if (match && match.length > buf.length) {
|
|
50
|
+
const tail = match.slice(buf.length);
|
|
51
|
+
process.stdout.write(`\x1b[2m${tail}\x1b[0m`);
|
|
52
|
+
lastGhost = match;
|
|
53
|
+
} else {
|
|
54
|
+
lastGhost = '';
|
|
55
|
+
}
|
|
56
|
+
process.stdout.write('\x1b[u');
|
|
57
|
+
};
|
|
58
|
+
// Intercept Right-arrow at end-of-line to accept the suggestion.
|
|
59
|
+
// We attach as a prependListener so we run before readline's own
|
|
60
|
+
// handler — when we accept, we mutate rl.line ourselves and call
|
|
61
|
+
// _refreshLine, then return without forwarding the keypress.
|
|
62
|
+
const onKeypress = (_str, key) => {
|
|
63
|
+
if (!key) return;
|
|
64
|
+
// While a streaming response is being printed, do nothing —
|
|
65
|
+
// any ANSI cursor save / restore we emit would tear the wide-
|
|
66
|
+
// character (CJK) output apart on the visible terminal.
|
|
67
|
+
if (suspended) return;
|
|
68
|
+
if (key.name === 'right' && lastGhost && rl.line === rl.line.trim() &&
|
|
69
|
+
rl.cursor === (rl.line || '').length && (rl.line || '').length < lastGhost.length) {
|
|
70
|
+
const accepted = lastGhost;
|
|
71
|
+
// Clear the dim ghost before redrawing the line (otherwise the
|
|
72
|
+
// residue overlaps the new line content).
|
|
73
|
+
process.stdout.write('\x1b[s\x1b[K\x1b[u');
|
|
74
|
+
rl.line = accepted;
|
|
75
|
+
rl.cursor = accepted.length;
|
|
76
|
+
// _refreshLine is private but stable across Node 18+ readline
|
|
77
|
+
// implementations. Falls back to manual redraw if it ever changes.
|
|
78
|
+
if (typeof rl._refreshLine === 'function') rl._refreshLine();
|
|
79
|
+
else { process.stdout.write('\r\x1b[K' + (rl._prompt || '') + accepted); }
|
|
80
|
+
lastGhost = '';
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
// For any other key, schedule the ghost re-render after readline
|
|
84
|
+
// has updated rl.line. setImmediate runs after readline's keypress
|
|
85
|
+
// handler completes.
|
|
86
|
+
setImmediate(render);
|
|
87
|
+
};
|
|
88
|
+
process.stdin.on('keypress', onKeypress);
|
|
89
|
+
// Clear ghost on each new prompt so a stale dim hint doesn't carry
|
|
90
|
+
// over between turns.
|
|
91
|
+
const onLine = () => { lastGhost = ''; };
|
|
92
|
+
rl.on('line', onLine);
|
|
93
|
+
const dispose = () => {
|
|
94
|
+
try { process.stdin.removeListener('keypress', onKeypress); } catch (_) {}
|
|
95
|
+
try { rl.removeListener('line', onLine); } catch (_) {}
|
|
96
|
+
// Wipe any leftover ghost on screen so the user's terminal doesn't
|
|
97
|
+
// keep a dim suffix after we exit.
|
|
98
|
+
try { process.stdout.write('\x1b[s\x1b[K\x1b[u'); } catch (_) {}
|
|
99
|
+
};
|
|
100
|
+
return {
|
|
101
|
+
dispose,
|
|
102
|
+
suspend: () => {
|
|
103
|
+
suspended = true;
|
|
104
|
+
// Wipe any half-rendered ghost before streaming starts so the
|
|
105
|
+
// first chunk lands at the same column as the prompt.
|
|
106
|
+
try { process.stdout.write('\x1b[s\x1b[K\x1b[u'); } catch (_) {}
|
|
107
|
+
},
|
|
108
|
+
resume: () => { suspended = false; },
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// LazyClaw banner — printed once at the top of every interactive chat
|
|
113
|
+
// session so users see the active provider/model before they start
|
|
114
|
+
// typing. Plain ANSI; auto-skipped when stdout isn't a TTY (so piped
|
|
115
|
+
// invocations stay clean for tests/scripts).
|
|
116
|
+
// Single source of truth for the LazyClaw banner — used by the chat
|
|
117
|
+
// REPL header, the no-arg launcher, and the first-run welcome panel.
|
|
118
|
+
// Returns an array of pre-formatted lines (with ANSI colour) so the
|
|
119
|
+
// caller can splice in additional rows without re-implementing the
|
|
120
|
+
// alignment.
|
|
121
|
+
//
|
|
122
|
+
// Width-management rule: every inner line is forced through
|
|
123
|
+
// `.padEnd(W)` so a stray width miscount can't punch the right
|
|
124
|
+
// border off the box (which is exactly the bug v3.99.5 shipped:
|
|
125
|
+
// v4.2.2 — boxed figlet "lazy" wordmark, single-colour orange. The
|
|
126
|
+
// previous mixed-colour banner (helmet-red letter art + ink-beige
|
|
127
|
+
// caption) read as "two banners glued together" because the colour
|
|
128
|
+
// changed mid-box. We use one warm orange (#F08246) for everything —
|
|
129
|
+
// border, letter art, caption — so the eye reads it as one badge.
|
|
130
|
+
//
|
|
131
|
+
// Letter art is figlet "standard" (6 rows) rather than the v3.99.11
|
|
132
|
+
// "small" (4 rows), because small renders as a pixel mush in most
|
|
133
|
+
// terminal fonts. Standard's strokes are wide enough that the
|
|
134
|
+
// letters read as `l a z y` even at small terminal sizes.
|
|
135
|
+
//
|
|
136
|
+
// Layout invariant: every inner row is exactly INNER_W visible cells
|
|
137
|
+
// (no double-width glyphs, all chars are 1 cell in any monospace
|
|
138
|
+
// font), so the right edge `│` always lands in the same column.
|
|
139
|
+
//
|
|
140
|
+
// _renderMascot / _renderMascotTiny are kept as stubs so any leftover
|
|
141
|
+
// caller doesn't crash; no state-coloured art is produced any more.
|
|
142
|
+
|
|
143
|
+
const _ORANGE_RGB = '241;130;70'; // #F18246
|
|
144
|
+
export function _orange(s) { return `\x1b[38;2;${_ORANGE_RGB}m${s}\x1b[0m`; }
|
|
145
|
+
|
|
146
|
+
export function _renderMascot() {
|
|
147
|
+
return ['lazyclaw'];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function _renderMascotTiny() {
|
|
151
|
+
return 'lazyclaw';
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// figlet "standard" "lazy", trimmed of leading blank line. Each row
|
|
155
|
+
// is left-padded by two spaces inside the box, and every row is then
|
|
156
|
+
// padded to INNER_W cells.
|
|
157
|
+
const _LAZY_STANDARD = [
|
|
158
|
+
' _ ',
|
|
159
|
+
'| | __ _ _____ _ ',
|
|
160
|
+
'| |/ _` |_ / | | | ',
|
|
161
|
+
'| | (_| |/ /| |_| | ',
|
|
162
|
+
'|_|\\__,_/___|\\__, | ',
|
|
163
|
+
' |___/ ',
|
|
164
|
+
];
|
|
165
|
+
|
|
166
|
+
const _INNER_W = 32; // 2 left pad + 20 letter art + caption headroom
|
|
167
|
+
|
|
168
|
+
export function _renderBanner(version) {
|
|
169
|
+
const v = String(version || '?.?.?');
|
|
170
|
+
const cap = ` LazyClaw v${v}`;
|
|
171
|
+
const padInner = (s) => ' ' + s.padEnd(_INNER_W - 2, ' ');
|
|
172
|
+
const wrap = (inner) => _orange('│') + _orange(inner) + _orange('│');
|
|
173
|
+
const top = _orange('╭' + '─'.repeat(_INNER_W) + '╮');
|
|
174
|
+
const bot = _orange('╰' + '─'.repeat(_INNER_W) + '╯');
|
|
175
|
+
return [
|
|
176
|
+
top,
|
|
177
|
+
..._LAZY_STANDARD.map((row) => wrap(padInner(row))),
|
|
178
|
+
wrap(padInner(cap)),
|
|
179
|
+
bot,
|
|
180
|
+
];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// v5 hero banner — ANSI Shadow LAZYCLAW wordmark stacked on top of the
|
|
184
|
+
// braille sloth (tui/banner.generated.mjs + tui/wordmark.mjs). Left-aligned
|
|
185
|
+
// with a 2-cell margin so wide terminals don't push the art to the right.
|
|
186
|
+
// Opt out with LAZYCLAW_LEGACY_MENU=1 to fall back to the v4 figlet box.
|
|
187
|
+
let _bannerAssetsCache = null;
|
|
188
|
+
export async function _loadBannerAssets() {
|
|
189
|
+
if (_bannerAssetsCache !== null) return _bannerAssetsCache;
|
|
190
|
+
try {
|
|
191
|
+
const { banner } = await import('./banner.generated.mjs');
|
|
192
|
+
const { wordmark } = await import('./wordmark.mjs');
|
|
193
|
+
_bannerAssetsCache = { banner, wordmark };
|
|
194
|
+
} catch {
|
|
195
|
+
_bannerAssetsCache = null;
|
|
196
|
+
}
|
|
197
|
+
return _bannerAssetsCache;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export async function _renderV5Banner(version) {
|
|
201
|
+
const a = await _loadBannerAssets();
|
|
202
|
+
if (!a) return _renderBanner(version); // missing tarball asset → v4 figlet
|
|
203
|
+
const v = String(version || '?.?.?');
|
|
204
|
+
const rows = [];
|
|
205
|
+
const palette = a.wordmark.palette || [];
|
|
206
|
+
const gradient = a.wordmark.gradient || [];
|
|
207
|
+
function tint(idx, s) {
|
|
208
|
+
const hex = palette[idx] || '#FFB347';
|
|
209
|
+
const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex);
|
|
210
|
+
if (!m) return _orange(s);
|
|
211
|
+
const [, r, g, b] = m;
|
|
212
|
+
const R = parseInt(r, 16), G = parseInt(g, 16), B = parseInt(b, 16);
|
|
213
|
+
return `\x1b[38;2;${R};${G};${B}m${s}\x1b[0m`;
|
|
214
|
+
}
|
|
215
|
+
a.wordmark.rows.forEach((r, i) => rows.push(tint(gradient[i] ?? 1, ' ' + r)));
|
|
216
|
+
rows.push('');
|
|
217
|
+
for (const r of a.banner.rows) rows.push(_orange(' ' + r));
|
|
218
|
+
rows.push(_orange(' ' + `lazyclaw v${v}`));
|
|
219
|
+
return rows;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function _printChatBanner(activeProvName, activeModel, version) {
|
|
223
|
+
if (!process.stdout.isTTY) return;
|
|
224
|
+
// Single-hue header: labels dim-orange, values/emphasis full-orange, so the
|
|
225
|
+
// four caption rows below the box read as part of the same warm badge.
|
|
226
|
+
const dimOrange = (s) => `\x1b[2m\x1b[38;2;${_ORANGE_RGB}m${s}\x1b[0m`;
|
|
227
|
+
const orange = _orange;
|
|
228
|
+
const lines = [
|
|
229
|
+
'',
|
|
230
|
+
..._renderBanner(version),
|
|
231
|
+
'',
|
|
232
|
+
` ${dimOrange('provider ·')} ${orange(activeProvName)}`,
|
|
233
|
+
` ${dimOrange('model ·')} ${orange(activeModel || '(default)')}`,
|
|
234
|
+
` ${dimOrange('slash ·')} ${orange('/help · /model · /provider · /exit')}`,
|
|
235
|
+
` ${dimOrange('hint ·')} ${orange('→')} ${dimOrange('to accept the suggested command,')} ${orange('Tab')} ${dimOrange('to cycle')}`,
|
|
236
|
+
'',
|
|
237
|
+
];
|
|
238
|
+
process.stdout.write(lines.join('\n') + '\n');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
// Interactive provider/model picker. Used on first run (no config) or
|
|
243
|
+
// when the user passes --pick. Falls back to plain stdin reads when
|
|
244
|
+
// stdout isn't a TTY (CI/script callers should pass --non-interactive
|
|
245
|
+
// equivalents instead).
|
|
246
|
+
// Generic arrow-key menu used by the multi-step provider/model
|
|
247
|
+
// picker below. Returns the picked item, or one of the sentinel
|
|
248
|
+
// strings 'BACK' (Esc — caller should retry the previous step) or
|
|
249
|
+
// 'CANCEL' (q — caller should bail entirely). Ctrl-C exits the
|
|
250
|
+
// process directly, matching every other interactive prompt in the
|
|
251
|
+
// CLI.
|
|
252
|
+
//
|
|
253
|
+
// `items` is an array of { id, label, desc, tag }. `tag` is an
|
|
254
|
+
// optional pre-coloured pill (e.g. "[api key]") that lands on the
|
|
255
|
+
// right side of the row. `defaultIdx` lets the caller pin where the
|
|
256
|
+
// cursor lands; default 0.
|
|
257
|
+
export async function _arrowMenu({ title, subtitle, footer, items, defaultIdx = 0, searchable = false }) {
|
|
258
|
+
if (!process.stdout.isTTY || !process.stdin.isTTY) {
|
|
259
|
+
// Non-TTY fallback: print the labels on stderr and read a single
|
|
260
|
+
// line of stdin. Used when somebody pipes input to `lazyclaw
|
|
261
|
+
// setup` — the wizard still works, just without arrows.
|
|
262
|
+
process.stderr.write(`${title}\n`);
|
|
263
|
+
items.forEach((it, i) => process.stderr.write(` ${i + 1}. ${it.label}${it.desc ? ' — ' + it.desc : ''}\n`));
|
|
264
|
+
process.stderr.write('pick (number / id, blank for first): ');
|
|
265
|
+
const ans = await new Promise((resolve) => {
|
|
266
|
+
let buf = '';
|
|
267
|
+
const onData = (chunk) => {
|
|
268
|
+
buf += chunk.toString();
|
|
269
|
+
if (buf.includes('\n')) { process.stdin.off('data', onData); resolve(buf.trim()); }
|
|
270
|
+
};
|
|
271
|
+
process.stdin.on('data', onData);
|
|
272
|
+
});
|
|
273
|
+
if (!ans) return items[0];
|
|
274
|
+
const byNum = parseInt(ans, 10);
|
|
275
|
+
if (Number.isFinite(byNum) && byNum >= 1 && byNum <= items.length) return items[byNum - 1];
|
|
276
|
+
const byId = items.find((it) => it.id === ans || it.label === ans);
|
|
277
|
+
return byId || items[0];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const readline = await import('node:readline');
|
|
281
|
+
readline.emitKeypressEvents(process.stdin);
|
|
282
|
+
if (process.stdin.setRawMode) process.stdin.setRawMode(true);
|
|
283
|
+
// A previous `readline.createInterface(...).close()` (e.g. from
|
|
284
|
+
// `_quickPrompt`) leaves stdin paused — the keypress listener we
|
|
285
|
+
// attach below would never fire and the menu would appear frozen
|
|
286
|
+
// instead of responding to arrow keys. Resume + ref defensively
|
|
287
|
+
// before drawing so the picker always receives the first keypress.
|
|
288
|
+
process.stdin.resume();
|
|
289
|
+
if (process.stdin.ref) process.stdin.ref();
|
|
290
|
+
const accent = (s) => `\x1b[38;5;208m${s}\x1b[0m`;
|
|
291
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
292
|
+
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
293
|
+
|
|
294
|
+
// Typeahead state. `query` accumulates printable chars when searchable
|
|
295
|
+
// is on; the visible item slice is recomputed on every keystroke. We
|
|
296
|
+
// keep `defaultIdx` semantics by mapping it to the unfiltered list and
|
|
297
|
+
// tracking selection inside the filtered view via the item identity.
|
|
298
|
+
let query = '';
|
|
299
|
+
const matchScore = (it, q) => {
|
|
300
|
+
if (!q) return 0;
|
|
301
|
+
const hay = `${it.label || ''} ${it.desc || ''} ${it.id || ''}`.toLowerCase();
|
|
302
|
+
const needle = q.toLowerCase();
|
|
303
|
+
if (hay.includes(needle)) return hay.indexOf(needle) === 0 ? 2 : 1;
|
|
304
|
+
// simple subsequence fallback so "g4o" matches "gpt-4o".
|
|
305
|
+
let i = 0; let matched = 0;
|
|
306
|
+
for (const ch of hay) {
|
|
307
|
+
if (ch === needle[matched]) { matched++; if (matched === needle.length) break; }
|
|
308
|
+
i++;
|
|
309
|
+
}
|
|
310
|
+
return matched === needle.length ? 0.5 : 0;
|
|
311
|
+
};
|
|
312
|
+
const filterItems = () => {
|
|
313
|
+
if (!searchable || !query) return items.slice();
|
|
314
|
+
const scored = items
|
|
315
|
+
.map((it) => ({ it, s: matchScore(it, query) }))
|
|
316
|
+
.filter((x) => x.s > 0)
|
|
317
|
+
.sort((a, b) => b.s - a.s);
|
|
318
|
+
return scored.map((x) => x.it);
|
|
319
|
+
};
|
|
320
|
+
let view = filterItems();
|
|
321
|
+
let idx = Math.max(0, Math.min(view.length - 1, defaultIdx));
|
|
322
|
+
if (idx < 0) idx = 0;
|
|
323
|
+
|
|
324
|
+
const draw = () => {
|
|
325
|
+
process.stdout.write('\x1b[?25l\x1b[2J\x1b[H');
|
|
326
|
+
process.stdout.write(accent(title) + '\n');
|
|
327
|
+
if (subtitle) process.stdout.write(dim(subtitle) + '\n');
|
|
328
|
+
const help = searchable
|
|
329
|
+
? '↑/↓ to move · Enter to confirm · type to search · Esc to back · Ctrl+U to clear · q to quit'
|
|
330
|
+
: '↑/↓ to move · Enter to confirm · Esc to back · q to quit';
|
|
331
|
+
process.stdout.write(dim(help) + '\n');
|
|
332
|
+
if (searchable) {
|
|
333
|
+
const q = query ? bold(query) : dim('(type to filter)');
|
|
334
|
+
process.stdout.write(dim(' search: ') + q + dim(` ${view.length}/${items.length} match`) + '\n\n');
|
|
335
|
+
} else {
|
|
336
|
+
process.stdout.write('\n');
|
|
337
|
+
}
|
|
338
|
+
if (view.length === 0) {
|
|
339
|
+
process.stdout.write(' ' + dim('(no matches — backspace or Ctrl+U to clear the filter)') + '\n');
|
|
340
|
+
if (footer) process.stdout.write('\n' + dim(footer) + '\n');
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const headerLines = subtitle ? 4 : 3;
|
|
344
|
+
const rows = Math.max(6, (process.stdout.rows || 24) - (headerLines + (searchable ? 3 : 4)));
|
|
345
|
+
let from = Math.max(0, idx - Math.floor(rows / 2));
|
|
346
|
+
if (from + rows > view.length) from = Math.max(0, view.length - rows);
|
|
347
|
+
const to = Math.min(view.length, from + rows);
|
|
348
|
+
// Pre-compute label width so descriptions line up across rows.
|
|
349
|
+
const labelW = view.reduce((w, it) => Math.max(w, (it.label || '').length), 12);
|
|
350
|
+
for (let i = from; i < to; i++) {
|
|
351
|
+
const it = view[i];
|
|
352
|
+
const marker = i === idx ? accent('❯ ') : ' ';
|
|
353
|
+
const lbl = (it.label || '').padEnd(labelW);
|
|
354
|
+
const lblOut = i === idx ? bold(lbl) : lbl;
|
|
355
|
+
const desc = it.desc ? ' ' + dim(it.desc) : '';
|
|
356
|
+
const tag = it.tag ? ' ' + it.tag : '';
|
|
357
|
+
process.stdout.write(`${marker}${lblOut}${desc}${tag}\n`);
|
|
358
|
+
}
|
|
359
|
+
if (to < view.length) {
|
|
360
|
+
process.stdout.write(`${dim(` …(${view.length - to} more)`)}\n`);
|
|
361
|
+
}
|
|
362
|
+
if (footer) process.stdout.write('\n' + dim(footer) + '\n');
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
draw();
|
|
366
|
+
return await new Promise((resolve) => {
|
|
367
|
+
const recompute = () => {
|
|
368
|
+
view = filterItems();
|
|
369
|
+
if (idx >= view.length) idx = Math.max(0, view.length - 1);
|
|
370
|
+
draw();
|
|
371
|
+
};
|
|
372
|
+
const onKey = (str, key) => {
|
|
373
|
+
if (!key) return;
|
|
374
|
+
if (key.name === 'up') { if (view.length) { idx = (idx - 1 + view.length) % view.length; draw(); } }
|
|
375
|
+
else if (key.name === 'down') { if (view.length) { idx = (idx + 1) % view.length; draw(); } }
|
|
376
|
+
else if (key.name === 'pageup') { idx = Math.max(0, idx - 10); draw(); }
|
|
377
|
+
else if (key.name === 'pagedown') { idx = Math.min(view.length - 1, idx + 10); draw(); }
|
|
378
|
+
else if (key.name === 'home') { idx = 0; draw(); }
|
|
379
|
+
else if (key.name === 'end') { idx = view.length - 1; draw(); }
|
|
380
|
+
else if (key.name === 'return') {
|
|
381
|
+
if (view.length === 0) return;
|
|
382
|
+
cleanup();
|
|
383
|
+
resolve(view[idx]);
|
|
384
|
+
}
|
|
385
|
+
else if (key.ctrl && key.name === 'c') { cleanup(); process.exit(130); }
|
|
386
|
+
else if (key.ctrl && key.name === 'u') { if (searchable) { query = ''; recompute(); } }
|
|
387
|
+
else if (key.name === 'escape') {
|
|
388
|
+
if (searchable && query) { query = ''; recompute(); return; }
|
|
389
|
+
cleanup(); resolve('BACK');
|
|
390
|
+
}
|
|
391
|
+
else if (key.name === 'backspace') {
|
|
392
|
+
if (searchable && query.length > 0) { query = query.slice(0, -1); recompute(); }
|
|
393
|
+
}
|
|
394
|
+
else if (searchable && str && str.length === 1 && str >= ' ' && str !== '\x7f' && !key.ctrl && !key.meta) {
|
|
395
|
+
// Printable char → append to filter buffer. We deliberately do not
|
|
396
|
+
// intercept 'q' as a shortcut when searchable is on, because the
|
|
397
|
+
// user might be typing a model id that contains 'q'. Use Esc / Ctrl+C
|
|
398
|
+
// to bail out instead.
|
|
399
|
+
query += str;
|
|
400
|
+
recompute();
|
|
401
|
+
}
|
|
402
|
+
else if (!searchable && key.name === 'q') { cleanup(); resolve('CANCEL'); }
|
|
403
|
+
};
|
|
404
|
+
const cleanup = () => {
|
|
405
|
+
process.stdin.off('keypress', onKey);
|
|
406
|
+
if (process.stdin.setRawMode) process.stdin.setRawMode(false);
|
|
407
|
+
process.stdout.write('\x1b[?25h\x1b[2J\x1b[H');
|
|
408
|
+
};
|
|
409
|
+
process.stdin.on('keypress', onKey);
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Bucket every registered provider into one of three auth-method
|
|
414
|
+
// families. The picker's first step asks the user which family
|
|
415
|
+
// they want before drilling into specific providers — much less
|
|
416
|
+
// overwhelming than a flat 40-row list. Bucket assignment lives
|
|
417
|
+
// here (rather than registry.mjs) because it's a UX concept, not
|
|
418
|
+
// an intrinsic provider attribute.
|
|
419
|
+
export function _providerFamilies() {
|
|
420
|
+
// Membership (api/cli/mock, orchestrator excluded) is shared with the Ink
|
|
421
|
+
// picker via tui/provider_families.mjs so both paths bucket identically.
|
|
422
|
+
// The ANSI tags below are readline-specific, so they're applied here.
|
|
423
|
+
const b = _bucketProviders(getRegistry());
|
|
424
|
+
return {
|
|
425
|
+
api: { label: 'API key', desc: 'paste an sk-... key during setup', tag: '\x1b[38;5;245m[needs key]\x1b[0m', members: b.api },
|
|
426
|
+
cli: { label: 'CLI / Local', desc: 'keyless — uses an existing CLI login or a local daemon', tag: '\x1b[38;5;208m[no key]\x1b[0m', members: b.cli },
|
|
427
|
+
mock: { label: 'Mock', desc: 'offline echo, only useful for testing', tag: '\x1b[38;5;245m[test]\x1b[0m', members: b.mock },
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Multi-step provider / model picker — replaces the flat 40-row
|
|
432
|
+
// list of v3.99.5 with a drill-in:
|
|
433
|
+
//
|
|
434
|
+
// Step 1 — auth family (API key / CLI-Local / Mock)
|
|
435
|
+
// Step 2 — provider in that family (gemini / openai / claude-cli / …)
|
|
436
|
+
// Step 3 — model in that provider's suggestedModels
|
|
437
|
+
//
|
|
438
|
+
// Esc at any step goes back one. q or Ctrl-C cancels entirely.
|
|
439
|
+
// Steps that have only one option auto-advance so the user doesn't
|
|
440
|
+
// stare at a single-row menu (e.g. the Mock family has just `mock`).
|
|
441
|
+
export async function _pickProviderInteractive() {
|
|
442
|
+
const providers = Object.keys(getRegistry().PROVIDERS);
|
|
443
|
+
if (!providers.length) return { provider: 'mock', model: null };
|
|
444
|
+
const info = getRegistry().PROVIDER_INFO || {};
|
|
445
|
+
const families = _providerFamilies();
|
|
446
|
+
|
|
447
|
+
// Non-TTY fallback — single-prompt picker, identical to before.
|
|
448
|
+
if (!process.stdout.isTTY || !process.stdin.isTTY) {
|
|
449
|
+
process.stdout.write(`provider [${providers.join('|')}]: `);
|
|
450
|
+
const ans = await new Promise((resolve) => {
|
|
451
|
+
let buf = '';
|
|
452
|
+
const onData = (chunk) => {
|
|
453
|
+
buf += chunk.toString();
|
|
454
|
+
if (buf.includes('\n')) { process.stdin.off('data', onData); resolve(buf.trim()); }
|
|
455
|
+
};
|
|
456
|
+
process.stdin.on('data', onData);
|
|
457
|
+
});
|
|
458
|
+
// v5.3.2 — non-TTY fallback used to be `providers[0]`, which was
|
|
459
|
+
// whatever happened to be first in the registry (currently
|
|
460
|
+
// anthropic). Pin to claude-cli to match the interactive onboard
|
|
461
|
+
// hint at cmdOnboard (the keyless subscription path).
|
|
462
|
+
return { provider: ans || 'claude-cli', model: null };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ── Step 1 — auth family ──────────────────────────────────────
|
|
466
|
+
let family = null;
|
|
467
|
+
while (!family) {
|
|
468
|
+
const familyItems = Object.entries(families)
|
|
469
|
+
.filter(([, b]) => b.members.length > 0)
|
|
470
|
+
.map(([id, b]) => {
|
|
471
|
+
// Show member count + a few names instead of the full list — the
|
|
472
|
+
// API-key family alone now has 12 vendors and joining all of them
|
|
473
|
+
// produced an unreadable line.
|
|
474
|
+
const preview = b.members.slice(0, 3).join(' / ');
|
|
475
|
+
const more = b.members.length > 3 ? ` … (+${b.members.length - 3} more)` : '';
|
|
476
|
+
return {
|
|
477
|
+
id,
|
|
478
|
+
label: b.label,
|
|
479
|
+
desc: `${b.desc} · ${preview}${more}`,
|
|
480
|
+
tag: b.tag,
|
|
481
|
+
};
|
|
482
|
+
});
|
|
483
|
+
const picked = await _arrowMenu({
|
|
484
|
+
title: 'LazyClaw setup — Step 1 of 3: pick how you want to auth',
|
|
485
|
+
subtitle: 'API: bring your own key · CLI/Local: use what\'s already on this machine · Mock: offline test',
|
|
486
|
+
items: familyItems,
|
|
487
|
+
});
|
|
488
|
+
if (picked === 'CANCEL' || picked === 'BACK') return null;
|
|
489
|
+
family = picked;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// ── Step 2 — provider in that family ──────────────────────────
|
|
493
|
+
let provider = null;
|
|
494
|
+
while (!provider) {
|
|
495
|
+
const memberNames = families[family.id].members;
|
|
496
|
+
const provItems = memberNames.map((name) => {
|
|
497
|
+
const meta = info[name] || {};
|
|
498
|
+
const isCustom = !!meta.custom;
|
|
499
|
+
const isBuiltinCompat = !!meta.builtinOpenAICompat;
|
|
500
|
+
// Step-2 desc used to preview four suggested model ids per provider.
|
|
501
|
+
// That made the row read like "gemini · models: gemini-2.5-pro ·
|
|
502
|
+
// gemini-2.5-flash · gemini-2.0-flash · gemini-2.0-flash-thinking-exp",
|
|
503
|
+
// which is too dense and partly redundant — step 3 already shows the
|
|
504
|
+
// full curated list. Keep the row to a vendor label + endpoint hint.
|
|
505
|
+
let desc = '';
|
|
506
|
+
if (isCustom) desc = `custom · ${meta.baseUrl || ''}`;
|
|
507
|
+
else if (isBuiltinCompat) desc = meta.label || meta.baseUrl || '';
|
|
508
|
+
else if (meta.label && meta.label !== name) desc = meta.label;
|
|
509
|
+
return {
|
|
510
|
+
id: name,
|
|
511
|
+
label: name,
|
|
512
|
+
desc,
|
|
513
|
+
tag: isCustom
|
|
514
|
+
? '\x1b[38;5;213m[custom]\x1b[0m'
|
|
515
|
+
: (meta.requiresApiKey ? '\x1b[38;5;245m[api key]\x1b[0m' : '\x1b[38;5;208m[no key]\x1b[0m'),
|
|
516
|
+
};
|
|
517
|
+
});
|
|
518
|
+
// Surface a "+ Add a new custom endpoint…" entry inside the API-key
|
|
519
|
+
// family. NIM, OpenRouter, vLLM, LM Studio, Together, Groq, etc. all
|
|
520
|
+
// speak the OpenAI Chat-Completions wire format — this single hook
|
|
521
|
+
// covers every one of them without shipping a per-vendor provider.
|
|
522
|
+
if (family.id === 'api') {
|
|
523
|
+
provItems.push({
|
|
524
|
+
id: '__add_custom__',
|
|
525
|
+
label: '+ Add a custom OpenAI-compatible endpoint…',
|
|
526
|
+
desc: 'NVIDIA NIM · OpenRouter · Together · Groq · vLLM · LM Studio · …',
|
|
527
|
+
tag: '\x1b[38;5;213m[new]\x1b[0m',
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
if (memberNames.length === 1 && family.id !== 'api') {
|
|
531
|
+
// Auto-advance — no point making the user pick from a single row,
|
|
532
|
+
// unless we just appended the "+ Add custom" entry above.
|
|
533
|
+
provider = { id: memberNames[0] };
|
|
534
|
+
break;
|
|
535
|
+
}
|
|
536
|
+
const picked = await _arrowMenu({
|
|
537
|
+
title: `LazyClaw setup — Step 2 of 3: pick a ${family.label} provider`,
|
|
538
|
+
subtitle: `Showing ${provItems.length} ${family.label.toLowerCase()} option(s). Type to filter.`,
|
|
539
|
+
items: provItems,
|
|
540
|
+
searchable: true,
|
|
541
|
+
});
|
|
542
|
+
if (picked === 'CANCEL') return null;
|
|
543
|
+
if (picked === 'BACK') { family = null; return _pickProviderInteractive(); }
|
|
544
|
+
if (picked && picked.id === '__add_custom__') {
|
|
545
|
+
const added = await _addCustomProviderInteractive();
|
|
546
|
+
if (!added) continue; // back to provider list
|
|
547
|
+
// Force the registry to pick up the new entry and recompute the
|
|
548
|
+
// family bucket for the next loop iteration.
|
|
549
|
+
await ensureRegistry();
|
|
550
|
+
Object.assign(families, _providerFamilies());
|
|
551
|
+
provider = { id: added.name };
|
|
552
|
+
break;
|
|
553
|
+
}
|
|
554
|
+
provider = picked;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// ── Step 3 — model picker ────────────────────────────────────────
|
|
558
|
+
// v5.3.2 — the setup wizard no longer surfaces composite providers
|
|
559
|
+
// (orchestrator is filtered out of _providerFamilies above), so this
|
|
560
|
+
// step is just the regular model picker. The orchestrator wizard
|
|
561
|
+
// (_setupOrchestratorInteractive) stays reachable via the dedicated
|
|
562
|
+
// `lazyclaw orchestrator …` subcommand and an explicit
|
|
563
|
+
// `--provider orchestrator` invocation.
|
|
564
|
+
const picked = await _pickModelInteractive(provider.id, {
|
|
565
|
+
titlePrefix: 'LazyClaw setup — Step 3 of 3:',
|
|
566
|
+
onBack: 'restart',
|
|
567
|
+
});
|
|
568
|
+
if (picked === 'CANCEL') return null;
|
|
569
|
+
if (picked === 'BACK') return _pickProviderInteractive();
|
|
570
|
+
return { provider: provider.id, model: picked };
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Step-3 alternative for composite providers (currently only the
|
|
574
|
+
// orchestrator). Builds `cfg.orchestrator = { planner, workers,
|
|
575
|
+
// maxSubtasks }` interactively and persists it before returning.
|
|
576
|
+
//
|
|
577
|
+
// planner: single picker over registered non-composite providers.
|
|
578
|
+
// workers: multi-select with a running list + add/remove/done loop.
|
|
579
|
+
// maxSubtasks: typed integer, default 5.
|
|
580
|
+
export async function _setupOrchestratorInteractive() {
|
|
581
|
+
const accent = (s) => `\x1b[38;5;208m${s}\x1b[0m`;
|
|
582
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
583
|
+
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
584
|
+
const ok = (s) => `\x1b[32m${s}\x1b[0m`;
|
|
585
|
+
const info = getRegistry().PROVIDER_INFO || {};
|
|
586
|
+
const eligibleNames = Object.keys(getRegistry().PROVIDERS).filter((n) => n !== 'orchestrator' && n !== 'mock');
|
|
587
|
+
if (eligibleNames.length === 0) {
|
|
588
|
+
process.stdout.write('\n' + accent('orchestrator setup') + ': no eligible workers — register a real provider first.\n');
|
|
589
|
+
await _quickPrompt(' press Enter to continue ');
|
|
590
|
+
return 'CANCEL';
|
|
591
|
+
}
|
|
592
|
+
const cfg = readConfig();
|
|
593
|
+
const existing = cfg.orchestrator && typeof cfg.orchestrator === 'object' ? cfg.orchestrator : {};
|
|
594
|
+
|
|
595
|
+
// ── Pick planner ─────────────────────────────────────────────────
|
|
596
|
+
const plannerItems = eligibleNames.map((name) => {
|
|
597
|
+
const m = info[name] || {};
|
|
598
|
+
const defaultModel = m.defaultModel || '';
|
|
599
|
+
return {
|
|
600
|
+
id: `${name}${defaultModel ? ':' + defaultModel : ''}`,
|
|
601
|
+
label: m.label && m.label !== name ? `${name} — ${m.label}` : name,
|
|
602
|
+
desc: defaultModel ? `default model: ${defaultModel}` : '',
|
|
603
|
+
};
|
|
604
|
+
});
|
|
605
|
+
const plannerPick = await _arrowMenu({
|
|
606
|
+
title: 'LazyClaw setup — Step 3 of 3: orchestrator — pick the planner',
|
|
607
|
+
subtitle: 'The planner decomposes the user request into subtasks and writes the final synthesis. Strong reasoning models work best here.',
|
|
608
|
+
items: plannerItems,
|
|
609
|
+
searchable: true,
|
|
610
|
+
defaultIdx: Math.max(0, plannerItems.findIndex((p) => p.id === existing.planner)),
|
|
611
|
+
});
|
|
612
|
+
if (plannerPick === 'CANCEL') return 'CANCEL';
|
|
613
|
+
if (plannerPick === 'BACK') return 'BACK';
|
|
614
|
+
const planner = plannerPick.id;
|
|
615
|
+
|
|
616
|
+
// ── Pick workers (iterative add/remove) ──────────────────────────
|
|
617
|
+
const workers = Array.isArray(existing.workers) ? existing.workers.slice() : [];
|
|
618
|
+
while (true) {
|
|
619
|
+
process.stdout.write('\x1b[2J\x1b[H');
|
|
620
|
+
process.stdout.write(accent('Orchestrator workers') + '\n');
|
|
621
|
+
process.stdout.write(dim('Subtasks are dispatched round-robin across this list.') + '\n\n');
|
|
622
|
+
if (workers.length === 0) {
|
|
623
|
+
process.stdout.write(' ' + dim('(none yet — add at least one)') + '\n\n');
|
|
624
|
+
} else {
|
|
625
|
+
workers.forEach((w, i) => {
|
|
626
|
+
process.stdout.write(` ${i + 1}. ${ok(w)}\n`);
|
|
627
|
+
});
|
|
628
|
+
process.stdout.write('\n');
|
|
629
|
+
}
|
|
630
|
+
const items = [
|
|
631
|
+
{ id: '__add__', label: '+ Add a worker', desc: 'pick from registered providers' },
|
|
632
|
+
{ id: '__remove__', label: '- Remove a worker', desc: workers.length ? 'pick which entry to drop' : '(nothing to remove)' },
|
|
633
|
+
{ id: '__done__', label: `Done${workers.length ? ` (${workers.length} worker${workers.length === 1 ? '' : 's'})` : ' — at least one worker required'}`, desc: workers.length ? 'save cfg.orchestrator and finish' : 'add one worker first' },
|
|
634
|
+
];
|
|
635
|
+
const action = await _arrowMenu({
|
|
636
|
+
title: 'LazyClaw setup — orchestrator workers',
|
|
637
|
+
subtitle: `Planner: ${planner}`,
|
|
638
|
+
items,
|
|
639
|
+
});
|
|
640
|
+
if (action === 'CANCEL') return 'CANCEL';
|
|
641
|
+
if (action === 'BACK') return 'BACK';
|
|
642
|
+
if (action.id === '__add__') {
|
|
643
|
+
const wPick = await _arrowMenu({
|
|
644
|
+
title: 'Add worker',
|
|
645
|
+
subtitle: 'Picked entries are appended to the workers list.',
|
|
646
|
+
items: plannerItems.filter((p) => !workers.includes(p.id)),
|
|
647
|
+
searchable: true,
|
|
648
|
+
});
|
|
649
|
+
if (wPick === 'CANCEL' || wPick === 'BACK') continue;
|
|
650
|
+
workers.push(wPick.id);
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
653
|
+
if (action.id === '__remove__') {
|
|
654
|
+
if (!workers.length) continue;
|
|
655
|
+
const rPick = await _arrowMenu({
|
|
656
|
+
title: 'Remove worker',
|
|
657
|
+
subtitle: 'Highlighted entry is removed from the list.',
|
|
658
|
+
items: workers.map((w) => ({ id: w, label: w })),
|
|
659
|
+
});
|
|
660
|
+
if (rPick === 'CANCEL' || rPick === 'BACK') continue;
|
|
661
|
+
const idx = workers.indexOf(rPick.id);
|
|
662
|
+
if (idx >= 0) workers.splice(idx, 1);
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
if (action.id === '__done__') {
|
|
666
|
+
if (workers.length === 0) continue;
|
|
667
|
+
break;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// ── maxSubtasks ──────────────────────────────────────────────────
|
|
672
|
+
const defaultMax = Number.isFinite(existing.maxSubtasks) && existing.maxSubtasks > 0
|
|
673
|
+
? Math.min(10, existing.maxSubtasks)
|
|
674
|
+
: 5;
|
|
675
|
+
const rawMax = (await _quickPrompt(` ${bold('maxSubtasks')} ${dim(`(2..10, blank → ${defaultMax}):`)} `)).trim();
|
|
676
|
+
let maxSubtasks = defaultMax;
|
|
677
|
+
if (rawMax) {
|
|
678
|
+
const n = parseInt(rawMax, 10);
|
|
679
|
+
if (Number.isFinite(n) && n >= 1) maxSubtasks = Math.min(10, Math.max(1, n));
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// ── Persist ──────────────────────────────────────────────────────
|
|
683
|
+
cfg.orchestrator = { planner, workers, maxSubtasks };
|
|
684
|
+
writeConfig(cfg);
|
|
685
|
+
process.stdout.write('\n');
|
|
686
|
+
process.stdout.write(` ${ok('✓ orchestrator saved')} ${dim('→')} ` +
|
|
687
|
+
`planner ${ok(planner)} · ${workers.length} worker${workers.length === 1 ? '' : 's'} · maxSubtasks ${maxSubtasks}\n`);
|
|
688
|
+
await _quickPrompt(' press Enter to continue ');
|
|
689
|
+
return { ok: true };
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// Pause the chat REPL's readline + ghost-autocomplete while a sub-picker
|
|
693
|
+
// (provider / model arrow menu) takes over the terminal. The sub-picker
|
|
694
|
+
// installs its own `keypress` listener and toggles raw mode; the chat's
|
|
695
|
+
// readline would race it for stdin if we left it active. After `body`
|
|
696
|
+
// returns we re-emit keypress events, restore raw mode, and re-prompt
|
|
697
|
+
// so the chat resumes cleanly. `body` is awaited — exceptions propagate.
|
|
698
|
+
export async function _pauseChatForSubMenu(rl, ghost, body) {
|
|
699
|
+
if (ghost && typeof ghost.suspend === 'function') ghost.suspend();
|
|
700
|
+
try { rl.pause(); } catch (_) {}
|
|
701
|
+
// Drop the readline keypress hook so the picker's own listener has
|
|
702
|
+
// sole ownership while it's open. We re-arm it on the way out.
|
|
703
|
+
if (process.stdin.setRawMode) {
|
|
704
|
+
try { process.stdin.setRawMode(false); } catch (_) {}
|
|
705
|
+
}
|
|
706
|
+
try {
|
|
707
|
+
await body();
|
|
708
|
+
} finally {
|
|
709
|
+
const readline = await import('node:readline');
|
|
710
|
+
try { readline.emitKeypressEvents(process.stdin); } catch (_) {}
|
|
711
|
+
if (process.stdin.setRawMode && process.stdin.isTTY) {
|
|
712
|
+
try { process.stdin.setRawMode(false); } catch (_) {}
|
|
713
|
+
}
|
|
714
|
+
process.stdin.resume();
|
|
715
|
+
if (process.stdin.ref) process.stdin.ref();
|
|
716
|
+
if (ghost && typeof ghost.resume === 'function') ghost.resume();
|
|
717
|
+
try { rl.resume(); } catch (_) {}
|
|
718
|
+
try { rl.prompt(); } catch (_) {}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// Standalone model picker for the chat REPL's `/model` slash. Returns
|
|
723
|
+
// the chosen model id (string), 'BACK', or 'CANCEL'. Falls through to
|
|
724
|
+
// null when the provider has no curated models and no live-fetch surface
|
|
725
|
+
// (mock) — the caller should treat that as "use the provider default".
|
|
726
|
+
export async function _pickModelInteractive(providerId, opts = {}) {
|
|
727
|
+
const info = getRegistry().PROVIDER_INFO || {};
|
|
728
|
+
const meta = info[providerId] || {};
|
|
729
|
+
const baseModels = Array.isArray(meta.suggestedModels) ? meta.suggestedModels.slice() : [];
|
|
730
|
+
const isCustom = !!meta.custom;
|
|
731
|
+
const isBuiltinCompat = !!meta.builtinOpenAICompat;
|
|
732
|
+
const supportsLiveFetch = _supportsLiveFetch(meta, providerId);
|
|
733
|
+
|
|
734
|
+
if (!baseModels.length && !supportsLiveFetch) return null;
|
|
735
|
+
|
|
736
|
+
let dynamicModels = [];
|
|
737
|
+
while (true) {
|
|
738
|
+
const allModels = Array.from(new Set([...baseModels, ...dynamicModels]));
|
|
739
|
+
const modelItems = allModels.map((m) => ({ id: m, label: m, desc: '' }));
|
|
740
|
+
if (supportsLiveFetch) {
|
|
741
|
+
modelItems.unshift({
|
|
742
|
+
id: '__fetch_models__',
|
|
743
|
+
label: '↻ Fetch live model list from /v1/models',
|
|
744
|
+
desc: isCustom || isBuiltinCompat ? `GET ${meta.baseUrl}/models` : 'pulls the up-to-date catalogue from the provider',
|
|
745
|
+
tag: '\x1b[38;5;245m[live]\x1b[0m',
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
modelItems.push({
|
|
749
|
+
id: '__custom_model__',
|
|
750
|
+
label: '… type a custom model id',
|
|
751
|
+
desc: 'use any model id supported by this provider, even if not listed above',
|
|
752
|
+
tag: '\x1b[38;5;245m[free]\x1b[0m',
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
const defaultIdx = supportsLiveFetch
|
|
756
|
+
? Math.max(0, 1 + allModels.indexOf(meta.defaultModel || allModels[0]))
|
|
757
|
+
: Math.max(0, allModels.indexOf(meta.defaultModel || allModels[0]));
|
|
758
|
+
const titlePrefix = opts.titlePrefix ? `${opts.titlePrefix} ` : '';
|
|
759
|
+
const picked = await _arrowMenu({
|
|
760
|
+
title: `${titlePrefix}pick a model for ${providerId}`,
|
|
761
|
+
subtitle: `Type to filter ${allModels.length} model(s). Enter to confirm. Backspace clears one char, Ctrl+U clears the filter.`,
|
|
762
|
+
items: modelItems,
|
|
763
|
+
defaultIdx,
|
|
764
|
+
searchable: true,
|
|
765
|
+
});
|
|
766
|
+
if (picked === 'CANCEL') return 'CANCEL';
|
|
767
|
+
if (picked === 'BACK') return 'BACK';
|
|
768
|
+
if (picked.id === '__custom_model__') {
|
|
769
|
+
const typed = (await _quickPrompt(` model id for ${providerId}: `)).trim();
|
|
770
|
+
if (!typed) continue;
|
|
771
|
+
return typed;
|
|
772
|
+
}
|
|
773
|
+
if (picked.id === '__fetch_models__') {
|
|
774
|
+
try {
|
|
775
|
+
process.stdout.write(`\n fetching ${providerId} model list…\n`);
|
|
776
|
+
const fetched = await _fetchModelsForProvider(providerId);
|
|
777
|
+
if (!fetched.length) {
|
|
778
|
+
process.stdout.write(` ${'\x1b[33m'}no models returned${'\x1b[0m'} — falling back to the suggested list.\n`);
|
|
779
|
+
await _quickPrompt(' press Enter to continue ');
|
|
780
|
+
} else {
|
|
781
|
+
dynamicModels = fetched;
|
|
782
|
+
process.stdout.write(` fetched ${fetched.length} model(s).\n`);
|
|
783
|
+
await _quickPrompt(' press Enter to pick one ');
|
|
784
|
+
}
|
|
785
|
+
} catch (e) {
|
|
786
|
+
process.stdout.write(`\n ${'\x1b[33m'}fetch failed:${'\x1b[0m'} ${e?.message || e}\n`);
|
|
787
|
+
await _quickPrompt(' press Enter to continue ');
|
|
788
|
+
}
|
|
789
|
+
continue;
|
|
790
|
+
}
|
|
791
|
+
return picked.id;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// Resolve {baseUrl, apiKey} for a provider so we can call /v1/models on
|
|
796
|
+
// its behalf. Returns null when the provider doesn't expose an OpenAI-
|
|
797
|
+
// compatible model catalogue (e.g. anthropic, gemini, claude-cli).
|
|
798
|
+
export function _modelCatalogueFor(providerId) {
|
|
799
|
+
const cfg = readConfig();
|
|
800
|
+
return _modelCatalogueResolve({
|
|
801
|
+
cfg,
|
|
802
|
+
registryMod: getRegistry(),
|
|
803
|
+
resolveAuthKey: (id) => _resolveAuthKey(cfg, id),
|
|
804
|
+
providerId,
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
export async function _fetchModelsForProvider(providerId) {
|
|
809
|
+
const cfg = readConfig();
|
|
810
|
+
return _fetchModelsResolve({
|
|
811
|
+
cfg,
|
|
812
|
+
registryMod: getRegistry(),
|
|
813
|
+
resolveAuthKey: (id) => _resolveAuthKey(cfg, id),
|
|
814
|
+
providerId,
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// Walk the user through registering a new OpenAI-compatible custom
|
|
819
|
+
// provider (NIM, OpenRouter, vLLM, LM Studio, Together, Groq, …).
|
|
820
|
+
// Persists into cfg.customProviders[] and returns { name } on success,
|
|
821
|
+
// or null when the user backs out.
|
|
822
|
+
export async function _addCustomProviderInteractive() {
|
|
823
|
+
const accent = (s) => `\x1b[38;5;208m${s}\x1b[0m`;
|
|
824
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
825
|
+
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
826
|
+
const ok = (s) => `\x1b[32m${s}\x1b[0m`;
|
|
827
|
+
|
|
828
|
+
process.stdout.write('\x1b[2J\x1b[H');
|
|
829
|
+
process.stdout.write(accent('Add a custom OpenAI-compatible endpoint') + '\n');
|
|
830
|
+
process.stdout.write(dim('Works with any service that speaks the OpenAI v1 wire format.') + '\n');
|
|
831
|
+
process.stdout.write(dim('Examples:') + '\n');
|
|
832
|
+
process.stdout.write(dim(' · NVIDIA NIM https://integrate.api.nvidia.com/v1') + '\n');
|
|
833
|
+
process.stdout.write(dim(' · OpenRouter https://openrouter.ai/api/v1') + '\n');
|
|
834
|
+
process.stdout.write(dim(' · Together AI https://api.together.xyz/v1') + '\n');
|
|
835
|
+
process.stdout.write(dim(' · Groq https://api.groq.com/openai/v1') + '\n');
|
|
836
|
+
process.stdout.write(dim(' · vLLM / LM Studio http://localhost:8000/v1') + '\n\n');
|
|
837
|
+
|
|
838
|
+
const { validateCustomProviderName, registerCustomProviders, fetchOpenAICompatModels, isBuiltinOpenAICompatName } = getRegistry();
|
|
839
|
+
let name;
|
|
840
|
+
while (true) {
|
|
841
|
+
const raw = (await _quickPrompt(` ${bold('name')} ${dim('(short id, e.g. "nim", "openrouter"):')} `)).trim();
|
|
842
|
+
if (!raw) {
|
|
843
|
+
process.stdout.write(dim(' cancelled — back to the picker.\n'));
|
|
844
|
+
return null;
|
|
845
|
+
}
|
|
846
|
+
try { name = validateCustomProviderName(raw); }
|
|
847
|
+
catch (e) {
|
|
848
|
+
process.stdout.write(` \x1b[33m${e.message}\x1b[0m — try again.\n`);
|
|
849
|
+
continue;
|
|
850
|
+
}
|
|
851
|
+
// OpenAI-compat builtins (nim / openrouter / groq / …) can be overridden
|
|
852
|
+
// by a custom entry of the same name — both go through
|
|
853
|
+
// makeOpenAICompatProvider, so the wire format is identical and the
|
|
854
|
+
// user is just pointing the same alias at a different URL/key. Surface
|
|
855
|
+
// the override so it isn't a silent surprise.
|
|
856
|
+
if (typeof isBuiltinOpenAICompatName === 'function' && isBuiltinOpenAICompatName(name)) {
|
|
857
|
+
process.stdout.write(
|
|
858
|
+
` \x1b[2mNote: "${name}" is a built-in OpenAI-compatible provider; ` +
|
|
859
|
+
`your custom entry will override the built-in baseUrl/api-key for this install. ` +
|
|
860
|
+
`Remove with: lazyclaw providers remove ${name}\x1b[0m\n`
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
break;
|
|
864
|
+
}
|
|
865
|
+
const baseUrlRaw = (await _quickPrompt(` ${bold('baseUrl')} ${dim('(must end in /v1, no trailing slash needed):')} `)).trim();
|
|
866
|
+
if (!baseUrlRaw) { process.stdout.write(dim(' cancelled — baseUrl is required.\n')); return null; }
|
|
867
|
+
if (!/^https?:\/\//i.test(baseUrlRaw)) {
|
|
868
|
+
process.stdout.write(' \x1b[33mbaseUrl must start with http:// or https://\x1b[0m — cancelled.\n');
|
|
869
|
+
return null;
|
|
870
|
+
}
|
|
871
|
+
const apiKey = (await _quickPrompt(` ${bold('api-key')} ${dim('(blank if the endpoint is auth-less, e.g. local vLLM):')} `)).trim();
|
|
872
|
+
|
|
873
|
+
// Persist + hot-register + best-effort probe via the shared, unit-tested
|
|
874
|
+
// core (providers/custom_provider.mjs) so this readline wizard and the Ink
|
|
875
|
+
// /provider add flow can't drift. The interactive prompts above already
|
|
876
|
+
// validated name + baseUrl; addCustomProvider re-validates harmlessly.
|
|
877
|
+
const result = await addCustomProvider({
|
|
878
|
+
registry: getRegistry(),
|
|
879
|
+
readConfig,
|
|
880
|
+
writeConfig,
|
|
881
|
+
name,
|
|
882
|
+
baseUrl: baseUrlRaw,
|
|
883
|
+
apiKey,
|
|
884
|
+
});
|
|
885
|
+
const entry = { name: result.name, baseUrl: result.baseUrl };
|
|
886
|
+
let probeMsg;
|
|
887
|
+
if (result.probe.ok && result.probe.count > 0) {
|
|
888
|
+
probeMsg = ` ${ok('✓')} reachable — ${result.probe.count} model(s) advertised at ${entry.baseUrl}/models\n`;
|
|
889
|
+
} else if (result.probe.ok) {
|
|
890
|
+
probeMsg = ` ${ok('✓')} registered — /v1/models returned no entries (will rely on free-text model id).\n`;
|
|
891
|
+
} else {
|
|
892
|
+
probeMsg = ` \x1b[33m!\x1b[0m registered, but /v1/models probe failed: ${result.probe.error}\n`;
|
|
893
|
+
}
|
|
894
|
+
process.stdout.write('\n');
|
|
895
|
+
process.stdout.write(` ${ok(bold('✓ custom provider saved:'))} ${entry.name} ${dim('→')} ${entry.baseUrl}\n`);
|
|
896
|
+
process.stdout.write(probeMsg);
|
|
897
|
+
process.stdout.write(dim(` Removable any time via: lazyclaw providers remove ${name}\n`));
|
|
898
|
+
await _quickPrompt(' press Enter to continue ');
|
|
899
|
+
return { name };
|
|
900
|
+
}
|
|
901
|
+
export async function _quickPrompt(label) {
|
|
902
|
+
const readline = await import('node:readline');
|
|
903
|
+
process.stdout.write('\n');
|
|
904
|
+
// Make sure stdin is in cooked / line-buffered mode for the
|
|
905
|
+
// duration of the prompt — a prior `_arrowMenu` may have left raw
|
|
906
|
+
// mode on, in which case readline.question() never fires its
|
|
907
|
+
// line-event because each byte is delivered as a keypress instead.
|
|
908
|
+
if (process.stdin.isTTY && process.stdin.setRawMode) {
|
|
909
|
+
try { process.stdin.setRawMode(false); } catch (_) {}
|
|
910
|
+
}
|
|
911
|
+
process.stdin.resume();
|
|
912
|
+
if (process.stdin.ref) process.stdin.ref();
|
|
913
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
914
|
+
const ans = await new Promise((resolve) => rl.question(label, resolve));
|
|
915
|
+
rl.close();
|
|
916
|
+
return ans.trim();
|
|
917
|
+
}
|