lazyclaw 5.1.0 → 5.3.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/editor.mjs CHANGED
@@ -2,6 +2,14 @@
2
2
  //
3
3
  // Pure-functional core (makeEditorState, applyKey) so it is testable
4
4
  // without ink stdin. The React component <Editor/> wraps useInput().
5
+ //
6
+ // v5.4: slash-popup integration. When the parent passes a non-empty
7
+ // `slashSuggestions` array, ↑/↓ move the popup selection (instead of
8
+ // scrolling history) and Tab/Enter fill the buffer with the highlighted
9
+ // command WITHOUT submitting it (Anthropic's recent UX rule: first
10
+ // Enter fills, second Enter runs). Esc clears the buffer + dismisses.
11
+ // All popup-aware branches are guarded by `slashOpen` so legacy callers
12
+ // see the pre-v5.4 behavior verbatim.
5
13
  import React, { useState, useEffect } from 'react';
6
14
  import { Box, Text, useInput } from 'ink';
7
15
  import { theme } from './theme.mjs';
@@ -63,11 +71,83 @@ export function applyKey(state, evt) {
63
71
  return next;
64
72
  }
65
73
 
66
- export function Editor({ history, onSubmit }) {
74
+ // Pure helper used by the slash-popup branch in <Editor/>. Replaces the
75
+ // editor buffer with `${cmd} ` (note trailing space so the user can keep
76
+ // typing args without an extra keystroke). Does NOT submit.
77
+ export function fillSlashCommand(state, cmd) {
78
+ const filled = cmd.endsWith(' ') ? cmd : cmd + ' ';
79
+ return {
80
+ ...state,
81
+ buffer: filled,
82
+ cursor: filled.length,
83
+ lastSubmit: null,
84
+ lastWasPaste: false,
85
+ };
86
+ }
87
+
88
+ export function Editor({
89
+ history,
90
+ onSubmit,
91
+ onEscape,
92
+ onBufferChange,
93
+ // v5.4 slash-popup wiring (all optional — pre-v5.4 callers pass none):
94
+ slashSuggestions, // Array<{cmd,help}> | null
95
+ slashSelectedIndex, // number
96
+ onSlashMove, // (delta: -1 | +1) => void
97
+ onSlashDismiss, // () => void
98
+ }) {
67
99
  const [state, setState] = useState(() => makeEditorState({ history }));
100
+ const slashOpen = Array.isArray(slashSuggestions) && slashSuggestions.length > 0;
101
+
68
102
  useInput((input, key) => {
103
+ // ─── Slash-popup keyboard contract (highest priority when open) ──
104
+ if (slashOpen) {
105
+ // Esc: clear the buffer and dismiss the popup. The host's onEscape
106
+ // is NOT called in this branch — Esc here is a popup gesture, not
107
+ // a turn-abort. (Outside of popup mode Esc still aborts streaming.)
108
+ if (key.escape) {
109
+ const cleared = { ...state, buffer: '', cursor: 0, lastSubmit: null, lastWasPaste: false };
110
+ setState(cleared);
111
+ if (onBufferChange) {
112
+ try { onBufferChange(''); } catch {}
113
+ }
114
+ if (onSlashDismiss) onSlashDismiss();
115
+ return;
116
+ }
117
+ // ↑/↓ navigate the popup instead of history.
118
+ if (key.upArrow) {
119
+ if (onSlashMove) onSlashMove(-1);
120
+ return;
121
+ }
122
+ if (key.downArrow) {
123
+ if (onSlashMove) onSlashMove(+1);
124
+ return;
125
+ }
126
+ // Tab / Enter — fill the buffer with the highlighted command.
127
+ // First Enter fills, second Enter runs (matches Anthropic's UX).
128
+ if (key.tab || key.return) {
129
+ const safeIdx = Math.max(0, Math.min(slashSuggestions.length - 1, slashSelectedIndex || 0));
130
+ 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 {}
136
+ }
137
+ }
138
+ return;
139
+ }
140
+ // Anything else (printable, backspace) falls through to applyKey.
141
+ }
142
+
143
+ // Esc: forward to host (ReplApp uses this to abort an in-flight turn).
144
+ // Do not mutate the buffer — the user may want to keep typing.
145
+ if (key.escape) { if (onEscape) onEscape(); return; }
69
146
  const next = applyKey(state, { input, key });
70
147
  setState(next);
148
+ if (onBufferChange) {
149
+ try { onBufferChange(next.buffer); } catch { /* observer is best-effort */ }
150
+ }
71
151
  });
72
152
  useEffect(() => {
73
153
  if (state.lastSubmit !== null && onSubmit) onSubmit(state.lastSubmit);
package/tui/repl.mjs CHANGED
@@ -1,18 +1,56 @@
1
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';
2
+ // (spec §5.8) AND a sticky-bottom chat layout (v5.3).
3
+ //
4
+ // Layout (top bottom inside the outer column):
5
+ // 1. <Static items={scrollback}/> splash item + per-turn user/assistant
6
+ // blocks. Static renders each item ONCE to terminal scrollback and
7
+ // never re-renders it, so the splash + history scroll away naturally
8
+ // as new content appends. This is the Claude CLI / opencode pattern
9
+ // translated to Ink's idiom — Static IS the scroll buffer.
10
+ // 2. Live region — partial assistant stream (state.liveAssistant) and
11
+ // optional <SlashHints/> while the input buffer starts with '/'.
12
+ // This Box re-renders on every chunk; the rest of the tree does not.
13
+ // 3. <StatusBar/> — single row above the input. flexShrink:0.
14
+ // 4. <Editor/> — sticky bottom, content-sized (1 line idle, grows with
15
+ // multiline). Last sibling in the column so it pins to the bottom.
16
+ //
17
+ // Streaming chunks now flow into React state via an injected writeFn
18
+ // (state.liveAssistant += chunk). On turn completion the accumulated
19
+ // text is committed to scrollback so React stops re-rendering it.
20
+ // This closes the v5.0.10 TODO from cli.mjs:2628-2632.
21
+ //
22
+ // Backward-compat contracts (do not break):
23
+ // - makeReplState() — still callable with zero args.
24
+ // - onUserInput, onEscape, onTurnComplete, consumeNextTurnFirstMessage
25
+ // keep their pre-v5.3 shapes (tests/phaseC-repl-interrupt.test.mjs).
26
+ // - ReplApp({ splashProps, runTurn }) — legacy callsite (cli.mjs:2637)
27
+ // still works; runTurn writes go to wherever its writeFn points.
28
+ // - ReplApp({ splashProps, runTurnFactory }) — new callsite for the
29
+ // sticky layout; ReplApp injects writeFn → scrollback.
30
+ //
31
+ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
32
+ import { Box, Static, Text, useApp } from 'ink';
33
+ import { Splash, renderSplashToString } from './splash.mjs';
7
34
  import { Editor } from './editor.mjs';
35
+ import { SlashPopup, filterSlashCommands } from './slash_popup.mjs';
36
+ import { SLASH_COMMANDS } from './slash_commands.mjs';
37
+ import { theme } from './theme.mjs';
8
38
 
9
- export function makeReplState() {
39
+ // ─── Pure state ──────────────────────────────────────────────────────────
40
+ //
41
+ // makeReplState stays callable with zero args (existing tests rely on it).
42
+ // The new fields default to empty so legacy callers see no behavior change.
43
+ export function makeReplState(opts) {
44
+ const splashItem = opts && opts.splashItem ? opts.splashItem : null;
10
45
  return {
11
46
  streaming: false,
12
47
  controller: null,
13
48
  pendingPrepend: null,
14
49
  nextTurnFirstMessage: null,
15
50
  history: [],
51
+ scrollback: splashItem ? [splashItem] : [],
52
+ liveAssistant: '',
53
+ turnCounter: 0,
16
54
  };
17
55
  }
18
56
 
@@ -22,12 +60,16 @@ export function onUserInput(state, { text, controller }) {
22
60
  try { state.controller.abort(); } catch {}
23
61
  return { ...state, pendingPrepend: text };
24
62
  }
25
- // idle — start a new turn.
63
+ // idle — start a new turn. Append a 'user' entry to scrollback so the
64
+ // sticky-layout caller sees the prompt history above the live stream.
65
+ const id = `u-${state.turnCounter}`;
26
66
  return {
27
67
  ...state,
28
68
  streaming: true,
29
69
  controller,
30
70
  history: [...state.history, text],
71
+ scrollback: [...state.scrollback, { kind: 'user', id, text }],
72
+ turnCounter: state.turnCounter + 1,
31
73
  };
32
74
  }
33
75
 
@@ -35,18 +77,45 @@ export function onEscape(state) {
35
77
  if (state.streaming && state.controller) {
36
78
  try { state.controller.abort(); } catch {}
37
79
  }
38
- return { ...state, streaming: false, controller: null, pendingPrepend: null };
80
+ // Drop any partial live assistant text on explicit Esc — the user is
81
+ // telling us to discard, not to keep.
82
+ return {
83
+ ...state,
84
+ streaming: false,
85
+ controller: null,
86
+ pendingPrepend: null,
87
+ liveAssistant: '',
88
+ };
89
+ }
90
+
91
+ // New reducer: stream chunk arrives, accumulate in liveAssistant.
92
+ export function onStreamChunk(state, { chunk }) {
93
+ return { ...state, liveAssistant: state.liveAssistant + chunk };
39
94
  }
40
95
 
41
96
  export function onTurnComplete(state, { reason } = {}) {
42
- void reason;
43
97
  const promoted = state.pendingPrepend;
98
+ const suffix = reason === 'aborted' ? ' [aborted]'
99
+ : reason === 'error' ? ' [error]'
100
+ : '';
101
+ const text = (state.liveAssistant || '') + suffix;
102
+ // Commit any accumulated live text to scrollback. If the turn produced
103
+ // nothing AND wasn't an error/abort, skip the empty append.
104
+ const shouldCommit = text.length > 0 && (state.liveAssistant.length > 0 || suffix.length > 0);
105
+ const id = `a-${state.turnCounter}`;
106
+ const kind = reason === 'error' ? 'error' : 'assistant';
107
+ const nextScrollback = shouldCommit
108
+ ? [...state.scrollback, { kind, id, text }]
109
+ : state.scrollback;
44
110
  return {
45
111
  ...state,
46
112
  streaming: false,
47
113
  controller: null,
48
114
  pendingPrepend: null,
49
115
  nextTurnFirstMessage: promoted,
116
+ liveAssistant: '',
117
+ scrollback: nextScrollback,
118
+ turnCounter: state.turnCounter + 1,
50
119
  };
51
120
  }
52
121
 
@@ -56,34 +125,202 @@ export function consumeNextTurnFirstMessage(state) {
56
125
  }
57
126
 
58
127
  // ─── React mount ─────────────────────────────────────────────────────────
59
- export function ReplApp({ splashProps, runTurn }) {
60
- const [state, setState] = useState(makeReplState);
128
+ //
129
+ // Two prop modes:
130
+ // - runTurnFactory(writeFn) → runTurn(text, signal) (sticky layout)
131
+ // - runTurn(text, signal) (legacy, stdout)
132
+ // Legacy mode is preserved verbatim for the existing cli.mjs callsite.
133
+ export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands }) {
134
+ // Splash is rendered ONCE as scrollback[0] via <Static>. Build it lazily
135
+ // so SSR-style imports without a TTY don't crash on process.stdout.
136
+ const splashItemRef = useRef(null);
137
+ if (splashItemRef.current === null) {
138
+ splashItemRef.current = splashProps
139
+ ? { kind: 'splash', id: 'splash-0', splashProps }
140
+ : null;
141
+ }
142
+ const [state, setState] = useState(() => makeReplState({ splashItem: splashItemRef.current }));
61
143
  const { exit } = useApp();
62
144
 
63
- async function handleSubmit(text) {
64
- if (text === '/exit') { exit(); return; }
145
+ // writeFn for run_turn: route chunks into React state instead of stdout.
146
+ // Only used when runTurnFactory is provided; the legacy `runTurn` prop
147
+ // keeps writing wherever its caller wired it.
148
+ const writeFn = useCallback((chunk) => {
149
+ setState((s) => onStreamChunk(s, { chunk }));
150
+ }, []);
151
+
152
+ // Build the actual runTurn once. Prefer factory (new); fall back to
153
+ // the legacy `runTurn` prop (existing cli.mjs callsite).
154
+ const runTurnRef = useRef(null);
155
+ if (runTurnRef.current === null) {
156
+ if (typeof runTurnFactory === 'function') {
157
+ runTurnRef.current = runTurnFactory(writeFn);
158
+ } else if (typeof runTurn === 'function') {
159
+ runTurnRef.current = runTurn;
160
+ } else {
161
+ runTurnRef.current = async () => {};
162
+ }
163
+ }
164
+
165
+ const handleSubmit = useCallback(async (text) => {
166
+ if (text === '/exit' || text === '/quit') { exit(); return; }
65
167
  const controller = new AbortController();
66
168
  setState((s) => onUserInput(s, { text, controller }));
67
169
  try {
68
- await runTurn(text, controller.signal);
170
+ await runTurnRef.current(text, controller.signal);
69
171
  setState((s) => onTurnComplete(s, { reason: 'done' }));
70
172
  } catch (err) {
71
- setState((s) => onTurnComplete(s, { reason: err.name === 'AbortError' ? 'aborted' : 'error' }));
173
+ setState((s) => onTurnComplete(s, {
174
+ reason: err && err.name === 'AbortError' ? 'aborted' : 'error',
175
+ }));
72
176
  }
73
- }
177
+ }, [exit]);
74
178
 
179
+ // Auto-submit queued mid-stream-interrupt message (spec §5.8). Read
180
+ // state.nextTurnFirstMessage so the effect re-fires when promoted.
75
181
  useEffect(() => {
76
- const [next, msg] = consumeNextTurnFirstMessage(state);
77
- if (msg) {
78
- setState(next);
182
+ if (state.nextTurnFirstMessage) {
183
+ const msg = state.nextTurnFirstMessage;
184
+ setState((s) => ({ ...s, nextTurnFirstMessage: null }));
79
185
  handleSubmit(msg);
80
186
  }
81
- }, [state.nextTurnFirstMessage]);
187
+ }, [state.nextTurnFirstMessage, handleSubmit]);
188
+
189
+ // Esc handler — abort streaming turn, drop partial output.
190
+ const onEscapeKey = useCallback(() => {
191
+ setState((s) => onEscape(s));
192
+ }, []);
193
+
194
+ // ─── Slash popup state (v5.4) ──────────────────────────────────────
195
+ // The editor reports its current buffer via onBufferChange; we derive
196
+ // the filtered command list from that and own the selection index.
197
+ const catalog = useMemo(
198
+ () => (Array.isArray(slashCommands) && slashCommands.length > 0
199
+ ? slashCommands : SLASH_COMMANDS),
200
+ [slashCommands]
201
+ );
202
+ const [bufferPeek, setBufferPeek] = useState('');
203
+ const [selectedSuggestion, setSelectedSuggestion] = useState(0);
204
+ const filtered = useMemo(
205
+ () => filterSlashCommands(bufferPeek, catalog),
206
+ [bufferPeek, catalog]
207
+ );
208
+ // Reset selection whenever the match list changes length (typing
209
+ // narrows results, so highlight the first row again).
210
+ const lastLenRef = useRef(0);
211
+ useEffect(() => {
212
+ if (filtered.length !== lastLenRef.current) {
213
+ setSelectedSuggestion(0);
214
+ lastLenRef.current = filtered.length;
215
+ } else if (selectedSuggestion >= filtered.length) {
216
+ setSelectedSuggestion(Math.max(0, filtered.length - 1));
217
+ }
218
+ }, [filtered.length, selectedSuggestion]);
219
+
220
+ const handleBufferChange = useCallback((buf) => {
221
+ setBufferPeek(buf || '');
222
+ }, []);
223
+ const handleSlashMove = useCallback((delta) => {
224
+ setSelectedSuggestion((i) => {
225
+ const max = Math.max(0, filtered.length - 1);
226
+ const n = i + delta;
227
+ if (n < 0) return 0;
228
+ if (n > max) return max;
229
+ return n;
230
+ });
231
+ }, [filtered.length]);
232
+ const handleSlashDismiss = useCallback(() => {
233
+ setBufferPeek('');
234
+ setSelectedSuggestion(0);
235
+ }, []);
236
+
237
+ const showSlashPopup = bufferPeek.startsWith('/') && filtered.length > 0;
82
238
 
83
239
  return React.createElement(
84
240
  Box,
85
241
  { flexDirection: 'column' },
86
- React.createElement(Splash, splashProps),
87
- React.createElement(Editor, { history: state.history, onSubmit: handleSubmit })
242
+ // 1) Scrollback (write-once via Ink Static)
243
+ React.createElement(
244
+ Static,
245
+ { items: state.scrollback },
246
+ (item) => React.createElement(ScrollbackItem, { key: item.id, item })
247
+ ),
248
+ // 2) Live region — partial assistant stream
249
+ state.liveAssistant
250
+ ? React.createElement(
251
+ Box,
252
+ { flexDirection: 'column' },
253
+ React.createElement(Text, { color: theme.fg }, state.liveAssistant)
254
+ )
255
+ : null,
256
+ // 3) Slash popup — flex sibling above the StatusBar; Ink can't
257
+ // absolutely position so this is the "just above input" pattern.
258
+ showSlashPopup
259
+ ? React.createElement(SlashPopup, {
260
+ buffer: bufferPeek,
261
+ commands: filtered,
262
+ selectedIndex: selectedSuggestion,
263
+ })
264
+ : null,
265
+ // 4) Status bar (sticky, single row above input)
266
+ React.createElement(StatusBar, {
267
+ provider: splashProps && splashProps.provider,
268
+ model: splashProps && splashProps.model,
269
+ streaming: state.streaming,
270
+ ctxUsed: splashProps && splashProps.ctxUsed,
271
+ ctxTotal: splashProps && splashProps.ctxTotal,
272
+ }),
273
+ // 5) Editor — sticky bottom, content-sized
274
+ React.createElement(Editor, {
275
+ history: state.history,
276
+ onSubmit: handleSubmit,
277
+ onEscape: onEscapeKey,
278
+ onBufferChange: handleBufferChange,
279
+ slashSuggestions: showSlashPopup ? filtered : null,
280
+ slashSelectedIndex: selectedSuggestion,
281
+ onSlashMove: handleSlashMove,
282
+ onSlashDismiss: handleSlashDismiss,
283
+ })
88
284
  );
89
285
  }
286
+
287
+ // ScrollbackItem renders each <Static/> child. Splash renders via the
288
+ // real <Splash/> component (preserves gradient wordmark colorization);
289
+ // everything else is plain Text.
290
+ export function ScrollbackItem({ item }) {
291
+ if (item.kind === 'splash') {
292
+ return React.createElement(Splash, item.splashProps);
293
+ }
294
+ if (item.kind === 'user') {
295
+ return React.createElement(
296
+ Box,
297
+ { flexDirection: 'column' },
298
+ React.createElement(Text, null, theme.accent('› ') + item.text)
299
+ );
300
+ }
301
+ if (item.kind === 'error') {
302
+ return React.createElement(Text, { color: 'red' }, item.text);
303
+ }
304
+ // 'assistant' (default)
305
+ return React.createElement(Text, { color: theme.fg }, item.text);
306
+ }
307
+
308
+ // StatusBar — single row, provider · model · ctx · streaming indicator.
309
+ // Kept intentionally minimal in v5.3; token gauges land separately once
310
+ // usage metrics flow into state.
311
+ export function StatusBar({ provider, model, streaming, ctxUsed, ctxTotal }) {
312
+ const ctx = (ctxUsed != null && ctxTotal != null) ? `${ctxUsed}/${ctxTotal}` : '--';
313
+ const indicator = streaming ? theme.accent('● streaming') : theme.dim('○ idle');
314
+ const prov = provider || '?';
315
+ const mdl = model || '?';
316
+ return React.createElement(
317
+ Box,
318
+ { flexShrink: 0, paddingX: 1 },
319
+ React.createElement(Text, null, `${indicator} ${prov} · ${mdl} ctx ${ctx}`)
320
+ );
321
+ }
322
+
323
+ // Exported for tests that want to verify the splash snapshot without a TTY.
324
+ export function _renderSplashToString(splashProps) {
325
+ return renderSplashToString(splashProps);
326
+ }