lazyclaw 5.3.0 → 5.3.2
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 +92 -13
- package/package.json +1 -1
- package/providers/orchestrator.mjs +25 -10
- package/tui/editor.mjs +87 -8
- package/tui/repl.mjs +44 -6
- package/tui/splash.mjs +43 -2
- package/tui/theme.mjs +6 -0
package/cli.mjs
CHANGED
|
@@ -853,6 +853,7 @@ async function cmdDoctor() {
|
|
|
853
853
|
await ensureRegistry();
|
|
854
854
|
const cfg = readConfig();
|
|
855
855
|
const issues = [];
|
|
856
|
+
const warnings = [];
|
|
856
857
|
if (!cfg.provider) issues.push('config.provider is missing — run `lazyclaw onboard`');
|
|
857
858
|
// Only flag a missing api-key when the picked provider actually
|
|
858
859
|
// requires one. claude-cli / ollama / mock all run keylessly, so the
|
|
@@ -864,6 +865,27 @@ async function cmdDoctor() {
|
|
|
864
865
|
if (cfg.provider && !PROVIDERS_HAS(_registryMod.PROVIDERS, cfg.provider)) {
|
|
865
866
|
issues.push(`unknown provider "${cfg.provider}" — registered: ${Object.keys(_registryMod.PROVIDERS).join(', ')}`);
|
|
866
867
|
}
|
|
868
|
+
// v5.3.2 soft-migration — pre-5.3.2 wizards could write
|
|
869
|
+
// `provider: 'orchestrator'` even when the user never configured the
|
|
870
|
+
// orchestrator section (planner / workers). On those installs the
|
|
871
|
+
// first chat turn dies with an opaque "orchestrator not configured"
|
|
872
|
+
// error. Surface a warning + the fix hint, but never auto-rewrite
|
|
873
|
+
// cfg.json — the user might have legitimately picked orchestrator
|
|
874
|
+
// and just hasn't finished setup yet.
|
|
875
|
+
if (cfg.provider === 'orchestrator') {
|
|
876
|
+
const orch = cfg.orchestrator;
|
|
877
|
+
const configured = orch && typeof orch === 'object'
|
|
878
|
+
&& typeof orch.planner === 'string' && orch.planner
|
|
879
|
+
&& Array.isArray(orch.workers) && orch.workers.length > 0;
|
|
880
|
+
if (!configured) {
|
|
881
|
+
warnings.push(
|
|
882
|
+
'config.provider is "orchestrator" but cfg.orchestrator is missing/empty. '
|
|
883
|
+
+ 'Pre-v5.3.2 setup wizards could leave you in this half-configured state. '
|
|
884
|
+
+ 'Either finish orchestrator setup (`lazyclaw orchestrator set-planner …` + `lazyclaw orchestrator workers add …`) '
|
|
885
|
+
+ 'or switch to a single concrete provider: `lazyclaw config set provider claude-cli`.'
|
|
886
|
+
);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
867
889
|
// C12 — MinGit / Windows safety net. mas/tools/git.mjs shells out to
|
|
868
890
|
// `git`; on a stripped Windows PATH (no Git-for-Windows installed) or
|
|
869
891
|
// a minimal Docker base image, that spawnSync ENOENTs and any agent
|
|
@@ -957,6 +979,7 @@ async function cmdDoctor() {
|
|
|
957
979
|
nodeVersion: process.version,
|
|
958
980
|
platform: `${process.platform}-${process.arch}`,
|
|
959
981
|
issues,
|
|
982
|
+
warnings,
|
|
960
983
|
knownProviders: Object.keys(_registryMod.PROVIDERS),
|
|
961
984
|
workflows,
|
|
962
985
|
git: gitInfo,
|
|
@@ -1941,7 +1964,15 @@ function _providerFamilies() {
|
|
|
1941
1964
|
mock: { label: 'Mock', desc: 'offline echo, only useful for testing', tag: '\x1b[38;5;245m[test]\x1b[0m', members: [] },
|
|
1942
1965
|
};
|
|
1943
1966
|
for (const name of all) {
|
|
1967
|
+
// v5.3.2 — orchestrator is strictly opt-in. It's still registered so
|
|
1968
|
+
// `lazyclaw orchestrator …` and explicit `--provider orchestrator`
|
|
1969
|
+
// keep working, but the setup wizard must never land on it as a
|
|
1970
|
+
// default. Previously a fresh user picking "CLI/Local" got
|
|
1971
|
+
// orchestrator bucketed alongside claude-cli/ollama and could end
|
|
1972
|
+
// up with `{ provider: 'orchestrator', model: 'orchestrator' }`
|
|
1973
|
+
// written to cfg.json.
|
|
1944
1974
|
if (name === 'mock') buckets.mock.members.push(name);
|
|
1975
|
+
else if (name === 'orchestrator') continue;
|
|
1945
1976
|
else if ((info[name] || {}).requiresApiKey) buckets.api.members.push(name);
|
|
1946
1977
|
else buckets.cli.members.push(name);
|
|
1947
1978
|
}
|
|
@@ -1975,7 +2006,11 @@ async function _pickProviderInteractive() {
|
|
|
1975
2006
|
};
|
|
1976
2007
|
process.stdin.on('data', onData);
|
|
1977
2008
|
});
|
|
1978
|
-
|
|
2009
|
+
// v5.3.2 — non-TTY fallback used to be `providers[0]`, which was
|
|
2010
|
+
// whatever happened to be first in the registry (currently
|
|
2011
|
+
// anthropic). Pin to claude-cli to match the interactive onboard
|
|
2012
|
+
// hint at cmdOnboard (the keyless subscription path).
|
|
2013
|
+
return { provider: ans || 'claude-cli', model: null };
|
|
1979
2014
|
}
|
|
1980
2015
|
|
|
1981
2016
|
// ── Step 1 — auth family ──────────────────────────────────────
|
|
@@ -2070,17 +2105,13 @@ async function _pickProviderInteractive() {
|
|
|
2070
2105
|
provider = picked;
|
|
2071
2106
|
}
|
|
2072
2107
|
|
|
2073
|
-
// ── Step 3 — model
|
|
2074
|
-
//
|
|
2075
|
-
//
|
|
2076
|
-
//
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
if (result === 'CANCEL') return null;
|
|
2081
|
-
if (result === 'BACK') return _pickProviderInteractive();
|
|
2082
|
-
return { provider: provider.id, model: 'orchestrator' };
|
|
2083
|
-
}
|
|
2108
|
+
// ── Step 3 — model picker ────────────────────────────────────────
|
|
2109
|
+
// v5.3.2 — the setup wizard no longer surfaces composite providers
|
|
2110
|
+
// (orchestrator is filtered out of _providerFamilies above), so this
|
|
2111
|
+
// step is just the regular model picker. The orchestrator wizard
|
|
2112
|
+
// (_setupOrchestratorInteractive) stays reachable via the dedicated
|
|
2113
|
+
// `lazyclaw orchestrator …` subcommand and an explicit
|
|
2114
|
+
// `--provider orchestrator` invocation.
|
|
2084
2115
|
const picked = await _pickModelInteractive(provider.id, {
|
|
2085
2116
|
titlePrefix: 'LazyClaw setup — Step 3 of 3:',
|
|
2086
2117
|
onBack: 'restart',
|
|
@@ -2471,7 +2502,11 @@ async function cmdChat(flags = {}) {
|
|
|
2471
2502
|
if (picked.model) activeModel = picked.model;
|
|
2472
2503
|
}
|
|
2473
2504
|
}
|
|
2474
|
-
|
|
2505
|
+
// v5.3.2 — last-resort safety net used to fall through to 'mock' (the
|
|
2506
|
+
// offline echo provider), which silently degraded a wiped config into
|
|
2507
|
+
// garbage replies. Default to claude-cli so the user lands on the
|
|
2508
|
+
// keyless subscription path instead.
|
|
2509
|
+
if (!activeProvName) activeProvName = 'claude-cli';
|
|
2475
2510
|
let prov = lookupProv(activeProvName);
|
|
2476
2511
|
if (!prov) { console.error(`unknown provider: ${activeProvName}`); process.exit(2); }
|
|
2477
2512
|
|
|
@@ -2634,9 +2669,53 @@ async function cmdChat(flags = {}) {
|
|
|
2634
2669
|
ctx: _inkCtx,
|
|
2635
2670
|
writeFn: (chunk) => process.stdout.write(chunk),
|
|
2636
2671
|
});
|
|
2672
|
+
// Minimal slash dispatcher for the Ink branch. Covers the read-only
|
|
2673
|
+
// info commands so the user sees something useful instead of having
|
|
2674
|
+
// their slash command sent to the model as a prompt. /exit + /quit
|
|
2675
|
+
// are intercepted inside ReplApp before this fires. Returning a
|
|
2676
|
+
// string causes ReplApp to render the result into scrollback.
|
|
2677
|
+
//
|
|
2678
|
+
// The full set of mutating commands (/model, /provider, /skill,
|
|
2679
|
+
// /personality, ...) still lives in the legacy readline path and
|
|
2680
|
+
// remains accessible via LAZYCLAW_NO_INK=1. Wiring those through
|
|
2681
|
+
// the Ink branch is a follow-up; today we at least stop sending
|
|
2682
|
+
// them as prompts.
|
|
2683
|
+
const _inkSlashHandler = async (line) => {
|
|
2684
|
+
const cmd = line.split(/\s+/)[0];
|
|
2685
|
+
switch (cmd) {
|
|
2686
|
+
case '/help': {
|
|
2687
|
+
const lines = ['slash commands:'];
|
|
2688
|
+
for (const c of SLASH_COMMANDS) lines.push(` ${c.cmd.padEnd(14)} — ${c.help}`);
|
|
2689
|
+
return lines.join('\n') + '\n';
|
|
2690
|
+
}
|
|
2691
|
+
case '/status': {
|
|
2692
|
+
const out = {
|
|
2693
|
+
provider: activeProvName,
|
|
2694
|
+
model: activeModel,
|
|
2695
|
+
keyMasked: _registryMod.maskApiKey(cfg['api-key']),
|
|
2696
|
+
messageCount: _inkMessages.length,
|
|
2697
|
+
};
|
|
2698
|
+
return JSON.stringify(out) + '\n';
|
|
2699
|
+
}
|
|
2700
|
+
case '/version': {
|
|
2701
|
+
return `lazyclaw ${readVersionFromRepo()} (node ${process.version}, ${process.platform})\n`;
|
|
2702
|
+
}
|
|
2703
|
+
case '/exit':
|
|
2704
|
+
case '/quit':
|
|
2705
|
+
// ReplApp intercepts these before onSlashCommand fires, but
|
|
2706
|
+
// return EXIT defensively in case that contract ever changes.
|
|
2707
|
+
return 'EXIT';
|
|
2708
|
+
default:
|
|
2709
|
+
// Mutating commands still require the legacy readline path.
|
|
2710
|
+
// Telling the user explicitly beats silently sending the
|
|
2711
|
+
// slash text to the model as a prompt.
|
|
2712
|
+
return `${cmd} is not yet wired into the ink REPL — set LAZYCLAW_NO_INK=1 and restart to use it.\n`;
|
|
2713
|
+
}
|
|
2714
|
+
};
|
|
2637
2715
|
const ink = render(/* @__PURE__ */ React.createElement(ReplApp, {
|
|
2638
2716
|
splashProps,
|
|
2639
2717
|
runTurn: _inkRunTurn,
|
|
2718
|
+
onSlashCommand: _inkSlashHandler,
|
|
2640
2719
|
}));
|
|
2641
2720
|
await ink.waitUntilExit();
|
|
2642
2721
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lazyclaw",
|
|
3
|
-
"version": "5.3.
|
|
3
|
+
"version": "5.3.2",
|
|
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",
|
|
@@ -124,18 +124,33 @@ export function makeOrchestratorProvider(opts = {}) {
|
|
|
124
124
|
const fallbackSpec = cfg.provider && cfg.provider !== 'orchestrator'
|
|
125
125
|
? `${cfg.provider}${cfg.model ? ':' + cfg.model : ''}`
|
|
126
126
|
: 'claude-cli';
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
127
|
+
// Unconfigured-orchestrator path (v5.3.2 fix): when cfg.orchestrator
|
|
128
|
+
// is missing OR has no workers configured, the multi-agent pipeline
|
|
129
|
+
// is unjustified — there is no second backend to delegate to. The
|
|
130
|
+
// previous behaviour printed a "single-agent chain" banner and then
|
|
131
|
+
// still ran Plan → Execute(N) → Synthesis against the same backend,
|
|
132
|
+
// turning a trivial question into a 4-subtask decomposition. That
|
|
133
|
+
// violates §1 truthfulness (the banner promised a single chain) and
|
|
134
|
+
// the user's stated intent. Do a real passthrough instead.
|
|
135
|
+
const hasWorkers = Array.isArray(o.workers) && o.workers.length > 0;
|
|
136
|
+
if (!cfg.orchestrator || !hasWorkers) {
|
|
137
|
+
const direct = _lookupProvider(fallbackSpec);
|
|
138
|
+
if (!direct || direct.name === 'orchestrator') {
|
|
139
|
+
yield `⚠ orchestrator: not configured and fallback provider \`${fallbackSpec}\` is not registered. ` +
|
|
140
|
+
`Set \`cfg.orchestrator.planner\` + \`cfg.orchestrator.workers\`, or set \`cfg.provider\` to a real backend.\n`;
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
yield `> Orchestrator not configured — using single-shot \`${direct.name}${direct.model ? ':' + direct.model : ''}\`. ` +
|
|
144
|
+
`Run \`lazyclaw orchestrator set-planner ${fallbackSpec}\` then \`lazyclaw orchestrator workers add <provider:model>\` to enable multi-agent.\n\n`;
|
|
145
|
+
for await (const chunk of direct.prov.sendMessage(messages, {
|
|
146
|
+
apiKey: keyResolver(cfg, direct.name),
|
|
147
|
+
model: direct.model || undefined,
|
|
148
|
+
signal: callerOpts.signal,
|
|
149
|
+
})) yield String(chunk);
|
|
150
|
+
return;
|
|
134
151
|
}
|
|
135
152
|
const plannerSpec = String(o.planner || fallbackSpec);
|
|
136
|
-
const workerSpecs =
|
|
137
|
-
? o.workers.map(String)
|
|
138
|
-
: [plannerSpec];
|
|
153
|
+
const workerSpecs = o.workers.map(String);
|
|
139
154
|
const maxSubtasks = Number.isFinite(o.maxSubtasks) && o.maxSubtasks > 0 ? Math.min(10, o.maxSubtasks) : 5;
|
|
140
155
|
|
|
141
156
|
const planner = _lookupProvider(plannerSpec);
|
package/tui/editor.mjs
CHANGED
|
@@ -10,10 +10,61 @@
|
|
|
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).
|
|
20
|
+
//
|
|
21
|
+
// v5.3.2: CJK / wide-character correctness. `string.length` returns the
|
|
22
|
+
// UTF-16 code-unit count, which is wrong for column math — a Hangul or
|
|
23
|
+
// Han glyph occupies 2 terminal cells but reports `.length === 1`. We
|
|
24
|
+
// keep `state.cursor` in codepoint-index units (so `buffer.slice(0,
|
|
25
|
+
// cursor)`, Backspace, and history recall still work), but expose all
|
|
26
|
+
// display-column math through the `displayWidth()` helper and the
|
|
27
|
+
// derived `cursorDisplayCol` field. The render also pins the Box to
|
|
28
|
+
// `width: '100%'` so Ink's wrap-ansi (already string-width aware) gets
|
|
29
|
+
// the full terminal budget — fixes the perceived right-edge truncation
|
|
30
|
+
// on long Korean buffers.
|
|
13
31
|
import React, { useState, useEffect } from 'react';
|
|
14
32
|
import { Box, Text, useInput } from 'ink';
|
|
33
|
+
import stringWidth from 'string-width';
|
|
15
34
|
import { theme } from './theme.mjs';
|
|
16
35
|
|
|
36
|
+
// The accent prompt (`› `) prepended to the first rendered line. Its
|
|
37
|
+
// display width matters for any caller that wants to know the usable
|
|
38
|
+
// inner width of the editor box. Defined once so the value stays in
|
|
39
|
+
// sync if the prompt glyph ever changes.
|
|
40
|
+
export const PROMPT_PREFIX = '› ';
|
|
41
|
+
export const PROMPT_WIDTH = stringWidth(PROMPT_PREFIX);
|
|
42
|
+
export const CONTINUATION_GUTTER = ' ';
|
|
43
|
+
export const CONTINUATION_WIDTH = stringWidth(CONTINUATION_GUTTER);
|
|
44
|
+
|
|
45
|
+
// Public helper: display width of a buffer (or any substring of it),
|
|
46
|
+
// counting wide chars (CJK, fullwidth, most emoji) as 2 cells and
|
|
47
|
+
// ignoring ANSI escapes. Use this — never `.length` — for column math.
|
|
48
|
+
export function displayWidth(text) {
|
|
49
|
+
if (!text) return 0;
|
|
50
|
+
return stringWidth(String(text));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Display column of the caret given a state. Counts wide chars as 2.
|
|
54
|
+
// On the first rendered line this is offset by PROMPT_WIDTH; on
|
|
55
|
+
// continuation lines (after a Shift+Enter) it is offset by
|
|
56
|
+
// CONTINUATION_WIDTH. Callers that only need the in-buffer column can
|
|
57
|
+
// pass `{ withPrefix: false }`.
|
|
58
|
+
export function cursorDisplayCol(state, { withPrefix = true } = {}) {
|
|
59
|
+
const before = String(state.buffer || '').slice(0, state.cursor || 0);
|
|
60
|
+
const newlineIdx = before.lastIndexOf('\n');
|
|
61
|
+
const lineSlice = newlineIdx === -1 ? before : before.slice(newlineIdx + 1);
|
|
62
|
+
const inLine = stringWidth(lineSlice);
|
|
63
|
+
if (!withPrefix) return inLine;
|
|
64
|
+
const prefix = newlineIdx === -1 ? PROMPT_WIDTH : CONTINUATION_WIDTH;
|
|
65
|
+
return prefix + inLine;
|
|
66
|
+
}
|
|
67
|
+
|
|
17
68
|
export function makeEditorState({ history = [] } = {}) {
|
|
18
69
|
return {
|
|
19
70
|
buffer: '',
|
|
@@ -125,17 +176,29 @@ export function Editor({
|
|
|
125
176
|
}
|
|
126
177
|
// Tab / Enter — fill the buffer with the highlighted command.
|
|
127
178
|
// First Enter fills, second Enter runs (matches Anthropic's UX).
|
|
179
|
+
// Exception: if the buffer already exactly matches the picked
|
|
180
|
+
// command (with or without a trailing space), there is nothing left
|
|
181
|
+
// to autocomplete. For Enter, fall through to the normal submit
|
|
182
|
+
// path so /exit, /quit, /help etc. fire on a single Enter. For Tab
|
|
183
|
+
// on an exact match, no-op.
|
|
128
184
|
if (key.tab || key.return) {
|
|
129
185
|
const safeIdx = Math.max(0, Math.min(slashSuggestions.length - 1, slashSelectedIndex || 0));
|
|
130
186
|
const picked = slashSuggestions[safeIdx];
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
if (
|
|
135
|
-
|
|
187
|
+
const bufTrim = state.buffer.replace(/\s+$/, '');
|
|
188
|
+
const alreadyExact = !!picked && (state.buffer === picked.cmd || bufTrim === picked.cmd);
|
|
189
|
+
if (alreadyExact) {
|
|
190
|
+
if (key.tab) return; // no completion to make
|
|
191
|
+
// key.return on exact match → fall through to applyKey/submit.
|
|
192
|
+
} else {
|
|
193
|
+
if (picked) {
|
|
194
|
+
const next = fillSlashCommand(state, picked.cmd);
|
|
195
|
+
setState(next);
|
|
196
|
+
if (onBufferChange) {
|
|
197
|
+
try { onBufferChange(next.buffer); } catch {}
|
|
198
|
+
}
|
|
136
199
|
}
|
|
200
|
+
return;
|
|
137
201
|
}
|
|
138
|
-
return;
|
|
139
202
|
}
|
|
140
203
|
// Anything else (printable, backspace) falls through to applyKey.
|
|
141
204
|
}
|
|
@@ -156,7 +219,23 @@ export function Editor({
|
|
|
156
219
|
const lines = state.buffer.split('\n');
|
|
157
220
|
return React.createElement(
|
|
158
221
|
Box,
|
|
159
|
-
{
|
|
160
|
-
|
|
222
|
+
{
|
|
223
|
+
borderStyle: 'round',
|
|
224
|
+
borderColor: theme.border,
|
|
225
|
+
paddingX: 1,
|
|
226
|
+
flexDirection: 'column',
|
|
227
|
+
flexShrink: 0,
|
|
228
|
+
// Pin to full terminal width. Ink's wrap-ansi (which is
|
|
229
|
+
// string-width aware) then has the correct cell budget for
|
|
230
|
+
// wrapping long CJK buffers — fixes the right-edge truncation
|
|
231
|
+
// perceived on Hangul / Han input. See `displayWidth`/
|
|
232
|
+
// `cursorDisplayCol` above for the public width helpers.
|
|
233
|
+
width: '100%',
|
|
234
|
+
},
|
|
235
|
+
lines.map((ln, i) => React.createElement(
|
|
236
|
+
Text,
|
|
237
|
+
{ key: i },
|
|
238
|
+
i === 0 ? theme.accent(PROMPT_PREFIX) + ln : CONTINUATION_GUTTER + ln,
|
|
239
|
+
)),
|
|
161
240
|
);
|
|
162
241
|
}
|
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,
|