lazyclaw 5.3.0 → 5.3.1

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
@@ -2634,9 +2634,53 @@ async function cmdChat(flags = {}) {
2634
2634
  ctx: _inkCtx,
2635
2635
  writeFn: (chunk) => process.stdout.write(chunk),
2636
2636
  });
2637
+ // Minimal slash dispatcher for the Ink branch. Covers the read-only
2638
+ // info commands so the user sees something useful instead of having
2639
+ // their slash command sent to the model as a prompt. /exit + /quit
2640
+ // are intercepted inside ReplApp before this fires. Returning a
2641
+ // string causes ReplApp to render the result into scrollback.
2642
+ //
2643
+ // The full set of mutating commands (/model, /provider, /skill,
2644
+ // /personality, ...) still lives in the legacy readline path and
2645
+ // remains accessible via LAZYCLAW_NO_INK=1. Wiring those through
2646
+ // the Ink branch is a follow-up; today we at least stop sending
2647
+ // them as prompts.
2648
+ const _inkSlashHandler = async (line) => {
2649
+ const cmd = line.split(/\s+/)[0];
2650
+ switch (cmd) {
2651
+ case '/help': {
2652
+ const lines = ['slash commands:'];
2653
+ for (const c of SLASH_COMMANDS) lines.push(` ${c.cmd.padEnd(14)} — ${c.help}`);
2654
+ return lines.join('\n') + '\n';
2655
+ }
2656
+ case '/status': {
2657
+ const out = {
2658
+ provider: activeProvName,
2659
+ model: activeModel,
2660
+ keyMasked: _registryMod.maskApiKey(cfg['api-key']),
2661
+ messageCount: _inkMessages.length,
2662
+ };
2663
+ return JSON.stringify(out) + '\n';
2664
+ }
2665
+ case '/version': {
2666
+ return `lazyclaw ${readVersionFromRepo()} (node ${process.version}, ${process.platform})\n`;
2667
+ }
2668
+ case '/exit':
2669
+ case '/quit':
2670
+ // ReplApp intercepts these before onSlashCommand fires, but
2671
+ // return EXIT defensively in case that contract ever changes.
2672
+ return 'EXIT';
2673
+ default:
2674
+ // Mutating commands still require the legacy readline path.
2675
+ // Telling the user explicitly beats silently sending the
2676
+ // slash text to the model as a prompt.
2677
+ return `${cmd} is not yet wired into the ink REPL — set LAZYCLAW_NO_INK=1 and restart to use it.\n`;
2678
+ }
2679
+ };
2637
2680
  const ink = render(/* @__PURE__ */ React.createElement(ReplApp, {
2638
2681
  splashProps,
2639
2682
  runTurn: _inkRunTurn,
2683
+ onSlashCommand: _inkSlashHandler,
2640
2684
  }));
2641
2685
  await ink.waitUntilExit();
2642
2686
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lazyclaw",
3
- "version": "5.3.0",
3
+ "version": "5.3.1",
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
@@ -10,6 +10,13 @@
10
10
  // Enter fills, second Enter runs). Esc clears the buffer + dismisses.
11
11
  // All popup-aware branches are guarded by `slashOpen` so legacy callers
12
12
  // see the pre-v5.4 behavior verbatim.
13
+ //
14
+ // v5.5: <Editor/> now renders inside a round-bordered Box — the
15
+ // Claude-CLI-style input frame. The border uses `theme.border` (a
16
+ // muted gray) so the accent `›` and sloth gutter stay the dominant
17
+ // amber notes. The box auto-fills the available terminal width via
18
+ // Ink's flex defaults and grows vertically as the buffer wraps onto
19
+ // new lines (Shift+Enter).
13
20
  import React, { useState, useEffect } from 'react';
14
21
  import { Box, Text, useInput } from 'ink';
15
22
  import { theme } from './theme.mjs';
@@ -125,17 +132,29 @@ export function Editor({
125
132
  }
126
133
  // Tab / Enter — fill the buffer with the highlighted command.
127
134
  // First Enter fills, second Enter runs (matches Anthropic's UX).
135
+ // Exception: if the buffer already exactly matches the picked
136
+ // command (with or without a trailing space), there is nothing left
137
+ // to autocomplete. For Enter, fall through to the normal submit
138
+ // path so /exit, /quit, /help etc. fire on a single Enter. For Tab
139
+ // on an exact match, no-op.
128
140
  if (key.tab || key.return) {
129
141
  const safeIdx = Math.max(0, Math.min(slashSuggestions.length - 1, slashSelectedIndex || 0));
130
142
  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 {}
143
+ const bufTrim = state.buffer.replace(/\s+$/, '');
144
+ const alreadyExact = !!picked && (state.buffer === picked.cmd || bufTrim === picked.cmd);
145
+ if (alreadyExact) {
146
+ if (key.tab) return; // no completion to make
147
+ // key.return on exact match → fall through to applyKey/submit.
148
+ } else {
149
+ if (picked) {
150
+ const next = fillSlashCommand(state, picked.cmd);
151
+ setState(next);
152
+ if (onBufferChange) {
153
+ try { onBufferChange(next.buffer); } catch {}
154
+ }
136
155
  }
156
+ return;
137
157
  }
138
- return;
139
158
  }
140
159
  // Anything else (printable, backspace) falls through to applyKey.
141
160
  }
@@ -156,7 +175,17 @@ export function Editor({
156
175
  const lines = state.buffer.split('\n');
157
176
  return React.createElement(
158
177
  Box,
159
- { flexDirection: 'column' },
160
- lines.map((ln, i) => React.createElement(Text, { key: i }, i === 0 ? theme.accent('') + ln : ' ' + ln))
178
+ {
179
+ borderStyle: 'round',
180
+ borderColor: theme.border,
181
+ paddingX: 1,
182
+ flexDirection: 'column',
183
+ flexShrink: 0,
184
+ },
185
+ lines.map((ln, i) => React.createElement(
186
+ Text,
187
+ { key: i },
188
+ i === 0 ? theme.accent('› ') + ln : ' ' + ln,
189
+ )),
161
190
  );
162
191
  }
package/tui/repl.mjs CHANGED
@@ -130,7 +130,7 @@ export function consumeNextTurnFirstMessage(state) {
130
130
  // - runTurnFactory(writeFn) → runTurn(text, signal) (sticky layout)
131
131
  // - runTurn(text, signal) (legacy, stdout)
132
132
  // Legacy mode is preserved verbatim for the existing cli.mjs callsite.
133
- export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands }) {
133
+ export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, onSlashCommand }) {
134
134
  // Splash is rendered ONCE as scrollback[0] via <Static>. Build it lazily
135
135
  // so SSR-style imports without a TTY don't crash on process.stdout.
136
136
  const splashItemRef = useRef(null);
@@ -163,18 +163,46 @@ export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands })
163
163
  }
164
164
 
165
165
  const handleSubmit = useCallback(async (text) => {
166
- if (text === '/exit' || text === '/quit') { exit(); return; }
166
+ // Normalize trailing whitespace so '/exit ' (left over from a popup
167
+ // fill) is treated identically to '/exit'. Empty input → no-op.
168
+ const trimmed = (text || '').replace(/\s+$/, '');
169
+ if (!trimmed) return;
170
+ // /exit + /quit unmount the Ink app. Done inline so the popup path
171
+ // and the no-popup path both terminate cleanly.
172
+ if (trimmed === '/exit' || trimmed === '/quit') { exit(); return; }
173
+ // Other slash commands: hand off to the host's slash dispatcher
174
+ // (cli.mjs handleSlash) when one is provided. The host returns a
175
+ // string (or void) which we append to scrollback as an assistant
176
+ // turn so the user sees the result inline. If no dispatcher is
177
+ // wired, fall through to runTurn (legacy behavior).
178
+ if (trimmed.startsWith('/') && typeof onSlashCommand === 'function') {
179
+ const controller = new AbortController();
180
+ setState((s) => onUserInput(s, { text: trimmed, controller }));
181
+ try {
182
+ const result = await onSlashCommand(trimmed);
183
+ if (result === 'EXIT') { exit(); return; }
184
+ if (typeof result === 'string' && result.length > 0) {
185
+ setState((s) => onStreamChunk(s, { chunk: result }));
186
+ }
187
+ setState((s) => onTurnComplete(s, { reason: 'done' }));
188
+ } catch (err) {
189
+ setState((s) => onTurnComplete(s, {
190
+ reason: err && err.name === 'AbortError' ? 'aborted' : 'error',
191
+ }));
192
+ }
193
+ return;
194
+ }
167
195
  const controller = new AbortController();
168
- setState((s) => onUserInput(s, { text, controller }));
196
+ setState((s) => onUserInput(s, { text: trimmed, controller }));
169
197
  try {
170
- await runTurnRef.current(text, controller.signal);
198
+ await runTurnRef.current(trimmed, controller.signal);
171
199
  setState((s) => onTurnComplete(s, { reason: 'done' }));
172
200
  } catch (err) {
173
201
  setState((s) => onTurnComplete(s, {
174
202
  reason: err && err.name === 'AbortError' ? 'aborted' : 'error',
175
203
  }));
176
204
  }
177
- }, [exit]);
205
+ }, [exit, onSlashCommand]);
178
206
 
179
207
  // Auto-submit queued mid-stream-interrupt message (spec §5.8). Read
180
208
  // state.nextTurnFirstMessage so the effect re-fires when promoted.
@@ -234,7 +262,17 @@ export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands })
234
262
  setSelectedSuggestion(0);
235
263
  }, []);
236
264
 
237
- const showSlashPopup = bufferPeek.startsWith('/') && filtered.length > 0;
265
+ // Hide the popup when the buffer already exactly matches the only
266
+ // remaining suggestion (with or without a trailing space). Otherwise
267
+ // the popup intercepts Enter and the fully-typed command (e.g.
268
+ // '/exit') never reaches handleSubmit. Belt-and-suspenders with the
269
+ // editor-side fall-through in tui/editor.mjs.
270
+ const _bufTrimmed = bufferPeek.replace(/\s+$/, '');
271
+ const _exactOnly =
272
+ filtered.length === 1 &&
273
+ (filtered[0].cmd === bufferPeek || filtered[0].cmd === _bufTrimmed);
274
+ const showSlashPopup =
275
+ bufferPeek.startsWith('/') && filtered.length > 0 && !_exactOnly;
238
276
 
239
277
  return React.createElement(
240
278
  Box,
package/tui/splash.mjs CHANGED
@@ -382,14 +382,55 @@ export function Splash(props) {
382
382
  const palette = wordmark.palette;
383
383
  const gradient = wordmark.gradient;
384
384
  const showWordmark = cols >= WORDMARK_BREAKPOINT;
385
+ // Sloth banner is emitted at the TOP of NARROW output (45..89) only when
386
+ // it fits inside the terminal width — see renderNarrow() guard.
387
+ const showSlothNarrow =
388
+ cols >= NARROW_BREAKPOINT && cols < MEDIUM_BREAKPOINT &&
389
+ cols >= banner.width + LMARGIN.length * 2;
390
+
391
+ // Per-tier sloth row range [start, end). MEDIUM interleaves the sloth
392
+ // inside panel rows, so it gets colored via the border regex below — no
393
+ // dedicated band is needed for that tier.
394
+ let slothStart = -1, slothEnd = -1;
395
+ if (showWordmark) {
396
+ slothStart = wordmark.height + 1 + 1; // wordmark + blank + panel-top
397
+ slothEnd = slothStart + banner.height;
398
+ } else if (showSlothNarrow) {
399
+ slothStart = 0; // sloth is the first thing emitted
400
+ slothEnd = banner.height;
401
+ }
402
+
403
+ // Section headers / summary / compact headline on NARROW that should be
404
+ // amber to match the WIDE wordmark accent. Matched by exact content.
405
+ const ACCENT_HEADERS = new Set(['Subcommands', 'Available Tools', 'Available Skills']);
406
+ // Panel border / status separator glyphs (leading box-drawing after
407
+ // optional whitespace). Catches ╭ ╰ │ as well as ─ separators.
408
+ const BORDER_RE = /^\s*[╭╰│├┤┬┴┼─╮╯]/;
409
+ // NARROW compact headline (e.g. " lazyclaw 5.3.0").
410
+ const HEADLINE_RE = /^\s*lazyclaw\s+\S/;
411
+ // NARROW summary line ("N subcmds · M tools · K skills · /help" or its
412
+ // wrapped variant). Also catches "/help for commands".
413
+ const SUMMARY_RE = /(subcmds\s+·|tools\s+·\s+\d+\s+skills|\/help\s+for\s+commands)/;
385
414
 
386
415
  return React.createElement(
387
416
  Box,
388
417
  { flexDirection: 'column' },
389
418
  lines.map((line, i) => {
390
419
  let color;
391
- if (showWordmark && i < wordmark.height) color = palette[gradient[i] ?? 1];
392
- else if (showWordmark && i < wordmark.height + 1 + 1 + banner.height) color = theme.fg;
420
+ const trimmed = line.trim();
421
+ if (showWordmark && i < wordmark.height) {
422
+ color = palette[gradient[i] ?? 1]; // wordmark gradient
423
+ } else if (i >= slothStart && i < slothEnd) {
424
+ color = theme.fg; // sloth band — any tier that stacks the sloth
425
+ } else if (BORDER_RE.test(line)) {
426
+ color = theme.fg; // panel borders + status separators
427
+ } else if (ACCENT_HEADERS.has(trimmed)) {
428
+ color = theme.fg; // section headers
429
+ } else if (!showWordmark && HEADLINE_RE.test(line) && /\d/.test(line)) {
430
+ color = theme.fg; // narrow/medium compact headline ("lazyclaw 5.x.y")
431
+ } else if (!showWordmark && SUMMARY_RE.test(line)) {
432
+ color = theme.fg; // narrow/medium summary line
433
+ }
393
434
  return React.createElement(Text, { key: i, color }, line);
394
435
  })
395
436
  );
package/tui/theme.mjs CHANGED
@@ -1,9 +1,14 @@
1
1
  // tui/theme.mjs — single source of truth for lazyclaw v5 color tokens.
2
2
  // The amber hex is also stamped into tui/banner.generated.mjs so the
3
3
  // sloth gutter and the prompt accent stay visually paired.
4
+ //
5
+ // v5.5: added `border` token for the chat-input frame (Claude-CLI-style
6
+ // rounded box around the editor). Kept subtly grayer than `amber` so the
7
+ // frame doesn't compete with the accent `›` or the sloth gutter.
4
8
  import chalk from 'chalk';
5
9
 
6
10
  const AMBER_HEX = '#FFB347';
11
+ const BORDER_HEX = '#5A5A5A';
7
12
 
8
13
  function amber(text) {
9
14
  return chalk.hex(AMBER_HEX)(text);
@@ -28,6 +33,7 @@ function plain(text) {
28
33
  export const theme = {
29
34
  amber: AMBER_HEX,
30
35
  fg: AMBER_HEX,
36
+ border: BORDER_HEX,
31
37
  colorize: amber,
32
38
  dim,
33
39
  accent,