@zenera/cli 1.1.18 → 1.1.20
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 +6 -0
- package/dist/banner.d.ts +8 -0
- package/dist/banner.js +10 -4
- package/dist/commands/cache.js +1 -0
- package/dist/commands/check.js +34 -2
- package/dist/commands/index.js +1 -1
- package/dist/commands/init.js +1 -1
- package/dist/commands/meta.js +1 -1
- package/dist/commands/open.js +1 -1
- package/dist/main.js +8 -1
- package/dist/meta.js +19 -7
- package/dist/scaffold.d.ts +37 -0
- package/dist/scaffold.js +67 -3
- package/dist/term.d.ts +16 -0
- package/dist/term.js +91 -1
- package/dist/tui/app.js +493 -186
- package/dist/tui/theme.d.ts +36 -7
- package/dist/tui/theme.js +66 -36
- package/dist/tui/wrap.d.ts +140 -33
- package/dist/tui/wrap.js +690 -44
- package/dist/validate.js +113 -20
- package/package.json +2 -2
- package/templates/editor/.github/copilot-instructions.md +35 -10
- package/templates/editor/.github/skills/zen-cli/references/check.md +0 -2
- package/templates/editor/.github/skills/zen-cli/references/projects.md +20 -8
- package/templates/editor/.github/skills/zen-memory/SKILL.md +77 -33
- package/templates/editor/.github/skills/zen-memory/scripts/check-instructions.sh +2 -1
- package/templates/editor/.vscode/agents.schema.json +611 -0
- package/templates/editor/.vscode/extensions.json +3 -0
- package/templates/editor/.vscode/settings.json +8 -0
- package/templates/project/agents/fork-instructions.md +151 -0
- package/templates/project/agents/memory-instructions.md +120 -8
- package/templates/project/agents/tools-instructions.md +8 -0
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, blocksOf, BOX_CHROME, boxWidth, BRANCH_ROWS, branchRows, budgetOf, CHROME_ROWS, clip, describeCall, gistOf, gridOf, summarise, THINKING_ROWS, TIME_COL, unmarked, 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,122 @@ 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] })] }));
|
|
46
|
+
}
|
|
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
|
+
* A run of inline markup, drawn as nested children of an unstyled parent.
|
|
83
|
+
*
|
|
84
|
+
* Ink styles a whole `<Text>` or none of it, so the weights have to be
|
|
85
|
+
* separate elements. They are still one text node to the layout, which is what
|
|
86
|
+
* lets the parent wrap across a `**bold**` without it becoming its own line.
|
|
87
|
+
*/
|
|
88
|
+
function Spans({ spans }) {
|
|
89
|
+
const theme = useTheme();
|
|
90
|
+
return (_jsx(_Fragment, { children: spans.map((s, i) => (_jsx(Text, { bold: s.bold === true, italic: s.italic === true, underline: s.href !== undefined && s.href !== '', ...(s.code ? { color: theme.code.color } : {}), children: s.text }, i))) }));
|
|
41
91
|
}
|
|
42
|
-
|
|
92
|
+
/**
|
|
93
|
+
* One block of an answer.
|
|
94
|
+
*
|
|
95
|
+
* A fence is cut rather than wrapped, because its indentation is its meaning.
|
|
96
|
+
* A table is laid out by `gridOf` and only ever styled a whole row at a time —
|
|
97
|
+
* a span is measured by its text, so nothing here can move a column.
|
|
98
|
+
*/
|
|
99
|
+
function BlockView({ block, gap, width, }) {
|
|
100
|
+
const theme = useTheme();
|
|
101
|
+
const spans = block.spans ?? [];
|
|
102
|
+
switch (block.kind) {
|
|
103
|
+
case 'code':
|
|
104
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: gap, children: [_jsx(Text, { color: theme.chrome.color, children: `\u250c\u2500${block.title ? ` ${block.title}` : ''}` }), (block.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' })] }));
|
|
105
|
+
case 'table':
|
|
106
|
+
return (_jsx(Box, { flexDirection: "column", marginTop: gap, children: gridOf(block).map((row, j) => (_jsx(Text, { wrap: "truncate-end", bold: block.align !== undefined && j === 0, ...(block.align !== undefined && j === 1
|
|
107
|
+
? { color: theme.chrome.color }
|
|
108
|
+
: {}), children: _jsx(Spans, { spans: row }) }, j))) }));
|
|
109
|
+
case 'heading':
|
|
110
|
+
// One weight, not a ladder of them: a terminal has bold and it has
|
|
111
|
+
// nothing else, so every level of heading is the same bold and the
|
|
112
|
+
// nesting is carried by the words.
|
|
113
|
+
return (_jsx(Box, { marginTop: gap, children: _jsx(Text, { bold: true, children: _jsx(Spans, { spans: spans }) }) }));
|
|
114
|
+
case 'item':
|
|
115
|
+
return (_jsxs(Box, { flexDirection: "row", marginTop: gap, paddingLeft: (block.level ?? 0) * 2, children: [_jsx(Text, { color: theme.chrome.color, children: `${block.marker ?? '\u2022'} ` }), _jsx(Box, { flexGrow: 1, children: _jsx(Text, { children: _jsx(Spans, { spans: spans }) }) })] }));
|
|
116
|
+
case 'quote':
|
|
117
|
+
return (_jsxs(Box, { flexDirection: "row", marginTop: gap, children: [_jsx(Text, { color: theme.chrome.color, children: '\u2502 ' }), _jsx(Box, { flexGrow: 1, children: _jsx(Text, { italic: true, children: _jsx(Spans, { spans: spans }) }) })] }));
|
|
118
|
+
case 'rule':
|
|
119
|
+
return (_jsx(Box, { marginTop: gap, children: _jsx(Text, { color: theme.chrome.color, children: '\u2500'.repeat(Math.max(3, width)) }) }));
|
|
120
|
+
default:
|
|
121
|
+
return (_jsx(Box, { marginTop: gap, children: _jsx(Text, { children: _jsx(Spans, { spans: spans }) }) }));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Prose the agent wrote, at the width a paragraph is worth reading at.
|
|
126
|
+
*
|
|
127
|
+
* Only the last one is boxed. A turn says several things on its way to an
|
|
128
|
+
* answer and boxing each of them turns the transcript into a stack of frames;
|
|
129
|
+
* the box means *this is the answer*, and it only means that if it is rare.
|
|
130
|
+
*/
|
|
131
|
+
function Answer({ text, boxed }) {
|
|
132
|
+
const theme = useTheme();
|
|
43
133
|
const { stdout } = useStdout();
|
|
44
|
-
const width =
|
|
45
|
-
const
|
|
134
|
+
const width = boxWidth(text, stdout?.columns ?? 80);
|
|
135
|
+
const blocks = blocksOf(text);
|
|
136
|
+
const inner = width - (boxed ? 4 : GUTTER);
|
|
137
|
+
const body = blocks.map((block, i) => (
|
|
138
|
+
// Items of one list are one thing; a blank row between them is two
|
|
139
|
+
// lists, and a list that reads as a list is most of why it was written.
|
|
140
|
+
_jsx(BlockView, { block: block, width: inner, gap: i > 0 && !(block.kind === 'item' && blocks[i - 1]?.kind === 'item') ? 1 : 0 }, i)));
|
|
46
141
|
// Bounded, like every answer in `examples/sdk/`: prose that runs the width of a
|
|
47
|
-
// wide terminal is a worse read than prose that stops
|
|
48
|
-
|
|
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))) }));
|
|
142
|
+
// wide terminal is a worse read than prose that stops.
|
|
143
|
+
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
144
|
}
|
|
51
145
|
function App({ engine, options, theme }) {
|
|
52
146
|
const { exit } = useApp();
|
|
@@ -63,18 +157,31 @@ function App({ engine, options, theme }) {
|
|
|
63
157
|
/** Model calls in the turn now running, and what answered the last one. */
|
|
64
158
|
const [step, setStep] = useState(0);
|
|
65
159
|
const [model, setModel] = useState(undefined);
|
|
160
|
+
/** Whether a trunk model call is open, which is what the shimmer means. */
|
|
161
|
+
const [musing, setMusing] = useState(false);
|
|
66
162
|
// In flight, and therefore not in the transcript yet. Refs, because the
|
|
67
163
|
// activity region below is redrawn by the frame timer regardless.
|
|
68
164
|
const running = useRef(new Map());
|
|
69
165
|
const branches = useRef(new Map());
|
|
166
|
+
/** The context mode of the fork now open; `branch_started` does not carry it. */
|
|
167
|
+
const context = useRef('inherit');
|
|
70
168
|
const lane = useLanes(theme);
|
|
71
169
|
// What the turn now running has cost so far, summed per model call rather
|
|
72
170
|
// than read off the state: it is wanted between calls, not after them. The
|
|
73
171
|
// exact figure replaces it when the turn lands.
|
|
74
172
|
const spent = useRef(zeroUsage());
|
|
75
173
|
const startedAt = useRef(0);
|
|
76
|
-
|
|
77
|
-
|
|
174
|
+
// The two streams, mirrored in refs.
|
|
175
|
+
//
|
|
176
|
+
// State is what draws them; the ref is what any other event can read. Both
|
|
177
|
+
// are settled by something that happens *later* — prose is committed to the
|
|
178
|
+
// scrollback when the turn moves on to a call, reasoning when the model
|
|
179
|
+
// call it belongs to lands — and an event handler reading state would be
|
|
180
|
+
// reading whatever the last render closed over.
|
|
181
|
+
const said = useRef('');
|
|
182
|
+
const mind = useRef('');
|
|
183
|
+
/** The last prose committed, so the answer is not printed twice. */
|
|
184
|
+
const told = useRef('');
|
|
78
185
|
const stopping = useRef(undefined);
|
|
79
186
|
const seq = useRef(0);
|
|
80
187
|
/** The tallest the repainting region has been this turn, which is the height it keeps. */
|
|
@@ -148,39 +255,32 @@ function App({ engine, options, theme }) {
|
|
|
148
255
|
// fan-out is read across, not down.
|
|
149
256
|
const allowance = Math.min(ACTIVITY_ROWS, Math.max(0, rows - CHROME_ROWS - 2));
|
|
150
257
|
const boxes = busy
|
|
151
|
-
? branchBoxesOf(running.current, branches.current, Date.now(), columns - GUTTER, lane, branchRows(branches.current.size, allowance))
|
|
258
|
+
? branchBoxesOf(running.current, branches.current, Date.now(), columns - GUTTER, lane, spin, branchRows(branches.current.size, allowance))
|
|
152
259
|
: [];
|
|
153
260
|
// What the trunk itself has in flight. A branch's call is the branch's
|
|
154
261
|
// business — it has a box saying so — and the trunk, having forked, is
|
|
155
262
|
// waiting at the join.
|
|
156
263
|
const mine = busy ? trunkCallsOf(running.current, branches.current) : [];
|
|
157
|
-
const trunkRows = busy ? trunkRowsOf(mine, Date.now(), columns - GUTTER - 2) : [];
|
|
264
|
+
const trunkRows = busy ? trunkRowsOf(mine, Date.now(), columns - GUTTER - 2, spin) : [];
|
|
158
265
|
// Priced as far as it has got. Read during render, so the frame timer is
|
|
159
266
|
// what advances the clock.
|
|
160
267
|
const inflight = busy
|
|
161
268
|
? { usage: spent.current, durationMs: Date.now() - startedAt.current }
|
|
162
269
|
: 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);
|
|
270
|
+
// Up from the first reasoning token until the step arrives at prose or a
|
|
271
|
+
// call, which is when what it was deciding stops being the news.
|
|
272
|
+
const streaming = busy && thinking !== '';
|
|
273
|
+
const budget = budgetOf(rows, activityHeight(boxes, trunkRows), streaming ? THINKING_ROWS : 0);
|
|
173
274
|
const fitted = fitActivity(boxes, trunkRows, budget.activity);
|
|
174
275
|
// How tall the region actually needs to be, mirroring what the three blocks
|
|
175
|
-
// below draw.
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
const liveRows = live ? windowOf(live, answerWidth(columns) - 4, budget.live).length : 0;
|
|
276
|
+
// below draw. The answer keeps a blank row above it, so what it is separated
|
|
277
|
+
// from is whatever the last call left on screen.
|
|
278
|
+
const textRows = Math.max(1, budget.live - 1);
|
|
279
|
+
const liveWidth = live ? boxWidth(live, columns) - 4 : 0;
|
|
280
|
+
const liveRows = live ? windowOf(unmarked(live, liveWidth), liveWidth, textRows).length + 1 : 0;
|
|
181
281
|
const wanted = activityHeight(fitted.boxes, fitted.trunk) +
|
|
182
282
|
(fitted.hidden ? 1 : 0) +
|
|
183
|
-
|
|
283
|
+
(streaming ? budget.thinking : 0) +
|
|
184
284
|
liveRows;
|
|
185
285
|
// A turn opens against the prompt and grows up from it, one row at a time,
|
|
186
286
|
// the way anything else printed to a terminal does. Reserving the whole
|
|
@@ -188,17 +288,65 @@ function App({ engine, options, theme }) {
|
|
|
188
288
|
// before a word of the answer existed. It only ever grows, so the footer
|
|
189
289
|
// still never rides back up: what a block gives back is left as slack.
|
|
190
290
|
grown.current = busy ? Math.min(budget.total, Math.max(grown.current, wanted)) : 0;
|
|
191
|
-
const push = useCallback((kind, text, detail, lead) => {
|
|
192
|
-
setLines((prev) => [
|
|
291
|
+
const push = useCallback((kind, text, detail, lead, time) => {
|
|
292
|
+
setLines((prev) => [
|
|
293
|
+
...prev,
|
|
294
|
+
{
|
|
295
|
+
key: `${seq.current++}`,
|
|
296
|
+
kind,
|
|
297
|
+
lead,
|
|
298
|
+
text,
|
|
299
|
+
detail,
|
|
300
|
+
time,
|
|
301
|
+
// Only the call that opens a run of them is worth a blank
|
|
302
|
+
// row: the gap says *the prose stopped here*, and repeating
|
|
303
|
+
// it between siblings says nothing.
|
|
304
|
+
gap: kind === 'tool' && prev[prev.length - 1]?.kind !== 'tool',
|
|
305
|
+
},
|
|
306
|
+
]);
|
|
307
|
+
}, []);
|
|
308
|
+
/**
|
|
309
|
+
* Commit whatever the model has said so far to the scrollback.
|
|
310
|
+
*
|
|
311
|
+
* Called when the turn moves on to something else — a call, a fork, a
|
|
312
|
+
* handoff — because that is the moment the prose stops being *what is
|
|
313
|
+
* arriving* and becomes *what was said before this*. Prose that is never
|
|
314
|
+
* followed by a call is the answer, so it is still on screen when the turn
|
|
315
|
+
* lands, and the boxed answer replaces it in place.
|
|
316
|
+
*/
|
|
317
|
+
const settle = useCallback(() => {
|
|
318
|
+
const text = said.current.trim();
|
|
319
|
+
said.current = '';
|
|
320
|
+
setLive('');
|
|
321
|
+
if (text) {
|
|
322
|
+
told.current = text;
|
|
323
|
+
push('text', text);
|
|
324
|
+
}
|
|
325
|
+
}, [push]);
|
|
326
|
+
/**
|
|
327
|
+
* The same, for a branch: its prose moves from *arriving* into its trail.
|
|
328
|
+
*
|
|
329
|
+
* The trail is where the box's rows come from, so prose committed here is
|
|
330
|
+
* still on screen under the call it introduced — which is the whole reason
|
|
331
|
+
* a branch says anything before calling something.
|
|
332
|
+
*/
|
|
333
|
+
const keep = useCallback((b) => {
|
|
334
|
+
const text = b.said.trim();
|
|
335
|
+
b.said = '';
|
|
336
|
+
if (!text) {
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
b.trail.push({ kind: 'said', id: `${seq.current++}`, text });
|
|
340
|
+
b.trail.splice(0, b.trail.length - BRANCH_ROWS);
|
|
193
341
|
}, []);
|
|
194
342
|
// Deltas arrive far faster than a terminal can usefully redraw, so text is
|
|
195
343
|
// accumulated in one string and React coalesces the repaints. The finished
|
|
196
344
|
// answer replaces it in one piece when the turn lands.
|
|
197
345
|
//
|
|
198
|
-
// Reasoning is accumulated the same way
|
|
199
|
-
//
|
|
200
|
-
// the
|
|
201
|
-
//
|
|
346
|
+
// Reasoning is accumulated the same way and dropped when its model call
|
|
347
|
+
// lands: the whole chain is in the trajectory (`LlmCallNode.thinking`) and
|
|
348
|
+
// the run's report, and a summary of how an answer was reached is worth
|
|
349
|
+
// watching happen and not worth a row once it has.
|
|
202
350
|
const onEvent = useCallback((event) => {
|
|
203
351
|
if (!isCheckpoint(event)) {
|
|
204
352
|
// Only the trunk's answer is drawn. A fork has several running
|
|
@@ -216,13 +364,31 @@ function App({ engine, options, theme }) {
|
|
|
216
364
|
return;
|
|
217
365
|
}
|
|
218
366
|
if (!event.branch) {
|
|
219
|
-
|
|
220
|
-
setThinking(
|
|
367
|
+
mind.current += event.delta;
|
|
368
|
+
setThinking(mind.current);
|
|
221
369
|
}
|
|
222
370
|
return;
|
|
223
371
|
}
|
|
224
|
-
if (event.type === 'text_delta'
|
|
225
|
-
|
|
372
|
+
if (event.type === 'text_delta') {
|
|
373
|
+
if (owner) {
|
|
374
|
+
// Same rule the trunk follows: prose supersedes the
|
|
375
|
+
// reasoning that decided on it.
|
|
376
|
+
owner.thinking = '';
|
|
377
|
+
owner.said += event.delta;
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
if (event.branch) {
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
// Whichever of prose or a call the step arrives at first
|
|
384
|
+
// retires the gist; the content streaming under a heading
|
|
385
|
+
// does not, which is why the heading holds still.
|
|
386
|
+
if (mind.current) {
|
|
387
|
+
mind.current = '';
|
|
388
|
+
setThinking('');
|
|
389
|
+
}
|
|
390
|
+
said.current += event.delta;
|
|
391
|
+
setLive(said.current);
|
|
226
392
|
}
|
|
227
393
|
return;
|
|
228
394
|
}
|
|
@@ -234,21 +400,51 @@ function App({ engine, options, theme }) {
|
|
|
234
400
|
if (b) {
|
|
235
401
|
b.steps++;
|
|
236
402
|
b.thinking = '';
|
|
403
|
+
keep(b);
|
|
404
|
+
b.musing = true;
|
|
237
405
|
}
|
|
238
406
|
}
|
|
239
407
|
else {
|
|
408
|
+
// A step that answered without calling anything leaves
|
|
409
|
+
// its gist up; this is where it goes.
|
|
410
|
+
mind.current = '';
|
|
240
411
|
setThinking('');
|
|
412
|
+
setMusing(true);
|
|
241
413
|
setStep((n) => n + 1);
|
|
242
414
|
}
|
|
243
415
|
break;
|
|
244
416
|
case 'after_llm_call':
|
|
245
417
|
// Branches included: they are what this turn is spending on.
|
|
246
418
|
spent.current = addUsage(spent.current, event.node.usage);
|
|
247
|
-
if (
|
|
248
|
-
|
|
419
|
+
if (from) {
|
|
420
|
+
// Its gist stays up until the call it decided on goes
|
|
421
|
+
// out; only the shimmer stops, as on the trunk.
|
|
422
|
+
const b = branches.current.get(from);
|
|
423
|
+
if (b) {
|
|
424
|
+
b.musing = false;
|
|
425
|
+
}
|
|
426
|
+
break;
|
|
249
427
|
}
|
|
428
|
+
setModel(event.node.model);
|
|
429
|
+
// The gist stays until the call it decided on goes out. Only
|
|
430
|
+
// the shimmer stops, because nothing is arriving any more.
|
|
431
|
+
setMusing(false);
|
|
250
432
|
break;
|
|
251
|
-
case 'before_tool_call':
|
|
433
|
+
case 'before_tool_call': {
|
|
434
|
+
// Everything the model said on the way to this call is now
|
|
435
|
+
// history, and history belongs above the call, not below it.
|
|
436
|
+
// The gist goes with it: the call is what it decided on, so
|
|
437
|
+
// the row saying so supersedes the row explaining it.
|
|
438
|
+
const b = from ? branches.current.get(from) : undefined;
|
|
439
|
+
if (b) {
|
|
440
|
+
keep(b);
|
|
441
|
+
b.thinking = '';
|
|
442
|
+
}
|
|
443
|
+
else if (!from) {
|
|
444
|
+
settle();
|
|
445
|
+
mind.current = '';
|
|
446
|
+
setThinking('');
|
|
447
|
+
}
|
|
252
448
|
running.current.set(event.call.callId, {
|
|
253
449
|
callId: event.call.callId,
|
|
254
450
|
name: event.call.name,
|
|
@@ -257,6 +453,7 @@ function App({ engine, options, theme }) {
|
|
|
257
453
|
branch: from,
|
|
258
454
|
});
|
|
259
455
|
break;
|
|
456
|
+
}
|
|
260
457
|
case 'after_tool_call': {
|
|
261
458
|
const { node } = event;
|
|
262
459
|
const call = running.current.get(node.callId);
|
|
@@ -269,21 +466,23 @@ function App({ engine, options, theme }) {
|
|
|
269
466
|
// scrollback is a fan-out shuffled, and the box is
|
|
270
467
|
// the only place the shape of the fork survives.
|
|
271
468
|
// The join summarises it; the report has all of it.
|
|
272
|
-
b.
|
|
273
|
-
|
|
469
|
+
b.trail.push({
|
|
470
|
+
kind: 'call',
|
|
471
|
+
id: node.callId,
|
|
274
472
|
name: node.name,
|
|
275
473
|
args: call?.args ?? '',
|
|
276
474
|
ms: node.durationMs,
|
|
277
475
|
failed: node.isError,
|
|
278
476
|
});
|
|
279
|
-
b.
|
|
477
|
+
b.trail.splice(0, b.trail.length - BRANCH_ROWS);
|
|
280
478
|
break;
|
|
281
479
|
}
|
|
282
480
|
// What it was asked and what it answered, which is the
|
|
283
481
|
// difference between knowing a tool ran and knowing what
|
|
284
482
|
// the agent did. Both are previews already; a whole file
|
|
285
483
|
// read belongs in the report, not in the scrollback.
|
|
286
|
-
|
|
484
|
+
const view = describeCall(node.name, call?.args ?? '');
|
|
485
|
+
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
486
|
break;
|
|
288
487
|
}
|
|
289
488
|
case 'handoff': {
|
|
@@ -294,44 +493,50 @@ function App({ engine, options, theme }) {
|
|
|
294
493
|
b.agent = event.to;
|
|
295
494
|
break;
|
|
296
495
|
}
|
|
496
|
+
settle();
|
|
297
497
|
setAgent(event.to);
|
|
298
498
|
push('note', `→ ${event.to}`, `handed off from ${event.from}`);
|
|
299
499
|
break;
|
|
300
500
|
}
|
|
301
501
|
case 'before_fork':
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
//
|
|
502
|
+
// Naming the branches here said nothing a box below does
|
|
503
|
+
// not say better, under the work it is doing. The one
|
|
504
|
+
// thing a box cannot say for itself is what it started
|
|
505
|
+
// from, so the fork leaves it for the boxes to carry.
|
|
506
|
+
context.current = event.node.contextMode;
|
|
507
|
+
// A fork is what the trunk concluded, so whatever it was
|
|
508
|
+
// saying is finished as far as the screen is concerned.
|
|
306
509
|
if (from) {
|
|
307
510
|
break;
|
|
308
511
|
}
|
|
309
|
-
|
|
310
|
-
push('note', `⑂ ${event.node.branches.length}
|
|
512
|
+
settle();
|
|
513
|
+
push('note', `⑂ ${event.node.branches.length} ${event.node.branches.length === 1 ? 'branch' : 'branches'}`);
|
|
311
514
|
break;
|
|
312
515
|
case 'branch_started':
|
|
313
516
|
branches.current.set(event.child.name, {
|
|
314
517
|
name: event.child.name,
|
|
315
518
|
agent: event.childState.agentName,
|
|
519
|
+
context: context.current,
|
|
316
520
|
startedAt: Date.now(),
|
|
317
521
|
steps: 0,
|
|
318
522
|
tools: 0,
|
|
319
523
|
thinking: '',
|
|
320
|
-
|
|
524
|
+
said: '',
|
|
525
|
+
musing: false,
|
|
526
|
+
trail: [],
|
|
321
527
|
});
|
|
322
528
|
break;
|
|
323
529
|
case 'branch_finished': {
|
|
324
530
|
const b = branches.current.get(event.child.name);
|
|
325
531
|
branches.current.delete(event.child.name);
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
push('tool', `⑂ ${event.child.name}`, `${event.status}${spent}${did}`);
|
|
532
|
+
const did = b ? `${b.steps} steps · ${b.tools} tools` : '';
|
|
533
|
+
push('tool', event.child.name, [event.status, did].filter(Boolean).join(' · '), '⑂ branch', b ? (durationOf(Date.now() - b.startedAt) ?? '') : '');
|
|
329
534
|
break;
|
|
330
535
|
}
|
|
331
536
|
default:
|
|
332
537
|
break;
|
|
333
538
|
}
|
|
334
|
-
}, [columns, push]);
|
|
539
|
+
}, [columns, keep, push, settle]);
|
|
335
540
|
const submit = useCallback((value) => {
|
|
336
541
|
const text = value.trim();
|
|
337
542
|
if (busy) {
|
|
@@ -363,7 +568,11 @@ function App({ engine, options, theme }) {
|
|
|
363
568
|
setBusy(true);
|
|
364
569
|
setLive('');
|
|
365
570
|
setThinking('');
|
|
571
|
+
said.current = '';
|
|
572
|
+
mind.current = '';
|
|
573
|
+
told.current = '';
|
|
366
574
|
setStep(0);
|
|
575
|
+
setMusing(false);
|
|
367
576
|
grown.current = 0;
|
|
368
577
|
spent.current = zeroUsage();
|
|
369
578
|
startedAt.current = Date.now();
|
|
@@ -374,7 +583,15 @@ function App({ engine, options, theme }) {
|
|
|
374
583
|
void (async () => {
|
|
375
584
|
try {
|
|
376
585
|
const outcome = await Engine.run(engine, text, onEvent, controller.signal);
|
|
377
|
-
|
|
586
|
+
// The answer is what the turn ended on, and it is normally
|
|
587
|
+
// still on screen unboxed — nothing followed it, so nothing
|
|
588
|
+
// committed it. Boxing it here replaces it in place. It is
|
|
589
|
+
// only skipped when the last thing the model said was
|
|
590
|
+
// already filed as prose, which is what an aborted turn
|
|
591
|
+
// looks like: the text is above the call it was cut off at.
|
|
592
|
+
if (outcome.text.trim() && outcome.text.trim() !== told.current) {
|
|
593
|
+
push('agent', outcome.text);
|
|
594
|
+
}
|
|
378
595
|
// The previous total is the only thing that can say what
|
|
379
596
|
// this turn cost, and reading it out of the updater is what
|
|
380
597
|
// makes that true regardless of when the turn lands.
|
|
@@ -400,6 +617,9 @@ function App({ engine, options, theme }) {
|
|
|
400
617
|
setBusy(false);
|
|
401
618
|
setLive('');
|
|
402
619
|
setThinking('');
|
|
620
|
+
setMusing(false);
|
|
621
|
+
said.current = '';
|
|
622
|
+
mind.current = '';
|
|
403
623
|
running.current.clear();
|
|
404
624
|
branches.current.clear();
|
|
405
625
|
stopping.current = undefined;
|
|
@@ -431,12 +651,12 @@ function App({ engine, options, theme }) {
|
|
|
431
651
|
recall(1);
|
|
432
652
|
}
|
|
433
653
|
});
|
|
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,
|
|
654
|
+
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, frame: frame, columns: columns }), streaming && budget.thinking ? (_jsx(Reasoning, { text: thinking, columns: columns, frame: frame, live: musing })) : null, _jsx(Activity, { rows: fitted.trunk, hidden: fitted.hidden, frame: frame }), 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
655
|
}
|
|
436
656
|
function Header({ engine, readOnly, started, }) {
|
|
437
657
|
const theme = useTheme();
|
|
438
658
|
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, {
|
|
659
|
+
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
660
|
}
|
|
441
661
|
// ---------------------------------------------------------------------------
|
|
442
662
|
// The repainting frame
|
|
@@ -464,6 +684,8 @@ function Header({ engine, readOnly, started, }) {
|
|
|
464
684
|
// ---------------------------------------------------------------------------
|
|
465
685
|
/** The gutter every streaming block is indented behind. */
|
|
466
686
|
const GUTTER = 2;
|
|
687
|
+
/** The most rows of a branch's own prose a box will show. */
|
|
688
|
+
const SAY_ROWS = 2;
|
|
467
689
|
/**
|
|
468
690
|
* A fan-out, demultiplexed into one box per branch. Interleaving eight
|
|
469
691
|
* branches' calls into a single list is not a picture of parallel work, it is
|
|
@@ -478,54 +700,99 @@ const GUTTER = 2;
|
|
|
478
700
|
* box keeps a row saying so rather than collapsing and making the whole board
|
|
479
701
|
* jump every time a tool returns.
|
|
480
702
|
*/
|
|
481
|
-
function branchBoxesOf(tools, branches, now, width, lane, share) {
|
|
703
|
+
function branchBoxesOf(tools, branches, now, width, lane, spin, share) {
|
|
482
704
|
const boxes = [];
|
|
705
|
+
const room = (w) => Math.max(12, w - TIME_COL - 8);
|
|
483
706
|
for (const b of branches.values()) {
|
|
484
|
-
// What it
|
|
485
|
-
//
|
|
486
|
-
|
|
487
|
-
|
|
707
|
+
// What it has done and said, in the order it happened. A branch's prose
|
|
708
|
+
// never reaches the scrollback — several of them landing in one
|
|
709
|
+
// transcript is a fan-out shuffled — so the box is the only place it is
|
|
710
|
+
// ever read, and it stays there once the call it introduced goes out.
|
|
711
|
+
const trail = [];
|
|
712
|
+
for (const note of b.trail) {
|
|
713
|
+
if (note.kind === 'said') {
|
|
714
|
+
windowOf(note.text, width - 2, SAY_ROWS).forEach((line, i) => trail.push({
|
|
715
|
+
key: `s:${note.id}:${i}`,
|
|
716
|
+
mark: '',
|
|
717
|
+
time: '',
|
|
718
|
+
lead: '',
|
|
719
|
+
text: line,
|
|
720
|
+
said: true,
|
|
721
|
+
}));
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
724
|
+
const view = describeCall(note.name, note.args);
|
|
725
|
+
trail.push({
|
|
726
|
+
key: `d:${note.id}`,
|
|
727
|
+
mark: note.failed ? '✗' : '✓',
|
|
728
|
+
time: note.failed ? 'failed' : (durationOf(note.ms) ?? ''),
|
|
729
|
+
lead: view.verb,
|
|
730
|
+
text: clip(view.subject, room(width) - view.verb.length),
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
// Prose arriving now, and failing that what it is reasoning about —
|
|
734
|
+
// one supersedes the other exactly as on the trunk. Either way it sits
|
|
735
|
+
// between what the branch did and what it is doing, because it is what
|
|
736
|
+
// got it from one to the other.
|
|
737
|
+
const say = b.said.trim() ? windowOf(b.said, width - 2, SAY_ROWS) : [];
|
|
738
|
+
const gist = !say.length && b.thinking.trim() ? gistOf(b.thinking, width - TIME_COL - 6) : '';
|
|
739
|
+
const narration = say.length
|
|
740
|
+
? say.map((line, i) => ({
|
|
741
|
+
key: `s:${b.name}:${i}`,
|
|
742
|
+
mark: '',
|
|
743
|
+
time: '',
|
|
744
|
+
lead: '',
|
|
745
|
+
text: line,
|
|
746
|
+
said: true,
|
|
747
|
+
}))
|
|
748
|
+
: gist
|
|
749
|
+
? [
|
|
750
|
+
{
|
|
751
|
+
key: `g:${b.name}`,
|
|
752
|
+
mark: '',
|
|
753
|
+
time: '',
|
|
754
|
+
lead: '',
|
|
755
|
+
text: gist,
|
|
756
|
+
thinking: true,
|
|
757
|
+
shimmer: b.musing,
|
|
758
|
+
},
|
|
759
|
+
]
|
|
760
|
+
: [];
|
|
488
761
|
const live = [];
|
|
489
762
|
for (const t of tools.values()) {
|
|
490
|
-
if (t.branch === b.name && live.length <
|
|
491
|
-
const
|
|
763
|
+
if (t.branch === b.name && live.length < share) {
|
|
764
|
+
const view = describeCall(t.name, t.args);
|
|
492
765
|
live.push({
|
|
493
766
|
key: `t:${t.callId}`,
|
|
494
|
-
|
|
495
|
-
|
|
767
|
+
mark: spin,
|
|
768
|
+
time: secs(now - t.startedAt),
|
|
769
|
+
lead: view.verb,
|
|
770
|
+
text: clip(view.subject, room(width) - view.verb.length),
|
|
496
771
|
});
|
|
497
772
|
}
|
|
498
773
|
}
|
|
499
|
-
//
|
|
500
|
-
//
|
|
501
|
-
const
|
|
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
|
|
515
|
-
? [{ key: `g:${b.name}`, label: `… ${gist}`, detail: '', thinking: true }]
|
|
516
|
-
: []),
|
|
517
|
-
...live,
|
|
518
|
-
];
|
|
774
|
+
// The tail, so the newest is always the row nearest the closing rule
|
|
775
|
+
// and what is in flight is never the thing that got cut.
|
|
776
|
+
const rows = [...trail, ...narration, ...live];
|
|
519
777
|
if (!rows.length) {
|
|
520
|
-
rows.push({
|
|
778
|
+
rows.push({
|
|
779
|
+
key: `w:${b.name}`,
|
|
780
|
+
mark: '',
|
|
781
|
+
time: '',
|
|
782
|
+
lead: '',
|
|
783
|
+
text: 'thinking…',
|
|
784
|
+
thinking: true,
|
|
785
|
+
shimmer: b.musing,
|
|
786
|
+
});
|
|
521
787
|
}
|
|
522
788
|
boxes.push({
|
|
523
789
|
name: b.name,
|
|
524
790
|
color: lane(b.name),
|
|
525
791
|
title: `${b.name}${b.agent ? ` · ${b.agent}` : ''}`,
|
|
792
|
+
context: `context ${b.context}`,
|
|
526
793
|
stats: `${b.steps} ${b.steps === 1 ? 'step' : 'steps'}` +
|
|
527
|
-
(b.tools ? ` ${b.tools} ${b.tools === 1 ? 'tool' : 'tools'}` : '')
|
|
528
|
-
|
|
794
|
+
(b.tools ? ` ${b.tools} ${b.tools === 1 ? 'tool' : 'tools'}` : ''),
|
|
795
|
+
elapsed: secs(now - b.startedAt),
|
|
529
796
|
rows: rows.slice(-share),
|
|
530
797
|
});
|
|
531
798
|
}
|
|
@@ -536,13 +803,15 @@ function trunkCallsOf(tools, branches) {
|
|
|
536
803
|
return [...tools.values()].filter((t) => t.branch === undefined || !branches.has(t.branch));
|
|
537
804
|
}
|
|
538
805
|
/** What the trunk itself has in flight. It is one thread, so it gets no box. */
|
|
539
|
-
function trunkRowsOf(calls, now, width) {
|
|
806
|
+
function trunkRowsOf(calls, now, width, spin) {
|
|
540
807
|
return calls.map((t) => {
|
|
541
|
-
const
|
|
808
|
+
const view = describeCall(t.name, t.args);
|
|
542
809
|
return {
|
|
543
810
|
key: `t:${t.callId}`,
|
|
544
|
-
|
|
545
|
-
|
|
811
|
+
mark: spin,
|
|
812
|
+
time: secs(now - t.startedAt),
|
|
813
|
+
lead: view.verb,
|
|
814
|
+
text: clip(view.subject, Math.max(12, width - TIME_COL - view.verb.length - 6)),
|
|
546
815
|
};
|
|
547
816
|
});
|
|
548
817
|
}
|
|
@@ -565,28 +834,84 @@ function fitActivity(boxes, trunk, allowance) {
|
|
|
565
834
|
kept.push(b);
|
|
566
835
|
used += h;
|
|
567
836
|
}
|
|
568
|
-
|
|
569
|
-
|
|
837
|
+
let hidden = boxes.length - kept.length;
|
|
838
|
+
// A branch that is neither drawn nor counted has simply vanished from the
|
|
839
|
+
// fork. If the boxes filled the region exactly there is no row left to say
|
|
840
|
+
// so, so the last of them gives one back.
|
|
841
|
+
while (hidden && used + 1 > allowance && kept.length) {
|
|
842
|
+
const last = kept.pop();
|
|
843
|
+
used -= BOX_CHROME + last.rows.length;
|
|
844
|
+
hidden++;
|
|
845
|
+
}
|
|
570
846
|
return {
|
|
571
847
|
boxes: kept,
|
|
572
848
|
trunk: trunk.slice(0, Math.max(0, allowance - used - (hidden ? 1 : 0))),
|
|
573
849
|
hidden,
|
|
574
850
|
};
|
|
575
851
|
}
|
|
576
|
-
function Branches({ boxes, spin, columns, }) {
|
|
852
|
+
function Branches({ boxes, spin, frame, columns, }) {
|
|
577
853
|
const theme = useTheme();
|
|
854
|
+
// What a row has between the two sides. Ink truncates rather than wraps, so
|
|
855
|
+
// anything wider than this would eat the closing side rather than the box
|
|
856
|
+
// costing itself a row it was not given.
|
|
857
|
+
const inner = Math.max(1, columns - 4);
|
|
578
858
|
return (_jsx(Box, { flexDirection: "column", children: boxes.map((b) => {
|
|
579
|
-
|
|
580
|
-
//
|
|
581
|
-
|
|
582
|
-
|
|
859
|
+
const title = clip(b.title, Math.max(1, columns - 10));
|
|
860
|
+
// The name is the branch's; the context is the fork's doing,
|
|
861
|
+
// so it is set after the spinner in the chrome's own grey
|
|
862
|
+
// rather than lifted into the lane with the name.
|
|
863
|
+
const said = `${title} ${spin} ${b.context}`;
|
|
864
|
+
// Who it is heads the box; what it has done and how long it has
|
|
865
|
+
// been doing it close it. A count belongs with the clock, and a
|
|
866
|
+
// name reads better without two numbers after it.
|
|
867
|
+
const foot = clip(`${b.stats} · ${b.elapsed}`, Math.max(1, columns - 8));
|
|
868
|
+
// One hue for the whole frame. A rule in one colour meeting a
|
|
869
|
+
// side in another is read as two things that failed to join.
|
|
870
|
+
const edge = b.color ?? theme.chrome.color;
|
|
871
|
+
return (_jsxs(Box, { flexDirection: "column", height: BOX_CHROME + b.rows.length, children: [_jsxs(Text, { wrap: "truncate-end", color: edge, children: [_jsx(Text, { children: '╭─ ' }), _jsx(Text, { bold: true, children: title }), _jsx(Text, { children: ` ${spin}` }), _jsx(Text, { color: theme.chrome.color, children: ` ${b.context} ` }), _jsxs(Text, { children: ['─'.repeat(Math.max(0, columns - said.length - 5)), '╮'] })] }), b.rows.map((r) => (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: edge, children: '│ ' }), _jsx(Work, { row: r, frame: frame }), _jsx(Text, { color: edge, children: `${' '.repeat(Math.max(0, inner - widthOf(r)))} │` })] }, r.key))), _jsxs(Text, { wrap: "truncate-end", color: edge, children: [_jsx(Text, { children: '╰─ ' }), _jsx(Text, { color: theme.chrome.color, children: foot }), _jsxs(Text, { children: [` ${'─'.repeat(Math.max(0, columns - foot.length - 5))}`, '╯'] })] })] }, b.name));
|
|
583
872
|
}) }));
|
|
584
873
|
}
|
|
585
|
-
|
|
874
|
+
/**
|
|
875
|
+
* The columns `Work` draws `row` in. It lives here so the two stay in step:
|
|
876
|
+
* a box closes on the right, and a side rule can only be put where the content
|
|
877
|
+
* is known to have stopped.
|
|
878
|
+
*/
|
|
879
|
+
function widthOf(row) {
|
|
880
|
+
if (row.thinking || row.said) {
|
|
881
|
+
return row.text.length;
|
|
882
|
+
}
|
|
883
|
+
return (2 +
|
|
884
|
+
Math.max(TIME_COL, row.time.length) +
|
|
885
|
+
2 +
|
|
886
|
+
row.lead.length +
|
|
887
|
+
(row.text ? row.text.length + 1 : 0));
|
|
888
|
+
}
|
|
889
|
+
/**
|
|
890
|
+
* A row of work in the same shape the transcript will keep it in.
|
|
891
|
+
*
|
|
892
|
+
* `mark` occupies the two columns a committed row leaves blank, so the spinner
|
|
893
|
+
* sits where nothing will be once the call returns and the time, verb and
|
|
894
|
+
* subject never move.
|
|
895
|
+
*/
|
|
896
|
+
function Work({ row, frame }) {
|
|
897
|
+
const theme = useTheme();
|
|
898
|
+
// Reasoning and prose are drawn in a branch's box exactly as they are drawn
|
|
899
|
+
// on the trunk: no mark, at the trunk's own gutter, the sweep saying it is
|
|
900
|
+
// still arriving. A branch is an agent, not a subsystem.
|
|
901
|
+
if (row.thinking) {
|
|
902
|
+
return row.shimmer ? (_jsx(Shimmer, { text: row.text, frame: frame, color: theme.thinking.color })) : (_jsx(Text, { color: theme.thinking.color, children: row.text }));
|
|
903
|
+
}
|
|
904
|
+
if (row.said) {
|
|
905
|
+
return _jsx(Text, { children: row.text });
|
|
906
|
+
}
|
|
907
|
+
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}` : '' })] }));
|
|
908
|
+
}
|
|
909
|
+
function Activity({ rows, hidden, frame, }) {
|
|
910
|
+
const theme = useTheme();
|
|
586
911
|
if (!rows.length && !hidden) {
|
|
587
912
|
return null;
|
|
588
913
|
}
|
|
589
|
-
return (_jsxs(Box, { flexDirection: "column", height: rows.length + (hidden ? 1 : 0), overflow: "hidden", children: [rows.map((r) => (
|
|
914
|
+
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, frame: frame }) }, r.key))), hidden ? (_jsx(Text, { color: theme.chrome.color, children: ` + ${hidden} more ${hidden === 1 ? 'branch' : 'branches'}` })) : null] }));
|
|
590
915
|
}
|
|
591
916
|
/**
|
|
592
917
|
* A stable colour per branch, handed out in order of first sight. Held in a ref
|
|
@@ -608,50 +933,35 @@ function useLanes(theme) {
|
|
|
608
933
|
return next;
|
|
609
934
|
}, [theme]);
|
|
610
935
|
}
|
|
611
|
-
/** A reasoning summary's own headings, which arrive as markdown. */
|
|
612
|
-
const HEADING = /^\s*(?:#{1,6}\s*)?\*\*(.+?)\*\*[:.]?\s*$/;
|
|
613
|
-
const TITLE = 'reasoning';
|
|
614
936
|
/**
|
|
615
|
-
*
|
|
616
|
-
*
|
|
617
|
-
*
|
|
618
|
-
*
|
|
619
|
-
*
|
|
937
|
+
* Where the model has got to, in one line.
|
|
938
|
+
*
|
|
939
|
+
* A reasoning summary is a heading, not a document. Six rows of it pushed the
|
|
940
|
+
* answer down the screen and asked to be read at the same time as the text
|
|
941
|
+
* arriving below it, and once the deltas stopped there was nothing to say the
|
|
942
|
+
* paragraph had finished — a block that had quietly settled read as a hang.
|
|
943
|
+
*
|
|
944
|
+
* So the stream is drawn as its gist, and the shimmer is the proof it is
|
|
945
|
+
* moving. The line outlives the call that wrote it — it is why the calls below
|
|
946
|
+
* it are being made, and a gist that vanished with its own model call was on
|
|
947
|
+
* screen for a fraction of the work it explains — but the shimmer stops, so a
|
|
948
|
+
* settled thought is never mistaken for an arriving one.
|
|
620
949
|
*/
|
|
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, }) {
|
|
950
|
+
function Reasoning({ text, columns, frame, live, }) {
|
|
633
951
|
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: '╰─' })] }));
|
|
952
|
+
const gist = gistOf(text, columns - GUTTER - 2);
|
|
953
|
+
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
954
|
}
|
|
650
|
-
function
|
|
955
|
+
function Streaming({ text, columns, rows }) {
|
|
651
956
|
// The same width the finished answer will take, so landing it reflows
|
|
652
957
|
// nothing: what is on screen is what stays there.
|
|
653
|
-
|
|
654
|
-
|
|
958
|
+
//
|
|
959
|
+
// Markers are resolved but nothing is styled. This region repaints, and
|
|
960
|
+
// everything that repaints is measured by `.length` — so the stream gets
|
|
961
|
+
// the words the answer will have and the weights it will not, which is the
|
|
962
|
+
// half of the change that can be made without lying about the height.
|
|
963
|
+
const shown = windowOf(unmarked(text, boxWidth(text, columns) - 4), boxWidth(text, columns) - 4, rows);
|
|
964
|
+
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
965
|
}
|
|
656
966
|
/** The label column the two number rows line up behind. */
|
|
657
967
|
const LABEL = 10;
|
|
@@ -663,8 +973,9 @@ const SWEEP_GAP = 6;
|
|
|
663
973
|
* The spinner is proof the process is alive, but it is chrome and the eye
|
|
664
974
|
* reads the word beside it — a word that never moves is what a hung run looks
|
|
665
975
|
* like. The wave is weight rather than hue because the theme has no ramp to
|
|
666
|
-
* spend:
|
|
667
|
-
*
|
|
976
|
+
* spend: a short bold crest travels through the word, and the word keeps
|
|
977
|
+
* whatever colour its role gave it. The trough is the word's own weight, not
|
|
978
|
+
* `dim` — a hue with `dim` over it is what Apple Terminal renders as nothing.
|
|
668
979
|
*/
|
|
669
980
|
function Shimmer({ text, frame, color, }) {
|
|
670
981
|
const chars = [...text];
|
|
@@ -680,10 +991,10 @@ function Shimmer({ text, frame, color, }) {
|
|
|
680
991
|
return (_jsx(Text, { children: chars.map((ch, i) => {
|
|
681
992
|
// The crest sits on the head and the tail drags behind it.
|
|
682
993
|
const behind = head - i;
|
|
683
|
-
return (_jsx(Text, { color: color, bold: behind
|
|
994
|
+
return (_jsx(Text, { color: color, bold: behind >= 0 && behind <= 2, children: ch }, i));
|
|
684
995
|
}) }));
|
|
685
996
|
}
|
|
686
|
-
function Footer({ agent, busy, spin, frame, running, forked, step, model, stats, inflight, reasoning,
|
|
997
|
+
function Footer({ agent, busy, spin, frame, running, forked, step, model, stats, inflight, reasoning, }) {
|
|
687
998
|
const theme = useTheme();
|
|
688
999
|
// Who before what. The agent is the subject of the sentence, and in a
|
|
689
1000
|
// handoff it is the thing that changed.
|
|
@@ -692,32 +1003,25 @@ function Footer({ agent, busy, spin, frame, running, forked, step, model, stats,
|
|
|
692
1003
|
...(model ? [model] : []),
|
|
693
1004
|
'esc to stop',
|
|
694
1005
|
].join(' · ');
|
|
695
|
-
//
|
|
696
|
-
//
|
|
697
|
-
//
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
const
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
: forked
|
|
1006
|
+
// One word for what the turn is spending its time on, and the three are
|
|
1007
|
+
// different kinds of waiting: the model is composing, the model is
|
|
1008
|
+
// reasoning about what to compose, or the model is not running at all and
|
|
1009
|
+
// something else is. What the call actually is belongs to the row above,
|
|
1010
|
+
// which has the width to say it.
|
|
1011
|
+
const phase = running.length || forked ? 'waiting' : reasoning ? 'reasoning' : 'working';
|
|
1012
|
+
const what = phase !== 'waiting'
|
|
1013
|
+
? phase
|
|
1014
|
+
: running.length > 1
|
|
1015
|
+
? `waiting on ${running.length} tools`
|
|
1016
|
+
: forked && !running.length
|
|
707
1017
|
? // Having forked, the trunk has nothing of its own to do. The
|
|
708
1018
|
// boxes above say what the branches are doing.
|
|
709
1019
|
`waiting on ${forked} ${forked === 1 ? 'branch' : 'branches'}`
|
|
710
|
-
:
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
// A clip has already ended it with one.
|
|
714
|
-
const status = what.endsWith('…') ? what : `${what}…`;
|
|
715
|
-
// Nothing of its own in flight means the word above is about reasoning, so
|
|
716
|
-
// it takes the same hue the reasoning block does.
|
|
717
|
-
const musing = !running.length && !forked;
|
|
718
|
-
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, ' ', _jsx(Shimmer, { text: status, frame: frame, color: musing ? theme.thinking.color : theme.warn })] })) : 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
|
|
1020
|
+
: 'waiting';
|
|
1021
|
+
const hue = theme.phase[phase];
|
|
1022
|
+
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
|
|
719
1023
|
? ''
|
|
720
|
-
: ` · ${durationOf(stats.durationMs) ?? ''}`] })) : null, _jsxs(Text, {
|
|
1024
|
+
: ` · ${durationOf(stats.durationMs) ?? ''}`] })) : null, _jsxs(Text, { color: theme.chrome.color, children: ['session'.padEnd(LABEL), tokens(stats.session), stats.calls
|
|
721
1025
|
? ` · ${stats.calls} ${stats.calls === 1 ? 'call' : 'calls'}`
|
|
722
1026
|
: ''] })] }))] }));
|
|
723
1027
|
}
|
|
@@ -748,14 +1052,17 @@ function durationOf(ms) {
|
|
|
748
1052
|
function secs(ms) {
|
|
749
1053
|
return `${(ms / 1000).toFixed(1)}s`;
|
|
750
1054
|
}
|
|
751
|
-
/**
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
1055
|
+
/**
|
|
1056
|
+
* What a finished tool call has to say for itself.
|
|
1057
|
+
*
|
|
1058
|
+
* The duration has its own column on the row now, and the branch has its own
|
|
1059
|
+
* box, so what is left is the outcome: `summarise` reads the fields that matter
|
|
1060
|
+
* for the tools the runtime ships and falls back to the raw preview for the
|
|
1061
|
+
* ones it does not know.
|
|
1062
|
+
*/
|
|
1063
|
+
function detailOf(node, width) {
|
|
1064
|
+
const said = summarise(node.name, node.result.preview ?? '');
|
|
1065
|
+
const parts = [...(node.isError ? ['failed'] : []), ...(said ? [said] : [])];
|
|
759
1066
|
// One row. Two left a six-character orphan under most results, and a
|
|
760
1067
|
// preview is already a preview: the whole of it is a click away in the
|
|
761
1068
|
// report, and a wall of it here buries the next answer.
|