lazyclaw 5.4.2 → 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.2",
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
@@ -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,6 +172,22 @@ 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;
@@ -167,6 +209,35 @@ export function Editor({
167
209
 
168
210
  useInput((input, key) => {
169
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
+ }
170
241
  // ─── Slash-popup keyboard contract (highest priority when open) ──
171
242
  if (slashOpen) {
172
243
  // Esc: clear the buffer and dismiss the popup. The host's onEscape
@@ -232,38 +303,77 @@ export function Editor({
232
303
  if (state.lastSubmit !== null && onSubmit) onSubmit(state.lastSubmit);
233
304
  }, [state.lastSubmit]);
234
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
+
235
367
  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.
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.
243
374
  const TERM = Math.max(20, process.stdout.columns || 80);
244
375
  // Box overhead: 1 border + 1 padX on each side = 4 cells; first row
245
376
  // 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
377
  const innerCells = Math.max(8, TERM - 4); // 2 border + 2 padX
268
378
  const renderedLines = [];
269
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
@@ -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
  )
@@ -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
  }