@zenera/cli 1.1.17 → 1.1.19
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 +99 -16
- package/dist/banner.d.ts +19 -0
- package/dist/banner.js +17 -10
- package/dist/command.d.ts +3 -0
- package/dist/commands/index.js +4 -1
- package/dist/commands/init.js +1 -1
- package/dist/commands/inspect.js +77 -13
- package/dist/commands/key.js +49 -1
- package/dist/commands/memory.js +16 -3
- package/dist/commands/meta.d.ts +3 -0
- package/dist/commands/meta.js +596 -0
- package/dist/commands/run.js +63 -38
- package/dist/engine.d.ts +15 -0
- package/dist/engine.js +20 -1
- package/dist/home.d.ts +2 -0
- package/dist/home.js +2 -0
- package/dist/main.js +1 -1
- package/dist/meta.d.ts +221 -0
- package/dist/meta.js +695 -0
- package/dist/podman.js +64 -2
- package/dist/projects.d.ts +9 -1
- package/dist/projects.js +19 -2
- package/dist/resolve.js +7 -1
- package/dist/sandbox.d.ts +2 -0
- package/dist/sandbox.js +1 -1
- package/dist/scaffold.js +1 -1
- package/dist/session.d.ts +10 -0
- package/dist/session.js +19 -1
- package/dist/term.d.ts +21 -0
- package/dist/term.js +74 -2
- package/dist/tui/app.js +313 -150
- package/dist/tui/theme.d.ts +26 -7
- package/dist/tui/theme.js +62 -36
- package/dist/tui/wrap.d.ts +41 -16
- package/dist/tui/wrap.js +191 -18
- package/dist/validate.js +3 -16
- package/package.json +3 -2
- package/templates/editor/.github/copilot-instructions.md +61 -7
- package/templates/editor/.github/prompts/{review-project.prompt.md → project-review.prompt.md} +0 -1
- package/templates/editor/.github/prompts/{update-spec-from-feedback.prompt.md → spec-apply-feedback.prompt.md} +5 -5
- package/templates/editor/.github/prompts/{sync-with-spec.prompt.md → spec-sync-project.prompt.md} +4 -4
- package/templates/editor/.github/skills/zen-cli/references/check.md +0 -2
- package/templates/editor/.github/skills/zen-cli/references/inspect.md +4 -3
- package/templates/editor/.github/skills/zen-cli/references/memory.md +14 -0
- package/templates/editor/.github/skills/zen-cli/references/projects.md +1 -0
- package/templates/editor/.github/skills/zen-cli/references/run.md +75 -16
- package/templates/editor/.github/skills/zen-memory/SKILL.md +52 -4
- package/templates/editor/.github/skills/zen-review/SKILL.md +2 -2
- package/templates/editor/.github/skills/zen-review/scripts/review.sh +1 -1
- package/templates/editor/.github/skills/zen-spec-sync/SKILL.md +2 -2
- package/templates/editor/.github/skills/zen-spec-sync/scripts/snapshot.sh +1 -1
- package/templates/project/SPECIFICATION.md.tmpl +25 -2
- package/templates/project/agents/tools-instructions.md +8 -0
- package/templates/project/agents.yaml.tmpl +3 -3
- package/templates/project/env +7 -1
- package/templates/project/gitignore +4 -4
package/dist/tui/app.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { TextInput } from '@inkjs/ui';
|
|
3
3
|
import { addUsage, isCheckpoint, turns, zeroUsage, } from '@zenera/neo';
|
|
4
4
|
import { Box, Static, Text, useApp, useInput, useStdout } from 'ink';
|
|
@@ -10,13 +10,14 @@ 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 { ACTIVITY_ROWS, answerWidth, BOX_CHROME, BRANCH_ROWS, branchRows, budgetOf, CHROME_ROWS, clip,
|
|
13
|
+
import { ACTIVITY_ROWS, answerWidth, BOX_CHROME, BRANCH_ROWS, branchRows, budgetOf, CHROME_ROWS, clip, describeCall, gistOf, segmentsOf, summarise, THINKING_ROWS, TIME_COL, windowOf, } from "./wrap.js";
|
|
14
14
|
const BANNER = { key: 'banner' };
|
|
15
15
|
const isBanner = (item) => item.key === 'banner';
|
|
16
16
|
const MARK = {
|
|
17
17
|
you: '›',
|
|
18
|
+
text: ' ',
|
|
18
19
|
agent: ' ',
|
|
19
|
-
tool: '
|
|
20
|
+
tool: ' ',
|
|
20
21
|
note: ' ',
|
|
21
22
|
error: '!',
|
|
22
23
|
};
|
|
@@ -24,29 +25,75 @@ const MARK = {
|
|
|
24
25
|
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
25
26
|
/** Redraw cadence for the activity region, independent of events. */
|
|
26
27
|
const FRAME_MS = 100;
|
|
27
|
-
/** How long after the last delta reasoning is still called live. */
|
|
28
|
-
const SETTLE_MS = 800;
|
|
29
28
|
// The theme is decided once, before the first frame, and never changes while
|
|
30
29
|
// the app is up — a terminal does not repaint its own scheme underneath us.
|
|
31
30
|
// A context rather than props only because every part of the view wants it.
|
|
32
31
|
const ThemeContext = React.createContext(THEMES.dark);
|
|
33
32
|
const useTheme = () => useContext(ThemeContext);
|
|
34
33
|
function Row({ line }) {
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
34
|
+
const theme = useTheme();
|
|
35
|
+
const style = theme.line[line.kind];
|
|
36
|
+
if (line.kind === 'you') {
|
|
37
|
+
return _jsx(Input, { text: line.text });
|
|
38
|
+
}
|
|
39
|
+
if (line.kind === 'tool') {
|
|
40
|
+
return _jsx(Call, { line: line });
|
|
41
|
+
}
|
|
42
|
+
if (line.kind === 'text' || line.kind === 'agent') {
|
|
43
|
+
return _jsx(Answer, { text: line.text, boxed: line.kind === 'agent' });
|
|
44
|
+
}
|
|
45
|
+
return (_jsxs(Box, { flexDirection: "row", children: [_jsxs(Text, { color: style.color, children: [MARK[line.kind], " "] }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: style.color, children: line.text }), line.detail ? _jsx(Text, { color: theme.chrome.color, children: line.detail }) : null] })] }));
|
|
41
46
|
}
|
|
42
|
-
|
|
47
|
+
/**
|
|
48
|
+
* The question, framed and labelled.
|
|
49
|
+
*
|
|
50
|
+
* It is the one thing on screen the person wrote, and scrolled past it is what
|
|
51
|
+
* everything below it is an answer to — so it gets a rule rather than a caret,
|
|
52
|
+
* in the accent, with the text itself left in the terminal's own foreground.
|
|
53
|
+
* The label is in the top rule because a frame with a name needs no legend.
|
|
54
|
+
*/
|
|
55
|
+
function Input({ text }) {
|
|
56
|
+
const theme = useTheme();
|
|
57
|
+
const { stdout } = useStdout();
|
|
58
|
+
const width = Math.max(INPUT_LABEL.length + 8, answerWidth(stdout?.columns ?? 80));
|
|
59
|
+
return (_jsxs(Box, { flexDirection: "column", width: width, marginY: 1, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: theme.accent, children: '\u256d\u2500 ' }), _jsx(Text, { color: theme.accent, bold: true, children: INPUT_LABEL }), _jsx(Text, { color: theme.accent, children: ` ${'\u2500'.repeat(width - INPUT_LABEL.length - 5)}\u256e` })] }), _jsx(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.accent, borderTop: false, paddingX: 1, children: _jsx(Text, { children: plain(text) }) })] }));
|
|
60
|
+
}
|
|
61
|
+
const INPUT_LABEL = '[input]';
|
|
62
|
+
/** C0 controls, which a paste carries and which move the cursor where it was not sent. */
|
|
63
|
+
const CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g;
|
|
64
|
+
/** A pasted prompt, reduced to what a terminal can be trusted to draw in place. */
|
|
65
|
+
function plain(text) {
|
|
66
|
+
return text.replace(/\r\n?/g, '\n').replace(/\t/g, ' ').replace(CONTROL, '').trim();
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* One finished call: how long it took, what it did, and what came back.
|
|
70
|
+
*
|
|
71
|
+
* Time first, because it is the one column that lines up down the page and the
|
|
72
|
+
* one number worth scanning for. Nothing in the row is lifted out of the grey —
|
|
73
|
+
* a call is read when it is looked for, not while the prose around it is.
|
|
74
|
+
* The blank row is spent on the *first* call after something else, so a batch
|
|
75
|
+
* of them reads as one block rather than a ladder.
|
|
76
|
+
*/
|
|
77
|
+
function Call({ line }) {
|
|
78
|
+
const theme = useTheme();
|
|
79
|
+
return (_jsx(Box, { marginTop: line.gap ? 1 : 0, children: _jsxs(Text, { wrap: "truncate-end", color: theme.chrome.color, children: [_jsx(Text, { children: ` ${(line.time ?? '').padStart(TIME_COL)} ` }), _jsx(Text, { children: line.lead }), _jsx(Text, { children: line.text ? ` ${line.text}` : '' }), _jsx(Text, { children: line.detail ? ` ${line.detail}` : '' })] }) }));
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Prose the agent wrote, at the width a paragraph is worth reading at.
|
|
83
|
+
*
|
|
84
|
+
* Only the last one is boxed. A turn says several things on its way to an
|
|
85
|
+
* answer and boxing each of them turns the transcript into a stack of frames;
|
|
86
|
+
* the box means *this is the answer*, and it only means that if it is rare.
|
|
87
|
+
*/
|
|
88
|
+
function Answer({ text, boxed }) {
|
|
89
|
+
const theme = useTheme();
|
|
43
90
|
const { stdout } = useStdout();
|
|
44
91
|
const width = answerWidth(stdout?.columns ?? 80);
|
|
45
92
|
const segments = segmentsOf(text);
|
|
46
|
-
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
return (_jsx(Box, { flexDirection: "column", width: width, borderStyle: "round",
|
|
93
|
+
const body = segments.map((s, i) => s.code ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: theme.chrome.color, children: `\u250c\u2500${s.title ? ` ${s.title}` : ''}` }), s.lines.map((l, j) => (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: theme.chrome.color, children: '\u2502 ' }), _jsx(Text, { wrap: "truncate-end", children: l || ' ' })] }, j))), _jsx(Text, { color: theme.chrome.color, children: '\u2514\u2500' })] }, i)) : (_jsx(Text, { children: s.lines.join('\n') }, i)));
|
|
94
|
+
// Bounded, like every answer in `examples/sdk/`: prose that runs the width of a
|
|
95
|
+
// wide terminal is a worse read than prose that stops.
|
|
96
|
+
return boxed ? (_jsx(Box, { flexDirection: "column", width: width, borderStyle: "round", borderColor: theme.chrome.color, paddingX: 1, marginY: 1, children: body })) : (_jsx(Box, { flexDirection: "column", width: width, paddingLeft: GUTTER, marginTop: 1, children: body }));
|
|
50
97
|
}
|
|
51
98
|
function App({ engine, options, theme }) {
|
|
52
99
|
const { exit } = useApp();
|
|
@@ -63,6 +110,8 @@ function App({ engine, options, theme }) {
|
|
|
63
110
|
/** Model calls in the turn now running, and what answered the last one. */
|
|
64
111
|
const [step, setStep] = useState(0);
|
|
65
112
|
const [model, setModel] = useState(undefined);
|
|
113
|
+
/** Whether a trunk model call is open, which is what the shimmer means. */
|
|
114
|
+
const [musing, setMusing] = useState(false);
|
|
66
115
|
// In flight, and therefore not in the transcript yet. Refs, because the
|
|
67
116
|
// activity region below is redrawn by the frame timer regardless.
|
|
68
117
|
const running = useRef(new Map());
|
|
@@ -73,8 +122,17 @@ function App({ engine, options, theme }) {
|
|
|
73
122
|
// exact figure replaces it when the turn lands.
|
|
74
123
|
const spent = useRef(zeroUsage());
|
|
75
124
|
const startedAt = useRef(0);
|
|
76
|
-
|
|
77
|
-
|
|
125
|
+
// The two streams, mirrored in refs.
|
|
126
|
+
//
|
|
127
|
+
// State is what draws them; the ref is what any other event can read. Both
|
|
128
|
+
// are settled by something that happens *later* — prose is committed to the
|
|
129
|
+
// scrollback when the turn moves on to a call, reasoning when the model
|
|
130
|
+
// call it belongs to lands — and an event handler reading state would be
|
|
131
|
+
// reading whatever the last render closed over.
|
|
132
|
+
const said = useRef('');
|
|
133
|
+
const mind = useRef('');
|
|
134
|
+
/** The last prose committed, so the answer is not printed twice. */
|
|
135
|
+
const told = useRef('');
|
|
78
136
|
const stopping = useRef(undefined);
|
|
79
137
|
const seq = useRef(0);
|
|
80
138
|
/** The tallest the repainting region has been this turn, which is the height it keeps. */
|
|
@@ -148,39 +206,31 @@ function App({ engine, options, theme }) {
|
|
|
148
206
|
// fan-out is read across, not down.
|
|
149
207
|
const allowance = Math.min(ACTIVITY_ROWS, Math.max(0, rows - CHROME_ROWS - 2));
|
|
150
208
|
const boxes = busy
|
|
151
|
-
? branchBoxesOf(running.current, branches.current, Date.now(), columns - GUTTER, lane, branchRows(branches.current.size, allowance))
|
|
209
|
+
? branchBoxesOf(running.current, branches.current, Date.now(), columns - GUTTER, lane, spin, branchRows(branches.current.size, allowance))
|
|
152
210
|
: [];
|
|
153
211
|
// What the trunk itself has in flight. A branch's call is the branch's
|
|
154
212
|
// business — it has a box saying so — and the trunk, having forked, is
|
|
155
213
|
// waiting at the join.
|
|
156
214
|
const mine = busy ? trunkCallsOf(running.current, branches.current) : [];
|
|
157
|
-
const trunkRows = busy ? trunkRowsOf(mine, Date.now(), columns - GUTTER - 2) : [];
|
|
215
|
+
const trunkRows = busy ? trunkRowsOf(mine, Date.now(), columns - GUTTER - 2, spin) : [];
|
|
158
216
|
// Priced as far as it has got. Read during render, so the frame timer is
|
|
159
217
|
// what advances the clock.
|
|
160
218
|
const inflight = busy
|
|
161
219
|
? { usage: spent.current, durationMs: Date.now() - startedAt.current }
|
|
162
220
|
: undefined;
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
|
|
166
|
-
|
|
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);
|
|
221
|
+
// Up from the first reasoning token until the step arrives at prose or a
|
|
222
|
+
// call, which is when what it was deciding stops being the news.
|
|
223
|
+
const streaming = busy && thinking !== '';
|
|
224
|
+
const budget = budgetOf(rows, activityHeight(boxes, trunkRows), streaming ? THINKING_ROWS : 0);
|
|
173
225
|
const fitted = fitActivity(boxes, trunkRows, budget.activity);
|
|
174
226
|
// How tall the region actually needs to be, mirroring what the three blocks
|
|
175
|
-
// below draw.
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
: 0;
|
|
180
|
-
const liveRows = live ? windowOf(live, answerWidth(columns) - 4, budget.live).length : 0;
|
|
227
|
+
// below draw. The answer keeps a blank row above it, so what it is separated
|
|
228
|
+
// from is whatever the last call left on screen.
|
|
229
|
+
const textRows = Math.max(1, budget.live - 1);
|
|
230
|
+
const liveRows = live ? windowOf(live, answerWidth(columns) - 4, textRows).length + 1 : 0;
|
|
181
231
|
const wanted = activityHeight(fitted.boxes, fitted.trunk) +
|
|
182
232
|
(fitted.hidden ? 1 : 0) +
|
|
183
|
-
|
|
233
|
+
(streaming ? budget.thinking : 0) +
|
|
184
234
|
liveRows;
|
|
185
235
|
// A turn opens against the prompt and grows up from it, one row at a time,
|
|
186
236
|
// the way anything else printed to a terminal does. Reserving the whole
|
|
@@ -188,17 +238,49 @@ function App({ engine, options, theme }) {
|
|
|
188
238
|
// before a word of the answer existed. It only ever grows, so the footer
|
|
189
239
|
// still never rides back up: what a block gives back is left as slack.
|
|
190
240
|
grown.current = busy ? Math.min(budget.total, Math.max(grown.current, wanted)) : 0;
|
|
191
|
-
const push = useCallback((kind, text, detail, lead) => {
|
|
192
|
-
setLines((prev) => [
|
|
241
|
+
const push = useCallback((kind, text, detail, lead, time) => {
|
|
242
|
+
setLines((prev) => [
|
|
243
|
+
...prev,
|
|
244
|
+
{
|
|
245
|
+
key: `${seq.current++}`,
|
|
246
|
+
kind,
|
|
247
|
+
lead,
|
|
248
|
+
text,
|
|
249
|
+
detail,
|
|
250
|
+
time,
|
|
251
|
+
// Only the call that opens a run of them is worth a blank
|
|
252
|
+
// row: the gap says *the prose stopped here*, and repeating
|
|
253
|
+
// it between siblings says nothing.
|
|
254
|
+
gap: kind === 'tool' && prev[prev.length - 1]?.kind !== 'tool',
|
|
255
|
+
},
|
|
256
|
+
]);
|
|
193
257
|
}, []);
|
|
258
|
+
/**
|
|
259
|
+
* Commit whatever the model has said so far to the scrollback.
|
|
260
|
+
*
|
|
261
|
+
* Called when the turn moves on to something else — a call, a fork, a
|
|
262
|
+
* handoff — because that is the moment the prose stops being *what is
|
|
263
|
+
* arriving* and becomes *what was said before this*. Prose that is never
|
|
264
|
+
* followed by a call is the answer, so it is still on screen when the turn
|
|
265
|
+
* lands, and the boxed answer replaces it in place.
|
|
266
|
+
*/
|
|
267
|
+
const settle = useCallback(() => {
|
|
268
|
+
const text = said.current.trim();
|
|
269
|
+
said.current = '';
|
|
270
|
+
setLive('');
|
|
271
|
+
if (text) {
|
|
272
|
+
told.current = text;
|
|
273
|
+
push('text', text);
|
|
274
|
+
}
|
|
275
|
+
}, [push]);
|
|
194
276
|
// Deltas arrive far faster than a terminal can usefully redraw, so text is
|
|
195
277
|
// accumulated in one string and React coalesces the repaints. The finished
|
|
196
278
|
// answer replaces it in one piece when the turn lands.
|
|
197
279
|
//
|
|
198
|
-
// Reasoning is accumulated the same way
|
|
199
|
-
//
|
|
200
|
-
// the
|
|
201
|
-
//
|
|
280
|
+
// Reasoning is accumulated the same way and dropped when its model call
|
|
281
|
+
// lands: the whole chain is in the trajectory (`LlmCallNode.thinking`) and
|
|
282
|
+
// the run's report, and a summary of how an answer was reached is worth
|
|
283
|
+
// watching happen and not worth a row once it has.
|
|
202
284
|
const onEvent = useCallback((event) => {
|
|
203
285
|
if (!isCheckpoint(event)) {
|
|
204
286
|
// Only the trunk's answer is drawn. A fork has several running
|
|
@@ -216,13 +298,21 @@ function App({ engine, options, theme }) {
|
|
|
216
298
|
return;
|
|
217
299
|
}
|
|
218
300
|
if (!event.branch) {
|
|
219
|
-
|
|
220
|
-
setThinking(
|
|
301
|
+
mind.current += event.delta;
|
|
302
|
+
setThinking(mind.current);
|
|
221
303
|
}
|
|
222
304
|
return;
|
|
223
305
|
}
|
|
224
306
|
if (event.type === 'text_delta' && !event.branch) {
|
|
225
|
-
|
|
307
|
+
// Whichever of prose or a call the step arrives at first
|
|
308
|
+
// retires the gist; the content streaming under a heading
|
|
309
|
+
// does not, which is why the heading holds still.
|
|
310
|
+
if (mind.current) {
|
|
311
|
+
mind.current = '';
|
|
312
|
+
setThinking('');
|
|
313
|
+
}
|
|
314
|
+
said.current += event.delta;
|
|
315
|
+
setLive(said.current);
|
|
226
316
|
}
|
|
227
317
|
return;
|
|
228
318
|
}
|
|
@@ -237,18 +327,35 @@ function App({ engine, options, theme }) {
|
|
|
237
327
|
}
|
|
238
328
|
}
|
|
239
329
|
else {
|
|
330
|
+
// A step that answered without calling anything leaves
|
|
331
|
+
// its gist up; this is where it goes.
|
|
332
|
+
mind.current = '';
|
|
240
333
|
setThinking('');
|
|
334
|
+
setMusing(true);
|
|
241
335
|
setStep((n) => n + 1);
|
|
242
336
|
}
|
|
243
337
|
break;
|
|
244
338
|
case 'after_llm_call':
|
|
245
339
|
// Branches included: they are what this turn is spending on.
|
|
246
340
|
spent.current = addUsage(spent.current, event.node.usage);
|
|
247
|
-
if (
|
|
248
|
-
|
|
341
|
+
if (from) {
|
|
342
|
+
break;
|
|
249
343
|
}
|
|
344
|
+
setModel(event.node.model);
|
|
345
|
+
// The gist stays until the call it decided on goes out. Only
|
|
346
|
+
// the shimmer stops, because nothing is arriving any more.
|
|
347
|
+
setMusing(false);
|
|
250
348
|
break;
|
|
251
349
|
case 'before_tool_call':
|
|
350
|
+
// Everything the model said on the way to this call is now
|
|
351
|
+
// history, and history belongs above the call, not below it.
|
|
352
|
+
// The gist goes with it: the call is what it decided on, so
|
|
353
|
+
// the row saying so supersedes the row explaining it.
|
|
354
|
+
if (!from) {
|
|
355
|
+
settle();
|
|
356
|
+
mind.current = '';
|
|
357
|
+
setThinking('');
|
|
358
|
+
}
|
|
252
359
|
running.current.set(event.call.callId, {
|
|
253
360
|
callId: event.call.callId,
|
|
254
361
|
name: event.call.name,
|
|
@@ -283,7 +390,8 @@ function App({ engine, options, theme }) {
|
|
|
283
390
|
// difference between knowing a tool ran and knowing what
|
|
284
391
|
// the agent did. Both are previews already; a whole file
|
|
285
392
|
// read belongs in the report, not in the scrollback.
|
|
286
|
-
|
|
393
|
+
const view = describeCall(node.name, call?.args ?? '');
|
|
394
|
+
push('tool', clip(view.subject, Math.max(20, columns - view.verb.length - TIME_COL - 10)), detailOf(node, columns - 4), view.verb, durationOf(node.durationMs) ?? '');
|
|
287
395
|
break;
|
|
288
396
|
}
|
|
289
397
|
case 'handoff': {
|
|
@@ -294,19 +402,18 @@ function App({ engine, options, theme }) {
|
|
|
294
402
|
b.agent = event.to;
|
|
295
403
|
break;
|
|
296
404
|
}
|
|
405
|
+
settle();
|
|
297
406
|
setAgent(event.to);
|
|
298
407
|
push('note', `→ ${event.to}`, `handed off from ${event.from}`);
|
|
299
408
|
break;
|
|
300
409
|
}
|
|
301
410
|
case 'before_fork':
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
// the join, so nothing else would clear it and it would sit
|
|
305
|
-
// there for the length of the fork looking hung.
|
|
411
|
+
// A fork is what the trunk concluded, so whatever it was
|
|
412
|
+
// saying is finished as far as the screen is concerned.
|
|
306
413
|
if (from) {
|
|
307
414
|
break;
|
|
308
415
|
}
|
|
309
|
-
|
|
416
|
+
settle();
|
|
310
417
|
push('note', `⑂ ${event.node.branches.length} branches`, `${event.node.branches.map((b) => b.name).join(', ')} · context ${event.node.contextMode}`);
|
|
311
418
|
break;
|
|
312
419
|
case 'branch_started':
|
|
@@ -323,15 +430,14 @@ function App({ engine, options, theme }) {
|
|
|
323
430
|
case 'branch_finished': {
|
|
324
431
|
const b = branches.current.get(event.child.name);
|
|
325
432
|
branches.current.delete(event.child.name);
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
push('tool', `⑂ ${event.child.name}`, `${event.status}${spent}${did}`);
|
|
433
|
+
const did = b ? `${b.steps} steps · ${b.tools} tools` : '';
|
|
434
|
+
push('tool', event.child.name, [event.status, did].filter(Boolean).join(' · '), '⑂ branch', b ? (durationOf(Date.now() - b.startedAt) ?? '') : '');
|
|
329
435
|
break;
|
|
330
436
|
}
|
|
331
437
|
default:
|
|
332
438
|
break;
|
|
333
439
|
}
|
|
334
|
-
}, [columns, push]);
|
|
440
|
+
}, [columns, push, settle]);
|
|
335
441
|
const submit = useCallback((value) => {
|
|
336
442
|
const text = value.trim();
|
|
337
443
|
if (busy) {
|
|
@@ -363,7 +469,11 @@ function App({ engine, options, theme }) {
|
|
|
363
469
|
setBusy(true);
|
|
364
470
|
setLive('');
|
|
365
471
|
setThinking('');
|
|
472
|
+
said.current = '';
|
|
473
|
+
mind.current = '';
|
|
474
|
+
told.current = '';
|
|
366
475
|
setStep(0);
|
|
476
|
+
setMusing(false);
|
|
367
477
|
grown.current = 0;
|
|
368
478
|
spent.current = zeroUsage();
|
|
369
479
|
startedAt.current = Date.now();
|
|
@@ -374,7 +484,15 @@ function App({ engine, options, theme }) {
|
|
|
374
484
|
void (async () => {
|
|
375
485
|
try {
|
|
376
486
|
const outcome = await Engine.run(engine, text, onEvent, controller.signal);
|
|
377
|
-
|
|
487
|
+
// The answer is what the turn ended on, and it is normally
|
|
488
|
+
// still on screen unboxed — nothing followed it, so nothing
|
|
489
|
+
// committed it. Boxing it here replaces it in place. It is
|
|
490
|
+
// only skipped when the last thing the model said was
|
|
491
|
+
// already filed as prose, which is what an aborted turn
|
|
492
|
+
// looks like: the text is above the call it was cut off at.
|
|
493
|
+
if (outcome.text.trim() && outcome.text.trim() !== told.current) {
|
|
494
|
+
push('agent', outcome.text);
|
|
495
|
+
}
|
|
378
496
|
// The previous total is the only thing that can say what
|
|
379
497
|
// this turn cost, and reading it out of the updater is what
|
|
380
498
|
// makes that true regardless of when the turn lands.
|
|
@@ -400,6 +518,9 @@ function App({ engine, options, theme }) {
|
|
|
400
518
|
setBusy(false);
|
|
401
519
|
setLive('');
|
|
402
520
|
setThinking('');
|
|
521
|
+
setMusing(false);
|
|
522
|
+
said.current = '';
|
|
523
|
+
mind.current = '';
|
|
403
524
|
running.current.clear();
|
|
404
525
|
branches.current.clear();
|
|
405
526
|
stopping.current = undefined;
|
|
@@ -431,12 +552,12 @@ function App({ engine, options, theme }) {
|
|
|
431
552
|
recall(1);
|
|
432
553
|
}
|
|
433
554
|
});
|
|
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 }),
|
|
555
|
+
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 }), streaming && budget.thinking ? (_jsx(Reasoning, { text: thinking, columns: columns, frame: frame, live: musing })) : null, _jsx(Activity, { rows: fitted.trunk, hidden: fitted.hidden }), live ? _jsx(Streaming, { text: live, columns: columns, rows: textRows }) : null] }), _jsx(Footer, { agent: agent, busy: busy, spin: spin, frame: frame, running: mine, forked: branches.current.size, step: step, model: model, stats: stats, inflight: inflight, reasoning: musing && streaming }), 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)] }))] }) }));
|
|
435
556
|
}
|
|
436
557
|
function Header({ engine, readOnly, started, }) {
|
|
437
558
|
const theme = useTheme();
|
|
438
559
|
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, {
|
|
560
|
+
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, { color: theme.chrome.color, children: [" ", engine.session.id] }), readOnly ? _jsx(Text, { color: theme.warn, children: " read-only" }) : null] }), _jsxs(Text, { color: theme.chrome.color, children: [started ? `${started.freshWorkspace ? 'new directory' : 'workspace'} ` : '', display(engine.workspace), model ? ` · ${model}` : ''] })] }));
|
|
440
561
|
}
|
|
441
562
|
// ---------------------------------------------------------------------------
|
|
442
563
|
// The repainting frame
|
|
@@ -467,7 +588,7 @@ const GUTTER = 2;
|
|
|
467
588
|
/**
|
|
468
589
|
* A fan-out, demultiplexed into one box per branch. Interleaving eight
|
|
469
590
|
* 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`
|
|
591
|
+
* eight pictures shuffled together; a box per branch is what `examples/sdk/board.ts`
|
|
471
592
|
* gets right and what makes the shape of the fork legible at a glance.
|
|
472
593
|
*
|
|
473
594
|
* A box shows the last few calls the branch made and whatever it has in flight,
|
|
@@ -478,46 +599,66 @@ const GUTTER = 2;
|
|
|
478
599
|
* box keeps a row saying so rather than collapsing and making the whole board
|
|
479
600
|
* jump every time a tool returns.
|
|
480
601
|
*/
|
|
481
|
-
function branchBoxesOf(tools, branches, now, width, lane, share) {
|
|
602
|
+
function branchBoxesOf(tools, branches, now, width, lane, spin, share) {
|
|
482
603
|
const boxes = [];
|
|
604
|
+
const room = (w) => Math.max(12, w - TIME_COL - 8);
|
|
483
605
|
for (const b of branches.values()) {
|
|
484
606
|
// What it is reasoning about, kept to one row: a box is a status line
|
|
485
607
|
// per branch, not a second transcript.
|
|
486
|
-
const gist = b.thinking.trim() ? gistOf(b.thinking, width - 6) : '';
|
|
487
|
-
const
|
|
608
|
+
const gist = b.thinking.trim() ? gistOf(b.thinking, width - TIME_COL - 6) : '';
|
|
609
|
+
const budget = Math.max(1, gist ? share - 1 : share);
|
|
488
610
|
const live = [];
|
|
489
611
|
for (const t of tools.values()) {
|
|
490
|
-
if (t.branch === b.name && live.length <
|
|
491
|
-
const
|
|
612
|
+
if (t.branch === b.name && live.length < budget) {
|
|
613
|
+
const view = describeCall(t.name, t.args);
|
|
492
614
|
live.push({
|
|
493
615
|
key: `t:${t.callId}`,
|
|
494
|
-
|
|
495
|
-
|
|
616
|
+
mark: spin,
|
|
617
|
+
time: secs(now - t.startedAt),
|
|
618
|
+
lead: view.verb,
|
|
619
|
+
text: clip(view.subject, room(width) - view.verb.length),
|
|
496
620
|
});
|
|
497
621
|
}
|
|
498
622
|
}
|
|
499
623
|
// Oldest of the calls that still fit, so the newest is always the row
|
|
500
624
|
// nearest the one running.
|
|
501
|
-
const past = b.done.slice(Math.max(0, b.done.length - (
|
|
625
|
+
const past = b.done.slice(Math.max(0, b.done.length - (budget - live.length)));
|
|
502
626
|
const rows = [
|
|
503
627
|
...past.map((d) => {
|
|
504
|
-
const
|
|
628
|
+
const view = describeCall(d.name, d.args);
|
|
505
629
|
return {
|
|
506
630
|
key: `d:${d.callId}`,
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
631
|
+
mark: d.failed ? '✗' : '✓',
|
|
632
|
+
time: d.failed ? 'failed' : (durationOf(d.ms) ?? ''),
|
|
633
|
+
lead: view.verb,
|
|
634
|
+
text: clip(view.subject, room(width) - view.verb.length),
|
|
510
635
|
};
|
|
511
636
|
}),
|
|
512
637
|
// Between the calls it made and the one it is making: the reasoning
|
|
513
638
|
// is what got it from one to the other.
|
|
514
639
|
...(gist
|
|
515
|
-
? [
|
|
640
|
+
? [
|
|
641
|
+
{
|
|
642
|
+
key: `g:${b.name}`,
|
|
643
|
+
mark: '✻',
|
|
644
|
+
time: '',
|
|
645
|
+
lead: '',
|
|
646
|
+
text: gist,
|
|
647
|
+
thinking: true,
|
|
648
|
+
},
|
|
649
|
+
]
|
|
516
650
|
: []),
|
|
517
651
|
...live,
|
|
518
652
|
];
|
|
519
653
|
if (!rows.length) {
|
|
520
|
-
rows.push({
|
|
654
|
+
rows.push({
|
|
655
|
+
key: `w:${b.name}`,
|
|
656
|
+
mark: '✻',
|
|
657
|
+
time: '',
|
|
658
|
+
lead: '',
|
|
659
|
+
text: 'thinking…',
|
|
660
|
+
thinking: true,
|
|
661
|
+
});
|
|
521
662
|
}
|
|
522
663
|
boxes.push({
|
|
523
664
|
name: b.name,
|
|
@@ -536,13 +677,15 @@ function trunkCallsOf(tools, branches) {
|
|
|
536
677
|
return [...tools.values()].filter((t) => t.branch === undefined || !branches.has(t.branch));
|
|
537
678
|
}
|
|
538
679
|
/** What the trunk itself has in flight. It is one thread, so it gets no box. */
|
|
539
|
-
function trunkRowsOf(calls, now, width) {
|
|
680
|
+
function trunkRowsOf(calls, now, width, spin) {
|
|
540
681
|
return calls.map((t) => {
|
|
541
|
-
const
|
|
682
|
+
const view = describeCall(t.name, t.args);
|
|
542
683
|
return {
|
|
543
684
|
key: `t:${t.callId}`,
|
|
544
|
-
|
|
545
|
-
|
|
685
|
+
mark: spin,
|
|
686
|
+
time: secs(now - t.startedAt),
|
|
687
|
+
lead: view.verb,
|
|
688
|
+
text: clip(view.subject, Math.max(12, width - TIME_COL - view.verb.length - 6)),
|
|
546
689
|
};
|
|
547
690
|
});
|
|
548
691
|
}
|
|
@@ -579,14 +722,29 @@ function Branches({ boxes, spin, columns, }) {
|
|
|
579
722
|
// Ink truncates rather than wraps, so an over-wide rule clips
|
|
580
723
|
// instead of costing the box a row it was not given.
|
|
581
724
|
const used = 3 + b.title.length + 2 + 2 + b.stats.length + 1;
|
|
582
|
-
return (_jsxs(Box, { flexDirection: "column", height: BOX_CHROME + b.rows.length, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, {
|
|
725
|
+
return (_jsxs(Box, { flexDirection: "column", height: BOX_CHROME + b.rows.length, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: theme.chrome.color, children: '╭─ ' }), _jsx(Text, { color: b.color, children: b.title }), _jsx(Text, { color: b.color, children: ` ${spin}` }), _jsx(Text, { color: theme.chrome.color, children: ` ${b.stats} ` }), _jsx(Text, { color: theme.chrome.color, children: '─'.repeat(Math.max(0, columns - used)) })] }), b.rows.map((r) => (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: b.color, children: '│ ' }), _jsx(Work, { row: r }, r.key)] }, r.key))), _jsx(Text, { color: theme.chrome.color, children: '╰─' })] }, b.name));
|
|
583
726
|
}) }));
|
|
584
727
|
}
|
|
728
|
+
/**
|
|
729
|
+
* A row of work in the same shape the transcript will keep it in.
|
|
730
|
+
*
|
|
731
|
+
* `mark` occupies the two columns a committed row leaves blank, so the spinner
|
|
732
|
+
* sits where nothing will be once the call returns and the time, verb and
|
|
733
|
+
* subject never move.
|
|
734
|
+
*/
|
|
735
|
+
function Work({ row }) {
|
|
736
|
+
const theme = useTheme();
|
|
737
|
+
if (row.thinking) {
|
|
738
|
+
return _jsx(Text, { color: theme.thinking.color, children: `${row.mark} ${row.text}` });
|
|
739
|
+
}
|
|
740
|
+
return (_jsxs(Text, { color: theme.chrome.color, children: [_jsx(Text, { color: row.color, children: `${row.mark} ` }), _jsx(Text, { children: `${row.time.padStart(TIME_COL)} ` }), _jsx(Text, { color: row.color, children: row.lead }), _jsx(Text, { children: row.text ? ` ${row.text}` : '' })] }));
|
|
741
|
+
}
|
|
585
742
|
function Activity({ rows, hidden, }) {
|
|
743
|
+
const theme = useTheme();
|
|
586
744
|
if (!rows.length && !hidden) {
|
|
587
745
|
return null;
|
|
588
746
|
}
|
|
589
|
-
return (_jsxs(Box, { flexDirection: "column", height: rows.length + (hidden ? 1 : 0), overflow: "hidden", children: [rows.map((r) => (
|
|
747
|
+
return (_jsxs(Box, { flexDirection: "column", height: rows.length + (hidden ? 1 : 0), overflow: "hidden", children: [rows.map((r) => (_jsx(Text, { wrap: "truncate-end", children: _jsx(Work, { row: r }) }, r.key))), hidden ? (_jsx(Text, { color: theme.chrome.color, children: ` + ${hidden} more ${hidden === 1 ? 'branch' : 'branches'}` })) : null] }));
|
|
590
748
|
}
|
|
591
749
|
/**
|
|
592
750
|
* A stable colour per branch, handed out in order of first sight. Held in a ref
|
|
@@ -608,54 +766,63 @@ function useLanes(theme) {
|
|
|
608
766
|
return next;
|
|
609
767
|
}, [theme]);
|
|
610
768
|
}
|
|
611
|
-
/** A reasoning summary's own headings, which arrive as markdown. */
|
|
612
|
-
const HEADING = /^\s*(?:#{1,6}\s*)?\*\*(.+?)\*\*[:.]?\s*$/;
|
|
613
|
-
const TITLE = 'reasoning';
|
|
614
769
|
/**
|
|
615
|
-
*
|
|
616
|
-
*
|
|
617
|
-
*
|
|
618
|
-
*
|
|
619
|
-
*
|
|
770
|
+
* Where the model has got to, in one line.
|
|
771
|
+
*
|
|
772
|
+
* A reasoning summary is a heading, not a document. Six rows of it pushed the
|
|
773
|
+
* answer down the screen and asked to be read at the same time as the text
|
|
774
|
+
* arriving below it, and once the deltas stopped there was nothing to say the
|
|
775
|
+
* paragraph had finished — a block that had quietly settled read as a hang.
|
|
776
|
+
*
|
|
777
|
+
* So the stream is drawn as its gist, and the shimmer is the proof it is
|
|
778
|
+
* moving. The line outlives the call that wrote it — it is why the calls below
|
|
779
|
+
* it are being made, and a gist that vanished with its own model call was on
|
|
780
|
+
* screen for a fraction of the work it explains — but the shimmer stops, so a
|
|
781
|
+
* settled thought is never mistaken for an arriving one.
|
|
620
782
|
*/
|
|
621
|
-
|
|
622
|
-
function gistOf(text, width) {
|
|
623
|
-
const lines = text.split('\n');
|
|
624
|
-
for (let i = lines.length - 1; i >= 0; i--) {
|
|
625
|
-
const head = HEADING.exec(lines[i] ?? '');
|
|
626
|
-
if (head?.[1]) {
|
|
627
|
-
return clip(head[1], width);
|
|
628
|
-
}
|
|
629
|
-
}
|
|
630
|
-
return clip(text, width);
|
|
631
|
-
}
|
|
632
|
-
function Thinking({ text, columns, rows, spin, live, }) {
|
|
783
|
+
function Reasoning({ text, columns, frame, live, }) {
|
|
633
784
|
const theme = useTheme();
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
// wrote is the shortest true account of where it got to.
|
|
637
|
-
const shown = live ? windowOf(text, columns - GUTTER, rows) : [gistOf(text, columns - GUTTER)];
|
|
638
|
-
// Ink truncates rather than wraps, so an over-wide rule clips instead of
|
|
639
|
-
// costing the block a row it was not given.
|
|
640
|
-
const dashes = Math.max(0, columns - TITLE.length - (live ? 6 : 4));
|
|
641
|
-
const { color, dim } = theme.thinking;
|
|
642
|
-
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, { color: color, children: TITLE }), live ? _jsx(Text, { color: color, children: ` ${spin}` }) : null, _jsx(Text, { dimColor: true, children: ` ${'─'.repeat(dashes)}` })] }), shown.map((row, i) => {
|
|
643
|
-
// Not italic, and not dim: dim italic on a dark terminal is the
|
|
644
|
-
// least legible thing available, and this is meant to be read.
|
|
645
|
-
// Its own hue is what separates it from the tool rows instead.
|
|
646
|
-
const head = HEADING.exec(row);
|
|
647
|
-
return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: color, children: '│ ' }), _jsx(Text, { color: color, dimColor: dim, bold: head !== null, children: head ? head[1] : row })] }, i));
|
|
648
|
-
}), _jsx(Text, { dimColor: true, children: '╰─' })] }));
|
|
785
|
+
const gist = gistOf(text, columns - GUTTER - 2);
|
|
786
|
+
return (_jsx(Box, { height: 1, paddingLeft: GUTTER, overflow: "hidden", children: _jsx(Text, { wrap: "truncate-end", children: live ? (_jsx(Shimmer, { text: gist, frame: frame, color: theme.thinking.color })) : (_jsx(Text, { color: theme.thinking.color, children: gist })) }) }));
|
|
649
787
|
}
|
|
650
|
-
function
|
|
788
|
+
function Streaming({ text, columns, rows }) {
|
|
651
789
|
// The same width the finished answer will take, so landing it reflows
|
|
652
790
|
// nothing: what is on screen is what stays there.
|
|
653
791
|
const shown = windowOf(text, answerWidth(columns) - 4, rows);
|
|
654
|
-
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))) }));
|
|
792
|
+
return (_jsx(Box, { flexDirection: "column", paddingLeft: GUTTER, marginTop: 1, height: shown.length, overflow: "hidden", children: shown.map((row, i) => (_jsx(Text, { wrap: "truncate-end", children: row }, i))) }));
|
|
655
793
|
}
|
|
656
794
|
/** The label column the two number rows line up behind. */
|
|
657
795
|
const LABEL = 10;
|
|
658
|
-
|
|
796
|
+
/** Frames the crest spends past the end, so a sweep reads as a pass, not a loop. */
|
|
797
|
+
const SWEEP_GAP = 6;
|
|
798
|
+
/**
|
|
799
|
+
* A crest of weight travelling through a word, left to right.
|
|
800
|
+
*
|
|
801
|
+
* The spinner is proof the process is alive, but it is chrome and the eye
|
|
802
|
+
* reads the word beside it — a word that never moves is what a hung run looks
|
|
803
|
+
* like. The wave is weight rather than hue because the theme has no ramp to
|
|
804
|
+
* spend: a short bold crest travels through the word, and the word keeps
|
|
805
|
+
* whatever colour its role gave it. The trough is the word's own weight, not
|
|
806
|
+
* `dim` — a hue with `dim` over it is what Apple Terminal renders as nothing.
|
|
807
|
+
*/
|
|
808
|
+
function Shimmer({ text, frame, color, }) {
|
|
809
|
+
const chars = [...text];
|
|
810
|
+
// A new word is a new thing to say, so the sweep starts over on it rather
|
|
811
|
+
// than picking the crest up wherever the last word had got to.
|
|
812
|
+
const begun = useRef(frame);
|
|
813
|
+
const said = useRef(text);
|
|
814
|
+
if (said.current !== text) {
|
|
815
|
+
said.current = text;
|
|
816
|
+
begun.current = frame;
|
|
817
|
+
}
|
|
818
|
+
const head = (frame - begun.current) % (chars.length + SWEEP_GAP);
|
|
819
|
+
return (_jsx(Text, { children: chars.map((ch, i) => {
|
|
820
|
+
// The crest sits on the head and the tail drags behind it.
|
|
821
|
+
const behind = head - i;
|
|
822
|
+
return (_jsx(Text, { color: color, bold: behind >= 0 && behind <= 2, children: ch }, i));
|
|
823
|
+
}) }));
|
|
824
|
+
}
|
|
825
|
+
function Footer({ agent, busy, spin, frame, running, forked, step, model, stats, inflight, reasoning, }) {
|
|
659
826
|
const theme = useTheme();
|
|
660
827
|
// Who before what. The agent is the subject of the sentence, and in a
|
|
661
828
|
// handoff it is the thing that changed.
|
|
@@ -664,32 +831,25 @@ function Footer({ agent, busy, spin, running, forked, step, model, stats, inflig
|
|
|
664
831
|
...(model ? [model] : []),
|
|
665
832
|
'esc to stop',
|
|
666
833
|
].join(' · ');
|
|
667
|
-
//
|
|
668
|
-
//
|
|
669
|
-
//
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
const
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
: forked
|
|
834
|
+
// One word for what the turn is spending its time on, and the three are
|
|
835
|
+
// different kinds of waiting: the model is composing, the model is
|
|
836
|
+
// reasoning about what to compose, or the model is not running at all and
|
|
837
|
+
// something else is. What the call actually is belongs to the row above,
|
|
838
|
+
// which has the width to say it.
|
|
839
|
+
const phase = running.length || forked ? 'waiting' : reasoning ? 'reasoning' : 'working';
|
|
840
|
+
const what = phase !== 'waiting'
|
|
841
|
+
? phase
|
|
842
|
+
: running.length > 1
|
|
843
|
+
? `waiting on ${running.length} tools`
|
|
844
|
+
: forked && !running.length
|
|
679
845
|
? // Having forked, the trunk has nothing of its own to do. The
|
|
680
846
|
// boxes above say what the branches are doing.
|
|
681
847
|
`waiting on ${forked} ${forked === 1 ? 'branch' : 'branches'}`
|
|
682
|
-
:
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
// A clip has already ended it with one.
|
|
686
|
-
const status = what.endsWith('…') ? what : `${what}…`;
|
|
687
|
-
// Nothing of its own in flight means the word above is about reasoning, so
|
|
688
|
-
// it takes the same hue the reasoning block does.
|
|
689
|
-
const musing = !running.length && !forked;
|
|
690
|
-
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, children: agent }), busy ? (_jsxs(Text, { color: musing ? theme.thinking.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
|
|
848
|
+
: 'waiting';
|
|
849
|
+
const hue = theme.phase[phase];
|
|
850
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, children: agent }), busy ? (_jsxs(Text, { color: hue, children: [' ', spin, " ", _jsx(Shimmer, { text: `${what}…`, frame: frame, color: hue })] })) : null, busy ? _jsx(Text, { color: theme.chrome.color, children: ` ${aside}` }) : null] }), inflight ? (_jsxs(Text, { color: theme.chrome.color, children: ['this turn'.padEnd(LABEL), tokens(inflight.usage), ` · ${secs(inflight.durationMs)}`] })) : (_jsxs(_Fragment, { children: [stats.turn ? (_jsxs(Text, { color: theme.chrome.color, children: ['last turn'.padEnd(LABEL), tokens(stats.turn), stats.durationMs === undefined
|
|
691
851
|
? ''
|
|
692
|
-
: ` · ${durationOf(stats.durationMs) ?? ''}`] })) : null, _jsxs(Text, {
|
|
852
|
+
: ` · ${durationOf(stats.durationMs) ?? ''}`] })) : null, _jsxs(Text, { color: theme.chrome.color, children: ['session'.padEnd(LABEL), tokens(stats.session), stats.calls
|
|
693
853
|
? ` · ${stats.calls} ${stats.calls === 1 ? 'call' : 'calls'}`
|
|
694
854
|
: ''] })] }))] }));
|
|
695
855
|
}
|
|
@@ -720,14 +880,17 @@ function durationOf(ms) {
|
|
|
720
880
|
function secs(ms) {
|
|
721
881
|
return `${(ms / 1000).toFixed(1)}s`;
|
|
722
882
|
}
|
|
723
|
-
/**
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
883
|
+
/**
|
|
884
|
+
* What a finished tool call has to say for itself.
|
|
885
|
+
*
|
|
886
|
+
* The duration has its own column on the row now, and the branch has its own
|
|
887
|
+
* box, so what is left is the outcome: `summarise` reads the fields that matter
|
|
888
|
+
* for the tools the runtime ships and falls back to the raw preview for the
|
|
889
|
+
* ones it does not know.
|
|
890
|
+
*/
|
|
891
|
+
function detailOf(node, width) {
|
|
892
|
+
const said = summarise(node.name, node.result.preview ?? '');
|
|
893
|
+
const parts = [...(node.isError ? ['failed'] : []), ...(said ? [said] : [])];
|
|
731
894
|
// One row. Two left a six-character orphan under most results, and a
|
|
732
895
|
// preview is already a preview: the whole of it is a click away in the
|
|
733
896
|
// report, and a wall of it here buries the next answer.
|