lazyclaw 5.0.9 → 5.2.0

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.
@@ -51,6 +51,13 @@ export async function callOnce({
51
51
  baseUrl,
52
52
  fetchImpl,
53
53
  signal,
54
+ // Group B / C9 — prompt caching. Opt-in; the existing phase 12b/24
55
+ // test surface asserts the no-cache byte shape so this defaults off.
56
+ // Production call sites (agent_turn, mention_router) pass `cache:true`
57
+ // so every real Messages API call from the tool-use path gets a
58
+ // cache_control:ephemeral block on the static system prefix AND on
59
+ // the last tool object (so the tool schema cache also benefits).
60
+ cache = false,
54
61
  } = {}) {
55
62
  if (!Array.isArray(messages) || messages.length === 0) {
56
63
  throw new AnthropicToolUseError('messages[] is required and non-empty', 'NO_MESSAGES');
@@ -65,16 +72,45 @@ export async function callOnce({
65
72
  max_tokens: maxTokens,
66
73
  messages,
67
74
  };
68
- if (system && String(system).trim()) body.system = String(system);
69
- if (tools && tools.length) body.tools = tools;
75
+ if (system && String(system).trim()) {
76
+ if (cache) {
77
+ // Lift the system string into a one-block array carrying
78
+ // cache_control. The Anthropic API accepts either a plain string
79
+ // OR an array of text blocks here; we use the array form so we
80
+ // can attach the breakpoint marker.
81
+ body.system = [{ type: 'text', text: String(system), cache_control: { type: 'ephemeral' } }];
82
+ } else {
83
+ body.system = String(system);
84
+ }
85
+ }
86
+ if (tools && tools.length) {
87
+ if (cache) {
88
+ // Clone the array (don't mutate the caller's reference) and
89
+ // attach cache_control to the LAST tool — that marks the entire
90
+ // tool block as cacheable, per Anthropic's spec.
91
+ const cloned = tools.map((t, i) => (i === tools.length - 1
92
+ ? { ...t, cache_control: { type: 'ephemeral' } }
93
+ : t));
94
+ body.tools = cloned;
95
+ } else {
96
+ body.tools = tools;
97
+ }
98
+ }
99
+
100
+ const headers = {
101
+ 'Content-Type': 'application/json',
102
+ 'x-api-key': apiKey,
103
+ 'anthropic-version': ANTHROPIC_VERSION,
104
+ };
105
+ if (cache) {
106
+ // The prompt-caching beta header. Required only on early adoption;
107
+ // safe to include on newer API versions where it's a no-op.
108
+ headers['anthropic-beta'] = 'prompt-caching-2024-07-31';
109
+ }
70
110
 
71
111
  const res = await fetchFn(url, {
72
112
  method: 'POST',
73
- headers: {
74
- 'Content-Type': 'application/json',
75
- 'x-api-key': apiKey,
76
- 'anthropic-version': ANTHROPIC_VERSION,
77
- },
113
+ headers,
78
114
  body: JSON.stringify(body),
79
115
  signal,
80
116
  });
package/skills.mjs CHANGED
@@ -15,6 +15,22 @@ import os from 'node:os';
15
15
  const SKILLS_DIRNAME = 'skills';
16
16
  const SKILL_EXT = '.md';
17
17
 
18
+ // Group B / M8 — memoize listSkills() + skillsIndex() per configDir.
19
+ // composePromptStack is called on every chat/agent turn; listing the
20
+ // skills directory + statting + reading every file used to cost ~2-5 ms
21
+ // at ~30 skills, and was the largest non-network item in the per-turn
22
+ // flame graph. Cache key is the skills/ directory's mtimeMs — any
23
+ // install/remove via this module bumps it (writeFile updates the
24
+ // containing dir's mtime). Manual edits with `vim` also bust the cache
25
+ // because writing a file in a directory updates that dir's mtime.
26
+ // _invalidateSkillsCache() is exported as the safety hatch for callers
27
+ // that want to be explicit.
28
+ const _indexCache = new Map(); // cfgDir → { mtimeMs, skills, index }
29
+
30
+ export function _invalidateSkillsCache(configDir = defaultConfigDir()) {
31
+ _indexCache.delete(configDir);
32
+ }
33
+
18
34
  export function defaultConfigDir() {
19
35
  return process.env.LAZYCLAW_CONFIG_DIR || path.join(os.homedir(), '.lazyclaw');
20
36
  }
@@ -33,7 +49,14 @@ export function skillPath(name, configDir = defaultConfigDir()) {
33
49
  export function listSkills(configDir = defaultConfigDir()) {
34
50
  const dir = skillsDir(configDir);
35
51
  if (!fs.existsSync(dir)) return [];
36
- return fs.readdirSync(dir)
52
+ // M8 — cache hit when the skills/ directory's mtime is unchanged.
53
+ let dirMtime = 0;
54
+ try { dirMtime = fs.statSync(dir).mtimeMs; } catch { /* unreadable → bypass cache */ }
55
+ const cached = _indexCache.get(configDir);
56
+ if (cached && cached.mtimeMs === dirMtime && dirMtime > 0) {
57
+ return cached.skills;
58
+ }
59
+ const skills = fs.readdirSync(dir)
37
60
  .filter(name => name.endsWith(SKILL_EXT))
38
61
  .map(name => {
39
62
  const full = path.join(dir, name);
@@ -57,6 +80,10 @@ export function listSkills(configDir = defaultConfigDir()) {
57
80
  };
58
81
  })
59
82
  .sort((a, b) => a.name.localeCompare(b.name));
83
+ if (dirMtime > 0) {
84
+ _indexCache.set(configDir, { mtimeMs: dirMtime, skills, index: null });
85
+ }
86
+ return skills;
60
87
  }
61
88
 
62
89
  // Parse a leading YAML frontmatter block (--- … ---). Only the flat
@@ -108,9 +135,19 @@ function firstHeading(body) {
108
135
  // paying for their full bodies (progressive disclosure — the model
109
136
  // pulls a full skill on demand via the skill_view tool).
110
137
  export function skillsIndex(configDir = defaultConfigDir()) {
138
+ // Memoize the rendered index alongside the cached skills array — both
139
+ // are pure functions of the skills/ mtime.
140
+ const cached = _indexCache.get(configDir);
141
+ if (cached && cached.index !== null) return cached.index;
111
142
  const skills = listSkills(configDir);
112
- if (!skills.length) return '';
113
- return skills.map((s) => `- ${s.name}: ${s.summary}`.trimEnd()).join('\n');
143
+ if (!skills.length) {
144
+ if (cached) cached.index = '';
145
+ return '';
146
+ }
147
+ const index = skills.map((s) => `- ${s.name}: ${s.summary}`.trimEnd()).join('\n');
148
+ const refreshed = _indexCache.get(configDir);
149
+ if (refreshed) refreshed.index = index;
150
+ return index;
114
151
  }
115
152
 
116
153
  export function loadSkill(name, configDir = defaultConfigDir()) {
@@ -123,12 +160,14 @@ export function installSkill(name, content, configDir = defaultConfigDir()) {
123
160
  const p = skillPath(name, configDir);
124
161
  fs.mkdirSync(path.dirname(p), { recursive: true });
125
162
  fs.writeFileSync(p, content);
163
+ _invalidateSkillsCache(configDir);
126
164
  return p;
127
165
  }
128
166
 
129
167
  export function removeSkill(name, configDir = defaultConfigDir()) {
130
168
  const p = skillPath(name, configDir);
131
169
  if (fs.existsSync(p)) fs.unlinkSync(p);
170
+ _invalidateSkillsCache(configDir);
132
171
  }
133
172
 
134
173
  export function skillExists(name, configDir = defaultConfigDir()) {
package/tasks.mjs CHANGED
@@ -157,7 +157,30 @@ export function appendTurn(id, turn, configDir = defaultConfigDir()) {
157
157
  const t = getTask(id, configDir);
158
158
  if (!t) throw new TaskError(`no task "${id}"`, 'TASK_NO_TASK');
159
159
  const turns = Array.isArray(t.turns) ? [...t.turns, turn] : [turn];
160
- return patchTask(id, { turns }, configDir);
160
+ const next = patchTask(id, { turns }, configDir);
161
+ // v5 Group A (M4): mirror the appended turn to the FTS5 sessions
162
+ // index using session_id = `task:<id>` so the recall tool can surface
163
+ // task transcripts the same way it surfaces chat sessions. Namespaced
164
+ // with the `task:` prefix to avoid colliding with chat session ids.
165
+ // Best-effort: any FTS failure stays inside the dynamic-import block
166
+ // so a missing index_db (e.g. in a stripped test env) never breaks
167
+ // task writes.
168
+ try {
169
+ void (async () => {
170
+ try {
171
+ const { indexSessionTurn } = await import('./mas/index_db.mjs');
172
+ const turnIdx = turns.length - 1;
173
+ indexSessionTurn({
174
+ session_id: `task:${id}`,
175
+ turn_idx: turnIdx,
176
+ role: turn.agent === 'user' ? 'user' : 'assistant',
177
+ ts: Date.parse(turn.ts) || Date.now(),
178
+ content: turn.text || '',
179
+ }, configDir);
180
+ } catch { /* swallow */ }
181
+ })();
182
+ } catch { /* swallow */ }
183
+ return next;
161
184
  }
162
185
 
163
186
  export function removeTask(id, configDir = defaultConfigDir()) {
package/tui/repl.mjs CHANGED
@@ -56,6 +56,12 @@ export function consumeNextTurnFirstMessage(state) {
56
56
  }
57
57
 
58
58
  // ─── React mount ─────────────────────────────────────────────────────────
59
+ // v5.1 TODO (C7 follow-up): replace direct stdout writes in cli.mjs's
60
+ // _chatRunTurnFactory with a scrollback ref'd via this component. The
61
+ // shape would be an `onOutput` prop that pushes chunks into a `useState`
62
+ // array rendered through Ink's <Static items={scrollback}/>. v5.0.10
63
+ // ships with raw stdout writes interleaved with Ink (acceptable visual
64
+ // jank in exchange for unblocking the chat loop).
59
65
  export function ReplApp({ splashProps, runTurn }) {
60
66
  const [state, setState] = useState(makeReplState);
61
67
  const { exit } = useApp();
@@ -0,0 +1,138 @@
1
+ // tui/run_turn.mjs — shared chat-turn streaming closure (v5 Group C, C7).
2
+ //
3
+ // Single source of truth for the streaming + persist + post-task
4
+ // learning loop. The same factory backs:
5
+ // - the ink REPL path (ReplApp.runTurn)
6
+ // - the legacy readline path (cli.mjs's `for await (const line of rl)` body)
7
+ //
8
+ // One factory ⇒ one set of bugs. Both call sites get the same buffered
9
+ // writer (CJK-safe 30 ms coalescing), the same persistTurn dual-write
10
+ // (sessions + memory/recent), and the same fire-and-forget learning
11
+ // hook (queueMicrotask).
12
+ //
13
+ // `ctx` carries the chat-session state. Getter functions close over
14
+ // *current* bindings so a mid-session /provider switch takes effect on
15
+ // the very next turn without re-creating the closure.
16
+ //
17
+ // `writeFn` is the sink for streamed chunks — the legacy path passes
18
+ // `(s) => process.stdout.write(s)`; the ink path also writes to stdout
19
+ // for v5.0.10 (interleaves with Ink output; visual jank accepted in
20
+ // exchange for unblocking the chat loop). v5.1 TODO: route the ink
21
+ // writeFn through a scrollback ref in ReplApp.
22
+ //
23
+ // Errors are swallowed (matches v4 legacy behavior) — we write
24
+ // "error: ..." into writeFn but do not re-throw, so the caller's
25
+ // turn-completion logic (ReplApp onTurnComplete, legacy `rl.prompt`)
26
+ // runs unconditionally.
27
+
28
+ /**
29
+ * @typedef {Object} RunTurnCtx
30
+ * @property {Object} cfg Active config.
31
+ * @property {string} cfgDir Resolved configDir.
32
+ * @property {Object|null} sandboxSpec Parsed --sandbox spec (or null).
33
+ * @property {string} syntheticChatSessionId Session-id fallback when --session not set.
34
+ * @property {() => Array<{role:string,content:string}>} getMessages
35
+ * @property {() => { sendMessage: Function, name?: string }} getProv
36
+ * @property {() => string} getActiveProvName
37
+ * @property {() => string|null} getActiveModel
38
+ * @property {() => string|null} getSessionId
39
+ * @property {(role: string, content: string) => void} persistTurn
40
+ * @property {(u: any) => void} accumulateUsage
41
+ * @property {(provider: string) => string} resolveAuthKey Caller supplies; mirrors cli.mjs::_resolveAuthKey.
42
+ * @property {(n: number) => void} [onCharsSent] Optional observer (legacy path increments charsSent).
43
+ */
44
+
45
+ /**
46
+ * Build a runTurn(text, signal) closure that drives one provider turn
47
+ * end-to-end.
48
+ *
49
+ * @param {{ ctx: RunTurnCtx, writeFn: (chunk: string) => void }} args
50
+ * @returns {(text: string, signal?: AbortSignal) => Promise<void>}
51
+ */
52
+ export function makeRunTurn({ ctx, writeFn }) {
53
+ return async function runTurn(text, signal) {
54
+ if (signal?.aborted) return;
55
+ const messages = ctx.getMessages();
56
+ messages.push({ role: 'user', content: text });
57
+ try { ctx.onCharsSent && ctx.onCharsSent(text.length); }
58
+ catch { /* observer is best-effort */ }
59
+ ctx.persistTurn('user', text);
60
+
61
+ let acc = '';
62
+ let _writeBuf = '';
63
+ let _writeTimer = null;
64
+ const _flush = () => {
65
+ if (_writeBuf) {
66
+ try { writeFn(_writeBuf); }
67
+ catch { /* sink failure must not kill the turn */ }
68
+ _writeBuf = '';
69
+ }
70
+ _writeTimer = null;
71
+ };
72
+ const _writeChunk = (s) => {
73
+ _writeBuf += s;
74
+ if (!_writeTimer) _writeTimer = setTimeout(_flush, 30);
75
+ };
76
+
77
+ try {
78
+ const sysMsg = messages.find((m) => m.role === 'system');
79
+ const prov = ctx.getProv();
80
+ const activeProvName = ctx.getActiveProvName();
81
+ const activeModel = ctx.getActiveModel();
82
+ // C8 — prompt-cache the static system prefix. The Anthropic
83
+ // provider prefers `systemStatic` when present; non-Anthropic
84
+ // providers ignore the field and fall back to the legacy
85
+ // single-block path with `cache:true`.
86
+ for await (const chunk of prov.sendMessage(messages, {
87
+ apiKey: ctx.resolveAuthKey(activeProvName),
88
+ model: activeModel,
89
+ sandbox: ctx.sandboxSpec,
90
+ signal,
91
+ onUsage: ctx.accumulateUsage,
92
+ cache: true,
93
+ ...(sysMsg ? { systemStatic: sysMsg.content } : {}),
94
+ })) {
95
+ _writeChunk(chunk);
96
+ acc += chunk;
97
+ }
98
+ if (_writeTimer) clearTimeout(_writeTimer);
99
+ _flush();
100
+ try { writeFn('\n'); }
101
+ catch { /* sink failure must not kill the turn */ }
102
+ messages.push({ role: 'assistant', content: acc });
103
+ ctx.persistTurn('assistant', acc);
104
+ // v5 Group A (C1): close the post-task learning loop on every
105
+ // successful chat turn. Fire-and-forget via queueMicrotask so the
106
+ // next prompt is never blocked on trajectory write / synth.
107
+ try {
108
+ queueMicrotask(() => {
109
+ import('../mas/learning.mjs').then((mod) => mod.runLearning('post-task', {
110
+ agent: { name: 'chat', provider: activeProvName, model: activeModel, role: '' },
111
+ task: {
112
+ id: ctx.getSessionId() || ctx.syntheticChatSessionId,
113
+ title: '(chat turn)',
114
+ turns: messages.slice(-2).map((m) => ({
115
+ agent: m.role === 'user' ? 'user' : 'chat',
116
+ text: m.content,
117
+ ts: new Date().toISOString(),
118
+ })),
119
+ },
120
+ configDir: ctx.cfgDir,
121
+ cfg: ctx.cfg,
122
+ transcript: acc.slice(0, 8000),
123
+ })).catch(() => { /* learning loop is best-effort */ });
124
+ });
125
+ } catch { /* never let learning hook break the chat */ }
126
+ } catch (err) {
127
+ if (_writeTimer) clearTimeout(_writeTimer);
128
+ _flush();
129
+ // ABORT errors are user-initiated; drop the partial reply (don't
130
+ // push an incomplete assistant message — next turn would treat
131
+ // it as a complete reply and confuse the model).
132
+ if (err?.code !== 'ABORT' && !signal?.aborted) {
133
+ try { writeFn(`error: ${err?.message || String(err)}\n`); }
134
+ catch { /* sink failure must not mask err */ }
135
+ }
136
+ }
137
+ };
138
+ }
package/tui/splash.mjs CHANGED
@@ -1,29 +1,12 @@
1
1
  // tui/splash.mjs — Hermes-style hero splash with gradient wordmark,
2
2
  // subcommand catalog, tool registry, skill index, and a bottom status bar.
3
3
  //
4
- // Layout (terminal-width responsive):
4
+ // Layout (terminal-width responsive across four tiers):
5
5
  //
6
- // LAZYCLAW wordmark (top, per-row gradient orange)
7
- //
8
- // ╭─ lazyclaw vX.Y.Z · trainer-split · FTS5 recall · 6-backend sandbox ─╮
9
- // [sloth braille] Subcommands │
10
- // │ core / workflow / config / state / ... │
11
- // │ Available Tools │
12
- // │ fs / exec / git / ... │
13
- // │ Available Skills │
14
- // │ N subcommands · M tool groups · K skills · /help │
15
- // ╰──────────────────────────────────────────────────────────────────────╯
16
- //
17
- // provider · X · Y · trainer · A · B
18
- // /cwd
19
- // Session: ...
20
- //
21
- // Welcome to lazyclaw. Type your message or /help for commands.
22
- // + Tip: ...
23
- //
24
- // ───────────────────────────────────────────────────────────────────────
25
- // provider · model | ctx -- | [progress] | 0s
26
- // ───────────────────────────────────────────────────────────────────────
6
+ // WIDE (cols >= 140) — full wordmark + panel + sloth + 2-col right side
7
+ // MEDIUM ( 90 <= cols < 140) — compact headline, panel + sloth + wrapped right column
8
+ // NARROW ( 60 <= cols < 90) single column, no sloth/panel, truncated verb lists
9
+ // MINIMAL (cols < 60) — headline + provider + cwd + /help line only
27
10
  import React from 'react';
28
11
  import { Box, Text } from 'ink';
29
12
  import stringWidth from 'string-width';
@@ -34,6 +17,14 @@ import { wordmark } from './wordmark.mjs';
34
17
  const LMARGIN = ' ';
35
18
  const TITLE = ' trainer-split · FTS5 recall · 6-backend sandbox ';
36
19
 
20
+ // Tier breakpoints. Wordmark is 120 cols wide + LMARGIN(2)*2 = 124 minimum;
21
+ // the user constraint pins WIDE at >=140 to give comfortable slack. Below
22
+ // 90 the sloth (48 cols) leaves <40 cols for the right column, so we drop
23
+ // it and go single-column. Below 60 we emit only a minimal headline.
24
+ const WORDMARK_BREAKPOINT = 140; // drop wordmark below this
25
+ const PANEL_BREAKPOINT = 90; // drop sloth+panel below this
26
+ const MINIMAL_BREAKPOINT = 60; // drop everything but headline below this
27
+
37
28
  // Subcommand catalog — grouped for the splash so a new user sees the
38
29
  // surface area at a glance. Mirrors SUBCOMMANDS in cli.mjs.
39
30
  export const SUBCOMMAND_GROUPS = [
@@ -79,10 +70,48 @@ function subcommandRow([label, verbs]) {
79
70
  return `${label.padEnd(12)} ${verbs.join(' · ')}`;
80
71
  }
81
72
 
82
- export function renderSplashToString(props, opts = {}) {
83
- const cols = opts.columns || process.stdout.columns || 100;
84
- const TERM = Math.max(80, cols);
85
- const PANEL_W = TERM - LMARGIN.length * 2;
73
+ // Wrap a labeled verb list onto multiple rows when it would overflow maxWidth.
74
+ // First row: '<label.padEnd(12)> verb · verb'; continuations: ' verb · verb'.
75
+ function wrapVerbs(label, verbs, maxWidth) {
76
+ const pad = ' '.repeat(13); // label.padEnd(12) + ' ' = 13 cells
77
+ const rows = [];
78
+ let current = label.padEnd(12) + ' ';
79
+ let firstOnRow = true;
80
+ for (const v of verbs) {
81
+ const candidate = firstOnRow ? current + v : current + ' · ' + v;
82
+ if (stringWidth(candidate) > maxWidth) {
83
+ if (firstOnRow) {
84
+ // even a single verb overflows — emit it anyway (truncated) to make progress.
85
+ rows.push(fit(candidate, maxWidth).trimEnd());
86
+ current = pad;
87
+ firstOnRow = true;
88
+ } else {
89
+ rows.push(current.trimEnd());
90
+ current = pad + v;
91
+ firstOnRow = false;
92
+ }
93
+ } else {
94
+ current = candidate;
95
+ firstOnRow = false;
96
+ }
97
+ }
98
+ if (current.trim()) rows.push(current.trimEnd());
99
+ return rows;
100
+ }
101
+
102
+ // Crush-style truncation for NARROW tier — take first N verbs, append '…' if more.
103
+ function truncateRow(label, verbs, maxWidth, take = 3) {
104
+ const head = label.padEnd(12) + ' ';
105
+ const tail = verbs.slice(0, take).join(' · ');
106
+ let line = head + tail;
107
+ if (verbs.length > take) line += ' …';
108
+ if (stringWidth(line) <= maxWidth) return line;
109
+ return fit(line, maxWidth).trimEnd();
110
+ }
111
+
112
+ // Wide tier — original v5.0.9 layout, kept verbatim.
113
+ function renderWide(props, cols) {
114
+ const PANEL_W = cols - LMARGIN.length * 2;
86
115
  const INNER = PANEL_W - 4;
87
116
  const SLOTH_W = banner.width;
88
117
  const RIGHT_W = Math.max(40, INNER - SLOTH_W - 2);
@@ -99,7 +128,7 @@ export function renderSplashToString(props, opts = {}) {
99
128
  const dashRight = '─'.repeat(Math.max(2, PANEL_W - 2 - dashLeft.length - stringWidth(versionLabel)));
100
129
  lines.push(`${LMARGIN}╭${dashLeft}${versionLabel}${dashRight}╮`);
101
130
 
102
- // 3) right column content (subcommands + tools + skills)
131
+ // 3) right column content
103
132
  const { tools = [], skills = [] } = props;
104
133
  const right = [];
105
134
  right.push('Subcommands');
@@ -143,7 +172,97 @@ export function renderSplashToString(props, opts = {}) {
143
172
  lines.push(`${LMARGIN}+ Tip: trainer learns from your Claude Pro subscription at $0.`);
144
173
  lines.push('');
145
174
 
146
- // 5) bottom border + status bar + border (Hermes-style separator)
175
+ // 5) status bar (Hermes-style separator)
176
+ const sep = '─'.repeat(PANEL_W);
177
+ lines.push(LMARGIN + sep);
178
+ const ctx = props.ctxUsed != null && props.ctxTotal != null
179
+ ? `[${'░'.repeat(20)}] ${props.ctxUsed}/${props.ctxTotal}`
180
+ : `[${'░'.repeat(20)}] --`;
181
+ lines.push(`${LMARGIN} ${provider} · ${model} | ctx -- | ${ctx} | 0s`);
182
+ lines.push(LMARGIN + sep);
183
+
184
+ return lines;
185
+ }
186
+
187
+ // Medium tier — no wordmark, compact panel title, sloth + wrapped right column.
188
+ function renderMedium(props, cols) {
189
+ const PANEL_W = cols - LMARGIN.length * 2;
190
+ const INNER = PANEL_W - 4;
191
+ const SLOTH_W = banner.width;
192
+ const RIGHT_W = Math.max(20, INNER - SLOTH_W - 2);
193
+
194
+ const lines = [];
195
+
196
+ // 1) compact headline (no wordmark)
197
+ lines.push(`${LMARGIN}lazyclaw ${props.version || ''}`.trimEnd());
198
+ lines.push('');
199
+
200
+ // 2) compact panel top — drop the TITLE chain, just version
201
+ const versionLabel = ` lazyclaw ${props.version || ''} `;
202
+ const dashLeft = '─'.repeat(4);
203
+ const dashRight = '─'.repeat(Math.max(2, PANEL_W - 2 - dashLeft.length - stringWidth(versionLabel)));
204
+ lines.push(`${LMARGIN}╭${dashLeft}${versionLabel}${dashRight}╮`);
205
+
206
+ // 3) build right column with wrapping
207
+ const { tools = [], skills = [] } = props;
208
+ const right = [];
209
+ right.push('Subcommands');
210
+ for (const [label, verbs] of SUBCOMMAND_GROUPS) {
211
+ for (const r of wrapVerbs(label, verbs, RIGHT_W)) right.push(r);
212
+ }
213
+ right.push('');
214
+ right.push('Available Tools');
215
+ for (const t of tools.slice(0, 14)) {
216
+ const label = t.sensitive ? `${t.category}*` : t.category;
217
+ for (const r of wrapVerbs(label, t.verbs.slice(0, 6), RIGHT_W)) right.push(r);
218
+ }
219
+ if (tools.length > 14) right.push(`(and ${tools.length - 14} more...)`);
220
+ right.push('');
221
+ right.push('Available Skills');
222
+ if (skills.length === 0) right.push('(none installed)');
223
+ else {
224
+ for (const s of skills.slice(0, 8)) {
225
+ for (const r of wrapVerbs(s.group, s.names.slice(0, 6), RIGHT_W)) right.push(r);
226
+ }
227
+ if (skills.length > 8) right.push(`(and ${skills.length - 8} more skill groups...)`);
228
+ }
229
+ right.push('');
230
+ const subcmdCount = SUBCOMMAND_GROUPS.reduce((n, [, v]) => n + v.length, 0);
231
+ const summary = `${subcmdCount} subcommands · ${tools.length} tool groups · ${skills.length} skills · /help`;
232
+ if (stringWidth(summary) > RIGHT_W) {
233
+ right.push(`${subcmdCount} subcmds · ${tools.length} tools · ${skills.length} skills`);
234
+ right.push('/help for commands');
235
+ } else {
236
+ right.push(summary);
237
+ }
238
+
239
+ const sloth = banner.rows.slice();
240
+ while (sloth.length < right.length) sloth.push(' '.repeat(SLOTH_W));
241
+ while (right.length < sloth.length) right.push('');
242
+
243
+ for (let i = 0; i < sloth.length; i++) {
244
+ const l = sloth[i] || ' '.repeat(SLOTH_W);
245
+ // pad (no ellipsis) — wrapVerbs already guarantees width <= RIGHT_W
246
+ const raw = right[i] || '';
247
+ const r = raw + ' '.repeat(Math.max(0, RIGHT_W - stringWidth(raw)));
248
+ lines.push(`${LMARGIN}│ ${l} ${r} │`);
249
+ }
250
+ lines.push(`${LMARGIN}╰${'─'.repeat(PANEL_W - 2)}╯`);
251
+ lines.push('');
252
+
253
+ // 4) provider / session info
254
+ const { provider, model, trainer = {}, sessionId, cwd } = props;
255
+ const tProv = trainer.provider || provider;
256
+ const tModel = trainer.model || model;
257
+ lines.push(`${LMARGIN}${provider} · ${model} · trainer ${tProv} · ${tModel}`);
258
+ lines.push(`${LMARGIN}${shortCwd(cwd || process.cwd())}`);
259
+ if (sessionId) lines.push(`${LMARGIN}Session: ${sessionId}`);
260
+ lines.push('');
261
+ lines.push(`${LMARGIN}Welcome to lazyclaw. Type your message or /help for commands.`);
262
+ lines.push(`${LMARGIN}+ Tip: trainer learns from your Claude Pro subscription at $0.`);
263
+ lines.push('');
264
+
265
+ // 5) status bar
147
266
  const sep = '─'.repeat(PANEL_W);
148
267
  lines.push(LMARGIN + sep);
149
268
  const ctx = props.ctxUsed != null && props.ctxTotal != null
@@ -152,25 +271,103 @@ export function renderSplashToString(props, opts = {}) {
152
271
  lines.push(`${LMARGIN} ${provider} · ${model} | ctx -- | ${ctx} | 0s`);
153
272
  lines.push(LMARGIN + sep);
154
273
 
274
+ return lines;
275
+ }
276
+
277
+ // Narrow tier — single column, no sloth, no panel, truncated verb lists.
278
+ function renderNarrow(props, cols) {
279
+ const W = cols - LMARGIN.length * 2;
280
+ const lines = [];
281
+
282
+ // 1) headline
283
+ lines.push(`${LMARGIN}lazyclaw ${props.version || ''}`.trimEnd());
284
+ lines.push('');
285
+
286
+ const { tools = [], skills = [], provider, model, trainer = {}, sessionId, cwd } = props;
287
+
288
+ // 2) subcommands
289
+ lines.push(`${LMARGIN}Subcommands`);
290
+ for (const [label, verbs] of SUBCOMMAND_GROUPS) {
291
+ lines.push(LMARGIN + truncateRow(label, verbs, W));
292
+ }
293
+ lines.push('');
294
+
295
+ // 3) tools
296
+ lines.push(`${LMARGIN}Available Tools`);
297
+ for (const t of tools.slice(0, 14)) {
298
+ const label = t.sensitive ? `${t.category}*` : t.category;
299
+ lines.push(LMARGIN + truncateRow(label, t.verbs, W));
300
+ }
301
+ if (tools.length > 14) lines.push(`${LMARGIN}(and ${tools.length - 14} more...)`);
302
+ lines.push('');
303
+
304
+ // 4) skills
305
+ lines.push(`${LMARGIN}Available Skills`);
306
+ if (skills.length === 0) lines.push(`${LMARGIN}(none installed)`);
307
+ else {
308
+ for (const s of skills.slice(0, 8)) {
309
+ lines.push(LMARGIN + truncateRow(s.group, s.names, W));
310
+ }
311
+ if (skills.length > 8) lines.push(`${LMARGIN}(and ${skills.length - 8} more skill groups...)`);
312
+ }
313
+ lines.push('');
314
+
315
+ // 5) provider / session info
316
+ const tProv = trainer.provider || provider;
317
+ const tModel = trainer.model || model;
318
+ lines.push(fit(`${LMARGIN}${provider} · ${model} · trainer ${tProv} · ${tModel}`, cols).trimEnd());
319
+ lines.push(fit(`${LMARGIN}${shortCwd(cwd || process.cwd())}`, cols).trimEnd());
320
+ if (sessionId) lines.push(fit(`${LMARGIN}Session: ${sessionId}`, cols).trimEnd());
321
+ lines.push('');
322
+ lines.push(fit(`${LMARGIN}Welcome to lazyclaw. /help for commands.`, cols).trimEnd());
323
+ lines.push('');
324
+
325
+ // 6) compact status — single line, no separator dashes
326
+ const ctx = props.ctxUsed != null && props.ctxTotal != null
327
+ ? `${props.ctxUsed}/${props.ctxTotal}`
328
+ : '--';
329
+ lines.push(fit(`${LMARGIN}${provider} · ${model} · ctx ${ctx}`, cols).trimEnd());
330
+
331
+ return lines;
332
+ }
333
+
334
+ // Minimal tier — bare-bones fallback for cols < 60.
335
+ function renderMinimal(props) {
336
+ const { version, provider, model, sessionId, cwd } = props;
337
+ const lines = [];
338
+ lines.push(`lazyclaw ${version || ''}`.trimEnd());
339
+ lines.push(`${provider} · ${model}`);
340
+ lines.push(shortCwd(cwd || process.cwd()));
341
+ if (sessionId) lines.push(`Session: ${sessionId}`);
342
+ lines.push('/help for commands');
343
+ return lines;
344
+ }
345
+
346
+ export function renderSplashToString(props, opts = {}) {
347
+ const cols = opts.columns || process.stdout.columns || 100;
348
+ let lines;
349
+ if (cols < MINIMAL_BREAKPOINT) lines = renderMinimal(props);
350
+ else if (cols < PANEL_BREAKPOINT) lines = renderNarrow(props, cols);
351
+ else if (cols < WORDMARK_BREAKPOINT) lines = renderMedium(props, cols);
352
+ else lines = renderWide(props, cols);
155
353
  return lines.join('\n');
156
354
  }
157
355
 
158
356
  export function Splash(props) {
159
357
  const cols = process.stdout.columns || 100;
160
- const TERM = Math.max(80, cols);
161
- const PANEL_W = TERM - LMARGIN.length * 2;
162
358
  const out = renderSplashToString(props, { columns: cols });
163
359
  const lines = out.split('\n');
164
360
  const palette = wordmark.palette;
165
361
  const gradient = wordmark.gradient;
362
+ const showWordmark = cols >= WORDMARK_BREAKPOINT;
166
363
 
167
364
  return React.createElement(
168
365
  Box,
169
366
  { flexDirection: 'column' },
170
367
  lines.map((line, i) => {
171
368
  let color;
172
- if (i < wordmark.height) color = palette[gradient[i] ?? 1];
173
- else if (i < wordmark.height + 1 + 1 + banner.height) color = theme.fg; // panel + sloth tinted
369
+ if (showWordmark && i < wordmark.height) color = palette[gradient[i] ?? 1];
370
+ else if (showWordmark && i < wordmark.height + 1 + 1 + banner.height) color = theme.fg;
174
371
  return React.createElement(Text, { key: i, color }, line);
175
372
  })
176
373
  );