kronk-cli 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1238,30 +1238,53 @@ The model can hand one job to a **sub-agent**: a fresh agent with its own contex
1238
1238
  own tool loop, and no memory of your conversation. It runs the task, and the only thing that
1239
1239
  ever comes back is its report.
1240
1240
 
1241
+ While it runs, it draws in a box below the prompt that redraws in place:
1242
+
1241
1243
  ```console
1242
1244
  › which tools in this repo need approval, and where is that decided?
1243
1245
 
1244
1246
  1 ⚙ task explore: list every tool definition in src/, say which are gated and where
1245
- ┌ explore · list every tool definition in src/, say which are gated…
1246
- │ 1/40 ⚙ search /def\(/ in src
1247
- │ ✓ 9 lines
1248
- │ 2/40 ⚙ read src/tools.js
1249
- │ ✓ 587 lines
1250
- │ 3/40 ⚙ read src/subagent.js
1251
- │ ✓ 211 lines
1252
- │ 2104→380 tok · 58.1 tok/s · 1980 cached
1247
+ ┌─ explore ──────────────────────────────────────── 6/40 · 48s ─┐
1248
+ │ 4/40 ⚙ read src/tools.js │
1249
+ │ ✓ 587 lines │
1250
+ │ 5/40 ⚙ search /NEEDS_APPROVAL/ in src │
1251
+ │ ✓ 3 lines │
1252
+ │ 6/40 ⚙ read src/subagent.js │
1253
+ └─ esc to collapse · ctrl-c to stop ────────────────────────────┘
1254
+ ```
1255
+
1256
+ When it finishes the box is erased, and what is left in the scrollback is the answer and one
1257
+ line saying what the run cost:
1258
+
1259
+ ```console
1260
+ 1 ⚙ task explore: list every tool definition in src/, say which are gated and where
1253
1261
  Six built-in tools. write_file and bash are gated, by NEEDS_APPROVAL at
1254
1262
  src/tools.js:261. MCP tools are gated by name, mcpNeedsApproval at :270.
1255
1263
  read_file, list_dir, search, set_plan and task are not gated.
1256
- └ 3 steps · 11.4s · /tmp/kronk-cli/mfa1k2-8817/task-001.json
1264
+ └ 3 steps · 11.4s · /tasks 1 for the transcript
1257
1265
  ✓ 3 lines
1258
1266
 
1259
1267
  `write_file` and `bash`, from the `NEEDS_APPROVAL` set in src/tools.js:246…
1260
1268
  ```
1261
1269
 
1262
- `│` marks the sub-agent's own loop; the unprefixed lines are its report, which is what the main
1263
- agent receives as the tool result. `┌` and `└` fence one run, so two in a row are told apart in
1264
- scrollback, and the closing line says what it cost and where the rest of it went.
1270
+ The box is the run's only appearance on screen, and that is deliberate. A nine-step survey
1271
+ printed into the conversation leaves forty lines of someone else's work between the question
1272
+ you asked and the answer you got; scroll back ten minutes later and the conversation is mostly
1273
+ sub-agent. While the run is going a live view is the only sign the thing is alive, and it is
1274
+ where you notice one thrashing — but once it has ended, almost none of it is worth keeping.
1275
+
1276
+ Nothing is lost by erasing it. Everything the box shows is written to the run's transcript as
1277
+ it happens, so the box is a view of a file that outlives it, and `/tasks 1` below is how you
1278
+ read it back. Three keys and one fallback are worth knowing:
1279
+
1280
+ - **`esc` collapses the box immediately** and the run carries on. For when you have seen enough
1281
+ and want your screen back.
1282
+ - **`ctrl-c`** stops the run, as it stops anything else.
1283
+ - **An approval prompt wipes the box first.** A `code` sub-agent's `write_file` and `bash` stop
1284
+ for the same question the main agent's do, and that question gets the terminal to itself.
1285
+ - **On a pipe or a dumb terminal there is no box at all.** `kronk-cli "…" | grep`, a CI job and
1286
+ an `--auto` run nobody is watching each get the plain nested lines instead, in order, with no
1287
+ cursor control in them.
1265
1288
 
1266
1289
  Three files were read. None of them is in your conversation: the 800-odd lines they cost were
1267
1290
  spent in a context that no longer exists, and all that is left in your window is the four-line
@@ -1273,10 +1296,10 @@ already leaving it.
1273
1296
 
1274
1297
  ### Reading a run back
1275
1298
 
1276
- The report on screen is dimmed and capped at fourteen lines. That used to be the end of it: what
1277
- the sub-agent read, what it ran, and any line fifteen of its report went out of scope with its
1278
- context. Delegating is supposed to cost the *conversation* nothing — it was never supposed to
1279
- cost you the ability to check.
1299
+ The report on screen is dimmed and capped at fourteen lines, and the box it ran in is gone. That
1300
+ used to be the end of it: what the sub-agent read, what it ran, and any line fifteen of its
1301
+ report went out of scope with its context. Delegating is supposed to cost the *conversation*
1302
+ nothing — it was never supposed to cost you the ability to check.
1280
1303
 
1281
1304
  So each run is written to a file as it goes, and `/tasks` reads them back.
1282
1305
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kronk-cli",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "A Claude-Code-style terminal agent for local models served by Kronk.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
package/src/agent.js CHANGED
@@ -75,6 +75,9 @@ function endTurn(messages, { out = console.log, plan = true } = {}) {
75
75
  return messages;
76
76
  }
77
77
 
78
+ /** Stands in for `liveLine()` under a pane, which is drawing the same seconds itself. */
79
+ const QUIET_LIVE = { update() {}, done() {} };
80
+
78
81
  /**
79
82
  * Run one user turn to completion, looping while the model requests tools.
80
83
  *
@@ -89,6 +92,10 @@ function endTurn(messages, { out = console.log, plan = true } = {}) {
89
92
  export async function runTurn({
90
93
  messages, model, signal, approve, grant, mcp, auto = false, maxSteps = config.maxSteps,
91
94
  tools: toolset = TOOLS, plan: usePlan = true, out = console.log, stream = true, depth = 0,
95
+ // Print nothing that redraws the current line — the spinner and the live
96
+ // command line. Set by src/subagent.js when a pane is drawing the run, and
97
+ // the pane owns the bottom of the screen for as long as it is up.
98
+ quiet = false,
92
99
  // Called with the transcript so far after every round. Only src/subagent.js
93
100
  // passes one: a delegated run is the transcript nobody would otherwise keep,
94
101
  // and the round that ends it may be a throw or a step cap rather than a
@@ -125,7 +132,7 @@ export async function runTurn({
125
132
  : '(stopped: step cap reached)' });
126
133
  return endTurn(messages, { out, plan: usePlan });
127
134
  }
128
- let sp = spinner(depth ? 'sub-agent' : 'thinking');
135
+ let sp = quiet ? null : spinner(depth ? 'sub-agent' : 'thinking');
129
136
  let text = '';
130
137
  let reasoning = '';
131
138
  let calls = [];
@@ -342,7 +349,7 @@ export async function runTurn({
342
349
  result = `error: the sub-agent failed — ${e.message}`;
343
350
  }
344
351
  } else {
345
- const live = liveLine();
352
+ const live = quiet ? QUIET_LIVE : liveLine();
346
353
  try {
347
354
  result = isMcp
348
355
  ? await mcp.call(call.name, args)
package/src/pane.js ADDED
@@ -0,0 +1,268 @@
1
+ import readline from 'node:readline';
2
+ import { c, elapsed } from './ui.js';
3
+
4
+ /**
5
+ * The transient pane a delegated run draws in while it is running.
6
+ *
7
+ * A sub-agent's tool calls used to go straight into the conversation, nested
8
+ * under ` │`, and stayed there. That is the wrong trade in both directions.
9
+ * While the run is going a live view is the only sign the thing is alive, and
10
+ * it is where you notice a sub-agent thrashing; once it has finished, almost
11
+ * none of it is worth keeping, because the question you asked and the report
12
+ * you got are now forty lines apart. The window never paid for any of it —
13
+ * that is the point of delegating — but the screen paid for all of it.
14
+ *
15
+ * So: draw it in a box that redraws in place, and erase the box when the run
16
+ * ends. Nothing is lost by erasing it. Every line the pane shows was already
17
+ * written to the run's transcript as it happened (src/tasklog.js), so the pane
18
+ * is a view of a file that outlives it, and `/tasks <n>` is how you get it
19
+ * back. That is what makes discarding the pane safe.
20
+ *
21
+ * Scrollback, not the alternate screen. A full takeover would be easier to
22
+ * draw and worse to live with, because it hides the conversation you are in
23
+ * the middle of. A fixed block redrawn in place keeps the history above it.
24
+ */
25
+
26
+ /** Rows of sub-agent activity kept on screen. The box is bounded; the file is not. */
27
+ const BODY_ROWS = 6;
28
+
29
+ const MIN_WIDTH = 40;
30
+ const MAX_WIDTH = 100;
31
+
32
+ // eslint-disable-next-line no-control-regex -- matching the escapes is the point
33
+ const SGR = /\x1b\[[0-9;]*m/g;
34
+
35
+ /** How wide a string prints, once its colour escapes are discounted. */
36
+ export const visibleWidth = (s) => String(s).replace(SGR, '').length;
37
+
38
+ /** The box's outer width for a terminal this wide, clamped both ways. */
39
+ export const paneWidth = (columns) => Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (columns || 80) - 2));
40
+
41
+ /**
42
+ * Cut a coloured string to `width` printed characters.
43
+ *
44
+ * Escapes are copied through without being counted — cutting in the middle of
45
+ * one would leave the rest of the terminal painted in whatever colour the
46
+ * fragment happened to open. The trailing reset is only added when something
47
+ * was actually opened, so a no-colour terminal still gets no escapes at all.
48
+ */
49
+ export function clip(line, width) {
50
+ const s = String(line);
51
+ if (visibleWidth(s) <= width) return s;
52
+ if (width <= 0) return '';
53
+ let out = '';
54
+ let seen = 0;
55
+ let i = 0;
56
+ while (i < s.length && seen < width - 1) {
57
+ if (s[i] === '\x1b') {
58
+ SGR.lastIndex = i;
59
+ const m = SGR.exec(s);
60
+ if (m && m.index === i) { out += m[0]; i += m[0].length; continue; }
61
+ }
62
+ out += s[i];
63
+ i += 1;
64
+ seen += 1;
65
+ }
66
+ return `${out}…${out.includes('\x1b') ? '\x1b[0m' : ''}`;
67
+ }
68
+
69
+ /** Right-pad to `width` printed characters, so every row ends on the same column. */
70
+ export const pad = (line, width) => `${line}${' '.repeat(Math.max(0, width - visibleWidth(line)))}`;
71
+
72
+ const rule = (n) => '─'.repeat(Math.max(1, n));
73
+
74
+ /**
75
+ * One frame of the box, as lines — pure, so the geometry is testable without a
76
+ * terminal. Every line it returns is exactly `width` printed characters wide;
77
+ * `test/pane.test.js` asserts that, because an off-by-one in the arithmetic
78
+ * below is invisible in a diff and unmissable on screen.
79
+ */
80
+ export function renderPane({ agent, rows = [], status = '', hint = '', width = 60 }) {
81
+ const inner = Math.max(4, width - 4);
82
+ // Printed width, not string length: the status and the hint arrive coloured,
83
+ // and counting their escapes would pull the right-hand corner off the edge.
84
+ const w = (s) => (s ? visibleWidth(s) + 1 : 0);
85
+
86
+ // `┌─ agent ──…── 6/40 · 48s ─┐`
87
+ const headFixed = 3 + w(agent) + 1 + w(status) + 2;
88
+ const top = c.grey('┌─ ') + c.bold(agent) + c.grey(` ${rule(width - headFixed)} ${status ? `${status} ` : ''}─┐`);
89
+
90
+ // `└─ esc to collapse · ctrl-c to stop ───┘`
91
+ const footFixed = 3 + w(hint) + 2;
92
+ const bottom = c.grey(`└─ ${hint ? `${hint} ` : ''}${rule(width - footFixed)}─┘`);
93
+
94
+ const body = rows.map((r) => `${c.grey('│')} ${pad(clip(r, inner), inner)} ${c.grey('│')}`);
95
+ return [top, ...body, bottom];
96
+ }
97
+
98
+ /**
99
+ * Watch for a bare `esc` while the pane is up.
100
+ *
101
+ * Deliberately not `setRawMode`. The REPL's readline already owns stdin and
102
+ * already holds it in raw mode, and it is still reading during a turn — that
103
+ * is how typing ahead survives (see src/prompt.js). Taking the mode away from
104
+ * it and handing back what we swallowed is the kind of thing that eats a
105
+ * user's typing when it goes wrong, and there is no need: readline has already
106
+ * made stdin emit `keypress`, so a listener is purely additive. Ctrl-C keeps
107
+ * working because we never intercepted it.
108
+ *
109
+ * Armed only when stdin is *already* raw, which is exactly "a readline is
110
+ * open and reading". In one-shot and `--auto` runs it is not, nobody is
111
+ * watching, and the footer does not offer a key that would do nothing.
112
+ */
113
+ export function watchEsc({ input = process.stdin, onEsc } = {}) {
114
+ if (!onEsc || !input?.isTTY || !input.isRaw) return null;
115
+ // Idempotent in Node: it returns early if the stream already has a decoder.
116
+ readline.emitKeypressEvents(input);
117
+ // `meta` is true on a bare esc and false on `esc [ A`, because node cannot
118
+ // know which it has until the next byte arrives or `escapeCodeTimeout`
119
+ // (500 ms) expires — so this is the key, and an arrow key is not. That half
120
+ // second of lag is invisible next to a run that takes tens of seconds.
121
+ const onKey = (_s, key) => { if (key?.name === 'escape' && !key.ctrl) onEsc(); };
122
+ input.on('keypress', onKey);
123
+ return () => input.off('keypress', onKey);
124
+ }
125
+
126
+ /**
127
+ * Whether to draw at all.
128
+ *
129
+ * `node src/index.js "hello" | grep` and every CI run land here with a pipe,
130
+ * and a pipe must get exactly what it got before the pane existed: the nested
131
+ * lines, in order, with no cursor control in them. A dumb terminal cannot move
132
+ * a cursor either, so it takes the same path.
133
+ */
134
+ export const paneUsable = (stream = process.stdout, env = process.env) =>
135
+ Boolean(stream?.isTTY) && env.TERM !== 'dumb';
136
+
137
+ /** The inert pane: every call a no-op, so the caller needs no branch of its own. */
138
+ const OFF = {
139
+ enabled: false,
140
+ open() {}, line() {}, step() {}, close() {}, collapse() {},
141
+ shield: (fn) => fn,
142
+ };
143
+
144
+ /**
145
+ * The live pane. `open` draws it, `line` feeds it, `close` erases it.
146
+ *
147
+ * `shield(fn)` is how the approval prompt stays readable: it wipes the box
148
+ * before `fn` runs and redraws it after. An approval prompt has to be exactly
149
+ * as visible and as blocking as it is without a pane, so it gets the terminal
150
+ * to itself rather than competing with a redraw every 250 ms.
151
+ */
152
+ export function livePane({
153
+ agent = 'agent', maxSteps, rows: keep = BODY_ROWS, signal,
154
+ stream = process.stdout, input = process.stdin, env = process.env,
155
+ now = Date.now, tickMs = 250,
156
+ } = {}) {
157
+ if (!paneUsable(stream, env)) return OFF;
158
+
159
+ const started = now();
160
+ const fed = [];
161
+ let steps = 0;
162
+ let height = 0; // rows the box currently occupies on screen
163
+ let collapsed = false;
164
+ let timer = null;
165
+ let unwatch = null;
166
+ let unabort = null;
167
+
168
+ const status = () => {
169
+ const at = Number.isFinite(maxSteps) ? `${steps}/${maxSteps}` : String(steps);
170
+ return c.grey(`${at} · ${elapsed(now() - started)}`);
171
+ };
172
+
173
+ const paint = () => {
174
+ if (collapsed) return;
175
+ const width = paneWidth(stream.columns);
176
+ const hint = c.grey(unwatch ? 'esc to collapse · ctrl-c to stop' : 'ctrl-c to stop');
177
+ const frame = renderPane({ agent, rows: fed, status: status(), hint, width });
178
+ // Up to the top of the old box, then repaint row by row. `\x1b[K` on each
179
+ // row so a shorter line does not leave the tail of a longer one behind.
180
+ let buf = height ? `\r\x1b[${height}A` : '';
181
+ for (const l of frame) buf += `${l}\x1b[K\n`;
182
+ // The box only ever grows, but a narrower terminal can shorten it; clear
183
+ // whatever the previous frame left below this one.
184
+ if (height > frame.length) buf += '\x1b[J';
185
+ stream.write(buf);
186
+ height = frame.length;
187
+ };
188
+
189
+ const erase = () => {
190
+ if (!height) return;
191
+ stream.write(`\r\x1b[${height}A\x1b[J`);
192
+ height = 0;
193
+ };
194
+
195
+ const tick = () => {
196
+ if (timer) return;
197
+ timer = setInterval(paint, tickMs);
198
+ // Never a reason to keep the process alive: the run is what we are waiting on.
199
+ timer.unref?.();
200
+ };
201
+ const untick = () => { clearInterval(timer); timer = null; };
202
+
203
+ const stop = () => {
204
+ untick();
205
+ unwatch?.();
206
+ unwatch = null;
207
+ unabort?.();
208
+ unabort = null;
209
+ };
210
+
211
+ return {
212
+ enabled: true,
213
+
214
+ open() {
215
+ unwatch = watchEsc({ input, onEsc: () => this.collapse() });
216
+ // Ctrl-C aborts the run and then prints `interrupted` — which would land
217
+ // on top of the box, leaving the next repaint counting rows from the
218
+ // wrong place and erasing lines of the conversation above. Abort fires
219
+ // this listener synchronously, so the box is gone before that line
220
+ // prints. Nothing is lost: the transcript has everything it showed.
221
+ const onAbort = () => this.collapse();
222
+ signal?.addEventListener('abort', onAbort, { once: true });
223
+ // Released with everything else: one turn can delegate several times, and
224
+ // a listener left on that turn's signal each time is a leak node warns about.
225
+ unabort = () => signal?.removeEventListener('abort', onAbort);
226
+ paint();
227
+ tick();
228
+ },
229
+
230
+ /** One line of sub-agent activity. Only the last `keep` survive. */
231
+ line(text) {
232
+ if (collapsed) return;
233
+ for (const l of String(text).split('\n')) fed.push(l);
234
+ if (fed.length > keep) fed.splice(0, fed.length - keep);
235
+ paint();
236
+ },
237
+
238
+ step(n) { steps = n; },
239
+
240
+ /** `esc`: give the screen back now. The run carries on into the transcript. */
241
+ collapse() {
242
+ if (collapsed) return;
243
+ erase();
244
+ collapsed = true;
245
+ stop();
246
+ },
247
+
248
+ close() {
249
+ erase();
250
+ collapsed = true;
251
+ stop();
252
+ },
253
+
254
+ shield(fn) {
255
+ if (!fn) return fn;
256
+ return async (...args) => {
257
+ const wasUp = !collapsed && height > 0;
258
+ erase();
259
+ untick();
260
+ try {
261
+ return await fn(...args);
262
+ } finally {
263
+ if (wasUp && !collapsed) { paint(); tick(); }
264
+ }
265
+ };
266
+ },
267
+ };
268
+ }
package/src/subagent.js CHANGED
@@ -2,6 +2,7 @@ import { def, TOOLS } from './tools.js';
2
2
  import { config } from './config.js';
3
3
  import { c, elapsed } from './ui.js';
4
4
  import { startRun } from './tasklog.js';
5
+ import { livePane } from './pane.js';
5
6
 
6
7
  /**
7
8
  * Sub-agents: one delegated task, one throwaway context.
@@ -152,13 +153,17 @@ export function openLine(agent, prompt) {
152
153
  *
153
154
  * This is what makes `reportLines`' "…N more lines" actionable: the report on
154
155
  * screen is bounded, and now there is somewhere to go for the rest of it and
155
- * for everything the sub-agent read on the way.
156
+ * for everything the sub-agent read on the way. Once the pane erases itself it
157
+ * carries more than that — it is all that is left of the run on screen.
156
158
  */
157
- export function closeLine({ ms, steps, path, ok }) {
159
+ export function closeLine({ ms, steps, n, ok }) {
158
160
  // A run that threw or was interrupted is the one worth reading, so the line
159
161
  // that points at its transcript must not read like a clean finish.
160
162
  const bits = [`${steps} step${steps === 1 ? '' : 's'}`, elapsed(ms), ...(ok ? [] : ['stopped'])];
161
- if (path) bits.push(path);
163
+ // The command that reads the transcript, not the file it reads. The absolute
164
+ // path under the temp dir is long, mostly noise, and not a thing anyone would
165
+ // type; `/tasks 3` is both shorter and the answer to "how do I see it".
166
+ if (n) bits.push(`/tasks ${n} for the transcript`);
162
167
  const line = ` └ ${bits.join(' · ')}`;
163
168
  return ok ? c.grey(line) : c.yellow(line);
164
169
  }
@@ -201,6 +206,9 @@ function reportFrom(messages) {
201
206
  */
202
207
  export async function runTask(args, {
203
208
  run, model, signal, approve, grant, out = console.log, maxSteps = config.subagentSteps,
209
+ // Injected the same way `run` and `out` are, and for the same reason: the
210
+ // pane's behaviour is a screen behaviour, and a test has no screen.
211
+ pane: makePane = livePane,
204
212
  }) {
205
213
  const agent = args?.agent ?? 'explore';
206
214
  const prompt = String(args?.prompt ?? '').trim();
@@ -223,7 +231,15 @@ export async function runTask(args, {
223
231
  // model call still leaves a file saying which task was asked for.
224
232
  const log = startRun({ agent, prompt, model: config.subagentModel ?? model });
225
233
  const started = Date.now();
226
- out(openLine(agent, prompt));
234
+
235
+ // On a terminal the run is drawn in a box that erases itself; on a pipe, a
236
+ // dumb terminal or an `--auto` run nobody is watching, `livePane` hands back
237
+ // an inert one and everything below takes exactly the path it took before
238
+ // the pane existed. The transcript is written either way, which is what
239
+ // makes erasing the box safe — see src/pane.js.
240
+ const pane = makePane({ agent, maxSteps, signal });
241
+ if (pane.enabled) pane.open();
242
+ else out(openLine(agent, prompt));
227
243
 
228
244
  let steps = 0;
229
245
  let report;
@@ -236,8 +252,11 @@ export async function runTask(args, {
236
252
  window: own ? own.contextWindow : config.contextWindow,
237
253
  maxTokens: own?.maxTokens ?? config.maxTokens,
238
254
  signal,
239
- approve,
240
- grant,
255
+ // Wrapped, not replaced: delegation is still not a way to get a command
256
+ // past the user. The wrapper only wipes the box first, so the question is
257
+ // asked on a clear screen and nothing redraws over it while it waits.
258
+ approve: pane.shield(approve),
259
+ grant: pane.shield(grant),
241
260
  // No MCP: a local model already struggles past ~25 tools, and the
242
261
  // sub-agent's list is meant to be the short one.
243
262
  mcp: null,
@@ -250,23 +269,33 @@ export async function runTask(args, {
250
269
  // second stream interleaved with the caller's is unreadable. Tool calls
251
270
  // still show, indented, so the run is not a black box.
252
271
  stream: false,
253
- out: (line) => out(nest(line)),
272
+ out: pane.enabled ? (line) => pane.line(line) : (line) => out(nest(line)),
273
+ // The pane is already the live view, and the spinner and the per-command
274
+ // live line both rewrite the current line with `\r` — which is the last
275
+ // row of the box. Two things redrawing the same row is the one way this
276
+ // can look broken, so below a pane there is only one of them.
277
+ quiet: pane.enabled,
254
278
  maxSteps,
255
279
  depth: 1,
256
280
  // Written as it goes rather than at the end. The runs worth reading are
257
281
  // the ones that were interrupted, threw, or ran out of steps, and none
258
282
  // of those reach the line below.
259
- onStep: (msgs, n) => { steps = n; log?.step(msgs, n); },
283
+ onStep: (msgs, n) => { steps = n; pane.step(n); log?.step(msgs, n); },
260
284
  });
261
285
 
262
286
  report = reportFrom(done);
287
+ // Before the report, so the box is gone by the time the answer lands in
288
+ // the scrollback it is keeping clean.
289
+ pane.close();
263
290
  reportLines(report).forEach((l) => out(l));
264
291
  return report;
265
292
  } finally {
266
293
  // In `finally` so an abort or a throw still closes the record and still
267
294
  // tells the user where the partial transcript is — that is the case the
268
- // transcript exists for.
269
- const path = log?.finish({ report, steps });
270
- out(closeLine({ ms: Date.now() - started, steps, path, ok: report !== undefined }));
295
+ // transcript exists for. `close` is idempotent; the success path already
296
+ // called it above.
297
+ pane.close();
298
+ log?.finish({ report, steps });
299
+ out(closeLine({ ms: Date.now() - started, steps, n: log?.n, ok: report !== undefined }));
271
300
  }
272
301
  }