@vincemakes/kiso-tui-cells 0.46.2 → 0.47.1

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.
@@ -29,13 +29,14 @@ import { displayWidth, visibleWidth, widthCut } from "./width.js";
29
29
  // TUI2-R2pre ④: the ONE display-verb table (strings.ts, beside
30
30
  // KEY_BINDINGS). strings.js imports only render/width here, so this edge
31
31
  // adds no cycle.
32
- import { displayVerb } from "./strings.js";
33
- import { bannerLines, breathFrame, cutLine, escapeTerminal, stripAnsi, foldThinking, foldResult, renderTerminalGap, renderToolSummary, toolTarget, kUnit, palette, currentGround, } from "./render.js";
32
+ import { SHEET_CLOSE, bandHeader, displayVerb } from "./strings.js";
33
+ import { bannerLines, breathFrame, cutLine, escapeTerminal, oneRow, stripAnsi, foldThinking, foldResult, renderTerminalGap, renderToolSummary, toolTarget, kUnit, palette, currentGround, TWINKLE, fadeRule, } from "./render.js";
34
34
  // TUI2-MD: the markdown renderer's surface reaches the tui through this
35
35
  // module (the tui's components shim re-exports it) — one import edge,
36
36
  // and it points one way: md.ts measures with the width authority, never
37
37
  // back through here.
38
- import { renderBlock } from "./md.js";
38
+ import { headingLevel, renderBlock, renderMarkdown } from "./md.js";
39
+ import { hunksDiff, hunksOf } from "./diff.js";
39
40
  export { MdStream, renderBlock, renderMarkdown } from "./md.js";
40
41
  /** The spinner glyphs, cycled by the compositor's on-demand tick. */
41
42
  export const SPINNER = ["▖", "▘", "▝", "▗"];
@@ -161,6 +162,16 @@ export class Container {
161
162
  return out;
162
163
  }
163
164
  }
165
+ /** Graphite §1.8 — THE CONTENT EDGE. Every block begins at column 2
166
+ * (owner, 2026-09-29: the 0.44 geometry); columns 0–1 are the mark
167
+ * column — the seal's `✦`, a streaming thought's twinkle, the live row's
168
+ * mark, the person's `▌`, a card's edge and its mark cell. */
169
+ export const EDGE = " ";
170
+ /** The room a block of words has: from the content edge to two columns
171
+ * short of the right edge, the same margin on both sides. No width cap —
172
+ * a cap left the answer and thinking far short of the cards beside them
173
+ * on a wide terminal (owner, 2026-09-29). */
174
+ const proseRoom = (W) => Math.max(1, W - EDGE.length * 2);
164
175
  const TOOL_SUMMARY_MAX = 60; // the tool line's parameter summary, chars
165
176
  /** The component for one cell — the mapping table lives here so the
166
177
  * compositor stays a pure writer. */
@@ -179,50 +190,85 @@ export function cellComponent(cell) {
179
190
  return new MarkdownBlock(cell);
180
191
  case "notice":
181
192
  return new ErrorLine(cell);
193
+ case "seal":
194
+ return new SealLine(cell);
182
195
  case "fold":
183
196
  return new FoldRow(cell);
184
197
  case "banner":
185
198
  return new Banner(cell);
186
199
  case "raw":
187
200
  return new RawBlock(cell);
201
+ case "bang":
202
+ return new BangCard(cell);
203
+ case "recall":
204
+ return new RecallBlock(cell);
188
205
  case "terminal":
189
206
  return new TerminalBlock(cell);
190
207
  }
191
208
  }
192
- /**
193
- * The user message — the W16 inset chip ALONE (the 2026-08-09 ruling:
194
- * the ▍ rail and the indent are retired — the rail's stated pipe
195
- * fallback was theoretical redundancy: the CLI's pipe path is the
196
- * line-mode "you>" form and never renders UserMessage). The chip folds
197
- * the text at W−2 (the side pads) and pads every row to the FULL width.
198
- *
199
- * R2 — law 1.6's recorded reversal. This used to size the block to its
200
- * longest row, on the argument that "a short message like /think would
201
- * paint a bar across the terminal". That optimises the degenerate case
202
- * at the cost of every real message, which is a paragraph and reads as
203
- * a block only when the block has an edge. The `/think` case is the
204
- * accepted price, and it is recorded as such in design.md §1.6.
205
- *
206
- * The padding is by cells (charWidth is the width authority), so a CJK
207
- * row pads by width, never by chars, and the chip never overruns.
208
- *
209
- * THE SURFACE IS REVERSE VIDEO, on every ground (owner ruling
210
- * 2026-09-02, reversing R9 P1 one release after it shipped).
211
- *
212
- * R9 P1 moved the chip onto the wash, reading §1.6's "verbatim" as one
213
- * surface shared by the human's words and the machine's. §1.6 now
214
- * splits the two, and the split is the reason: reverse video is THE
215
- * HUMAN'S surface and it is full contrast by construction — it inverts
216
- * whatever the terminal is, so it is the same weight on every ground
217
- * and cannot be under-read. The wash is the MACHINE'S verbatim surface
218
- * (inline code, tool output), where a lighter ground is right because
219
- * those rows are read as content rather than as an utterance.
220
- *
221
- * SGR 7 closed with SGR 27 — never SGR 0, the chip composes with a
222
- * surrounding span. NEVER dim inside it: reverse video inverts the
223
- * CURRENT colours, so dimmed text inverts into a dimmed block with no
224
- * contrast.
225
- */
209
+ /** G3 (R2d) — the block `!cmd` sends: a `console` fence holding `$ <command>`
210
+ * and what it printed (apps/cli dispatch.ts `runBang`). Null for any
211
+ * other text. */
212
+ export function bangOf(text) {
213
+ const m = /^```console\n\$ ([^\n]*)\n([\s\S]*)\n```$/.exec(text);
214
+ return m === null ? null : { command: m[1], output: m[2] };
215
+ }
216
+ /** G3 (R2d) — the person's command as a card on their own warm ground: a
217
+ * pad row, `$ <command>` with what became of it at the right (`exit N`
218
+ * when the output says so, then `sent to the model` or `not sent`), what
219
+ * it printed under the command in `ink2`, a pad row. `cap` keeps the
220
+ * output's TAIL (a command's conclusion is at its end) with the count of
221
+ * what was cut above it; null shows it all. Off a known ground: the same
222
+ * words, no surface. */
223
+ function bangRows(command, output, isError, fate, W, cap) {
224
+ const p = palette();
225
+ const painted = p.human !== "";
226
+ const exit = /^exit (\d+)/.exec(output);
227
+ const outcome = [exit !== null ? `exit ${exit[1]}` : isError ? "failed" : "", fate].filter((x) => x !== "").join(" \u00b7 ");
228
+ const textW = Math.max(1, W - PERSON_COL - 2 - CHIP_RIGHT);
229
+ let lines = escapeTerminal(stripAnsi(output)).replace(/\n+$/, "").split("\n").flatMap((l) => foldLine(l, textW));
230
+ if (lines.length === 1 && lines[0] === "")
231
+ lines = [];
232
+ let cut = 0;
233
+ if (cap !== null && lines.length > cap) {
234
+ cut = lines.length - cap;
235
+ lines = lines.slice(lines.length - cap);
236
+ }
237
+ const cmd = escapeTerminal(command);
238
+ const headRoom = Math.max(1, W - PERSON_COL - CHIP_RIGHT);
239
+ const tail = ` ${outcome}`;
240
+ const headText = visibleWidth(`$ ${cmd}${tail}`) + 1 <= headRoom ? `$ ${cmd}` : widthCut(`$ ${cmd}`, Math.max(1, headRoom - visibleWidth(tail) - 2));
241
+ const gap = " ".repeat(Math.max(1, headRoom - visibleWidth(headText) - visibleWidth(outcome)));
242
+ if (!painted) {
243
+ const rows = [cutLine(`${" ".repeat(PERSON_COL)}${p.bold}${headText}${p.reset}${gap}${p.dim}${outcome}${p.reset}`, W)];
244
+ if (cut > 0)
245
+ rows.push(cutLine(`${" ".repeat(PERSON_COL + 2)}${p.dim}\u2026 ${cut} earlier line${cut === 1 ? "" : "s"}${p.reset}`, W));
246
+ for (const l of lines)
247
+ rows.push(cutLine(`${" ".repeat(PERSON_COL + 2)}${p.dim}${l}${p.reset}`, W));
248
+ return rows;
249
+ }
250
+ const row = (inner) => {
251
+ const pad = " ".repeat(Math.max(0, W - 2 - visibleWidth(inner)));
252
+ return `${p.humanEdge} ${p.human}${p.humanInk} ${inner.replaceAll("\x1b[0m", `\x1b[0m${p.human}${p.humanInk}`)}${pad}${p.fgEnd}${p.washEnd}`;
253
+ };
254
+ const padRow = `${p.humanEdge} ${p.human}${" ".repeat(Math.max(0, W - 1))}${p.washEnd}`;
255
+ // `$ <command>` is ONE span: a reader (and a needle) finds the command
256
+ // line as it was typed
257
+ const head = `${p.bold}${headText}${p.reset}${gap}${p.dim}${outcome}${p.reset}`;
258
+ const body = [...(cut > 0 ? [` ${p.dim}\u2026 ${cut} earlier line${cut === 1 ? "" : "s"}${p.reset}`] : []), ...lines.map((l) => ` ${p.ink2}${l}${p.fgEnd}`)];
259
+ return [padRow, row(head), ...body.map(row), padRow].map((r) => cutLine(r, W));
260
+ }
261
+ /** G3 (R2d) — `!!cmd`: the card, all of the output (the person asked to
262
+ * see it here), `not sent`. */
263
+ class BangCard {
264
+ cell;
265
+ constructor(cell) {
266
+ this.cell = cell;
267
+ }
268
+ render(W, _ctx) {
269
+ return bangRows(this.cell.command, this.cell.output, this.cell.isError, "not sent", W, null);
270
+ }
271
+ }
226
272
  /**
227
273
  * REL-0152-D13 — how much of a turn the chip shows.
228
274
  *
@@ -232,9 +278,31 @@ export function cellComponent(cell) {
232
278
  * the same rule as three thousand short ones.
233
279
  */
234
280
  const USER_CHIP_ROWS = 12;
235
- /** R13 D4 — the chip's inner pad: two columns, so its text begins in
236
- * the same column as everything else on the page. */
237
- const CHIP_PAD = " ";
281
+ /** Graphite §7.9 — the text starts at the content edge (column 4) and
282
+ * stops two columns short of the right edge. */
283
+ const CHIP_RIGHT = 2;
284
+ /** Graphite §7.9 — the person's words start at the content edge. */
285
+ const PERSON_COL = EDGE.length;
286
+ /**
287
+ * Graphite §7.9 — THE PERSON'S BLOCK.
288
+ *
289
+ * On a known ground: the warm `human` ground across the full width, a
290
+ * whole row of it above and below (§1.5: a pad is a row of BACKGROUND —
291
+ * the half-row glyphs R1b tried left a seam in Apple Terminal), an EDGE
292
+ * cell of quieted gold in column 0 down every row — the same width as a
293
+ * card's edge, and like it a background, since a `▌` glyph shows a break
294
+ * at every row there — and the text at the content edge in `humanInk`.
295
+ * No label and no time: the block says whose words these are.
296
+ *
297
+ * On an unknown ground nothing is painted that assumes a background
298
+ * (§3.1): the `▌` in column 0, which is a character and so still marks
299
+ * the person's words once the escapes are stripped (§1.2), and reverse
300
+ * video from column 1.
301
+ *
302
+ * The fold is by WORD (R9 Q3: a word wider than the row still breaks,
303
+ * because invariant ① outranks it), ONE width over every row (DC-6), and
304
+ * padding is by display width, so a CJK row pads by cells.
305
+ */
238
306
  class UserMessage {
239
307
  cell;
240
308
  constructor(cell) {
@@ -242,163 +310,125 @@ class UserMessage {
242
310
  }
243
311
  render(W, _ctx) {
244
312
  const p = palette();
245
- // R13 D4 — TWO columns of inner pad, where R2 had one. The chip
246
- // keeps its surface (reverse video, one row per folded line, no
247
- // pad rows — R12 Round 2's ruling stands); what changes is where
248
- // its text STARTS, so the human's words begin in the same column
249
- // as the model's (E3) and as a card's rows (E4).
250
- const chipW = Math.max(1, W - 2 * CHIP_PAD.length);
251
- const rows = [];
252
- // REL-0152-D13: fold only as far as the bound needs. A pasted file
253
- // has thousands of lines and folding all of them to show twelve is
254
- // work with no reader — the measurement that opened this finding
255
- // was a 3000-line turn writing 260,298 bytes in one frame.
313
+ // G3 (R2d): a `!cmd` turn — the block the dispatcher sends — draws as
314
+ // the person's command card; the model's bytes are the text as sent
315
+ const bang = bangOf(this.cell.text);
316
+ if (bang !== null)
317
+ return bangRows(bang.command, bang.output, false, "sent to the model", W, USER_CHIP_ROWS);
318
+ const painted = p.human !== "";
319
+ const chipW = Math.max(1, W - PERSON_COL - CHIP_RIGHT);
256
320
  const paras = this.cell.text.split("\n");
257
- let truncated = false;
258
- // DC-6: the folded CONTENT first, then ONE width over all of it.
259
- // The pad used to be computed per source paragraph, so a message
260
- // with two lines drew as two bars of two different lengths — a
261
- // ragged right edge on a block that is one block. A single
262
- // paragraph was always correct, which is why it survived: the
263
- // shape only appears once a message has a second line.
264
- const content = [];
265
- for (const para of paras) {
266
- if (content.length >= USER_CHIP_ROWS) {
267
- truncated = true;
268
- break;
269
- }
270
- // R9 Q3: by WORD. The char fold was defended as lossless, and
271
- // that argument does not survive CJK — a run with no spaces has
272
- // nothing to lose — while every other prose surface in the
273
- // product already folds by word (ErrorLine, VD-10). foldWords
274
- // falls through to foldLine's hard break for a word wider than
275
- // the row, so invariant ① still outranks the word.
276
- for (const row of foldWords(escapeTerminal(para), chipW)) {
277
- if (content.length >= USER_CHIP_ROWS) {
278
- truncated = true;
279
- break;
280
- }
281
- content.push(row);
282
- }
283
- }
284
- // R2 (law 1.6's recorded reversal): the band is FULL WIDTH. It was
285
- // sized to its longest row, on the argument that a one-word turn
286
- // like `/think` would otherwise paint a bar across the terminal —
287
- // which optimises the degenerate case at the cost of every real
288
- // message. The human's words are the one surface that gets the
289
- // whole row.
290
- //
291
- // displayWidth stays the padding authority (never `length`): a CJK
292
- // row is two cells per character and pads by cells.
293
- const inner = chipW;
294
- for (const row of content) {
295
- rows.push(`${p.rv}${CHIP_PAD}${row}${" ".repeat(Math.max(0, inner - displayWidth(row)))}${CHIP_PAD}${p.rvEnd}`);
296
- }
321
+ // Graphite §5 (G6, R2b): the person's words render as markdown, the
322
+ // line breaks they typed kept. REL-0152-D13: only as far as the
323
+ // bound needs — a pasted file has thousands of lines and twelve rows
324
+ // are shown, so a prefix of the source is rendered (a few lines past
325
+ // the bound, so a block that closes there still closes).
326
+ const source = paras.slice(0, USER_CHIP_ROWS + 4).join("\n");
327
+ const rendered = renderMarkdown(escapeTerminal(source), chipW, { hardBreaks: true });
328
+ const truncated = rendered.length > USER_CHIP_ROWS || paras.length > USER_CHIP_ROWS + 4;
329
+ const content = rendered.slice(0, USER_CHIP_ROWS);
330
+ const fill = (row) => " ".repeat(Math.max(0, chipW - visibleWidth(row) + CHIP_RIGHT));
331
+ const padRow = `${p.humanEdge} ${p.human}${" ".repeat(Math.max(0, W - 1))}${p.washEnd}`;
332
+ // every reset inside a row re-opens the block's ground and ink, so a
333
+ // styled span never strands the rest of the row off the block
334
+ const onGround = (row) => row.replaceAll("\x1b[0m", `\x1b[0m${p.human}${p.humanInk}`).replaceAll("\x1b[49m", p.human).replaceAll("\x1b[39m", p.humanInk);
335
+ const out = painted
336
+ ? [padRow, ...content.map((row) => `${p.humanEdge} ${p.human}${p.humanInk} ${onGround(row)}${fill(row)}${p.fgEnd}${p.washEnd}`), padRow]
337
+ : content.map((row) => `\u258c${p.rv} ${row.replaceAll("\x1b[0m", `\x1b[0m${p.rv}`)}${fill(row)}${p.rvEnd}`);
297
338
  if (!truncated)
298
- return rows;
299
- // The notice is OUTSIDE the chip's reverse video, in the cut-row
300
- // vocabulary the rest of the product uses, and it says the thing
301
- // that matters: the model got all of it. A bounded display of a
302
- // complete message is not a truncated message, and the row has to
303
- // make that difference visible or it reads as data loss.
304
- const shown = rows.length;
305
- const total = paras.length;
306
- const more = Math.max(0, total - shown);
307
- // DC-45: THE NOTICE FOLDS TOO. It was written at a fixed 30 columns
308
- // and emitted verbatim at every width, so a paste of thirteen lines
309
- // in a terminal narrower than the sentence tripped the compositor's
310
- // invariant ① and killed the session. The tiers are TUI2-R1.5 ⑤'s
311
- // discipline: `sent in full` is the SEMANTICS — the whole reason
312
- // the row exists — so the count gives way before it, and `cutLine`
313
- // is the backstop that holds at any width there is.
339
+ return out;
340
+ // The notice is OUTSIDE the block, in the cut-row vocabulary, and it
341
+ // says what matters: the model got all of it (DC-45: it folds too —
342
+ // `sent in full` is the semantics and gives way last).
343
+ const more = Math.max(0, paras.length - content.length);
314
344
  const count = more > 0 ? `+${more} more line${more === 1 ? "" : "s"}` : "cut here";
315
345
  const short = more > 0 ? `+${more}` : "cut";
316
- rows.push(cutLine(`${p.dim}\u2514 ${pickTier([`${count} \u00b7 sent in full`, `${short} \u00b7 sent in full`, "sent in full", count], Math.max(1, W - 2))}${p.reset}`, W));
317
- return rows;
346
+ // on a very narrow terminal the edge gives way before the notice does
347
+ const lead = W - PERSON_COL >= 8 ? " ".repeat(PERSON_COL) : "";
348
+ out.push(cutLine(`${lead}${p.dim}\u2514 ${pickTier([`${count} \u00b7 sent in full`, `${short} \u00b7 sent in full`, "sent in full", count], Math.max(1, W - lead.length - 2))}${p.reset}`, W));
349
+ return out;
318
350
  }
319
351
  }
320
- /** W22: the pending-queue chips — queued user lines pre-render above
321
- * the input row as the SAME UserMessage chip (undimmed: reverse video
322
- * inverts the CURRENT colours, so a dim span would invert into a
323
- * dimmed block), the dim `□` gutter marking the queued
324
- * state (the gutter rides EVERY row — the gutterFold precedent: the
325
- * left edge alone distinguishes the states). Each chip folds at W−3
326
- * (the gutter's 2 cells), so a long line hard-folds INSIDE the chip
327
- * and invariant ① holds on the band. */
328
- export function pendingQueueRows(lines, W) {
352
+ /**
353
+ * Graphite §8.7, the main-sync round (owner, 2026-09-30) — a STEER that
354
+ * has not landed is ONE row in the live zone:
355
+ *
356
+ * ◇ <the message's first line> next step · ↑ takes back
357
+ *
358
+ * ADR-0057: Enter while a run works steers it — there is no queue on a
359
+ * terminal — and the steer lands inside the same run at its next quiet
360
+ * step, or after an open approval, which it never answers. The gold `◇`
361
+ * (a message the person sent that has not landed, §4) in the mark column
362
+ * and the text at the content edge; no label word (owner: the mark says
363
+ * it). The keys at the right say what happens next and give way first
364
+ * when the row is short. DECLARED REVERSAL of the `◇ queued … after this
365
+ * turn · ↑ edit` row, whose queue main retired in 0.45.0.
366
+ */
367
+ export function pendingQueueRows(lines, W, waiting = "step") {
329
368
  const p = palette();
330
- const out = [];
331
- for (const line of lines) {
332
- for (const row of new UserMessage({ text: line }).render(Math.max(1, W - 3), { spinnerI: 0, now: 0, height: 0 })) {
333
- out.push(`${p.dim}□${p.reset} ${row}`);
369
+ const mark = p.goldMark === "" ? "\u25c7" : `${p.goldMark}\u25c7${p.fgEnd}`;
370
+ const keys = `${waiting === "approval" ? "after the approval" : "next step"} \u00b7 \u2191 takes back`;
371
+ return lines.map((line) => {
372
+ const lead = `${mark} `;
373
+ const leadW = visibleWidth(lead);
374
+ const text = escapeTerminal(line);
375
+ const withKeys = W - leadW - visibleWidth(keys) - 2;
376
+ if (withKeys >= Math.min(12, visibleWidth(text))) {
377
+ const shown = visibleWidth(text) <= withKeys ? text : `${widthCut(text, Math.max(1, withKeys - 1))}\u2026`;
378
+ return `${lead}${shown}${" ".repeat(Math.max(2, W - leadW - visibleWidth(shown) - visibleWidth(keys)))}${p.dim}${keys}${p.reset}`;
334
379
  }
335
- }
336
- return out;
380
+ const room = Math.max(1, W - leadW);
381
+ return cutLine(`${lead}${visibleWidth(text) <= room ? text : `${widthCut(text, Math.max(1, room - 1))}\u2026`}`, W);
382
+ });
337
383
  }
338
384
  /**
339
- * R7 — THINKING IS A BLOCK OF WORDS.
385
+ * Graphite §7.2 — THINKING IS WORDS, under its label.
386
+ *
387
+ * `THINK <seconds>` in blue at the content edge, then the model's
388
+ * reasoning as `dim` italic paragraphs, shown in full and never folded
389
+ * (R7, the owner: "I cannot see what it was thinking" was the complaint
390
+ * through four rounds of folding). While the block streams, the label
391
+ * carries the twinkle, hanging in the mark column; the seconds appear
392
+ * when it settles, and only if they were measured — a block replayed from
393
+ * the log has no clock.
340
394
  *
341
- * The owner's ruling, arrived at from a side-by-side with pi's screen:
342
- * the model's reasoning reads as its own paragraphs — italic, dim,
343
- * indented two — and is never folded away. A one-row fold (the pipe
344
- * path's `foldThinking` is what remains of it) stood for it, with a
345
- * key; four rounds
346
- * of machinery were then built to hand the rest back, and the owner's
347
- * complaint through all of them was the same sentence: I cannot see
348
- * what it was thinking.
395
+ * The LABEL is what tells thinking from the answer once the escapes are
396
+ * stripped (§1.2): the answer carries none. A pipe never sees a thinking
397
+ * paragraph — the inactive path writes `foldThinking`'s one line.
349
398
  *
350
- * THE INDENT IS NOT DECORATION. Law 1.2 is SETTLED — "strip every
351
- * escape sequence and no fact is lost" — and italic is SGR 3, which a
352
- * pipe strips and `COLOR_OFF` empties outright. Stripped, an italic
353
- * paragraph and the model's ANSWER are the same bytes, and the reader
354
- * cannot tell the reasoning from the reply. The two-space indent is the
355
- * byte that survives: reasoning sits in, the answer stands at the
356
- * margin. The owner chose this over the un-indented form for exactly
357
- * that reason, with the cost stated.
399
+ * `ctrl+t` hides every block to its label line (`· hidden · ctrl+t`), and
400
+ * the text is never drawn while hidden, live or settled.
358
401
  *
359
- * Paragraphs are preserved (a blank line between them); the newlines
360
- * INSIDE a paragraph collapse, because a hard-wrapped source line is
361
- * the model's line width, not the reader's.
402
+ * Paragraphs are kept (a blank row between them); newlines INSIDE one
403
+ * collapse, because a hard-wrapped source line is the model's width, not
404
+ * the reader's.
405
+ */
406
+ /**
407
+ * Graphite §7.2 — THINKING: the model's reasoning as it streams, `dim`
408
+ * italic at the content edge, folded by WORD like every prose surface.
409
+ * No label (owner, 2026-09-29): the grey italic is what tells it from the
410
+ * answer. While it streams the twinkle hangs in the mark column of its
411
+ * first row; settled, the column is empty (§4.2). Hidden (ctrl+t), it is
412
+ * one dim row that says so. With colour off there is no grey and no
413
+ * italic to tell it by, so there — and only there — its first row opens
414
+ * with the plain word `thinking:`.
362
415
  */
363
416
  class ThinkingBlock {
364
417
  cell;
365
418
  constructor(cell) {
366
419
  this.cell = cell;
367
420
  }
368
- render(W, _ctx) {
421
+ render(W, ctx) {
369
422
  const p = palette();
370
- const text = escapeTerminal(this.cell.text).trim();
423
+ const c = this.cell;
424
+ const text = escapeTerminal(c.text).trim();
371
425
  if (text === "")
372
426
  return [];
373
- // §2.3 / 0.40.6 — ctrl+t hid thinking. Hidden is ONE italic line in
374
- // the block's own column (DC-47): `thinking…` while it runs,
375
- // `thinking… · /think` once it settles. The text itself is never
376
- // drawn, live or settled (0.40.5 folded only settled blocks, into a
377
- // 100-character preview, while the open one kept streaming). The
378
- // pipe keeps its own one-line fold (render.ts foldThinking).
379
- if (this.cell.folded)
380
- return [`${THINK_COL}${p.dim}${p.italic}${this.cell.done ? "thinking… · /think" : "thinking…"}${p.italicEnd}${p.reset}`];
381
- // DC-47 — THINKING GOES ONE LEVEL DEEPER THAN PROSE, and the
382
- // reason is a law rather than a taste.
383
- //
384
- // §7.2: the indent is the price of §1.2 — italic and dim are
385
- // escape sequences, so a rendered frame with its colour stripped
386
- // (a terminal capture, a paste out of the scrollback, a log of
387
- // what was drawn) would lose the line between the model's
388
- // reasoning and its answer. R13's E3 moved PROSE to column 2,
389
- // which is where the thinking already was, so after
390
- // `sed 's/\x1b\[[0-9;]*m//g'` the two became the same row.
391
- // Measured, not reasoned: both rendered
392
- // `" Weighing the two shapes."` exactly.
393
- //
394
- // NOT a pipe, though §7.2 used to say so: `thinkingEnd`'s inactive
395
- // path writes `foldThinking` — one dim line — so a pipe never sees
396
- // a thinking paragraph to confuse with prose.
397
- //
398
- // So the thinking takes the next column in. It is still the only
399
- // carrier that survives a pipe, and it is still one indent step —
400
- // what moved is which step, because prose took the one it had.
401
- const room = Math.max(1, W - THINK_COL.length);
427
+ const mark = c.done ? EDGE : `${p.gold}${TWINKLE[ctx.spinnerI % TWINKLE.length]}${p.gold === "" ? "" : p.fgEnd} `;
428
+ if (c.folded)
429
+ return [cutLine(`${mark}${p.dim}${p.italic}thinking \u00b7 hidden \u00b7 ctrl+t${p.italicEnd}${p.reset}`, W)];
430
+ const room = proseRoom(W);
431
+ const plainWord = p.italic === "" && p.dim === "" ? "thinking: " : "";
402
432
  const rows = [];
403
433
  for (const para of text.split(/\n\s*\n/)) {
404
434
  const flat = para.replace(/\s+/g, " ").trim();
@@ -406,11 +436,9 @@ class ThinkingBlock {
406
436
  continue;
407
437
  if (rows.length > 0)
408
438
  rows.push("");
409
- // foldLine is the ONE width authority and returns real rows —
410
- // invariant ①b (a row is one physical row) holds by
411
- // construction rather than by remembering to split.
412
- for (const line of foldLine(flat, room))
413
- rows.push(`${THINK_COL}${p.dim}${p.italic}${line}${p.italicEnd}${p.reset}`);
439
+ const lead = rows.length === 0 ? `${plainWord}${flat}` : flat;
440
+ for (const line of foldWords(lead, room))
441
+ rows.push(`${rows.length === 0 ? mark : THINK_COL}${p.dim}${p.italic}${line}${p.italicEnd}${p.reset}`);
414
442
  }
415
443
  return rows;
416
444
  }
@@ -427,6 +455,22 @@ function spansOpenAfter(text, before) {
427
455
  }
428
456
  return open;
429
457
  }
458
+ /** A line of text folded to `width` cells, breaking after the last space
459
+ * that fits (the space is the break), or hard at the width when a run has
460
+ * none. Every other character is kept, in order. */
461
+ export function foldAtSpaces(text, width) {
462
+ const rows = [];
463
+ let rest = text;
464
+ while (visibleWidth(rest) > width) {
465
+ const head = widthCut(rest, width);
466
+ const space = head.lastIndexOf(" ");
467
+ const cut = space > 0 ? space + 1 : Math.max(1, head.length);
468
+ rows.push(rest.slice(0, cut).trimEnd());
469
+ rest = rest.slice(cut);
470
+ }
471
+ rows.push(rest);
472
+ return rows;
473
+ }
430
474
  /**
431
475
  * TUI2-R1.5 ⑨ (VD-10) — the WORD-aware fold, for text a human reads.
432
476
  *
@@ -454,44 +498,72 @@ export function foldWords(line, W) {
454
498
  out.push(para);
455
499
  continue;
456
500
  }
457
- let rest = para;
501
+ // LINEAR (the thinking-freeze fix, 2026-09-30): the row's remainder is
502
+ // never re-measured or rebuilt as a string. It was — `visibleWidth` of
503
+ // the whole rest and a new rest string per row — which is O(n²/W) for
504
+ // one long paragraph, and a thinking block is ONE flattened paragraph
505
+ // re-folded every frame: 61 ms a render at 40k characters, long enough
506
+ // to starve the working row's timer (the twinkle froze while the model
507
+ // thought). The rows are byte for byte what they were: `rest` is the
508
+ // same `open` prefix + the same tail, read through a cursor.
458
509
  // the spans open at the cut point, so each emitted row closes them
459
510
  // and the next row reopens them — foldLine's own discipline, applied
460
511
  // across the segments this function creates.
512
+ let pos = 0;
461
513
  let open = [];
462
- while (visibleWidth(rest) > W) {
514
+ while (widerFrom(para, pos, W)) {
463
515
  // the widest prefix that fits, then back up to the last space in
464
516
  // it — the SGR-aware cut keeps the spans intact
465
- const head = widthCut(rest, W);
517
+ const pre = open.join("");
518
+ const head = headOf(pre, para, pos, W);
466
519
  const at = head.lastIndexOf(" ");
467
520
  if (at <= 0)
468
521
  break; // one long word (or no space at all) — hard-break it
469
522
  const cut = head.slice(0, at);
470
523
  out.push(`${cut}${open.length > 0 || /\x1b\[[0-9;]*m/.test(cut) ? "\x1b[0m" : ""}`);
471
524
  open = spansOpenAfter(cut, open);
472
- rest = `${open.join("")}${rest.slice(cut.length + 1)}`;
525
+ // the space the row broke at is consumed; the prefix was never in `para`
526
+ pos += at + 1 - pre.length;
473
527
  }
474
- out.push(...foldLine(rest, W));
528
+ out.push(...foldLine(`${open.join("")}${para.slice(pos)}`, W));
475
529
  }
476
530
  return out.length > 0 ? out : [""];
477
531
  }
532
+ /** `visibleWidth(s.slice(from)) > W`, answered by reading at most the
533
+ * first W + 1 visible cells (and the escapes among them) — never the
534
+ * whole remainder. The same skip rule as visibleWidth. */
535
+ function widerFrom(s, from, W) {
536
+ let w = 0;
537
+ for (let i = from; i < s.length;) {
538
+ if (s[i] === "\x1b") {
539
+ const m = /^\x1b\[[0-9;?]*[A-Za-z]/.exec(s.slice(i, i + 256));
540
+ i += m !== null ? m[0].length : 1;
541
+ continue;
542
+ }
543
+ // per UTF-16 unit, exactly as visibleWidth counts
544
+ w += displayWidth(s[i]);
545
+ if (w > W)
546
+ return true;
547
+ i += 1;
548
+ }
549
+ return false;
550
+ }
551
+ /** `widthCut(pre + s.slice(from), W)` without building the whole string:
552
+ * widthCut stops within its first W cells, so a window of the tail is
553
+ * enough — widened (doubling) only when the cut ran to the window's end
554
+ * before the tail's (a run of zero-width code points). */
555
+ function headOf(pre, s, from, W) {
556
+ for (let span = 2 * W + 256;; span *= 2) {
557
+ const probe = `${pre}${s.slice(from, from + span)}`;
558
+ const head = widthCut(probe, W);
559
+ if (head.length < probe.length || from + span >= s.length)
560
+ return head;
561
+ }
562
+ }
478
563
  export function gutterFold(gutter, line, W) {
479
564
  const textW = Math.max(1, W - 2);
480
565
  return foldLine(line, textW).map((r) => `${gutter}${r}`);
481
566
  }
482
- /** A6: the tool-header variant — ONE cut row, never a fold. A wide
483
- * header (a long target path, a wordy denial reason) used to wrap
484
- * through foldLine — every wrapped row repeated the gutter, the
485
- * settled row grew past its previewed height. The header names the
486
- * call — the ellipsis marks the cut, the body below still carries the
487
- * full content. The budget: the gutter's own visible width + the
488
- * ellipsis ride the row (the invariant ① cap holds). */
489
- export function gutterCut(gutter, line, W) {
490
- const gutterW = visibleWidth(gutter);
491
- const textW = Math.max(1, W - gutterW - 1);
492
- const cut = widthCut(line, textW);
493
- return [`${gutter}${cut}${visibleWidth(line) > textW ? "…" : ""}`];
494
- }
495
567
  /** Lines without the phantom empty line after a trailing newline. */
496
568
  function countLines(text) {
497
569
  if (text === "")
@@ -519,7 +591,9 @@ function settledMeta(c) {
519
591
  // any other error.
520
592
  if (c.name === "shell" && /^exit \d+/.test(c.resultText))
521
593
  return `exit ${/^exit (\d+)/.exec(c.resultText)[1]}`;
522
- return c.resultText.split("\n")[0].slice(0, 60);
594
+ // Graphite §7.5: the outcome is a WORD on the head row — the error's
595
+ // own text is the card's body, where it has the room to be read
596
+ return "failed";
523
597
  }
524
598
  if (c.name === "read_file") {
525
599
  const noteAt = c.resultText.lastIndexOf("\n… ");
@@ -533,6 +607,11 @@ function settledMeta(c) {
533
607
  return `${k(shown)} line${shown === 1 ? "" : "s"}`;
534
608
  }
535
609
  if (c.name === "write_file" || c.name === "edit_file") {
610
+ // R2a: the call's own diff speaks for it — a batch's hunks counted
611
+ // together, a write as a new file or a rewrite
612
+ const edit = editDiffOf(c);
613
+ if (edit !== null)
614
+ return edit.meta;
536
615
  if (c.added + c.removed > 0)
537
616
  return `+${c.added} -${c.removed}`;
538
617
  // no approval diff (an auto-allowed write): the input summary may
@@ -590,18 +669,27 @@ function toolTargetOf(c) {
590
669
  }
591
670
  return toolTarget(c.name, input);
592
671
  }
593
- /** THE TOOL CARD — one per call, every state its own render (R13):
594
- * pad · head row · blank · the preview, capped at CAP_PREVIEW rows and
595
- * cut with a note that names the key · blank · the outcome row · pad;
596
- * a call with nothing to show is the three-row bodiless card. The body
597
- * is capped in SCREEN rows at the current width; the tool's OWN
598
- * truncation note (W10, `capped by …`) is a different fact, never
599
- * counted against the cap. W3: the verb drops its "_file" suffix and
600
- * pads to 5 columns so the targets line up (the pipe path prints the
601
- * same verb). A4: the settled head row is verb + target + outcome; the
602
- * W19 pinned deny keeps the full call name instead. A5: a decidedBy on
603
- * the cell appends `· approved by X` (an extension's auto-approval) or
604
- * `· by X` on the pinned deny; the human decision needs no marker. */
672
+ /**
673
+ * Graphite §7.4 — THE CARD: one per call (R13), one skeleton in every
674
+ * state —
675
+ *
676
+ * head · body · foot
677
+ *
678
+ * The head carries the verb, the target (a command folds under itself,
679
+ * at most three rows collapsed), and at its right end the outcome, a
680
+ * running or waiting card's mark in front of it (§7.5). The body is the
681
+ * preview, aligned under the verb; when it cut rows away, its cut note
682
+ * carries the `ctrl+o` key at the right margin. The foot is the way back
683
+ * on an expanded card; a card with no body carries the key at the end of
684
+ * its head row instead (the card round, owner 2026-10-05). The ground is
685
+ * the call's STATE (§1.6): the machine's blue while it runs and once it
686
+ * has run, red failed or refused, gold waiting for the person. No pad
687
+ * rows and no side bar: surfaces are backgrounds only (§1.5).
688
+ *
689
+ * On an unknown ground nothing is painted (§3.1): the head at the content
690
+ * edge, the body indented under it and opened by `└`, the foot dim. The
691
+ * CONTENT is the same either way.
692
+ */
605
693
  class ToolExecution {
606
694
  cell;
607
695
  constructor(cell) {
@@ -610,254 +698,131 @@ class ToolExecution {
610
698
  render(W, ctx) {
611
699
  const p = palette();
612
700
  const c = this.cell;
613
- const verb = escapeTerminal(displayVerb(c.name));
614
- const verbCol = verb.length < 5 ? `${verb}${" ".repeat(5 - verb.length)}` : verb;
615
- // DECLARED REVERSAL (R13): the W13 rollup row and TUI2-R1 (B)'s
616
- // exploration row stood here, ahead of everything else a settled
617
- // call could be; both retired with the `rolled` field they read.
701
+ const verb = escapeTerminal(displayVerb(c.name)).toUpperCase();
702
+ // the card round: a command folds whole (three rows collapsed, all of
703
+ // it expanded); every other target is one row
704
+ const fold = c.name === "shell" ? (c.expanded ? Number.POSITIVE_INFINITY : HEAD_ROWS) : 0;
618
705
  if (c.state === "done") {
619
- // R3i phase 5: an answered (or declined) ask_user renders its
620
- // OWN block — the questions and what the human said. The row
621
- // it replaces was ` ask_user (3 lines, 41.2s)`: an empty
622
- // target and the answers thrown away, though the result
623
- // already carried them. `askedBlock` returns [] for anything
624
- // that is not the ask's own JSON, so a payload this renderer
625
- // did not write can never be guessed at.
706
+ // R3i phase 5: an answered (or declined) ask_user renders its OWN
707
+ // block — the questions and what the human said.
626
708
  if (c.name === "ask_user" && c.reason === null && !c.isError) {
627
709
  const asked = askedBlock(c.resultText, c.startedAt !== null && c.doneAt !== null ? (c.doneAt - c.startedAt) / 1000 : c.startedAt === null && c.doneAt === null ? null : 0, W);
628
710
  if (asked.length > 0)
629
711
  return asked;
630
712
  }
631
- // W19: the pinned deny — the claimed shape verbatim: the FULL
632
- // call name (the denial names the call), the target, the reason
633
- // in the W4 parentheses idiom, no timing (the call never ran).
634
- // The same ✗ family as any failure; the [result ✗] body still
635
- // rides below (never hide information). A5: an extension's
636
- // denial appends `· by <decidedBy>` — the aggregated head row
637
- // names the decider; a human denial (no decidedBy) has no tail.
713
+ // W19: the refused call — a failed card whose outcome is the
714
+ // refusal and its reason; the [result] body still rides below
715
+ // (never hide information). A5: an extension's denial names it.
716
+ // Graphite §7.5 (R3b): a call still open when the person stopped the
717
+ // turn was not refused by anyone — it says `interrupted`, on the
718
+ // machine's ground (it was running), its output so far below. It
719
+ // used to read `denied · interrupted` on the failure ground.
720
+ if (c.reason === "interrupted") {
721
+ const body = toolBlockParts(c, W, ctx);
722
+ return card("run", " ", verb, escapeTerminal(toolTargetOf(c)), ["interrupted"], body.rows, null, W, false, fold);
723
+ }
638
724
  if (c.reason !== null) {
639
- const by = attribution(c);
640
- const out = gutterCut(" ", `${p.red}${escapeTerminal(`${c.name} ${toolTargetOf(c)}`)} (${escapeTerminal(c.reason)}${by})${p.reset}`, W);
641
- out.push(...toolBlockBody(c, W, ctx));
642
- return out;
725
+ // VD-11: a PERSON's refusal is worth saying (they were asked
726
+ // and answered); a policy's is ambient. The outcome word says
727
+ // which, once.
728
+ const word = c.verdict !== null && c.verdict.decision === "denied" && c.verdict.decidedBy === undefined ? "denied by you" : "denied";
729
+ // 0.47.1 (finding 0470-F4, owner 2026-10-07): said once. The
730
+ // runtime's reason when the person gave none is the words
731
+ // "denied by user" — `denied by you · denied by user` said the
732
+ // refusal twice on one row, and the body (`[Permission denied]
733
+ // denied by user`, what the model was handed) a third time. So
734
+ // that refusal is `denied by you` alone, with no body. A refusal
735
+ // WITH a reason keeps both: the head gives its reason way on a
736
+ // narrow row (the pinned order), and the body still says it.
737
+ const quiet = word === "denied by you" && c.reason === DEFAULT_REFUSAL;
738
+ const outcome = quiet ? [word] : [`${word} \u00b7 ${escapeTerminal(c.reason)}`, word];
739
+ const body = quiet && c.resultText.trim() === `[Permission denied] ${c.reason}` ? { rows: [] } : toolBlockParts(c, W, ctx);
740
+ return card("fail", " ", verb, escapeTerminal(toolTargetOf(c)), outcome, body.rows, null, W, true, fold);
643
741
  }
644
- // 4c: a card settled from the durable log carries no clock at all
645
- // (the log's events have no timestamps) — it says nothing about
646
- // time rather than `?s`, which reads as a measurement that failed.
742
+ // 4c: a card settled from the durable log carries no clock at
743
+ // all — it says nothing about time rather than `?s`.
647
744
  const elapsed = c.startedAt !== null && c.doneAt !== null ? settledLabel((c.doneAt - c.startedAt) / 1000) : c.startedAt === null && c.doneAt === null ? "" : "?s";
648
- // TUI2-R1.5 ⑤ (VD-6): the line count is stated EXACTLY ONCE. Every
649
- // read card carried it twice — `(2 lines, 0.0s) · 2 lines · ctrl+o
650
- // expands` — because the parens and the suffix were written by
651
- // different rounds, each unaware the other was counting. The
652
- // SUFFIX keeps it (it is the one that also names the key), so a
653
- // meta that says only "<n> lines" drops out when a suffix will
654
- // carry it. A meta that says something else — read_file's
655
- // "200 of 250 lines", a diff's "+1 -1", a shell's "exit 0" — is a
656
- // different fact and stays.
745
+ // The tasks round (owner, 2026-10-06): a BACKGROUND delegation
746
+ // returns at once, so its card settles in a tenth of a second with
747
+ // the work only just begun. It names its children — the roles on
748
+ // the head, `N in the background`, a row per child with the task id
749
+ // `/tasks` and the notice use — instead of the foreground summary it
750
+ // never writes (the card read `DELEGATE … 1 line` with no target).
751
+ const children = c.name === "delegate" && !c.isError ? backgroundChildren(c) : null;
752
+ if (children !== null) {
753
+ const p2 = palette();
754
+ const rw = Math.max(...children.map((x) => x.role.length));
755
+ const room = bodyRowRoom(W);
756
+ const rows = children.map((x) => cutLine(`${bodyRow()}${x.id} ${p2.ink2}${x.role.padEnd(rw)}${p2.ink2 === "" ? "" : p2.fgEnd} ${p2.dim}${x.task}${p2.reset}`, room));
757
+ const outcome = `${children.length} in the background${elapsed === "" ? "" : ` \u00b7 ${elapsed}`}`;
758
+ return card("done", " ", verb, children.map((x) => x.role).join(" \u00b7 "), [outcome], slabPaints() ? rows : openBlock(rows), null, W, false);
759
+ }
657
760
  const rawMeta = settledMeta(c);
658
- // VD-6's suppression RETIRED with the thing it was suppressing.
659
- // A read card said `(2 lines, 0.0s) · 2 lines · ctrl+o expands`
660
- // because the parens and the suffix were written by different
661
- // rounds, each unaware the other was counting; the fix blanked
662
- // the meta whenever the suffix would repeat it. R13 takes the
663
- // count OUT of the suffix, so there is one place again and the
664
- // meta is it — blanking it now would drop the fact entirely.
665
- // "Stated exactly once" is unchanged and is what `countedHead`
666
- // below preserves: a meta that already counts lines gets no
667
- // second count beside it.
668
761
  const meta = escapeTerminal(rawMeta);
669
- // A4: the target rides the settled head row — the verb's
670
- // summary column (W3's 5-char pad keeps the paths lined up).
671
- // A5: an extension's auto-approval appends `· approved by
672
- // <decidedBy>` — the "why wasn't I asked" answer; the human
673
- // approval (no decidedBy) leaves the row unchanged.
674
- const approvedBy = attribution(c);
675
- // TUI2-R1 (A): the card names its own key — the suffix rides the
676
- // settled head row.
677
- // TUI2-R1.5 ④(c): the suffix is now RESERVED rather than given
678
- // the leftovers. It used to take the width that happened to be
679
- // left, so a long command spent it all and the row said nothing
680
- // about the seven lines behind the key — tolerable while the
681
- // body was on screen, a silence now that the body is not. The
682
- // command is the cuttable span (the approval panel's option-2
683
- // rule name is the same idea); the affordance is the semantics.
684
- const hidden = hiddenLines(c, W);
685
- // TUI2-R1.5 ⑤: the shortest tier is RESERVED — the affordance is
686
- // the semantics. TUI2-R1.5 pin 4: and the parts give way in a
687
- // PINNED ORDER, rather than whichever happened to be last.
688
- const nAll = countLines(c.resultText);
689
- const countedHead = nAll > 0 && !/^\d+( of \d+)? lines?$/.test(rawMeta) ? `${nAll} line${nAll === 1 ? "" : "s"}` : "";
690
- const text = settledHeadText(verbCol, escapeTerminal(toolTargetOf(c)), meta, approvedBy, elapsed, W - 2 - (hidden === null ? 0 : SUFFIX_MIN), countedHead);
691
- // R2 (owner, 2026-08-27): no tick, no cross. A symbol earns its
692
- // cell by carrying a fact the words do not, and a row that
693
- // already says `exit 0` does not need one more thing saying it
694
- // went fine. The gutter is two spaces; the OUTCOME lives in the
695
- // metadata, in words, which is also the only form that survives
696
- // a pipe with the colour stripped. A failure keeps its colour
697
- // AND its words — see settledMeta.
698
- const body = toolBlockParts(c, W, ctx).rows;
699
- if (body.length > 0) {
700
- // R13 — THE CARD, when there is something to preview: the head
701
- // row names the call, the preview sits inside, and the outcome
702
- // CLOSES it on its own line (§7.5's words, moved off the head
703
- // row because the head row is no longer the only row). D6: the
704
- // target is bold there — the head row's job is to say WHAT was
705
- // run, and the metadata has its own row now.
706
- //
707
- // A failure takes NO tint on the head row (R9): only the
708
- // outcome word is coloured, which is §1.2 exactly — the
709
- // colour rides the fact, not the object that carries it.
710
- const target = escapeTerminal(toolTargetOf(c));
711
- const head = cutLine(` ${verbCol} ${p.bold}${target}${p.reset}`, W);
712
- // §7.5's outcome, in words, on its own row: what happened, how
713
- // much of it there was, how long it took. The count is stated
714
- // exactly ONCE (VD-6) — a meta that already counts lines does
715
- // not get a second count beside it.
716
- const n = countLines(c.resultText);
717
- const counted = n > 0 && !/^\d+( of \d+)? lines?$/.test(rawMeta) ? `${n} line${n === 1 ? "" : "s"}` : "";
718
- // pin 4's order, on the row that carries the core now: the
719
- // ATTRIBUTION drops first, then the count, and the core —
720
- // what happened and how long it took — is never cut open.
721
- const join = (...xs) => xs.filter((x) => x !== "").join(" · ");
722
- const attr = approvedBy.replace(/^ · /, "");
723
- // DC-50 / R14: the key is RESERVED (§7.5), so its width comes
724
- // out of the budget BEFORE the tiers are picked — not
725
- // appended after, which is what the first build did and
726
- // which let a narrow row cut the affordance off entirely.
727
- // The tier ladder is what gives way; the key never is.
728
- const keySuffix = c.expanded ? ` · ${COLLAPSE_ROW}` : "";
729
- const words = pickTier([join(meta, counted, elapsed, attr), join(meta, counted, elapsed), join(meta, elapsed), meta], W - visibleWidth(noteIndent()) - keySuffix.length);
730
- // DC-50 / R14 — ONE CARD, ONE SKELETON, expanded or not.
731
- //
732
- // The expanded card used to take a DIFFERENT shape: its head
733
- // row carried the outcome inline (`shell npm test · exit 0 ·
734
- // 40 lines · 0.1s`), it had no outcome row at all, and the
735
- // `ctrl+o collapses` affordance was a row of its own at the
736
- // end of the body. That was defensible while an expanded card
737
- // was rare — reachable only for a live or approval-parked
738
- // cell — and it stopped being defensible the moment ctrl+o
739
- // became a switch that expands EVERY settled card at once: a
740
- // page where the same call has two skeletons depending on a
741
- // global toggle is the instability this round is named for.
742
- //
743
- // So both states are pad · head · blank · body · blank ·
744
- // outcome · pad. The head row says WHAT was run and nothing
745
- // else; the outcome row says what happened, how much, how
746
- // long — and carries the affordance, because the affordance
747
- // is a fact about this card's state and the outcome row is
748
- // where this card's facts live.
749
- const withKey = `${words}${keySuffix}`;
750
- const outcome = c.isError ? `${p.red}${withKey}${p.reset}` : withKey;
751
- return slabBlock(head, body, outcome, W);
762
+ const attr = attribution(c).replace(/^ · /, "");
763
+ // VD-6: the line count is stated exactly ONCE — a meta that
764
+ // already counts lines gets no second count beside it.
765
+ // R2a: an edit's or a write's result is kiso's receipt (`edited
766
+ // <path>` and its revision), not something it made: its diff is
767
+ // the body, and the receipt's line count is no fact about it
768
+ const n = editDiffOf(c) === null ? countLines(c.resultText) : 0;
769
+ const counted = n > 0 && !/^\d+( of \d+)? lines?$/.test(rawMeta) ? `${n} line${n === 1 ? "" : "s"}` : "";
770
+ const join = (...xs) => xs.filter((x) => x !== "").join(" \u00b7 ");
771
+ // pin 4's order: the ATTRIBUTION gives way first, then the count;
772
+ // what happened and how long it took is never cut open.
773
+ // The last tier is the CORE — what happened and how long it took;
774
+ // below it the target elides (headCore), never the core.
775
+ const tiers = [join(meta, counted, elapsed, attr), join(meta, counted, elapsed), join(meta, elapsed)];
776
+ const body = toolBlockParts(c, W, ctx);
777
+ const state = c.isError ? "fail" : "done";
778
+ if (body.rows.length > 0) {
779
+ // the foot is the way back: an expanded card's, when collapsing
780
+ // would hide something again (a card whose whole body always
781
+ // shows has no way back to offer). A collapsed card's key stands
782
+ // on its cut note's row (the card round, `cutNote`).
783
+ const collapsed = { ...c, expanded: false };
784
+ const behind = c.expanded && (toolBlockParts(collapsed, W, ctx).cut || hiddenLines(collapsed, W) !== null);
785
+ const foot = behind ? COLLAPSE_ROW : null;
786
+ return card(state, " ", verb, escapeTerminal(toolTargetOf(c)), tiers, body.rows, foot, W, c.isError, fold);
752
787
  }
753
- // R13 — nothing to preview: the SAME card, three rows, with the
754
- // outcome riding the head row because there is nothing between
755
- // them to close.
756
- //
757
- // DECLARED REVERSAL of the owner's 2026-09-02 narrowing ("a call
758
- // with no output on screen has no slab at all"), which read §1.6
759
- // as giving the wash to the machine's VERBATIM text only. The
760
- // ruling of 2026-09-03 supersedes it: the surface says WORK, not
761
- // VERBATIM, and a page where some calls are cards and others are
762
- // loose rows is the instability the owner was pointing at. §1.6
763
- // moves with it.
764
- const bare = c.isError ? ` ${p.red}${text}${p.reset}` : ` ${text}`;
765
- return slabBlock(appendSuffix(bare, expandSuffix(hidden, W - visibleWidth(bare))), [], null, W);
788
+ // no body: the head row between its pads, and the key — when the
789
+ // result sits behind it (a read) — at the end of the head row
790
+ const hidden = hiddenLines(c, W);
791
+ // the key is RESERVED (TUI2-R1.5 ⑤): its words shorten only after
792
+ // the attribution and the count have given way
793
+ const keyed = hidden === null ? tiers : [...tiers.map((t) => `${t} \u00b7 ${EXPAND_ROW}`), `${tiers[tiers.length - 1]} \u00b7 ctrl+o`];
794
+ return card(state, " ", verb, escapeTerminal(toolTargetOf(c)), keyed, [], null, W, c.isError, fold);
766
795
  }
767
796
  if (c.state === "approval") {
768
- // W2: the ❯ is the GUTTER (the left edge), never the line's tail
769
- const out = gutterCut(`${p.bold}❯${p.reset} `, `${verbCol} ${liveTarget(c)}`, W);
770
- out.push(...toolBlockBody(c, W, ctx));
771
- return out;
797
+ const body = toolBlockParts(c, W, ctx);
798
+ // a foot only where there is a body above it (§1.3)
799
+ return card("ask", `${p.gold}\u276f${p.gold === "" ? "" : p.fgEnd} `, verb, liveTarget(c), ["needs you"], body.rows, c.expanded && body.rows.length > 0 ? COLLAPSE_ROW : null, W, false, fold);
772
800
  }
773
801
  if (c.state === "running") {
774
- // W2: the spinner IS the gutter (the left edge); the elapsed
775
- // rides the summary's tail.
776
- // TUI2-R1.5 ④(a) (VD-4): the duration is its OWN trailing segment.
777
- // It used to be concatenated into the text BEFORE the cut, so a
778
- // header wider than the row lost it entirely or, worse, kept it
779
- // welded to the last surviving characters of a cut word
780
- // ("compil 1s"). The head is cut against the room the duration
781
- // leaves; the duration then rides the row, always legible.
802
+ // R3 (design §5.2): a running call BREATHES in its mark cell; its
803
+ // duration rides the head row's right end, where the settled
804
+ // card's outcome will stand — the settle changes the words and
805
+ // the ground, never the height (DC-46: the settle may add only
806
+ // the foot).
782
807
  const elapsed = c.startedAt !== null ? Math.max(1, Math.round((ctx.now - c.startedAt) / 1000)) : 1;
783
- const dur = ` · ${elapsedLabel(elapsed)}`;
784
- // R3 (design §5.2): a running command BREATHES — one glyph, seven
785
- // greys, bottoming out on the ground's dim token (§2.2 applies
786
- // mid-animation, not just at rest). The quadrant spinner it
787
- // replaces ROTATED, which §5.3 forbids for a call whose duration
788
- // cannot be predicted: a turning mark implies progress the
789
- // product does not have. With no ground the breath freezes to a
790
- // static `●` and says the same thing more quietly.
791
- // R13 E2 — A RUNNING CALL IS THE SAME CARD, allocated at the
792
- // SETTLED card's height from its first frame. The settle then
793
- // changes CONTENT and never position: the spinner becomes two
794
- // spaces in the same two columns, the live window gives back
795
- // the rows the result did not need, and the metadata row that
796
- // said `3s` says `exit 0 · 90 lines · 3.2s`.
797
- //
798
- // The elapsed moves OFF the head row onto that metadata row,
799
- // which is where the settled card keeps it — the head row's job
800
- // is to say what is running, and it says the same thing before
801
- // and after.
802
- //
803
- // DC-43: with too little room for the seven-row skeleton (two
804
- // pads, the head, two blanks, one preview row and the metadata)
805
- // there is no card — the call keeps its head row until it
806
- // commits, which is the one form that fits anywhere.
808
+ const el = elapsedLabel(elapsed);
809
+ const gestures = c.name === "shell" ? " \u00b7 esc stops \u00b7 alt+\u23ce redirects" : "";
810
+ // the gestures give way first (the live row names the same keys);
811
+ // `running · Ns` is the core, and the narrow ladder keeps the
812
+ // elapsed alone at the very end
813
+ const tiers = [`running \u00b7 ${el}${gestures}`, `running \u00b7 ${el}`];
814
+ const mark = `${breathFrame(ctx.spinnerI)} `;
815
+ // DC-43: with too little room for a card the call keeps its head
816
+ // row alone until it commits — the one form that fits anywhere.
807
817
  const liveRows = ctx.liveWindow ?? CAP_PREVIEW;
808
- const gutter = `${breathFrame(ctx.spinnerI)} `;
809
- if (liveRows <= 0) {
810
- // the degraded form is the head row ALONE, so it keeps the
811
- // duration it would otherwise have lost with its card — cut
812
- // against the room the duration leaves (VD-4: the duration
813
- // is its own segment, never welded to a cut word).
814
- const bare = gutterCut(gutter, `${verbCol} ${liveTarget(c)}`, Math.max(4, W - dur.length));
815
- return [`${bare[0]}${p.dim}${dur}${p.reset}`];
816
- }
817
- // DC-46 — the two GESTURES ride the status row, where they cost
818
- // nothing. They were a footer INSIDE the window, spending one of
819
- // its rows on a sentence that is not output; with the window
820
- // grown from its content, that row was the difference between a
821
- // settle that swaps content and one that changes height. The
822
- // row's shape is the settled outcome row's, so the settle
823
- // rewrites it in place: `3s · esc stops` → `exit 0 · 90 lines ·
824
- // 3.2s`.
825
- const gestures = c.name === "shell" ? " · esc stops · alt+⏎ redirects" : "";
826
- const status = pickTier([`${elapsedLabel(elapsed)}${gestures}`, elapsedLabel(elapsed)], Math.max(1, W - visibleWidth(noteIndent())));
827
- const live = toolBlockBody(c, W, ctx);
828
- if (live.length > 0)
829
- return slabBlock(gutterCut(gutter, `${verbCol} ${liveTarget(c)}`, W)[0], live, status, W);
830
- // DC-48 — THE THREE-ROW CARD IS ONE ROW, so it is assembled here
831
- // against the room it actually has.
832
- //
833
- // This branch used to cut the head to `W` and hand it to
834
- // `slabBlock`, which joins head and outcome and cut nothing —
835
- // so the row came out `W` wide PLUS the whole status, and the
836
- // compositor did what it promises: it threw. On the owner's
837
- // 80-column terminal a long `find` produced a 113-column row on
838
- // its FIRST FRAME, which is the first second of every command.
839
- //
840
- // Pin 4's order: the command is the cuttable span and the
841
- // elapsed is never cut open, so the command takes what the
842
- // status leaves — the `· ` between them and the two-column
843
- // gutter included.
844
- // the status takes its own tier against the room the row has, not
845
- // against a whole width: on a narrow terminal the gestures give
846
- // way so the COMMAND keeps something to say, and the elapsed —
847
- // pin 4's core — never does.
848
- const MIN_TARGET = 10; // the gutter, the verb column, a character of command
849
- const oneRow = pickTier([`${elapsedLabel(elapsed)}${gestures}`, elapsedLabel(elapsed)], Math.max(1, W - MIN_TARGET - 3));
850
- const room = Math.max(4, W - visibleWidth(oneRow) - 3);
851
- const only = gutterCut(gutter, `${verbCol} ${liveTarget(c)}`, room)[0];
852
- return slabBlock(`${only}${p.dim} · ${oneRow}${p.reset}`, [], null, W);
818
+ if (liveRows <= 0)
819
+ return [cardHeadRow(mark, verb, liveTarget(c), tiers, W, false, false)];
820
+ const body = toolBlockParts(c, W, ctx);
821
+ return card("run", mark, verb, liveTarget(c), tiers, body.rows, c.expanded && body.rows.length > 0 ? COLLAPSE_ROW : null, W, false, fold);
853
822
  }
854
- // W2: ◦ replaces → for QUEUED — · is the separator inside every
855
- // metadata group; a queued marker that is also the separator
856
- // glyph reads as noise
857
- // The `◦` carried "not started yet" ALONE, and a marker is not a
858
- // word: on a screen where running and queued rows sit together, a
859
- // reader has to already know the glyph. The suffix says it.
860
- return gutterCut(`${p.dim}◦${p.reset} `, `${verbCol} ${liveTarget(c)}${p.dim} · queued${p.reset}`, W);
823
+ // not started yet (queued behind an exclusive call): the same card,
824
+ // its outcome says so.
825
+ return card("run", " ", verb, liveTarget(c), ["queued"], [], null, W, false, fold);
861
826
  }
862
827
  }
863
828
  // ---- TUI2-R1 (A): the self-naming expand affordance ----
@@ -887,7 +852,7 @@ function hiddenLines(c, W) {
887
852
  if (n === 0)
888
853
  return null;
889
854
  if (c.isError)
890
- return null; // errorBody's own cut row is the affordance there
855
+ return null; // an error previews its text, and the foot carries the key
891
856
  // R13: a call that PREVIEWS carries the key on its own note row when
892
857
  // something is cut, and needs no affordance at all when nothing is.
893
858
  // A head-row suffix as well would be TUI2-R1's two affordances for
@@ -897,90 +862,6 @@ function hiddenLines(c, W) {
897
862
  return null;
898
863
  return n;
899
864
  }
900
- /**
901
- * TUI2-R1 (A) — the suffix, in the width that is LEFT.
902
- *
903
- * Three tiers, degrading: the full form teaches the key AND what it
904
- * does, the terse form keeps the count and the key, the bare form keeps
905
- * the key alone. Below that the row is left exactly as it is today — the
906
- * affordance is worth a suffix, never worth cutting the path the row
907
- * exists to name (invariant ① holds by construction: the tier is chosen
908
- * against the room the row actually has).
909
- */
910
- /** TUI2-R1.5 ⑤ — the cells a settled head row reserves for its
911
- * affordance: exactly the shortest tier, " · ctrl+o". The suffix used
912
- * to take whatever width happened to be left, so a long target spent it
913
- * all and the card said nothing about the lines behind the key. Every
914
- * row that fitted its head before still fits it; only a head that would
915
- * have eaten the whole row gives up its last nine cells. */
916
- const SUFFIX_MIN = " · ctrl+o".length;
917
- /**
918
- * TUI2-R1.5 pin 4 — the settled head row's text, with a PINNED cut
919
- * order.
920
- *
921
- * The row carries four things of very different value, and until now a
922
- * single trailing widthCut decided between them by position: the parens
923
- * came last, so the parens were what got cut. The walkthrough caught
924
- * both consequences —
925
- *
926
- * ✓ shell printf '…' 1 2 … 12 (exit 0 · approv… · ctrl+o
927
- * ✓ shell for i in 1 2 3 … … · ctrl+o
928
- *
929
- * — an UNCLOSED parenthesis, and a row that lost the exit code and the
930
- * duration to a command string that had no claim on them. A cut that
931
- * leaves `(exit 0 · approv…` has not shortened a fact, it has broken
932
- * one: the reader is left holding the beginning of a sentence.
933
- *
934
- * The order, tightest last:
935
- * 1. the affordance is already reserved by the caller (⑤);
936
- * 2. the RESULT CORE — `(exit 0, 3.0s)`, `(+1 -1, 0.2s)` — renders
937
- * whole, closing paren included, or not at all;
938
- * 3. the ATTRIBUTION segment drops before the core is touched: it is
939
- * a note about who decided, and the result is what happened;
940
- * 4. the COMMAND/target truncates with `…`. It is the most
941
- * compressible thing on the row — a reader recognises a command
942
- * from its head — and it is the only part with a natural ellipsis.
943
- */
944
- function settledHeadText(verbCol, target, meta, attr, elapsed, room, counted = "") {
945
- // R13 — ONE GRAMMAR FOR BOTH CARDS. This row used to close with W4's
946
- // parentheses — `read a.ts (0.1s) · 10 lines · ctrl+o expands` —
947
- // while the bodied card's outcome row said `exit 0 · 90 lines · 0.4s`
948
- // in a `·` chain. Two shapes for the same facts, and which one a call
949
- // got depended on whether it happened to have a preview. The chain
950
- // wins: it is the one the outcome row already uses, it reads in one
951
- // direction, and it puts the elapsed where the other card puts it.
952
- //
953
- // The giving-way order is pin 4's, unchanged: the ATTRIBUTION drops
954
- // first, then the count, and the core — what happened and how long it
955
- // took — is never cut open; below that the target itself truncates.
956
- const join = (...xs) => xs.filter((x) => x !== "").join(" · ");
957
- const core = join(meta, counted, elapsed);
958
- const withAttr = join(meta, counted, elapsed, attr.replace(" · ", ""));
959
- const lead = `${verbCol} `;
960
- const row = (tail) => `${lead}${target}${tail === "" ? "" : ` · ${tail}`}`;
961
- // 1. everything; 2. the attribution gives way; 2b. the COUNT gives
962
- // way next (pin 4), where the suffix is not already carrying it —
963
- // the target whole through all three
964
- const whole = firstFit([row(withAttr), row(core), row(join(meta, elapsed))], room);
965
- if (whole !== null)
966
- return whole;
967
- // 3. the target truncates, the core stays whole
968
- const stem = join(meta, elapsed);
969
- const budget = room - visibleWidth(lead) - visibleWidth(stem) - 4; // the ellipsis + " · "
970
- if (budget >= 1)
971
- return `${lead}${widthCut(target, budget)}… · ${stem}`;
972
- // 4. DC-48 — the ELAPSED still rides, and the target takes what is
973
- // left. This used to return the target alone, on the argument that
974
- // a cut core would leave a half-open parenthesis; R13's chain has
975
- // no bracket to leave open, so the reason retired with the
976
- // parentheses and pin 4's own rule applies at every width: what
977
- // happened and how long it took is never cut away.
978
- const floor = elapsed;
979
- const left = room - visibleWidth(lead) - visibleWidth(floor) - 4; // the ellipsis + " · "
980
- if (left >= 1)
981
- return `${lead}${widthCut(target, left)}… · ${floor}`;
982
- return `${lead}${widthCut(target, Math.max(1, room - visibleWidth(lead)))}`;
983
- }
984
865
  /**
985
866
  * TUI2-R1.5 ⑤ (VD-11) — approval attribution, about humans.
986
867
  *
@@ -1001,33 +882,15 @@ function attribution(c) {
1001
882
  return "";
1002
883
  return c.verdict.decision === "denied" ? " · denied" : " · approved";
1003
884
  }
1004
- /** The expand key as a row's tail, in two tiers; empty when there is
1005
- * nothing hidden. R13 moved the line COUNT off this suffix onto the
1006
- * head row's own `·` chain (VD-6: stated exactly once). */
1007
- function expandSuffix(lines, room) {
1008
- if (lines === null)
1009
- return "";
1010
- for (const tier of [" · ctrl+o expands", " · ctrl+o"]) {
1011
- if (tier.length <= room)
1012
- return tier;
1013
- }
1014
- return "";
1015
- }
1016
- /** The suffix as the row's dim tail (the empty suffix leaves the row's
1017
- * bytes untouched — a caller never has to branch). */
1018
- function appendSuffix(row, suffix) {
1019
- if (suffix === "")
1020
- return row;
1021
- const p = palette();
1022
- return `${row}${p.dim}${suffix}${p.reset}`;
1023
- }
1024
885
  /* DECLARED REVERSAL (D-S2-1, owner-ruled 2026-09-06): `focusToken` and
1025
886
  `EXPAND_KEY` stood here — TUI2-R2 ⑤'s bright ctrl+o token on the
1026
887
  newest live card, "exactly one bright token per frame". DC-50 made
1027
888
  ctrl+o a global switch, so there was no target left for a marker to
1028
889
  name; the status row's idle hint names the switch (`idleHint`). */
1029
- /** TUI2-R1 (A) — the expanded card's key suffix: the way back. */
890
+ /** TUI2-R1 (A) — the expanded card's key: the way back. */
1030
891
  const COLLAPSE_ROW = "ctrl+o collapses";
892
+ /** Graphite §7.4 — the key on a card with rows behind it. */
893
+ const EXPAND_ROW = "ctrl+o expands";
1031
894
  /* DECLARED REVERSAL (R13, owner-ruled 2026-09-03): the W13 rollup's
1032
895
  noun table (`ROLLUP_NOUN`) and TUI2-R1 (B)'s exploration-run set
1033
896
  (`EXPLORE_NOUN`, `isExploreTool`) stood here. Both collapses retired
@@ -1113,8 +976,8 @@ export function askedBlock(resultText, seconds, W) {
1113
976
  return [];
1114
977
  const asked = parsed;
1115
978
  const p = palette();
1116
- const head = (n, outcome) => cutLine(` ${p.bold}asked${p.reset} ${n} ${n === 1 ? "question" : "questions"} ${p.dim}(${seconds === null ? outcome : `${outcome}, ${seconds.toFixed(1)}s`})${p.reset}`, W);
1117
- const row = (body) => cutLine(` ${p.dim}│${p.reset} ${body}`, W);
979
+ const head = (n, outcome) => cutLine(`${EDGE}${p.bold}asked${p.reset} ${n} ${n === 1 ? "question" : "questions"} ${p.dim}(${seconds === null ? outcome : `${outcome}, ${seconds.toFixed(1)}s`})${p.reset}`, W);
980
+ const row = (body) => cutLine(`${EDGE}${p.dim}│${p.reset} ${body}`, W);
1118
981
  if (Array.isArray(asked.declined) && asked.declined.length > 0) {
1119
982
  // the honest decline record: WHAT went unanswered, and what the
1120
983
  // choices had been — the panel already computes both.
@@ -1159,72 +1022,68 @@ const CAP_DIFF = 12; // the approval diff: head + the named middle + tail
1159
1022
  * `└` survives as the mark that OPENS the block, once, on its first
1160
1023
  * row (see openBlock). In-block notes take the same indent,
1161
1024
  * no glyph — because a second `└` inside one block would be the same
1162
- * mark meaning two things (§4.1). CUT_ROW is unchanged for the
1163
- * surfaces that are not a tool block. */
1164
- /** R8a's four columns — off the surface. Inside a painted card every
1165
- * row sits at column 2 (R13 E4): the head row and the outcome row
1166
- * bracket the preview, so the indent is no longer what says "these
1167
- * rows are output". Where nothing paints, it is exactly that, and R8a
1168
- * stands unchanged. */
1025
+ * mark meaning two things (§4.1). */
1026
+ /** Graphite §7.4 — off the surface (unknown ground), a card's body sits
1027
+ * two columns under its head: the head at the content edge (2), the body
1028
+ * at 4, opened by `└` (R8a's indent, which is what says "these rows are
1029
+ * output" once nothing is painted). */
1169
1030
  const BODY_ROW_FLAT = " ";
1170
- const NOTE_ROW_FLAT = " ";
1171
- const CARD_ROW = " ";
1172
- /** R13 E3 — the column the model's words begin in, the same one the
1173
- * card's rows and the chip's text begin in. */
1174
- const PROSE_COL = " ";
1175
- /** DC-47, ADJUDICATED — the model's THINKING begins in the SAME column
1176
- * as everything else: two.
1177
- *
1178
- * It went to four when E3 moved prose to two, so that stripping the
1179
- * escapes would still tell them apart (§1.2). The owner looked at it
1180
- * and ruled against it: "the thinking area is not indented by the same
1181
- * two as the first line — it needs to keep the same first-line indent
1182
- * as everything else" (2026-09-04). §1.8's one left edge outranks the
1183
- * distinction, and §1.2 takes a DECLARED EXCEPTION for this one pair —
1184
- * see design.md §1.2 and §7.2 for what is given up and what is not. */
1185
- const THINK_COL = " ";
1186
- const bodyRow = () => (slabPaints() ? CARD_ROW : BODY_ROW_FLAT);
1187
- const noteIndent = () => (slabPaints() ? CARD_ROW : NOTE_ROW_FLAT);
1188
- const CUT_ROW = "└ ";
1031
+ /** Inside a painted card the body sits at the content edge, under the
1032
+ * verb — the head and what it printed line up (owner, 2026-09-29, the
1033
+ * 0.44 card's alignment). */
1034
+ const CARD_BODY = "";
1035
+ /** A card's inner right margin. */
1036
+ const CARD_RIGHT = 1;
1037
+ /** The cells a painted card has for its content: after its edge cell and
1038
+ * its mark cell, from the content edge to the right edge. */
1039
+ const cardInner = (W) => Math.max(1, W - EDGE.length);
1040
+ /** Graphite §1.8 — the model's words and its thinking begin at the
1041
+ * content edge, like every other block. */
1042
+ const PROSE_COL = EDGE;
1043
+ const THINK_COL = EDGE;
1044
+ const bodyRow = () => (slabPaints() ? CARD_BODY : BODY_ROW_FLAT);
1045
+ const noteIndent = bodyRow;
1046
+ /** The cells a body row's text has: under the target inside a card, under
1047
+ * the flat indent outside one. */
1048
+ const bodyTextWidth = (W) => Math.max(1, slabPaints() ? cardInner(W) - CARD_BODY.length - CARD_RIGHT : W - BODY_ROW_FLAT.length);
1049
+ /** The cells a whole body row has (its indent included). */
1050
+ const bodyRowRoom = (W) => Math.max(1, slabPaints() ? cardInner(W) - CARD_RIGHT : W);
1189
1051
  /**
1190
- * R9 P2 — THE SLAB: a single call's block is one washed object.
1191
- *
1192
- * §1.6 gives the wash to the machine's verbatim text, and a call's own
1193
- * output is exactly that. The slab is the surface that says so: full-
1194
- * width washed rows, the head row naming the call, the output inside,
1195
- * the outcome closing it. `└` does not open a slab — the surface is the
1196
- * container, and a corner inside it is §1.3's empty mark one scale up.
1197
- *
1198
- * THE DEGRADATION IS THE POINT OF THE PREDICATE. `wash` is a chosen
1199
- * background on the two KNOWN grounds and reverse video on the third
1200
- * (§3's last rung). A chip inverting for one row is the design working; eight
1201
- * output rows inverting is a blackboard in the middle of the transcript.
1202
- * So a slab paints only where the wash is a real background, and where
1203
- * it is not the block degrades to what it has always been — the R8a
1204
- * four-column indent with a dim tail. Never to reverse video.
1205
- *
1206
- * The content shape does NOT change with the surface: the note row and
1207
- * the outcome row exist either way, in `washDim` on the slab and in the
1208
- * ordinary dim off it. Only the surface and its two blank rows are
1209
- * contingent, because an unpainted blank row is §1.3's empty mark at the
1210
- * scale of a row.
1052
+ * Graphite §7.4 / §3.1 — a card paints only where the ground is known:
1053
+ * there its surfaces are real backgrounds. On the unknown ground they
1054
+ * would be reverse video, and eight inverted output rows are a black slab
1055
+ * in the middle of the transcript, so the card keeps its content and
1056
+ * loses its surface.
1211
1057
  */
1212
1058
  function slabPaints() {
1059
+ return palette().washDone !== "" && currentGround() !== "unknown";
1060
+ }
1061
+ function cardPaint(state) {
1062
+ const p = palette();
1063
+ if (state === "fail")
1064
+ return { bg: p.washFail, edge: p.failEdge };
1065
+ if (state === "ask")
1066
+ return { bg: p.washAsk, edge: p.askEdge };
1067
+ return { bg: p.washRun, edge: p.runEdge };
1068
+ }
1069
+ /** One painted card row, the full width: the edge cell in column 0, the
1070
+ * mark cell in column 1, the content from the content edge, the state's
1071
+ * ground to the right edge, padded by DISPLAY width. A reset inside the
1072
+ * content would strand the ground for the rest of the row, so every
1073
+ * reset re-opens it (the selection bar's discipline). */
1074
+ function slabRow(inner, W, paint, mark = " ") {
1213
1075
  const p = palette();
1214
- return p.wash !== "" && currentGround() !== "unknown";
1076
+ const room = cardInner(W);
1077
+ const fitted = visibleWidth(inner) > room ? cutLine(inner, room) : inner;
1078
+ const body = fitted.replaceAll(p.reset, `${p.reset}${paint.bg}`);
1079
+ const pad = Math.max(0, room - visibleWidth(fitted));
1080
+ return `${paint.edge} ${paint.bg}${mark.replaceAll(p.reset, `${p.reset}${paint.bg}`)}${body}${paint.bg}${" ".repeat(pad)}${p.washEnd}`;
1215
1081
  }
1216
- /** One washed row, padded to the full width by DISPLAY width. A reset
1217
- * inside the content would strand the background for the rest of the
1218
- * row, so every reset re-opens it — the selection bar's discipline,
1219
- * applied to a surface that spans many rows instead of one. */
1220
- function slabRow(inner, W) {
1082
+ /** A card's pad: a whole row of its ground (§1.5), its edge cell too. */
1083
+ function padRow(paint, W) {
1221
1084
  const p = palette();
1222
- const body = inner.replaceAll(p.reset, `${p.reset}${p.wash}`);
1223
- const pad = Math.max(0, W - visibleWidth(inner));
1224
- return `${p.wash}${body}${" ".repeat(pad)}${p.washEnd}`;
1085
+ return `${paint.edge} ${paint.bg}${" ".repeat(Math.max(0, W - 1))}${p.washEnd}`;
1225
1086
  }
1226
- /** A metadata row inside (or under) a block: `washDim` on the slab,
1227
- * the ordinary dim off it. §2.1 is why there are two. */
1228
1087
  /** The widest form that fits the row, or the last one — the head row's
1229
1088
  * own discipline (TUI2-R1.5 ⑤, pin 4) applied to the slab's two
1230
1089
  * metadata rows: the parts give way in a PINNED ORDER, and the part
@@ -1232,65 +1091,177 @@ function slabRow(inner, W) {
1232
1091
  function pickTier(tiers, room) {
1233
1092
  return firstFit(tiers, room) ?? tiers[tiers.length - 1];
1234
1093
  }
1235
- /** The widest form that fits the row, or null when none does — the
1236
- * settled head row asks this with its target whole, and only then
1237
- * cuts the target. The three rows that give way in a pinned order
1238
- * (the settled head, the running head, the outcome row) share this
1239
- * walk; their ORDERS differ by ruling and stay their own. */
1094
+ /** The widest form that fits the row, or null when none does. */
1240
1095
  function firstFit(tiers, room) {
1241
1096
  for (const t of tiers)
1242
1097
  if (visibleWidth(t) <= room)
1243
1098
  return t;
1244
1099
  return null;
1245
1100
  }
1101
+ /** A metadata row inside a card's body (a cut note, the tool's own cap):
1102
+ * cut, never folded — a metadata row that wraps costs the block a row it
1103
+ * did not budget. */
1246
1104
  function noteRow(text, W, tone) {
1247
1105
  const p = palette();
1248
- // CUT, never folded — A6's rule for the tool header, and for the same
1249
- // reason: a metadata row that wraps costs the block a row it did not
1250
- // budget, and at 30 columns the fold put "expands" alone on a line of
1251
- // its own. The row names a fact; a cut names it shorter.
1252
1106
  const open = tone === "body" ? p.washDim : p.dim;
1253
1107
  const close = tone === "body" ? p.washDimEnd : p.reset;
1254
- return [cutLine(`${open}${noteIndent()}${text}${close}`, W)];
1108
+ return [cutLine(`${open}${noteIndent()}${text}${close}`, bodyRowRoom(W))];
1109
+ }
1110
+ /** `text` elided in its MIDDLE to `room` cells — a path keeps its head
1111
+ * and its file name, which is how a reader recognises it (§7.5). */
1112
+ function elideMiddle(text, room) {
1113
+ if (visibleWidth(text) <= room)
1114
+ return text;
1115
+ if (room <= 1)
1116
+ return "\u2026".slice(0, Math.max(0, room));
1117
+ const right = Math.floor((room - 1) / 2);
1118
+ const left = room - 1 - right;
1119
+ const chars = Array.from(text);
1120
+ let tail = "";
1121
+ for (let i = chars.length - 1; i >= 0; i -= 1) {
1122
+ if (displayWidth(chars[i] + tail) > right)
1123
+ break;
1124
+ tail = chars[i] + tail;
1125
+ }
1126
+ return `${widthCut(text, left)}\u2026${tail}`;
1127
+ }
1128
+ /** §7.5 — only the outcome WORD takes colour: the first segment of the
1129
+ * outcome — `exit 0` in the success colour, a failure's word in the
1130
+ * failure colour, `needs you` in gold (the card round: it stands with
1131
+ * its `❯` now) — and the rest is `dim`. */
1132
+ function outcomeStyled(text, error) {
1133
+ const p = palette();
1134
+ if (text === "")
1135
+ return "";
1136
+ const at = text.indexOf(" \u00b7 ");
1137
+ const first = at < 0 ? text : text.slice(0, at);
1138
+ const rest = at < 0 ? "" : text.slice(at);
1139
+ const tone = error ? p.red : /^exit 0$/.test(first) ? p.green : first === "needs you" ? p.gold : "";
1140
+ return tone === "" ? `${p.dim}${text}${p.reset}` : `${tone}${first}${p.reset}${rest === "" ? "" : `${p.dim}${rest}${p.reset}`}`;
1255
1141
  }
1256
1142
  /**
1257
- * Assemble a call's block.
1258
- *
1259
- * `head` is the row that names the call, `body` its rows (already
1260
- * indented and toned), `outcome` the closing line in words (§7.5) or
1261
- * null for a call whose head row already carries it — a read has no
1262
- * body, so nothing needs closing and its outcome stays inline.
1143
+ * §7.4 / §7.5 — a card's head row, without the card's edge cell: the verb
1144
+ * (upper case, `dim`), the target, and the outcome right-aligned in
1145
+ * `room` cells, `mark` (one styled cell, or "") standing in front of the
1146
+ * outcome's words. It gives way in a pinned order: the outcome's tiers
1147
+ * first (the attribution, then the count), then the target elides in its
1148
+ * middle; the outcome word is never cut. The row never folds; a command
1149
+ * folds in `headRows`.
1150
+ */
1151
+ function headCore(verb, target, tiers, room, error, mark = "") {
1152
+ const p = palette();
1153
+ // the verb and ONE space, then the target (owner, 2026-09-29: the verb
1154
+ // column padded to seven left the target stranded between the verb and
1155
+ // the body under it — 0.44's `shell pwd && ls -la` reads as one line)
1156
+ const lead = `${p.dim}${verb}${p.reset} `;
1157
+ const avail = Math.max(1, room - (verb.length + 1));
1158
+ // the card round (owner, 2026-10-05): the running `●` and the waiting
1159
+ // `❯` stand where the settled outcome will, so the state is read in one
1160
+ // place and nothing sits against the verb
1161
+ const marked = (out) => `${mark === "" ? "" : `${mark} `}${outcomeStyled(out, error)}`;
1162
+ const mw = mark === "" ? 0 : 2;
1163
+ const compose = (tg, out) => `${lead}${tg}${" ".repeat(Math.max(2, avail - visibleWidth(tg) - visibleWidth(out) - mw))}${marked(out)}`;
1164
+ for (const t of tiers)
1165
+ if (visibleWidth(target) + 2 + visibleWidth(t) + mw <= avail)
1166
+ return compose(target, t);
1167
+ const last = tiers[tiers.length - 1] ?? "";
1168
+ const tRoom = avail - 2 - visibleWidth(last) - mw;
1169
+ if (tRoom >= 4)
1170
+ return compose(elideMiddle(target, tRoom), last);
1171
+ // A NARROW row: the target gives way entirely, then the verb's padding
1172
+ // and the verb, then everything but the outcome — and the outcome's
1173
+ // last word, how long it took, is the one thing kept to the end (pin 4:
1174
+ // what happened and how long it took is never cut open).
1175
+ const bareLead = `${p.dim}${verb}${p.reset} `;
1176
+ // the outcome's segments give way from the FRONT — the count, then
1177
+ // the outcome word (and the mark with it) — so how long it took, and
1178
+ // the key where there is one, are the last to go
1179
+ const segs = last.split(" \u00b7 ");
1180
+ const suffixes = segs.map((_, k) => segs.slice(k).join(" \u00b7 "));
1181
+ for (const row of [`${lead}${marked(last)}`, `${bareLead}${marked(last)}`, ...suffixes.map((x, k) => (k === 0 ? marked(x) : `${p.dim}${x}${p.reset}`))]) {
1182
+ if (visibleWidth(row) <= room)
1183
+ return row;
1184
+ }
1185
+ return cutLine(`${p.dim}${suffixes[suffixes.length - 1]}${p.reset}`, room);
1186
+ }
1187
+ /** The card round — the cells the head's first row keeps for the outcome
1188
+ * when a command folds, whatever the outcome says now: `● running · 59m
1189
+ * 59s` and `exit 127 · 59m 59s` both fit. The fold is measured against
1190
+ * this, never against the words of the moment, so a card neither
1191
+ * re-folds while it runs nor changes height when it settles (DC-46: a
1192
+ * live card never shrinks). */
1193
+ const OUTCOME_ROOM = 20;
1194
+ /** The least room a command's first row keeps; narrower, the head is one
1195
+ * row and the command elides (the narrow ladder, `headCore`). */
1196
+ const FOLD_MIN = 12;
1197
+ /** A collapsed card's command shows at most this many rows. */
1198
+ const HEAD_ROWS = 3;
1199
+ /**
1200
+ * The card round (owner, 2026-10-05) — the head's rows. A shell command
1201
+ * is code the person approves and audits, so it is never cut in its
1202
+ * middle: when it does not fit beside the outcome it folds at its spaces,
1203
+ * each further row hanging under its own first character, for at most
1204
+ * `max` rows — past that the last row ends in `…` and ctrl+o shows the
1205
+ * rest. Every other target (a path keeps its head and its file name) is
1206
+ * one row, `headCore`'s. DECLARED REVERSAL of §7.5's "the row never
1207
+ * folds" for a command.
1208
+ */
1209
+ function headRows(verb, target, tiers, room, error, mark, max) {
1210
+ const single = [headCore(verb, target, tiers, room, error, mark)];
1211
+ if (max <= 1)
1212
+ return single;
1213
+ const lead = verb.length + 1;
1214
+ const avail = room - lead;
1215
+ const last = tiers[tiers.length - 1] ?? "";
1216
+ const first = avail - 2 - Math.max(OUTCOME_ROOM, visibleWidth(last) + (mark === "" ? 0 : 2));
1217
+ if (first < FOLD_MIN || visibleWidth(target) <= first)
1218
+ return single;
1219
+ const head = widthCut(target, first);
1220
+ const space = head.lastIndexOf(" ");
1221
+ const cut = space > 0 ? space + 1 : Math.max(1, head.length);
1222
+ const rest = target.slice(cut).trimStart();
1223
+ const rows = [target.slice(0, cut).trimEnd(), ...(rest === "" ? [] : foldAtSpaces(rest, avail))];
1224
+ const shown = rows.length <= max ? rows : [...rows.slice(0, max - 1), `${widthCut(rows.slice(max - 1).join(" "), avail - 1)}\u2026`];
1225
+ return [headCore(verb, shown[0], tiers, room, error, mark), ...shown.slice(1).map((r) => `${" ".repeat(lead)}${r}`)];
1226
+ }
1227
+ /** DC-43 — the head row ALONE, its mark in the mark column: a running
1228
+ * call's form when the room left is too small for a card. `mark` is two
1229
+ * cells (the glyph and a space, or two spaces). */
1230
+ function cardHeadRow(mark, verb, target, tiers, W, _painted, error) {
1231
+ return cutLine(`${mark}${headCore(verb, target, tiers, Math.max(1, W - EDGE.length), error)}`, W);
1232
+ }
1233
+ /** The card's foot: the key, right-aligned. */
1234
+ function footRow(key, room) {
1235
+ const p = palette();
1236
+ return `${" ".repeat(Math.max(0, room - visibleWidth(key)))}${p.dim}${key}${p.reset}`;
1237
+ }
1238
+ /**
1239
+ * Graphite §7.4 — assemble a card: pad · head · body · foot · pad on the
1240
+ * state's ground, full width, its edge cell in column 0, column 1 blank;
1241
+ * or, off the surface, the same content without it — the mark in the
1242
+ * mark column, the head at the content edge, the body under it, the foot
1243
+ * dim. `mark` is two cells: the glyph and a space, or two spaces. Inside
1244
+ * a card the glyph stands in front of the outcome's words (the card
1245
+ * round: in column 1 it sat against the verb). `fold` is how many rows
1246
+ * the head's command may take (0: the target never folds).
1263
1247
  */
1264
- function slabBlock(head, body, outcome, W) {
1248
+ function card(state, mark, verb, target, tiers, body, foot, W, error, fold = 0) {
1265
1249
  if (!slabPaints()) {
1266
- const out = [head, ...body];
1267
- if (outcome !== null)
1268
- out.push(...noteRow(outcome, W, "dim"));
1250
+ const [first, ...more] = headRows(verb, target, tiers, Math.max(1, W - EDGE.length), error, "", fold);
1251
+ const out = [cutLine(`${mark}${first}`, W), ...more.map((r) => cutLine(`${EDGE}${r}`, W)), ...body];
1252
+ if (foot !== null)
1253
+ out.push(cutLine(footRow(foot, W), W));
1269
1254
  return out;
1270
1255
  }
1271
- // R13 — THE CARD. pad · head · blank · preview · blank · outcome ·
1272
- // pad, and a call with nothing to preview is three rows with its
1273
- // outcome riding the head. Every row sits at column 2 (E4): R8a put
1274
- // a block's body at column 4 so a pipe could tell output from prose,
1275
- // and inside a painted card the head and the outcome bracket it
1276
- // instead — off the surface R8a's indent is still the fact, which is
1277
- // why the degradation above keeps it.
1278
- if (body.length === 0) {
1279
- // DC-48: the join makes a ROW, so it is cut like every other row.
1280
- // The callers above size their parts against the room they have;
1281
- // this is the backstop that makes invariant ① hold whatever they
1282
- // do, and it is what was missing when a running card's head was
1283
- // cut to W and then had a whole status appended to it.
1284
- const only = cutLine(outcome === null ? head : `${head} ${outcome}`, W);
1285
- return [slabRow("", W), slabRow(only, W), slabRow("", W)];
1286
- }
1287
- const top = [slabRow("", W), slabRow(head, W), slabRow("", W), ...body.map((r) => slabRow(r, W))];
1288
- // An EXPANDED block closes with its own footer and passes no outcome;
1289
- // a blank row and an empty metadata row under it would be §1.3's
1290
- // empty mark twice over.
1291
- if (outcome === null)
1292
- return [...top, slabRow("", W)];
1293
- return [...top, slabRow("", W), ...noteRow(outcome, W, "body").map((r) => slabRow(r, W)), slabRow("", W)];
1256
+ const paint = cardPaint(state);
1257
+ const inner = cardInner(W);
1258
+ const glyph = mark.replace(/ $/, "");
1259
+ const head = headRows(verb, target, tiers, inner - CARD_RIGHT, error, glyph.trim() === "" ? "" : glyph, fold);
1260
+ const rows = [padRow(paint, W), ...head.map((r) => slabRow(r, W, paint)), ...body.map((r) => slabRow(r, W, paint))];
1261
+ if (foot !== null)
1262
+ rows.push(slabRow(footRow(foot, inner - CARD_RIGHT), W, paint));
1263
+ rows.push(padRow(paint, W));
1264
+ return rows;
1294
1265
  }
1295
1266
  /*
1296
1267
  * RETIRED (DC-50 / R14, 2026-09-05) — `expandedCard`.
@@ -1307,7 +1278,7 @@ function slabBlock(head, body, outcome, W) {
1307
1278
  * `r13-rhythm-surface.test.ts`.
1308
1279
  */
1309
1280
  /** 0.24.2 ② — the live region's `thinking…` placeholder: dim italic at
1310
- * column 2, no glyph, the SAME shape a thinking paragraph takes so that
1281
+ * the content edge, no glyph, the SAME shape a thinking paragraph takes so that
1311
1282
  * whatever arrives replaces it in place. Never committed — see the
1312
1283
  * compositor's #project for why that is what makes it allowed. */
1313
1284
  export function thinkingRow() {
@@ -1331,10 +1302,12 @@ function openBlock(rows) {
1331
1302
  return rows;
1332
1303
  // the corner REPLACES two of the four indent columns, so the text
1333
1304
  // stays in the same column as every other row of the block.
1334
- return [...rows.slice(0, i), `${first.slice(0, at)} \u2514 ${first.slice(at + BODY_ROW_FLAT.length)}`, ...rows.slice(i + 1)];
1305
+ return [...rows.slice(0, i), `${first.slice(0, at)}${" ".repeat(BODY_ROW_FLAT.length - 2)}\u2514 ${first.slice(at + BODY_ROW_FLAT.length)}`, ...rows.slice(i + 1)];
1335
1306
  }
1336
1307
  const blockMemo = new WeakMap();
1337
- /** The block's body rows below the header (memoized, W9). */
1308
+ /** The block's body rows below the header (memoized, W9), and whether
1309
+ * the preview CUT anything — which is what puts the key on a card's
1310
+ * foot (§7.4). */
1338
1311
  function toolBlockParts(c, W, ctx) {
1339
1312
  const memo = blockMemo.get(c);
1340
1313
  // the SURFACE is part of the key: the same cell renders different rows
@@ -1342,80 +1315,114 @@ function toolBlockParts(c, W, ctx) {
1342
1315
  // would otherwise be served the pre-ground shape forever.
1343
1316
  const liveRows = ctx.liveWindow ?? CAP_PREVIEW;
1344
1317
  const state = `${c.state}:${c.isError}:${c.name}:${c.expanded ? "x" : ""}:${slabPaints() ? "slab" : "flat"}:${liveRows}`;
1345
- const content = c.state === "approval" ? (c.diff ?? null) : c.resultText;
1318
+ // R2a: an edit or a write shows what it changed while it runs and once
1319
+ // it has — never after a refusal (nothing changed) or an error
1320
+ const edit = !c.isError && c.reason === null && (c.state === "running" || c.state === "done") ? editDiffOf(c) : null;
1321
+ const content = c.state === "approval" ? (c.diff ?? null) : edit !== null ? edit : c.resultText;
1346
1322
  if (memo !== undefined && memo.width === W && memo.state === state && memo.content === content)
1347
1323
  return memo;
1348
1324
  const p = palette();
1349
- const rows = c.expanded
1350
- ? // W15: the toggle's full form — the WHOLE body, no cap, no
1351
- // cut note (nothing is cut; the width fold still holds — the
1352
- // height may change while live, the user asked for it). The
1353
- // delegate has no body — its rows are unchanged.
1354
- c.state === "approval"
1355
- ? diffBody(c.diff, W, true)
1356
- : c.name === "delegate"
1357
- ? c.state === "running"
1358
- ? delegateRunning(c, W)
1359
- : delegateSettled(c, W)
1360
- : blockRows(c.resultText, W)
1361
- : c.state === "done"
1362
- ? c.isError
1363
- ? errorBody(c, W)
1364
- : c.name === "delegate"
1365
- ? delegateSettled(c, W)
1366
- : // R13 — EVERY settled call previews. The shell's tail is
1367
- // VD-5's own cap and direction (the conclusion is at the
1368
- // end); everything else shows its head, because that is
1369
- // where its answer is. read_file is the single exception
1370
- // (E1): its result is the file, kiso has nothing to add by
1371
- // showing five lines of it, and the head row's key opens
1372
- // the whole thing.
1373
- noPreview(c)
1374
- ? []
1375
- : c.name === "shell"
1376
- ? shellTail(c.resultText, W, slabPaints() ? "body" : "dim")
1377
- : previewHead(c.resultText, W, slabPaints() ? "body" : "dim")
1378
- : c.state === "running"
1379
- ? c.name === "delegate"
1380
- ? delegateRunning(c, W)
1381
- : // R13 E1 — the call with no preview settled has none while
1382
- // it runs either; its card is three rows the whole way.
1383
- noPreview({ ...c, state: "done" })
1384
- ? []
1385
- : liveWindow(c, W, liveCap(c, liveRows), slabPaints() ? "body" : "dim")
1386
- : c.state === "approval"
1387
- ? diffBody(c.diff, W)
1388
- : [];
1389
- // E1 governs the PREVIEW — five lines of a file kiso has nothing to
1390
- // add to — not kiso's sentence about the result. `offset=201 for the
1391
- // rest` is actionable and no other row says it (W10 pins exactly
1392
- // that), so a read the TOOL capped takes one body row and a read it
1393
- // did not takes none. That shape difference is information: the two
1394
- // events are different, and the row is what says so.
1395
- const note = c.expanded ? null : toolCutNote(c.name, c.resultText);
1325
+ const tone = slabPaints() ? "body" : "dim";
1326
+ let cut = false;
1327
+ const keyed = c.state === "done" && !c.expanded;
1328
+ const capped = (all, dir, starts = []) => {
1329
+ if (all.length <= CAP_PREVIEW)
1330
+ return all;
1331
+ cut = true;
1332
+ // R9 P2 / D4: FIVE output rows, and the note is a row of its own.
1333
+ // A shell's conclusion is at the bottom, so its note goes ABOVE the
1334
+ // tail; everything else answers at the top, so its note closes it.
1335
+ // The card round: a settled, collapsed card's key rides the note.
1336
+ if (dir === "tail") {
1337
+ const w = tailWindow(all, CAP_PREVIEW, starts);
1338
+ return [...cutNote(w.lines, "earlier", W, tone, keyed, w.partial), ...w.rows];
1339
+ }
1340
+ const h = headCut(all, CAP_PREVIEW, starts);
1341
+ return [...h.rows, ...cutNote(h.lines, "more", W, tone, keyed, h.partial)];
1342
+ };
1343
+ const shellTail = (out, dir) => capped(out.rows, dir, out.starts);
1344
+ // a diff inside a card: its own rows, the head of them until the key
1345
+ const diffCapped = (lines, cap) => {
1346
+ const all = lines === null ? [] : diffRows(lines, W);
1347
+ if (c.expanded || all.length <= cap)
1348
+ return all;
1349
+ cut = true;
1350
+ return [...all.slice(0, cap), ...cutNote(all.length - cap, "more", W, tone, keyed)];
1351
+ };
1352
+ const rows = edit !== null
1353
+ ? diffCapped(edit.lines, edit.cap)
1354
+ : c.state === "approval" && slabPaints()
1355
+ ? diffCapped(c.diff, CAP_DIFF)
1356
+ : c.expanded
1357
+ ? // W15: the WHOLE body, no cap, no cut note (nothing is cut).
1358
+ c.state === "approval"
1359
+ ? diffBody(c.diff, W, true)
1360
+ : c.name === "delegate"
1361
+ ? c.state === "running"
1362
+ ? delegateRunning(c, W)
1363
+ : delegateSettled(c, W)
1364
+ : blockRows(c.resultText, W, tone)
1365
+ : c.state === "done"
1366
+ ? c.isError
1367
+ ? capped(errorRows(c, W, tone), "head")
1368
+ : c.name === "delegate"
1369
+ ? delegateSettled(c, W)
1370
+ : // R13 — EVERY settled call previews: a shell its tail,
1371
+ // everything else its head. read_file is the single
1372
+ // exception (E1): its result is the file, and the key
1373
+ // opens the whole thing.
1374
+ noPreview(c)
1375
+ ? []
1376
+ : shellTail(blockLines(c.resultText, W, tone), c.name === "shell" ? "tail" : "head")
1377
+ : c.state === "running"
1378
+ ? c.name === "delegate"
1379
+ ? delegateRunning(c, W)
1380
+ : // R13 E1 — the call with no preview settled has none
1381
+ // while it runs either.
1382
+ noPreview({ ...c, state: "done" })
1383
+ ? []
1384
+ : liveWindow(c, W, liveCap(c, liveRows), tone)
1385
+ : c.state === "approval"
1386
+ ? diffBody(c.diff, W)
1387
+ : [];
1388
+ // E1 governs the PREVIEW, not kiso's sentence about the result: a read
1389
+ // the TOOL capped takes one body row (`offset=201 for the rest`).
1390
+ const note = c.expanded || edit !== null ? null : toolCutNote(c.name, c.resultText);
1396
1391
  if (note !== null)
1397
- rows.push(...foldLine(`${p.dim}${noteIndent()}${note}${p.reset}`, W));
1398
- // TUI2-R1 (A): an EXPANDED block says how to put it back. The footer
1399
- // rides a block that HAS rows — an expanded delegate whose summary
1400
- // marker is missing renders nothing, and a lone footer under a head
1401
- // row would be an affordance for an empty block.
1402
- //
1403
- // DC-50 / R14: a SETTLED card carries the affordance on its OUTCOME
1404
- // row instead, so that both states have one skeleton. This footer is
1405
- // for the states that have no outcome row to carry it — a card parked
1406
- // for approval, or one still running that was expanded before it
1407
- // settled.
1408
- if (c.expanded && rows.length > 0 && c.state !== "done")
1409
- rows.push(...foldLine(`${p.dim}${noteIndent()}${COLLAPSE_ROW}${p.reset}`, W));
1410
- // R9 P2: `└` opens a block that has no surface. Inside a slab the
1411
- // surface IS the container, and a corner in it is §1.3's empty mark
1412
- // one scale up — so the corner and the slab are alternatives, never
1413
- // both.
1392
+ rows.push(...noteRow(note, W, "dim"));
1393
+ // R8a: `└` opens a block that has no surface; inside a card the surface
1394
+ // IS the container.
1414
1395
  const opened = slabPaints() ? rows : openBlock(rows);
1415
- const parts = { width: W, state, content, rows: opened };
1396
+ const parts = { width: W, state, content, rows: opened, cut };
1416
1397
  blockMemo.set(c, parts);
1417
1398
  return parts;
1418
1399
  }
1400
+ /** The tasks round — a background delegation's children, from what the
1401
+ * call returned (`started 2 background children: t1 explorer (session …),
1402
+ * t2 reviewer (session …). They read …`) and what it was asked (each
1403
+ * task's role and words): the task id, the role, the task's first line.
1404
+ * Null for anything else — a foreground delegation, a refusal. */
1405
+ function backgroundChildren(c) {
1406
+ const m = /^started \d+ background (?:child|children): (.*?)\. They read the workspace/s.exec(c.resultText);
1407
+ if (m === null)
1408
+ return null;
1409
+ let asked = {};
1410
+ try {
1411
+ asked = JSON.parse(c.inputFull);
1412
+ }
1413
+ catch {
1414
+ // the full input is always JSON (stringified at toolStart)
1415
+ }
1416
+ const pool = (asked.tasks ?? []).map((t) => ({ role: String(t.role ?? ""), task: oneRow(escapeTerminal(String(t.task ?? "").split("\n")[0] ?? "")), used: false }));
1417
+ const out = [];
1418
+ for (const s of m[1].matchAll(/(\S+) (\S+) \(session [^)]*\)/g)) {
1419
+ const at = pool.findIndex((t) => !t.used && t.role === s[2]);
1420
+ if (at >= 0)
1421
+ pool[at].used = true;
1422
+ out.push({ id: escapeTerminal(s[1]), role: escapeTerminal(s[2]), task: at >= 0 ? pool[at].task : "" });
1423
+ }
1424
+ return out.length === 0 ? null : out;
1425
+ }
1419
1426
  /** R13 E1 — the one settled call that previews NOTHING. A read's result
1420
1427
  * IS the file; five lines of it tell a reader less than the head row
1421
1428
  * already does, and the key opens the whole thing. (The reference
@@ -1424,96 +1431,143 @@ function toolBlockParts(c, W, ctx) {
1424
1431
  function noPreview(c) {
1425
1432
  return c.state === "done" && !c.expanded && !c.isError && c.reason === null && c.name === "read_file";
1426
1433
  }
1427
- /** The block's body rows. */
1428
- function toolBlockBody(c, W, ctx) {
1429
- return toolBlockParts(c, W, ctx).rows;
1430
- }
1434
+ /** How far a wrapped output line's continuation rows stand in. */
1435
+ const HANG = 2;
1431
1436
  /** Fold result text into body rows (the block's own indent): escape,
1432
- * split, fold each line at W−prefix; trailing empty rows (the result's
1433
- * final newline) drop. */
1437
+ * split, fold each line in the body's text width; trailing empty rows
1438
+ * (the result's final newline) drop. Inside a card the output is
1439
+ * `ink2` (§2: tool output); off the surface it is dim. */
1434
1440
  function blockRows(text, W, tone = "dim") {
1441
+ return blockLines(text, W, tone).rows;
1442
+ }
1443
+ /** `blockRows`, with which rows begin a line of output (a wrapped line's
1444
+ * continuation rows do not) — what a tail window needs to start on a
1445
+ * whole line. */
1446
+ function blockLines(text, W, tone = "dim") {
1435
1447
  const p = palette();
1436
- const textW = Math.max(1, W - visibleWidth(bodyRow()));
1448
+ const textW = bodyTextWidth(W);
1437
1449
  const rows = [];
1438
- // R9 P2: inside a SLAB the output rows are body strength, never dim —
1439
- // §2.1 bars dim from the wash (3.91:1 light, 4.35:1 dark) and these
1440
- // rows are the verbatim content the surface exists for. The metadata
1441
- // rows around them take `washDim`, which was chosen for that ground.
1442
- const open = tone === "dim" ? p.dim : "";
1443
- const close = tone === "dim" ? p.reset : "";
1450
+ const starts = [];
1451
+ const open = tone === "dim" ? p.dim : p.ink2;
1452
+ const close = tone === "dim" ? p.reset : p.ink2 === "" ? "" : p.fgEnd;
1444
1453
  // 0.40.0: the output's own styling is dropped whole before the escape
1454
+ // The card round (owner, 2026-10-05): a line too long for the row
1455
+ // continues HANG cells in, so one line of output reads as one.
1456
+ const hang = textW > HANG * 4 ? HANG : 0;
1445
1457
  for (const raw of escapeTerminal(stripAnsi(text)).split("\n")) {
1446
- for (const row of foldLine(raw, textW))
1447
- rows.push(`${open}${bodyRow()}${row}${close}`);
1458
+ const [first = "", ...more] = foldLine(raw, textW);
1459
+ rows.push(`${bodyRow()}${open}${first}${close}`);
1460
+ starts.push(true);
1461
+ if (more.length === 0)
1462
+ continue;
1463
+ for (const row of foldLine(more.join(""), textW - hang)) {
1464
+ rows.push(`${bodyRow()}${" ".repeat(hang)}${open}${row}${close}`);
1465
+ starts.push(false);
1466
+ }
1448
1467
  }
1449
- while (rows.length > 0 && visibleWidth(rows[rows.length - 1]) === visibleWidth(bodyRow()))
1468
+ while (rows.length > 0 && visibleWidth(rows[rows.length - 1]) === visibleWidth(bodyRow())) {
1450
1469
  rows.pop();
1451
- return rows;
1470
+ starts.pop();
1471
+ }
1472
+ return { rows, starts };
1452
1473
  }
1453
- /** The shell output tail, settled: the LAST rows, capped at 5 — the
1454
- * renderer cut at the block's bottom ("earlier rows" — the conclusion
1455
- * is at the end, the reference implementation's truncateToVisualLines
1456
- * direction). */
1457
- function shellTail(text, W, tone = "dim") {
1458
- const rows = blockRows(text, W, tone);
1459
- if (rows.length <= CAP_PREVIEW)
1460
- return rows;
1461
- // R9 P2 / D4: FIVE output rows, and the note is a row of its own. The
1462
- // pre-slab arithmetic spent one of the five on the cut note, because
1463
- // the note had nowhere else to live; the slab's metadata rows are not
1464
- // output and are not counted against the output's cap.
1465
- const kept = CAP_PREVIEW;
1466
- // The note goes ABOVE the tail: it says what was cut, and what was
1467
- // cut is what came BEFORE these rows. One position on both surfaces —
1468
- // the surface degrades, the content shape does not.
1469
- return [...cutNote(rows.length - kept, "earlier", W, tone), ...rows.slice(rows.length - kept)];
1470
- }
1471
- /** R13 — the preview every OTHER settled call gets: the FIRST rows,
1472
- * capped at five, the cut note BELOW them. A shell's conclusion is at
1473
- * the bottom of its output, so its preview is the tail and its note
1474
- * opens the block (shellTail above); a list, a search, a fetch all
1475
- * answer at the top, so the note closes it. One rule, two directions,
1476
- * and the direction follows where the answer is.
1477
- *
1478
- * This is the DECLARED REVERSAL of VD-5 for every tool but the shell
1479
- * (0.22.0 reversed the shell alone): VD-5 collapsed a settled call to
1480
- * its head row because ungrounded output rows owned the screen, and
1481
- * the card is what changes that arithmetic — the rows are inside a
1482
- * surface that says where the call begins and ends. */
1483
- function previewHead(text, W, tone) {
1484
- const rows = blockRows(text, W, tone);
1485
- if (rows.length <= CAP_PREVIEW)
1486
- return rows;
1487
- return [...rows.slice(0, CAP_PREVIEW), ...cutNote(rows.length - CAP_PREVIEW, "more", W, tone)];
1488
- }
1489
- /** The preview's cut note, in one place so the head and the tail
1490
- * directions cannot drift apart. The KEY is reserved (TUI2-R1.5 ⑤): a
1491
- * row that says how much is hidden without saying how to see it is the
1492
- * silence the affordance exists to remove. */
1493
- function cutNote(cut, word, W, tone) {
1494
- const n = `${cut} ${word} line${cut === 1 ? "" : "s"}`;
1495
- return noteRow(pickTier([`… ${n} · ctrl+o expands`, `… ${n} · ctrl+o`, `… ${cut} · ctrl+o`, "· ctrl+o"], W - visibleWidth(noteIndent())), W, tone);
1496
- }
1497
- /** The error text head: the FIRST rows, capped at 3 — the answer is at
1498
- * the start (opencode's collapseToolOutput direction). The header row
1499
- * already summarizes the first line, so the body starts at line 2. */
1500
- function errorBody(c, W) {
1501
- const p = palette();
1502
- // W4: a shell EXECUTION failure's line 0 ("exit 1: …") no longer
1503
- // rides the header — the parsed code does — so the body keeps the
1504
- // FULL text. Any other error keeps the pre-W4 split: line 0 is the
1505
- // header's metadata, the body shows the rest.
1506
- // W19: a DENIED call's header meta is the PARSED reason (from the
1507
- // denied tag), decoupled from the result text — the body keeps the
1508
- // FULL content including the "[Permission denied] " prefix (never
1509
- // hide information — the folded body rides the pinned row).
1510
- const skipFirst = c.name === "shell" && /^exit \d+/.test(c.resultText) ? 0 : c.reason !== null && c.reason !== undefined ? 0 : 1;
1511
- // R13 — a failure is a CARD like any other call: the same cap of
1512
- // five, the same note wording, the same direction (an error's answer
1513
- // is at the top). It kept a cap of three and `+2 more · ctrl+o` from
1514
- // before the card existed, which made the one shape a reader most
1515
- // wants to read the one shape that showed least of itself.
1516
- return previewHead(c.resultText.split("\n").slice(skipFirst).join("\n"), W, slabPaints() ? "body" : "dim");
1474
+ /** The preview's cut note — how much was cut and in which direction.
1475
+ * With `key` (a settled card, collapsed) the key that shows the rest
1476
+ * stands at the same row's right margin: what was cut at the left, how
1477
+ * to see it at the right (the card round, owner 2026-10-05). DECLARED
1478
+ * REVERSAL of §7.4's foot for a collapsed card — the note and the key
1479
+ * were two rows for one fact. The key gives way after the count's word,
1480
+ * the count never. */
1481
+ function cutNote(cut, word, W, tone, key = false, partial = false) {
1482
+ // 0.47.1 (finding 0470-F1, owner 2026-10-07): `cut` counts output
1483
+ // LINES, the unit the note names (it counted rows: a wrapped line hidden
1484
+ // whole was two or more "lines"). `partial`: the line at the cut is
1485
+ // shown only in part — its start above a tail, its rest below a head —
1486
+ // and the note says so rather than counting it.
1487
+ const part = word === "earlier" ? "start" : "rest";
1488
+ const lines = `${cut} ${word} line${cut === 1 ? "" : "s"}`;
1489
+ const n = !partial ? lines : cut === 0 ? `the ${part} of this line` : `${lines} and the ${part} of this one`;
1490
+ const short = !partial ? `\u2026 ${cut}` : cut === 0 ? "\u2026" : `\u2026 ${cut}+`;
1491
+ // the part's clause gives way before the count's word does
1492
+ const tiers = partial && cut > 0 ? [`\u2026 ${n}`, `\u2026 ${lines}`, short] : [`\u2026 ${n}`, short];
1493
+ const room = bodyTextWidth(W);
1494
+ if (key) {
1495
+ for (const [left, k] of [...tiers.map((l) => [l, EXPAND_ROW]), [short, "ctrl+o"]]) {
1496
+ const gap = room - visibleWidth(left) - visibleWidth(k);
1497
+ if (gap >= 2)
1498
+ return noteRow(`${left}${" ".repeat(gap)}${k}`, W, tone);
1499
+ }
1500
+ }
1501
+ return noteRow(pickTier(tiers, room), W, tone);
1502
+ }
1503
+ /** What a cut window hides, for its note (0.47.1, finding 0470-F1): the
1504
+ * output lines among the hidden rows, and whether the line at the cut is
1505
+ * shown in part. `starts` marks each row that begins a line; where it is
1506
+ * unknown (empty) every row counts as a line, as before. A tail that opens
1507
+ * on a continuation row shows the end of a line whose start is hidden —
1508
+ * that line is not counted, it is the note's `partial`. */
1509
+ function tailCount(shown, hidden, starts, first) {
1510
+ const partial = starts[first] === false;
1511
+ return { rows: shown, lines: hidden.filter((i) => starts[i] !== false).length - (partial ? 1 : 0), partial };
1512
+ }
1513
+ function headCut(rows, cap, starts) {
1514
+ let lines = 0;
1515
+ for (let i = cap; i < rows.length; i += 1)
1516
+ if (starts[i] !== false)
1517
+ lines += 1;
1518
+ return { rows: rows.slice(0, cap), lines, partial: starts[cap] === false };
1519
+ }
1520
+ /** The card round — a shell's tail never opens on a blank row or in the
1521
+ * middle of a wrapped line, and keeps its height (DC-46: a live window
1522
+ * never shrinks, and the settle moves nothing). When the window's top row
1523
+ * would be either, the window starts at an earlier line's first row and
1524
+ * blank rows inside it give way to make the room, the top-most first: a
1525
+ * blank row carries no fact, and the conclusion is at the bottom. It
1526
+ * looks back at most `TAIL_REACH` rows (a bounded walk, never a scan of
1527
+ * the whole output); when no line start can be reached, the window is
1528
+ * the plain tail. The rows it hides are counted in the note. `starts`
1529
+ * marks each row that begins a line of output. */
1530
+ const TAIL_REACH = 12;
1531
+ /** The runtime's reason for a refusal the person gave no words for
1532
+ * (session.approve(id, false)) — a card says `denied by you` alone then. */
1533
+ const DEFAULT_REFUSAL = "denied by user";
1534
+ function tailWindow(rows, cap, starts) {
1535
+ const n = rows.length;
1536
+ if (n <= cap)
1537
+ return { rows: [...rows], lines: 0, partial: false };
1538
+ const blank = (i) => visibleWidth(rows[i]) <= visibleWidth(bodyRow());
1539
+ let blanks = 0;
1540
+ for (let i = n - cap; i < n; i += 1)
1541
+ if (blank(i))
1542
+ blanks += 1;
1543
+ // s stops at 1: the window reaches only while a row above it stays
1544
+ // cut, so the note never counts nothing but the blanks it skipped
1545
+ for (let s = n - cap; s >= Math.max(1, n - cap - TAIL_REACH); s -= 1) {
1546
+ if (s < n - cap && blank(s))
1547
+ blanks += 1;
1548
+ const extra = n - s - cap;
1549
+ if (blank(s) || starts[s] === false || blanks < extra)
1550
+ continue;
1551
+ const out = [];
1552
+ const hidden = Array.from({ length: s }, (_, i) => i);
1553
+ let dropped = 0;
1554
+ for (let i = s; i < n; i += 1) {
1555
+ if (dropped < extra && blank(i)) {
1556
+ dropped += 1;
1557
+ hidden.push(i);
1558
+ }
1559
+ else
1560
+ out.push(rows[i]);
1561
+ }
1562
+ return tailCount(out, hidden, starts, s);
1563
+ }
1564
+ return tailCount(rows.slice(n - cap), Array.from({ length: n - cap }, (_, i) => i), starts, n - cap);
1565
+ }
1566
+ /** The error text, uncapped: the answer is at the start, and the whole
1567
+ * of it is the body — the head row carries only the outcome word
1568
+ * (W4, W19: never hide information). */
1569
+ function errorRows(c, W, tone) {
1570
+ return blockRows(c.resultText, W, tone);
1517
1571
  }
1518
1572
  /**
1519
1573
  * DC-46 — THE RUNNING WINDOW GROWS, and nothing pads it.
@@ -1544,7 +1598,7 @@ function liveWindow(c, W, cap, tone) {
1544
1598
  // TUI2-R1.5 ④(b) (VD-4): leading empty lines in the sidecar (a
1545
1599
  // 4096-byte tail can begin on a line boundary) are skipped, so the
1546
1600
  // output starts under its own header.
1547
- const all = blockRows(c.resultText, W, tone);
1601
+ const { rows: all, starts } = blockLines(c.resultText, W, tone);
1548
1602
  const from = all.findIndex((r) => visibleWidth(r) > visibleWidth(bodyRow()));
1549
1603
  // DC-46, derived — NOTHING YET IS NO WINDOW AT ALL, so a running call
1550
1604
  // with no output is the same THREE-ROW card as a settled one with
@@ -1562,9 +1616,12 @@ function liveWindow(c, W, cap, tone) {
1562
1616
  const rows = all.slice(from);
1563
1617
  if (rows.length <= cap)
1564
1618
  return rows;
1565
- return c.name === "shell"
1566
- ? [...cutNote(rows.length - cap, "earlier", W, tone), ...rows.slice(rows.length - cap)]
1567
- : [...rows.slice(0, cap), ...cutNote(rows.length - cap, "more", W, tone)];
1619
+ if (c.name === "shell") {
1620
+ const w = tailWindow(rows, cap, starts.slice(from));
1621
+ return [...cutNote(w.lines, "earlier", W, tone, false, w.partial), ...w.rows];
1622
+ }
1623
+ const h = headCut(rows, cap, starts.slice(from));
1624
+ return [...h.rows, ...cutNote(h.lines, "more", W, tone, false, h.partial)];
1568
1625
  }
1569
1626
  /** DC-46 — the window's HIGH-WATER, per cell: the room a frame leaves
1570
1627
  * caps how far a window may GROW, and never shrinks one that already
@@ -1600,24 +1657,31 @@ function delegateRunning(c, W) {
1600
1657
  const p = palette();
1601
1658
  const n = c.childRoles.length;
1602
1659
  const text = n === 0 ? "children running…" : `${n === 1 ? "1 child" : `${n} children`} · ${c.childRoles.join(" · ")}`;
1603
- return [oneLineRow(p, text, W)];
1660
+ return [bodyLineRow(p, text, W)];
1604
1661
  }
1605
1662
  function delegateSettled(c, W) {
1606
1663
  const p = palette();
1607
1664
  const m = /^summary: (.+)$/m.exec(c.resultText);
1608
1665
  if (m === null)
1609
1666
  return [];
1610
- return [oneLineRow(p, `${m[1]} · /last for the report`, W)];
1667
+ return [bodyLineRow(p, `${m[1]} · /last for the report`, W)];
1611
1668
  }
1612
1669
  /** ONE row at the left gutter, truncated to fit the width — never a
1613
1670
  * fold (a fold would wrap into TWO rows and break the one-line height
1614
1671
  * contract). */
1615
1672
  function oneLineRow(p, text, W) {
1616
1673
  const esc = escapeTerminal(text);
1617
- if (visibleWidth(`${p.dim}${CUT_ROW}${esc}${p.reset}`) <= W)
1618
- return `${p.dim}${CUT_ROW}${esc}${p.reset}`;
1619
- const w = Math.max(1, W - visibleWidth(`${p.dim}${CUT_ROW}${p.reset}`));
1620
- return `${p.dim}${CUT_ROW}${esc.slice(0, w - 1)}…${p.reset}`;
1674
+ if (visibleWidth(`${p.dim}\u2514 ${esc}${p.reset}`) <= W)
1675
+ return `${p.dim}\u2514 ${esc}${p.reset}`;
1676
+ const w = Math.max(1, W - 2);
1677
+ return `${p.dim}\u2514 ${esc.slice(0, w - 1)}\u2026${p.reset}`;
1678
+ }
1679
+ /** Graphite §7.4 — a card body's ONE row (the delegate's): at the body
1680
+ * column like every body row, dim, cut to its room. */
1681
+ function bodyLineRow(p, text, W) {
1682
+ const esc = escapeTerminal(text);
1683
+ const room = bodyTextWidth(W);
1684
+ return `${bodyRow()}${p.dim}${visibleWidth(esc) <= room ? esc : `${widthCut(esc, Math.max(1, room - 1))}\u2026`}${p.reset}`;
1621
1685
  }
1622
1686
  /** The approval mini-diff (W7): capped at 12 folded rows — the head +
1623
1687
  * the named middle (the renderer cut — what was cut, how to expand) +
@@ -1676,6 +1740,102 @@ export function diffBody(diff, W, expanded = false) {
1676
1740
  return [...rows.slice(0, CAP_DIFF - 1), cut(rows.length - (CAP_DIFF - 1))];
1677
1741
  return [...rows.slice(0, head), cut(rows.length - head - tail), ...rows.slice(rows.length - tail)];
1678
1742
  }
1743
+ /** Graphite §6 (R2a) — the rows a card shows for a diff: a `-` / `+` line
1744
+ * on its tint (a background, §1.5) from the content edge to the card's
1745
+ * inner margin, the marker in its colour, the changed words on a deeper
1746
+ * tint; a kept line `dim`; kiso's own notes (`···` between hunks, a miss)
1747
+ * `dim` in the marker column. A long line folds under its text with the
1748
+ * tint on every row. Off a painted card the lines keep the flat form:
1749
+ * marker and text in the line's colour, the changed words underlined. */
1750
+ function diffRows(lines, W) {
1751
+ const p = palette();
1752
+ const painted = slabPaints();
1753
+ const room = bodyRowRoom(W) - bodyRow().length;
1754
+ const textW = Math.max(1, bodyTextWidth(W) - 2);
1755
+ const out = [];
1756
+ for (const d of lines) {
1757
+ const text = escapeTerminal(d.text);
1758
+ if (d.kind === "note") {
1759
+ out.push(...noteRow(text, W, "dim"));
1760
+ continue;
1761
+ }
1762
+ if (d.kind === " ") {
1763
+ for (const r of foldLine(text, textW))
1764
+ out.push(`${bodyRow()}${p.dim} ${r}${p.reset}`);
1765
+ continue;
1766
+ }
1767
+ const del = d.kind === "-";
1768
+ const rows = foldLine(text, textW);
1769
+ // the marks index the line's own text: they hold only where the
1770
+ // escape left it as it was and the fold cut it without loss
1771
+ const marks = text === d.text && rows.join("") === text ? (d.marks ?? []) : [];
1772
+ const base = del ? p.del : p.add;
1773
+ const [on, off] = painted ? [del ? p.delWord : p.addWord, base] : [p.underline, p.underlineEnd];
1774
+ let at = 0;
1775
+ for (const [i, r] of rows.entries()) {
1776
+ let body = "";
1777
+ let k = 0;
1778
+ for (const [s0, e0] of marks) {
1779
+ const s1 = Math.max(s0, at) - at;
1780
+ const e1 = Math.min(e0, at + r.length) - at;
1781
+ if (e1 <= s1)
1782
+ continue;
1783
+ body += `${r.slice(k, s1)}${on}${r.slice(s1, e1)}${off}`;
1784
+ k = e1;
1785
+ }
1786
+ body += r.slice(k);
1787
+ at += r.length;
1788
+ const lead = i === 0 ? d.kind : " ";
1789
+ if (painted) {
1790
+ const pad = " ".repeat(Math.max(0, room - 2 - visibleWidth(r)));
1791
+ out.push(`${bodyRow()}${base}${del ? p.fail : p.ok}${lead}${p.fgEnd} ${body}${pad}`);
1792
+ }
1793
+ else {
1794
+ out.push(`${bodyRow()}${del ? p.red : p.green}${lead} ${body}${p.reset}`);
1795
+ }
1796
+ }
1797
+ }
1798
+ return out;
1799
+ }
1800
+ /** How many of a write's lines its card shows before the key. */
1801
+ const WRITE_HEAD = 5;
1802
+ const editDiffMemo = new WeakMap();
1803
+ function editDiffOf(c) {
1804
+ if (c.name !== "edit_file" && c.name !== "write_file")
1805
+ return null;
1806
+ const known = editDiffMemo.get(c);
1807
+ if (known !== undefined)
1808
+ return known;
1809
+ let input = null;
1810
+ try {
1811
+ const v = JSON.parse(c.inputFull);
1812
+ input = typeof v === "object" && v !== null && !Array.isArray(v) ? v : null;
1813
+ }
1814
+ catch {
1815
+ input = null;
1816
+ }
1817
+ let out = null;
1818
+ if (input !== null && c.name === "edit_file") {
1819
+ const hunks = hunksOf(input);
1820
+ const d = hunks === null ? null : hunksDiff(hunks);
1821
+ if (hunks !== null && d !== null && d.lines.length > 0) {
1822
+ const counts = `+${d.added} -${d.removed}`;
1823
+ out = { lines: d.lines, meta: hunks.length > 1 ? `${counts} \u00b7 ${hunks.length} hunks` : counts, cap: CAP_DIFF };
1824
+ }
1825
+ }
1826
+ else if (input !== null && typeof input.content === "string") {
1827
+ const content = input.content.replace(/\n$/, "");
1828
+ const all = content === "" ? [] : content.split("\n");
1829
+ // a write that cites "absent" creates the file; any other rewrites
1830
+ // one whose old text is not in the log, so its lines are shown as
1831
+ // they now read, not as additions
1832
+ const created = input.expectedRevision === "absent";
1833
+ const n = `${all.length} line${all.length === 1 ? "" : "s"}`;
1834
+ out = { lines: all.map((text) => ({ kind: created ? "+" : " ", text })), meta: created ? `new file \u00b7 ${n}` : `rewrote \u00b7 ${n}`, cap: WRITE_HEAD };
1835
+ }
1836
+ editDiffMemo.set(c, out);
1837
+ return out;
1838
+ }
1679
1839
  /** The TOOL's OWN truncation note (W10) — a different fact from the
1680
1840
  * renderer's cut: the tools truncate and append a continuation note
1681
1841
  * (packages/tools-node/src/index.ts — read_file's "call again with
@@ -1722,18 +1882,133 @@ class MarkdownBlock {
1722
1882
  // holds by construction. A block's own leading blank (its `gap`)
1723
1883
  // stays EMPTY: an indented blank row is trailing whitespace, and
1724
1884
  // §1.3 forbids a mark on a row with nothing to mark.
1725
- return renderBlock(this.cell.block, Math.max(1, W - PROSE_COL.length)).map((r) => (r === "" ? r : `${PROSE_COL}${r}`));
1885
+ // Graphite §1.8: at the content edge, 92 columns at most (§7.15).
1886
+ const rows = renderBlock(this.cell.block, proseRoom(W)).map((r) => (r === "" ? r : `${PROSE_COL}${r}`));
1887
+ // Graphite §5 (R2b): a `##` heading carries `§` in the mark column
1888
+ // (`rail`), outside the block, the way the prototype hangs it
1889
+ const p = palette();
1890
+ if (headingLevel(this.cell.block) === 2 && p.rail !== "") {
1891
+ const at = rows.findIndex((r) => r !== "");
1892
+ if (at >= 0)
1893
+ rows[at] = `${p.rail}\u00a7${p.fgEnd} ${rows[at].slice(PROSE_COL.length)}`;
1894
+ }
1895
+ return rows;
1726
1896
  }
1727
1897
  }
1728
- /** The notice lines — the error surface. */
1898
+ /** Graphite §7.12 — the label column of a meta row: the longest label
1899
+ * (`INTERRUPTED`) and one space, so the sentences line up with a card's
1900
+ * body (column 16). */
1901
+ const META_LABEL = 12;
1902
+ /** The labels that name an outcome, and so take its colour (§1.2). */
1903
+ const META_FAIL = new Set(["FAILED", "UNCERTAIN"]);
1904
+ /**
1905
+ * Graphite §7.12 — kiso's own sentences are META ROWS: a bold label at
1906
+ * the content edge and the sentence beside it, folded by word under
1907
+ * itself (VD-10: a notice is a sentence addressed to a human). No card
1908
+ * and no ground — they are not the machine's work. A notice with no
1909
+ * kind of its own (a command's confirmation) has no label and stays
1910
+ * whole at the content edge.
1911
+ */
1729
1912
  class ErrorLine {
1730
1913
  cell;
1731
1914
  constructor(cell) {
1732
1915
  this.cell = cell;
1733
1916
  }
1734
1917
  render(W, _ctx) {
1735
- // TUI2-R1.5 9 (VD-10): a notice is a sentence addressed to a human.
1736
- return foldWords(escapeTerminal(this.cell.text), W);
1918
+ const p = palette();
1919
+ const c = this.cell;
1920
+ // a sentence with no kind of its own stays whole at the content edge
1921
+ if (c.label === undefined)
1922
+ return foldWords(escapeTerminal(c.sentence ?? c.text), Math.max(1, W - EDGE.length)).map((r) => `${EDGE}${r}`);
1923
+ const sentence = escapeTerminal(c.sentence ?? "");
1924
+ const room = Math.max(1, W - EDGE.length - META_LABEL);
1925
+ const tone = META_FAIL.has(c.label) ? p.red : p.dim;
1926
+ const head = `${EDGE}${p.bold}${tone}${c.label.padEnd(META_LABEL - 1)}${p.reset} `;
1927
+ // a label wider than the row's room is cut like any row (invariant ①)
1928
+ if (sentence === "")
1929
+ return [cutLine(head.trimEnd(), W)];
1930
+ const folded = foldWords(sentence, room);
1931
+ // Graphite R3e (the MODE row): one word of the sentence carries the
1932
+ // weight — the mode switched to, bold, bypass in the failure colour
1933
+ const m = c.mark;
1934
+ const lit = (r) => {
1935
+ if (m === undefined || !r.includes(m.text))
1936
+ return `${p.dim}${r}${p.reset}`;
1937
+ const tone = m.tone === "fail" ? p.red : m.tone === "blue" ? p.blue : m.tone === "ok" ? p.green : m.tone === "gold" ? p.gold : "";
1938
+ // the word after the arrow (the mode switched TO), not an earlier one
1939
+ const arrow = r.indexOf(`\u2192 ${m.text}`);
1940
+ const at = arrow >= 0 ? arrow + 2 : r.indexOf(m.text);
1941
+ return `${p.dim}${r.slice(0, at)}${p.reset}${p.bold}${tone}${m.text}${p.reset}${p.dim}${r.slice(at + m.text.length)}${p.reset}`;
1942
+ };
1943
+ // Graphite R3e (owner, 2026-09-29): the MODE switch is the person's own
1944
+ // choice, so its label is GOLD — the colour of what the person says —
1945
+ // and its short `from → to` follows two spaces after it on the same
1946
+ // row, not the meta rows' twelve-column label column
1947
+ if (c.stacked === true) {
1948
+ const label = `${EDGE}${p.bold}${p.gold}${c.label}${p.reset} `;
1949
+ const lead = visibleWidth(label);
1950
+ return foldWords(sentence, Math.max(1, W - lead)).map((r, i) => cutLine(i === 0 ? `${label}${lit(r)}` : `${" ".repeat(lead)}${lit(r)}`, W));
1951
+ }
1952
+ // the tasks round: a row that names a thing (a task, a project) is ONE
1953
+ // row — what ran is cut with an ellipsis, never folded under itself
1954
+ if (c.oneRow === true) {
1955
+ const rows = [cutLine(`${head}${lit(sentence)}`, W)];
1956
+ for (const r of c.also ?? [])
1957
+ rows.push(...new ErrorLine({ text: c.text, label: r.label, sentence: r.sentence, ...(r.mark !== undefined ? { mark: r.mark } : {}), oneRow: true }).render(W, _ctx));
1958
+ return rows;
1959
+ }
1960
+ return folded.map((r, i) => (i === 0 ? cutLine(`${head}${lit(r)}`, W) : `${EDGE}${" ".repeat(META_LABEL)}${p.dim}${r}${p.reset}`));
1961
+ }
1962
+ }
1963
+ /** The last sweep (owner, 2026-10-06) — `/think` and `/last` on the
1964
+ * terminal. They printed at column 0 under `--- … ---` rules, a blank row
1965
+ * between every part; now the row says what came back (`THINKING the last
1966
+ * block · 3 lines`, `LAST CALL LIST (root) · 2 lines`) and each part
1967
+ * follows in its own look: thinking as the thinking block draws it, a
1968
+ * call's input and output under dim titles, in `ink2`, hung by two. One
1969
+ * cell, so no blank row splits it. */
1970
+ class RecallBlock {
1971
+ cell;
1972
+ constructor(cell) {
1973
+ this.cell = cell;
1974
+ }
1975
+ render(W, ctx) {
1976
+ const p = palette();
1977
+ const rows = new ErrorLine({ text: "", label: this.cell.label, sentence: this.cell.sentence, oneRow: true }).render(W, ctx);
1978
+ for (const s of this.cell.sections) {
1979
+ if (s.title !== undefined)
1980
+ rows.push(cutLine(`${EDGE}${p.dim}${s.title}${p.reset}`, W));
1981
+ if (s.style === "thinking") {
1982
+ rows.push(...new ThinkingBlock({ text: s.text, done: true }).render(W, ctx));
1983
+ continue;
1984
+ }
1985
+ const room = Math.max(1, W - EDGE.length - HANG);
1986
+ const lines = escapeTerminal(stripAnsi(s.text)).replace(/\n+$/, "").split("\n").flatMap((l) => foldLine(l, room));
1987
+ for (const l of lines)
1988
+ rows.push(`${EDGE}${" ".repeat(HANG)}${p.ink2}${l}${p.fgEnd}`);
1989
+ }
1990
+ return rows;
1991
+ }
1992
+ }
1993
+ /**
1994
+ * Graphite §7.11 — THE SEAL: the turn's line — `✦` in gold hanging in
1995
+ * the mark column, the words `dim` at the content edge. Its forms come widest first (the cold
1996
+ * cache sheds its label before anything is cut, R3g); the widest that
1997
+ * fits is drawn, and the last is cut with `…` — one row at every width.
1998
+ */
1999
+ class SealLine {
2000
+ cell;
2001
+ constructor(cell) {
2002
+ this.cell = cell;
2003
+ }
2004
+ render(W, _ctx) {
2005
+ const p = palette();
2006
+ const mark = p.goldMark === "" ? `${p.bold}\u2726${p.reset}` : `${p.goldMark}\u2726${p.fgEnd}`;
2007
+ // the mark HANGS in the mark column (§1.8), the words at the edge
2008
+ const room = Math.max(1, W - EDGE.length);
2009
+ const text = pickTier(this.cell.tiers, room);
2010
+ const fitted = visibleWidth(text) <= room ? text : `${widthCut(text, Math.max(1, room - 1))}\u2026`;
2011
+ return [cutLine(`${mark} ${p.dim}${fitted}${p.reset}`, W)];
1737
2012
  }
1738
2013
  }
1739
2014
  /** 4c — the folded history's ONE row. Dim, like every row that is about
@@ -1746,7 +2021,7 @@ class FoldRow {
1746
2021
  }
1747
2022
  render(W, _ctx) {
1748
2023
  const p = palette();
1749
- return [cutLine(` ${p.dim}${escapeTerminal(this.cell.label)}${p.reset}`, W)];
2024
+ return [cutLine(`${EDGE}${p.dim}${escapeTerminal(this.cell.label)}${p.reset}`, W)];
1750
2025
  }
1751
2026
  }
1752
2027
  /** The CLI's pre-rendered blocks (the banner, the recap, slash-command
@@ -1880,20 +2155,28 @@ export function idleHint(room, expand = null) {
1880
2155
  }
1881
2156
  export function statusLine(status, tail, W, hint, expand = null) {
1882
2157
  const p = palette();
1883
- const text = `${status}${tail === "" ? "" : ` · ${tail}`}`;
2158
+ // Graphite §8.7 (R3b): a status that waits for the person (`❯ …`, a
2159
+ // panel's own words) says so first — `❯ needs you · run paused` — the
2160
+ // `❯` gold where there is colour, the words as they were after it. The
2161
+ // window title already says `needs you` (§8.10); now the screen does.
2162
+ const waiting = status.startsWith("\u276f ");
2163
+ const said = waiting ? `\u276f needs you \u00b7 ${status.slice(2)}` : status;
2164
+ const text = `${said}${tail === "" ? "" : ` · ${tail}`}`;
2165
+ // the gold goes on the `❯` only while the cut row still starts with it
2166
+ const lead = (s) => (waiting && p.gold !== "" && s.startsWith("\u276f") ? `${p.gold}\u276f${p.fgEnd}${p.dim}${s.slice(1)}` : `${p.dim}${s}`);
1884
2167
  // W18: the hint is a parameter — the compacting row right-aligns its
1885
2168
  // "esc to cancel" (the same one-line-bounded shape as W12's delegate
1886
2169
  // row; the #16g rule still cuts the HINT first, then the status with
1887
2170
  // a "…" — never a fold).
1888
2171
  const statusW = visibleWidth(text);
1889
2172
  if (statusW > W) {
1890
- return `${p.dim}${widthCut(text, W - 1)}…${p.reset}`;
2173
+ return `${lead(widthCut(text, W - 1))}…${p.reset}`;
1891
2174
  }
1892
2175
  const hintText = hint ?? idleHint(Math.max(0, W - statusW), expand);
1893
2176
  const hintW = visibleWidth(hintText);
1894
2177
  if (hintW === 0 || statusW + hintW > W)
1895
- return `${p.dim}${text}${p.reset}`;
1896
- return `${p.dim}${text}${" ".repeat(Math.max(0, W - statusW - hintW))}${hintText}${p.reset}`;
2178
+ return `${lead(text)}${p.reset}`;
2179
+ return `${lead(text)}${" ".repeat(Math.max(0, W - statusW - hintW))}${hintText}${p.reset}`;
1897
2180
  }
1898
2181
  /**
1899
2182
  * TUI2-R3v2 ① — THE selection bar. One engine, every selection surface.
@@ -1924,6 +2207,19 @@ export function statusLine(status, tail, W, hint, expand = null) {
1924
2207
  */
1925
2208
  export function selectionBar(styled, visible, W) {
1926
2209
  const p = palette();
2210
+ // Graphite §8.2 (R3a) — on a known ground the selected row is a card's
2211
+ // head in the colour that waits for the person: its `askEdge` cell in
2212
+ // column 0, the row on `washAsk` to the right edge, and a row's `→`
2213
+ // marker (the approval, question, pick and command lists carry one in
2214
+ // column 1) drawn as a gold `›`. DECLARED REVERSAL of the reverse-video
2215
+ // bar and its `→` there; `dim` stays (it meets the floor on washAsk).
2216
+ // Off a known ground the reverse-video bar below stays — one row, so no
2217
+ // seam.
2218
+ if (slabPaints()) {
2219
+ const marked = styled.replace(/^((?:\x1b\[[0-9;]*m)*)\u2192/, `$1${p.gold}\u203a${p.fgEnd}`);
2220
+ const inner = marked.replaceAll(p.reset, `${p.reset}${p.washAsk}`);
2221
+ return `${p.askEdge} ${p.washAsk}${inner}${" ".repeat(Math.max(0, W - 1 - visible))}${p.washEnd}`;
2222
+ }
1927
2223
  // R2 (design §2.1 — nothing dim ever sits on the wash): the bar IS a
1928
2224
  // wash. A dim span inside it renders grey-on-grey — 3.91:1 on the
1929
2225
  // light ground, under the 4.5 floor — and the dim spans are exactly
@@ -1955,17 +2251,33 @@ export function selectionBar(styled, visible, W) {
1955
2251
  * columns the walls were taking.
1956
2252
  */
1957
2253
  export function boxTop(W) {
1958
- // R3: the palette's dim, not a hardcoded SGR 2 — `dim` is an absolute
1959
- // grey once the ground is known, and a rail that hardcodes the
1960
- // attribute would be the one chrome row not obeying the table.
2254
+ // Graphite §7.8: the one rule that carries colour — gold at its left
2255
+ // end, where the person's edge is, fading to the hairline.
2256
+ return fadeRule(W);
2257
+ }
2258
+ /** Graphite R3e — a read-only sheet over the input (`/status`): the band's
2259
+ * named hairline, one fact per row — its label dim in a column, its value
2260
+ * folded by word under itself — and the row that says how it closes.
2261
+ * It closes like the keys sheet, but what is typed after it is typed. */
2262
+ export function infoSheetRows(title, facts, W) {
1961
2263
  const p = palette();
1962
- return `${p.dim}${"\u2500".repeat(Math.max(0, W))}${p.reset}`;
2264
+ const labelW = Math.max(0, ...facts.map((f) => f.label.length)) + 2;
2265
+ const room = Math.max(1, W - 2 - labelW);
2266
+ const rows = [bandHeader(title, W)];
2267
+ for (const f of facts) {
2268
+ const folded = foldWords(escapeTerminal(f.value), room);
2269
+ for (const [i, line] of folded.entries())
2270
+ rows.push(` ${i === 0 ? `${p.dim}${f.label.padEnd(labelW)}${p.reset}` : " ".repeat(labelW)}${line}`);
2271
+ }
2272
+ rows.push(` ${p.dim}${SHEET_CLOSE}${p.reset}`);
2273
+ return rows.map((r) => cutLine(r, W));
1963
2274
  }
1964
- /** R2 — the same rule below. Named for its POSITION, not its shape, so
1965
- * the compositor's two call sites did not have to move. */
2275
+ /** R2 — the same rule below, in the hairline colour (§1.1). Named for its
2276
+ * POSITION, not its shape, so the compositor's two call sites did not
2277
+ * have to move. */
1966
2278
  export function boxBottom(W) {
1967
2279
  const p = palette();
1968
- return `${p.dim}${"\u2500".repeat(Math.max(0, W))}${p.reset}`;
2280
+ return `${p.line === "" ? p.dim : p.line}${"\u2500".repeat(Math.max(0, W))}${p.line === "" ? p.reset : p.fgEnd}`;
1969
2281
  }
1970
2282
  /** The terminal label + rhythm gap (the pipe path's v2c bytes — the
1971
2283
  * exact render the passthrough needs). */
@@ -1974,3 +2286,108 @@ export function terminalPipe(label, statusLineText) {
1974
2286
  }
1975
2287
  /** The pipe-path pieces the passthrough reuses (byte-identical). */
1976
2288
  export { foldThinking, foldResult, renderToolSummary, TOOL_SUMMARY_MAX };
2289
+ // ── Graphite §8.2 — what every list band shares ─────────────────────
2290
+ // Moved from the @ picker (P3) so the pick panels, which draw in this
2291
+ // package, read the same window, marks, key row and matcher.
2292
+ /**
2293
+ * The subsequence embedding, TIGHTENED — the two-pass walk every good
2294
+ * fuzzy finder uses, and the reason `@ra` emboldens the "ra" of
2295
+ * "src/range.js" rather than the r of "src" and the a of "range".
2296
+ *
2297
+ * Pass 1 walks forward and stops at the EARLIEST index that completes
2298
+ * the query — this both answers "does it match at all" and fixes the
2299
+ * right-hand edge. Pass 2 walks backward from that edge, taking the
2300
+ * LATEST position for each query character in turn, which slides every
2301
+ * matched character as far right as it can go without crossing the
2302
+ * next one. The result is the most clustered embedding that ends where
2303
+ * the earliest match ends.
2304
+ *
2305
+ * Case-insensitive: both sides are lowercased by the caller once per
2306
+ * query rather than once per character.
2307
+ *
2308
+ * Returns the ascending match indices, or null when the query is not a
2309
+ * subsequence of the path at all.
2310
+ */
2311
+ export function atEmbed(lowerPath, lowerQuery) {
2312
+ if (lowerQuery === "")
2313
+ return [];
2314
+ // pass 1 — forward, to the earliest completing index
2315
+ let qi = 0;
2316
+ let end = -1;
2317
+ for (let pi = 0; pi < lowerPath.length; pi += 1) {
2318
+ if (lowerPath[pi] === lowerQuery[qi]) {
2319
+ qi += 1;
2320
+ if (qi === lowerQuery.length) {
2321
+ end = pi;
2322
+ break;
2323
+ }
2324
+ }
2325
+ }
2326
+ if (end === -1)
2327
+ return null; // not a subsequence — no embedding exists
2328
+ // pass 2 — backward from that edge, sliding each character right
2329
+ const hit = new Array(lowerQuery.length);
2330
+ let pi = end;
2331
+ for (let j = lowerQuery.length - 1; j >= 0; j -= 1) {
2332
+ while (lowerPath[pi] !== lowerQuery[j])
2333
+ pi -= 1;
2334
+ hit[j] = pi;
2335
+ pi -= 1;
2336
+ }
2337
+ return hit;
2338
+ }
2339
+ /**
2340
+ * Graphite P2 (owner, 2026-10-03) — the band's window, shared by the file
2341
+ * picker, the command list and the session picker: EIGHT rows on a
2342
+ * terminal 30 rows or taller, five below.
2343
+ */
2344
+ export function bandVisible(height) {
2345
+ return height >= 30 ? 8 : 5;
2346
+ }
2347
+ /** The window over a list, with a scroll-off of one: while more lies past
2348
+ * an edge the cursor stays a row inside it, so the edge row that carries
2349
+ * a more-mark is never the selected one. Stateless, like bandWindow. */
2350
+ export function bandWindow(total, selected, visible) {
2351
+ const count = Math.min(total, visible);
2352
+ return { first: Math.max(0, Math.min(selected - count + 2, total - count)), count };
2353
+ }
2354
+ /** The more-mark for row `i` of a window: `↑` on its first row when the
2355
+ * list goes on above, `↓` on its last when it goes on below. */
2356
+ export function moreMark(i, first, count, total) {
2357
+ return i === first && first > 0 ? "↑" : i === first + count - 1 && first + count < total ? "↓" : null;
2358
+ }
2359
+ /** The key row that closes a band: what the keys do, then the selection's
2360
+ * place in the list at the right edge. The keys give way from the least
2361
+ * needed (the first in `keys`); the counter stays. */
2362
+ export function bandKeyRow(keys, selected, total, W) {
2363
+ const p = palette();
2364
+ const count = total === 0 ? "0/0" : `${selected + 1}/${total}`;
2365
+ let kept = [...keys];
2366
+ while (kept.length > 0 && 2 + visibleWidth(kept.join(" · ")) + 2 + count.length + 1 > W)
2367
+ kept = kept.slice(1);
2368
+ const text = kept.join(" · ");
2369
+ const gap = Math.max(1, W - 1 - 2 - visibleWidth(text) - count.length);
2370
+ return `${p.dim}${widthCut(` ${text}${" ".repeat(gap)}${count}`, Math.max(0, W))}${p.reset}`;
2371
+ }
2372
+ /** Text with the given code-unit positions in bold gold — what the person
2373
+ * typed, in the colour of what the person says — and the rest in `base`. */
2374
+ export function goldHits(text, hits, base) {
2375
+ const p = palette();
2376
+ // off a known ground there is no gold: the warn tint carries it (as
2377
+ // /resume's title does), so a selected row — bold throughout — still
2378
+ // shows which letters matched
2379
+ const gold = p.gold !== "" ? p.gold : p.warn;
2380
+ // one span per RUN of hits, not per letter: a typed prefix is a run
2381
+ let out = "";
2382
+ let inHit = null;
2383
+ let i = 0;
2384
+ for (const ch of text) {
2385
+ const hit = hits.has(i);
2386
+ if (hit !== inHit)
2387
+ out += hit ? `${p.reset}${p.bold}${gold}` : `${inHit === null ? "" : p.reset}${base}`;
2388
+ inHit = hit;
2389
+ out += ch;
2390
+ i += ch.length;
2391
+ }
2392
+ return `${out}${p.reset}`;
2393
+ }