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.
- package/agents.mjs +10 -8
- package/chat_window.mjs +39 -0
- package/cli.mjs +280 -55
- package/daemon.mjs +87 -7
- package/mas/agent_turn.mjs +81 -6
- package/mas/index_db.mjs +62 -2
- package/mas/learning.mjs +371 -0
- package/mas/mention_router.mjs +94 -13
- package/mas/orchestra.mjs +56 -0
- package/mas/skill_synth.mjs +36 -2
- package/mas/tool_runner.mjs +8 -1
- package/mas/tools/git.mjs +18 -1
- package/mas/tools/recall.mjs +64 -0
- package/package.json +2 -1
- package/providers/anthropic.mjs +26 -10
- package/providers/orchestrator.mjs +99 -21
- package/providers/registry.mjs +6 -0
- package/providers/tool_use/anthropic.mjs +43 -7
- package/skills.mjs +42 -3
- package/tasks.mjs +24 -1
- package/tui/editor.mjs +81 -1
- package/tui/repl.mjs +259 -22
- package/tui/run_turn.mjs +138 -0
- package/tui/slash_commands.mjs +37 -0
- package/tui/slash_popup.mjs +157 -0
- package/tui/splash.mjs +65 -43
- package/web/dashboard.html +1 -1
|
@@ -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())
|
|
69
|
-
|
|
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
|
-
|
|
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)
|
|
113
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
|
170
|
+
await runTurnRef.current(text, controller.signal);
|
|
69
171
|
setState((s) => onTurnComplete(s, { reason: 'done' }));
|
|
70
172
|
} catch (err) {
|
|
71
|
-
setState((s) => onTurnComplete(s, {
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
setState(
|
|
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
|
-
|
|
87
|
-
React.createElement(
|
|
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
|
+
}
|