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 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.1",
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 = { ...state, buffer: '', cursor: 0, lastSubmit: null, lastWasPaste: false };
161
- setState(cleared);
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 = state.buffer.replace(/\s+$/, '');
188
- const alreadyExact = !!picked && (state.buffer === picked.cmd || bufTrim === picked.cmd);
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(state, picked.cmd);
195
- setState(next);
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(state, { input, key });
210
- setState(next);
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
- // Manual cell-aware wrapping. Ink's <Text wrap="wrap"> uses wrap-ansi,
221
- // which IS string-width aware, but the box's width:'100%' resolves
222
- // against ink-testing-library's stdout shim (and some real terminals
223
- // when columns is reset by SIGWINCH late) at 100 cols regardless of
224
- // the actual viewport so wide CJK buffers visibly bleed past the
225
- // box right edge in narrow terminals. Pre-wrap to the actual cell
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. <Static items={scrollback}/> — splash item + per-turn user/assistant
6
- // blocks. Static renders each item ONCE to terminal scrollback and
7
- // never re-renders it, so the splash + history scroll away naturally
8
- // as new content appends. This is the Claude CLI / opencode pattern
9
- // translated to Ink's idiom Static IS the scroll buffer.
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 (write-once via Ink Static).
370
- // In alt-buffer mode the Static lives inside a flex-grow inner
371
- // Box so it absorbs slack rather than pushing the editor out of
372
- // the viewport. In legacy mode it stays sibling-flat so the
373
- // structural test (Editor must be last sibling) still passes.
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
- React.createElement(
379
- Static,
380
- { items: state.scrollback },
381
- (item) => React.createElement(ScrollbackItem, { key: item.id, 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) =>
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
  )
@@ -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
  ];
@@ -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
- const lookup = (name) => {
131
- if (typeof registry.lookupProv === 'function') return registry.lookupProv(name);
132
- return registry.PROVIDERS ? registry.PROVIDERS[name] : null;
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
- return `provider: ${ctx.getActiveProvName()}\n(interactive picker not available in Ink chat — pass an arg: /provider <name>)`;
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 = lookup(args);
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
- return `model: ${ctx.getActiveModel() || '(default)'}\n(interactive picker not available in Ink chat — pass an arg: /model <name>)`;
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 lookup = (name) => (typeof registry.lookupProv === 'function'
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(_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.';
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 _task() {
573
- return 'task: slash form lands in v5.5 use the `lazyclaw task` CLI for now.';
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
- async function _trainer() {
577
- return 'trainer: configure via config.json (cfg.trainer) or `lazyclaw trainer` CLI slash form lands in v5.5.';
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
- 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
  }