lazyclaw 5.0.1 → 5.0.3

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
@@ -1634,31 +1634,28 @@ function _renderBanner(version) {
1634
1634
  ];
1635
1635
  }
1636
1636
 
1637
- // v5 sloth banner — shared with the ink splash (tui/splash.mjs).
1638
- // Single-tone orange like _renderBanner so the no-arg launcher and the
1639
- // chat splash share a visual identity. Opt out with LAZYCLAW_LEGACY_MENU=1
1640
- // to fall back to the boxed figlet variant above.
1641
- let _v5BannerRowsCache = null;
1642
- async function _renderV5Banner(version) {
1643
- if (!_v5BannerRowsCache) {
1644
- try {
1645
- const { banner } = await import('./tui/banner.generated.mjs');
1646
- _v5BannerRowsCache = banner.rows;
1647
- } catch {
1648
- _v5BannerRowsCache = null;
1649
- }
1650
- }
1651
- if (!_v5BannerRowsCache) return _renderBanner(version);
1637
+ // v5 wordmark banner — ANSI Shadow style "LAZYCLAW" rendered in box-drawing
1638
+ // + half-block glyphs at 67 cols x 6 rows. Wider than the chat splash gutter
1639
+ // because the no-arg launcher has the full terminal width to work with.
1640
+ // Single-tone orange. Opt out with LAZYCLAW_LEGACY_MENU=1 to fall back to
1641
+ // the boxed figlet variant above.
1642
+ const _V5_WORDMARK = [
1643
+ " ██╗ █████╗ ███████╗██╗ ██╗ ██████╗██╗ █████╗ ██╗ ██╗",
1644
+ " ██║ ██╔══██╗╚══███╔╝╚██╗ ██╔╝██╔════╝██║ ██╔══██╗██║ ██║",
1645
+ " ██║ ███████║ ███╔╝ ╚████╔╝ ██║ ██║ ███████║██║ █╗ ██║",
1646
+ " ██║ ██╔══██║ ███╔╝ ╚██╔╝ ██║ ██║ ██╔══██║██║███╗██║",
1647
+ " ███████╗██║ ██║███████╗ ██║ ╚██████╗███████╗██║ ██║╚███╔███╔╝",
1648
+ " ╚══════╝╚═╝ ╚═╝╚══════╝ ╚═╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝ ",
1649
+ ];
1650
+
1651
+ function _renderV5Banner(version) {
1652
1652
  const v = String(version || '?.?.?');
1653
- return _v5BannerRowsCache.map((row, i) => {
1654
- if (i === 8) {
1655
- // Caption row — overlay "lazyclaw v<version>" centered on the 24-wide art
1656
- const cap = `lazyclaw v${v}`;
1657
- const pad = Math.max(0, Math.floor((24 - cap.length) / 2));
1658
- return _orange(' '.repeat(pad) + cap + ' '.repeat(24 - pad - cap.length));
1659
- }
1660
- return _orange(row);
1661
- });
1653
+ const wmCols = 69; // width of _V5_WORDMARK rows including leading " "
1654
+ const versionLine = ` v${v}`;
1655
+ return [
1656
+ ..._V5_WORDMARK.map(_orange),
1657
+ _orange(versionLine.padEnd(wmCols)),
1658
+ ];
1662
1659
  }
1663
1660
 
1664
1661
  function _printChatBanner(activeProvName, activeModel, version) {
@@ -6749,7 +6746,7 @@ async function cmdLauncher() {
6749
6746
  const useLegacyBanner = !!process.env.LAZYCLAW_LEGACY_MENU;
6750
6747
  const bannerRowsCached = useLegacyBanner
6751
6748
  ? _renderBanner(readVersionFromRepo())
6752
- : await _renderV5Banner(readVersionFromRepo());
6749
+ : _renderV5Banner(readVersionFromRepo());
6753
6750
  const draw = () => {
6754
6751
  process.stdout.write('\x1b[?25l\x1b[2J\x1b[H'); // hide cursor + clear
6755
6752
  bannerRowsCached.forEach((l) => process.stdout.write(l + '\n'));
package/mcp/client.mjs ADDED
@@ -0,0 +1,87 @@
1
+ // MCP client — spawn external MCP servers over stdio, list their tools,
2
+ // and register each one in mas/tools/registry.mjs with a stable prefix
3
+ // ("mcp:<server>:<tool>"). Defaults: sensitive=true (so the approve hook
4
+ // gates every external call), category="mcp:<server>". Per-server
5
+ // allowGlob narrows which tools are exposed.
6
+ //
7
+ // The real transport uses @modelcontextprotocol/sdk's StdioClientTransport;
8
+ // tests inject a fake transport via __setTransport so the spec can run
9
+ // without the binary.
10
+
11
+ import * as registry from '../mas/tools/registry.mjs';
12
+
13
+ let _transport = null;
14
+ export function __setTransport(t) { _transport = t; }
15
+
16
+ const SERVERS = new Map(); // name -> { client, tools }
17
+
18
+ function matchGlob(name, glob) {
19
+ if (!glob || glob === '*') return true;
20
+ const re = new RegExp('^' + glob.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$');
21
+ return re.test(name);
22
+ }
23
+
24
+ async function realTransport({ command, args, env }) {
25
+ const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
26
+ const { StdioClientTransport } = await import('@modelcontextprotocol/sdk/client/stdio.js');
27
+ const transport = new StdioClientTransport({ command, args: args || [], env });
28
+ const client = new Client({ name: 'lazyclaw', version: '5.0.0' }, { capabilities: {} });
29
+ await client.connect(transport);
30
+ return {
31
+ listTools: () => client.listTools(),
32
+ callTool: (req) => client.callTool(req),
33
+ close: () => client.close(),
34
+ };
35
+ }
36
+
37
+ export async function startServer({ name, command, args = [], env = {}, allowGlob = '*', sensitive = true } = {}) {
38
+ if (!name || !command) throw new Error('startServer: name + command required');
39
+ if (SERVERS.has(name)) throw new Error(`startServer: server "${name}" already running`);
40
+
41
+ const transport = _transport || { connect: realTransport };
42
+ const client = await transport.connect({ command, args, env });
43
+ const { tools = [] } = await client.listTools();
44
+
45
+ const registered = [];
46
+ for (const t of tools) {
47
+ if (!matchGlob(t.name, allowGlob)) continue;
48
+ const toolName = `mcp:${name}:${t.name}`;
49
+ const rec = {
50
+ name: toolName,
51
+ category: `mcp:${name}`,
52
+ sensitive,
53
+ description: t.description || `MCP tool ${t.name} on server ${name}`,
54
+ parameters: t.inputSchema || { type: 'object', properties: {} },
55
+ async exec(callArgs) {
56
+ try {
57
+ const res = await client.callTool({ name: t.name, arguments: callArgs || {} });
58
+ const text = (res?.content || [])
59
+ .filter(c => c.type === 'text')
60
+ .map(c => c.text)
61
+ .join('\n');
62
+ return { ok: true, text, raw: res };
63
+ } catch (e) {
64
+ return { ok: false, error: `${toolName}: ${e.message}` };
65
+ }
66
+ },
67
+ };
68
+ registry.register(rec);
69
+ registered.push(toolName);
70
+ }
71
+
72
+ SERVERS.set(name, { client, tools: registered });
73
+ return { ok: true, name, tools: registered };
74
+ }
75
+
76
+ export async function stopServer(name) {
77
+ const entry = SERVERS.get(name);
78
+ if (!entry) return { ok: false, error: `stopServer: ${name} not running` };
79
+ for (const toolName of entry.tools) registry.unregister(toolName);
80
+ try { await entry.client.close(); } catch { /* best-effort */ }
81
+ SERVERS.delete(name);
82
+ return { ok: true, name };
83
+ }
84
+
85
+ export function listServers() {
86
+ return [...SERVERS.entries()].map(([name, e]) => ({ name, toolCount: e.tools.length }));
87
+ }
@@ -0,0 +1,25 @@
1
+ // server_spawn — drive startServer from cfg.mcp.servers[] at daemon boot.
2
+ //
3
+ // cfg.mcp.servers = [
4
+ // { name: 'fs', command: 'npx', args: ['-y', '@modelcontextprotocol/server-filesystem', '/tmp'],
5
+ // allowGlob: 'read_*' },
6
+ // { name: 'git', command: 'mcp-git', args: [] },
7
+ // ]
8
+
9
+ import { startServer, stopServer, listServers } from './client.mjs';
10
+
11
+ export async function startConfigured(cfg) {
12
+ const servers = cfg?.mcp?.servers || [];
13
+ const results = [];
14
+ for (const s of servers) {
15
+ try { results.push(await startServer(s)); }
16
+ catch (e) { results.push({ ok: false, name: s.name, error: e.message }); }
17
+ }
18
+ return results;
19
+ }
20
+
21
+ export async function stopAll() {
22
+ for (const { name } of listServers()) {
23
+ try { await stopServer(name); } catch { /* best-effort */ }
24
+ }
25
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lazyclaw",
3
- "version": "5.0.1",
3
+ "version": "5.0.3",
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",
@@ -67,6 +67,8 @@
67
67
  "workflow/",
68
68
  "web/",
69
69
  "mas/",
70
+ "mcp/",
71
+ "tui/",
70
72
  "gateway/",
71
73
  "docs/multi-agent.md",
72
74
  "scripts/loop-worker.mjs",
@@ -0,0 +1,29 @@
1
+ // Hand-crafted 24x12 sleepy sloth icon — replaces the chafa-generated
2
+ // placeholder. Source image (docs/assets/sleepy-sloth-source.png) is a
3
+ // circle silhouette with no internal detail, so chafa --symbols=braille
4
+ // renders an oval-of-dots at this resolution. A hand drawing reads as
5
+ // a creature; the rasterised conversion did not.
6
+ //
7
+ // All rows are exactly 24 East-Asian-Width cells. Render width matches
8
+ // the splash gutter (GUTTER_WIDTH = 24 in tui/splash.mjs). Don't change
9
+ // row widths without updating tests/phaseC-build-splash.test.mjs (width
10
+ // strict-equals 24, height <= 12).
11
+ export const banner = {
12
+ rows: [
13
+ " ╭──╮ ╭──╮ ",
14
+ " ╭╯ ╰────╯ ╰╮ ",
15
+ " │ ─ ─ │ ",
16
+ " │ │ ",
17
+ " │ ‿‿‿ │ ",
18
+ " ╰╮ ╭╯ ",
19
+ " ╰─┬─────┬─╯ ",
20
+ " │ │ ",
21
+ " ╱│ z │╲ ",
22
+ " ╱ │ z │ ╲ ",
23
+ " ✦ ╰─────╯ ✦ ",
24
+ " lazyclaw "
25
+ ],
26
+ width: 24,
27
+ height: 12,
28
+ fg: "#FFB347"
29
+ };
package/tui/editor.mjs ADDED
@@ -0,0 +1,82 @@
1
+ // tui/editor.mjs — multiline input state machine (spec §5.8).
2
+ //
3
+ // Pure-functional core (makeEditorState, applyKey) so it is testable
4
+ // without ink stdin. The React component <Editor/> wraps useInput().
5
+ import React, { useState, useEffect } from 'react';
6
+ import { Box, Text, useInput } from 'ink';
7
+ import { theme } from './theme.mjs';
8
+
9
+ export function makeEditorState({ history = [] } = {}) {
10
+ return {
11
+ buffer: '',
12
+ cursor: 0,
13
+ historyIdx: history.length,
14
+ history,
15
+ lastSubmit: null,
16
+ lastWasPaste: false,
17
+ };
18
+ }
19
+
20
+ export function applyKey(state, evt) {
21
+ const { input = '', key = {}, paste = false } = evt;
22
+ const next = { ...state, lastSubmit: null, lastWasPaste: false };
23
+
24
+ if (key.return && key.shift) {
25
+ next.buffer = state.buffer + '\n';
26
+ next.cursor = next.buffer.length;
27
+ return next;
28
+ }
29
+ if (key.return) {
30
+ next.lastSubmit = state.buffer;
31
+ next.buffer = '';
32
+ next.cursor = 0;
33
+ next.historyIdx = state.history.length;
34
+ return next;
35
+ }
36
+ if (key.upArrow) {
37
+ const idx = Math.max(0, state.historyIdx - 1);
38
+ if (state.history[idx] !== undefined) {
39
+ next.historyIdx = idx;
40
+ next.buffer = state.history[idx];
41
+ next.cursor = next.buffer.length;
42
+ }
43
+ return next;
44
+ }
45
+ if (key.downArrow) {
46
+ const idx = Math.min(state.history.length, state.historyIdx + 1);
47
+ next.historyIdx = idx;
48
+ next.buffer = state.history[idx] !== undefined ? state.history[idx] : '';
49
+ next.cursor = next.buffer.length;
50
+ return next;
51
+ }
52
+ if (key.backspace || key.delete) {
53
+ next.buffer = state.buffer.slice(0, -1);
54
+ next.cursor = next.buffer.length;
55
+ return next;
56
+ }
57
+ if (input) {
58
+ next.buffer = state.buffer + input;
59
+ next.cursor = next.buffer.length;
60
+ next.lastWasPaste = paste || input.length >= 16;
61
+ return next;
62
+ }
63
+ return next;
64
+ }
65
+
66
+ export function Editor({ history, onSubmit }) {
67
+ const [state, setState] = useState(() => makeEditorState({ history }));
68
+ useInput((input, key) => {
69
+ const next = applyKey(state, { input, key });
70
+ setState(next);
71
+ });
72
+ useEffect(() => {
73
+ if (state.lastSubmit !== null && onSubmit) onSubmit(state.lastSubmit);
74
+ }, [state.lastSubmit]);
75
+
76
+ const lines = state.buffer.split('\n');
77
+ return React.createElement(
78
+ Box,
79
+ { flexDirection: 'column' },
80
+ lines.map((ln, i) => React.createElement(Text, { key: i }, i === 0 ? theme.accent('› ') + ln : ' ' + ln))
81
+ );
82
+ }
package/tui/ghost.mjs ADDED
@@ -0,0 +1,42 @@
1
+ // tui/ghost.mjs — ink port of the v4 readline ghost autocomplete
2
+ // (cli.mjs:1388-1500). Pure functions; the React surface is owned
3
+ // by tui/repl.mjs which renders the suggestion dim-styled.
4
+ import { theme } from './theme.mjs';
5
+
6
+ function matches(prefix, cmds) {
7
+ return cmds.filter((c) => c.startsWith(prefix) && c !== prefix);
8
+ }
9
+
10
+ export function computeGhost(buffer, cmds) {
11
+ if (!buffer.startsWith('/')) return null;
12
+ const ms = matches(buffer, cmds);
13
+ if (ms.length === 0) return null;
14
+ const suggestion = ms[0];
15
+ return {
16
+ suggestion,
17
+ suffix: suggestion.slice(buffer.length),
18
+ candidates: ms,
19
+ idx: 0,
20
+ };
21
+ }
22
+
23
+ export function cycleGhost(ghost, cmds) {
24
+ if (!ghost || !ghost.candidates || ghost.candidates.length === 0) return ghost;
25
+ const next = (ghost.idx + 1) % ghost.candidates.length;
26
+ const suggestion = ghost.candidates[next];
27
+ return {
28
+ suggestion,
29
+ suffix: suggestion.slice(suggestion.length - (suggestion.length - ghost.candidates[ghost.idx].length + ghost.suffix.length)),
30
+ candidates: ghost.candidates,
31
+ idx: next,
32
+ };
33
+ }
34
+
35
+ export function acceptGhost(buffer, ghost) {
36
+ if (!ghost) return buffer;
37
+ return ghost.suggestion;
38
+ }
39
+
40
+ export function ghostStyle(suffix) {
41
+ return theme.dim(suffix);
42
+ }
package/tui/repl.mjs ADDED
@@ -0,0 +1,89 @@
1
+ // tui/repl.mjs — REPL host with mid-stream interrupt-and-redirect
2
+ // (spec §5.8). Pure state functions for testability; the React
3
+ // mount lives at the bottom and is exercised only when stdin isTTY.
4
+ import React, { useState, useEffect } from 'react';
5
+ import { Box, useApp } from 'ink';
6
+ import { Splash } from './splash.mjs';
7
+ import { Editor } from './editor.mjs';
8
+
9
+ export function makeReplState() {
10
+ return {
11
+ streaming: false,
12
+ controller: null,
13
+ pendingPrepend: null,
14
+ nextTurnFirstMessage: null,
15
+ history: [],
16
+ };
17
+ }
18
+
19
+ export function onUserInput(state, { text, controller }) {
20
+ if (state.streaming && state.controller) {
21
+ // mid-stream interrupt — abort current turn, queue text for next turn.
22
+ try { state.controller.abort(); } catch {}
23
+ return { ...state, pendingPrepend: text };
24
+ }
25
+ // idle — start a new turn.
26
+ return {
27
+ ...state,
28
+ streaming: true,
29
+ controller,
30
+ history: [...state.history, text],
31
+ };
32
+ }
33
+
34
+ export function onEscape(state) {
35
+ if (state.streaming && state.controller) {
36
+ try { state.controller.abort(); } catch {}
37
+ }
38
+ return { ...state, streaming: false, controller: null, pendingPrepend: null };
39
+ }
40
+
41
+ export function onTurnComplete(state, { reason } = {}) {
42
+ void reason;
43
+ const promoted = state.pendingPrepend;
44
+ return {
45
+ ...state,
46
+ streaming: false,
47
+ controller: null,
48
+ pendingPrepend: null,
49
+ nextTurnFirstMessage: promoted,
50
+ };
51
+ }
52
+
53
+ export function consumeNextTurnFirstMessage(state) {
54
+ const msg = state.nextTurnFirstMessage;
55
+ return [{ ...state, nextTurnFirstMessage: null }, msg];
56
+ }
57
+
58
+ // ─── React mount ─────────────────────────────────────────────────────────
59
+ export function ReplApp({ splashProps, runTurn }) {
60
+ const [state, setState] = useState(makeReplState);
61
+ const { exit } = useApp();
62
+
63
+ async function handleSubmit(text) {
64
+ if (text === '/exit') { exit(); return; }
65
+ const controller = new AbortController();
66
+ setState((s) => onUserInput(s, { text, controller }));
67
+ try {
68
+ await runTurn(text, controller.signal);
69
+ setState((s) => onTurnComplete(s, { reason: 'done' }));
70
+ } catch (err) {
71
+ setState((s) => onTurnComplete(s, { reason: err.name === 'AbortError' ? 'aborted' : 'error' }));
72
+ }
73
+ }
74
+
75
+ useEffect(() => {
76
+ const [next, msg] = consumeNextTurnFirstMessage(state);
77
+ if (msg) {
78
+ setState(next);
79
+ handleSubmit(msg);
80
+ }
81
+ }, [state.nextTurnFirstMessage]);
82
+
83
+ return React.createElement(
84
+ Box,
85
+ { flexDirection: 'column' },
86
+ React.createElement(Splash, splashProps),
87
+ React.createElement(Editor, { history: state.history, onSubmit: handleSubmit })
88
+ );
89
+ }
package/tui/splash.mjs ADDED
@@ -0,0 +1,107 @@
1
+ // tui/splash.mjs — two-column launch splash (spec §5.1).
2
+ //
3
+ // Public surface:
4
+ // - <Splash {...props} /> ink component for live REPL mount
5
+ // - renderSplashToString(props) pure string builder used by tests
6
+ // and by the non-TTY path.
7
+ //
8
+ // Layout: 24-cell sloth gutter (cols 0-23) | 2-cell separator (24-25)
9
+ // | 52-cell right column (cols 26-77) | 2-cell right padding.
10
+ // Footer: exactly 4 lines, blank row separates body from footer.
11
+ import React from 'react';
12
+ import { Box, Text } from 'ink';
13
+ import stringWidth from 'string-width';
14
+ import { theme } from './theme.mjs';
15
+ import { banner } from './banner.generated.mjs';
16
+
17
+ const RIGHT_COL_WIDTH = 52;
18
+ const GUTTER_WIDTH = 24;
19
+
20
+ function fit(text, max) {
21
+ if (stringWidth(text) <= max) return text.padEnd(max);
22
+ let lo = 0, hi = text.length;
23
+ while (lo < hi) {
24
+ const mid = (lo + hi + 1) >> 1;
25
+ if (stringWidth(text.slice(0, mid) + '…') <= max) lo = mid;
26
+ else hi = mid - 1;
27
+ }
28
+ return (text.slice(0, lo) + '…').padEnd(max);
29
+ }
30
+
31
+ function shortCwd(cwd) {
32
+ const home = process.env.HOME || '';
33
+ if (home && cwd.startsWith(home)) return '~' + cwd.slice(home.length);
34
+ return cwd;
35
+ }
36
+
37
+ function toolRow({ category, sensitive, verbs }) {
38
+ const label = sensitive ? `(sensitive) ${category}` : category;
39
+ const labelWidth = sensitive ? 20 : 9;
40
+ const tail = verbs.slice(0, 6).join(' · ');
41
+ const more = verbs.length > 6 ? ` (${verbs.length - 6} more)` : '';
42
+ return `${fit(label, labelWidth)}${tail}${more}`;
43
+ }
44
+
45
+ function skillRow({ group, names }) {
46
+ const tail = names.slice(0, 6).join(' · ');
47
+ const more = names.length > 6 ? ` (${names.length - 6} more)` : '';
48
+ return `${fit(group, 9)}${tail}${more}`;
49
+ }
50
+
51
+ function buildBody(props) {
52
+ const { tools = [], skills = [] } = props;
53
+ const right = [];
54
+ right.push('Available Tools');
55
+ right.push('─'.repeat(45));
56
+ for (const t of tools.slice(0, 8)) right.push(toolRow(t));
57
+ if (tools.length > 8) right.push(`... and ${tools.length - 8} more tool groups`);
58
+ right.push('');
59
+ right.push('Available Skills');
60
+ right.push('─'.repeat(45));
61
+ for (const s of skills.slice(0, 8)) right.push(skillRow(s));
62
+ if (skills.length > 8) right.push(`... and ${skills.length - 8} more skill groups`);
63
+
64
+ const left = banner.rows.slice();
65
+ while (left.length < right.length) left.push('');
66
+ while (right.length < left.length) right.push('');
67
+
68
+ const lines = [];
69
+ for (let i = 0; i < left.length; i++) {
70
+ const lhs = fit(left[i], GUTTER_WIDTH);
71
+ const rhs = fit(right[i], RIGHT_COL_WIDTH);
72
+ lines.push(` ${lhs} ${rhs}`);
73
+ }
74
+ return lines;
75
+ }
76
+
77
+ function buildFooter(props) {
78
+ const { provider, model, trainer = {}, sessionId, cwd } = props;
79
+ const tProv = trainer.provider || provider;
80
+ const tModel = trainer.model || model;
81
+ const sid = (sessionId || '').slice(0, 8);
82
+ const cwdShort = shortCwd(cwd || process.cwd());
83
+ return [
84
+ fit(` provider · ${provider} · ${model}`, 56) + fit(`cwd · ${cwdShort}`, 22),
85
+ fit(` trainer · ${tProv} · ${tModel} session ${sid}`, 78),
86
+ fit(' slash · /help · /model · /trainer · /skills · /tools · /exit', 78),
87
+ fit(' hint · Shift+Enter newline · Ctrl-R recall · Esc interrupt', 78),
88
+ ];
89
+ }
90
+
91
+ export function renderSplashToString(props, { columns = 80 } = {}) {
92
+ void columns; // currently fixed-width at 80; <72 fallback is in cli.mjs
93
+ const body = buildBody(props);
94
+ const footer = buildFooter(props);
95
+ return [...body, '', ...footer].join('\n');
96
+ }
97
+
98
+ export function Splash(props) {
99
+ const lines = renderSplashToString(props).split('\n');
100
+ return React.createElement(
101
+ Box,
102
+ { flexDirection: 'column' },
103
+ lines.map((line, i) =>
104
+ React.createElement(Text, { key: i, color: i < banner.height ? theme.fg : undefined }, line)
105
+ )
106
+ );
107
+ }
package/tui/theme.mjs ADDED
@@ -0,0 +1,39 @@
1
+ // tui/theme.mjs — single source of truth for lazyclaw v5 color tokens.
2
+ // The amber hex is also stamped into tui/banner.generated.mjs so the
3
+ // sloth gutter and the prompt accent stay visually paired.
4
+ import chalk from 'chalk';
5
+
6
+ const AMBER_HEX = '#FFB347';
7
+
8
+ function amber(text) {
9
+ return chalk.hex(AMBER_HEX)(text);
10
+ }
11
+
12
+ function dim(text) {
13
+ return chalk.dim(text);
14
+ }
15
+
16
+ function accent(text) {
17
+ return chalk.bold.hex(AMBER_HEX)(text);
18
+ }
19
+
20
+ function muted(text) {
21
+ return chalk.gray(text);
22
+ }
23
+
24
+ function plain(text) {
25
+ return text;
26
+ }
27
+
28
+ export const theme = {
29
+ amber: AMBER_HEX,
30
+ fg: AMBER_HEX,
31
+ colorize: amber,
32
+ dim,
33
+ accent,
34
+ muted,
35
+ plain,
36
+ };
37
+
38
+ // Compatibility: some callers want the colorizer when reading `theme.amber`.
39
+ // Keep the hex on the property for builders, and expose `colorize` for runtime use.