lazyclaw 6.2.0 → 6.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/commands/chat.mjs CHANGED
@@ -23,17 +23,16 @@ import { SLASH_COMMANDS } from '../tui/slash_commands.mjs';
23
23
 
24
24
  // Legacy (non-Ink) slash routing for dispatcher-style, ctx-only commands.
25
25
  // The Ink REPL routes every slash through _dispatchSlash/SLASH_HANDLERS, but
26
- // the legacy readline path uses a hand-written switch (in cmdChat). Commands
27
- // like /config only mutate ctx and return a sentinel, so we keep that wiring
28
- // in one exported helper that BOTH the legacy switch and the regression test
29
- // drive — this is the seam that proves /config reaches setup on the legacy
30
- // path (the post-loop guard re-runs cmdSetup when ctx.requestSetup is set).
31
- // Returns 'EXIT' when the loop must break, or undefined when the command is
32
- // not one this helper owns (caller falls through to its own handling).
26
+ // the legacy readline path uses a hand-written switch (in cmdChat). This
27
+ // exported helper is the wiring BOTH that switch and the regression test
28
+ // drive. Returns 'EXIT' to break the loop, undefined when not owned here.
33
29
  export function legacySlashRoute(cmd, ctx) {
34
30
  switch (cmd) {
31
+ // Legacy readline path has no modal picker, so BOTH /setup and /config
32
+ // route to the full wizard here (the Ink path gives /config its
33
+ // single-setting picker via tui/config_picker.mjs).
35
34
  case '/config':
36
- // Mirror tui/slash_dispatcher.mjs '/config': signal the host, unmount.
35
+ case '/setup':
37
36
  ctx.requestSetup = true;
38
37
  return 'EXIT';
39
38
  default:
@@ -297,18 +296,23 @@ export async function cmdChat(flags = {}) {
297
296
  pickerRef: _inkPickerRef,
298
297
  }), { exitOnCtrlC: true, patchConsole: true });
299
298
  await ink.waitUntilExit();
300
- // /config asks to (re)run the wizard: now that Ink has released stdin,
301
- // run setup, then return to the shell (re-launch `lazyclaw` to chat).
299
+ // /setup full wizard (then shell). /config single step run JUST
300
+ // that step now that Ink released stdin, then re-enter chat.
302
301
  if (_inkCtx.requestSetup) await (await import('./setup.mjs')).cmdSetup(undefined, [], {});
302
+ else if (_inkCtx.requestConfigStep) {
303
+ await (await import('./config_step.mjs')).runConfigStep(_inkCtx.requestConfigStep);
304
+ return cmdChat(flags);
305
+ }
303
306
  return;
304
307
  } catch (e) {
305
- // Fall through to legacy path on any ink failure (missing import,
306
- // narrow terminal, sandboxed stdout).
307
- if (process.env.LAZYCLAW_DEBUG) console.error('[ink] fallback:', e.message);
308
+ // Fall through to the legacy readline path on any ink failure. ALWAYS
309
+ // say why, in one dim line — the silent downgrade made real-terminal
310
+ // failures (node incompat, <60-col windows) undiagnosable from reports.
311
+ process.stderr.write(`\x1b[2m(ink UI unavailable: ${e?.message || e} — using the legacy reader)\x1b[0m\n`);
308
312
  }
309
313
  }
310
314
  // ─── legacy v4 path (unchanged) ─────────────────────────────────
311
- _printChatBanner(activeProvName, activeModel, readVersionFromRepo());
315
+ await _printChatBanner(activeProvName, activeModel, readVersionFromRepo());
312
316
 
313
317
  const readline = await import('node:readline');
314
318
  // Use terminal:true when we're attached to a TTY so the prompt shows
@@ -1110,14 +1114,10 @@ export async function cmdChat(flags = {}) {
1110
1114
  } catch { /* /exit must never hang or throw */ }
1111
1115
  return 'EXIT';
1112
1116
  }
1113
- case '/config': {
1114
- // Route through the shared legacySlashRoute helper (covered by
1115
- // tests/f-config-slash-splash.test.mjs) so the legacy path's /config
1116
- // wiring is the exact code the regression test exercises. It sets
1117
- // _legacyCtx.requestSetup and returns 'EXIT'; the post-loop guard at the
1118
- // bottom of cmdChat re-runs the setup wizard when requestSetup is set.
1119
- // Without this case the legacy readline path fell through to `default:`
1120
- // and printed "unknown slash" instead of launching setup.
1117
+ case '/config':
1118
+ case '/setup': {
1119
+ // Shared legacySlashRoute wiring (tests/f-config-slash-splash.test.mjs):
1120
+ // sets requestSetup + 'EXIT'; the post-loop guard runs the wizard.
1121
1121
  return legacySlashRoute(cmd, _legacyCtx);
1122
1122
  }
1123
1123
  default: {
@@ -0,0 +1,39 @@
1
+ // commands/config_step.mjs — run ONE setup step outside the full wizard.
2
+ //
3
+ // Backs the in-chat `/config` picker: credential steps (channel tokens,
4
+ // outbound webhook) need raw readline prompts that can't run inside the Ink
5
+ // REPL, so the REPL unmounts with ctx.requestConfigStep set, chat.mjs calls
6
+ // runConfigStep(step) here, and then re-enters chat — the user changes one
7
+ // value (e.g. a webhook URL) without re-walking every wizard step.
8
+
9
+ import path from 'node:path';
10
+ import { configPath } from '../lib/config.mjs';
11
+ import { _quickPrompt } from '../tui/pickers.mjs';
12
+ import { runChannelStep, runWebhookStep } from './setup_channels.mjs';
13
+
14
+ const COLORS = {
15
+ accent: (s) => `\x1b[38;2;217;179;90m${s}\x1b[0m`,
16
+ bold: (s) => `\x1b[1m${s}\x1b[0m`,
17
+ dim: (s) => `\x1b[2m${s}\x1b[0m`,
18
+ ok: (s) => `\x1b[32m${s}\x1b[0m`,
19
+ warn: (s) => `\x1b[33m${s}\x1b[0m`,
20
+ };
21
+
22
+ export async function runConfigStep(step, deps = {}) {
23
+ const prompt = deps.prompt || _quickPrompt;
24
+ const colors = deps.colors || COLORS;
25
+ const write = deps.write || ((s) => process.stdout.write(s));
26
+ const cfgDir = deps.cfgDir || path.dirname(configPath());
27
+
28
+ write(`\n ${colors.bold(`⚙ config — ${step}`)}\n`);
29
+ if (step === 'channel') {
30
+ await runChannelStep({ cfgDir, prompt, colors, write });
31
+ return true;
32
+ }
33
+ if (step === 'webhook') {
34
+ await runWebhookStep({ prompt, colors, write });
35
+ return true;
36
+ }
37
+ write(` ${colors.warn(`unknown config step: ${step}`)}\n`);
38
+ return false;
39
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lazyclaw",
3
- "version": "6.2.0",
3
+ "version": "6.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",
@@ -0,0 +1,54 @@
1
+ // tui/config_picker.mjs — the `/config` slash: change ONE setting without
2
+ // re-running the whole wizard.
3
+ //
4
+ // Split of duties (user-requested):
5
+ // /setup — first-run / full re-setup: leaves chat and runs EVERY wizard
6
+ // step (the behavior /config used to have).
7
+ // /config — settings editor: pick a single item. In-chat items (provider /
8
+ // model / context / trainer / orchestrator) delegate to their
9
+ // existing slash handlers and stay inside chat; credential items
10
+ // (channel tokens, outbound webhook) need readline prompts, so
11
+ // they unmount, run JUST that step, and re-enter chat.
12
+ //
13
+ // On the legacy readline path (no ctx.openPicker modal) /config falls back
14
+ // to the full wizard — same as before, no silent degradation.
15
+
16
+ const CONFIG_ITEMS = [
17
+ { id: 'provider', label: 'provider', desc: 'switch the chat provider (family → vendor picker)' },
18
+ { id: 'model', label: 'model', desc: 'switch the model (live list when the provider supports it)' },
19
+ { id: 'context', label: 'context window', desc: 'history turns / token budget sent per turn' },
20
+ { id: 'trainer', label: 'trainer', desc: 'learning-loop provider/model (auto = $0 on claude-cli)' },
21
+ { id: 'orchestrator', label: 'orchestrator', desc: 'multi-agent on/off, planner, workers' },
22
+ { id: 'channel', label: 'channel credentials', desc: 'Slack/Telegram/Matrix tokens — leaves chat for the prompts, then returns' },
23
+ { id: 'webhook', label: 'outbound webhook', desc: 'message-send webhook URL — leaves chat, then returns' },
24
+ { id: 'wizard', label: 'everything (full wizard)', desc: 'rerun all setup steps — same as /setup' },
25
+ ];
26
+
27
+ export async function runConfigSlash(_args, ctx, handlers) {
28
+ if (typeof ctx.openPicker !== 'function') {
29
+ // Legacy readline path has no modal picker — keep the old /config
30
+ // behavior there (full wizard) rather than failing.
31
+ ctx.requestSetup = true;
32
+ return 'EXIT';
33
+ }
34
+ const picked = await ctx.openPicker({
35
+ kind: 'config-item',
36
+ title: 'config — change one setting',
37
+ subtitle: 'Enter to edit · Esc to cancel · /setup reruns the whole wizard',
38
+ items: CONFIG_ITEMS.map((i) => ({ id: i.id, label: i.label, desc: i.desc })),
39
+ });
40
+ const id = typeof picked === 'string' ? picked : (picked && picked.id);
41
+ if (!id || id === 'CANCEL') return 'config: cancelled';
42
+ if (id === 'wizard') { ctx.requestSetup = true; return 'EXIT'; }
43
+ if (id === 'channel' || id === 'webhook') {
44
+ // These steps need raw readline prompts (secrets), so the REPL unmounts,
45
+ // chat.mjs runs the single step, and chat restarts automatically.
46
+ ctx.requestConfigStep = id;
47
+ return 'EXIT';
48
+ }
49
+ const handler = handlers && handlers.get && handlers.get(`/${id}`);
50
+ if (!handler) return `config: no in-chat editor for "${id}"`;
51
+ return handler('', ctx);
52
+ }
53
+
54
+ export { CONFIG_ITEMS };
package/tui/editor.mjs CHANGED
@@ -422,7 +422,12 @@ export function Editor({
422
422
  try {
423
423
  process.stdout.write(`${undo}\x1b[${rowsUp}A\x1b[${colTarget}G\x1b[?25h`);
424
424
  } catch { /* stdout closed — swallow */ }
425
- }, [state.buffer, state.cursor, altEnabled]);
425
+ });
426
+ // ^ NO dependency array — re-anchor after EVERY commit. Renders triggered
427
+ // by OTHER components (status-bar ticks, streaming output) redraw the
428
+ // frame and park the terminal cursor below it; with buffer-only deps the
429
+ // anchor didn't re-run and the cursor drifted out of the box whenever the
430
+ // user wasn't typing. The pending-offset undo above makes repeats safe.
426
431
 
427
432
  const lines = state.buffer.split('\n');
428
433
  // Ink's <Text wrap="wrap"> uses wrap-ansi (string-width aware) but the
@@ -446,6 +451,17 @@ export function Editor({
446
451
  });
447
452
  }
448
453
  }
454
+ // Always-visible caret: an inverse-video cell drawn AT the cursor (the
455
+ // editor has no mid-line cursor movement, so the cursor is always at the
456
+ // end of the buffer — including column 0 of an empty box). The real
457
+ // terminal cursor is anchored to the same cell for IME pre-edit; this
458
+ // glyph keeps the position visible even between anchor writes (e.g.
459
+ // while another component renders). Hidden while a modal picker owns
460
+ // the keyboard so it can't masquerade as an active prompt.
461
+ if (!modalOpen && renderedLines.length > 0) {
462
+ const last = renderedLines[renderedLines.length - 1];
463
+ last.text = `${last.text}\x1b[7m \x1b[27m`;
464
+ }
449
465
  return React.createElement(
450
466
  Box,
451
467
  {
package/tui/pickers.mjs CHANGED
@@ -1,8 +1,8 @@
1
- // Interactive TUI helpers — readline pickers, banner/mascot renderers,
2
- // arrow-key menu, provider/model selection, and the _quickPrompt line reader.
3
- // Extracted from cli.mjs in Phase D4. Lives in tui/ so banner asset imports
4
- // are siblings (./banner.generated.mjs, ./wordmark.mjs).
1
+ // Interactive TUI helpers — readline pickers, banner renderers, arrow-key
2
+ // menu, provider/model selection, _quickPrompt. Extracted from cli.mjs (D4);
3
+ // lives in tui/ so banner asset imports are siblings.
5
4
  import { readConfig, writeConfig, _resolveAuthKey } from '../lib/config.mjs';
5
+ import { SLASH_COMMANDS } from './slash_commands.mjs';
6
6
  import { ensureRegistry, getRegistry } from '../lib/registry_boot.mjs';
7
7
  import { bucketProviders as _bucketProviders } from './provider_families.mjs';
8
8
  import { addCustomProvider } from '../providers/custom_provider.mjs';
@@ -13,13 +13,11 @@ import {
13
13
  } from '../providers/model_catalogue.mjs';
14
14
 
15
15
  export function _attachGhostAutocomplete(rl) {
16
- // Returns `{ dispose, suspend, resume }`. Dispose detaches the
17
- // keypress + rl 'line' listeners (failure to do so leaks the
18
- // event-loop ref, which is exactly the slow-exit bug v3.92
19
- // fixed). Suspend / resume gate the keypress handler so the
20
- // streaming chat output isn't interleaved with `\x1b[s\x1b[K\x1b[u`
21
- // ghost-render escapes — that interleaving is what surfaces as
22
- // visible gaps between Korean characters in long replies.
16
+ // Returns `{ dispose, suspend, resume }`. Dispose detaches the keypress +
17
+ // rl 'line' listeners (leaking them is the v3.92 slow-exit bug). Suspend /
18
+ // resume gate the keypress handler so streaming chat output isn't
19
+ // interleaved with `\x1b[s\x1b[K\x1b[u` ghost-render escapes that
20
+ // interleaving surfaced as visible gaps between Korean characters.
23
21
  const noop = () => {};
24
22
  if (!process.stdout.isTTY) return { dispose: noop, suspend: noop, resume: noop };
25
23
  const cmds = SLASH_COMMANDS.map((c) => c.cmd);
@@ -219,15 +217,16 @@ export async function _renderV5Banner(version) {
219
217
  return rows;
220
218
  }
221
219
 
222
- export function _printChatBanner(activeProvName, activeModel, version) {
220
+ export async function _printChatBanner(activeProvName, activeModel, version) {
223
221
  if (!process.stdout.isTTY) return;
224
- // Single-hue header: labels dim-orange, values/emphasis full-orange, so the
225
- // four caption rows below the box read as part of the same warm badge.
222
+ // Single-hue header: labels dim-orange, values/emphasis full-orange. Uses
223
+ // the v5 sloth splash (NOT the retired v4 figlet box see _renderV5Banner;
224
+ // figlet remains only as the missing-asset last resort).
226
225
  const dimOrange = (s) => `\x1b[2m\x1b[38;2;${_ORANGE_RGB}m${s}\x1b[0m`;
227
226
  const orange = _orange;
228
227
  const lines = [
229
228
  '',
230
- ..._renderBanner(version),
229
+ ...(await _renderV5Banner(version)),
231
230
  '',
232
231
  ` ${dimOrange('provider ·')} ${orange(activeProvName)}`,
233
232
  ` ${dimOrange('model ·')} ${orange(activeModel || '(default)')}`,
@@ -26,7 +26,8 @@ export const SLASH_COMMANDS = [
26
26
  { cmd: '/personality', help: 'pick a personality (or sub: list|show|install|remove|use)' },
27
27
  { cmd: '/dashboard', help: 'open the lazyclaw web UI in your browser' },
28
28
  { cmd: '/menu', help: 'browse the full subcommand catalog (command palette)' },
29
- { cmd: '/config', help: 'leave chat and re-run the setup wizard (provider, model, channels)' },
29
+ { cmd: '/setup', help: 'first-run / full re-setup: leave chat and run every wizard step' },
30
+ { cmd: '/config', help: 'change ONE setting: provider, model, context, channel creds, webhook, …' },
30
31
  { cmd: '/channels', help: 'view configured channels; /channels <name> on|off to toggle' },
31
32
  { cmd: '/orchestrator', help: 'multi-agent: status | on | off | planner <spec> | worker add|remove <spec>' },
32
33
  { cmd: '/context', help: 'chat history window: status | turns <N> | tokens <N>' },
@@ -1,8 +1,6 @@
1
1
  // tui/slash_dispatcher.mjs — single source of slash-command routing for the
2
2
  // Ink chat REPL (v5.4). Lifted from cli.mjs's legacy readline handler so the
3
- // Ink branch stops shipping the "not yet wired" placeholder and so future
4
- // channels (Slack/Telegram inline commands, /handoff cross-channel control)
5
- // can share one dispatch table.
3
+ // Ink branch and future channel surfaces share one dispatch table.
6
4
  //
7
5
  // Contract:
8
6
  // dispatchSlash(cmd, args, ctx, write) → Promise<string|'EXIT'|void>
@@ -1814,17 +1812,18 @@ export const SLASH_HANDLERS = new Map([
1814
1812
  ['/channels', _channels],
1815
1813
  ['/orchestrator', _orchestrator],
1816
1814
  ['/context', _context],
1817
- // /configunmount and let chat.mjs run the setup wizard (ctx.requestSetup).
1818
- ['/config', async (_a, ctx) => { ctx.requestSetup = true; return 'EXIT'; }],
1815
+ // /setupfull wizard (every step); /config pick ONE setting to change
1816
+ // (in-chat where possible; credential steps unmount, run, re-enter chat).
1817
+ ['/setup', async (_a, ctx) => { ctx.requestSetup = true; return 'EXIT'; }],
1818
+ ['/config', async (a, ctx) => (await import('./config_picker.mjs')).runConfigSlash(a, ctx, SLASH_HANDLERS)],
1819
1819
  ['/exit', async () => 'EXIT'],
1820
1820
  ['/quit', async () => 'EXIT'],
1821
1821
  ]);
1822
1822
 
1823
1823
  /**
1824
- * Primary entry point. Resolves the command name to a handler in
1825
- * SLASH_HANDLERS and invokes it. Unknown commands return a friendly
1826
- * "unknown" string (caller renders to scrollback) rather than throwing,
1827
- * so the user sees feedback instead of an error toast.
1824
+ * Primary entry point. Resolves the command name to a SLASH_HANDLERS entry
1825
+ * and invokes it. Unknown commands return a friendly "unknown" string
1826
+ * (rendered to scrollback) rather than throwing.
1828
1827
  */
1829
1828
  export async function dispatchSlash(cmd, args, ctx, write) {
1830
1829
  const handler = SLASH_HANDLERS.get(cmd);