lazyclaw 5.4.1 → 5.4.3
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 +163 -37
- package/tui/modal_picker.mjs +166 -0
- package/tui/repl.mjs +141 -15
- package/tui/slash_commands.mjs +2 -0
- package/tui/slash_dispatcher.mjs +311 -20
- 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.3",
|
|
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
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
// `width: '100%'` so Ink's wrap-ansi (already string-width aware) gets
|
|
29
29
|
// the full terminal budget — fixes the perceived right-edge truncation
|
|
30
30
|
// on long Korean buffers.
|
|
31
|
-
import React, { useState, useEffect } from 'react';
|
|
31
|
+
import React, { useState, useEffect, useRef } from 'react';
|
|
32
32
|
import { Box, Text, useInput } from 'ink';
|
|
33
33
|
import stringWidth from 'string-width';
|
|
34
34
|
import { theme } from './theme.mjs';
|
|
@@ -50,6 +50,32 @@ export function displayWidth(text) {
|
|
|
50
50
|
return stringWidth(String(text));
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
// Cell-aware soft-wrap. Returns an array of visual rows whose width
|
|
54
|
+
// respects the budget (first row uses `firstBudget`, subsequent rows
|
|
55
|
+
// use `contBudget`). Hoisted to module level (was inner-fn) so the
|
|
56
|
+
// cursor-anchor useEffect in <Editor/> can reuse it.
|
|
57
|
+
export function wrapToBudget(text, firstBudget, contBudget) {
|
|
58
|
+
if (!text) return [''];
|
|
59
|
+
const out = [];
|
|
60
|
+
let line = '';
|
|
61
|
+
let lineW = 0;
|
|
62
|
+
let budget = firstBudget;
|
|
63
|
+
for (const ch of text) {
|
|
64
|
+
const w = stringWidth(ch);
|
|
65
|
+
if (lineW + w > budget) {
|
|
66
|
+
out.push(line);
|
|
67
|
+
line = ch;
|
|
68
|
+
lineW = w;
|
|
69
|
+
budget = contBudget;
|
|
70
|
+
} else {
|
|
71
|
+
line += ch;
|
|
72
|
+
lineW += w;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
out.push(line);
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
53
79
|
// Display column of the caret given a state. Counts wide chars as 2.
|
|
54
80
|
// On the first rendered line this is offset by PROMPT_WIDTH; on
|
|
55
81
|
// continuation lines (after a Shift+Enter) it is offset by
|
|
@@ -146,19 +172,80 @@ export function Editor({
|
|
|
146
172
|
slashSelectedIndex, // number
|
|
147
173
|
onSlashMove, // (delta: -1 | +1) => void
|
|
148
174
|
onSlashDismiss, // () => void
|
|
175
|
+
// v5.4.3 modal-picker wiring (all optional — pre-v5.4.3 callers pass none).
|
|
176
|
+
// When modalOpen is true the Editor intercepts every key and routes it
|
|
177
|
+
// to the host callbacks; nothing reaches applyKey / onSubmit so the
|
|
178
|
+
// chat buffer is untouched while a picker is up.
|
|
179
|
+
modalOpen, // boolean
|
|
180
|
+
modalQuery, // string — current filter buffer (host-owned)
|
|
181
|
+
onModalMove, // (delta: -1 | +1) => void
|
|
182
|
+
onModalConfirm, // () => void
|
|
183
|
+
onModalCancel, // () => void
|
|
184
|
+
onModalQuery, // (next: string) => void
|
|
185
|
+
// v5.4.3 IME cursor anchor — when altEnabled is true, the Editor
|
|
186
|
+
// moves the terminal cursor back inside its content row after each
|
|
187
|
+
// render so macOS IMEs (Hangul / Japanese / Chinese) draw their
|
|
188
|
+
// pre-edit overlay at the actual typing position. Opt-out via the
|
|
189
|
+
// LAZYCLAW_NO_CURSOR_ANCHOR env var (handled internally).
|
|
190
|
+
altEnabled,
|
|
149
191
|
}) {
|
|
150
192
|
const [state, setState] = useState(() => makeEditorState({ history }));
|
|
151
193
|
const slashOpen = Array.isArray(slashSuggestions) && slashSuggestions.length > 0;
|
|
152
194
|
|
|
195
|
+
// v5.4.2: keep a synchronous mirror of the editor state so back-to-back
|
|
196
|
+
// keystrokes don't lose characters to React's stale-closure problem.
|
|
197
|
+
// Korean / Japanese IME commits each completed syllable as a separate
|
|
198
|
+
// stdin event; if two events fire inside one React frame the second
|
|
199
|
+
// useInput call captures the pre-first-event `state` and overwrites
|
|
200
|
+
// the first event's setState payload — leaving the first character
|
|
201
|
+
// missing from `buffer`. Reading + writing through the ref means every
|
|
202
|
+
// applyKey() sees the latest buffer regardless of render timing.
|
|
203
|
+
const stateRef = useRef(state);
|
|
204
|
+
useEffect(() => { stateRef.current = state; }, [state]);
|
|
205
|
+
const commit = (next) => {
|
|
206
|
+
stateRef.current = next;
|
|
207
|
+
setState(next);
|
|
208
|
+
};
|
|
209
|
+
|
|
153
210
|
useInput((input, key) => {
|
|
211
|
+
const current = stateRef.current;
|
|
212
|
+
// ─── Modal-picker keyboard contract (highest priority — v5.4.3) ──
|
|
213
|
+
// When a host-owned picker is up, EVERY key is consumed by the
|
|
214
|
+
// picker. The chat buffer is never mutated and onSubmit is never
|
|
215
|
+
// fired. This is what prevents "Enter while picking a model" from
|
|
216
|
+
// accidentally submitting the typed-so-far buffer as a chat turn.
|
|
217
|
+
if (modalOpen) {
|
|
218
|
+
if (key.escape) { if (onModalCancel) onModalCancel(); return; }
|
|
219
|
+
if (key.return) { if (onModalConfirm) onModalConfirm(); return; }
|
|
220
|
+
if (key.upArrow) { if (onModalMove) onModalMove(-1); return; }
|
|
221
|
+
if (key.downArrow) { if (onModalMove) onModalMove(+1); return; }
|
|
222
|
+
if (key.pageUp) { if (onModalMove) onModalMove(-10); return; }
|
|
223
|
+
if (key.pageDown) { if (onModalMove) onModalMove(+10); return; }
|
|
224
|
+
// Ctrl+U clears the filter (Unix-style line kill).
|
|
225
|
+
if (key.ctrl && (input === 'u' || input === 'U')) {
|
|
226
|
+
if (onModalQuery) onModalQuery('');
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (key.backspace || key.delete) {
|
|
230
|
+
if (onModalQuery) onModalQuery(String(modalQuery || '').slice(0, -1));
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
// Printable single char (no modifier) appends to filter.
|
|
234
|
+
if (input && !key.ctrl && !key.meta && input.length >= 1 && !key.tab) {
|
|
235
|
+
if (onModalQuery) onModalQuery(String(modalQuery || '') + input);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
// Anything else (Tab, function keys, etc.) swallowed.
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
154
241
|
// ─── Slash-popup keyboard contract (highest priority when open) ──
|
|
155
242
|
if (slashOpen) {
|
|
156
243
|
// Esc: clear the buffer and dismiss the popup. The host's onEscape
|
|
157
244
|
// is NOT called in this branch — Esc here is a popup gesture, not
|
|
158
245
|
// a turn-abort. (Outside of popup mode Esc still aborts streaming.)
|
|
159
246
|
if (key.escape) {
|
|
160
|
-
const cleared = { ...
|
|
161
|
-
|
|
247
|
+
const cleared = { ...current, buffer: '', cursor: 0, lastSubmit: null, lastWasPaste: false };
|
|
248
|
+
commit(cleared);
|
|
162
249
|
if (onBufferChange) {
|
|
163
250
|
try { onBufferChange(''); } catch {}
|
|
164
251
|
}
|
|
@@ -184,15 +271,15 @@ export function Editor({
|
|
|
184
271
|
if (key.tab || key.return) {
|
|
185
272
|
const safeIdx = Math.max(0, Math.min(slashSuggestions.length - 1, slashSelectedIndex || 0));
|
|
186
273
|
const picked = slashSuggestions[safeIdx];
|
|
187
|
-
const bufTrim =
|
|
188
|
-
const alreadyExact = !!picked && (
|
|
274
|
+
const bufTrim = current.buffer.replace(/\s+$/, '');
|
|
275
|
+
const alreadyExact = !!picked && (current.buffer === picked.cmd || bufTrim === picked.cmd);
|
|
189
276
|
if (alreadyExact) {
|
|
190
277
|
if (key.tab) return; // no completion to make
|
|
191
278
|
// key.return on exact match → fall through to applyKey/submit.
|
|
192
279
|
} else {
|
|
193
280
|
if (picked) {
|
|
194
|
-
const next = fillSlashCommand(
|
|
195
|
-
|
|
281
|
+
const next = fillSlashCommand(current, picked.cmd);
|
|
282
|
+
commit(next);
|
|
196
283
|
if (onBufferChange) {
|
|
197
284
|
try { onBufferChange(next.buffer); } catch {}
|
|
198
285
|
}
|
|
@@ -206,8 +293,8 @@ export function Editor({
|
|
|
206
293
|
// Esc: forward to host (ReplApp uses this to abort an in-flight turn).
|
|
207
294
|
// Do not mutate the buffer — the user may want to keep typing.
|
|
208
295
|
if (key.escape) { if (onEscape) onEscape(); return; }
|
|
209
|
-
const next = applyKey(
|
|
210
|
-
|
|
296
|
+
const next = applyKey(current, { input, key });
|
|
297
|
+
commit(next);
|
|
211
298
|
if (onBufferChange) {
|
|
212
299
|
try { onBufferChange(next.buffer); } catch { /* observer is best-effort */ }
|
|
213
300
|
}
|
|
@@ -216,38 +303,77 @@ export function Editor({
|
|
|
216
303
|
if (state.lastSubmit !== null && onSubmit) onSubmit(state.lastSubmit);
|
|
217
304
|
}, [state.lastSubmit]);
|
|
218
305
|
|
|
306
|
+
// v5.4.3 — IME cursor anchor.
|
|
307
|
+
//
|
|
308
|
+
// After every Ink render commit, move the terminal cursor BACK inside
|
|
309
|
+
// the editor's content row so macOS Hangul / Japanese / Chinese IMEs
|
|
310
|
+
// draw their pre-edit overlay where the user is actually typing
|
|
311
|
+
// (instead of on the row below the editor box, where Ink's log-update
|
|
312
|
+
// parks the cursor by default via its trailing '\n').
|
|
313
|
+
//
|
|
314
|
+
// Trade-off: each render briefly moves the cursor, which means the
|
|
315
|
+
// NEXT render's log-update eraseLines starts from inside the editor.
|
|
316
|
+
// Ink immediately overwrites with the full new frame, so the user
|
|
317
|
+
// sees at most a one-tick flicker. The IME-correctness win is worth
|
|
318
|
+
// the cosmetic cost. Opt out via LAZYCLAW_NO_CURSOR_ANCHOR=1.
|
|
319
|
+
useEffect(() => {
|
|
320
|
+
if (!altEnabled) return;
|
|
321
|
+
if (process.env.LAZYCLAW_NO_CURSOR_ANCHOR === '1') return;
|
|
322
|
+
if (!(process.stdout && process.stdout.isTTY)) return;
|
|
323
|
+
const cols = Math.max(20, process.stdout.columns || 80);
|
|
324
|
+
const inner = Math.max(8, cols - 4);
|
|
325
|
+
const fb = inner - PROMPT_WIDTH;
|
|
326
|
+
const cb = inner - CONTINUATION_WIDTH;
|
|
327
|
+
|
|
328
|
+
// Count total rendered rows (for "rowsUp" math) and locate the
|
|
329
|
+
// cursor's (rowInEditor, colInLine) by wrapping the buffer up to
|
|
330
|
+
// the codepoint cursor position.
|
|
331
|
+
const fullLines = String(state.buffer || '').split('\n');
|
|
332
|
+
let totalRows = 0;
|
|
333
|
+
for (let li = 0; li < fullLines.length; li++) {
|
|
334
|
+
const wrapped = wrapToBudget(fullLines[li], fb, cb);
|
|
335
|
+
totalRows += wrapped.length;
|
|
336
|
+
}
|
|
337
|
+
const before = String(state.buffer || '').slice(0, state.cursor || 0);
|
|
338
|
+
const beforeLines = before.split('\n');
|
|
339
|
+
let rowInEditor = 0;
|
|
340
|
+
for (let li = 0; li < beforeLines.length - 1; li++) {
|
|
341
|
+
const wrapped = wrapToBudget(beforeLines[li], fb, cb);
|
|
342
|
+
rowInEditor += wrapped.length;
|
|
343
|
+
}
|
|
344
|
+
const lastBefore = beforeLines[beforeLines.length - 1] || '';
|
|
345
|
+
const lastWrapped = wrapToBudget(lastBefore, fb, cb);
|
|
346
|
+
rowInEditor += lastWrapped.length - 1;
|
|
347
|
+
const lastSegment = lastWrapped[lastWrapped.length - 1] || '';
|
|
348
|
+
const colInLine = stringWidth(lastSegment);
|
|
349
|
+
|
|
350
|
+
// After Ink writes `<frame>\n`, cursor sits on the line below the
|
|
351
|
+
// editor's bottom border at column 0. Editor geometry: 1 top border
|
|
352
|
+
// + totalRows content + 1 bottom border. So the row count between
|
|
353
|
+
// "below editor" and the cursor's target content row is
|
|
354
|
+
// (1 trailing-\n + 1 bottom border + (totalRows - 1 - rowInEditor))
|
|
355
|
+
// = totalRows + 1 - rowInEditor.
|
|
356
|
+
const rowsUp = Math.max(1, totalRows + 1 - rowInEditor);
|
|
357
|
+
|
|
358
|
+
// Column: 1-indexed. Left border at col 1, padX at col 2, prefix
|
|
359
|
+
// starts at col 3. Cursor sits one cell past the typed content.
|
|
360
|
+
const prefixWidth = rowInEditor === 0 ? PROMPT_WIDTH : CONTINUATION_WIDTH;
|
|
361
|
+
const colTarget = 3 + prefixWidth + colInLine;
|
|
362
|
+
try {
|
|
363
|
+
process.stdout.write(`\x1b[${rowsUp}A\x1b[${colTarget}G\x1b[?25h`);
|
|
364
|
+
} catch { /* stdout closed — swallow */ }
|
|
365
|
+
}, [state.buffer, state.cursor, altEnabled]);
|
|
366
|
+
|
|
219
367
|
const lines = state.buffer.split('\n');
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
// the actual
|
|
225
|
-
//
|
|
226
|
-
// budget so Ink never has to guess.
|
|
368
|
+
// Ink's <Text wrap="wrap"> uses wrap-ansi (string-width aware) but the
|
|
369
|
+
// box's width resolves against ink-testing-library's stdout shim and
|
|
370
|
+
// some real terminals at 100 cols regardless of the actual viewport,
|
|
371
|
+
// so wide CJK buffers bleed past the right edge in narrow terminals.
|
|
372
|
+
// Pre-wrap to the actual cell budget via the module-level wrapToBudget
|
|
373
|
+
// (see top of this file) so Ink never has to guess.
|
|
227
374
|
const TERM = Math.max(20, process.stdout.columns || 80);
|
|
228
375
|
// Box overhead: 1 border + 1 padX on each side = 4 cells; first row
|
|
229
376
|
// also reserves PROMPT_WIDTH; continuation rows reserve CONTINUATION_WIDTH.
|
|
230
|
-
function wrapToBudget(text, firstBudget, contBudget) {
|
|
231
|
-
if (!text) return [''];
|
|
232
|
-
const out = [];
|
|
233
|
-
let line = '';
|
|
234
|
-
let lineW = 0;
|
|
235
|
-
let budget = firstBudget;
|
|
236
|
-
for (const ch of text) {
|
|
237
|
-
const w = stringWidth(ch);
|
|
238
|
-
if (lineW + w > budget) {
|
|
239
|
-
out.push(line);
|
|
240
|
-
line = ch;
|
|
241
|
-
lineW = w;
|
|
242
|
-
budget = contBudget;
|
|
243
|
-
} else {
|
|
244
|
-
line += ch;
|
|
245
|
-
lineW += w;
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
out.push(line);
|
|
249
|
-
return out;
|
|
250
|
-
}
|
|
251
377
|
const innerCells = Math.max(8, TERM - 4); // 2 border + 2 padX
|
|
252
378
|
const renderedLines = [];
|
|
253
379
|
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
|
@@ -2,11 +2,15 @@
|
|
|
2
2
|
// (spec §5.8) AND a sticky-bottom chat layout (v5.3).
|
|
3
3
|
//
|
|
4
4
|
// Layout (top → bottom inside the outer column):
|
|
5
|
-
// 1.
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// as new content appends
|
|
9
|
-
//
|
|
5
|
+
// 1. Scrollback — splash item + per-turn user/assistant blocks.
|
|
6
|
+
// Non-alt path uses <Static items={scrollback}/>: Ink writes each
|
|
7
|
+
// item ONCE to terminal scrollback so the splash + history scroll
|
|
8
|
+
// away naturally as new content appends (the v5.3 contract).
|
|
9
|
+
// Alt-buffer path renders the same items as regular flex children
|
|
10
|
+
// instead — Static's "write above the live frame" mechanism is
|
|
11
|
+
// invisible inside the DEC 1049 alt canvas (the live frame
|
|
12
|
+
// immediately overwrites that area), so v5.4.1 splashes vanished.
|
|
13
|
+
// Flex children re-render each frame; <Splash/> output is stable.
|
|
10
14
|
// 2. Live region — partial assistant stream (state.liveAssistant) and
|
|
11
15
|
// optional <SlashHints/> while the input buffer starts with '/'.
|
|
12
16
|
// This Box re-renders on every chunk; the rest of the tree does not.
|
|
@@ -34,6 +38,7 @@ import { Splash, renderSplashToString } from './splash.mjs';
|
|
|
34
38
|
import { Editor } from './editor.mjs';
|
|
35
39
|
import { SlashPopup, filterSlashCommands } from './slash_popup.mjs';
|
|
36
40
|
import { SLASH_COMMANDS } from './slash_commands.mjs';
|
|
41
|
+
import { ModalPicker, filterModalItems } from './modal_picker.mjs';
|
|
37
42
|
import { theme } from './theme.mjs';
|
|
38
43
|
|
|
39
44
|
// ─── Alt-buffer mount (DEC 1049) ─────────────────────────────────────────
|
|
@@ -183,7 +188,7 @@ export function consumeNextTurnFirstMessage(state) {
|
|
|
183
188
|
// - runTurnFactory(writeFn) → runTurn(text, signal) (sticky layout)
|
|
184
189
|
// - runTurn(text, signal) (legacy, stdout)
|
|
185
190
|
// Legacy mode is preserved verbatim for the existing cli.mjs callsite.
|
|
186
|
-
export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, onSlashCommand, statusInfo }) {
|
|
191
|
+
export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, onSlashCommand, statusInfo, pickerRef }) {
|
|
187
192
|
// statusInfo lets the host supply provider/model/ctx to the StatusBar
|
|
188
193
|
// independently of splashProps. Needed for the alt-buffer hand-off
|
|
189
194
|
// where splashProps is pre-printed to the primary buffer (not the
|
|
@@ -352,7 +357,88 @@ export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, o
|
|
|
352
357
|
const _exactOnly =
|
|
353
358
|
filtered.length === 1 &&
|
|
354
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.
|
|
355
440
|
const showSlashPopup =
|
|
441
|
+
!modalOpen &&
|
|
356
442
|
bufferPeek.startsWith('/') && filtered.length > 0 && !_exactOnly;
|
|
357
443
|
|
|
358
444
|
// Outer column height: pinned to rows-1 in alt-buffer mode so the
|
|
@@ -366,19 +452,32 @@ export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, o
|
|
|
366
452
|
React.createElement(
|
|
367
453
|
Box,
|
|
368
454
|
{ flexDirection: 'column', height: outerHeight },
|
|
369
|
-
// 1) Scrollback
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
// the
|
|
373
|
-
//
|
|
455
|
+
// 1) Scrollback.
|
|
456
|
+
// Alt-buffer path: render items as regular flex children, NOT via
|
|
457
|
+
// <Static/>. Ink's <Static/> writes once to stdout above the live
|
|
458
|
+
// frame — in the DEC 1049 alt canvas that area is immediately
|
|
459
|
+
// overwritten by the next live frame, so the splash + history
|
|
460
|
+
// end up invisible (v5.4.1 regression). Trade-off: items
|
|
461
|
+
// re-render each frame; <Splash/> output is stable so this is
|
|
462
|
+
// visually identical to the Static version.
|
|
463
|
+
// Non-alt path: keeps <Static/> — the legacy v5.3 contract
|
|
464
|
+
// (splash scrolls away naturally on the primary buffer) AND the
|
|
465
|
+
// structural snapshot in tests/v53-repl-layout.test.mjs.
|
|
374
466
|
altEnabled
|
|
375
467
|
? React.createElement(
|
|
376
468
|
Box,
|
|
377
469
|
{ flexDirection: 'column', flexGrow: 1, flexShrink: 1, overflow: 'hidden' },
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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) =>
|
|
480
|
+
React.createElement(ScrollbackItem, { key: item.id, item })
|
|
382
481
|
),
|
|
383
482
|
// Live region — partial assistant stream (inside the scroll
|
|
384
483
|
// region so it grows naturally above the status bar).
|
|
@@ -412,6 +511,19 @@ export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, o
|
|
|
412
511
|
selectedIndex: selectedSuggestion,
|
|
413
512
|
})
|
|
414
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,
|
|
415
527
|
// 4) Status bar (sticky, single row above input). flexShrink:0 so
|
|
416
528
|
// it isn't squeezed when the scrollback grows.
|
|
417
529
|
React.createElement(StatusBar, {
|
|
@@ -435,6 +547,20 @@ export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, o
|
|
|
435
547
|
slashSelectedIndex: selectedSuggestion,
|
|
436
548
|
onSlashMove: handleSlashMove,
|
|
437
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,
|
|
438
564
|
})
|
|
439
565
|
)
|
|
440
566
|
)
|
package/tui/slash_commands.mjs
CHANGED
|
@@ -32,6 +32,8 @@ export const SLASH_COMMANDS = [
|
|
|
32
32
|
{ cmd: '/team', help: 'list, create, or join a team' },
|
|
33
33
|
{ cmd: '/task', help: 'create, list, or manage tasks' },
|
|
34
34
|
{ cmd: '/handoff', help: 'hand current task off to another agent' },
|
|
35
|
+
{ cmd: '/dashboard', help: 'open the lazyclaw web UI in your browser' },
|
|
36
|
+
{ cmd: '/clear', help: 'alias for /new — clear conversation' },
|
|
35
37
|
{ cmd: '/exit', help: 'leave the chat' },
|
|
36
38
|
{ cmd: '/quit', help: 'alias for /exit' },
|
|
37
39
|
];
|
package/tui/slash_dispatcher.mjs
CHANGED
|
@@ -125,16 +125,38 @@ async function _newReset(_args, ctx) {
|
|
|
125
125
|
return 'cleared — new conversation';
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
function _providerLookup(registry, name) {
|
|
129
|
+
if (typeof registry.lookupProv === 'function') return registry.lookupProv(name);
|
|
130
|
+
return registry.PROVIDERS ? registry.PROVIDERS[name] : null;
|
|
131
|
+
}
|
|
132
|
+
|
|
128
133
|
async function _provider(args, ctx) {
|
|
129
134
|
const registry = await _mod(ctx, 'registryMod', () => import('../providers/registry.mjs'));
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
};
|
|
135
|
+
// No arg → open the Ink modal picker (v5.4.3). Falls back to the
|
|
136
|
+
// pre-v5.4.3 hint string when ctx.openPicker isn't available (e.g.
|
|
137
|
+
// non-Ink callers or before the picker ref settles).
|
|
134
138
|
if (!args) {
|
|
135
|
-
|
|
139
|
+
if (typeof ctx.openPicker === 'function') {
|
|
140
|
+
const known = Object.keys(registry.PROVIDERS || {}).sort();
|
|
141
|
+
const info = registry.PROVIDER_INFO || {};
|
|
142
|
+
const items = known.map((id) => ({
|
|
143
|
+
id,
|
|
144
|
+
label: id,
|
|
145
|
+
desc: info[id] && info[id].docs ? String(info[id].docs).split('\n')[0].slice(0, 60) : '',
|
|
146
|
+
}));
|
|
147
|
+
const picked = await ctx.openPicker({
|
|
148
|
+
kind: 'provider',
|
|
149
|
+
title: 'select provider',
|
|
150
|
+
subtitle: `current: ${ctx.getActiveProvName()}`,
|
|
151
|
+
items,
|
|
152
|
+
});
|
|
153
|
+
if (!picked) return 'cancelled';
|
|
154
|
+
args = picked;
|
|
155
|
+
} else {
|
|
156
|
+
return `provider: ${ctx.getActiveProvName()}\n(pass an arg: /provider <name>)`;
|
|
157
|
+
}
|
|
136
158
|
}
|
|
137
|
-
const next =
|
|
159
|
+
const next = _providerLookup(registry, args);
|
|
138
160
|
if (!next) {
|
|
139
161
|
const known = registry.PROVIDERS ? Object.keys(registry.PROVIDERS).join(', ') : '?';
|
|
140
162
|
return `unknown provider: ${args} (known: ${known})`;
|
|
@@ -147,17 +169,36 @@ async function _provider(args, ctx) {
|
|
|
147
169
|
async function _model(args, ctx) {
|
|
148
170
|
const registry = await _mod(ctx, 'registryMod', () => import('../providers/registry.mjs'));
|
|
149
171
|
if (!args) {
|
|
150
|
-
|
|
172
|
+
if (typeof ctx.openPicker === 'function') {
|
|
173
|
+
const provName = ctx.getActiveProvName();
|
|
174
|
+
const info = (registry.PROVIDER_INFO && registry.PROVIDER_INFO[provName]) || {};
|
|
175
|
+
const suggested = Array.isArray(info.suggestedModels) ? info.suggestedModels : [];
|
|
176
|
+
if (!suggested.length) {
|
|
177
|
+
return `no suggested models known for provider "${provName}" — pass one: /model <name>`;
|
|
178
|
+
}
|
|
179
|
+
const items = suggested.map((m) => ({
|
|
180
|
+
id: m,
|
|
181
|
+
label: m,
|
|
182
|
+
desc: info.defaultModel === m ? '(default)' : '',
|
|
183
|
+
}));
|
|
184
|
+
const picked = await ctx.openPicker({
|
|
185
|
+
kind: 'model',
|
|
186
|
+
title: `select model for ${provName}`,
|
|
187
|
+
subtitle: `current: ${ctx.getActiveModel() || '(default)'}`,
|
|
188
|
+
items,
|
|
189
|
+
});
|
|
190
|
+
if (!picked) return 'cancelled';
|
|
191
|
+
args = picked;
|
|
192
|
+
} else {
|
|
193
|
+
return `model: ${ctx.getActiveModel() || '(default)'}\n(pass an arg: /model <name>)`;
|
|
194
|
+
}
|
|
151
195
|
}
|
|
152
196
|
const { parseSlashProviderModel } = registry;
|
|
153
197
|
const parsed = typeof parseSlashProviderModel === 'function'
|
|
154
198
|
? parseSlashProviderModel(args)
|
|
155
199
|
: { provider: null, model: args };
|
|
156
200
|
if (parsed.provider) {
|
|
157
|
-
const
|
|
158
|
-
? registry.lookupProv(name)
|
|
159
|
-
: (registry.PROVIDERS ? registry.PROVIDERS[name] : null));
|
|
160
|
-
const next = lookup(parsed.provider);
|
|
201
|
+
const next = _providerLookup(registry, parsed.provider);
|
|
161
202
|
if (!next) return `unknown provider: ${parsed.provider}`;
|
|
162
203
|
if (ctx.setActiveProvName) ctx.setActiveProvName(parsed.provider);
|
|
163
204
|
if (ctx.setProv) ctx.setProv(next);
|
|
@@ -562,19 +603,267 @@ async function _handoff(args, ctx) {
|
|
|
562
603
|
}
|
|
563
604
|
}
|
|
564
605
|
|
|
565
|
-
async function _personality(
|
|
566
|
-
// cmdPersonality
|
|
567
|
-
//
|
|
568
|
-
//
|
|
569
|
-
|
|
606
|
+
async function _personality(args, ctx) {
|
|
607
|
+
// cmdPersonality in cli.mjs is actually non-interactive (takes
|
|
608
|
+
// sub+a+b args). We mirror that surface here without going through
|
|
609
|
+
// cli.mjs (avoid circular import) — list / show / install / remove /
|
|
610
|
+
// use, plus a no-arg picker over installed personality files.
|
|
611
|
+
let fs, path;
|
|
612
|
+
try {
|
|
613
|
+
fs = await import('node:fs');
|
|
614
|
+
path = await import('node:path');
|
|
615
|
+
} catch (e) { return `/personality unavailable: ${e?.message || e}`; }
|
|
616
|
+
const dir = path.join(ctx.cfgDir, 'personalities');
|
|
617
|
+
try { fs.mkdirSync(dir, { recursive: true }); } catch { /* swallow */ }
|
|
618
|
+
|
|
619
|
+
const list = () => fs.existsSync(dir)
|
|
620
|
+
? fs.readdirSync(dir).filter((f) => f.endsWith('.md')).map((f) => f.slice(0, -3)).sort()
|
|
621
|
+
: [];
|
|
622
|
+
|
|
623
|
+
const tokens = splitWhitespace(args);
|
|
624
|
+
const sub = tokens[0];
|
|
625
|
+
const a = tokens[1];
|
|
626
|
+
const b = tokens[2];
|
|
627
|
+
|
|
628
|
+
// No arg → open Ink modal picker (v5.4.3). Confirm selects + activates.
|
|
629
|
+
if (!sub) {
|
|
630
|
+
const names = list();
|
|
631
|
+
if (!names.length) return 'no personalities installed — `lazyclaw personality install <name> <file.md>`';
|
|
632
|
+
if (typeof ctx.openPicker !== 'function') {
|
|
633
|
+
return `personalities: ${names.join(', ')}\n(pass an arg: /personality use <name>)`;
|
|
634
|
+
}
|
|
635
|
+
const items = names.map((n) => ({ id: n, label: n }));
|
|
636
|
+
const picked = await ctx.openPicker({
|
|
637
|
+
kind: 'personality',
|
|
638
|
+
title: 'select personality',
|
|
639
|
+
subtitle: 'Enter activates · Esc cancels',
|
|
640
|
+
items,
|
|
641
|
+
});
|
|
642
|
+
if (!picked) return 'cancelled';
|
|
643
|
+
return _personalityUse(picked, ctx, fs, path);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if (sub === 'list') {
|
|
647
|
+
const names = list();
|
|
648
|
+
if (!names.length) return 'no personalities installed';
|
|
649
|
+
return names.join('\n');
|
|
650
|
+
}
|
|
651
|
+
if (sub === 'show') {
|
|
652
|
+
if (!a) return 'usage: /personality show <name>';
|
|
653
|
+
const p = path.join(dir, `${a}.md`);
|
|
654
|
+
if (!fs.existsSync(p)) return `personality not found: ${a}`;
|
|
655
|
+
return fs.readFileSync(p, 'utf8');
|
|
656
|
+
}
|
|
657
|
+
if (sub === 'install') {
|
|
658
|
+
if (!a || !b) return 'usage: /personality install <name> <file.md>';
|
|
659
|
+
const dst = path.join(dir, `${a}.md`);
|
|
660
|
+
if (fs.existsSync(dst)) return `personality already installed: ${a}`;
|
|
661
|
+
if (!fs.existsSync(b)) return `source file not found: ${b}`;
|
|
662
|
+
fs.writeFileSync(dst, fs.readFileSync(b, 'utf8'));
|
|
663
|
+
return `installed ${a}`;
|
|
664
|
+
}
|
|
665
|
+
if (sub === 'remove' || sub === 'rm' || sub === 'delete') {
|
|
666
|
+
if (!a) return 'usage: /personality remove <name>';
|
|
667
|
+
const p = path.join(dir, `${a}.md`);
|
|
668
|
+
if (!fs.existsSync(p)) return `personality not installed: ${a}`;
|
|
669
|
+
fs.unlinkSync(p);
|
|
670
|
+
return `removed ${a}`;
|
|
671
|
+
}
|
|
672
|
+
if (sub === 'use') {
|
|
673
|
+
if (!a) return 'usage: /personality use <name>';
|
|
674
|
+
return _personalityUse(a, ctx, fs, path);
|
|
675
|
+
}
|
|
676
|
+
return `/personality: unknown sub "${sub}" — list|show|install|remove|use (or no-arg picker)`;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function _personalityUse(name, ctx, fs, path) {
|
|
680
|
+
const dir = path.join(ctx.cfgDir, 'personalities');
|
|
681
|
+
const p = path.join(dir, `${name}.md`);
|
|
682
|
+
if (!fs.existsSync(p)) return `personality not installed: ${name}`;
|
|
683
|
+
// Read-merge-write config.json so we never clobber unrelated keys.
|
|
684
|
+
const cfgPath = path.join(ctx.cfgDir, 'config.json');
|
|
685
|
+
let diskCfg = {};
|
|
686
|
+
try { diskCfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); } catch { /* fresh */ }
|
|
687
|
+
diskCfg.persona = { ...(diskCfg.persona || {}), personality: name };
|
|
688
|
+
try { fs.mkdirSync(ctx.cfgDir, { recursive: true }); } catch {}
|
|
689
|
+
fs.writeFileSync(cfgPath, JSON.stringify(diskCfg, null, 2));
|
|
690
|
+
// Mirror onto the in-memory cfg so the next turn picks up the change.
|
|
691
|
+
if (ctx.cfg) {
|
|
692
|
+
ctx.cfg.persona = { ...(ctx.cfg.persona || {}), personality: name };
|
|
693
|
+
}
|
|
694
|
+
return `active personality → ${name}`;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
async function _task(args, ctx) {
|
|
698
|
+
let tasksMod, loopMod;
|
|
699
|
+
try {
|
|
700
|
+
tasksMod = await import('../tasks.mjs');
|
|
701
|
+
loopMod = await import('../loop-engine.mjs');
|
|
702
|
+
} catch (e) { return `/task unavailable: ${e?.message || e}`; }
|
|
703
|
+
let tokens;
|
|
704
|
+
try { tokens = loopMod.splitArgs(args); }
|
|
705
|
+
catch (e) { return `/task error: ${e?.message || e}`; }
|
|
706
|
+
const sub = tokens[0];
|
|
707
|
+
const id = tokens[1];
|
|
708
|
+
|
|
709
|
+
try {
|
|
710
|
+
if (!sub || sub === 'list') {
|
|
711
|
+
const items = tasksMod.listTasks(ctx.cfgDir);
|
|
712
|
+
if (!items.length) return 'no tasks. `lazyclaw task start --team ... --title ...` from the shell to create one.';
|
|
713
|
+
return items.map((t) =>
|
|
714
|
+
`• ${t.id} [${t.status || 'unknown'}] ${t.title || '(no title)'}${t.team ? ` — team=${t.team}` : ''}${t.lead ? ` — lead=${t.lead}` : ''}`
|
|
715
|
+
).join('\n');
|
|
716
|
+
}
|
|
717
|
+
if (sub === 'show') {
|
|
718
|
+
if (!id) return 'usage: /task show <id>';
|
|
719
|
+
const t = tasksMod.getTask(id, ctx.cfgDir);
|
|
720
|
+
if (!t) return `no task "${id}"`;
|
|
721
|
+
return JSON.stringify(t, null, 2);
|
|
722
|
+
}
|
|
723
|
+
if (sub === 'transcript') {
|
|
724
|
+
if (!id) return 'usage: /task transcript <id> [text|md|json]';
|
|
725
|
+
const t = tasksMod.getTask(id, ctx.cfgDir);
|
|
726
|
+
if (!t) return `no task "${id}"`;
|
|
727
|
+
const fmt = tokens[2] || 'text';
|
|
728
|
+
if (typeof tasksMod.formatTranscript === 'function') {
|
|
729
|
+
return tasksMod.formatTranscript(t, fmt);
|
|
730
|
+
}
|
|
731
|
+
return JSON.stringify(t.turns || [], null, 2);
|
|
732
|
+
}
|
|
733
|
+
if (sub === 'abandon' || sub === 'done') {
|
|
734
|
+
if (!id) return `usage: /task ${sub} <id>`;
|
|
735
|
+
const next = tasksMod.patchTask(id, { status: sub === 'done' ? 'done' : 'abandoned' }, ctx.cfgDir);
|
|
736
|
+
return `✓ task ${id} → ${next?.status || sub}`;
|
|
737
|
+
}
|
|
738
|
+
if (sub === 'remove' || sub === 'rm' || sub === 'delete') {
|
|
739
|
+
if (!id) return 'usage: /task remove <id>';
|
|
740
|
+
tasksMod.removeTask(id, ctx.cfgDir);
|
|
741
|
+
return `✓ removed task ${id}`;
|
|
742
|
+
}
|
|
743
|
+
if (sub === 'start' || sub === 'tick') {
|
|
744
|
+
return `task ${sub}: needs Slack + multi-agent router — run from the shell:\n lazyclaw task ${sub} ...`;
|
|
745
|
+
}
|
|
746
|
+
return `/task: unknown sub "${sub}" — list|show|transcript|abandon|done|remove (start/tick: use CLI)`;
|
|
747
|
+
} catch (e) {
|
|
748
|
+
return `/task error: ${e?.message || e}`;
|
|
749
|
+
}
|
|
570
750
|
}
|
|
571
751
|
|
|
572
|
-
async function
|
|
573
|
-
|
|
752
|
+
async function _trainer(args, ctx) {
|
|
753
|
+
const registry = await _mod(ctx, 'registryMod', () => import('../providers/registry.mjs'));
|
|
754
|
+
const tokens = splitWhitespace(args);
|
|
755
|
+
const sub = tokens[0] || 'show';
|
|
756
|
+
|
|
757
|
+
if (sub === 'show') {
|
|
758
|
+
let effective = { provider: ctx.getActiveProvName ? ctx.getActiveProvName() : null,
|
|
759
|
+
model: ctx.getActiveModel ? ctx.getActiveModel() : null };
|
|
760
|
+
try {
|
|
761
|
+
if (typeof registry.resolveTrainer === 'function') {
|
|
762
|
+
effective = registry.resolveTrainer(ctx.cfg || {});
|
|
763
|
+
}
|
|
764
|
+
} catch { /* fall through */ }
|
|
765
|
+
const configured = (ctx.cfg && ctx.cfg.trainer) || null;
|
|
766
|
+
const cfgRender = configured
|
|
767
|
+
? JSON.stringify(configured, null, 2)
|
|
768
|
+
: '(unset — trainer mirrors the chat provider/model)';
|
|
769
|
+
return [
|
|
770
|
+
'trainer (effective):',
|
|
771
|
+
` provider: ${effective.provider}`,
|
|
772
|
+
` model: ${effective.model || '(default)'}`,
|
|
773
|
+
'trainer (configured):',
|
|
774
|
+
cfgRender,
|
|
775
|
+
].join('\n');
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
if (sub === 'set') {
|
|
779
|
+
const spec = tokens[1];
|
|
780
|
+
if (!spec) return 'usage: /trainer set <provider>[:<model>] (or `auto` for orchestrator-managed)';
|
|
781
|
+
const parsed = typeof registry.parseProviderModel === 'function'
|
|
782
|
+
? registry.parseProviderModel(spec)
|
|
783
|
+
: { provider: spec.split(':')[0], model: spec.split(':')[1] || null };
|
|
784
|
+
if (!parsed || !parsed.provider) return `/trainer set: could not parse "${spec}"`;
|
|
785
|
+
if (parsed.provider !== 'auto') {
|
|
786
|
+
const next = _providerLookup(registry, parsed.provider);
|
|
787
|
+
if (!next) return `/trainer set: unknown provider "${parsed.provider}"`;
|
|
788
|
+
}
|
|
789
|
+
// Read-merge-write so unrelated cfg keys survive.
|
|
790
|
+
const fs = await import('node:fs');
|
|
791
|
+
const path = await import('node:path');
|
|
792
|
+
const cfgPath = path.join(ctx.cfgDir, 'config.json');
|
|
793
|
+
let diskCfg = {};
|
|
794
|
+
try { diskCfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); } catch { /* fresh */ }
|
|
795
|
+
diskCfg.trainer = { ...(diskCfg.trainer || {}), provider: parsed.provider };
|
|
796
|
+
if (parsed.model) diskCfg.trainer.model = parsed.model;
|
|
797
|
+
else delete diskCfg.trainer.model;
|
|
798
|
+
try { fs.mkdirSync(ctx.cfgDir, { recursive: true }); } catch {}
|
|
799
|
+
fs.writeFileSync(cfgPath, JSON.stringify(diskCfg, null, 2));
|
|
800
|
+
if (ctx.cfg) ctx.cfg.trainer = { ...diskCfg.trainer };
|
|
801
|
+
return `✓ trainer → ${parsed.provider}${parsed.model ? ':' + parsed.model : ''}`;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
if (sub === 'clear' || sub === 'unset') {
|
|
805
|
+
const fs = await import('node:fs');
|
|
806
|
+
const path = await import('node:path');
|
|
807
|
+
const cfgPath = path.join(ctx.cfgDir, 'config.json');
|
|
808
|
+
let diskCfg = {};
|
|
809
|
+
try { diskCfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); } catch { /* fresh */ }
|
|
810
|
+
delete diskCfg.trainer;
|
|
811
|
+
try { fs.mkdirSync(ctx.cfgDir, { recursive: true }); } catch {}
|
|
812
|
+
fs.writeFileSync(cfgPath, JSON.stringify(diskCfg, null, 2));
|
|
813
|
+
if (ctx.cfg) delete ctx.cfg.trainer;
|
|
814
|
+
return '✓ trainer cleared (will mirror chat provider/model)';
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
return `/trainer: unknown sub "${sub}" — show|set <p:m>|clear`;
|
|
574
818
|
}
|
|
575
819
|
|
|
576
|
-
|
|
577
|
-
|
|
820
|
+
// /dashboard — open the lazyclaw web UI. Reuses an already-running
|
|
821
|
+
// daemon if one answers /healthz on 19600; otherwise spawns a detached
|
|
822
|
+
// `node cli.mjs dashboard --no-open` child so the daemon outlives this
|
|
823
|
+
// chat session. Never installs signal handlers / never process.exits —
|
|
824
|
+
// Ctrl-C inside chat must NOT touch the dashboard.
|
|
825
|
+
async function _dashboard(_args) {
|
|
826
|
+
const port = 19600;
|
|
827
|
+
const url = `http://127.0.0.1:${port}/dashboard`;
|
|
828
|
+
const healthUrl = `http://127.0.0.1:${port}/healthz`;
|
|
829
|
+
const { spawn } = await import('node:child_process');
|
|
830
|
+
const openBrowser = (u) => {
|
|
831
|
+
let cmd, args;
|
|
832
|
+
if (process.platform === 'darwin') { cmd = 'open'; args = [u]; }
|
|
833
|
+
else if (process.platform === 'win32') { cmd = 'cmd'; args = ['/c', 'start', '""', u]; }
|
|
834
|
+
else { cmd = 'xdg-open'; args = [u]; }
|
|
835
|
+
try { spawn(cmd, args, { stdio: 'ignore', detached: true }).unref(); } catch { /* swallow */ }
|
|
836
|
+
};
|
|
837
|
+
const probe = async () => {
|
|
838
|
+
if (typeof fetch !== 'function') return false;
|
|
839
|
+
try {
|
|
840
|
+
const ac = new AbortController();
|
|
841
|
+
const t = setTimeout(() => ac.abort(), 300);
|
|
842
|
+
const r = await fetch(healthUrl, { signal: ac.signal });
|
|
843
|
+
clearTimeout(t);
|
|
844
|
+
return !!(r && r.ok);
|
|
845
|
+
} catch { return false; }
|
|
846
|
+
};
|
|
847
|
+
if (await probe()) {
|
|
848
|
+
openBrowser(url);
|
|
849
|
+
return `✓ dashboard already running — opened ${url}`;
|
|
850
|
+
}
|
|
851
|
+
// Spawn detached so the daemon outlives this chat process.
|
|
852
|
+
try {
|
|
853
|
+
const child = spawn(process.execPath, [process.argv[1], 'dashboard', '--no-open'], {
|
|
854
|
+
detached: true, stdio: 'ignore', cwd: process.cwd(), env: process.env,
|
|
855
|
+
});
|
|
856
|
+
child.unref();
|
|
857
|
+
} catch (e) {
|
|
858
|
+
return `dashboard error: failed to spawn — ${e?.message || e}`;
|
|
859
|
+
}
|
|
860
|
+
// Poll /healthz up to ~3s before opening the browser.
|
|
861
|
+
const start = Date.now();
|
|
862
|
+
while (Date.now() - start < 3000) {
|
|
863
|
+
if (await probe()) { openBrowser(url); return `✓ started dashboard — opened ${url}`; }
|
|
864
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
865
|
+
}
|
|
866
|
+
return `⚠ dashboard didn't come up within 3s. Try \`lazyclaw dashboard\` from another terminal. URL: ${url}`;
|
|
578
867
|
}
|
|
579
868
|
|
|
580
869
|
// ─── dispatch table ──────────────────────────────────────────────────────
|
|
@@ -586,6 +875,7 @@ export const SLASH_HANDLERS = new Map([
|
|
|
586
875
|
['/usage', _usage],
|
|
587
876
|
['/new', _newReset],
|
|
588
877
|
['/reset', _newReset],
|
|
878
|
+
['/clear', _newReset],
|
|
589
879
|
['/provider', _provider],
|
|
590
880
|
['/model', _model],
|
|
591
881
|
['/skill', _skill],
|
|
@@ -602,6 +892,7 @@ export const SLASH_HANDLERS = new Map([
|
|
|
602
892
|
['/personality', _personality],
|
|
603
893
|
['/task', _task],
|
|
604
894
|
['/trainer', _trainer],
|
|
895
|
+
['/dashboard', _dashboard],
|
|
605
896
|
['/exit', async () => 'EXIT'],
|
|
606
897
|
['/quit', async () => 'EXIT'],
|
|
607
898
|
]);
|
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
|
}
|