lazyclaw 5.4.2 → 5.4.4
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/cli.mjs +14 -0
- package/package.json +1 -1
- package/tui/editor.mjs +182 -28
- package/tui/modal_picker.mjs +166 -0
- package/tui/repl.mjs +120 -2
- package/tui/slash_commands.mjs +7 -5
- package/tui/slash_dispatcher.mjs +484 -47
- package/tui/splash.mjs +6 -27
package/cli.mjs
CHANGED
|
@@ -2682,6 +2682,19 @@ async function cmdChat(flags = {}) {
|
|
|
2682
2682
|
resolveAuthKey: (providerName) => _resolveAuthKey(cfg, providerName),
|
|
2683
2683
|
onCharsSent: (n) => { _inkCharsSent += Number(n) || 0; },
|
|
2684
2684
|
};
|
|
2685
|
+
// v5.4.3 — ReplApp exposes an openPicker(opts) → Promise<id|null>
|
|
2686
|
+
// via this ref. The slash dispatcher reads it through ctx.openPicker
|
|
2687
|
+
// to drive /provider, /model, /personality without forking off raw
|
|
2688
|
+
// stdin from Ink. When ReplApp hasn't populated the ref yet (early
|
|
2689
|
+
// mount / non-Ink path) the dispatcher falls back to its hint
|
|
2690
|
+
// string so users aren't stranded.
|
|
2691
|
+
const _inkPickerRef = { current: null };
|
|
2692
|
+
_inkCtx.openPicker = (opts) => {
|
|
2693
|
+
const api = _inkPickerRef.current;
|
|
2694
|
+
return api && typeof api.openPicker === 'function'
|
|
2695
|
+
? api.openPicker(opts)
|
|
2696
|
+
: Promise.resolve(null);
|
|
2697
|
+
};
|
|
2685
2698
|
// v5.0.10: write streamed chunks straight to process.stdout. Ink
|
|
2686
2699
|
// owns the screen, so interleaved stdout writes can produce some
|
|
2687
2700
|
// visual jank — accepted trade for unblocking the chat loop. v5.1
|
|
@@ -2711,6 +2724,7 @@ async function cmdChat(flags = {}) {
|
|
|
2711
2724
|
statusInfo: { provider: activeProvName, model: activeModel },
|
|
2712
2725
|
runTurn: _inkRunTurn,
|
|
2713
2726
|
onSlashCommand: _inkSlashHandler,
|
|
2727
|
+
pickerRef: _inkPickerRef,
|
|
2714
2728
|
}), { exitOnCtrlC: true, patchConsole: true });
|
|
2715
2729
|
await ink.waitUntilExit();
|
|
2716
2730
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lazyclaw",
|
|
3
|
-
"version": "5.4.
|
|
3
|
+
"version": "5.4.4",
|
|
4
4
|
"description": "Lazy, elegant terminal CLI for chatting with Claude / OpenAI / Gemini / Ollama, orchestrating multi-step LLM workflows, and running multi-agent Slack teams with cross-task memory. Banner-on-launch, slash-command ghost autocomplete, persistent sessions, local HTTP gateway.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
package/tui/editor.mjs
CHANGED
|
@@ -50,6 +50,74 @@ export function displayWidth(text) {
|
|
|
50
50
|
return stringWidth(String(text));
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
// ─── IME cursor anchor (v5.4.4) ─────────────────────────────────────
|
|
54
|
+
//
|
|
55
|
+
// v5.4.3 shipped an anchor that moved the cursor inside the editor
|
|
56
|
+
// after every render so IME pre-edit composition appeared in the
|
|
57
|
+
// editor box. It also caused visible flicker because Ink's log-update
|
|
58
|
+
// (node_modules/ink/build/log-update.js) emits an eraseLines sequence
|
|
59
|
+
// (`\x1b[2K\x1b[1A...`) on every redraw — and that sequence walks UP
|
|
60
|
+
// from the CURRENT cursor position. With our anchor up inside the
|
|
61
|
+
// editor, eraseLines erased rows ABOVE the frame, then wrote the new
|
|
62
|
+
// frame starting one editor-height higher than the previous one.
|
|
63
|
+
//
|
|
64
|
+
// v5.4.4 fix — monkey-patch process.stdout.write the first time the
|
|
65
|
+
// anchor fires. When the patched writer sees a chunk that BEGINS with
|
|
66
|
+
// `\x1b[2K` (the start of log-update's eraseLines) AND the anchor
|
|
67
|
+
// offset is non-zero, it prepends `\x1b[<offset>B\r` to move the
|
|
68
|
+
// cursor BACK DOWN to the row log-update expects (one below the
|
|
69
|
+
// previous frame's last line). The user sees no flicker; IME still
|
|
70
|
+
// reads the editor cursor position because the anchor lives across
|
|
71
|
+
// the gap between renders.
|
|
72
|
+
const _anchorState = { offset: 0, shimmed: false };
|
|
73
|
+
|
|
74
|
+
function _installAnchorShim() {
|
|
75
|
+
if (_anchorState.shimmed) return;
|
|
76
|
+
if (!(process.stdout && typeof process.stdout.write === 'function')) return;
|
|
77
|
+
const orig = process.stdout.write.bind(process.stdout);
|
|
78
|
+
process.stdout.write = function patchedWrite(chunk, ...rest) {
|
|
79
|
+
try {
|
|
80
|
+
if (
|
|
81
|
+
_anchorState.offset > 0 &&
|
|
82
|
+
typeof chunk === 'string' &&
|
|
83
|
+
chunk.startsWith('\x1b[2K')
|
|
84
|
+
) {
|
|
85
|
+
const off = _anchorState.offset;
|
|
86
|
+
_anchorState.offset = 0;
|
|
87
|
+
return orig.call(this, `\x1b[${off}B\r` + chunk, ...rest);
|
|
88
|
+
}
|
|
89
|
+
} catch { /* fall through to unmodified write */ }
|
|
90
|
+
return orig.call(this, chunk, ...rest);
|
|
91
|
+
};
|
|
92
|
+
_anchorState.shimmed = true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Cell-aware soft-wrap. Returns an array of visual rows whose width
|
|
96
|
+
// respects the budget (first row uses `firstBudget`, subsequent rows
|
|
97
|
+
// use `contBudget`). Hoisted to module level (was inner-fn) so the
|
|
98
|
+
// cursor-anchor useEffect in <Editor/> can reuse it.
|
|
99
|
+
export function wrapToBudget(text, firstBudget, contBudget) {
|
|
100
|
+
if (!text) return [''];
|
|
101
|
+
const out = [];
|
|
102
|
+
let line = '';
|
|
103
|
+
let lineW = 0;
|
|
104
|
+
let budget = firstBudget;
|
|
105
|
+
for (const ch of text) {
|
|
106
|
+
const w = stringWidth(ch);
|
|
107
|
+
if (lineW + w > budget) {
|
|
108
|
+
out.push(line);
|
|
109
|
+
line = ch;
|
|
110
|
+
lineW = w;
|
|
111
|
+
budget = contBudget;
|
|
112
|
+
} else {
|
|
113
|
+
line += ch;
|
|
114
|
+
lineW += w;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
out.push(line);
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
53
121
|
// Display column of the caret given a state. Counts wide chars as 2.
|
|
54
122
|
// On the first rendered line this is offset by PROMPT_WIDTH; on
|
|
55
123
|
// continuation lines (after a Shift+Enter) it is offset by
|
|
@@ -146,6 +214,22 @@ export function Editor({
|
|
|
146
214
|
slashSelectedIndex, // number
|
|
147
215
|
onSlashMove, // (delta: -1 | +1) => void
|
|
148
216
|
onSlashDismiss, // () => void
|
|
217
|
+
// v5.4.3 modal-picker wiring (all optional — pre-v5.4.3 callers pass none).
|
|
218
|
+
// When modalOpen is true the Editor intercepts every key and routes it
|
|
219
|
+
// to the host callbacks; nothing reaches applyKey / onSubmit so the
|
|
220
|
+
// chat buffer is untouched while a picker is up.
|
|
221
|
+
modalOpen, // boolean
|
|
222
|
+
modalQuery, // string — current filter buffer (host-owned)
|
|
223
|
+
onModalMove, // (delta: -1 | +1) => void
|
|
224
|
+
onModalConfirm, // () => void
|
|
225
|
+
onModalCancel, // () => void
|
|
226
|
+
onModalQuery, // (next: string) => void
|
|
227
|
+
// v5.4.3 IME cursor anchor — when altEnabled is true, the Editor
|
|
228
|
+
// moves the terminal cursor back inside its content row after each
|
|
229
|
+
// render so macOS IMEs (Hangul / Japanese / Chinese) draw their
|
|
230
|
+
// pre-edit overlay at the actual typing position. Opt-out via the
|
|
231
|
+
// LAZYCLAW_NO_CURSOR_ANCHOR env var (handled internally).
|
|
232
|
+
altEnabled,
|
|
149
233
|
}) {
|
|
150
234
|
const [state, setState] = useState(() => makeEditorState({ history }));
|
|
151
235
|
const slashOpen = Array.isArray(slashSuggestions) && slashSuggestions.length > 0;
|
|
@@ -167,6 +251,35 @@ export function Editor({
|
|
|
167
251
|
|
|
168
252
|
useInput((input, key) => {
|
|
169
253
|
const current = stateRef.current;
|
|
254
|
+
// ─── Modal-picker keyboard contract (highest priority — v5.4.3) ──
|
|
255
|
+
// When a host-owned picker is up, EVERY key is consumed by the
|
|
256
|
+
// picker. The chat buffer is never mutated and onSubmit is never
|
|
257
|
+
// fired. This is what prevents "Enter while picking a model" from
|
|
258
|
+
// accidentally submitting the typed-so-far buffer as a chat turn.
|
|
259
|
+
if (modalOpen) {
|
|
260
|
+
if (key.escape) { if (onModalCancel) onModalCancel(); return; }
|
|
261
|
+
if (key.return) { if (onModalConfirm) onModalConfirm(); return; }
|
|
262
|
+
if (key.upArrow) { if (onModalMove) onModalMove(-1); return; }
|
|
263
|
+
if (key.downArrow) { if (onModalMove) onModalMove(+1); return; }
|
|
264
|
+
if (key.pageUp) { if (onModalMove) onModalMove(-10); return; }
|
|
265
|
+
if (key.pageDown) { if (onModalMove) onModalMove(+10); return; }
|
|
266
|
+
// Ctrl+U clears the filter (Unix-style line kill).
|
|
267
|
+
if (key.ctrl && (input === 'u' || input === 'U')) {
|
|
268
|
+
if (onModalQuery) onModalQuery('');
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (key.backspace || key.delete) {
|
|
272
|
+
if (onModalQuery) onModalQuery(String(modalQuery || '').slice(0, -1));
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
// Printable single char (no modifier) appends to filter.
|
|
276
|
+
if (input && !key.ctrl && !key.meta && input.length >= 1 && !key.tab) {
|
|
277
|
+
if (onModalQuery) onModalQuery(String(modalQuery || '') + input);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
// Anything else (Tab, function keys, etc.) swallowed.
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
170
283
|
// ─── Slash-popup keyboard contract (highest priority when open) ──
|
|
171
284
|
if (slashOpen) {
|
|
172
285
|
// Esc: clear the buffer and dismiss the popup. The host's onEscape
|
|
@@ -232,38 +345,79 @@ export function Editor({
|
|
|
232
345
|
if (state.lastSubmit !== null && onSubmit) onSubmit(state.lastSubmit);
|
|
233
346
|
}, [state.lastSubmit]);
|
|
234
347
|
|
|
348
|
+
// v5.4.3 — IME cursor anchor.
|
|
349
|
+
//
|
|
350
|
+
// After every Ink render commit, move the terminal cursor BACK inside
|
|
351
|
+
// the editor's content row so macOS Hangul / Japanese / Chinese IMEs
|
|
352
|
+
// draw their pre-edit overlay where the user is actually typing
|
|
353
|
+
// (instead of on the row below the editor box, where Ink's log-update
|
|
354
|
+
// parks the cursor by default via its trailing '\n').
|
|
355
|
+
//
|
|
356
|
+
// Trade-off: each render briefly moves the cursor, which means the
|
|
357
|
+
// NEXT render's log-update eraseLines starts from inside the editor.
|
|
358
|
+
// Ink immediately overwrites with the full new frame, so the user
|
|
359
|
+
// sees at most a one-tick flicker. The IME-correctness win is worth
|
|
360
|
+
// the cosmetic cost. Opt out via LAZYCLAW_NO_CURSOR_ANCHOR=1.
|
|
361
|
+
useEffect(() => {
|
|
362
|
+
if (!altEnabled) return;
|
|
363
|
+
if (process.env.LAZYCLAW_NO_CURSOR_ANCHOR === '1') return;
|
|
364
|
+
if (!(process.stdout && process.stdout.isTTY)) return;
|
|
365
|
+
const cols = Math.max(20, process.stdout.columns || 80);
|
|
366
|
+
const inner = Math.max(8, cols - 4);
|
|
367
|
+
const fb = inner - PROMPT_WIDTH;
|
|
368
|
+
const cb = inner - CONTINUATION_WIDTH;
|
|
369
|
+
|
|
370
|
+
// Count total rendered rows (for "rowsUp" math) and locate the
|
|
371
|
+
// cursor's (rowInEditor, colInLine) by wrapping the buffer up to
|
|
372
|
+
// the codepoint cursor position.
|
|
373
|
+
const fullLines = String(state.buffer || '').split('\n');
|
|
374
|
+
let totalRows = 0;
|
|
375
|
+
for (let li = 0; li < fullLines.length; li++) {
|
|
376
|
+
const wrapped = wrapToBudget(fullLines[li], fb, cb);
|
|
377
|
+
totalRows += wrapped.length;
|
|
378
|
+
}
|
|
379
|
+
const before = String(state.buffer || '').slice(0, state.cursor || 0);
|
|
380
|
+
const beforeLines = before.split('\n');
|
|
381
|
+
let rowInEditor = 0;
|
|
382
|
+
for (let li = 0; li < beforeLines.length - 1; li++) {
|
|
383
|
+
const wrapped = wrapToBudget(beforeLines[li], fb, cb);
|
|
384
|
+
rowInEditor += wrapped.length;
|
|
385
|
+
}
|
|
386
|
+
const lastBefore = beforeLines[beforeLines.length - 1] || '';
|
|
387
|
+
const lastWrapped = wrapToBudget(lastBefore, fb, cb);
|
|
388
|
+
rowInEditor += lastWrapped.length - 1;
|
|
389
|
+
const lastSegment = lastWrapped[lastWrapped.length - 1] || '';
|
|
390
|
+
const colInLine = stringWidth(lastSegment);
|
|
391
|
+
|
|
392
|
+
// After Ink writes `<frame>\n`, cursor sits on the line below the
|
|
393
|
+
// editor's bottom border at column 0. Editor geometry: 1 top border
|
|
394
|
+
// + totalRows content + 1 bottom border. So the row count between
|
|
395
|
+
// "below editor" and the cursor's target content row is
|
|
396
|
+
// (1 trailing-\n + 1 bottom border + (totalRows - 1 - rowInEditor))
|
|
397
|
+
// = totalRows + 1 - rowInEditor.
|
|
398
|
+
const rowsUp = Math.max(1, totalRows + 1 - rowInEditor);
|
|
399
|
+
|
|
400
|
+
// Column: 1-indexed. Left border at col 1, padX at col 2, prefix
|
|
401
|
+
// starts at col 3. Cursor sits one cell past the typed content.
|
|
402
|
+
const prefixWidth = rowInEditor === 0 ? PROMPT_WIDTH : CONTINUATION_WIDTH;
|
|
403
|
+
const colTarget = 3 + prefixWidth + colInLine;
|
|
404
|
+
_installAnchorShim();
|
|
405
|
+
_anchorState.offset = rowsUp;
|
|
406
|
+
try {
|
|
407
|
+
process.stdout.write(`\x1b[${rowsUp}A\x1b[${colTarget}G\x1b[?25h`);
|
|
408
|
+
} catch { /* stdout closed — swallow */ }
|
|
409
|
+
}, [state.buffer, state.cursor, altEnabled]);
|
|
410
|
+
|
|
235
411
|
const lines = state.buffer.split('\n');
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
// the actual
|
|
241
|
-
//
|
|
242
|
-
// budget so Ink never has to guess.
|
|
412
|
+
// Ink's <Text wrap="wrap"> uses wrap-ansi (string-width aware) but the
|
|
413
|
+
// box's width resolves against ink-testing-library's stdout shim and
|
|
414
|
+
// some real terminals at 100 cols regardless of the actual viewport,
|
|
415
|
+
// so wide CJK buffers bleed past the right edge in narrow terminals.
|
|
416
|
+
// Pre-wrap to the actual cell budget via the module-level wrapToBudget
|
|
417
|
+
// (see top of this file) so Ink never has to guess.
|
|
243
418
|
const TERM = Math.max(20, process.stdout.columns || 80);
|
|
244
419
|
// Box overhead: 1 border + 1 padX on each side = 4 cells; first row
|
|
245
420
|
// also reserves PROMPT_WIDTH; continuation rows reserve CONTINUATION_WIDTH.
|
|
246
|
-
function wrapToBudget(text, firstBudget, contBudget) {
|
|
247
|
-
if (!text) return [''];
|
|
248
|
-
const out = [];
|
|
249
|
-
let line = '';
|
|
250
|
-
let lineW = 0;
|
|
251
|
-
let budget = firstBudget;
|
|
252
|
-
for (const ch of text) {
|
|
253
|
-
const w = stringWidth(ch);
|
|
254
|
-
if (lineW + w > budget) {
|
|
255
|
-
out.push(line);
|
|
256
|
-
line = ch;
|
|
257
|
-
lineW = w;
|
|
258
|
-
budget = contBudget;
|
|
259
|
-
} else {
|
|
260
|
-
line += ch;
|
|
261
|
-
lineW += w;
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
out.push(line);
|
|
265
|
-
return out;
|
|
266
|
-
}
|
|
267
421
|
const innerCells = Math.max(8, TERM - 4); // 2 border + 2 padX
|
|
268
422
|
const renderedLines = [];
|
|
269
423
|
for (let li = 0; li < lines.length; li++) {
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// tui/modal_picker.mjs — v5.4.3 in-Ink modal picker.
|
|
2
|
+
//
|
|
3
|
+
// Used by /provider, /model, /personality (and any future overlay slash
|
|
4
|
+
// that needs a single-shot selection from a fixed list). Mounted as a
|
|
5
|
+
// flex sibling above <StatusBar/> when ReplApp's `modal` state is set;
|
|
6
|
+
// Editor intercepts keys (Up/Down/Enter/Esc/printable filter) and
|
|
7
|
+
// forwards them as host callbacks. The component itself is purely
|
|
8
|
+
// presentational + a pure scoring helper so it stays snapshot-testable.
|
|
9
|
+
//
|
|
10
|
+
// Picker resolves via Promise (ReplApp's openPicker(opts) returns one).
|
|
11
|
+
// Confirm → resolve(itemId). Cancel/Esc → resolve(null). Unmount during
|
|
12
|
+
// active picker → also resolve(null) so dispatcher promises never hang.
|
|
13
|
+
|
|
14
|
+
import React from 'react';
|
|
15
|
+
import { Box, Text } from 'ink';
|
|
16
|
+
import stringWidth from 'string-width';
|
|
17
|
+
import { theme } from './theme.mjs';
|
|
18
|
+
|
|
19
|
+
const DEFAULT_MAX_ROWS = 12;
|
|
20
|
+
|
|
21
|
+
// Pure filter — prefix > substring > subsequence. Stable order within
|
|
22
|
+
// each tier (original list order is the tiebreaker).
|
|
23
|
+
export function filterModalItems(query, items) {
|
|
24
|
+
const q = String(query || '').trim().toLowerCase();
|
|
25
|
+
const list = Array.isArray(items) ? items : [];
|
|
26
|
+
if (!q) return list.slice();
|
|
27
|
+
const prefix = [], substr = [], subseq = [];
|
|
28
|
+
for (const it of list) {
|
|
29
|
+
const hay = `${it.label || it.id || ''} ${it.desc || ''}`.toLowerCase();
|
|
30
|
+
if (hay.startsWith(q)) prefix.push(it);
|
|
31
|
+
else if (hay.includes(q)) substr.push(it);
|
|
32
|
+
else if (_isSubseq(q, hay)) subseq.push(it);
|
|
33
|
+
}
|
|
34
|
+
return [...prefix, ...substr, ...subseq];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function _isSubseq(needle, hay) {
|
|
38
|
+
let i = 0;
|
|
39
|
+
for (const ch of hay) {
|
|
40
|
+
if (ch === needle[i]) i++;
|
|
41
|
+
if (i === needle.length) return true;
|
|
42
|
+
}
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Pure window computation — slide a window of `maxRows` items so that
|
|
47
|
+
// `selectedIndex` is always visible. Mirrors the pattern in
|
|
48
|
+
// tui/slash_popup.mjs._computeWindow.
|
|
49
|
+
export function _computeWindow(idx, total, maxRows) {
|
|
50
|
+
const n = Math.max(0, total);
|
|
51
|
+
const m = Math.max(1, maxRows);
|
|
52
|
+
if (n <= m) return { start: 0, end: n };
|
|
53
|
+
let start = Math.max(0, Math.min(n - m, idx - Math.floor(m / 2)));
|
|
54
|
+
return { start, end: start + m };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Presentational component.
|
|
58
|
+
//
|
|
59
|
+
// Props:
|
|
60
|
+
// title:string — top label (bold)
|
|
61
|
+
// subtitle?:string — second row (dim)
|
|
62
|
+
// items:Array — [{id, label, desc?, tag?}]
|
|
63
|
+
// selectedIndex:number — host-owned selection cursor (in filtered list)
|
|
64
|
+
// query:string — host-owned filter buffer
|
|
65
|
+
// searchable?:boolean — show the filter row (default true)
|
|
66
|
+
// maxRows?:number — viewport height (default 12)
|
|
67
|
+
// toast?:string — transient one-line message below the list
|
|
68
|
+
// columns?:number — terminal width override (tests)
|
|
69
|
+
export function ModalPicker({
|
|
70
|
+
title,
|
|
71
|
+
subtitle,
|
|
72
|
+
items,
|
|
73
|
+
selectedIndex,
|
|
74
|
+
query,
|
|
75
|
+
searchable = true,
|
|
76
|
+
maxRows = DEFAULT_MAX_ROWS,
|
|
77
|
+
toast,
|
|
78
|
+
columns,
|
|
79
|
+
}) {
|
|
80
|
+
const list = Array.isArray(items) ? items : [];
|
|
81
|
+
const total = list.length;
|
|
82
|
+
const idx = Math.max(0, Math.min(total - 1, Number.isFinite(selectedIndex) ? selectedIndex : 0));
|
|
83
|
+
const { start, end } = _computeWindow(idx, total, maxRows);
|
|
84
|
+
const visible = list.slice(start, end);
|
|
85
|
+
const cols = Number.isFinite(columns) ? columns : (process.stdout.columns || 80);
|
|
86
|
+
|
|
87
|
+
// Label column width = longest visible label, capped at 36.
|
|
88
|
+
const labelW = Math.min(36, Math.max(
|
|
89
|
+
8,
|
|
90
|
+
...visible.map((it) => stringWidth(String(it.label || it.id || '')))
|
|
91
|
+
));
|
|
92
|
+
|
|
93
|
+
const rows = [];
|
|
94
|
+
|
|
95
|
+
// Title.
|
|
96
|
+
rows.push(React.createElement(
|
|
97
|
+
Text, { key: 'title', bold: true }, String(title || 'select')
|
|
98
|
+
));
|
|
99
|
+
if (subtitle) {
|
|
100
|
+
rows.push(React.createElement(
|
|
101
|
+
Text, { key: 'sub', dimColor: true },
|
|
102
|
+
String(subtitle)
|
|
103
|
+
));
|
|
104
|
+
}
|
|
105
|
+
// Filter row.
|
|
106
|
+
if (searchable) {
|
|
107
|
+
const q = String(query || '');
|
|
108
|
+
const tail = total > 0 ? `${total} match${total === 1 ? '' : 'es'}` : 'no matches';
|
|
109
|
+
rows.push(React.createElement(
|
|
110
|
+
Text, { key: 'filter' },
|
|
111
|
+
`${theme.accent ? theme.accent('› ') : '› '}${q}${q.length ? '' : ' '} `,
|
|
112
|
+
React.createElement(Text, { key: 'count', dimColor: true }, tail),
|
|
113
|
+
));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Items.
|
|
117
|
+
if (total === 0) {
|
|
118
|
+
rows.push(React.createElement(
|
|
119
|
+
Text, { key: 'empty', dimColor: true }, ' (no items)'
|
|
120
|
+
));
|
|
121
|
+
} else {
|
|
122
|
+
for (let i = 0; i < visible.length; i++) {
|
|
123
|
+
const it = visible[i];
|
|
124
|
+
const absoluteIdx = start + i;
|
|
125
|
+
const selected = absoluteIdx === idx;
|
|
126
|
+
const marker = selected ? '❯ ' : ' ';
|
|
127
|
+
const label = String(it.label || it.id || '').padEnd(labelW);
|
|
128
|
+
const desc = it.desc ? ` ${it.desc}` : '';
|
|
129
|
+
// Render selected row inverse-bold for contrast.
|
|
130
|
+
rows.push(React.createElement(
|
|
131
|
+
Text,
|
|
132
|
+
{ key: `it-${absoluteIdx}`, bold: selected, inverse: selected },
|
|
133
|
+
`${marker}${label}${desc}`,
|
|
134
|
+
));
|
|
135
|
+
}
|
|
136
|
+
if (end < total) {
|
|
137
|
+
rows.push(React.createElement(
|
|
138
|
+
Text, { key: 'more', dimColor: true },
|
|
139
|
+
` … ${total - end} more (Down to scroll)`,
|
|
140
|
+
));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (toast) {
|
|
144
|
+
rows.push(React.createElement(
|
|
145
|
+
Text, { key: 'toast', color: 'yellow' }, toast,
|
|
146
|
+
));
|
|
147
|
+
}
|
|
148
|
+
// Footer.
|
|
149
|
+
rows.push(React.createElement(
|
|
150
|
+
Text, { key: 'help', dimColor: true },
|
|
151
|
+
' ↑/↓ move · Enter pick · Esc cancel · type to filter · Ctrl+U clear',
|
|
152
|
+
));
|
|
153
|
+
|
|
154
|
+
return React.createElement(
|
|
155
|
+
Box,
|
|
156
|
+
{
|
|
157
|
+
flexDirection: 'column',
|
|
158
|
+
borderStyle: 'round',
|
|
159
|
+
borderColor: theme.amber || theme.border || 'yellow',
|
|
160
|
+
paddingX: 1,
|
|
161
|
+
width: Math.min(cols, 100),
|
|
162
|
+
flexShrink: 0,
|
|
163
|
+
},
|
|
164
|
+
rows,
|
|
165
|
+
);
|
|
166
|
+
}
|
package/tui/repl.mjs
CHANGED
|
@@ -38,6 +38,7 @@ import { Splash, renderSplashToString } from './splash.mjs';
|
|
|
38
38
|
import { Editor } from './editor.mjs';
|
|
39
39
|
import { SlashPopup, filterSlashCommands } from './slash_popup.mjs';
|
|
40
40
|
import { SLASH_COMMANDS } from './slash_commands.mjs';
|
|
41
|
+
import { ModalPicker, filterModalItems } from './modal_picker.mjs';
|
|
41
42
|
import { theme } from './theme.mjs';
|
|
42
43
|
|
|
43
44
|
// ─── Alt-buffer mount (DEC 1049) ─────────────────────────────────────────
|
|
@@ -187,7 +188,7 @@ export function consumeNextTurnFirstMessage(state) {
|
|
|
187
188
|
// - runTurnFactory(writeFn) → runTurn(text, signal) (sticky layout)
|
|
188
189
|
// - runTurn(text, signal) (legacy, stdout)
|
|
189
190
|
// Legacy mode is preserved verbatim for the existing cli.mjs callsite.
|
|
190
|
-
export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, onSlashCommand, statusInfo }) {
|
|
191
|
+
export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, onSlashCommand, statusInfo, pickerRef }) {
|
|
191
192
|
// statusInfo lets the host supply provider/model/ctx to the StatusBar
|
|
192
193
|
// independently of splashProps. Needed for the alt-buffer hand-off
|
|
193
194
|
// where splashProps is pre-printed to the primary buffer (not the
|
|
@@ -356,7 +357,88 @@ export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, o
|
|
|
356
357
|
const _exactOnly =
|
|
357
358
|
filtered.length === 1 &&
|
|
358
359
|
(filtered[0].cmd === bufferPeek || filtered[0].cmd === _bufTrimmed);
|
|
360
|
+
|
|
361
|
+
// ─── Modal picker state (v5.4.3) ───────────────────────────────────
|
|
362
|
+
// openPicker(opts) → Promise<id|null>. Stores the resolver on `modal`
|
|
363
|
+
// so confirm/cancel/unmount can settle it. The Editor intercepts
|
|
364
|
+
// Up/Down/Enter/Esc/Backspace/printable when modalOpen is true and
|
|
365
|
+
// routes them as host callbacks below; chat buffer and onSubmit are
|
|
366
|
+
// untouched while a picker is up.
|
|
367
|
+
const [modal, setModal] = useState(null);
|
|
368
|
+
const [modalIdx, setModalIdx] = useState(0);
|
|
369
|
+
const [modalQuery, setModalQuery] = useState('');
|
|
370
|
+
const modalView = useMemo(
|
|
371
|
+
() => (modal ? filterModalItems(modalQuery, modal.items) : []),
|
|
372
|
+
[modal, modalQuery],
|
|
373
|
+
);
|
|
374
|
+
const modalRef = useRef(null);
|
|
375
|
+
modalRef.current = modal;
|
|
376
|
+
const closeModal = useCallback((picked) => {
|
|
377
|
+
const m = modalRef.current;
|
|
378
|
+
setModal(null);
|
|
379
|
+
setModalIdx(0);
|
|
380
|
+
setModalQuery('');
|
|
381
|
+
if (m && typeof m.resolve === 'function') {
|
|
382
|
+
try { m.resolve(picked); } catch { /* swallow */ }
|
|
383
|
+
}
|
|
384
|
+
}, []);
|
|
385
|
+
const openPicker = useCallback((opts = {}) => {
|
|
386
|
+
return new Promise((resolve) => {
|
|
387
|
+
setModalIdx(Number.isFinite(opts.defaultIdx) ? opts.defaultIdx : 0);
|
|
388
|
+
setModalQuery('');
|
|
389
|
+
setModal({
|
|
390
|
+
kind: opts.kind || 'generic',
|
|
391
|
+
title: opts.title || 'select',
|
|
392
|
+
subtitle: opts.subtitle || '',
|
|
393
|
+
items: Array.isArray(opts.items) ? opts.items : [],
|
|
394
|
+
searchable: opts.searchable !== false,
|
|
395
|
+
resolve,
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
}, []);
|
|
399
|
+
// Expose openPicker via the host-supplied ref so cli.mjs can inject
|
|
400
|
+
// it into the slash dispatcher's ctx.
|
|
401
|
+
useEffect(() => {
|
|
402
|
+
if (pickerRef && typeof pickerRef === 'object') {
|
|
403
|
+
pickerRef.current = { openPicker };
|
|
404
|
+
}
|
|
405
|
+
return () => {
|
|
406
|
+
if (pickerRef && typeof pickerRef === 'object') {
|
|
407
|
+
pickerRef.current = null;
|
|
408
|
+
}
|
|
409
|
+
// If the app unmounts while a picker is open, resolve(null) so
|
|
410
|
+
// the awaiting dispatcher promise doesn't hang the next /command.
|
|
411
|
+
const m = modalRef.current;
|
|
412
|
+
if (m && typeof m.resolve === 'function') {
|
|
413
|
+
try { m.resolve(null); } catch { /* swallow */ }
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
}, [pickerRef, openPicker]);
|
|
417
|
+
|
|
418
|
+
const onModalMove = useCallback((delta) => {
|
|
419
|
+
setModalIdx((i) => {
|
|
420
|
+
const max = Math.max(0, modalView.length - 1);
|
|
421
|
+
const n = i + delta;
|
|
422
|
+
if (n < 0) return 0;
|
|
423
|
+
if (n > max) return max;
|
|
424
|
+
return n;
|
|
425
|
+
});
|
|
426
|
+
}, [modalView.length]);
|
|
427
|
+
const onModalConfirm = useCallback(() => {
|
|
428
|
+
const picked = modalView[modalIdx];
|
|
429
|
+
closeModal(picked ? picked.id : null);
|
|
430
|
+
}, [modalView, modalIdx, closeModal]);
|
|
431
|
+
const onModalCancel = useCallback(() => { closeModal(null); }, [closeModal]);
|
|
432
|
+
const onModalQuery = useCallback((next) => {
|
|
433
|
+
setModalQuery(next || '');
|
|
434
|
+
setModalIdx(0);
|
|
435
|
+
}, []);
|
|
436
|
+
const modalOpen = !!modal;
|
|
437
|
+
|
|
438
|
+
// Slash popup is suppressed whenever a modal picker is active so the
|
|
439
|
+
// overlays don't stack.
|
|
359
440
|
const showSlashPopup =
|
|
441
|
+
!modalOpen &&
|
|
360
442
|
bufferPeek.startsWith('/') && filtered.length > 0 && !_exactOnly;
|
|
361
443
|
|
|
362
444
|
// Outer column height: pinned to rows-1 in alt-buffer mode so the
|
|
@@ -385,7 +467,16 @@ export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, o
|
|
|
385
467
|
? React.createElement(
|
|
386
468
|
Box,
|
|
387
469
|
{ flexDirection: 'column', flexGrow: 1, flexShrink: 1, overflow: 'hidden' },
|
|
388
|
-
|
|
470
|
+
// v5.4.3 — once any user/assistant turn has been committed,
|
|
471
|
+
// drop the splash from the visible scrollback. Inside the
|
|
472
|
+
// alt-buffer canvas the splash cannot scroll off naturally
|
|
473
|
+
// (no native scrollback), so leaving it visible pushes the
|
|
474
|
+
// newest content past the StatusBar+Editor on tall outputs
|
|
475
|
+
// like /help. The splash still flashes on first mount.
|
|
476
|
+
(state.scrollback.length > 1
|
|
477
|
+
? state.scrollback.filter((it) => it.kind !== 'splash')
|
|
478
|
+
: state.scrollback
|
|
479
|
+
).map((item) =>
|
|
389
480
|
React.createElement(ScrollbackItem, { key: item.id, item })
|
|
390
481
|
),
|
|
391
482
|
// Live region — partial assistant stream (inside the scroll
|
|
@@ -420,6 +511,19 @@ export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, o
|
|
|
420
511
|
selectedIndex: selectedSuggestion,
|
|
421
512
|
})
|
|
422
513
|
: null,
|
|
514
|
+
// 3b) Modal picker (v5.4.3) — flex sibling above StatusBar, only
|
|
515
|
+
// visible while ReplApp's `modal` state is set. Suppresses the
|
|
516
|
+
// slash popup so the overlays don't stack.
|
|
517
|
+
modalOpen
|
|
518
|
+
? React.createElement(ModalPicker, {
|
|
519
|
+
title: modal.title,
|
|
520
|
+
subtitle: modal.subtitle,
|
|
521
|
+
items: modalView,
|
|
522
|
+
selectedIndex: modalIdx,
|
|
523
|
+
query: modalQuery,
|
|
524
|
+
searchable: modal.searchable,
|
|
525
|
+
})
|
|
526
|
+
: null,
|
|
423
527
|
// 4) Status bar (sticky, single row above input). flexShrink:0 so
|
|
424
528
|
// it isn't squeezed when the scrollback grows.
|
|
425
529
|
React.createElement(StatusBar, {
|
|
@@ -443,6 +547,20 @@ export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, o
|
|
|
443
547
|
slashSelectedIndex: selectedSuggestion,
|
|
444
548
|
onSlashMove: handleSlashMove,
|
|
445
549
|
onSlashDismiss: handleSlashDismiss,
|
|
550
|
+
// v5.4.3 — modal picker key contract. When modalOpen the
|
|
551
|
+
// Editor swallows all keys (no buffer mutation, no submit).
|
|
552
|
+
modalOpen,
|
|
553
|
+
modalQuery,
|
|
554
|
+
onModalMove,
|
|
555
|
+
onModalConfirm,
|
|
556
|
+
onModalCancel,
|
|
557
|
+
onModalQuery,
|
|
558
|
+
// v5.4.3 — when alt-buffer is on, Editor moves the terminal
|
|
559
|
+
// cursor back inside its content row after each render so
|
|
560
|
+
// macOS Hangul / Japanese / Chinese IMEs draw their pre-edit
|
|
561
|
+
// overlay where the user is actually typing instead of on
|
|
562
|
+
// the row below the editor box. Opt-out via env.
|
|
563
|
+
altEnabled,
|
|
446
564
|
})
|
|
447
565
|
)
|
|
448
566
|
)
|
package/tui/slash_commands.mjs
CHANGED
|
@@ -14,15 +14,17 @@ export const SLASH_COMMANDS = [
|
|
|
14
14
|
{ cmd: '/status', help: 'print current provider, model, masked key' },
|
|
15
15
|
{ cmd: '/version', help: 'print version + node + platform' },
|
|
16
16
|
{ cmd: '/new', help: 'clear conversation and start over' },
|
|
17
|
+
{ cmd: '/clear', help: 'alias for /new — clear conversation' },
|
|
17
18
|
{ cmd: '/reset', help: 'alias for /new' },
|
|
18
19
|
{ cmd: '/usage', help: 'show message count + chars sent so far' },
|
|
19
20
|
{ cmd: '/skills', help: 'list and activate skills (alias /skill)' },
|
|
20
21
|
{ cmd: '/skill', help: 'switch active skills: /skill review,style (no arg → clear)' },
|
|
21
22
|
{ cmd: '/tools', help: 'list available tool registry verbs' },
|
|
22
|
-
{ cmd: '/provider', help: '
|
|
23
|
-
{ cmd: '/model', help: '
|
|
24
|
-
{ cmd: '/trainer', help: '
|
|
25
|
-
{ cmd: '/personality', help: '
|
|
23
|
+
{ cmd: '/provider', help: 'pick provider from a list (or pass a name: /provider openai)' },
|
|
24
|
+
{ cmd: '/model', help: 'pick a model from a list (or pass a name: /model gpt-4.1)' },
|
|
25
|
+
{ cmd: '/trainer', help: 'view or set trainer provider/model: /trainer show|set <p:m>|clear' },
|
|
26
|
+
{ cmd: '/personality', help: 'pick a personality (or sub: list|show|install|remove|use)' },
|
|
27
|
+
{ cmd: '/dashboard', help: 'open the lazyclaw web UI in your browser' },
|
|
26
28
|
{ cmd: '/loop', help: 'repeat one prompt: /loop "fix lint" [--max N] [--until "<regex>"]' },
|
|
27
29
|
{ cmd: '/goal', help: 'register/switch goal: /goal add NAME | /goal list' },
|
|
28
30
|
{ cmd: '/memory', help: 'show layered memory: /memory [core|recent|episodic [topic]]' },
|
|
@@ -30,7 +32,7 @@ export const SLASH_COMMANDS = [
|
|
|
30
32
|
{ cmd: '/dream', help: 'consolidate recent memory into per-topic episodic files' },
|
|
31
33
|
{ cmd: '/agent', help: 'spawn or switch agent: /agent NAME' },
|
|
32
34
|
{ cmd: '/team', help: 'list, create, or join a team' },
|
|
33
|
-
{ cmd: '/task', help: '
|
|
35
|
+
{ cmd: '/task', help: 'list/show/transcript/abandon/done/remove (start/tick: CLI)' },
|
|
34
36
|
{ cmd: '/handoff', help: 'hand current task off to another agent' },
|
|
35
37
|
{ cmd: '/exit', help: 'leave the chat' },
|
|
36
38
|
{ cmd: '/quit', help: 'alias for /exit' },
|
package/tui/slash_dispatcher.mjs
CHANGED
|
@@ -73,14 +73,19 @@ async function _help() {
|
|
|
73
73
|
|
|
74
74
|
async function _status(_args, ctx) {
|
|
75
75
|
const registry = await _mod(ctx, 'registryMod', () => import('../providers/registry.mjs'));
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
76
|
+
const provider = ctx.getActiveProvName();
|
|
77
|
+
const model = ctx.getActiveModel() || '(default)';
|
|
78
|
+
const keyMasked = registry.maskApiKey(ctx.cfg && ctx.cfg['api-key']);
|
|
79
|
+
const messageCount = ctx.getMessages().length;
|
|
80
|
+
const sessionId = ctx.getSessionId() || '(none — in-memory)';
|
|
81
|
+
return [
|
|
82
|
+
'status:',
|
|
83
|
+
` provider: ${provider}`,
|
|
84
|
+
` model: ${model}`,
|
|
85
|
+
` api key: ${keyMasked}`,
|
|
86
|
+
` messages: ${messageCount}`,
|
|
87
|
+
` session: ${sessionId}`,
|
|
88
|
+
].join('\n');
|
|
84
89
|
}
|
|
85
90
|
|
|
86
91
|
async function _version(_args, ctx) {
|
|
@@ -91,22 +96,33 @@ async function _version(_args, ctx) {
|
|
|
91
96
|
async function _usage(_args, ctx) {
|
|
92
97
|
const msgs = ctx.getMessages();
|
|
93
98
|
const runningUsage = ctx.getRunningUsage && ctx.getRunningUsage();
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
99
|
+
const charsSent = (ctx.getCharsSent && ctx.getCharsSent()) || 0;
|
|
100
|
+
const lines = [
|
|
101
|
+
'usage:',
|
|
102
|
+
` messages: ${msgs.length}`,
|
|
103
|
+
` chars sent: ${charsSent.toLocaleString('en-US')}`,
|
|
104
|
+
];
|
|
105
|
+
if (runningUsage) {
|
|
106
|
+
lines.push(
|
|
107
|
+
` tokens in: ${(runningUsage.inputTokens || 0).toLocaleString('en-US')}`,
|
|
108
|
+
` tokens out: ${(runningUsage.outputTokens || 0).toLocaleString('en-US')}`,
|
|
109
|
+
` tokens tot: ${(runningUsage.totalTokens || 0).toLocaleString('en-US')}`,
|
|
110
|
+
` turns: ${runningUsage.turnsWithUsage || 0}`,
|
|
111
|
+
);
|
|
112
|
+
if (ctx.cfg && ctx.cfg.rates && typeof ctx.cfg.rates === 'object') {
|
|
113
|
+
try {
|
|
114
|
+
const { costFromUsage } = await import('../providers/rates.mjs');
|
|
115
|
+
const r = costFromUsage(
|
|
116
|
+
{ provider: ctx.getActiveProvName(), model: ctx.getActiveModel(), usage: runningUsage },
|
|
117
|
+
ctx.cfg.rates,
|
|
118
|
+
);
|
|
119
|
+
if (r && r.totalUsd != null) {
|
|
120
|
+
lines.push(` cost (USD): $${Number(r.totalUsd).toFixed(4)}`);
|
|
121
|
+
}
|
|
122
|
+
} catch { /* never let cost-card lookup fail the slash */ }
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return lines.join('\n');
|
|
110
126
|
}
|
|
111
127
|
|
|
112
128
|
async function _newReset(_args, ctx) {
|
|
@@ -125,16 +141,38 @@ async function _newReset(_args, ctx) {
|
|
|
125
141
|
return 'cleared — new conversation';
|
|
126
142
|
}
|
|
127
143
|
|
|
144
|
+
function _providerLookup(registry, name) {
|
|
145
|
+
if (typeof registry.lookupProv === 'function') return registry.lookupProv(name);
|
|
146
|
+
return registry.PROVIDERS ? registry.PROVIDERS[name] : null;
|
|
147
|
+
}
|
|
148
|
+
|
|
128
149
|
async function _provider(args, ctx) {
|
|
129
150
|
const registry = await _mod(ctx, 'registryMod', () => import('../providers/registry.mjs'));
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
};
|
|
151
|
+
// No arg → open the Ink modal picker (v5.4.3). Falls back to the
|
|
152
|
+
// pre-v5.4.3 hint string when ctx.openPicker isn't available (e.g.
|
|
153
|
+
// non-Ink callers or before the picker ref settles).
|
|
134
154
|
if (!args) {
|
|
135
|
-
|
|
155
|
+
if (typeof ctx.openPicker === 'function') {
|
|
156
|
+
const known = Object.keys(registry.PROVIDERS || {}).sort();
|
|
157
|
+
const info = registry.PROVIDER_INFO || {};
|
|
158
|
+
const items = known.map((id) => ({
|
|
159
|
+
id,
|
|
160
|
+
label: id,
|
|
161
|
+
desc: info[id] && info[id].docs ? String(info[id].docs).split('\n')[0].slice(0, 60) : '',
|
|
162
|
+
}));
|
|
163
|
+
const picked = await ctx.openPicker({
|
|
164
|
+
kind: 'provider',
|
|
165
|
+
title: 'select provider',
|
|
166
|
+
subtitle: `current: ${ctx.getActiveProvName()}`,
|
|
167
|
+
items,
|
|
168
|
+
});
|
|
169
|
+
if (!picked) return 'cancelled';
|
|
170
|
+
args = picked;
|
|
171
|
+
} else {
|
|
172
|
+
return `provider: ${ctx.getActiveProvName()}\n(pass an arg: /provider <name>)`;
|
|
173
|
+
}
|
|
136
174
|
}
|
|
137
|
-
const next =
|
|
175
|
+
const next = _providerLookup(registry, args);
|
|
138
176
|
if (!next) {
|
|
139
177
|
const known = registry.PROVIDERS ? Object.keys(registry.PROVIDERS).join(', ') : '?';
|
|
140
178
|
return `unknown provider: ${args} (known: ${known})`;
|
|
@@ -147,17 +185,36 @@ async function _provider(args, ctx) {
|
|
|
147
185
|
async function _model(args, ctx) {
|
|
148
186
|
const registry = await _mod(ctx, 'registryMod', () => import('../providers/registry.mjs'));
|
|
149
187
|
if (!args) {
|
|
150
|
-
|
|
188
|
+
if (typeof ctx.openPicker === 'function') {
|
|
189
|
+
const provName = ctx.getActiveProvName();
|
|
190
|
+
const info = (registry.PROVIDER_INFO && registry.PROVIDER_INFO[provName]) || {};
|
|
191
|
+
const suggested = Array.isArray(info.suggestedModels) ? info.suggestedModels : [];
|
|
192
|
+
if (!suggested.length) {
|
|
193
|
+
return `no suggested models known for provider "${provName}" — pass one: /model <name>`;
|
|
194
|
+
}
|
|
195
|
+
const items = suggested.map((m) => ({
|
|
196
|
+
id: m,
|
|
197
|
+
label: m,
|
|
198
|
+
desc: info.defaultModel === m ? '(default)' : '',
|
|
199
|
+
}));
|
|
200
|
+
const picked = await ctx.openPicker({
|
|
201
|
+
kind: 'model',
|
|
202
|
+
title: `select model for ${provName}`,
|
|
203
|
+
subtitle: `current: ${ctx.getActiveModel() || '(default)'}`,
|
|
204
|
+
items,
|
|
205
|
+
});
|
|
206
|
+
if (!picked) return 'cancelled';
|
|
207
|
+
args = picked;
|
|
208
|
+
} else {
|
|
209
|
+
return `model: ${ctx.getActiveModel() || '(default)'}\n(pass an arg: /model <name>)`;
|
|
210
|
+
}
|
|
151
211
|
}
|
|
152
212
|
const { parseSlashProviderModel } = registry;
|
|
153
213
|
const parsed = typeof parseSlashProviderModel === 'function'
|
|
154
214
|
? parseSlashProviderModel(args)
|
|
155
215
|
: { provider: null, model: args };
|
|
156
216
|
if (parsed.provider) {
|
|
157
|
-
const
|
|
158
|
-
? registry.lookupProv(name)
|
|
159
|
-
: (registry.PROVIDERS ? registry.PROVIDERS[name] : null));
|
|
160
|
-
const next = lookup(parsed.provider);
|
|
217
|
+
const next = _providerLookup(registry, parsed.provider);
|
|
161
218
|
if (!next) return `unknown provider: ${parsed.provider}`;
|
|
162
219
|
if (ctx.setActiveProvName) ctx.setActiveProvName(parsed.provider);
|
|
163
220
|
if (ctx.setProv) ctx.setProv(next);
|
|
@@ -254,8 +311,15 @@ async function _memory(args, ctx) {
|
|
|
254
311
|
return body || '(empty core memory)';
|
|
255
312
|
}
|
|
256
313
|
if (which === 'recent') {
|
|
257
|
-
const items = mem.loadRecent(20, ctx.cfgDir);
|
|
258
|
-
|
|
314
|
+
const items = mem.loadRecent(20, ctx.cfgDir) || [];
|
|
315
|
+
if (!items.length) return '(no recent memory)';
|
|
316
|
+
return ['recent memory (last ' + items.length + '):',
|
|
317
|
+
...items.map((it, i) => {
|
|
318
|
+
const role = it.role || 'msg';
|
|
319
|
+
const content = String(it.content || '').replace(/\s+/g, ' ').slice(0, 80);
|
|
320
|
+
return ` ${String(i + 1).padStart(2)}. [${role}] ${content}${(it.content || '').length > 80 ? '…' : ''}`;
|
|
321
|
+
})
|
|
322
|
+
].join('\n');
|
|
259
323
|
}
|
|
260
324
|
if (which === 'episodic') {
|
|
261
325
|
const topic = tokens[1];
|
|
@@ -263,7 +327,11 @@ async function _memory(args, ctx) {
|
|
|
263
327
|
const body = mem.loadEpisodic(topic, ctx.cfgDir);
|
|
264
328
|
return body || `(no episodic file "${topic}")`;
|
|
265
329
|
}
|
|
266
|
-
|
|
330
|
+
const items = mem.listEpisodic(ctx.cfgDir) || [];
|
|
331
|
+
if (!items.length) return '(no episodic files yet — run /dream to consolidate)';
|
|
332
|
+
return ['episodic files:',
|
|
333
|
+
...items.map((it) => ` • ${typeof it === 'string' ? it : (it.topic || JSON.stringify(it))}`)
|
|
334
|
+
].join('\n');
|
|
267
335
|
}
|
|
268
336
|
return 'usage: /memory [core|recent|episodic [topic]]';
|
|
269
337
|
}
|
|
@@ -562,19 +630,386 @@ async function _handoff(args, ctx) {
|
|
|
562
630
|
}
|
|
563
631
|
}
|
|
564
632
|
|
|
565
|
-
async function _personality(
|
|
566
|
-
// cmdPersonality
|
|
567
|
-
//
|
|
568
|
-
//
|
|
569
|
-
|
|
633
|
+
async function _personality(args, ctx) {
|
|
634
|
+
// cmdPersonality in cli.mjs is actually non-interactive (takes
|
|
635
|
+
// sub+a+b args). We mirror that surface here without going through
|
|
636
|
+
// cli.mjs (avoid circular import) — list / show / install / remove /
|
|
637
|
+
// use, plus a no-arg picker over installed personality files.
|
|
638
|
+
let fs, path;
|
|
639
|
+
try {
|
|
640
|
+
fs = await import('node:fs');
|
|
641
|
+
path = await import('node:path');
|
|
642
|
+
} catch (e) { return `/personality unavailable: ${e?.message || e}`; }
|
|
643
|
+
const dir = path.join(ctx.cfgDir, 'personalities');
|
|
644
|
+
try { fs.mkdirSync(dir, { recursive: true }); } catch { /* swallow */ }
|
|
645
|
+
|
|
646
|
+
const list = () => fs.existsSync(dir)
|
|
647
|
+
? fs.readdirSync(dir).filter((f) => f.endsWith('.md')).map((f) => f.slice(0, -3)).sort()
|
|
648
|
+
: [];
|
|
649
|
+
|
|
650
|
+
const tokens = splitWhitespace(args);
|
|
651
|
+
const sub = tokens[0];
|
|
652
|
+
const a = tokens[1];
|
|
653
|
+
const b = tokens[2];
|
|
654
|
+
|
|
655
|
+
// No arg → open Ink modal picker (v5.4.3). Confirm selects + activates.
|
|
656
|
+
if (!sub) {
|
|
657
|
+
const names = list();
|
|
658
|
+
if (!names.length) return 'no personalities installed — `lazyclaw personality install <name> <file.md>`';
|
|
659
|
+
if (typeof ctx.openPicker !== 'function') {
|
|
660
|
+
return `personalities: ${names.join(', ')}\n(pass an arg: /personality use <name>)`;
|
|
661
|
+
}
|
|
662
|
+
const items = names.map((n) => ({ id: n, label: n }));
|
|
663
|
+
const picked = await ctx.openPicker({
|
|
664
|
+
kind: 'personality',
|
|
665
|
+
title: 'select personality',
|
|
666
|
+
subtitle: 'Enter activates · Esc cancels',
|
|
667
|
+
items,
|
|
668
|
+
});
|
|
669
|
+
if (!picked) return 'cancelled';
|
|
670
|
+
return _personalityUse(picked, ctx, fs, path);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
if (sub === 'list') {
|
|
674
|
+
const names = list();
|
|
675
|
+
if (!names.length) return 'no personalities installed';
|
|
676
|
+
return names.join('\n');
|
|
677
|
+
}
|
|
678
|
+
if (sub === 'show') {
|
|
679
|
+
if (!a) return 'usage: /personality show <name>';
|
|
680
|
+
const p = path.join(dir, `${a}.md`);
|
|
681
|
+
if (!fs.existsSync(p)) return `personality not found: ${a}`;
|
|
682
|
+
return fs.readFileSync(p, 'utf8');
|
|
683
|
+
}
|
|
684
|
+
if (sub === 'install') {
|
|
685
|
+
if (!a || !b) return 'usage: /personality install <name> <file.md>';
|
|
686
|
+
const dst = path.join(dir, `${a}.md`);
|
|
687
|
+
if (fs.existsSync(dst)) return `personality already installed: ${a}`;
|
|
688
|
+
if (!fs.existsSync(b)) return `source file not found: ${b}`;
|
|
689
|
+
fs.writeFileSync(dst, fs.readFileSync(b, 'utf8'));
|
|
690
|
+
return `installed ${a}`;
|
|
691
|
+
}
|
|
692
|
+
if (sub === 'remove' || sub === 'rm' || sub === 'delete') {
|
|
693
|
+
if (!a) return 'usage: /personality remove <name>';
|
|
694
|
+
const p = path.join(dir, `${a}.md`);
|
|
695
|
+
if (!fs.existsSync(p)) return `personality not installed: ${a}`;
|
|
696
|
+
fs.unlinkSync(p);
|
|
697
|
+
return `removed ${a}`;
|
|
698
|
+
}
|
|
699
|
+
if (sub === 'use') {
|
|
700
|
+
if (!a) return 'usage: /personality use <name>';
|
|
701
|
+
return _personalityUse(a, ctx, fs, path);
|
|
702
|
+
}
|
|
703
|
+
return `/personality: unknown sub "${sub}" — list|show|install|remove|use (or no-arg picker)`;
|
|
570
704
|
}
|
|
571
705
|
|
|
572
|
-
|
|
573
|
-
|
|
706
|
+
function _personalityUse(name, ctx, fs, path) {
|
|
707
|
+
const dir = path.join(ctx.cfgDir, 'personalities');
|
|
708
|
+
const p = path.join(dir, `${name}.md`);
|
|
709
|
+
if (!fs.existsSync(p)) return `personality not installed: ${name}`;
|
|
710
|
+
// Read-merge-write config.json so we never clobber unrelated keys.
|
|
711
|
+
const cfgPath = path.join(ctx.cfgDir, 'config.json');
|
|
712
|
+
let diskCfg = {};
|
|
713
|
+
try { diskCfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); } catch { /* fresh */ }
|
|
714
|
+
diskCfg.persona = { ...(diskCfg.persona || {}), personality: name };
|
|
715
|
+
try { fs.mkdirSync(ctx.cfgDir, { recursive: true }); } catch {}
|
|
716
|
+
fs.writeFileSync(cfgPath, JSON.stringify(diskCfg, null, 2));
|
|
717
|
+
// Mirror onto the in-memory cfg so the next turn picks up the change.
|
|
718
|
+
if (ctx.cfg) {
|
|
719
|
+
ctx.cfg.persona = { ...(ctx.cfg.persona || {}), personality: name };
|
|
720
|
+
}
|
|
721
|
+
return `active personality → ${name}`;
|
|
574
722
|
}
|
|
575
723
|
|
|
576
|
-
async function
|
|
577
|
-
|
|
724
|
+
async function _task(args, ctx) {
|
|
725
|
+
let tasksMod, loopMod;
|
|
726
|
+
try {
|
|
727
|
+
tasksMod = await import('../tasks.mjs');
|
|
728
|
+
loopMod = await import('../loop-engine.mjs');
|
|
729
|
+
} catch (e) { return `/task unavailable: ${e?.message || e}`; }
|
|
730
|
+
let tokens;
|
|
731
|
+
try { tokens = loopMod.splitArgs(args); }
|
|
732
|
+
catch (e) { return `/task error: ${e?.message || e}`; }
|
|
733
|
+
const sub = tokens[0];
|
|
734
|
+
const id = tokens[1];
|
|
735
|
+
|
|
736
|
+
try {
|
|
737
|
+
if (!sub || sub === 'list') {
|
|
738
|
+
const items = tasksMod.listTasks(ctx.cfgDir);
|
|
739
|
+
if (!items.length) return 'no tasks. `lazyclaw task start --team ... --title ...` from the shell to create one.';
|
|
740
|
+
return items.map((t) =>
|
|
741
|
+
`• ${t.id} [${t.status || 'unknown'}] ${t.title || '(no title)'}${t.team ? ` — team=${t.team}` : ''}${t.lead ? ` — lead=${t.lead}` : ''}`
|
|
742
|
+
).join('\n');
|
|
743
|
+
}
|
|
744
|
+
if (sub === 'show') {
|
|
745
|
+
if (!id) return 'usage: /task show <id>';
|
|
746
|
+
const t = tasksMod.getTask(id, ctx.cfgDir);
|
|
747
|
+
if (!t) return `no task "${id}"`;
|
|
748
|
+
return JSON.stringify(t, null, 2);
|
|
749
|
+
}
|
|
750
|
+
if (sub === 'transcript') {
|
|
751
|
+
if (!id) return 'usage: /task transcript <id> [text|md|json]';
|
|
752
|
+
const t = tasksMod.getTask(id, ctx.cfgDir);
|
|
753
|
+
if (!t) return `no task "${id}"`;
|
|
754
|
+
const fmt = tokens[2] || 'text';
|
|
755
|
+
if (typeof tasksMod.formatTranscript === 'function') {
|
|
756
|
+
return tasksMod.formatTranscript(t, fmt);
|
|
757
|
+
}
|
|
758
|
+
return JSON.stringify(t.turns || [], null, 2);
|
|
759
|
+
}
|
|
760
|
+
if (sub === 'abandon' || sub === 'done') {
|
|
761
|
+
if (!id) return `usage: /task ${sub} <id>`;
|
|
762
|
+
const next = tasksMod.patchTask(id, { status: sub === 'done' ? 'done' : 'abandoned' }, ctx.cfgDir);
|
|
763
|
+
return `✓ task ${id} → ${next?.status || sub}`;
|
|
764
|
+
}
|
|
765
|
+
if (sub === 'remove' || sub === 'rm' || sub === 'delete') {
|
|
766
|
+
if (!id) return 'usage: /task remove <id>';
|
|
767
|
+
tasksMod.removeTask(id, ctx.cfgDir);
|
|
768
|
+
return `✓ removed task ${id}`;
|
|
769
|
+
}
|
|
770
|
+
if (sub === 'start' || sub === 'tick') {
|
|
771
|
+
return `task ${sub}: needs Slack + multi-agent router — run from the shell:\n lazyclaw task ${sub} ...`;
|
|
772
|
+
}
|
|
773
|
+
return `/task: unknown sub "${sub}" — list|show|transcript|abandon|done|remove (start/tick: use CLI)`;
|
|
774
|
+
} catch (e) {
|
|
775
|
+
return `/task error: ${e?.message || e}`;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
async function _trainer(args, ctx) {
|
|
780
|
+
const registry = await _mod(ctx, 'registryMod', () => import('../providers/registry.mjs'));
|
|
781
|
+
const tokens = splitWhitespace(args);
|
|
782
|
+
const sub = tokens[0] || 'show';
|
|
783
|
+
|
|
784
|
+
if (sub === 'show') {
|
|
785
|
+
let effective = { provider: ctx.getActiveProvName ? ctx.getActiveProvName() : null,
|
|
786
|
+
model: ctx.getActiveModel ? ctx.getActiveModel() : null };
|
|
787
|
+
try {
|
|
788
|
+
if (typeof registry.resolveTrainer === 'function') {
|
|
789
|
+
effective = registry.resolveTrainer(ctx.cfg || {});
|
|
790
|
+
}
|
|
791
|
+
} catch { /* fall through */ }
|
|
792
|
+
const configured = (ctx.cfg && ctx.cfg.trainer) || null;
|
|
793
|
+
const cfgRender = configured
|
|
794
|
+
? JSON.stringify(configured, null, 2)
|
|
795
|
+
: '(unset — trainer mirrors the chat provider/model)';
|
|
796
|
+
return [
|
|
797
|
+
'trainer (effective):',
|
|
798
|
+
` provider: ${effective.provider}`,
|
|
799
|
+
` model: ${effective.model || '(default)'}`,
|
|
800
|
+
'trainer (configured):',
|
|
801
|
+
cfgRender,
|
|
802
|
+
].join('\n');
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
if (sub === 'set') {
|
|
806
|
+
const spec = tokens[1];
|
|
807
|
+
if (!spec) return 'usage: /trainer set <provider>[:<model>] (or `auto` for orchestrator-managed)';
|
|
808
|
+
const parsed = typeof registry.parseProviderModel === 'function'
|
|
809
|
+
? registry.parseProviderModel(spec)
|
|
810
|
+
: { provider: spec.split(':')[0], model: spec.split(':')[1] || null };
|
|
811
|
+
if (!parsed || !parsed.provider) return `/trainer set: could not parse "${spec}"`;
|
|
812
|
+
if (parsed.provider !== 'auto') {
|
|
813
|
+
const next = _providerLookup(registry, parsed.provider);
|
|
814
|
+
if (!next) return `/trainer set: unknown provider "${parsed.provider}"`;
|
|
815
|
+
}
|
|
816
|
+
// Read-merge-write so unrelated cfg keys survive.
|
|
817
|
+
const fs = await import('node:fs');
|
|
818
|
+
const path = await import('node:path');
|
|
819
|
+
const cfgPath = path.join(ctx.cfgDir, 'config.json');
|
|
820
|
+
let diskCfg = {};
|
|
821
|
+
try { diskCfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); } catch { /* fresh */ }
|
|
822
|
+
diskCfg.trainer = { ...(diskCfg.trainer || {}), provider: parsed.provider };
|
|
823
|
+
if (parsed.model) diskCfg.trainer.model = parsed.model;
|
|
824
|
+
else delete diskCfg.trainer.model;
|
|
825
|
+
try { fs.mkdirSync(ctx.cfgDir, { recursive: true }); } catch {}
|
|
826
|
+
fs.writeFileSync(cfgPath, JSON.stringify(diskCfg, null, 2));
|
|
827
|
+
if (ctx.cfg) ctx.cfg.trainer = { ...diskCfg.trainer };
|
|
828
|
+
return `✓ trainer → ${parsed.provider}${parsed.model ? ':' + parsed.model : ''}`;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
if (sub === 'clear' || sub === 'unset') {
|
|
832
|
+
const fs = await import('node:fs');
|
|
833
|
+
const path = await import('node:path');
|
|
834
|
+
const cfgPath = path.join(ctx.cfgDir, 'config.json');
|
|
835
|
+
let diskCfg = {};
|
|
836
|
+
try { diskCfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); } catch { /* fresh */ }
|
|
837
|
+
delete diskCfg.trainer;
|
|
838
|
+
try { fs.mkdirSync(ctx.cfgDir, { recursive: true }); } catch {}
|
|
839
|
+
fs.writeFileSync(cfgPath, JSON.stringify(diskCfg, null, 2));
|
|
840
|
+
if (ctx.cfg) delete ctx.cfg.trainer;
|
|
841
|
+
return '✓ trainer cleared (will mirror chat provider/model)';
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
return `/trainer: unknown sub "${sub}" — show|set <p:m>|clear`;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// /dashboard — open the lazyclaw web UI.
|
|
848
|
+
//
|
|
849
|
+
// v5.4.4 ROOT-CAUSE FIX (was: rapid repeated /dashboard within one chat
|
|
850
|
+
// session spawned 20+ daemon children).
|
|
851
|
+
//
|
|
852
|
+
// Original implementation:
|
|
853
|
+
// probe /healthz → if !200, spawn detached `lazyclaw dashboard
|
|
854
|
+
// --no-open` and poll for up to 3s.
|
|
855
|
+
//
|
|
856
|
+
// Failure mode that produced the 20+ spawn pile-up:
|
|
857
|
+
// 1. User types /dashboard. probe fails (no daemon). Spawn child A.
|
|
858
|
+
// 2. Child A begins binding port 19600. Takes ~500ms-2s to be ready.
|
|
859
|
+
// 3. User types /dashboard again BEFORE A is ready. probe still fails.
|
|
860
|
+
// Spawn child B. Child B sees EADDRINUSE and calls _killPortOccupant
|
|
861
|
+
// (cli.mjs:3611) which SIGTERMs child A. B takes over.
|
|
862
|
+
// 4. Repeat. Each /dashboard kills the previous daemon and starts a
|
|
863
|
+
// new one. With autorepeat / many slash calls this stacks fast.
|
|
864
|
+
//
|
|
865
|
+
// Two-layer guard:
|
|
866
|
+
// - A module-level _dashboardSpawning latch refuses concurrent spawn
|
|
867
|
+
// attempts. While a spawn is in flight, /dashboard says so + returns
|
|
868
|
+
// without firing another child.
|
|
869
|
+
// - A _dashboardChildPid cache remembers the PID we already spawned;
|
|
870
|
+
// subsequent calls check kill(pid, 0) to confirm the child is alive
|
|
871
|
+
// and just open the browser without spawning.
|
|
872
|
+
//
|
|
873
|
+
// We probe both /healthz (HTTP) AND a raw net.connect port check so a
|
|
874
|
+
// slow-starting daemon (binding the listener but not yet answering HTTP)
|
|
875
|
+
// still counts as "running".
|
|
876
|
+
let _dashboardSpawning = false;
|
|
877
|
+
let _dashboardChildPid = null;
|
|
878
|
+
|
|
879
|
+
function _portIsListening(port, timeoutMs = 200) {
|
|
880
|
+
return new Promise((resolve) => {
|
|
881
|
+
import('node:net').then(({ createConnection }) => {
|
|
882
|
+
let settled = false;
|
|
883
|
+
const sock = createConnection({ host: '127.0.0.1', port });
|
|
884
|
+
const done = (ok) => {
|
|
885
|
+
if (settled) return;
|
|
886
|
+
settled = true;
|
|
887
|
+
try { sock.destroy(); } catch {}
|
|
888
|
+
resolve(ok);
|
|
889
|
+
};
|
|
890
|
+
sock.once('connect', () => done(true));
|
|
891
|
+
sock.once('error', () => done(false));
|
|
892
|
+
setTimeout(() => done(false), timeoutMs);
|
|
893
|
+
}).catch(() => resolve(false));
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
async function _dashboardProbe(port) {
|
|
898
|
+
// Fast path — port-level probe. Catches a daemon that has bound the
|
|
899
|
+
// socket but hasn't finished initializing its HTTP routes.
|
|
900
|
+
if (await _portIsListening(port, 200)) return true;
|
|
901
|
+
// Slow path — full /healthz fetch, for defense in depth.
|
|
902
|
+
if (typeof fetch !== 'function') return false;
|
|
903
|
+
try {
|
|
904
|
+
const ac = new AbortController();
|
|
905
|
+
const t = setTimeout(() => ac.abort(), 250);
|
|
906
|
+
const r = await fetch(`http://127.0.0.1:${port}/healthz`, { signal: ac.signal });
|
|
907
|
+
clearTimeout(t);
|
|
908
|
+
return !!(r && r.ok);
|
|
909
|
+
} catch { return false; }
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
function _openBrowser(url) {
|
|
913
|
+
return import('node:child_process').then(({ spawn }) => {
|
|
914
|
+
let cmd, args;
|
|
915
|
+
if (process.platform === 'darwin') { cmd = 'open'; args = [url]; }
|
|
916
|
+
else if (process.platform === 'win32') { cmd = 'cmd'; args = ['/c', 'start', '""', url]; }
|
|
917
|
+
else { cmd = 'xdg-open'; args = [url]; }
|
|
918
|
+
try { spawn(cmd, args, { stdio: 'ignore', detached: true }).unref(); } catch { /* swallow */ }
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
async function _dashboardStop(port) {
|
|
923
|
+
// Best-effort kill of every lazyclaw dashboard daemon on the box.
|
|
924
|
+
// Used to clean up after the v5.4.3 spawn pile-up bug.
|
|
925
|
+
if (process.platform === 'win32') {
|
|
926
|
+
return 'dashboard stop: not implemented on Windows yet — kill via Task Manager';
|
|
927
|
+
}
|
|
928
|
+
const { spawn } = await import('node:child_process');
|
|
929
|
+
// Step 1: lsof the port and SIGTERM each PID.
|
|
930
|
+
const portPids = await new Promise((resolve) => {
|
|
931
|
+
try {
|
|
932
|
+
const lsof = spawn('lsof', ['-ti', `tcp:${port}`], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
933
|
+
let buf = '';
|
|
934
|
+
lsof.stdout.on('data', (d) => { buf += d.toString('utf8'); });
|
|
935
|
+
lsof.on('error', () => resolve([]));
|
|
936
|
+
lsof.on('close', () => resolve(
|
|
937
|
+
buf.trim().split(/\s+/).map((s) => parseInt(s, 10)).filter(Number.isFinite)
|
|
938
|
+
));
|
|
939
|
+
} catch { resolve([]); }
|
|
940
|
+
});
|
|
941
|
+
for (const pid of portPids) {
|
|
942
|
+
try { process.kill(pid, 'SIGTERM'); } catch { /* gone */ }
|
|
943
|
+
}
|
|
944
|
+
// Step 2: pkill any process whose command line includes "lazyclaw dashboard"
|
|
945
|
+
// — catches detached children that bound a different (random) port via
|
|
946
|
+
// cmdDashboard's EADDRINUSE fallback.
|
|
947
|
+
let pkilled = 0;
|
|
948
|
+
try {
|
|
949
|
+
const pkill = spawn('pkill', ['-f', 'lazyclaw dashboard'], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
950
|
+
pkilled = await new Promise((r) => pkill.on('close', (code) => r(code === 0 ? 1 : 0)));
|
|
951
|
+
} catch { /* fine */ }
|
|
952
|
+
_dashboardChildPid = null;
|
|
953
|
+
return `✓ stopped ${portPids.length} listener(s) on :${port}${pkilled ? ' + remaining `lazyclaw dashboard` processes via pkill' : ''}`;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
async function _dashboard(args) {
|
|
957
|
+
const port = 19600;
|
|
958
|
+
const url = `http://127.0.0.1:${port}/dashboard`;
|
|
959
|
+
const sub = splitWhitespace(args)[0];
|
|
960
|
+
if (sub === 'stop' || sub === 'kill') return _dashboardStop(port);
|
|
961
|
+
|
|
962
|
+
// 1. Already running anywhere on the machine? → reuse.
|
|
963
|
+
if (await _dashboardProbe(port)) {
|
|
964
|
+
await _openBrowser(url);
|
|
965
|
+
return `✓ dashboard already running — opened ${url}`;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// 2. We spawned in this chat — is that child still alive?
|
|
969
|
+
if (_dashboardChildPid != null) {
|
|
970
|
+
try {
|
|
971
|
+
process.kill(_dashboardChildPid, 0); // signal 0 = liveness probe
|
|
972
|
+
// Child alive but not answering yet. Don't re-spawn; just nudge.
|
|
973
|
+
await _openBrowser(url);
|
|
974
|
+
return `✓ dashboard starting (pid ${_dashboardChildPid}) — opened ${url}`;
|
|
975
|
+
} catch {
|
|
976
|
+
_dashboardChildPid = null; // child died; fall through and respawn.
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
// 3. Spawn already in flight from a concurrent /dashboard? Don't pile on.
|
|
981
|
+
if (_dashboardSpawning) {
|
|
982
|
+
await _openBrowser(url);
|
|
983
|
+
return `dashboard is still booting — opened ${url}; try again in a moment if it didn't load`;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// 4. Cold start. Spawn ONE detached child, poll up to 3s, latch the
|
|
987
|
+
// spawn flag in a finally so it always clears.
|
|
988
|
+
_dashboardSpawning = true;
|
|
989
|
+
try {
|
|
990
|
+
const { spawn } = await import('node:child_process');
|
|
991
|
+
let child;
|
|
992
|
+
try {
|
|
993
|
+
child = spawn(process.execPath, [process.argv[1], 'dashboard', '--no-open'], {
|
|
994
|
+
detached: true, stdio: 'ignore', cwd: process.cwd(), env: process.env,
|
|
995
|
+
});
|
|
996
|
+
child.unref();
|
|
997
|
+
_dashboardChildPid = child.pid;
|
|
998
|
+
} catch (e) {
|
|
999
|
+
return `dashboard error: failed to spawn — ${e?.message || e}`;
|
|
1000
|
+
}
|
|
1001
|
+
const start = Date.now();
|
|
1002
|
+
while (Date.now() - start < 3000) {
|
|
1003
|
+
if (await _dashboardProbe(port)) {
|
|
1004
|
+
await _openBrowser(url);
|
|
1005
|
+
return `✓ started dashboard (pid ${child.pid}) — opened ${url}`;
|
|
1006
|
+
}
|
|
1007
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
1008
|
+
}
|
|
1009
|
+
return `⚠ dashboard didn't come up within 3s (pid ${child.pid}). URL: ${url}`;
|
|
1010
|
+
} finally {
|
|
1011
|
+
_dashboardSpawning = false;
|
|
1012
|
+
}
|
|
578
1013
|
}
|
|
579
1014
|
|
|
580
1015
|
// ─── dispatch table ──────────────────────────────────────────────────────
|
|
@@ -586,6 +1021,7 @@ export const SLASH_HANDLERS = new Map([
|
|
|
586
1021
|
['/usage', _usage],
|
|
587
1022
|
['/new', _newReset],
|
|
588
1023
|
['/reset', _newReset],
|
|
1024
|
+
['/clear', _newReset],
|
|
589
1025
|
['/provider', _provider],
|
|
590
1026
|
['/model', _model],
|
|
591
1027
|
['/skill', _skill],
|
|
@@ -602,6 +1038,7 @@ export const SLASH_HANDLERS = new Map([
|
|
|
602
1038
|
['/personality', _personality],
|
|
603
1039
|
['/task', _task],
|
|
604
1040
|
['/trainer', _trainer],
|
|
1041
|
+
['/dashboard', _dashboard],
|
|
605
1042
|
['/exit', async () => 'EXIT'],
|
|
606
1043
|
['/quit', async () => 'EXIT'],
|
|
607
1044
|
]);
|
package/tui/splash.mjs
CHANGED
|
@@ -161,16 +161,10 @@ function renderWide(props, cols) {
|
|
|
161
161
|
lines.push('');
|
|
162
162
|
lines.push(`${LMARGIN}Welcome to lazyclaw. Type your message or /help for commands.`);
|
|
163
163
|
lines.push(`${LMARGIN}+ Tip: trainer learns from your Claude Pro subscription at $0.`);
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
//
|
|
167
|
-
|
|
168
|
-
lines.push(LMARGIN + sep);
|
|
169
|
-
const ctx = props.ctxUsed != null && props.ctxTotal != null
|
|
170
|
-
? `[${'░'.repeat(20)}] ${props.ctxUsed}/${props.ctxTotal}`
|
|
171
|
-
: `[${'░'.repeat(20)}] --`;
|
|
172
|
-
lines.push(`${LMARGIN} ${provider} · ${model} | ctx -- | ${ctx} | 0s`);
|
|
173
|
-
lines.push(LMARGIN + sep);
|
|
164
|
+
// v5.4.3 — the baked-in status row that used to live here duplicated
|
|
165
|
+
// ReplApp's real <StatusBar/> (tui/repl.mjs:476). Removing it cuts 4
|
|
166
|
+
// rows from the splash AND eliminates the visible overlap the user
|
|
167
|
+
// saw on /help in alt-buffer mode.
|
|
174
168
|
|
|
175
169
|
return lines;
|
|
176
170
|
}
|
|
@@ -251,16 +245,7 @@ function renderMedium(props, cols) {
|
|
|
251
245
|
lines.push('');
|
|
252
246
|
lines.push(`${LMARGIN}Welcome to lazyclaw. Type your message or /help for commands.`);
|
|
253
247
|
lines.push(`${LMARGIN}+ Tip: trainer learns from your Claude Pro subscription at $0.`);
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
// 5) status bar
|
|
257
|
-
const sep = '─'.repeat(PANEL_W);
|
|
258
|
-
lines.push(LMARGIN + sep);
|
|
259
|
-
const ctx = props.ctxUsed != null && props.ctxTotal != null
|
|
260
|
-
? `[${'░'.repeat(20)}] ${props.ctxUsed}/${props.ctxTotal}`
|
|
261
|
-
: `[${'░'.repeat(20)}] --`;
|
|
262
|
-
lines.push(`${LMARGIN} ${provider} · ${model} | ctx -- | ${ctx} | 0s`);
|
|
263
|
-
lines.push(LMARGIN + sep);
|
|
248
|
+
// v5.4.3 — baked-in status row removed; ReplApp renders the real one.
|
|
264
249
|
|
|
265
250
|
return lines;
|
|
266
251
|
}
|
|
@@ -342,13 +327,7 @@ function renderNarrow(props, cols) {
|
|
|
342
327
|
if (sessionId) lines.push(fit(`${LMARGIN}Session: ${sessionId}`, cols).trimEnd());
|
|
343
328
|
lines.push('');
|
|
344
329
|
lines.push(fit(`${LMARGIN}Welcome to lazyclaw. /help for commands.`, cols).trimEnd());
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
// 7) compact status — single line, no separator dashes.
|
|
348
|
-
const ctx = props.ctxUsed != null && props.ctxTotal != null
|
|
349
|
-
? `${props.ctxUsed}/${props.ctxTotal}`
|
|
350
|
-
: '--';
|
|
351
|
-
lines.push(fit(`${LMARGIN}${provider} · ${model} · ctx ${ctx}`, cols).trimEnd());
|
|
330
|
+
// v5.4.3 — baked-in status row removed; ReplApp renders the real one.
|
|
352
331
|
|
|
353
332
|
return lines;
|
|
354
333
|
}
|