@zenera/cli 1.1.19 → 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
@@ -10,7 +10,7 @@ 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, describeCall, gistOf, segmentsOf, summarise, THINKING_ROWS, TIME_COL, 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 = {
@@ -78,6 +78,49 @@ function Call({ line }) {
78
78
  const theme = useTheme();
79
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
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))) }));
91
+ }
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
+ }
81
124
  /**
82
125
  * Prose the agent wrote, at the width a paragraph is worth reading at.
83
126
  *
@@ -88,9 +131,13 @@ function Call({ line }) {
88
131
  function Answer({ text, boxed }) {
89
132
  const theme = useTheme();
90
133
  const { stdout } = useStdout();
91
- const width = answerWidth(stdout?.columns ?? 80);
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)));
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)));
94
141
  // Bounded, like every answer in `examples/sdk/`: prose that runs the width of a
95
142
  // wide terminal is a worse read than prose that stops.
96
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 }));
@@ -116,6 +163,8 @@ function App({ engine, options, theme }) {
116
163
  // activity region below is redrawn by the frame timer regardless.
117
164
  const running = useRef(new Map());
118
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');
119
168
  const lane = useLanes(theme);
120
169
  // What the turn now running has cost so far, summed per model call rather
121
170
  // than read off the state: it is wanted between calls, not after them. The
@@ -227,7 +276,8 @@ function App({ engine, options, theme }) {
227
276
  // below draw. The answer keeps a blank row above it, so what it is separated
228
277
  // from is whatever the last call left on screen.
229
278
  const textRows = Math.max(1, budget.live - 1);
230
- const liveRows = live ? windowOf(live, answerWidth(columns) - 4, textRows).length + 1 : 0;
279
+ const liveWidth = live ? boxWidth(live, columns) - 4 : 0;
280
+ const liveRows = live ? windowOf(unmarked(live, liveWidth), liveWidth, textRows).length + 1 : 0;
231
281
  const wanted = activityHeight(fitted.boxes, fitted.trunk) +
232
282
  (fitted.hidden ? 1 : 0) +
233
283
  (streaming ? budget.thinking : 0) +
@@ -273,6 +323,22 @@ function App({ engine, options, theme }) {
273
323
  push('text', text);
274
324
  }
275
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);
341
+ }, []);
276
342
  // Deltas arrive far faster than a terminal can usefully redraw, so text is
277
343
  // accumulated in one string and React coalesces the repaints. The finished
278
344
  // answer replaces it in one piece when the turn lands.
@@ -303,7 +369,17 @@ function App({ engine, options, theme }) {
303
369
  }
304
370
  return;
305
371
  }
306
- if (event.type === 'text_delta' && !event.branch) {
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
+ }
307
383
  // Whichever of prose or a call the step arrives at first
308
384
  // retires the gist; the content streaming under a heading
309
385
  // does not, which is why the heading holds still.
@@ -324,6 +400,8 @@ function App({ engine, options, theme }) {
324
400
  if (b) {
325
401
  b.steps++;
326
402
  b.thinking = '';
403
+ keep(b);
404
+ b.musing = true;
327
405
  }
328
406
  }
329
407
  else {
@@ -339,6 +417,12 @@ function App({ engine, options, theme }) {
339
417
  // Branches included: they are what this turn is spending on.
340
418
  spent.current = addUsage(spent.current, event.node.usage);
341
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
+ }
342
426
  break;
343
427
  }
344
428
  setModel(event.node.model);
@@ -346,12 +430,17 @@ function App({ engine, options, theme }) {
346
430
  // the shimmer stops, because nothing is arriving any more.
347
431
  setMusing(false);
348
432
  break;
349
- case 'before_tool_call':
433
+ case 'before_tool_call': {
350
434
  // Everything the model said on the way to this call is now
351
435
  // history, and history belongs above the call, not below it.
352
436
  // The gist goes with it: the call is what it decided on, so
353
437
  // the row saying so supersedes the row explaining it.
354
- if (!from) {
438
+ const b = from ? branches.current.get(from) : undefined;
439
+ if (b) {
440
+ keep(b);
441
+ b.thinking = '';
442
+ }
443
+ else if (!from) {
355
444
  settle();
356
445
  mind.current = '';
357
446
  setThinking('');
@@ -364,6 +453,7 @@ function App({ engine, options, theme }) {
364
453
  branch: from,
365
454
  });
366
455
  break;
456
+ }
367
457
  case 'after_tool_call': {
368
458
  const { node } = event;
369
459
  const call = running.current.get(node.callId);
@@ -376,14 +466,15 @@ function App({ engine, options, theme }) {
376
466
  // scrollback is a fan-out shuffled, and the box is
377
467
  // the only place the shape of the fork survives.
378
468
  // The join summarises it; the report has all of it.
379
- b.done.push({
380
- callId: node.callId,
469
+ b.trail.push({
470
+ kind: 'call',
471
+ id: node.callId,
381
472
  name: node.name,
382
473
  args: call?.args ?? '',
383
474
  ms: node.durationMs,
384
475
  failed: node.isError,
385
476
  });
386
- b.done.splice(0, b.done.length - BRANCH_ROWS);
477
+ b.trail.splice(0, b.trail.length - BRANCH_ROWS);
387
478
  break;
388
479
  }
389
480
  // What it was asked and what it answered, which is the
@@ -408,23 +499,31 @@ function App({ engine, options, theme }) {
408
499
  break;
409
500
  }
410
501
  case 'before_fork':
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;
411
507
  // A fork is what the trunk concluded, so whatever it was
412
508
  // saying is finished as far as the screen is concerned.
413
509
  if (from) {
414
510
  break;
415
511
  }
416
512
  settle();
417
- push('note', `⑂ ${event.node.branches.length} branches`, `${event.node.branches.map((b) => b.name).join(', ')} · context ${event.node.contextMode}`);
513
+ push('note', `⑂ ${event.node.branches.length} ${event.node.branches.length === 1 ? 'branch' : 'branches'}`);
418
514
  break;
419
515
  case 'branch_started':
420
516
  branches.current.set(event.child.name, {
421
517
  name: event.child.name,
422
518
  agent: event.childState.agentName,
519
+ context: context.current,
423
520
  startedAt: Date.now(),
424
521
  steps: 0,
425
522
  tools: 0,
426
523
  thinking: '',
427
- done: [],
524
+ said: '',
525
+ musing: false,
526
+ trail: [],
428
527
  });
429
528
  break;
430
529
  case 'branch_finished': {
@@ -437,7 +536,7 @@ function App({ engine, options, theme }) {
437
536
  default:
438
537
  break;
439
538
  }
440
- }, [columns, push, settle]);
539
+ }, [columns, keep, push, settle]);
441
540
  const submit = useCallback((value) => {
442
541
  const text = value.trim();
443
542
  if (busy) {
@@ -552,7 +651,7 @@ function App({ engine, options, theme }) {
552
651
  recall(1);
553
652
  }
554
653
  });
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)] }))] }) }));
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)] }))] }) }));
556
655
  }
557
656
  function Header({ engine, readOnly, started, }) {
558
657
  const theme = useTheme();
@@ -585,6 +684,8 @@ function Header({ engine, readOnly, started, }) {
585
684
  // ---------------------------------------------------------------------------
586
685
  /** The gutter every streaming block is indented behind. */
587
686
  const GUTTER = 2;
687
+ /** The most rows of a branch's own prose a box will show. */
688
+ const SAY_ROWS = 2;
588
689
  /**
589
690
  * A fan-out, demultiplexed into one box per branch. Interleaving eight
590
691
  * branches' calls into a single list is not a picture of parallel work, it is
@@ -603,13 +704,63 @@ function branchBoxesOf(tools, branches, now, width, lane, spin, share) {
603
704
  const boxes = [];
604
705
  const room = (w) => Math.max(12, w - TIME_COL - 8);
605
706
  for (const b of branches.values()) {
606
- // What it is reasoning about, kept to one row: a box is a status line
607
- // per branch, not a second transcript.
608
- const gist = b.thinking.trim() ? gistOf(b.thinking, width - TIME_COL - 6) : '';
609
- const budget = 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
+ : [];
610
761
  const live = [];
611
762
  for (const t of tools.values()) {
612
- if (t.branch === b.name && live.length < budget) {
763
+ if (t.branch === b.name && live.length < share) {
613
764
  const view = describeCall(t.name, t.args);
614
765
  live.push({
615
766
  key: `t:${t.callId}`,
@@ -620,53 +771,28 @@ function branchBoxesOf(tools, branches, now, width, lane, spin, share) {
620
771
  });
621
772
  }
622
773
  }
623
- // Oldest of the calls that still fit, so the newest is always the row
624
- // nearest the one running.
625
- const past = b.done.slice(Math.max(0, b.done.length - (budget - live.length)));
626
- const rows = [
627
- ...past.map((d) => {
628
- const view = describeCall(d.name, d.args);
629
- return {
630
- key: `d:${d.callId}`,
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),
635
- };
636
- }),
637
- // Between the calls it made and the one it is making: the reasoning
638
- // is what got it from one to the other.
639
- ...(gist
640
- ? [
641
- {
642
- key: `g:${b.name}`,
643
- mark: '✻',
644
- time: '',
645
- lead: '',
646
- text: gist,
647
- thinking: true,
648
- },
649
- ]
650
- : []),
651
- ...live,
652
- ];
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];
653
777
  if (!rows.length) {
654
778
  rows.push({
655
779
  key: `w:${b.name}`,
656
- mark: '✻',
780
+ mark: '',
657
781
  time: '',
658
782
  lead: '',
659
783
  text: 'thinking…',
660
784
  thinking: true,
785
+ shimmer: b.musing,
661
786
  });
662
787
  }
663
788
  boxes.push({
664
789
  name: b.name,
665
790
  color: lane(b.name),
666
791
  title: `${b.name}${b.agent ? ` · ${b.agent}` : ''}`,
792
+ context: `context ${b.context}`,
667
793
  stats: `${b.steps} ${b.steps === 1 ? 'step' : 'steps'}` +
668
- (b.tools ? ` ${b.tools} ${b.tools === 1 ? 'tool' : 'tools'}` : '') +
669
- ` ${secs(now - b.startedAt)}`,
794
+ (b.tools ? ` ${b.tools} ${b.tools === 1 ? 'tool' : 'tools'}` : ''),
795
+ elapsed: secs(now - b.startedAt),
670
796
  rows: rows.slice(-share),
671
797
  });
672
798
  }
@@ -708,23 +834,58 @@ function fitActivity(boxes, trunk, allowance) {
708
834
  kept.push(b);
709
835
  used += h;
710
836
  }
711
- const left = boxes.length - kept.length;
712
- 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
+ }
713
846
  return {
714
847
  boxes: kept,
715
848
  trunk: trunk.slice(0, Math.max(0, allowance - used - (hidden ? 1 : 0))),
716
849
  hidden,
717
850
  };
718
851
  }
719
- function Branches({ boxes, spin, columns, }) {
852
+ function Branches({ boxes, spin, frame, columns, }) {
720
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);
721
858
  return (_jsx(Box, { flexDirection: "column", children: boxes.map((b) => {
722
- // Ink truncates rather than wraps, so an over-wide rule clips
723
- // instead of costing the box a row it was not given.
724
- const used = 3 + b.title.length + 2 + 2 + b.stats.length + 1;
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));
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));
726
872
  }) }));
727
873
  }
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
+ }
728
889
  /**
729
890
  * A row of work in the same shape the transcript will keep it in.
730
891
  *
@@ -732,19 +893,25 @@ function Branches({ boxes, spin, columns, }) {
732
893
  * sits where nothing will be once the call returns and the time, verb and
733
894
  * subject never move.
734
895
  */
735
- function Work({ row }) {
896
+ function Work({ row, frame }) {
736
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.
737
901
  if (row.thinking) {
738
- return _jsx(Text, { color: theme.thinking.color, children: `${row.mark} ${row.text}` });
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 });
739
906
  }
740
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}` : '' })] }));
741
908
  }
742
- function Activity({ rows, hidden, }) {
909
+ function Activity({ rows, hidden, frame, }) {
743
910
  const theme = useTheme();
744
911
  if (!rows.length && !hidden) {
745
912
  return null;
746
913
  }
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] }));
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] }));
748
915
  }
749
916
  /**
750
917
  * A stable colour per branch, handed out in order of first sight. Held in a ref
@@ -788,7 +955,12 @@ function Reasoning({ text, columns, frame, live, }) {
788
955
  function Streaming({ text, columns, rows }) {
789
956
  // The same width the finished answer will take, so landing it reflows
790
957
  // nothing: what is on screen is what stays there.
791
- const shown = windowOf(text, answerWidth(columns) - 4, rows);
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);
792
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))) }));
793
965
  }
794
966
  /** The label column the two number rows line up behind. */
@@ -33,6 +33,16 @@ export interface Theme {
33
33
  * model's thinking from the work it went on to do.
34
34
  */
35
35
  readonly thinking: LineStyle;
36
+ /**
37
+ * Inline code inside prose — `zen check`, `--theme`, a path.
38
+ *
39
+ * The one role added for markdown, and it is added because the alternative
40
+ * is worse: bold is already what `**strong**` means, and grey would say a
41
+ * command name matters less than the sentence around it when it is usually
42
+ * the only part worth copying. A hue says *this is literal* without
43
+ * ranking it.
44
+ */
45
+ readonly code: LineStyle;
36
46
  /**
37
47
  * The three things a turn spends time on. One word in the footer says which,
38
48
  * and it is read at a glance from across a desk — so the word changing is
package/dist/tui/theme.js CHANGED
@@ -56,6 +56,9 @@ const DARK = {
56
56
  // Bright: the gist is one line among many and plain `magenta` on black is
57
57
  // the darkest of the six.
58
58
  thinking: { color: 'magentaBright' },
59
+ // Green is the one hue with no other job here: it is not the accent, not
60
+ // the warning, and not the thinking.
61
+ code: { color: 'green' },
59
62
  phase: { reasoning: 'magenta', waiting: 'yellow', working: 'cyan' },
60
63
  lanes: ['cyan', 'green', 'yellow', 'blue', 'red', 'magenta'],
61
64
  };
@@ -75,6 +78,7 @@ const LIGHT = {
75
78
  warn: 'magenta',
76
79
  chrome: LIGHT_CHROME,
77
80
  thinking: { color: 'magenta' },
81
+ code: { color: 'green' },
78
82
  // No yellow: `waiting` takes blue and `working` green, which are the two
79
83
  // that survive paper.
80
84
  phase: { reasoning: 'magenta', waiting: 'blue', working: 'green' },