lazyclaw 5.2.0 → 5.3.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lazyclaw",
3
- "version": "5.2.0",
3
+ "version": "5.3.0",
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
@@ -2,6 +2,14 @@
2
2
  //
3
3
  // Pure-functional core (makeEditorState, applyKey) so it is testable
4
4
  // without ink stdin. The React component <Editor/> wraps useInput().
5
+ //
6
+ // v5.4: slash-popup integration. When the parent passes a non-empty
7
+ // `slashSuggestions` array, ↑/↓ move the popup selection (instead of
8
+ // scrolling history) and Tab/Enter fill the buffer with the highlighted
9
+ // command WITHOUT submitting it (Anthropic's recent UX rule: first
10
+ // Enter fills, second Enter runs). Esc clears the buffer + dismisses.
11
+ // All popup-aware branches are guarded by `slashOpen` so legacy callers
12
+ // see the pre-v5.4 behavior verbatim.
5
13
  import React, { useState, useEffect } from 'react';
6
14
  import { Box, Text, useInput } from 'ink';
7
15
  import { theme } from './theme.mjs';
@@ -63,11 +71,83 @@ export function applyKey(state, evt) {
63
71
  return next;
64
72
  }
65
73
 
66
- export function Editor({ history, onSubmit }) {
74
+ // Pure helper used by the slash-popup branch in <Editor/>. Replaces the
75
+ // editor buffer with `${cmd} ` (note trailing space so the user can keep
76
+ // typing args without an extra keystroke). Does NOT submit.
77
+ export function fillSlashCommand(state, cmd) {
78
+ const filled = cmd.endsWith(' ') ? cmd : cmd + ' ';
79
+ return {
80
+ ...state,
81
+ buffer: filled,
82
+ cursor: filled.length,
83
+ lastSubmit: null,
84
+ lastWasPaste: false,
85
+ };
86
+ }
87
+
88
+ export function Editor({
89
+ history,
90
+ onSubmit,
91
+ onEscape,
92
+ onBufferChange,
93
+ // v5.4 slash-popup wiring (all optional — pre-v5.4 callers pass none):
94
+ slashSuggestions, // Array<{cmd,help}> | null
95
+ slashSelectedIndex, // number
96
+ onSlashMove, // (delta: -1 | +1) => void
97
+ onSlashDismiss, // () => void
98
+ }) {
67
99
  const [state, setState] = useState(() => makeEditorState({ history }));
100
+ const slashOpen = Array.isArray(slashSuggestions) && slashSuggestions.length > 0;
101
+
68
102
  useInput((input, key) => {
103
+ // ─── Slash-popup keyboard contract (highest priority when open) ──
104
+ if (slashOpen) {
105
+ // Esc: clear the buffer and dismiss the popup. The host's onEscape
106
+ // is NOT called in this branch — Esc here is a popup gesture, not
107
+ // a turn-abort. (Outside of popup mode Esc still aborts streaming.)
108
+ if (key.escape) {
109
+ const cleared = { ...state, buffer: '', cursor: 0, lastSubmit: null, lastWasPaste: false };
110
+ setState(cleared);
111
+ if (onBufferChange) {
112
+ try { onBufferChange(''); } catch {}
113
+ }
114
+ if (onSlashDismiss) onSlashDismiss();
115
+ return;
116
+ }
117
+ // ↑/↓ navigate the popup instead of history.
118
+ if (key.upArrow) {
119
+ if (onSlashMove) onSlashMove(-1);
120
+ return;
121
+ }
122
+ if (key.downArrow) {
123
+ if (onSlashMove) onSlashMove(+1);
124
+ return;
125
+ }
126
+ // Tab / Enter — fill the buffer with the highlighted command.
127
+ // First Enter fills, second Enter runs (matches Anthropic's UX).
128
+ if (key.tab || key.return) {
129
+ const safeIdx = Math.max(0, Math.min(slashSuggestions.length - 1, slashSelectedIndex || 0));
130
+ const picked = slashSuggestions[safeIdx];
131
+ if (picked) {
132
+ const next = fillSlashCommand(state, picked.cmd);
133
+ setState(next);
134
+ if (onBufferChange) {
135
+ try { onBufferChange(next.buffer); } catch {}
136
+ }
137
+ }
138
+ return;
139
+ }
140
+ // Anything else (printable, backspace) falls through to applyKey.
141
+ }
142
+
143
+ // Esc: forward to host (ReplApp uses this to abort an in-flight turn).
144
+ // Do not mutate the buffer — the user may want to keep typing.
145
+ if (key.escape) { if (onEscape) onEscape(); return; }
69
146
  const next = applyKey(state, { input, key });
70
147
  setState(next);
148
+ if (onBufferChange) {
149
+ try { onBufferChange(next.buffer); } catch { /* observer is best-effort */ }
150
+ }
71
151
  });
72
152
  useEffect(() => {
73
153
  if (state.lastSubmit !== null && onSubmit) onSubmit(state.lastSubmit);
package/tui/repl.mjs CHANGED
@@ -1,18 +1,56 @@
1
1
  // tui/repl.mjs — REPL host with mid-stream interrupt-and-redirect
2
- // (spec §5.8). Pure state functions for testability; the React
3
- // mount lives at the bottom and is exercised only when stdin isTTY.
4
- import React, { useState, useEffect } from 'react';
5
- import { Box, useApp } from 'ink';
6
- import { Splash } from './splash.mjs';
2
+ // (spec §5.8) AND a sticky-bottom chat layout (v5.3).
3
+ //
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.
10
+ // 2. Live region — partial assistant stream (state.liveAssistant) and
11
+ // optional <SlashHints/> while the input buffer starts with '/'.
12
+ // This Box re-renders on every chunk; the rest of the tree does not.
13
+ // 3. <StatusBar/> — single row above the input. flexShrink:0.
14
+ // 4. <Editor/> — sticky bottom, content-sized (1 line idle, grows with
15
+ // multiline). Last sibling in the column so it pins to the bottom.
16
+ //
17
+ // Streaming chunks now flow into React state via an injected writeFn
18
+ // (state.liveAssistant += chunk). On turn completion the accumulated
19
+ // text is committed to scrollback so React stops re-rendering it.
20
+ // This closes the v5.0.10 TODO from cli.mjs:2628-2632.
21
+ //
22
+ // Backward-compat contracts (do not break):
23
+ // - makeReplState() — still callable with zero args.
24
+ // - onUserInput, onEscape, onTurnComplete, consumeNextTurnFirstMessage
25
+ // keep their pre-v5.3 shapes (tests/phaseC-repl-interrupt.test.mjs).
26
+ // - ReplApp({ splashProps, runTurn }) — legacy callsite (cli.mjs:2637)
27
+ // still works; runTurn writes go to wherever its writeFn points.
28
+ // - ReplApp({ splashProps, runTurnFactory }) — new callsite for the
29
+ // sticky layout; ReplApp injects writeFn → scrollback.
30
+ //
31
+ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
32
+ import { Box, Static, Text, useApp } from 'ink';
33
+ import { Splash, renderSplashToString } from './splash.mjs';
7
34
  import { Editor } from './editor.mjs';
35
+ import { SlashPopup, filterSlashCommands } from './slash_popup.mjs';
36
+ import { SLASH_COMMANDS } from './slash_commands.mjs';
37
+ import { theme } from './theme.mjs';
8
38
 
9
- export function makeReplState() {
39
+ // ─── Pure state ──────────────────────────────────────────────────────────
40
+ //
41
+ // makeReplState stays callable with zero args (existing tests rely on it).
42
+ // The new fields default to empty so legacy callers see no behavior change.
43
+ export function makeReplState(opts) {
44
+ const splashItem = opts && opts.splashItem ? opts.splashItem : null;
10
45
  return {
11
46
  streaming: false,
12
47
  controller: null,
13
48
  pendingPrepend: null,
14
49
  nextTurnFirstMessage: null,
15
50
  history: [],
51
+ scrollback: splashItem ? [splashItem] : [],
52
+ liveAssistant: '',
53
+ turnCounter: 0,
16
54
  };
17
55
  }
18
56
 
@@ -22,12 +60,16 @@ export function onUserInput(state, { text, controller }) {
22
60
  try { state.controller.abort(); } catch {}
23
61
  return { ...state, pendingPrepend: text };
24
62
  }
25
- // idle — start a new turn.
63
+ // idle — start a new turn. Append a 'user' entry to scrollback so the
64
+ // sticky-layout caller sees the prompt history above the live stream.
65
+ const id = `u-${state.turnCounter}`;
26
66
  return {
27
67
  ...state,
28
68
  streaming: true,
29
69
  controller,
30
70
  history: [...state.history, text],
71
+ scrollback: [...state.scrollback, { kind: 'user', id, text }],
72
+ turnCounter: state.turnCounter + 1,
31
73
  };
32
74
  }
33
75
 
@@ -35,18 +77,45 @@ export function onEscape(state) {
35
77
  if (state.streaming && state.controller) {
36
78
  try { state.controller.abort(); } catch {}
37
79
  }
38
- return { ...state, streaming: false, controller: null, pendingPrepend: null };
80
+ // Drop any partial live assistant text on explicit Esc — the user is
81
+ // telling us to discard, not to keep.
82
+ return {
83
+ ...state,
84
+ streaming: false,
85
+ controller: null,
86
+ pendingPrepend: null,
87
+ liveAssistant: '',
88
+ };
89
+ }
90
+
91
+ // New reducer: stream chunk arrives, accumulate in liveAssistant.
92
+ export function onStreamChunk(state, { chunk }) {
93
+ return { ...state, liveAssistant: state.liveAssistant + chunk };
39
94
  }
40
95
 
41
96
  export function onTurnComplete(state, { reason } = {}) {
42
- void reason;
43
97
  const promoted = state.pendingPrepend;
98
+ const suffix = reason === 'aborted' ? ' [aborted]'
99
+ : reason === 'error' ? ' [error]'
100
+ : '';
101
+ const text = (state.liveAssistant || '') + suffix;
102
+ // Commit any accumulated live text to scrollback. If the turn produced
103
+ // nothing AND wasn't an error/abort, skip the empty append.
104
+ const shouldCommit = text.length > 0 && (state.liveAssistant.length > 0 || suffix.length > 0);
105
+ const id = `a-${state.turnCounter}`;
106
+ const kind = reason === 'error' ? 'error' : 'assistant';
107
+ const nextScrollback = shouldCommit
108
+ ? [...state.scrollback, { kind, id, text }]
109
+ : state.scrollback;
44
110
  return {
45
111
  ...state,
46
112
  streaming: false,
47
113
  controller: null,
48
114
  pendingPrepend: null,
49
115
  nextTurnFirstMessage: promoted,
116
+ liveAssistant: '',
117
+ scrollback: nextScrollback,
118
+ turnCounter: state.turnCounter + 1,
50
119
  };
51
120
  }
52
121
 
@@ -56,40 +125,202 @@ export function consumeNextTurnFirstMessage(state) {
56
125
  }
57
126
 
58
127
  // ─── React mount ─────────────────────────────────────────────────────────
59
- // v5.1 TODO (C7 follow-up): replace direct stdout writes in cli.mjs's
60
- // _chatRunTurnFactory with a scrollback ref'd via this component. The
61
- // shape would be an `onOutput` prop that pushes chunks into a `useState`
62
- // array rendered through Ink's <Static items={scrollback}/>. v5.0.10
63
- // ships with raw stdout writes interleaved with Ink (acceptable visual
64
- // jank in exchange for unblocking the chat loop).
65
- export function ReplApp({ splashProps, runTurn }) {
66
- const [state, setState] = useState(makeReplState);
128
+ //
129
+ // Two prop modes:
130
+ // - runTurnFactory(writeFn) runTurn(text, signal) (sticky layout)
131
+ // - runTurn(text, signal) (legacy, stdout)
132
+ // Legacy mode is preserved verbatim for the existing cli.mjs callsite.
133
+ export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands }) {
134
+ // Splash is rendered ONCE as scrollback[0] via <Static>. Build it lazily
135
+ // so SSR-style imports without a TTY don't crash on process.stdout.
136
+ const splashItemRef = useRef(null);
137
+ if (splashItemRef.current === null) {
138
+ splashItemRef.current = splashProps
139
+ ? { kind: 'splash', id: 'splash-0', splashProps }
140
+ : null;
141
+ }
142
+ const [state, setState] = useState(() => makeReplState({ splashItem: splashItemRef.current }));
67
143
  const { exit } = useApp();
68
144
 
69
- async function handleSubmit(text) {
70
- if (text === '/exit') { exit(); return; }
145
+ // writeFn for run_turn: route chunks into React state instead of stdout.
146
+ // Only used when runTurnFactory is provided; the legacy `runTurn` prop
147
+ // keeps writing wherever its caller wired it.
148
+ const writeFn = useCallback((chunk) => {
149
+ setState((s) => onStreamChunk(s, { chunk }));
150
+ }, []);
151
+
152
+ // Build the actual runTurn once. Prefer factory (new); fall back to
153
+ // the legacy `runTurn` prop (existing cli.mjs callsite).
154
+ const runTurnRef = useRef(null);
155
+ if (runTurnRef.current === null) {
156
+ if (typeof runTurnFactory === 'function') {
157
+ runTurnRef.current = runTurnFactory(writeFn);
158
+ } else if (typeof runTurn === 'function') {
159
+ runTurnRef.current = runTurn;
160
+ } else {
161
+ runTurnRef.current = async () => {};
162
+ }
163
+ }
164
+
165
+ const handleSubmit = useCallback(async (text) => {
166
+ if (text === '/exit' || text === '/quit') { exit(); return; }
71
167
  const controller = new AbortController();
72
168
  setState((s) => onUserInput(s, { text, controller }));
73
169
  try {
74
- await runTurn(text, controller.signal);
170
+ await runTurnRef.current(text, controller.signal);
75
171
  setState((s) => onTurnComplete(s, { reason: 'done' }));
76
172
  } catch (err) {
77
- setState((s) => onTurnComplete(s, { reason: err.name === 'AbortError' ? 'aborted' : 'error' }));
173
+ setState((s) => onTurnComplete(s, {
174
+ reason: err && err.name === 'AbortError' ? 'aborted' : 'error',
175
+ }));
78
176
  }
79
- }
177
+ }, [exit]);
80
178
 
179
+ // Auto-submit queued mid-stream-interrupt message (spec §5.8). Read
180
+ // state.nextTurnFirstMessage so the effect re-fires when promoted.
81
181
  useEffect(() => {
82
- const [next, msg] = consumeNextTurnFirstMessage(state);
83
- if (msg) {
84
- setState(next);
182
+ if (state.nextTurnFirstMessage) {
183
+ const msg = state.nextTurnFirstMessage;
184
+ setState((s) => ({ ...s, nextTurnFirstMessage: null }));
85
185
  handleSubmit(msg);
86
186
  }
87
- }, [state.nextTurnFirstMessage]);
187
+ }, [state.nextTurnFirstMessage, handleSubmit]);
188
+
189
+ // Esc handler — abort streaming turn, drop partial output.
190
+ const onEscapeKey = useCallback(() => {
191
+ setState((s) => onEscape(s));
192
+ }, []);
193
+
194
+ // ─── Slash popup state (v5.4) ──────────────────────────────────────
195
+ // The editor reports its current buffer via onBufferChange; we derive
196
+ // the filtered command list from that and own the selection index.
197
+ const catalog = useMemo(
198
+ () => (Array.isArray(slashCommands) && slashCommands.length > 0
199
+ ? slashCommands : SLASH_COMMANDS),
200
+ [slashCommands]
201
+ );
202
+ const [bufferPeek, setBufferPeek] = useState('');
203
+ const [selectedSuggestion, setSelectedSuggestion] = useState(0);
204
+ const filtered = useMemo(
205
+ () => filterSlashCommands(bufferPeek, catalog),
206
+ [bufferPeek, catalog]
207
+ );
208
+ // Reset selection whenever the match list changes length (typing
209
+ // narrows results, so highlight the first row again).
210
+ const lastLenRef = useRef(0);
211
+ useEffect(() => {
212
+ if (filtered.length !== lastLenRef.current) {
213
+ setSelectedSuggestion(0);
214
+ lastLenRef.current = filtered.length;
215
+ } else if (selectedSuggestion >= filtered.length) {
216
+ setSelectedSuggestion(Math.max(0, filtered.length - 1));
217
+ }
218
+ }, [filtered.length, selectedSuggestion]);
219
+
220
+ const handleBufferChange = useCallback((buf) => {
221
+ setBufferPeek(buf || '');
222
+ }, []);
223
+ const handleSlashMove = useCallback((delta) => {
224
+ setSelectedSuggestion((i) => {
225
+ const max = Math.max(0, filtered.length - 1);
226
+ const n = i + delta;
227
+ if (n < 0) return 0;
228
+ if (n > max) return max;
229
+ return n;
230
+ });
231
+ }, [filtered.length]);
232
+ const handleSlashDismiss = useCallback(() => {
233
+ setBufferPeek('');
234
+ setSelectedSuggestion(0);
235
+ }, []);
236
+
237
+ const showSlashPopup = bufferPeek.startsWith('/') && filtered.length > 0;
88
238
 
89
239
  return React.createElement(
90
240
  Box,
91
241
  { flexDirection: 'column' },
92
- React.createElement(Splash, splashProps),
93
- React.createElement(Editor, { history: state.history, onSubmit: handleSubmit })
242
+ // 1) Scrollback (write-once via Ink Static)
243
+ React.createElement(
244
+ Static,
245
+ { items: state.scrollback },
246
+ (item) => React.createElement(ScrollbackItem, { key: item.id, item })
247
+ ),
248
+ // 2) Live region — partial assistant stream
249
+ state.liveAssistant
250
+ ? React.createElement(
251
+ Box,
252
+ { flexDirection: 'column' },
253
+ React.createElement(Text, { color: theme.fg }, state.liveAssistant)
254
+ )
255
+ : null,
256
+ // 3) Slash popup — flex sibling above the StatusBar; Ink can't
257
+ // absolutely position so this is the "just above input" pattern.
258
+ showSlashPopup
259
+ ? React.createElement(SlashPopup, {
260
+ buffer: bufferPeek,
261
+ commands: filtered,
262
+ selectedIndex: selectedSuggestion,
263
+ })
264
+ : null,
265
+ // 4) Status bar (sticky, single row above input)
266
+ React.createElement(StatusBar, {
267
+ provider: splashProps && splashProps.provider,
268
+ model: splashProps && splashProps.model,
269
+ streaming: state.streaming,
270
+ ctxUsed: splashProps && splashProps.ctxUsed,
271
+ ctxTotal: splashProps && splashProps.ctxTotal,
272
+ }),
273
+ // 5) Editor — sticky bottom, content-sized
274
+ React.createElement(Editor, {
275
+ history: state.history,
276
+ onSubmit: handleSubmit,
277
+ onEscape: onEscapeKey,
278
+ onBufferChange: handleBufferChange,
279
+ slashSuggestions: showSlashPopup ? filtered : null,
280
+ slashSelectedIndex: selectedSuggestion,
281
+ onSlashMove: handleSlashMove,
282
+ onSlashDismiss: handleSlashDismiss,
283
+ })
94
284
  );
95
285
  }
286
+
287
+ // ScrollbackItem renders each <Static/> child. Splash renders via the
288
+ // real <Splash/> component (preserves gradient wordmark colorization);
289
+ // everything else is plain Text.
290
+ export function ScrollbackItem({ item }) {
291
+ if (item.kind === 'splash') {
292
+ return React.createElement(Splash, item.splashProps);
293
+ }
294
+ if (item.kind === 'user') {
295
+ return React.createElement(
296
+ Box,
297
+ { flexDirection: 'column' },
298
+ React.createElement(Text, null, theme.accent('› ') + item.text)
299
+ );
300
+ }
301
+ if (item.kind === 'error') {
302
+ return React.createElement(Text, { color: 'red' }, item.text);
303
+ }
304
+ // 'assistant' (default)
305
+ return React.createElement(Text, { color: theme.fg }, item.text);
306
+ }
307
+
308
+ // StatusBar — single row, provider · model · ctx · streaming indicator.
309
+ // Kept intentionally minimal in v5.3; token gauges land separately once
310
+ // usage metrics flow into state.
311
+ export function StatusBar({ provider, model, streaming, ctxUsed, ctxTotal }) {
312
+ const ctx = (ctxUsed != null && ctxTotal != null) ? `${ctxUsed}/${ctxTotal}` : '--';
313
+ const indicator = streaming ? theme.accent('● streaming') : theme.dim('○ idle');
314
+ const prov = provider || '?';
315
+ const mdl = model || '?';
316
+ return React.createElement(
317
+ Box,
318
+ { flexShrink: 0, paddingX: 1 },
319
+ React.createElement(Text, null, `${indicator} ${prov} · ${mdl} ctx ${ctx}`)
320
+ );
321
+ }
322
+
323
+ // Exported for tests that want to verify the splash snapshot without a TTY.
324
+ export function _renderSplashToString(splashProps) {
325
+ return renderSplashToString(splashProps);
326
+ }
@@ -0,0 +1,37 @@
1
+ // tui/slash_commands.mjs — single source of truth for the REPL slash-command
2
+ // catalog. Imported by both cli.mjs (legacy ghost-autocomplete + /help dump)
3
+ // and tui/slash_popup.mjs (Ink popup chooser). Keeping the list here avoids
4
+ // a tui/ → cli.mjs circular import.
5
+ //
6
+ // Schema:
7
+ // { cmd: string, help: string }
8
+ //
9
+ // Order matters — it is the order shown in the popup and in /help. New
10
+ // commands should be appended unless there is a UX reason to slot them in.
11
+
12
+ export const SLASH_COMMANDS = [
13
+ { cmd: '/help', help: 'list available slash commands' },
14
+ { cmd: '/status', help: 'print current provider, model, masked key' },
15
+ { cmd: '/version', help: 'print version + node + platform' },
16
+ { cmd: '/new', help: 'clear conversation and start over' },
17
+ { cmd: '/reset', help: 'alias for /new' },
18
+ { cmd: '/usage', help: 'show message count + chars sent so far' },
19
+ { cmd: '/skills', help: 'list and activate skills (alias /skill)' },
20
+ { cmd: '/skill', help: 'switch active skills: /skill review,style (no arg → clear)' },
21
+ { cmd: '/tools', help: 'list available tool registry verbs' },
22
+ { cmd: '/provider', help: 'switch provider: /provider openai (no arg → print current)' },
23
+ { cmd: '/model', help: 'switch model: /model gpt-4.1 or anthropic/claude-opus-4-7' },
24
+ { cmd: '/trainer', help: 'configure trainer provider/model for learning loop' },
25
+ { cmd: '/personality', help: 'switch agent personality preset' },
26
+ { cmd: '/loop', help: 'repeat one prompt: /loop "fix lint" [--max N] [--until "<regex>"]' },
27
+ { cmd: '/goal', help: 'register/switch goal: /goal add NAME | /goal list' },
28
+ { cmd: '/memory', help: 'show layered memory: /memory [core|recent|episodic [topic]]' },
29
+ { cmd: '/recall', help: 'FTS5 recall across sessions and memory' },
30
+ { cmd: '/dream', help: 'consolidate recent memory into per-topic episodic files' },
31
+ { cmd: '/agent', help: 'spawn or switch agent: /agent NAME' },
32
+ { cmd: '/team', help: 'list, create, or join a team' },
33
+ { cmd: '/task', help: 'create, list, or manage tasks' },
34
+ { cmd: '/handoff', help: 'hand current task off to another agent' },
35
+ { cmd: '/exit', help: 'leave the chat' },
36
+ { cmd: '/quit', help: 'alias for /exit' },
37
+ ];
@@ -0,0 +1,157 @@
1
+ // tui/slash_popup.mjs — Ink popup chooser for slash commands.
2
+ //
3
+ // Renders a bordered, vertically-stacked list of slash-command suggestions
4
+ // just above the input row. Selection state lives in the parent (ReplApp),
5
+ // because the Editor owns the keyboard. This component is purely
6
+ // presentational so it can be snapshotted and rendered without an Ink
7
+ // runtime.
8
+ //
9
+ // Ink has no absolute positioning, so "popup" here means "flex sibling
10
+ // rendered between the live region and the StatusBar". When the popup is
11
+ // visible the live region naturally shrinks by its row count, which is the
12
+ // same trick Claude CLI uses.
13
+ //
14
+ // Two exports:
15
+ // - filterSlashCommands(buffer, commands) → pure, unit-testable
16
+ // - <SlashPopup buffer commands selectedIndex maxRows /> → presentational
17
+
18
+ import React from 'react';
19
+ import { Box, Text } from 'ink';
20
+ import { theme } from './theme.mjs';
21
+ import { SLASH_COMMANDS as DEFAULT_SLASH_COMMANDS } from './slash_commands.mjs';
22
+
23
+ export { DEFAULT_SLASH_COMMANDS };
24
+
25
+ // ─── Pure filter ─────────────────────────────────────────────────────────
26
+ //
27
+ // Returns the subset of `commands` to show in the popup for the current
28
+ // `buffer`. Empty array means "dismiss popup". See the inline cases for
29
+ // the precedence — prefix first, then substring fallback, then space-arg
30
+ // degeneration into a single-row inline hint.
31
+ export function filterSlashCommands(buffer, commands) {
32
+ if (!buffer || !buffer.startsWith('/')) return [];
33
+ const space = buffer.indexOf(' ');
34
+ if (space > 0) {
35
+ // User has typed args. Popup degenerates into a one-row inline hint
36
+ // showing only the matched command's help text.
37
+ const head = buffer.slice(0, space);
38
+ return commands.filter((c) => c.cmd === head);
39
+ }
40
+ if (buffer === '/') return commands.slice();
41
+ const q = buffer.toLowerCase();
42
+ const prefix = commands.filter((c) => c.cmd.toLowerCase().startsWith(q));
43
+ if (prefix.length > 0) return prefix;
44
+ // Substring fallback: '/em' → /memory. Strip the leading '/' on both
45
+ // sides so the substring search isn't dominated by the trigger char.
46
+ const sub = commands.filter((c) =>
47
+ c.cmd.slice(1).toLowerCase().includes(q.slice(1))
48
+ );
49
+ return sub;
50
+ }
51
+
52
+ // Truncate help text to a column budget, preserving the leading space.
53
+ // Never wraps — the popup is a single row per command.
54
+ function _truncate(s, max) {
55
+ if (max <= 0) return '';
56
+ if (s.length <= max) return s;
57
+ if (max <= 1) return s.slice(0, max);
58
+ return s.slice(0, max - 1) + '…';
59
+ }
60
+
61
+ // Window a long match list around the selected row so the popup doesn't
62
+ // grow taller than `maxRows`. Returns `{ visible, windowStart }`.
63
+ export function _computeWindow(matches, selectedIndex, maxRows) {
64
+ if (matches.length <= maxRows) return { visible: matches, windowStart: 0 };
65
+ const half = Math.floor(maxRows / 2);
66
+ let start = Math.max(0, selectedIndex - half);
67
+ const end = Math.min(matches.length, start + maxRows);
68
+ // Re-anchor if we overflowed past the end.
69
+ start = Math.max(0, end - maxRows);
70
+ return { visible: matches.slice(start, start + maxRows), windowStart: start };
71
+ }
72
+
73
+ // ─── Component ───────────────────────────────────────────────────────────
74
+ //
75
+ // Props:
76
+ // buffer — string, current editor buffer (used for the inline hint)
77
+ // commands — Array<{cmd,help}>, already filtered by caller
78
+ // selectedIndex — number, index into `commands`
79
+ // maxRows — number, max visible rows (defaults to 8)
80
+ // columns — number, terminal width (defaults to process.stdout.columns)
81
+ //
82
+ // When `columns < 50` the popup collapses to a single column (cmd only).
83
+ export function SlashPopup({
84
+ buffer = '',
85
+ commands = [],
86
+ selectedIndex = 0,
87
+ maxRows = 8,
88
+ columns,
89
+ }) {
90
+ if (!commands || commands.length === 0) return null;
91
+ const cols = columns
92
+ || (process.stdout && process.stdout.columns)
93
+ || 80;
94
+ const compact = cols < 50;
95
+ const safeSelected = Math.max(0, Math.min(commands.length - 1, selectedIndex));
96
+ const { visible, windowStart } = _computeWindow(commands, safeSelected, maxRows);
97
+
98
+ // Inline-hint mode: buffer already has args + a single match. Render
99
+ // the help text on one dimmed line. No border, no chooser.
100
+ const isInlineHint = buffer.includes(' ') && commands.length === 1;
101
+ if (isInlineHint) {
102
+ const c = commands[0];
103
+ return React.createElement(
104
+ Box,
105
+ { paddingX: 1 },
106
+ React.createElement(Text, { dimColor: true },
107
+ `${c.cmd} — ${_truncate(c.help, Math.max(10, cols - c.cmd.length - 5))}`
108
+ )
109
+ );
110
+ }
111
+
112
+ // Reserve 2 chars border + 2 padding + cmd column (14) + 1 gap.
113
+ const cmdCol = 14;
114
+ const helpBudget = Math.max(0, cols - cmdCol - 6);
115
+
116
+ return React.createElement(
117
+ Box,
118
+ {
119
+ flexDirection: 'column',
120
+ borderStyle: 'round',
121
+ borderColor: 'gray',
122
+ paddingX: 1,
123
+ },
124
+ visible.map((c, i) => {
125
+ const absIdx = i + windowStart;
126
+ const isSel = absIdx === safeSelected;
127
+ const cmdText = c.cmd.padEnd(cmdCol);
128
+ return React.createElement(
129
+ Box,
130
+ { key: c.cmd },
131
+ React.createElement(
132
+ Text,
133
+ {
134
+ inverse: isSel,
135
+ color: isSel ? theme.amber : undefined,
136
+ bold: isSel,
137
+ },
138
+ cmdText
139
+ ),
140
+ !compact
141
+ ? React.createElement(
142
+ Text,
143
+ { dimColor: true },
144
+ ' ' + _truncate(c.help, helpBudget)
145
+ )
146
+ : null
147
+ );
148
+ }),
149
+ commands.length > maxRows
150
+ ? React.createElement(
151
+ Text,
152
+ { dimColor: true },
153
+ ` ${safeSelected + 1}/${commands.length}`
154
+ )
155
+ : null
156
+ );
157
+ }
package/tui/splash.mjs CHANGED
@@ -3,10 +3,10 @@
3
3
  //
4
4
  // Layout (terminal-width responsive across four tiers):
5
5
  //
6
- // WIDE (cols >= 140) — full wordmark + panel + sloth + 2-col right side
7
- // MEDIUM ( 90 <= cols < 140) — compact headline, panel + sloth + wrapped right column
8
- // NARROW ( 60 <= cols < 90) — single column, no sloth/panel, truncated verb lists
9
- // MINIMAL (cols < 60) — headline + provider + cwd + /help line only
6
+ // WIDE (cols >= 140) — full wordmark + panel + sloth side-by-side
7
+ // MEDIUM ( 90 <= cols < 140) — compact headline, sloth side-by-side, wrapped right column
8
+ // NARROW ( 45 <= cols < 90) — sloth STACKED above full-width panel, wrapped verbs
9
+ // MINIMAL (cols < 45) — headline + provider + cwd + /help line only
10
10
  import React from 'react';
11
11
  import { Box, Text } from 'ink';
12
12
  import stringWidth from 'string-width';
@@ -19,11 +19,12 @@ const TITLE = ' trainer-split · FTS5 recall · 6-backend sandbox ';
19
19
 
20
20
  // Tier breakpoints. Wordmark is 120 cols wide + LMARGIN(2)*2 = 124 minimum;
21
21
  // the user constraint pins WIDE at >=140 to give comfortable slack. Below
22
- // 90 the sloth (48 cols) leaves <40 cols for the right column, so we drop
23
- // it and go single-column. Below 60 we emit only a minimal headline.
22
+ // 90 the sloth (48 cols) cannot share a row with a usable right column,
23
+ // so NARROW stacks the sloth ABOVE a full-width wrapped panel. Below 45
24
+ // even a stacked sloth overflows, so MINIMAL absorbs that range.
24
25
  const WORDMARK_BREAKPOINT = 140; // drop wordmark below this
25
- const PANEL_BREAKPOINT = 90; // drop sloth+panel below this
26
- const MINIMAL_BREAKPOINT = 60; // drop everything but headline below this
26
+ const MEDIUM_BREAKPOINT = 90; // side-by-side sloth+panel above this; stacked below
27
+ const NARROW_BREAKPOINT = 45; // headline-only fallback below this
27
28
 
28
29
  // Subcommand catalog — grouped for the splash so a new user sees the
29
30
  // surface area at a glance. Mirrors SUBCOMMANDS in cli.mjs.
@@ -99,16 +100,6 @@ function wrapVerbs(label, verbs, maxWidth) {
99
100
  return rows;
100
101
  }
101
102
 
102
- // Crush-style truncation for NARROW tier — take first N verbs, append '…' if more.
103
- function truncateRow(label, verbs, maxWidth, take = 3) {
104
- const head = label.padEnd(12) + ' ';
105
- const tail = verbs.slice(0, take).join(' · ');
106
- let line = head + tail;
107
- if (verbs.length > take) line += ' …';
108
- if (stringWidth(line) <= maxWidth) return line;
109
- return fit(line, maxWidth).trimEnd();
110
- }
111
-
112
103
  // Wide tier — original v5.0.9 layout, kept verbatim.
113
104
  function renderWide(props, cols) {
114
105
  const PANEL_W = cols - LMARGIN.length * 2;
@@ -274,45 +265,76 @@ function renderMedium(props, cols) {
274
265
  return lines;
275
266
  }
276
267
 
277
- // Narrow tier — single column, no sloth, no panel, truncated verb lists.
268
+ // Narrow tier — sloth STACKED above a full-width panel; verb lists wrap
269
+ // onto multiple rows instead of being truncated. Used for 45 <= cols < 90.
278
270
  function renderNarrow(props, cols) {
279
- const W = cols - LMARGIN.length * 2;
271
+ const PANEL_W = cols - LMARGIN.length * 2;
272
+ const INNER = PANEL_W - 4;
273
+ const SLOTH_W = banner.width;
280
274
  const lines = [];
281
275
 
282
- // 1) headline
276
+ // 1) sloth banner CENTERED above panel (stacked layout).
277
+ // Only emit if the sloth itself fits within the terminal; otherwise
278
+ // skip it (MINIMAL absorbs the truly tiny case below NARROW_BREAKPOINT).
279
+ if (cols >= SLOTH_W + LMARGIN.length * 2) {
280
+ const leftPad = ' '.repeat(Math.max(0, Math.floor((cols - SLOTH_W) / 2)));
281
+ for (const r of banner.rows) lines.push(leftPad + r);
282
+ lines.push('');
283
+ }
284
+
285
+ // 2) compact headline (no wordmark — too wide).
283
286
  lines.push(`${LMARGIN}lazyclaw ${props.version || ''}`.trimEnd());
284
287
  lines.push('');
285
288
 
286
- const { tools = [], skills = [], provider, model, trainer = {}, sessionId, cwd } = props;
289
+ // 3) panel top version label only, dashes fill remainder.
290
+ const versionLabel = ` lazyclaw ${props.version || ''} `;
291
+ const dashLeft = '─'.repeat(2);
292
+ const dashRight = '─'.repeat(Math.max(2, PANEL_W - 2 - dashLeft.length - stringWidth(versionLabel)));
293
+ lines.push(`${LMARGIN}╭${dashLeft}${versionLabel}${dashRight}╮`);
287
294
 
288
- // 2) subcommands
289
- lines.push(`${LMARGIN}Subcommands`);
295
+ // 4) panel body — full-width single column, wrapped via wrapVerbs.
296
+ const { tools = [], skills = [] } = props;
297
+ const body = [];
298
+ body.push('Subcommands');
290
299
  for (const [label, verbs] of SUBCOMMAND_GROUPS) {
291
- lines.push(LMARGIN + truncateRow(label, verbs, W));
300
+ for (const r of wrapVerbs(label, verbs, INNER)) body.push(r);
292
301
  }
293
- lines.push('');
294
-
295
- // 3) tools
296
- lines.push(`${LMARGIN}Available Tools`);
302
+ body.push('');
303
+ body.push('Available Tools');
297
304
  for (const t of tools.slice(0, 14)) {
298
305
  const label = t.sensitive ? `${t.category}*` : t.category;
299
- lines.push(LMARGIN + truncateRow(label, t.verbs, W));
306
+ for (const r of wrapVerbs(label, t.verbs.slice(0, 6), INNER)) body.push(r);
300
307
  }
301
- if (tools.length > 14) lines.push(`${LMARGIN}(and ${tools.length - 14} more...)`);
302
- lines.push('');
303
-
304
- // 4) skills
305
- lines.push(`${LMARGIN}Available Skills`);
306
- if (skills.length === 0) lines.push(`${LMARGIN}(none installed)`);
308
+ if (tools.length > 14) body.push(`(and ${tools.length - 14} more...)`);
309
+ body.push('');
310
+ body.push('Available Skills');
311
+ if (skills.length === 0) body.push('(none installed)');
307
312
  else {
308
313
  for (const s of skills.slice(0, 8)) {
309
- lines.push(LMARGIN + truncateRow(s.group, s.names, W));
314
+ for (const r of wrapVerbs(s.group, s.names.slice(0, 6), INNER)) body.push(r);
310
315
  }
311
- if (skills.length > 8) lines.push(`${LMARGIN}(and ${skills.length - 8} more skill groups...)`);
316
+ if (skills.length > 8) body.push(`(and ${skills.length - 8} more skill groups...)`);
312
317
  }
318
+ body.push('');
319
+ const subcmdCount = SUBCOMMAND_GROUPS.reduce((n, [, v]) => n + v.length, 0);
320
+ const summary = `${subcmdCount} subcmds · ${tools.length} tools · ${skills.length} skills · /help`;
321
+ if (stringWidth(summary) > INNER) {
322
+ body.push(`${subcmdCount} subcmds · ${tools.length} tools · ${skills.length} skills`);
323
+ body.push('/help for commands');
324
+ } else {
325
+ body.push(summary);
326
+ }
327
+
328
+ // 5) emit panel rows (single column, full INNER width, pad with spaces).
329
+ for (const row of body) {
330
+ const padded = row + ' '.repeat(Math.max(0, INNER - stringWidth(row)));
331
+ lines.push(`${LMARGIN}│ ${padded} │`);
332
+ }
333
+ lines.push(`${LMARGIN}╰${'─'.repeat(PANEL_W - 2)}╯`);
313
334
  lines.push('');
314
335
 
315
- // 5) provider / session info
336
+ // 6) provider / session info (single line each, fit-truncated for safety).
337
+ const { provider, model, trainer = {}, sessionId, cwd } = props;
316
338
  const tProv = trainer.provider || provider;
317
339
  const tModel = trainer.model || model;
318
340
  lines.push(fit(`${LMARGIN}${provider} · ${model} · trainer ${tProv} · ${tModel}`, cols).trimEnd());
@@ -322,7 +344,7 @@ function renderNarrow(props, cols) {
322
344
  lines.push(fit(`${LMARGIN}Welcome to lazyclaw. /help for commands.`, cols).trimEnd());
323
345
  lines.push('');
324
346
 
325
- // 6) compact status — single line, no separator dashes
347
+ // 7) compact status — single line, no separator dashes.
326
348
  const ctx = props.ctxUsed != null && props.ctxTotal != null
327
349
  ? `${props.ctxUsed}/${props.ctxTotal}`
328
350
  : '--';
@@ -331,7 +353,7 @@ function renderNarrow(props, cols) {
331
353
  return lines;
332
354
  }
333
355
 
334
- // Minimal tier — bare-bones fallback for cols < 60.
356
+ // Minimal tier — bare-bones fallback for cols < 45.
335
357
  function renderMinimal(props) {
336
358
  const { version, provider, model, sessionId, cwd } = props;
337
359
  const lines = [];
@@ -346,8 +368,8 @@ function renderMinimal(props) {
346
368
  export function renderSplashToString(props, opts = {}) {
347
369
  const cols = opts.columns || process.stdout.columns || 100;
348
370
  let lines;
349
- if (cols < MINIMAL_BREAKPOINT) lines = renderMinimal(props);
350
- else if (cols < PANEL_BREAKPOINT) lines = renderNarrow(props, cols);
371
+ if (cols < NARROW_BREAKPOINT) lines = renderMinimal(props);
372
+ else if (cols < MEDIUM_BREAKPOINT) lines = renderNarrow(props, cols);
351
373
  else if (cols < WORDMARK_BREAKPOINT) lines = renderMedium(props, cols);
352
374
  else lines = renderWide(props, cols);
353
375
  return lines.join('\n');