@zenera/cli 1.1.10 → 1.1.11
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/README.md +49 -15
- package/dist/cache.d.ts +98 -0
- package/dist/cache.js +301 -0
- package/dist/catalog.d.ts +3 -0
- package/dist/catalog.js +35 -11
- package/dist/commands/cache.d.ts +7 -0
- package/dist/commands/cache.js +245 -0
- package/dist/commands/check.js +6 -3
- package/dist/commands/index.js +2 -0
- package/dist/commands/key.js +68 -14
- package/dist/commands/models.js +17 -1
- package/dist/commands/run.js +11 -3
- package/dist/commands/sandbox.js +70 -23
- package/dist/home.d.ts +2 -2
- package/dist/home.js +2 -2
- package/dist/keys.d.ts +22 -0
- package/dist/keys.js +105 -2
- package/dist/lib.d.ts +1 -0
- package/dist/lib.js +1 -0
- package/dist/liveness.js +11 -0
- package/dist/resolve.d.ts +4 -0
- package/dist/resolve.js +43 -17
- package/dist/term.d.ts +23 -2
- package/dist/term.js +215 -8
- package/dist/tui/app.d.ts +10 -0
- package/dist/tui/app.js +470 -54
- package/dist/tui/theme.d.ts +6 -2
- package/dist/tui/theme.js +14 -8
- package/dist/tui/wrap.d.ts +92 -0
- package/dist/tui/wrap.js +147 -2
- package/dist/validate.d.ts +2 -0
- package/dist/validate.js +87 -2
- package/package.json +2 -2
- package/templates/editor/.github/copilot-instructions.md +50 -13
- package/templates/editor/.github/prompts/new-agent.prompt.md +5 -2
- package/templates/editor/.github/prompts/sync-with-spec.prompt.md +4 -0
- package/templates/editor/.github/skills/zen-cli/references/faker.md +18 -8
- package/templates/editor/.github/skills/zen-cli/references/keys.md +7 -7
- package/templates/editor/.github/skills/zen-rag-docs/SKILL.md +575 -0
- package/templates/editor/.github/skills/{api-schema-index → zen-rag-schema}/SKILL.md +2 -2
- package/templates/editor/.vscode/settings.json +1 -1
package/dist/tui/app.js
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { TextInput } from '@inkjs/ui';
|
|
3
|
-
import { isCheckpoint, turns, zeroUsage } from '@zenera/neo';
|
|
3
|
+
import { addUsage, isCheckpoint, turns, zeroUsage, } from '@zenera/neo';
|
|
4
4
|
import { Box, Static, Text, useApp, useInput, useStdout } from 'ink';
|
|
5
5
|
import { pathToFileURL } from 'node:url';
|
|
6
|
-
import React, { useCallback, useContext, useRef, useState } from 'react';
|
|
6
|
+
import React, { useCallback, useContext, useEffect, useRef, useState } from 'react';
|
|
7
7
|
import * as Engine from "../engine.js";
|
|
8
8
|
import { History } from "../history.js";
|
|
9
9
|
import { format } from "../narrate.js";
|
|
10
10
|
import { display } from "../session.js";
|
|
11
11
|
import { CliError } from "../term.js";
|
|
12
12
|
import { resolveTheme, THEMES } from "./theme.js";
|
|
13
|
-
import { windowOf } from "./wrap.js";
|
|
13
|
+
import { ACTIVITY_ROWS, answerWidth, BOX_CHROME, BRANCH_ROWS, branchRows, budgetOf, CHROME_ROWS, clip, readable, segmentsOf, THINKING_CHROME, THINKING_ROWS, windowOf, } from "./wrap.js";
|
|
14
14
|
const BANNER = { key: 'banner' };
|
|
15
15
|
const isBanner = (item) => item.key === 'banner';
|
|
16
16
|
const MARK = {
|
|
@@ -20,6 +20,12 @@ const MARK = {
|
|
|
20
20
|
note: ' ',
|
|
21
21
|
error: '!',
|
|
22
22
|
};
|
|
23
|
+
/** Proof of life. Ten frames at 100ms is a turn of the wheel per second. */
|
|
24
|
+
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
25
|
+
/** Redraw cadence for the activity region, independent of events. */
|
|
26
|
+
const FRAME_MS = 100;
|
|
27
|
+
/** How long after the last delta reasoning is still called live. */
|
|
28
|
+
const SETTLE_MS = 800;
|
|
23
29
|
// The theme is decided once, before the first frame, and never changes while
|
|
24
30
|
// the app is up — a terminal does not repaint its own scheme underneath us.
|
|
25
31
|
// A context rather than props only because every part of the view wants it.
|
|
@@ -27,7 +33,20 @@ const ThemeContext = React.createContext(THEMES.dark);
|
|
|
27
33
|
const useTheme = () => useContext(ThemeContext);
|
|
28
34
|
function Row({ line }) {
|
|
29
35
|
const style = useTheme().line[line.kind];
|
|
30
|
-
return (_jsxs(Box, { flexDirection: "row", marginTop: line.kind === 'you' ? 1 : 0, children: [_jsxs(Text, { color: style.color, dimColor: style.dim, children: [MARK[line.kind], ' '] }), _jsxs(Box, { flexDirection: "column", children: [
|
|
36
|
+
return (_jsxs(Box, { flexDirection: "row", marginTop: line.kind === 'you' ? 1 : 0, children: [_jsxs(Text, { color: style.color, dimColor: style.dim, children: [MARK[line.kind], ' '] }), _jsxs(Box, { flexDirection: "column", children: [line.kind === 'agent' ? (_jsx(Answer, { text: line.text })) : (
|
|
37
|
+
// The lead is what the eye scans for down the left edge, so
|
|
38
|
+
// it keeps the row's colour without its dimming. Nested
|
|
39
|
+
// rather than one string: Ink dims a whole Text or none of it.
|
|
40
|
+
_jsxs(Text, { children: [line.lead ? _jsx(Text, { color: style.color, children: `${line.lead} ` }) : null, _jsx(Text, { color: style.color, dimColor: style.dim, bold: line.kind === 'you', children: line.text })] })), line.detail ? _jsx(Text, { dimColor: true, children: line.detail }) : null] })] }));
|
|
41
|
+
}
|
|
42
|
+
function Answer({ text }) {
|
|
43
|
+
const { stdout } = useStdout();
|
|
44
|
+
const width = answerWidth(stdout?.columns ?? 80);
|
|
45
|
+
const segments = segmentsOf(text);
|
|
46
|
+
// Bounded, like every answer in `examples/`: prose that runs the width of a
|
|
47
|
+
// wide terminal is a worse read than prose that stops, and the box is also
|
|
48
|
+
// what separates the answer from the machinery that produced it.
|
|
49
|
+
return (_jsx(Box, { flexDirection: "column", width: width, borderStyle: "round", borderDimColor: true, paddingX: 1, marginY: 1, children: segments.map((s, i) => s.code ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: `\u250c\u2500${s.title ? ` ${s.title}` : ''}` }), s.lines.map((l, j) => (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { dimColor: true, children: '\u2502 ' }), _jsx(Text, { wrap: "truncate-end", children: l || ' ' })] }, j))), _jsx(Text, { dimColor: true, children: '\u2514\u2500' })] }, i)) : (_jsx(Text, { children: s.lines.join('\n') }, i))) }));
|
|
31
50
|
}
|
|
32
51
|
function App({ engine, options, theme }) {
|
|
33
52
|
const { exit } = useApp();
|
|
@@ -41,9 +60,25 @@ function App({ engine, options, theme }) {
|
|
|
41
60
|
session: engine.state?.usage ?? zeroUsage(),
|
|
42
61
|
calls: engine.state ? turns(engine.state) : 0,
|
|
43
62
|
});
|
|
44
|
-
|
|
63
|
+
/** Model calls in the turn now running, and what answered the last one. */
|
|
64
|
+
const [step, setStep] = useState(0);
|
|
65
|
+
const [model, setModel] = useState(undefined);
|
|
66
|
+
// In flight, and therefore not in the transcript yet. Refs, because the
|
|
67
|
+
// activity region below is redrawn by the frame timer regardless.
|
|
68
|
+
const running = useRef(new Map());
|
|
69
|
+
const branches = useRef(new Map());
|
|
70
|
+
const lane = useLanes(theme);
|
|
71
|
+
// What the turn now running has cost so far, summed per model call rather
|
|
72
|
+
// than read off the state: it is wanted between calls, not after them. The
|
|
73
|
+
// exact figure replaces it when the turn lands.
|
|
74
|
+
const spent = useRef(zeroUsage());
|
|
75
|
+
const startedAt = useRef(0);
|
|
76
|
+
/** When reasoning text last arrived, which is the only proof it is still coming. */
|
|
77
|
+
const flowing = useRef(0);
|
|
45
78
|
const stopping = useRef(undefined);
|
|
46
79
|
const seq = useRef(0);
|
|
80
|
+
/** The tallest the repainting region has been this turn, which is the height it keeps. */
|
|
81
|
+
const grown = useRef(0);
|
|
47
82
|
// The prompt, and how to get back into it what was asked before.
|
|
48
83
|
//
|
|
49
84
|
// `TextInput` is uncontrolled: it takes a starting value and owns it from
|
|
@@ -91,16 +126,70 @@ function App({ engine, options, theme }) {
|
|
|
91
126
|
setDraft(value);
|
|
92
127
|
setGeneration((g) => g + 1);
|
|
93
128
|
}, [history]);
|
|
129
|
+
// A spinner and a clock have to move on their own, so the frame is driven
|
|
130
|
+
// by a timer rather than by events — but only while there is something to
|
|
131
|
+
// watch, so an idle prompt repaints exactly never.
|
|
132
|
+
const [frame, setFrame] = useState(0);
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
if (!busy) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const timer = setInterval(() => setFrame((n) => n + 1), FRAME_MS);
|
|
138
|
+
return () => clearInterval(timer);
|
|
139
|
+
}, [busy]);
|
|
94
140
|
// Read during render so a resize, which re-renders the root, resizes the
|
|
95
|
-
// windows below with it. The
|
|
141
|
+
// windows below with it. The three repainting blocks share one budget: what
|
|
96
142
|
// is left of the terminal once the chrome has had its rows.
|
|
97
143
|
const rows = stdout?.rows ?? 24;
|
|
98
144
|
const columns = stdout?.columns ?? 80;
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
145
|
+
const spin = SPINNER[frame % SPINNER.length];
|
|
146
|
+
// Every branch box is the same height, so the share has to be settled
|
|
147
|
+
// before the boxes are built: the rows come out of one allowance and a
|
|
148
|
+
// fan-out is read across, not down.
|
|
149
|
+
const allowance = Math.min(ACTIVITY_ROWS, Math.max(0, rows - CHROME_ROWS - 2));
|
|
150
|
+
const boxes = busy
|
|
151
|
+
? branchBoxesOf(running.current, branches.current, Date.now(), columns - GUTTER, lane, branchRows(branches.current.size, allowance))
|
|
152
|
+
: [];
|
|
153
|
+
// What the trunk itself has in flight. A branch's call is the branch's
|
|
154
|
+
// business — it has a box saying so — and the trunk, having forked, is
|
|
155
|
+
// waiting at the join.
|
|
156
|
+
const mine = busy ? trunkCallsOf(running.current, branches.current) : [];
|
|
157
|
+
const trunkRows = busy ? trunkRowsOf(mine, Date.now(), columns - GUTTER - 2) : [];
|
|
158
|
+
// Priced as far as it has got. Read during render, so the frame timer is
|
|
159
|
+
// what advances the clock.
|
|
160
|
+
const inflight = busy
|
|
161
|
+
? { usage: spent.current, durationMs: Date.now() - startedAt.current }
|
|
162
|
+
: undefined;
|
|
163
|
+
// Reasoning survives the call that produced it, because it is why the tool
|
|
164
|
+
// now running is running. That makes settled text indistinguishable from
|
|
165
|
+
// live text unless the difference is drawn, and a paragraph that has quietly
|
|
166
|
+
// stopped moving reads as a hang.
|
|
167
|
+
const streaming = busy && Date.now() - flowing.current < SETTLE_MS;
|
|
168
|
+
// A settled block collapses to its opening line. It still says why the work
|
|
169
|
+
// now running is running, but it stops holding six rows of a paragraph that
|
|
170
|
+
// finished a minute ago — during a fan-out the trunk makes no call of its
|
|
171
|
+
// own, so nothing clears it and it reads as hung.
|
|
172
|
+
const budget = budgetOf(rows, activityHeight(boxes, trunkRows), thinking ? (streaming ? THINKING_ROWS : 1) : 0);
|
|
173
|
+
const fitted = fitActivity(boxes, trunkRows, budget.activity);
|
|
174
|
+
// How tall the region actually needs to be, mirroring what the three blocks
|
|
175
|
+
// below draw.
|
|
176
|
+
const thinkingRows = thinking && budget.thinking
|
|
177
|
+
? (streaming ? windowOf(thinking, columns - GUTTER, budget.thinking).length : 1) +
|
|
178
|
+
THINKING_CHROME
|
|
179
|
+
: 0;
|
|
180
|
+
const liveRows = live ? windowOf(live, answerWidth(columns) - 4, budget.live).length : 0;
|
|
181
|
+
const wanted = activityHeight(fitted.boxes, fitted.trunk) +
|
|
182
|
+
(fitted.hidden ? 1 : 0) +
|
|
183
|
+
thinkingRows +
|
|
184
|
+
liveRows;
|
|
185
|
+
// A turn opens against the prompt and grows up from it, one row at a time,
|
|
186
|
+
// the way anything else printed to a terminal does. Reserving the whole
|
|
187
|
+
// region up front instead threw the question that started it at the ceiling
|
|
188
|
+
// before a word of the answer existed. It only ever grows, so the footer
|
|
189
|
+
// still never rides back up: what a block gives back is left as slack.
|
|
190
|
+
grown.current = busy ? Math.min(budget.total, Math.max(grown.current, wanted)) : 0;
|
|
191
|
+
const push = useCallback((kind, text, detail, lead) => {
|
|
192
|
+
setLines((prev) => [...prev, { key: `${seq.current++}`, kind, lead, text, detail }]);
|
|
104
193
|
}, []);
|
|
105
194
|
// Deltas arrive far faster than a terminal can usefully redraw, so text is
|
|
106
195
|
// accumulated in one string and React coalesces the repaints. The finished
|
|
@@ -112,39 +201,137 @@ function App({ engine, options, theme }) {
|
|
|
112
201
|
// is lost when it is cleared at the start of the next model call.
|
|
113
202
|
const onEvent = useCallback((event) => {
|
|
114
203
|
if (!isCheckpoint(event)) {
|
|
115
|
-
|
|
116
|
-
|
|
204
|
+
// Only the trunk's answer is drawn. A fork has several running
|
|
205
|
+
// at once, and appending them all to one string is not a
|
|
206
|
+
// transcript of anything: it is several answers interleaved
|
|
207
|
+
// token by token. Each branch's distilled result arrives at the
|
|
208
|
+
// join, and the whole of it is in the report.
|
|
209
|
+
//
|
|
210
|
+
// Reasoning is different: it is a progress indicator, and each
|
|
211
|
+
// branch has a box to put its own in.
|
|
212
|
+
const owner = event.branch ? branches.current.get(event.branch.name) : undefined;
|
|
213
|
+
if (event.type === 'thinking_delta') {
|
|
214
|
+
if (owner) {
|
|
215
|
+
owner.thinking += event.delta;
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (!event.branch) {
|
|
219
|
+
flowing.current = Date.now();
|
|
220
|
+
setThinking((prev) => prev + event.delta);
|
|
221
|
+
}
|
|
222
|
+
return;
|
|
117
223
|
}
|
|
118
|
-
|
|
119
|
-
|
|
224
|
+
if (event.type === 'text_delta' && !event.branch) {
|
|
225
|
+
setLive((prev) => prev + event.delta);
|
|
120
226
|
}
|
|
121
227
|
return;
|
|
122
228
|
}
|
|
229
|
+
const from = event.branch?.name;
|
|
123
230
|
switch (event.type) {
|
|
124
231
|
case 'before_llm_call':
|
|
125
|
-
|
|
232
|
+
if (from) {
|
|
233
|
+
const b = branches.current.get(from);
|
|
234
|
+
if (b) {
|
|
235
|
+
b.steps++;
|
|
236
|
+
b.thinking = '';
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
setThinking('');
|
|
241
|
+
setStep((n) => n + 1);
|
|
242
|
+
}
|
|
243
|
+
break;
|
|
244
|
+
case 'after_llm_call':
|
|
245
|
+
// Branches included: they are what this turn is spending on.
|
|
246
|
+
spent.current = addUsage(spent.current, event.node.usage);
|
|
247
|
+
if (!from) {
|
|
248
|
+
setModel(event.node.model);
|
|
249
|
+
}
|
|
126
250
|
break;
|
|
127
251
|
case 'before_tool_call':
|
|
128
|
-
|
|
252
|
+
running.current.set(event.call.callId, {
|
|
253
|
+
callId: event.call.callId,
|
|
254
|
+
name: event.call.name,
|
|
255
|
+
args: event.call.args.preview ?? '',
|
|
256
|
+
startedAt: Date.now(),
|
|
257
|
+
branch: from,
|
|
258
|
+
});
|
|
129
259
|
break;
|
|
130
|
-
case 'after_tool_call':
|
|
131
|
-
|
|
132
|
-
|
|
260
|
+
case 'after_tool_call': {
|
|
261
|
+
const { node } = event;
|
|
262
|
+
const call = running.current.get(node.callId);
|
|
263
|
+
running.current.delete(node.callId);
|
|
264
|
+
const b = from ? branches.current.get(from) : undefined;
|
|
265
|
+
if (b) {
|
|
266
|
+
b.tools++;
|
|
267
|
+
// A branch's calls belong to its box, not to the
|
|
268
|
+
// transcript: several branches finishing into one
|
|
269
|
+
// scrollback is a fan-out shuffled, and the box is
|
|
270
|
+
// the only place the shape of the fork survives.
|
|
271
|
+
// The join summarises it; the report has all of it.
|
|
272
|
+
b.done.push({
|
|
273
|
+
callId: node.callId,
|
|
274
|
+
name: node.name,
|
|
275
|
+
args: call?.args ?? '',
|
|
276
|
+
ms: node.durationMs,
|
|
277
|
+
failed: node.isError,
|
|
278
|
+
});
|
|
279
|
+
b.done.splice(0, b.done.length - BRANCH_ROWS);
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
// What it was asked and what it answered, which is the
|
|
283
|
+
// difference between knowing a tool ran and knowing what
|
|
284
|
+
// the agent did. Both are previews already; a whole file
|
|
285
|
+
// read belongs in the report, not in the scrollback.
|
|
286
|
+
push('tool', clip(readable(call?.args ?? ''), columns - node.name.length - 5), detailOf(node, from, columns - 4), node.name);
|
|
133
287
|
break;
|
|
134
|
-
|
|
288
|
+
}
|
|
289
|
+
case 'handoff': {
|
|
290
|
+
// A handoff inside a branch renames that branch, not the
|
|
291
|
+
// session: the trunk is still whoever forked.
|
|
292
|
+
const b = from ? branches.current.get(from) : undefined;
|
|
293
|
+
if (b) {
|
|
294
|
+
b.agent = event.to;
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
135
297
|
setAgent(event.to);
|
|
136
298
|
push('note', `→ ${event.to}`, `handed off from ${event.from}`);
|
|
137
299
|
break;
|
|
300
|
+
}
|
|
138
301
|
case 'before_fork':
|
|
139
|
-
|
|
302
|
+
// Whatever the trunk was reasoning about, forking is the
|
|
303
|
+
// conclusion it reached. It makes no call of its own until
|
|
304
|
+
// the join, so nothing else would clear it and it would sit
|
|
305
|
+
// there for the length of the fork looking hung.
|
|
306
|
+
if (from) {
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
setThinking('');
|
|
310
|
+
push('note', `⑂ ${event.node.branches.length} branches`, `${event.node.branches.map((b) => b.name).join(', ')} · context ${event.node.contextMode}`);
|
|
311
|
+
break;
|
|
312
|
+
case 'branch_started':
|
|
313
|
+
branches.current.set(event.child.name, {
|
|
314
|
+
name: event.child.name,
|
|
315
|
+
agent: event.childState.agentName,
|
|
316
|
+
startedAt: Date.now(),
|
|
317
|
+
steps: 0,
|
|
318
|
+
tools: 0,
|
|
319
|
+
thinking: '',
|
|
320
|
+
done: [],
|
|
321
|
+
});
|
|
140
322
|
break;
|
|
141
|
-
case 'branch_finished':
|
|
142
|
-
|
|
323
|
+
case 'branch_finished': {
|
|
324
|
+
const b = branches.current.get(event.child.name);
|
|
325
|
+
branches.current.delete(event.child.name);
|
|
326
|
+
const spent = b ? ` · ${durationOf(Date.now() - b.startedAt) ?? ''}` : '';
|
|
327
|
+
const did = b ? ` · ${b.steps} steps · ${b.tools} tools` : '';
|
|
328
|
+
push('tool', `⑂ ${event.child.name}`, `${event.status}${spent}${did}`);
|
|
143
329
|
break;
|
|
330
|
+
}
|
|
144
331
|
default:
|
|
145
332
|
break;
|
|
146
333
|
}
|
|
147
|
-
}, [push]);
|
|
334
|
+
}, [columns, push]);
|
|
148
335
|
const submit = useCallback((value) => {
|
|
149
336
|
const text = value.trim();
|
|
150
337
|
if (busy) {
|
|
@@ -161,6 +348,12 @@ function App({ engine, options, theme }) {
|
|
|
161
348
|
exit();
|
|
162
349
|
return;
|
|
163
350
|
}
|
|
351
|
+
if (text === '/help') {
|
|
352
|
+
push('note', 'commands', '/help /clear /exit · ↑ recalls, esc stops a turn');
|
|
353
|
+
push('note', 'the footer', 'in = what was sent · out = what came back · a number in brackets is part' +
|
|
354
|
+
' of the one before it, not an extra');
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
164
357
|
if (text === '/clear') {
|
|
165
358
|
setLines([]);
|
|
166
359
|
stdout?.write('\u001b[2J\u001b[H');
|
|
@@ -170,6 +363,12 @@ function App({ engine, options, theme }) {
|
|
|
170
363
|
setBusy(true);
|
|
171
364
|
setLive('');
|
|
172
365
|
setThinking('');
|
|
366
|
+
setStep(0);
|
|
367
|
+
grown.current = 0;
|
|
368
|
+
spent.current = zeroUsage();
|
|
369
|
+
startedAt.current = Date.now();
|
|
370
|
+
running.current.clear();
|
|
371
|
+
branches.current.clear();
|
|
173
372
|
const controller = new AbortController();
|
|
174
373
|
stopping.current = controller;
|
|
175
374
|
void (async () => {
|
|
@@ -201,7 +400,8 @@ function App({ engine, options, theme }) {
|
|
|
201
400
|
setBusy(false);
|
|
202
401
|
setLive('');
|
|
203
402
|
setThinking('');
|
|
204
|
-
|
|
403
|
+
running.current.clear();
|
|
404
|
+
branches.current.clear();
|
|
205
405
|
stopping.current = undefined;
|
|
206
406
|
}
|
|
207
407
|
})();
|
|
@@ -231,11 +431,12 @@ function App({ engine, options, theme }) {
|
|
|
231
431
|
recall(1);
|
|
232
432
|
}
|
|
233
433
|
});
|
|
234
|
-
return (_jsx(ThemeContext.Provider, { value: theme, children: _jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: [BANNER, ...lines], children: (item) => isBanner(item) ? (_jsx(Header, { engine: engine, readOnly: options.readOnly }, item.key)) : (_jsx(Row, { line: item }, item.key)) }), thinking ? (_jsx(Thinking, { text: thinking, columns: columns, rows:
|
|
434
|
+
return (_jsx(ThemeContext.Provider, { value: theme, children: _jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: [BANNER, ...lines], children: (item) => isBanner(item) ? (_jsx(Header, { engine: engine, readOnly: options.readOnly, started: options.started }, item.key)) : (_jsx(Row, { line: item }, item.key)) }), _jsxs(Box, { flexDirection: "column", justifyContent: "flex-end", height: busy ? grown.current : undefined, overflow: "hidden", children: [_jsx(Branches, { boxes: fitted.boxes, spin: spin, columns: columns }), _jsx(Activity, { rows: fitted.trunk, hidden: fitted.hidden }), thinking && budget.thinking ? (_jsx(Thinking, { text: thinking, columns: columns, rows: budget.thinking, spin: spin, live: streaming })) : null, live ? _jsx(Live, { text: live, columns: columns, rows: budget.live }) : null] }), _jsx(Footer, { agent: agent, busy: busy, spin: spin, running: mine, forked: branches.current.size, step: step, model: model, stats: stats, inflight: inflight, reasoning: streaming, columns: columns }), busy ? null : (_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, children: "\u203A " }), _jsx(TextInput, { defaultValue: draft, placeholder: "Ask something\u2026 (\u2191 for history, /help for commands)", onChange: remember, onSubmit: submit }, generation)] }))] }) }));
|
|
235
435
|
}
|
|
236
|
-
function Header({ engine, readOnly, }) {
|
|
436
|
+
function Header({ engine, readOnly, started, }) {
|
|
237
437
|
const theme = useTheme();
|
|
238
|
-
|
|
438
|
+
const model = engine.project.config.model;
|
|
439
|
+
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: engine.name }), started ? (_jsx(Text, { color: theme.accent, children: started.created ? ' new session' : ' continuing' })) : null, _jsxs(Text, { dimColor: true, children: [" ", engine.session.id] }), readOnly ? _jsx(Text, { color: theme.warn, children: " read-only" }) : null] }), _jsxs(Text, { dimColor: true, children: [started ? `${started.freshWorkspace ? 'new directory' : 'workspace'} ` : '', display(engine.workspace), model ? ` · ${model}` : ''] })] }));
|
|
239
440
|
}
|
|
240
441
|
// ---------------------------------------------------------------------------
|
|
241
442
|
// The repainting frame
|
|
@@ -258,44 +459,242 @@ function Header({ engine, readOnly, }) {
|
|
|
258
459
|
//
|
|
259
460
|
// Nothing is lost by any of it: the finished answer lands in `Static` whole,
|
|
260
461
|
// and the full reasoning chain is in the trajectory and the run's report.
|
|
462
|
+
//
|
|
463
|
+
// How the rows are divided between the three blocks is `budgetOf` in wrap.ts.
|
|
261
464
|
// ---------------------------------------------------------------------------
|
|
262
|
-
/** How much of the reasoning stream is worth showing. It is a progress bar. */
|
|
263
|
-
const THINKING_ROWS = 6;
|
|
264
|
-
/** The two footer rows, its margin, the prompt, and a row in hand. */
|
|
265
|
-
const CHROME_ROWS = 6;
|
|
266
465
|
/** The gutter every streaming block is indented behind. */
|
|
267
466
|
const GUTTER = 2;
|
|
268
|
-
|
|
467
|
+
/**
|
|
468
|
+
* A fan-out, demultiplexed into one box per branch. Interleaving eight
|
|
469
|
+
* branches' calls into a single list is not a picture of parallel work, it is
|
|
470
|
+
* eight pictures shuffled together; a box per branch is what `examples/board.ts`
|
|
471
|
+
* gets right and what makes the shape of the fork legible at a glance.
|
|
472
|
+
*
|
|
473
|
+
* A box shows the last few calls the branch made and whatever it has in flight,
|
|
474
|
+
* in that order: what it just did is why it is doing this. `share` is how many
|
|
475
|
+
* rows it may spend on them, so the region is bounded however wide the fork is.
|
|
476
|
+
*
|
|
477
|
+
* A branch between calls is not idle, it is deciding what to call next, so its
|
|
478
|
+
* box keeps a row saying so rather than collapsing and making the whole board
|
|
479
|
+
* jump every time a tool returns.
|
|
480
|
+
*/
|
|
481
|
+
function branchBoxesOf(tools, branches, now, width, lane, share) {
|
|
482
|
+
const boxes = [];
|
|
483
|
+
for (const b of branches.values()) {
|
|
484
|
+
// What it is reasoning about, kept to one row: a box is a status line
|
|
485
|
+
// per branch, not a second transcript.
|
|
486
|
+
const gist = b.thinking.trim() ? gistOf(b.thinking, width - 6) : '';
|
|
487
|
+
const room = Math.max(1, gist ? share - 1 : share);
|
|
488
|
+
const live = [];
|
|
489
|
+
for (const t of tools.values()) {
|
|
490
|
+
if (t.branch === b.name && live.length < room) {
|
|
491
|
+
const detail = ` ${secs(now - t.startedAt)}`;
|
|
492
|
+
live.push({
|
|
493
|
+
key: `t:${t.callId}`,
|
|
494
|
+
label: clip(`${t.name} ${readable(t.args)}`, width - detail.length - 2),
|
|
495
|
+
detail,
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
// Oldest of the calls that still fit, so the newest is always the row
|
|
500
|
+
// nearest the one running.
|
|
501
|
+
const past = b.done.slice(Math.max(0, b.done.length - (room - live.length)));
|
|
502
|
+
const rows = [
|
|
503
|
+
...past.map((d) => {
|
|
504
|
+
const detail = ` ${d.failed ? 'failed' : (durationOf(d.ms) ?? '')}`;
|
|
505
|
+
return {
|
|
506
|
+
key: `d:${d.callId}`,
|
|
507
|
+
label: clip(`✓ ${d.name} ${readable(d.args)}`, width - detail.length - 2),
|
|
508
|
+
detail,
|
|
509
|
+
past: true,
|
|
510
|
+
};
|
|
511
|
+
}),
|
|
512
|
+
// Between the calls it made and the one it is making: the reasoning
|
|
513
|
+
// is what got it from one to the other.
|
|
514
|
+
...(gist ? [{ key: `g:${b.name}`, label: `… ${gist}`, detail: '', past: true }] : []),
|
|
515
|
+
...live,
|
|
516
|
+
];
|
|
517
|
+
if (!rows.length) {
|
|
518
|
+
rows.push({ key: `w:${b.name}`, label: 'thinking…', detail: '', past: false });
|
|
519
|
+
}
|
|
520
|
+
boxes.push({
|
|
521
|
+
name: b.name,
|
|
522
|
+
color: lane(b.name),
|
|
523
|
+
title: `${b.name}${b.agent ? ` · ${b.agent}` : ''}`,
|
|
524
|
+
stats: `${b.steps} ${b.steps === 1 ? 'step' : 'steps'}` +
|
|
525
|
+
(b.tools ? ` ${b.tools} ${b.tools === 1 ? 'tool' : 'tools'}` : '') +
|
|
526
|
+
` ${secs(now - b.startedAt)}`,
|
|
527
|
+
rows: rows.slice(-share),
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
return boxes;
|
|
531
|
+
}
|
|
532
|
+
/** What the trunk itself has in flight: everything no live branch owns. */
|
|
533
|
+
function trunkCallsOf(tools, branches) {
|
|
534
|
+
return [...tools.values()].filter((t) => t.branch === undefined || !branches.has(t.branch));
|
|
535
|
+
}
|
|
536
|
+
/** What the trunk itself has in flight. It is one thread, so it gets no box. */
|
|
537
|
+
function trunkRowsOf(calls, now, width) {
|
|
538
|
+
return calls.map((t) => {
|
|
539
|
+
const detail = ` ${secs(now - t.startedAt)}`;
|
|
540
|
+
return {
|
|
541
|
+
key: `t:${t.callId}`,
|
|
542
|
+
label: clip(`${t.name} ${readable(t.args)}`, width - detail.length),
|
|
543
|
+
detail,
|
|
544
|
+
};
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
/** The rows the whole region wants before anything is cut. */
|
|
548
|
+
function activityHeight(boxes, trunk) {
|
|
549
|
+
return boxes.reduce((n, b) => n + BOX_CHROME + b.rows.length, 0) + trunk.length;
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Boxes first and whole: half a box is an opening rule with nothing to close
|
|
553
|
+
* it, so a branch that does not fit is counted rather than clipped.
|
|
554
|
+
*/
|
|
555
|
+
function fitActivity(boxes, trunk, allowance) {
|
|
556
|
+
const kept = [];
|
|
557
|
+
let used = 0;
|
|
558
|
+
for (const b of boxes) {
|
|
559
|
+
const h = BOX_CHROME + b.rows.length;
|
|
560
|
+
if (used + h > allowance) {
|
|
561
|
+
break;
|
|
562
|
+
}
|
|
563
|
+
kept.push(b);
|
|
564
|
+
used += h;
|
|
565
|
+
}
|
|
566
|
+
const left = boxes.length - kept.length;
|
|
567
|
+
const hidden = left && used < allowance ? left : 0;
|
|
568
|
+
return {
|
|
569
|
+
boxes: kept,
|
|
570
|
+
trunk: trunk.slice(0, Math.max(0, allowance - used - (hidden ? 1 : 0))),
|
|
571
|
+
hidden,
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
function Branches({ boxes, spin, columns, }) {
|
|
575
|
+
return (_jsx(Box, { flexDirection: "column", children: boxes.map((b) => {
|
|
576
|
+
// Ink truncates rather than wraps, so an over-wide rule clips
|
|
577
|
+
// instead of costing the box a row it was not given.
|
|
578
|
+
const used = 3 + b.title.length + 2 + 2 + b.stats.length + 1;
|
|
579
|
+
return (_jsxs(Box, { flexDirection: "column", height: BOX_CHROME + b.rows.length, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: '╭─ ' }), _jsx(Text, { color: b.color, children: b.title }), _jsx(Text, { color: b.color, children: ` ${spin}` }), _jsx(Text, { dimColor: true, children: ` ${b.stats} ` }), _jsx(Text, { dimColor: true, children: '─'.repeat(Math.max(0, columns - used)) })] }), b.rows.map((r) => (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: b.color, children: '│ ' }), _jsx(Text, { dimColor: true, children: r.label }), _jsx(Text, { dimColor: true, children: r.detail })] }, r.key))), _jsx(Text, { dimColor: true, children: '╰─' })] }, b.name));
|
|
580
|
+
}) }));
|
|
581
|
+
}
|
|
582
|
+
function Activity({ rows, hidden, }) {
|
|
583
|
+
if (!rows.length && !hidden) {
|
|
584
|
+
return null;
|
|
585
|
+
}
|
|
586
|
+
return (_jsxs(Box, { flexDirection: "column", height: rows.length + (hidden ? 1 : 0), overflow: "hidden", children: [rows.map((r) => (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: ' ' }), _jsx(Text, { color: r.color, dimColor: r.color === undefined, children: r.label }), _jsx(Text, { dimColor: true, children: r.detail })] }, r.key))), hidden ? (_jsx(Text, { dimColor: true, children: ` + ${hidden} more ${hidden === 1 ? 'branch' : 'branches'}` })) : null] }));
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* A stable colour per branch, handed out in order of first sight. Held in a ref
|
|
590
|
+
* so a branch keeps its colour for the whole turn rather than being recoloured
|
|
591
|
+
* every time the map is rebuilt.
|
|
592
|
+
*/
|
|
593
|
+
function useLanes(theme) {
|
|
594
|
+
const assigned = useRef(new Map());
|
|
595
|
+
return useCallback((name) => {
|
|
596
|
+
if (!name) {
|
|
597
|
+
return undefined;
|
|
598
|
+
}
|
|
599
|
+
const seen = assigned.current.get(name);
|
|
600
|
+
if (seen !== undefined) {
|
|
601
|
+
return seen;
|
|
602
|
+
}
|
|
603
|
+
const next = theme.lanes[assigned.current.size % theme.lanes.length];
|
|
604
|
+
assigned.current.set(name, next);
|
|
605
|
+
return next;
|
|
606
|
+
}, [theme]);
|
|
607
|
+
}
|
|
608
|
+
/** A reasoning summary's own headings, which arrive as markdown. */
|
|
609
|
+
const HEADING = /^\s*(?:#{1,6}\s*)?\*\*(.+?)\*\*[:.]?\s*$/;
|
|
610
|
+
const TITLE = 'reasoning';
|
|
611
|
+
/**
|
|
612
|
+
* Reasoning is dim prose sitting between dim tool rows and a dim footer, with
|
|
613
|
+
* nothing to say where it starts or stops — so it reads as part of whatever is
|
|
614
|
+
* above it, and it never leaves the bottom of the screen. A rule at each end
|
|
615
|
+
* gives it an edge. Round, to tell live chrome from the square fences an answer
|
|
616
|
+
* puts around code.
|
|
617
|
+
*/
|
|
618
|
+
/** Where a settled block got to: its last heading, or failing that its opening. */
|
|
619
|
+
function gistOf(text, width) {
|
|
620
|
+
const lines = text.split('\n');
|
|
621
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
622
|
+
const head = HEADING.exec(lines[i] ?? '');
|
|
623
|
+
if (head?.[1]) {
|
|
624
|
+
return clip(head[1], width);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
return clip(text, width);
|
|
628
|
+
}
|
|
629
|
+
function Thinking({ text, columns, rows, spin, live, }) {
|
|
269
630
|
const theme = useTheme();
|
|
270
|
-
|
|
271
|
-
|
|
631
|
+
// Settled, the block is down to one row, and the tail of a paragraph is a
|
|
632
|
+
// fragment ("ports.") rather than a summary. The last heading the model
|
|
633
|
+
// wrote is the shortest true account of where it got to.
|
|
634
|
+
const shown = live ? windowOf(text, columns - GUTTER, rows) : [gistOf(text, columns - GUTTER)];
|
|
635
|
+
// Ink truncates rather than wraps, so an over-wide rule clips instead of
|
|
636
|
+
// costing the block a row it was not given.
|
|
637
|
+
const dashes = Math.max(0, columns - TITLE.length - (live ? 6 : 4));
|
|
638
|
+
return (_jsxs(Box, { flexDirection: "column", height: shown.length + THINKING_CHROME, overflow: "hidden", children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: '╭─ ' }), _jsx(Text, { dimColor: true, children: TITLE }), live ? _jsx(Text, { color: theme.warn, children: ` ${spin}` }) : null, _jsx(Text, { dimColor: true, children: ` ${'─'.repeat(dashes)}` })] }), shown.map((row, i) => {
|
|
639
|
+
// Not italic: dim italic on a dark terminal is the least
|
|
640
|
+
// legible thing available, and this is meant to be read.
|
|
641
|
+
const head = HEADING.exec(row);
|
|
642
|
+
return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: '│ ' }), _jsx(Text, { dimColor: true, bold: head !== null, children: head ? head[1] : row })] }, i));
|
|
643
|
+
}), _jsx(Text, { dimColor: true, children: '╰─' })] }));
|
|
272
644
|
}
|
|
273
645
|
function Live({ text, columns, rows }) {
|
|
274
|
-
|
|
646
|
+
// The same width the finished answer will take, so landing it reflows
|
|
647
|
+
// nothing: what is on screen is what stays there.
|
|
648
|
+
const shown = windowOf(text, answerWidth(columns) - 4, rows);
|
|
275
649
|
return (_jsx(Box, { flexDirection: "column", paddingLeft: GUTTER, height: shown.length, overflow: "hidden", children: shown.map((row, i) => (_jsx(Text, { wrap: "truncate-end", children: row }, i))) }));
|
|
276
650
|
}
|
|
277
|
-
|
|
278
|
-
|
|
651
|
+
/** The label column the two number rows line up behind. */
|
|
652
|
+
const LABEL = 10;
|
|
653
|
+
function Footer({ agent, busy, spin, running, forked, step, model, stats, inflight, reasoning, columns, }) {
|
|
279
654
|
const theme = useTheme();
|
|
280
|
-
|
|
655
|
+
// Who before what. The agent is the subject of the sentence, and in a
|
|
656
|
+
// handoff it is the thing that changed.
|
|
657
|
+
const aside = [
|
|
658
|
+
...(step ? [`step ${step}`] : []),
|
|
659
|
+
...(model ? [model] : []),
|
|
660
|
+
'esc to stop',
|
|
661
|
+
].join(' · ');
|
|
662
|
+
// The argument is what identifies a generic tool — `run_command` is every
|
|
663
|
+
// shell command there is — but the activity row above carries it in full,
|
|
664
|
+
// so the footer takes it only when there is width to say something useful.
|
|
665
|
+
const first = running[0];
|
|
666
|
+
const room = columns - agent.length - aside.length - 12;
|
|
667
|
+
const what = running.length > 1
|
|
668
|
+
? `running ${running.length} tools`
|
|
669
|
+
: first
|
|
670
|
+
? room >= 24
|
|
671
|
+
? clip(`${first.name} ${readable(first.args)}`, room)
|
|
672
|
+
: `running ${first.name}`
|
|
673
|
+
: forked
|
|
674
|
+
? // Having forked, the trunk has nothing of its own to do. The
|
|
675
|
+
// boxes above say what the branches are doing.
|
|
676
|
+
`waiting on ${forked} ${forked === 1 ? 'branch' : 'branches'}`
|
|
677
|
+
: reasoning
|
|
678
|
+
? 'reasoning'
|
|
679
|
+
: 'thinking';
|
|
680
|
+
// A clip has already ended it with one.
|
|
681
|
+
const status = what.endsWith('…') ? what : `${what}…`;
|
|
682
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, dimColor: true, children: agent }), busy ? (_jsxs(Text, { color: theme.warn, children: [' ', spin, " ", status] })) : null, busy ? _jsx(Text, { dimColor: true, children: ` ${aside}` }) : null] }), inflight ? (_jsxs(Text, { dimColor: true, children: ['this turn'.padEnd(LABEL), tokens(inflight.usage), ` · ${secs(inflight.durationMs)}`] })) : (_jsxs(_Fragment, { children: [stats.turn ? (_jsxs(Text, { dimColor: true, children: ['last turn'.padEnd(LABEL), tokens(stats.turn), stats.durationMs === undefined
|
|
281
683
|
? ''
|
|
282
|
-
: ` · ${durationOf(stats.durationMs) ?? ''}`] })) : null,
|
|
684
|
+
: ` · ${durationOf(stats.durationMs) ?? ''}`] })) : null, _jsxs(Text, { dimColor: true, children: ['session'.padEnd(LABEL), tokens(stats.session), stats.calls
|
|
685
|
+
? ` · ${stats.calls} ${stats.calls === 1 ? 'call' : 'calls'}`
|
|
686
|
+
: ''] })] }))] }));
|
|
283
687
|
}
|
|
284
688
|
/**
|
|
285
|
-
* Cache and reasoning are
|
|
286
|
-
*
|
|
287
|
-
*
|
|
689
|
+
* Cache and reasoning are SUBSETS of the number beside them, so they are drawn
|
|
690
|
+
* inside it. On one dotted line they read as four things to add up, which is
|
|
691
|
+
* the single reading that is wrong. Both are skipped when the provider reports
|
|
692
|
+
* nothing — most do not, and a row of zeroes teaches nobody anything.
|
|
288
693
|
*/
|
|
289
694
|
function tokens(usage) {
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
}
|
|
294
|
-
parts.push(`${format(usage.outputTokens)} out`);
|
|
295
|
-
if (usage.reasoningTokens) {
|
|
296
|
-
parts.push(`${format(usage.reasoningTokens)} thinking`);
|
|
297
|
-
}
|
|
298
|
-
return parts.join(' · ');
|
|
695
|
+
const cached = usage.cachedInputTokens ? ` (${format(usage.cachedInputTokens)} cached)` : '';
|
|
696
|
+
const think = usage.reasoningTokens ? ` (${format(usage.reasoningTokens)} thinking)` : '';
|
|
697
|
+
return `${format(usage.inputTokens)} in${cached} · ${format(usage.outputTokens)} out${think}`;
|
|
299
698
|
}
|
|
300
699
|
/** What the last turn added. Usage only ever grows, so a subtraction is safe. */
|
|
301
700
|
function since(before, after) {
|
|
@@ -309,6 +708,23 @@ function since(before, after) {
|
|
|
309
708
|
function durationOf(ms) {
|
|
310
709
|
return ms === undefined ? undefined : ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`;
|
|
311
710
|
}
|
|
711
|
+
/** A clock that is being watched. Always seconds, so the digits do not jump. */
|
|
712
|
+
function secs(ms) {
|
|
713
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
714
|
+
}
|
|
715
|
+
/** What a finished tool call has to say for itself, under its own name. */
|
|
716
|
+
function detailOf(node, branch, width) {
|
|
717
|
+
const parts = [
|
|
718
|
+
...(branch ? [`⑂ ${branch}`] : []),
|
|
719
|
+
...(node.isError ? ['failed'] : []),
|
|
720
|
+
...(durationOf(node.durationMs) ? [durationOf(node.durationMs)] : []),
|
|
721
|
+
...(node.result.preview ? [readable(node.result.preview)] : []),
|
|
722
|
+
];
|
|
723
|
+
// One row. Two left a six-character orphan under most results, and a
|
|
724
|
+
// preview is already a preview: the whole of it is a click away in the
|
|
725
|
+
// report, and a wall of it here buries the next answer.
|
|
726
|
+
return clip(parts.join(' · '), Math.max(40, width));
|
|
727
|
+
}
|
|
312
728
|
// ---------------------------------------------------------------------------
|
|
313
729
|
export async function start(engine, options) {
|
|
314
730
|
// Asked before Ink takes the terminal: the query talks to stdin directly,
|