@zenera/cli 1.1.9 → 1.1.11

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.
Files changed (45) hide show
  1. package/README.md +49 -15
  2. package/dist/cache.d.ts +98 -0
  3. package/dist/cache.js +301 -0
  4. package/dist/catalog.d.ts +3 -0
  5. package/dist/catalog.js +35 -11
  6. package/dist/commands/cache.d.ts +7 -0
  7. package/dist/commands/cache.js +245 -0
  8. package/dist/commands/check.js +6 -3
  9. package/dist/commands/index.js +3 -1
  10. package/dist/commands/key.js +68 -14
  11. package/dist/commands/models.js +17 -1
  12. package/dist/commands/run.js +11 -3
  13. package/dist/commands/sandbox.js +70 -23
  14. package/dist/history.d.ts +18 -0
  15. package/dist/history.js +93 -0
  16. package/dist/home.d.ts +2 -2
  17. package/dist/home.js +2 -2
  18. package/dist/keys.d.ts +22 -0
  19. package/dist/keys.js +105 -2
  20. package/dist/lib.d.ts +1 -0
  21. package/dist/lib.js +1 -0
  22. package/dist/liveness.js +11 -0
  23. package/dist/resolve.d.ts +4 -0
  24. package/dist/resolve.js +43 -17
  25. package/dist/term.d.ts +25 -2
  26. package/dist/term.js +224 -10
  27. package/dist/tui/app.d.ts +10 -0
  28. package/dist/tui/app.js +542 -58
  29. package/dist/tui/theme.d.ts +6 -2
  30. package/dist/tui/theme.js +14 -8
  31. package/dist/tui/wrap.d.ts +92 -0
  32. package/dist/tui/wrap.js +147 -2
  33. package/dist/validate.d.ts +2 -0
  34. package/dist/validate.js +87 -2
  35. package/package.json +2 -2
  36. package/templates/editor/.github/copilot-instructions.md +50 -13
  37. package/templates/editor/.github/prompts/new-agent.prompt.md +5 -2
  38. package/templates/editor/.github/prompts/sync-with-spec.prompt.md +202 -0
  39. package/templates/editor/.github/skills/zen-cli/SKILL.md +2 -1
  40. package/templates/editor/.github/skills/zen-cli/references/faker.md +18 -8
  41. package/templates/editor/.github/skills/zen-cli/references/keys.md +7 -7
  42. package/templates/editor/.github/skills/zen-cli/references/rag.md +104 -0
  43. package/templates/editor/.github/skills/zen-rag-docs/SKILL.md +575 -0
  44. package/templates/editor/.github/skills/{api-schema-index → zen-rag-schema}/SKILL.md +28 -20
  45. package/templates/editor/.vscode/settings.json +1 -1
package/dist/tui/app.js CHANGED
@@ -1,15 +1,16 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { TextInput } from '@inkjs/ui';
3
+ import { addUsage, isCheckpoint, turns, zeroUsage, } from '@zenera/neo';
3
4
  import { Box, Static, Text, useApp, useInput, useStdout } from 'ink';
4
5
  import { pathToFileURL } from 'node:url';
5
- import React, { useCallback, useContext, useRef, useState } from 'react';
6
- import { isCheckpoint, turns, zeroUsage } from '@zenera/neo';
6
+ import React, { useCallback, useContext, useEffect, useRef, useState } from 'react';
7
7
  import * as Engine from "../engine.js";
8
+ import { History } from "../history.js";
8
9
  import { format } from "../narrate.js";
9
10
  import { display } from "../session.js";
10
11
  import { CliError } from "../term.js";
11
12
  import { resolveTheme, THEMES } from "./theme.js";
12
- import { windowOf } from "./wrap.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
14
  const BANNER = { key: 'banner' };
14
15
  const isBanner = (item) => item.key === 'banner';
15
16
  const MARK = {
@@ -19,6 +20,12 @@ const MARK = {
19
20
  note: ' ',
20
21
  error: '!',
21
22
  };
23
+ /** Proof of life. Ten frames at 100ms is a turn of the wheel per second. */
24
+ const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
25
+ /** Redraw cadence for the activity region, independent of events. */
26
+ const FRAME_MS = 100;
27
+ /** How long after the last delta reasoning is still called live. */
28
+ const SETTLE_MS = 800;
22
29
  // The theme is decided once, before the first frame, and never changes while
23
30
  // the app is up — a terminal does not repaint its own scheme underneath us.
24
31
  // A context rather than props only because every part of the view wants it.
@@ -26,7 +33,20 @@ const ThemeContext = React.createContext(THEMES.dark);
26
33
  const useTheme = () => useContext(ThemeContext);
27
34
  function Row({ line }) {
28
35
  const style = useTheme().line[line.kind];
29
- 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: [_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] })] }));
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 keeps the row's colour without its dimming. 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, 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] })] }));
41
+ }
42
+ function Answer({ text }) {
43
+ const { stdout } = useStdout();
44
+ const width = answerWidth(stdout?.columns ?? 80);
45
+ const segments = segmentsOf(text);
46
+ // Bounded, like every answer in `examples/`: 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))) }));
30
50
  }
31
51
  function App({ engine, options, theme }) {
32
52
  const { exit } = useApp();
@@ -40,19 +60,136 @@ function App({ engine, options, theme }) {
40
60
  session: engine.state?.usage ?? zeroUsage(),
41
61
  calls: engine.state ? turns(engine.state) : 0,
42
62
  });
43
- const [tool, setTool] = useState(undefined);
63
+ /** Model calls in the turn now running, and what answered the last one. */
64
+ const [step, setStep] = useState(0);
65
+ const [model, setModel] = useState(undefined);
66
+ // In flight, and therefore not in the transcript yet. Refs, because the
67
+ // activity region below is redrawn by the frame timer regardless.
68
+ const running = useRef(new Map());
69
+ const branches = useRef(new Map());
70
+ const lane = useLanes(theme);
71
+ // What the turn now running has cost so far, summed per model call rather
72
+ // than read off the state: it is wanted between calls, not after them. The
73
+ // exact figure replaces it when the turn lands.
74
+ const spent = useRef(zeroUsage());
75
+ const startedAt = useRef(0);
76
+ /** When reasoning text last arrived, which is the only proof it is still coming. */
77
+ const flowing = useRef(0);
44
78
  const stopping = useRef(undefined);
45
79
  const seq = useRef(0);
80
+ /** The tallest the repainting region has been this turn, which is the height it keeps. */
81
+ const grown = useRef(0);
82
+ // The prompt, and how to get back into it what was asked before.
83
+ //
84
+ // `TextInput` is uncontrolled: it takes a starting value and owns it from
85
+ // there. So recalling a line means handing it a new starting value and a
86
+ // new `key`, which mounts a fresh input — that is also what puts the cursor
87
+ // at the end of the recalled text, where it is wanted.
88
+ const [history] = useState(() => History.open(engine.project.root));
89
+ const [draft, setDraft] = useState('');
90
+ const [generation, setGeneration] = useState(0);
91
+ /** Where in the history the prompt is; `entries.length` is the live line. */
92
+ const at = useRef(history.entries.length);
93
+ /** What the input holds right now, which nothing else can see. */
94
+ const typed = useRef('');
95
+ /** The half-written line browsing started from, returned to by walking back down. */
96
+ const pending = useRef('');
97
+ const remember = useCallback((value) => {
98
+ typed.current = value;
99
+ }, []);
100
+ const reset = useCallback(() => {
101
+ at.current = history.entries.length;
102
+ pending.current = '';
103
+ typed.current = '';
104
+ setDraft('');
105
+ setGeneration((g) => g + 1);
106
+ }, [history]);
107
+ const recall = useCallback((delta) => {
108
+ const items = history.entries;
109
+ const end = items.length;
110
+ if (end === 0) {
111
+ return;
112
+ }
113
+ if (at.current === end) {
114
+ if (delta > 0) {
115
+ return;
116
+ }
117
+ pending.current = typed.current;
118
+ }
119
+ const next = Math.min(end, Math.max(0, at.current + delta));
120
+ if (next === at.current) {
121
+ return;
122
+ }
123
+ at.current = next;
124
+ const value = next === end ? pending.current : (items[next] ?? '');
125
+ typed.current = value;
126
+ setDraft(value);
127
+ setGeneration((g) => g + 1);
128
+ }, [history]);
129
+ // A spinner and a clock have to move on their own, so the frame is driven
130
+ // by a timer rather than by events — but only while there is something to
131
+ // watch, so an idle prompt repaints exactly never.
132
+ const [frame, setFrame] = useState(0);
133
+ useEffect(() => {
134
+ if (!busy) {
135
+ return;
136
+ }
137
+ const timer = setInterval(() => setFrame((n) => n + 1), FRAME_MS);
138
+ return () => clearInterval(timer);
139
+ }, [busy]);
46
140
  // Read during render so a resize, which re-renders the root, resizes the
47
- // windows below with it. The two streaming blocks share one budget: what
141
+ // windows below with it. The three repainting blocks share one budget: what
48
142
  // is left of the terminal once the chrome has had its rows.
49
143
  const rows = stdout?.rows ?? 24;
50
144
  const columns = stdout?.columns ?? 80;
51
- const budget = Math.max(2, rows - CHROME_ROWS);
52
- const thinkingRows = thinking ? Math.min(THINKING_ROWS, Math.max(1, budget - 2)) : 0;
53
- const liveRows = Math.max(1, budget - thinkingRows);
54
- const push = useCallback((kind, text, detail) => {
55
- setLines((prev) => [...prev, { key: `${seq.current++}`, kind, text, detail }]);
145
+ const spin = SPINNER[frame % SPINNER.length];
146
+ // Every branch box is the same height, so the share has to be settled
147
+ // before the boxes are built: the rows come out of one allowance and a
148
+ // fan-out is read across, not down.
149
+ const allowance = Math.min(ACTIVITY_ROWS, Math.max(0, rows - CHROME_ROWS - 2));
150
+ const boxes = busy
151
+ ? branchBoxesOf(running.current, branches.current, Date.now(), columns - GUTTER, lane, branchRows(branches.current.size, allowance))
152
+ : [];
153
+ // What the trunk itself has in flight. A branch's call is the branch's
154
+ // business — it has a box saying so — and the trunk, having forked, is
155
+ // waiting at the join.
156
+ const mine = busy ? trunkCallsOf(running.current, branches.current) : [];
157
+ const trunkRows = busy ? trunkRowsOf(mine, Date.now(), columns - GUTTER - 2) : [];
158
+ // Priced as far as it has got. Read during render, so the frame timer is
159
+ // what advances the clock.
160
+ const inflight = busy
161
+ ? { usage: spent.current, durationMs: Date.now() - startedAt.current }
162
+ : 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);
173
+ const fitted = fitActivity(boxes, trunkRows, budget.activity);
174
+ // 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;
181
+ const wanted = activityHeight(fitted.boxes, fitted.trunk) +
182
+ (fitted.hidden ? 1 : 0) +
183
+ thinkingRows +
184
+ liveRows;
185
+ // A turn opens against the prompt and grows up from it, one row at a time,
186
+ // the way anything else printed to a terminal does. Reserving the whole
187
+ // region up front instead threw the question that started it at the ceiling
188
+ // before a word of the answer existed. It only ever grows, so the footer
189
+ // still never rides back up: what a block gives back is left as slack.
190
+ 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 }]);
56
193
  }, []);
57
194
  // Deltas arrive far faster than a terminal can usefully redraw, so text is
58
195
  // accumulated in one string and React coalesces the repaints. The finished
@@ -64,48 +201,159 @@ function App({ engine, options, theme }) {
64
201
  // is lost when it is cleared at the start of the next model call.
65
202
  const onEvent = useCallback((event) => {
66
203
  if (!isCheckpoint(event)) {
67
- if (event.type === 'text_delta') {
68
- setLive((prev) => prev + event.delta);
204
+ // Only the trunk's answer is drawn. A fork has several running
205
+ // at once, and appending them all to one string is not a
206
+ // transcript of anything: it is several answers interleaved
207
+ // token by token. Each branch's distilled result arrives at the
208
+ // join, and the whole of it is in the report.
209
+ //
210
+ // Reasoning is different: it is a progress indicator, and each
211
+ // branch has a box to put its own in.
212
+ const owner = event.branch ? branches.current.get(event.branch.name) : undefined;
213
+ if (event.type === 'thinking_delta') {
214
+ if (owner) {
215
+ owner.thinking += event.delta;
216
+ return;
217
+ }
218
+ if (!event.branch) {
219
+ flowing.current = Date.now();
220
+ setThinking((prev) => prev + event.delta);
221
+ }
222
+ return;
69
223
  }
70
- else if (event.type === 'thinking_delta') {
71
- setThinking((prev) => prev + event.delta);
224
+ if (event.type === 'text_delta' && !event.branch) {
225
+ setLive((prev) => prev + event.delta);
72
226
  }
73
227
  return;
74
228
  }
229
+ const from = event.branch?.name;
75
230
  switch (event.type) {
76
231
  case 'before_llm_call':
77
- setThinking('');
232
+ if (from) {
233
+ const b = branches.current.get(from);
234
+ if (b) {
235
+ b.steps++;
236
+ b.thinking = '';
237
+ }
238
+ }
239
+ else {
240
+ setThinking('');
241
+ setStep((n) => n + 1);
242
+ }
243
+ break;
244
+ case 'after_llm_call':
245
+ // Branches included: they are what this turn is spending on.
246
+ spent.current = addUsage(spent.current, event.node.usage);
247
+ if (!from) {
248
+ setModel(event.node.model);
249
+ }
78
250
  break;
79
251
  case 'before_tool_call':
80
- setTool(event.call.name);
252
+ running.current.set(event.call.callId, {
253
+ callId: event.call.callId,
254
+ name: event.call.name,
255
+ args: event.call.args.preview ?? '',
256
+ startedAt: Date.now(),
257
+ branch: from,
258
+ });
81
259
  break;
82
- case 'after_tool_call':
83
- setTool(undefined);
84
- push('tool', event.node.name, event.node.isError ? 'failed' : durationOf(event.node.durationMs));
260
+ case 'after_tool_call': {
261
+ const { node } = event;
262
+ const call = running.current.get(node.callId);
263
+ running.current.delete(node.callId);
264
+ const b = from ? branches.current.get(from) : undefined;
265
+ if (b) {
266
+ b.tools++;
267
+ // A branch's calls belong to its box, not to the
268
+ // transcript: several branches finishing into one
269
+ // scrollback is a fan-out shuffled, and the box is
270
+ // the only place the shape of the fork survives.
271
+ // The join summarises it; the report has all of it.
272
+ b.done.push({
273
+ callId: node.callId,
274
+ name: node.name,
275
+ args: call?.args ?? '',
276
+ ms: node.durationMs,
277
+ failed: node.isError,
278
+ });
279
+ b.done.splice(0, b.done.length - BRANCH_ROWS);
280
+ break;
281
+ }
282
+ // What it was asked and what it answered, which is the
283
+ // difference between knowing a tool ran and knowing what
284
+ // the agent did. Both are previews already; a whole file
285
+ // 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);
85
287
  break;
86
- case 'handoff':
288
+ }
289
+ case 'handoff': {
290
+ // A handoff inside a branch renames that branch, not the
291
+ // session: the trunk is still whoever forked.
292
+ const b = from ? branches.current.get(from) : undefined;
293
+ if (b) {
294
+ b.agent = event.to;
295
+ break;
296
+ }
87
297
  setAgent(event.to);
88
298
  push('note', `→ ${event.to}`, `handed off from ${event.from}`);
89
299
  break;
300
+ }
90
301
  case 'before_fork':
91
- push('note', `⑂ ${event.node.branches.map((b) => b.name).join(', ')}`);
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.
306
+ if (from) {
307
+ break;
308
+ }
309
+ setThinking('');
310
+ push('note', `⑂ ${event.node.branches.length} branches`, `${event.node.branches.map((b) => b.name).join(', ')} · context ${event.node.contextMode}`);
92
311
  break;
93
- case 'branch_finished':
94
- push('tool', `⑂ ${event.child.name}`, event.status);
312
+ case 'branch_started':
313
+ branches.current.set(event.child.name, {
314
+ name: event.child.name,
315
+ agent: event.childState.agentName,
316
+ startedAt: Date.now(),
317
+ steps: 0,
318
+ tools: 0,
319
+ thinking: '',
320
+ done: [],
321
+ });
95
322
  break;
323
+ case 'branch_finished': {
324
+ const b = branches.current.get(event.child.name);
325
+ 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}`);
329
+ break;
330
+ }
96
331
  default:
97
332
  break;
98
333
  }
99
- }, [push]);
334
+ }, [columns, push]);
100
335
  const submit = useCallback((value) => {
101
336
  const text = value.trim();
102
- if (!text || busy) {
337
+ if (busy) {
338
+ return;
339
+ }
340
+ // Recorded before it is acted on, so a question that fails — or
341
+ // one that quits — is still one arrow key away next time.
342
+ history.add(text);
343
+ reset();
344
+ if (!text) {
103
345
  return;
104
346
  }
105
347
  if (text === '/exit' || text === '/quit') {
106
348
  exit();
107
349
  return;
108
350
  }
351
+ if (text === '/help') {
352
+ push('note', 'commands', '/help /clear /exit · ↑ recalls, esc stops a turn');
353
+ push('note', 'the footer', 'in = what was sent · out = what came back · a number in brackets is part' +
354
+ ' of the one before it, not an extra');
355
+ return;
356
+ }
109
357
  if (text === '/clear') {
110
358
  setLines([]);
111
359
  stdout?.write('\u001b[2J\u001b[H');
@@ -115,6 +363,12 @@ function App({ engine, options, theme }) {
115
363
  setBusy(true);
116
364
  setLive('');
117
365
  setThinking('');
366
+ setStep(0);
367
+ grown.current = 0;
368
+ spent.current = zeroUsage();
369
+ startedAt.current = Date.now();
370
+ running.current.clear();
371
+ branches.current.clear();
118
372
  const controller = new AbortController();
119
373
  stopping.current = controller;
120
374
  void (async () => {
@@ -146,28 +400,43 @@ function App({ engine, options, theme }) {
146
400
  setBusy(false);
147
401
  setLive('');
148
402
  setThinking('');
149
- setTool(undefined);
403
+ running.current.clear();
404
+ branches.current.clear();
150
405
  stopping.current = undefined;
151
406
  }
152
407
  })();
153
- }, [busy, engine, exit, onEvent, push, stdout]);
408
+ }, [busy, engine, exit, history, onEvent, push, reset, stdout]);
154
409
  // Escape stops the turn; ctrl-c leaves. They are different things, and a
155
410
  // run that is asked to stop still writes its state, so the session survives
156
411
  // either one.
157
- useInput((_input, keys) => {
412
+ //
413
+ // The arrows are ours because `TextInput` explicitly ignores them; a turn
414
+ // in flight owns the keyboard, so they only walk the history while idle.
415
+ useInput((input, keys) => {
158
416
  if (keys.escape && stopping.current) {
159
417
  stopping.current.abort();
160
418
  }
161
- if (keys.ctrl && _input === 'c') {
419
+ if (keys.ctrl && input === 'c') {
162
420
  stopping.current?.abort();
163
421
  exit();
422
+ return;
423
+ }
424
+ if (busy) {
425
+ return;
426
+ }
427
+ if (keys.upArrow) {
428
+ recall(-1);
429
+ }
430
+ else if (keys.downArrow) {
431
+ recall(1);
164
432
  }
165
433
  });
166
- 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 }, item.key)) : (_jsx(Row, { line: item }, item.key)) }), thinking ? (_jsx(Thinking, { text: thinking, columns: columns, rows: thinkingRows })) : null, live ? _jsx(Live, { text: live, columns: columns, rows: liveRows }) : null, _jsx(Footer, { agent: agent, busy: busy, tool: tool, stats: stats, thinking: Boolean(thinking) }), busy ? null : (_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, children: "\u203A " }), _jsx(TextInput, { placeholder: "Ask something\u2026 (/exit to leave)", onSubmit: submit })] }))] }) }));
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, 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)] }))] }) }));
167
435
  }
168
- function Header({ engine, readOnly, }) {
436
+ function Header({ engine, readOnly, started, }) {
169
437
  const theme = useTheme();
170
- return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: engine.name }), _jsxs(Text, { dimColor: true, children: [" ", engine.session.id] }), readOnly ? _jsx(Text, { color: theme.warn, children: " read-only" }) : null] }), _jsx(Text, { dimColor: true, children: display(engine.workspace) })] }));
438
+ 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}` : ''] })] }));
171
440
  }
172
441
  // ---------------------------------------------------------------------------
173
442
  // The repainting frame
@@ -190,44 +459,242 @@ function Header({ engine, readOnly, }) {
190
459
  //
191
460
  // Nothing is lost by any of it: the finished answer lands in `Static` whole,
192
461
  // and the full reasoning chain is in the trajectory and the run's report.
462
+ //
463
+ // How the rows are divided between the three blocks is `budgetOf` in wrap.ts.
193
464
  // ---------------------------------------------------------------------------
194
- /** How much of the reasoning stream is worth showing. It is a progress bar. */
195
- const THINKING_ROWS = 6;
196
- /** The two footer rows, its margin, the prompt, and a row in hand. */
197
- const CHROME_ROWS = 6;
198
465
  /** The gutter every streaming block is indented behind. */
199
466
  const GUTTER = 2;
200
- function Thinking({ text, columns, rows }) {
467
+ /**
468
+ * A fan-out, demultiplexed into one box per branch. Interleaving eight
469
+ * branches' calls into a single list is not a picture of parallel work, it is
470
+ * eight pictures shuffled together; a box per branch is what `examples/board.ts`
471
+ * gets right and what makes the shape of the fork legible at a glance.
472
+ *
473
+ * A box shows the last few calls the branch made and whatever it has in flight,
474
+ * in that order: what it just did is why it is doing this. `share` is how many
475
+ * rows it may spend on them, so the region is bounded however wide the fork is.
476
+ *
477
+ * A branch between calls is not idle, it is deciding what to call next, so its
478
+ * box keeps a row saying so rather than collapsing and making the whole board
479
+ * jump every time a tool returns.
480
+ */
481
+ function branchBoxesOf(tools, branches, now, width, lane, share) {
482
+ const boxes = [];
483
+ 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);
488
+ const live = [];
489
+ for (const t of tools.values()) {
490
+ if (t.branch === b.name && live.length < room) {
491
+ const detail = ` ${secs(now - t.startedAt)}`;
492
+ live.push({
493
+ key: `t:${t.callId}`,
494
+ label: clip(`${t.name} ${readable(t.args)}`, width - detail.length - 2),
495
+ detail,
496
+ });
497
+ }
498
+ }
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 ? [{ key: `g:${b.name}`, label: `… ${gist}`, detail: '', past: true }] : []),
515
+ ...live,
516
+ ];
517
+ if (!rows.length) {
518
+ rows.push({ key: `w:${b.name}`, label: 'thinking…', detail: '', past: false });
519
+ }
520
+ boxes.push({
521
+ name: b.name,
522
+ color: lane(b.name),
523
+ title: `${b.name}${b.agent ? ` · ${b.agent}` : ''}`,
524
+ stats: `${b.steps} ${b.steps === 1 ? 'step' : 'steps'}` +
525
+ (b.tools ? ` ${b.tools} ${b.tools === 1 ? 'tool' : 'tools'}` : '') +
526
+ ` ${secs(now - b.startedAt)}`,
527
+ rows: rows.slice(-share),
528
+ });
529
+ }
530
+ return boxes;
531
+ }
532
+ /** What the trunk itself has in flight: everything no live branch owns. */
533
+ function trunkCallsOf(tools, branches) {
534
+ return [...tools.values()].filter((t) => t.branch === undefined || !branches.has(t.branch));
535
+ }
536
+ /** What the trunk itself has in flight. It is one thread, so it gets no box. */
537
+ function trunkRowsOf(calls, now, width) {
538
+ return calls.map((t) => {
539
+ const detail = ` ${secs(now - t.startedAt)}`;
540
+ return {
541
+ key: `t:${t.callId}`,
542
+ label: clip(`${t.name} ${readable(t.args)}`, width - detail.length),
543
+ detail,
544
+ };
545
+ });
546
+ }
547
+ /** The rows the whole region wants before anything is cut. */
548
+ function activityHeight(boxes, trunk) {
549
+ return boxes.reduce((n, b) => n + BOX_CHROME + b.rows.length, 0) + trunk.length;
550
+ }
551
+ /**
552
+ * Boxes first and whole: half a box is an opening rule with nothing to close
553
+ * it, so a branch that does not fit is counted rather than clipped.
554
+ */
555
+ function fitActivity(boxes, trunk, allowance) {
556
+ const kept = [];
557
+ let used = 0;
558
+ for (const b of boxes) {
559
+ const h = BOX_CHROME + b.rows.length;
560
+ if (used + h > allowance) {
561
+ break;
562
+ }
563
+ kept.push(b);
564
+ used += h;
565
+ }
566
+ const left = boxes.length - kept.length;
567
+ const hidden = left && used < allowance ? left : 0;
568
+ return {
569
+ boxes: kept,
570
+ trunk: trunk.slice(0, Math.max(0, allowance - used - (hidden ? 1 : 0))),
571
+ hidden,
572
+ };
573
+ }
574
+ function Branches({ boxes, spin, columns, }) {
575
+ return (_jsx(Box, { flexDirection: "column", children: boxes.map((b) => {
576
+ // Ink truncates rather than wraps, so an over-wide rule clips
577
+ // instead of costing the box a row it was not given.
578
+ const used = 3 + b.title.length + 2 + 2 + b.stats.length + 1;
579
+ 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, { dimColor: true, children: r.label }), _jsx(Text, { dimColor: true, children: r.detail })] }, r.key))), _jsx(Text, { dimColor: true, children: '╰─' })] }, b.name));
580
+ }) }));
581
+ }
582
+ function Activity({ rows, hidden, }) {
583
+ if (!rows.length && !hidden) {
584
+ return null;
585
+ }
586
+ 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, dimColor: r.color === undefined, 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] }));
587
+ }
588
+ /**
589
+ * A stable colour per branch, handed out in order of first sight. Held in a ref
590
+ * so a branch keeps its colour for the whole turn rather than being recoloured
591
+ * every time the map is rebuilt.
592
+ */
593
+ function useLanes(theme) {
594
+ const assigned = useRef(new Map());
595
+ return useCallback((name) => {
596
+ if (!name) {
597
+ return undefined;
598
+ }
599
+ const seen = assigned.current.get(name);
600
+ if (seen !== undefined) {
601
+ return seen;
602
+ }
603
+ const next = theme.lanes[assigned.current.size % theme.lanes.length];
604
+ assigned.current.set(name, next);
605
+ return next;
606
+ }, [theme]);
607
+ }
608
+ /** A reasoning summary's own headings, which arrive as markdown. */
609
+ const HEADING = /^\s*(?:#{1,6}\s*)?\*\*(.+?)\*\*[:.]?\s*$/;
610
+ const TITLE = 'reasoning';
611
+ /**
612
+ * Reasoning is dim prose sitting between dim tool rows and a dim footer, with
613
+ * nothing to say where it starts or stops — so it reads as part of whatever is
614
+ * above it, and it never leaves the bottom of the screen. A rule at each end
615
+ * gives it an edge. Round, to tell live chrome from the square fences an answer
616
+ * puts around code.
617
+ */
618
+ /** Where a settled block got to: its last heading, or failing that its opening. */
619
+ function gistOf(text, width) {
620
+ const lines = text.split('\n');
621
+ for (let i = lines.length - 1; i >= 0; i--) {
622
+ const head = HEADING.exec(lines[i] ?? '');
623
+ if (head?.[1]) {
624
+ return clip(head[1], width);
625
+ }
626
+ }
627
+ return clip(text, width);
628
+ }
629
+ function Thinking({ text, columns, rows, spin, live, }) {
201
630
  const theme = useTheme();
202
- const shown = windowOf(text, columns - GUTTER, rows);
203
- return (_jsxs(Box, { flexDirection: "row", height: shown.length, overflow: "hidden", children: [_jsx(Box, { flexDirection: "column", width: GUTTER, children: shown.map((_, i) => (_jsx(Text, { color: theme.rule, dimColor: true, children: i === 0 ? '◇ ' : ' ' }, i))) }), _jsx(Box, { flexDirection: "column", children: shown.map((row, i) => (_jsx(Text, { dimColor: true, italic: true, wrap: "truncate-end", children: row }, i))) })] }));
631
+ // Settled, the block is down to one row, and the tail of a paragraph is a
632
+ // fragment ("ports.") rather than a summary. The last heading the model
633
+ // wrote is the shortest true account of where it got to.
634
+ const shown = live ? windowOf(text, columns - GUTTER, rows) : [gistOf(text, columns - GUTTER)];
635
+ // Ink truncates rather than wraps, so an over-wide rule clips instead of
636
+ // costing the block a row it was not given.
637
+ const dashes = Math.max(0, columns - TITLE.length - (live ? 6 : 4));
638
+ 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, { dimColor: true, children: TITLE }), live ? _jsx(Text, { color: theme.warn, children: ` ${spin}` }) : null, _jsx(Text, { dimColor: true, children: ` ${'─'.repeat(dashes)}` })] }), shown.map((row, i) => {
639
+ // Not italic: dim italic on a dark terminal is the least
640
+ // legible thing available, and this is meant to be read.
641
+ const head = HEADING.exec(row);
642
+ return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: '│ ' }), _jsx(Text, { dimColor: true, bold: head !== null, children: head ? head[1] : row })] }, i));
643
+ }), _jsx(Text, { dimColor: true, children: '╰─' })] }));
204
644
  }
205
645
  function Live({ text, columns, rows }) {
206
- const shown = windowOf(text, columns - GUTTER, rows);
646
+ // The same width the finished answer will take, so landing it reflows
647
+ // nothing: what is on screen is what stays there.
648
+ const shown = windowOf(text, answerWidth(columns) - 4, rows);
207
649
  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))) }));
208
650
  }
209
- function Footer({ agent, busy, tool, stats, thinking, }) {
210
- const what = tool ? `running ${tool}` : thinking ? 'reasoning' : 'thinking';
651
+ /** The label column the two number rows line up behind. */
652
+ const LABEL = 10;
653
+ function Footer({ agent, busy, spin, running, forked, step, model, stats, inflight, reasoning, columns, }) {
211
654
  const theme = useTheme();
212
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { children: [busy ? _jsxs(Text, { color: theme.warn, children: [what, "\u2026 "] }) : null, _jsx(Text, { color: theme.accent, dimColor: true, children: agent }), stats.turn ? (_jsxs(Text, { dimColor: true, children: [' turn ', tokens(stats.turn), stats.durationMs === undefined
655
+ // Who before what. The agent is the subject of the sentence, and in a
656
+ // handoff it is the thing that changed.
657
+ const aside = [
658
+ ...(step ? [`step ${step}`] : []),
659
+ ...(model ? [model] : []),
660
+ 'esc to stop',
661
+ ].join(' · ');
662
+ // The argument is what identifies a generic tool — `run_command` is every
663
+ // shell command there is — but the activity row above carries it in full,
664
+ // so the footer takes it only when there is width to say something useful.
665
+ const first = running[0];
666
+ const room = columns - agent.length - aside.length - 12;
667
+ const what = running.length > 1
668
+ ? `running ${running.length} tools`
669
+ : first
670
+ ? room >= 24
671
+ ? clip(`${first.name} ${readable(first.args)}`, room)
672
+ : `running ${first.name}`
673
+ : forked
674
+ ? // Having forked, the trunk has nothing of its own to do. The
675
+ // boxes above say what the branches are doing.
676
+ `waiting on ${forked} ${forked === 1 ? 'branch' : 'branches'}`
677
+ : reasoning
678
+ ? 'reasoning'
679
+ : 'thinking';
680
+ // A clip has already ended it with one.
681
+ const status = what.endsWith('…') ? what : `${what}…`;
682
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, dimColor: true, children: agent }), busy ? (_jsxs(Text, { color: theme.warn, children: [' ', spin, " ", status] })) : null, busy ? _jsx(Text, { dimColor: true, children: ` ${aside}` }) : null] }), inflight ? (_jsxs(Text, { dimColor: true, children: ['this turn'.padEnd(LABEL), tokens(inflight.usage), ` · ${secs(inflight.durationMs)}`] })) : (_jsxs(_Fragment, { children: [stats.turn ? (_jsxs(Text, { dimColor: true, children: ['last turn'.padEnd(LABEL), tokens(stats.turn), stats.durationMs === undefined
213
683
  ? ''
214
- : ` · ${durationOf(stats.durationMs) ?? ''}`] })) : null, busy ? _jsx(Text, { dimColor: true, children: ' esc to stop' }) : null] }), _jsxs(Text, { dimColor: true, children: ['session ', tokens(stats.session), stats.calls ? ` · ${stats.calls} ${stats.calls === 1 ? 'call' : 'calls'}` : ''] })] }));
684
+ : ` · ${durationOf(stats.durationMs) ?? ''}`] })) : null, _jsxs(Text, { dimColor: true, children: ['session'.padEnd(LABEL), tokens(stats.session), stats.calls
685
+ ? ` · ${stats.calls} ${stats.calls === 1 ? 'call' : 'calls'}`
686
+ : ''] })] }))] }));
215
687
  }
216
688
  /**
217
- * Cache and reasoning are subsets of the numbers beside them, not additions to
218
- * them, and they are only worth the width when a provider actually reports one
219
- * most do not, and a row of zeroes teaches nobody anything.
689
+ * Cache and reasoning are SUBSETS of the number beside them, so they are drawn
690
+ * inside it. On one dotted line they read as four things to add up, which is
691
+ * the single reading that is wrong. Both are skipped when the provider reports
692
+ * nothing — most do not, and a row of zeroes teaches nobody anything.
220
693
  */
221
694
  function tokens(usage) {
222
- const parts = [`${format(usage.inputTokens)} in`];
223
- if (usage.cachedInputTokens) {
224
- parts.push(`${format(usage.cachedInputTokens)} cached`);
225
- }
226
- parts.push(`${format(usage.outputTokens)} out`);
227
- if (usage.reasoningTokens) {
228
- parts.push(`${format(usage.reasoningTokens)} thinking`);
229
- }
230
- return parts.join(' · ');
695
+ const cached = usage.cachedInputTokens ? ` (${format(usage.cachedInputTokens)} cached)` : '';
696
+ const think = usage.reasoningTokens ? ` (${format(usage.reasoningTokens)} thinking)` : '';
697
+ return `${format(usage.inputTokens)} in${cached} · ${format(usage.outputTokens)} out${think}`;
231
698
  }
232
699
  /** What the last turn added. Usage only ever grows, so a subtraction is safe. */
233
700
  function since(before, after) {
@@ -241,6 +708,23 @@ function since(before, after) {
241
708
  function durationOf(ms) {
242
709
  return ms === undefined ? undefined : ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`;
243
710
  }
711
+ /** A clock that is being watched. Always seconds, so the digits do not jump. */
712
+ function secs(ms) {
713
+ return `${(ms / 1000).toFixed(1)}s`;
714
+ }
715
+ /** What a finished tool call has to say for itself, under its own name. */
716
+ function detailOf(node, branch, width) {
717
+ const parts = [
718
+ ...(branch ? [`⑂ ${branch}`] : []),
719
+ ...(node.isError ? ['failed'] : []),
720
+ ...(durationOf(node.durationMs) ? [durationOf(node.durationMs)] : []),
721
+ ...(node.result.preview ? [readable(node.result.preview)] : []),
722
+ ];
723
+ // One row. Two left a six-character orphan under most results, and a
724
+ // preview is already a preview: the whole of it is a click away in the
725
+ // report, and a wall of it here buries the next answer.
726
+ return clip(parts.join(' · '), Math.max(40, width));
727
+ }
244
728
  // ---------------------------------------------------------------------------
245
729
  export async function start(engine, options) {
246
730
  // Asked before Ink takes the terminal: the query talks to stdin directly,