@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/dist/tui/app.js CHANGED
@@ -1,4 +1,4 @@
1
- import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
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, readable, segmentsOf, THINKING_CHROME, THINKING_ROWS, windowOf, } from "./wrap.js";
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 style = useTheme().line[line.kind];
36
- return (_jsxs(Box, { flexDirection: "row", marginTop: line.kind === 'you' ? 1 : 0, children: [_jsxs(Text, { color: style.color, dimColor: style.dim, children: [MARK[line.kind], ' '] }), _jsxs(Box, { flexDirection: "column", children: [line.kind === 'agent' ? (_jsx(Answer, { text: line.text })) : (
37
- // The lead is what the eye scans for down the left edge, so
38
- // it carries the weight the row's text does not. Nested
39
- // rather than one string: Ink dims a whole Text or none of it.
40
- _jsxs(Text, { children: [line.lead ? _jsx(Text, { color: style.color, bold: true, children: `${line.lead} ` }) : null, _jsx(Text, { color: style.color, dimColor: style.dim, bold: line.kind === 'you', children: line.text })] })), line.detail ? _jsx(Text, { dimColor: true, children: line.detail }) : null] })] }));
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
- function Answer({ text }) {
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 = answerWidth(stdout?.columns ?? 80);
45
- const segments = segmentsOf(text);
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, and the box is also
48
- // what separates the answer from the machinery that produced it.
49
- return (_jsx(Box, { flexDirection: "column", width: width, borderStyle: "round", borderDimColor: true, paddingX: 1, marginY: 1, children: segments.map((s, i) => s.code ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: `\u250c\u2500${s.title ? ` ${s.title}` : ''}` }), s.lines.map((l, j) => (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { dimColor: true, children: '\u2502 ' }), _jsx(Text, { wrap: "truncate-end", children: l || ' ' })] }, j))), _jsx(Text, { dimColor: true, children: '\u2514\u2500' })] }, i)) : (_jsx(Text, { children: s.lines.join('\n') }, i))) }));
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
- /** When reasoning text last arrived, which is the only proof it is still coming. */
77
- const flowing = useRef(0);
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
- // Reasoning survives the call that produced it, because it is why the tool
164
- // now running is running. That makes settled text indistinguishable from
165
- // live text unless the difference is drawn, and a paragraph that has quietly
166
- // stopped moving reads as a hang.
167
- const streaming = busy && Date.now() - flowing.current < SETTLE_MS;
168
- // A settled block collapses to its opening line. It still says why the work
169
- // now running is running, but it stops holding six rows of a paragraph that
170
- // finished a minute ago — during a fan-out the trunk makes no call of its
171
- // own, so nothing clears it and it reads as hung.
172
- const budget = budgetOf(rows, activityHeight(boxes, trunkRows), thinking ? (streaming ? THINKING_ROWS : 1) : 0);
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
- const thinkingRows = thinking && budget.thinking
177
- ? (streaming ? windowOf(thinking, columns - GUTTER, budget.thinking).length : 1) +
178
- THINKING_CHROME
179
- : 0;
180
- const liveRows = live ? windowOf(live, answerWidth(columns) - 4, budget.live).length : 0;
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
- thinkingRows +
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) => [...prev, { key: `${seq.current++}`, kind, lead, text, detail }]);
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 but never enters `lines`: it is a
199
- // progress indicator, not part of the conversation. The full chain is in
200
- // the trajectory (`LlmCallNode.thinking`) and the run's report, so nothing
201
- // is lost when it is cleared at the start of the next model call.
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
- flowing.current = Date.now();
220
- setThinking((prev) => prev + event.delta);
367
+ mind.current += event.delta;
368
+ setThinking(mind.current);
221
369
  }
222
370
  return;
223
371
  }
224
- if (event.type === 'text_delta' && !event.branch) {
225
- setLive((prev) => prev + event.delta);
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 (!from) {
248
- setModel(event.node.model);
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.done.push({
273
- callId: node.callId,
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.done.splice(0, b.done.length - BRANCH_ROWS);
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
- push('tool', clip(readable(call?.args ?? ''), columns - node.name.length - 5), detailOf(node, from, columns - 4), node.name);
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
- // Whatever the trunk was reasoning about, forking is the
303
- // conclusion it reached. It makes no call of its own until
304
- // the join, so nothing else would clear it and it would sit
305
- // there for the length of the fork looking hung.
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
- setThinking('');
310
- push('note', `⑂ ${event.node.branches.length} branches`, `${event.node.branches.map((b) => b.name).join(', ')} · context ${event.node.contextMode}`);
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
- done: [],
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 spent = b ? ` · ${durationOf(Date.now() - b.startedAt) ?? ''}` : '';
327
- const did = b ? ` · ${b.steps} steps · ${b.tools} tools` : '';
328
- push('tool', `⑂ ${event.child.name}`, `${event.status}${spent}${did}`);
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
- push('agent', outcome.text);
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, columns: columns }), _jsx(Activity, { rows: fitted.trunk, hidden: fitted.hidden }), thinking && budget.thinking ? (_jsx(Thinking, { text: thinking, columns: columns, rows: budget.thinking, spin: spin, live: streaming })) : null, live ? _jsx(Live, { text: live, columns: columns, rows: budget.live }) : null] }), _jsx(Footer, { agent: agent, busy: busy, spin: spin, frame: frame, running: mine, forked: branches.current.size, step: step, model: model, stats: stats, inflight: inflight, reasoning: streaming, columns: columns }), busy ? null : (_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, children: "\u203A " }), _jsx(TextInput, { defaultValue: draft, placeholder: "Ask something\u2026 (\u2191 for history, /help for commands)", onChange: remember, onSubmit: submit }, generation)] }))] }) }));
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, { dimColor: true, children: [" ", engine.session.id] }), readOnly ? _jsx(Text, { color: theme.warn, children: " read-only" }) : null] }), _jsxs(Text, { dimColor: true, children: [started ? `${started.freshWorkspace ? 'new directory' : 'workspace'} ` : '', display(engine.workspace), model ? ` · ${model}` : ''] })] }));
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 is reasoning about, kept to one row: a box is a status line
485
- // per branch, not a second transcript.
486
- const gist = b.thinking.trim() ? gistOf(b.thinking, width - 6) : '';
487
- const room = Math.max(1, gist ? share - 1 : share);
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 < room) {
491
- const detail = ` ${secs(now - t.startedAt)}`;
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
- label: clip(`${t.name} ${readable(t.args)}`, width - detail.length - 2),
495
- detail,
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
- // Oldest of the calls that still fit, so the newest is always the row
500
- // nearest the one running.
501
- const past = b.done.slice(Math.max(0, b.done.length - (room - live.length)));
502
- const rows = [
503
- ...past.map((d) => {
504
- const detail = ` ${d.failed ? 'failed' : (durationOf(d.ms) ?? '')}`;
505
- return {
506
- key: `d:${d.callId}`,
507
- label: clip(`✓ ${d.name} ${readable(d.args)}`, width - detail.length - 2),
508
- detail,
509
- past: true,
510
- };
511
- }),
512
- // Between the calls it made and the one it is making: the reasoning
513
- // is what got it from one to the other.
514
- ...(gist
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({ key: `w:${b.name}`, label: 'thinking…', detail: '', thinking: true });
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
- ` ${secs(now - b.startedAt)}`,
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 detail = ` ${secs(now - t.startedAt)}`;
808
+ const view = describeCall(t.name, t.args);
542
809
  return {
543
810
  key: `t:${t.callId}`,
544
- label: clip(`${t.name} ${readable(t.args)}`, width - detail.length),
545
- detail,
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
- const left = boxes.length - kept.length;
569
- const hidden = left && used < allowance ? left : 0;
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
- // Ink truncates rather than wraps, so an over-wide rule clips
580
- // instead of costing the box a row it was not given.
581
- 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, { dimColor: true, children: '╭─ ' }), _jsx(Text, { color: b.color, children: b.title }), _jsx(Text, { color: b.color, children: ` ${spin}` }), _jsx(Text, { dimColor: true, children: ` ${b.stats} ` }), _jsx(Text, { dimColor: true, children: '─'.repeat(Math.max(0, columns - used)) })] }), b.rows.map((r) => (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: b.color, children: '│ ' }), _jsx(Text, { color: r.thinking ? theme.thinking.color : undefined, dimColor: r.thinking ? theme.thinking.dim : undefined, children: r.label }), _jsx(Text, { dimColor: true, children: r.detail })] }, r.key))), _jsx(Text, { dimColor: true, children: '╰─' })] }, b.name));
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
- function Activity({ rows, hidden, }) {
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) => (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: ' ' }), _jsx(Text, { color: r.color, children: r.label }), _jsx(Text, { dimColor: true, children: r.detail })] }, r.key))), hidden ? (_jsx(Text, { dimColor: true, children: ` + ${hidden} more ${hidden === 1 ? 'branch' : 'branches'}` })) : null] }));
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
- * Reasoning is dim prose sitting between dim tool rows and a dim footer, with
616
- * nothing to say where it starts or stops — so it reads as part of whatever is
617
- * above it, and it never leaves the bottom of the screen. A rule at each end
618
- * gives it an edge. Round, to tell live chrome from the square fences an answer
619
- * puts around code.
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
- /** Where a settled block got to: its last heading, or failing that its opening. */
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
- // Settled, the block is down to one row, and the tail of a paragraph is a
635
- // fragment ("ports.") rather than a summary. The last heading the model
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 Live({ text, columns, rows }) {
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
- 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))) }));
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: dim at rest, the terminal's own weight at the crest, and the word
667
- * keeps whatever colour its role gave it.
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 === 0, dimColor: behind < 0 || behind > 2, children: ch }, i));
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, columns, }) {
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
- // The argument is what identifies a generic tool — `run_command` is every
696
- // shell command there is — but the activity row above carries it in full,
697
- // so the footer takes it only when there is width to say something useful.
698
- const first = running[0];
699
- const room = columns - agent.length - aside.length - 12;
700
- const what = running.length > 1
701
- ? `running ${running.length} tools`
702
- : first
703
- ? room >= 24
704
- ? clip(`${first.name} ${readable(first.args)}`, room)
705
- : `running ${first.name}`
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
- : reasoning
711
- ? 'reasoning'
712
- : 'thinking';
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, { dimColor: true, children: ['session'.padEnd(LABEL), tokens(stats.session), stats.calls
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
- /** What a finished tool call has to say for itself, under its own name. */
752
- function detailOf(node, branch, width) {
753
- const parts = [
754
- ...(branch ? [`⑂ ${branch}`] : []),
755
- ...(node.isError ? ['failed'] : []),
756
- ...(durationOf(node.durationMs) ? [durationOf(node.durationMs)] : []),
757
- ...(node.result.preview ? [readable(node.result.preview)] : []),
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.