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 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.2",
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
- // Manual cell-aware wrapping. Ink's <Text wrap="wrap"> uses wrap-ansi,
237
- // which IS string-width aware, but the box's width:'100%' resolves
238
- // against ink-testing-library's stdout shim (and some real terminals
239
- // when columns is reset by SIGWINCH late) at 100 cols regardless of
240
- // the actual viewport so wide CJK buffers visibly bleed past the
241
- // box right edge in narrow terminals. Pre-wrap to the actual cell
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
- state.scrollback.map((item) =>
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
  )
@@ -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: 'switch provider: /provider openai (no arg print current)' },
23
- { cmd: '/model', help: 'switch model: /model gpt-4.1 or anthropic/claude-opus-4-7' },
24
- { cmd: '/trainer', help: 'configure trainer provider/model for learning loop' },
25
- { cmd: '/personality', help: 'switch agent personality preset' },
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: 'create, list, or manage tasks' },
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' },
@@ -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 out = {
77
- provider: ctx.getActiveProvName(),
78
- model: ctx.getActiveModel(),
79
- keyMasked: registry.maskApiKey(ctx.cfg && ctx.cfg['api-key']),
80
- messageCount: ctx.getMessages().length,
81
- sessionId: ctx.getSessionId() || null,
82
- };
83
- return JSON.stringify(out);
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 out = {
95
- messageCount: msgs.length,
96
- charsSent: (ctx.getCharsSent && ctx.getCharsSent()) || 0,
97
- };
98
- if (runningUsage) out.tokens = runningUsage;
99
- if (runningUsage && ctx.cfg && ctx.cfg.rates && typeof ctx.cfg.rates === 'object') {
100
- try {
101
- const { costFromUsage } = await import('../providers/rates.mjs');
102
- const r = costFromUsage(
103
- { provider: ctx.getActiveProvName(), model: ctx.getActiveModel(), usage: runningUsage },
104
- ctx.cfg.rates,
105
- );
106
- if (r) out.cost = r;
107
- } catch { /* never let cost-card lookup fail the slash */ }
108
- }
109
- return JSON.stringify(out);
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
- const lookup = (name) => {
131
- if (typeof registry.lookupProv === 'function') return registry.lookupProv(name);
132
- return registry.PROVIDERS ? registry.PROVIDERS[name] : null;
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
- return `provider: ${ctx.getActiveProvName()}\n(interactive picker not available in Ink chat — pass an arg: /provider <name>)`;
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 = lookup(args);
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
- return `model: ${ctx.getActiveModel() || '(default)'}\n(interactive picker not available in Ink chat — pass an arg: /model <name>)`;
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 lookup = (name) => (typeof registry.lookupProv === 'function'
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
- return JSON.stringify(items, null, 2);
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
- return JSON.stringify(mem.listEpisodic(ctx.cfgDir), null, 2);
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(_args) {
566
- // cmdPersonality lives in cli.mjs and is readline-coupled (reads from
567
- // stdin via the global rl). Lifting it cleanly is a v5.5 follow-up; for
568
- // v5.4 we surface the CLI alternative so operators aren't stranded.
569
- return 'personality: interactive picker not yet wired into Ink chat — use `lazyclaw personality list|set <name>` from the shell, or restart with LAZYCLAW_NO_INK=1.';
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
- async function _task() {
573
- return 'task: slash form lands in v5.5 — use the `lazyclaw task` CLI for now.';
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 _trainer() {
577
- return 'trainer: configure via config.json (cfg.trainer) or `lazyclaw trainer` CLI — slash form lands in v5.5.';
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
- lines.push('');
165
-
166
- // 5) status bar (Hermes-style separator)
167
- const sep = '─'.repeat(PANEL_W);
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
- lines.push('');
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
- lines.push('');
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
  }