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 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
- return { provider: ans || providers[0], model: null };
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 (or, for composite providers, a config wizard) ───
2074
- // The orchestrator (and any future composite provider) has no model
2075
- // of its own it dispatches to other providers. Step 3 routes
2076
- // through a custom wizard instead of the standard model picker.
2077
- const providerMeta = (_registryMod.PROVIDER_INFO || {})[provider.id] || {};
2078
- if (providerMeta.composite || provider.id === 'orchestrator') {
2079
- const result = await _setupOrchestratorInteractive();
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
- if (!activeProvName) activeProvName = 'mock';
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.0",
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
- // First-run hint when cfg.orchestrator is missing entirely. The
128
- // fallback path below still works (planner = cfg.provider, single
129
- // worker = same), but the user almost certainly meant to opt in
130
- // explicitly surface the shortest valid CLI to set it up.
131
- if (!cfg.orchestrator) {
132
- yield `> orchestrator: \`cfg.orchestrator\` is not set. Defaulting to a single-agent chain on \`${fallbackSpec}\`.\n` +
133
- `> Configure properly: \`lazyclaw orchestrator set-planner ${fallbackSpec}\` then \`lazyclaw orchestrator workers add <provider:model>\` (one per agent).\n\n`;
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 = Array.isArray(o.workers) && o.workers.length
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
- if (picked) {
132
- const next = fillSlashCommand(state, picked.cmd);
133
- setState(next);
134
- if (onBufferChange) {
135
- try { onBufferChange(next.buffer); } catch {}
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
- { flexDirection: 'column' },
160
- lines.map((ln, i) => React.createElement(Text, { key: i }, i === 0 ? theme.accent('') + ln : ' ' + ln))
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
- 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,