@zenera/cli 1.1.9 → 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 +3 -1
- 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/history.d.ts +18 -0
- package/dist/history.js +93 -0
- 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 +25 -2
- package/dist/term.js +224 -10
- package/dist/tui/app.d.ts +10 -0
- package/dist/tui/app.js +542 -58
- 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 +202 -0
- package/templates/editor/.github/skills/zen-cli/SKILL.md +2 -1
- 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-cli/references/rag.md +104 -0
- 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 +28 -20
- package/templates/editor/.vscode/settings.json +1 -1
package/dist/tui/app.js
CHANGED
|
@@ -1,15 +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 { addUsage, isCheckpoint, turns, zeroUsage, } from '@zenera/neo';
|
|
3
4
|
import { Box, Static, Text, useApp, useInput, useStdout } from 'ink';
|
|
4
5
|
import { pathToFileURL } from 'node:url';
|
|
5
|
-
import React, { useCallback, useContext, useRef, useState } from 'react';
|
|
6
|
-
import { isCheckpoint, turns, zeroUsage } from '@zenera/neo';
|
|
6
|
+
import React, { useCallback, useContext, useEffect, useRef, useState } from 'react';
|
|
7
7
|
import * as Engine from "../engine.js";
|
|
8
|
+
import { History } from "../history.js";
|
|
8
9
|
import { format } from "../narrate.js";
|
|
9
10
|
import { display } from "../session.js";
|
|
10
11
|
import { CliError } from "../term.js";
|
|
11
12
|
import { resolveTheme, THEMES } from "./theme.js";
|
|
12
|
-
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";
|
|
13
14
|
const BANNER = { key: 'banner' };
|
|
14
15
|
const isBanner = (item) => item.key === 'banner';
|
|
15
16
|
const MARK = {
|
|
@@ -19,6 +20,12 @@ const MARK = {
|
|
|
19
20
|
note: ' ',
|
|
20
21
|
error: '!',
|
|
21
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;
|
|
22
29
|
// The theme is decided once, before the first frame, and never changes while
|
|
23
30
|
// the app is up — a terminal does not repaint its own scheme underneath us.
|
|
24
31
|
// A context rather than props only because every part of the view wants it.
|
|
@@ -26,7 +33,20 @@ const ThemeContext = React.createContext(THEMES.dark);
|
|
|
26
33
|
const useTheme = () => useContext(ThemeContext);
|
|
27
34
|
function Row({ line }) {
|
|
28
35
|
const style = useTheme().line[line.kind];
|
|
29
|
-
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))) }));
|
|
30
50
|
}
|
|
31
51
|
function App({ engine, options, theme }) {
|
|
32
52
|
const { exit } = useApp();
|
|
@@ -40,19 +60,136 @@ function App({ engine, options, theme }) {
|
|
|
40
60
|
session: engine.state?.usage ?? zeroUsage(),
|
|
41
61
|
calls: engine.state ? turns(engine.state) : 0,
|
|
42
62
|
});
|
|
43
|
-
|
|
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);
|
|
44
78
|
const stopping = useRef(undefined);
|
|
45
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);
|
|
82
|
+
// The prompt, and how to get back into it what was asked before.
|
|
83
|
+
//
|
|
84
|
+
// `TextInput` is uncontrolled: it takes a starting value and owns it from
|
|
85
|
+
// there. So recalling a line means handing it a new starting value and a
|
|
86
|
+
// new `key`, which mounts a fresh input — that is also what puts the cursor
|
|
87
|
+
// at the end of the recalled text, where it is wanted.
|
|
88
|
+
const [history] = useState(() => History.open(engine.project.root));
|
|
89
|
+
const [draft, setDraft] = useState('');
|
|
90
|
+
const [generation, setGeneration] = useState(0);
|
|
91
|
+
/** Where in the history the prompt is; `entries.length` is the live line. */
|
|
92
|
+
const at = useRef(history.entries.length);
|
|
93
|
+
/** What the input holds right now, which nothing else can see. */
|
|
94
|
+
const typed = useRef('');
|
|
95
|
+
/** The half-written line browsing started from, returned to by walking back down. */
|
|
96
|
+
const pending = useRef('');
|
|
97
|
+
const remember = useCallback((value) => {
|
|
98
|
+
typed.current = value;
|
|
99
|
+
}, []);
|
|
100
|
+
const reset = useCallback(() => {
|
|
101
|
+
at.current = history.entries.length;
|
|
102
|
+
pending.current = '';
|
|
103
|
+
typed.current = '';
|
|
104
|
+
setDraft('');
|
|
105
|
+
setGeneration((g) => g + 1);
|
|
106
|
+
}, [history]);
|
|
107
|
+
const recall = useCallback((delta) => {
|
|
108
|
+
const items = history.entries;
|
|
109
|
+
const end = items.length;
|
|
110
|
+
if (end === 0) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (at.current === end) {
|
|
114
|
+
if (delta > 0) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
pending.current = typed.current;
|
|
118
|
+
}
|
|
119
|
+
const next = Math.min(end, Math.max(0, at.current + delta));
|
|
120
|
+
if (next === at.current) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
at.current = next;
|
|
124
|
+
const value = next === end ? pending.current : (items[next] ?? '');
|
|
125
|
+
typed.current = value;
|
|
126
|
+
setDraft(value);
|
|
127
|
+
setGeneration((g) => g + 1);
|
|
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]);
|
|
46
140
|
// Read during render so a resize, which re-renders the root, resizes the
|
|
47
|
-
// windows below with it. The
|
|
141
|
+
// windows below with it. The three repainting blocks share one budget: what
|
|
48
142
|
// is left of the terminal once the chrome has had its rows.
|
|
49
143
|
const rows = stdout?.rows ?? 24;
|
|
50
144
|
const columns = stdout?.columns ?? 80;
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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 }]);
|
|
56
193
|
}, []);
|
|
57
194
|
// Deltas arrive far faster than a terminal can usefully redraw, so text is
|
|
58
195
|
// accumulated in one string and React coalesces the repaints. The finished
|
|
@@ -64,48 +201,159 @@ function App({ engine, options, theme }) {
|
|
|
64
201
|
// is lost when it is cleared at the start of the next model call.
|
|
65
202
|
const onEvent = useCallback((event) => {
|
|
66
203
|
if (!isCheckpoint(event)) {
|
|
67
|
-
|
|
68
|
-
|
|
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;
|
|
69
223
|
}
|
|
70
|
-
|
|
71
|
-
|
|
224
|
+
if (event.type === 'text_delta' && !event.branch) {
|
|
225
|
+
setLive((prev) => prev + event.delta);
|
|
72
226
|
}
|
|
73
227
|
return;
|
|
74
228
|
}
|
|
229
|
+
const from = event.branch?.name;
|
|
75
230
|
switch (event.type) {
|
|
76
231
|
case 'before_llm_call':
|
|
77
|
-
|
|
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
|
+
}
|
|
78
250
|
break;
|
|
79
251
|
case 'before_tool_call':
|
|
80
|
-
|
|
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
|
+
});
|
|
81
259
|
break;
|
|
82
|
-
case 'after_tool_call':
|
|
83
|
-
|
|
84
|
-
|
|
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);
|
|
85
287
|
break;
|
|
86
|
-
|
|
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
|
+
}
|
|
87
297
|
setAgent(event.to);
|
|
88
298
|
push('note', `→ ${event.to}`, `handed off from ${event.from}`);
|
|
89
299
|
break;
|
|
300
|
+
}
|
|
90
301
|
case 'before_fork':
|
|
91
|
-
|
|
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}`);
|
|
92
311
|
break;
|
|
93
|
-
case '
|
|
94
|
-
|
|
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
|
+
});
|
|
95
322
|
break;
|
|
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}`);
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
96
331
|
default:
|
|
97
332
|
break;
|
|
98
333
|
}
|
|
99
|
-
}, [push]);
|
|
334
|
+
}, [columns, push]);
|
|
100
335
|
const submit = useCallback((value) => {
|
|
101
336
|
const text = value.trim();
|
|
102
|
-
if (
|
|
337
|
+
if (busy) {
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
// Recorded before it is acted on, so a question that fails — or
|
|
341
|
+
// one that quits — is still one arrow key away next time.
|
|
342
|
+
history.add(text);
|
|
343
|
+
reset();
|
|
344
|
+
if (!text) {
|
|
103
345
|
return;
|
|
104
346
|
}
|
|
105
347
|
if (text === '/exit' || text === '/quit') {
|
|
106
348
|
exit();
|
|
107
349
|
return;
|
|
108
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
|
+
}
|
|
109
357
|
if (text === '/clear') {
|
|
110
358
|
setLines([]);
|
|
111
359
|
stdout?.write('\u001b[2J\u001b[H');
|
|
@@ -115,6 +363,12 @@ function App({ engine, options, theme }) {
|
|
|
115
363
|
setBusy(true);
|
|
116
364
|
setLive('');
|
|
117
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();
|
|
118
372
|
const controller = new AbortController();
|
|
119
373
|
stopping.current = controller;
|
|
120
374
|
void (async () => {
|
|
@@ -146,28 +400,43 @@ function App({ engine, options, theme }) {
|
|
|
146
400
|
setBusy(false);
|
|
147
401
|
setLive('');
|
|
148
402
|
setThinking('');
|
|
149
|
-
|
|
403
|
+
running.current.clear();
|
|
404
|
+
branches.current.clear();
|
|
150
405
|
stopping.current = undefined;
|
|
151
406
|
}
|
|
152
407
|
})();
|
|
153
|
-
}, [busy, engine, exit, onEvent, push, stdout]);
|
|
408
|
+
}, [busy, engine, exit, history, onEvent, push, reset, stdout]);
|
|
154
409
|
// Escape stops the turn; ctrl-c leaves. They are different things, and a
|
|
155
410
|
// run that is asked to stop still writes its state, so the session survives
|
|
156
411
|
// either one.
|
|
157
|
-
|
|
412
|
+
//
|
|
413
|
+
// The arrows are ours because `TextInput` explicitly ignores them; a turn
|
|
414
|
+
// in flight owns the keyboard, so they only walk the history while idle.
|
|
415
|
+
useInput((input, keys) => {
|
|
158
416
|
if (keys.escape && stopping.current) {
|
|
159
417
|
stopping.current.abort();
|
|
160
418
|
}
|
|
161
|
-
if (keys.ctrl &&
|
|
419
|
+
if (keys.ctrl && input === 'c') {
|
|
162
420
|
stopping.current?.abort();
|
|
163
421
|
exit();
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
if (busy) {
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
if (keys.upArrow) {
|
|
428
|
+
recall(-1);
|
|
429
|
+
}
|
|
430
|
+
else if (keys.downArrow) {
|
|
431
|
+
recall(1);
|
|
164
432
|
}
|
|
165
433
|
});
|
|
166
|
-
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)] }))] }) }));
|
|
167
435
|
}
|
|
168
|
-
function Header({ engine, readOnly, }) {
|
|
436
|
+
function Header({ engine, readOnly, started, }) {
|
|
169
437
|
const theme = useTheme();
|
|
170
|
-
|
|
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}` : ''] })] }));
|
|
171
440
|
}
|
|
172
441
|
// ---------------------------------------------------------------------------
|
|
173
442
|
// The repainting frame
|
|
@@ -190,44 +459,242 @@ function Header({ engine, readOnly, }) {
|
|
|
190
459
|
//
|
|
191
460
|
// Nothing is lost by any of it: the finished answer lands in `Static` whole,
|
|
192
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.
|
|
193
464
|
// ---------------------------------------------------------------------------
|
|
194
|
-
/** How much of the reasoning stream is worth showing. It is a progress bar. */
|
|
195
|
-
const THINKING_ROWS = 6;
|
|
196
|
-
/** The two footer rows, its margin, the prompt, and a row in hand. */
|
|
197
|
-
const CHROME_ROWS = 6;
|
|
198
465
|
/** The gutter every streaming block is indented behind. */
|
|
199
466
|
const GUTTER = 2;
|
|
200
|
-
|
|
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, }) {
|
|
201
630
|
const theme = useTheme();
|
|
202
|
-
|
|
203
|
-
|
|
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: '╰─' })] }));
|
|
204
644
|
}
|
|
205
645
|
function Live({ text, columns, rows }) {
|
|
206
|
-
|
|
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);
|
|
207
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))) }));
|
|
208
650
|
}
|
|
209
|
-
|
|
210
|
-
|
|
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, }) {
|
|
211
654
|
const theme = useTheme();
|
|
212
|
-
|
|
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
|
|
213
683
|
? ''
|
|
214
|
-
: ` · ${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
|
+
: ''] })] }))] }));
|
|
215
687
|
}
|
|
216
688
|
/**
|
|
217
|
-
* Cache and reasoning are
|
|
218
|
-
*
|
|
219
|
-
*
|
|
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.
|
|
220
693
|
*/
|
|
221
694
|
function tokens(usage) {
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
}
|
|
226
|
-
parts.push(`${format(usage.outputTokens)} out`);
|
|
227
|
-
if (usage.reasoningTokens) {
|
|
228
|
-
parts.push(`${format(usage.reasoningTokens)} thinking`);
|
|
229
|
-
}
|
|
230
|
-
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}`;
|
|
231
698
|
}
|
|
232
699
|
/** What the last turn added. Usage only ever grows, so a subtraction is safe. */
|
|
233
700
|
function since(before, after) {
|
|
@@ -241,6 +708,23 @@ function since(before, after) {
|
|
|
241
708
|
function durationOf(ms) {
|
|
242
709
|
return ms === undefined ? undefined : ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`;
|
|
243
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
|
+
}
|
|
244
728
|
// ---------------------------------------------------------------------------
|
|
245
729
|
export async function start(engine, options) {
|
|
246
730
|
// Asked before Ink takes the terminal: the query talks to stdin directly,
|