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 +44 -0
- package/package.json +1 -1
- package/tui/editor.mjs +37 -8
- package/tui/repl.mjs +44 -6
- package/tui/splash.mjs +43 -2
- package/tui/theme.mjs +6 -0
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.
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
if (
|
|
135
|
-
|
|
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
|
-
{
|
|
160
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
392
|
-
|
|
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,
|