@zenera/cli 1.1.18 → 1.1.19

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