@vincemakes/kiso-tui-cells 0.46.1 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,122 @@ 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
+ const outcome = [`${word} \u00b7 ${escapeTerminal(c.reason)}`, word];
730
+ const body = toolBlockParts(c, W, ctx);
731
+ return card("fail", " ", verb, escapeTerminal(toolTargetOf(c)), outcome, body.rows, null, W, true, fold);
643
732
  }
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.
733
+ // 4c: a card settled from the durable log carries no clock at
734
+ // all — it says nothing about time rather than `?s`.
647
735
  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.
736
+ // The tasks round (owner, 2026-10-06): a BACKGROUND delegation
737
+ // returns at once, so its card settles in a tenth of a second with
738
+ // the work only just begun. It names its children — the roles on
739
+ // the head, `N in the background`, a row per child with the task id
740
+ // `/tasks` and the notice use — instead of the foreground summary it
741
+ // never writes (the card read `DELEGATE … 1 line` with no target).
742
+ const children = c.name === "delegate" && !c.isError ? backgroundChildren(c) : null;
743
+ if (children !== null) {
744
+ const p2 = palette();
745
+ const rw = Math.max(...children.map((x) => x.role.length));
746
+ const room = bodyRowRoom(W);
747
+ 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));
748
+ const outcome = `${children.length} in the background${elapsed === "" ? "" : ` \u00b7 ${elapsed}`}`;
749
+ return card("done", " ", verb, children.map((x) => x.role).join(" \u00b7 "), [outcome], slabPaints() ? rows : openBlock(rows), null, W, false);
750
+ }
657
751
  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
752
  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);
753
+ const attr = attribution(c).replace(/^ · /, "");
754
+ // VD-6: the line count is stated exactly ONCE — a meta that
755
+ // already counts lines gets no second count beside it.
756
+ // R2a: an edit's or a write's result is kiso's receipt (`edited
757
+ // <path>` and its revision), not something it made: its diff is
758
+ // the body, and the receipt's line count is no fact about it
759
+ const n = editDiffOf(c) === null ? countLines(c.resultText) : 0;
760
+ const counted = n > 0 && !/^\d+( of \d+)? lines?$/.test(rawMeta) ? `${n} line${n === 1 ? "" : "s"}` : "";
761
+ const join = (...xs) => xs.filter((x) => x !== "").join(" \u00b7 ");
762
+ // pin 4's order: the ATTRIBUTION gives way first, then the count;
763
+ // what happened and how long it took is never cut open.
764
+ // The last tier is the CORE — what happened and how long it took;
765
+ // below it the target elides (headCore), never the core.
766
+ const tiers = [join(meta, counted, elapsed, attr), join(meta, counted, elapsed), join(meta, elapsed)];
767
+ const body = toolBlockParts(c, W, ctx);
768
+ const state = c.isError ? "fail" : "done";
769
+ if (body.rows.length > 0) {
770
+ // the foot is the way back: an expanded card's, when collapsing
771
+ // would hide something again (a card whose whole body always
772
+ // shows has no way back to offer). A collapsed card's key stands
773
+ // on its cut note's row (the card round, `cutNote`).
774
+ const collapsed = { ...c, expanded: false };
775
+ const behind = c.expanded && (toolBlockParts(collapsed, W, ctx).cut || hiddenLines(collapsed, W) !== null);
776
+ const foot = behind ? COLLAPSE_ROW : null;
777
+ return card(state, " ", verb, escapeTerminal(toolTargetOf(c)), tiers, body.rows, foot, W, c.isError, fold);
752
778
  }
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);
779
+ // no body: the head row between its pads, and the key — when the
780
+ // result sits behind it (a read) — at the end of the head row
781
+ const hidden = hiddenLines(c, W);
782
+ // the key is RESERVED (TUI2-R1.5 ⑤): its words shorten only after
783
+ // the attribution and the count have given way
784
+ const keyed = hidden === null ? tiers : [...tiers.map((t) => `${t} \u00b7 ${EXPAND_ROW}`), `${tiers[tiers.length - 1]} \u00b7 ctrl+o`];
785
+ return card(state, " ", verb, escapeTerminal(toolTargetOf(c)), keyed, [], null, W, c.isError, fold);
766
786
  }
767
787
  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;
788
+ const body = toolBlockParts(c, W, ctx);
789
+ // a foot only where there is a body above it (§1.3)
790
+ 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
791
  }
773
792
  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.
793
+ // R3 (design §5.2): a running call BREATHES in its mark cell; its
794
+ // duration rides the head row's right end, where the settled
795
+ // card's outcome will stand — the settle changes the words and
796
+ // the ground, never the height (DC-46: the settle may add only
797
+ // the foot).
782
798
  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.
799
+ const el = elapsedLabel(elapsed);
800
+ const gestures = c.name === "shell" ? " \u00b7 esc stops \u00b7 alt+\u23ce redirects" : "";
801
+ // the gestures give way first (the live row names the same keys);
802
+ // `running · Ns` is the core, and the narrow ladder keeps the
803
+ // elapsed alone at the very end
804
+ const tiers = [`running \u00b7 ${el}${gestures}`, `running \u00b7 ${el}`];
805
+ const mark = `${breathFrame(ctx.spinnerI)} `;
806
+ // DC-43: with too little room for a card the call keeps its head
807
+ // row alone until it commits — the one form that fits anywhere.
807
808
  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);
809
+ if (liveRows <= 0)
810
+ return [cardHeadRow(mark, verb, liveTarget(c), tiers, W, false, false)];
811
+ const body = toolBlockParts(c, W, ctx);
812
+ return card("run", mark, verb, liveTarget(c), tiers, body.rows, c.expanded && body.rows.length > 0 ? COLLAPSE_ROW : null, W, false, fold);
853
813
  }
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);
814
+ // not started yet (queued behind an exclusive call): the same card,
815
+ // its outcome says so.
816
+ return card("run", " ", verb, liveTarget(c), ["queued"], [], null, W, false, fold);
861
817
  }
862
818
  }
863
819
  // ---- TUI2-R1 (A): the self-naming expand affordance ----
@@ -887,7 +843,7 @@ function hiddenLines(c, W) {
887
843
  if (n === 0)
888
844
  return null;
889
845
  if (c.isError)
890
- return null; // errorBody's own cut row is the affordance there
846
+ return null; // an error previews its text, and the foot carries the key
891
847
  // R13: a call that PREVIEWS carries the key on its own note row when
892
848
  // something is cut, and needs no affordance at all when nothing is.
893
849
  // A head-row suffix as well would be TUI2-R1's two affordances for
@@ -897,90 +853,6 @@ function hiddenLines(c, W) {
897
853
  return null;
898
854
  return n;
899
855
  }
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
856
  /**
985
857
  * TUI2-R1.5 ⑤ (VD-11) — approval attribution, about humans.
986
858
  *
@@ -1001,33 +873,15 @@ function attribution(c) {
1001
873
  return "";
1002
874
  return c.verdict.decision === "denied" ? " · denied" : " · approved";
1003
875
  }
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
876
  /* DECLARED REVERSAL (D-S2-1, owner-ruled 2026-09-06): `focusToken` and
1025
877
  `EXPAND_KEY` stood here — TUI2-R2 ⑤'s bright ctrl+o token on the
1026
878
  newest live card, "exactly one bright token per frame". DC-50 made
1027
879
  ctrl+o a global switch, so there was no target left for a marker to
1028
880
  name; the status row's idle hint names the switch (`idleHint`). */
1029
- /** TUI2-R1 (A) — the expanded card's key suffix: the way back. */
881
+ /** TUI2-R1 (A) — the expanded card's key: the way back. */
1030
882
  const COLLAPSE_ROW = "ctrl+o collapses";
883
+ /** Graphite §7.4 — the key on a card with rows behind it. */
884
+ const EXPAND_ROW = "ctrl+o expands";
1031
885
  /* DECLARED REVERSAL (R13, owner-ruled 2026-09-03): the W13 rollup's
1032
886
  noun table (`ROLLUP_NOUN`) and TUI2-R1 (B)'s exploration-run set
1033
887
  (`EXPLORE_NOUN`, `isExploreTool`) stood here. Both collapses retired
@@ -1113,8 +967,8 @@ export function askedBlock(resultText, seconds, W) {
1113
967
  return [];
1114
968
  const asked = parsed;
1115
969
  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);
970
+ 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);
971
+ const row = (body) => cutLine(`${EDGE}${p.dim}│${p.reset} ${body}`, W);
1118
972
  if (Array.isArray(asked.declined) && asked.declined.length > 0) {
1119
973
  // the honest decline record: WHAT went unanswered, and what the
1120
974
  // choices had been — the panel already computes both.
@@ -1159,72 +1013,68 @@ const CAP_DIFF = 12; // the approval diff: head + the named middle + tail
1159
1013
  * `└` survives as the mark that OPENS the block, once, on its first
1160
1014
  * row (see openBlock). In-block notes take the same indent,
1161
1015
  * 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. */
1016
+ * mark meaning two things (§4.1). */
1017
+ /** Graphite §7.4 — off the surface (unknown ground), a card's body sits
1018
+ * two columns under its head: the head at the content edge (2), the body
1019
+ * at 4, opened by `└` (R8a's indent, which is what says "these rows are
1020
+ * output" once nothing is painted). */
1169
1021
  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 = "└ ";
1022
+ /** Inside a painted card the body sits at the content edge, under the
1023
+ * verb — the head and what it printed line up (owner, 2026-09-29, the
1024
+ * 0.44 card's alignment). */
1025
+ const CARD_BODY = "";
1026
+ /** A card's inner right margin. */
1027
+ const CARD_RIGHT = 1;
1028
+ /** The cells a painted card has for its content: after its edge cell and
1029
+ * its mark cell, from the content edge to the right edge. */
1030
+ const cardInner = (W) => Math.max(1, W - EDGE.length);
1031
+ /** Graphite §1.8 — the model's words and its thinking begin at the
1032
+ * content edge, like every other block. */
1033
+ const PROSE_COL = EDGE;
1034
+ const THINK_COL = EDGE;
1035
+ const bodyRow = () => (slabPaints() ? CARD_BODY : BODY_ROW_FLAT);
1036
+ const noteIndent = bodyRow;
1037
+ /** The cells a body row's text has: under the target inside a card, under
1038
+ * the flat indent outside one. */
1039
+ const bodyTextWidth = (W) => Math.max(1, slabPaints() ? cardInner(W) - CARD_BODY.length - CARD_RIGHT : W - BODY_ROW_FLAT.length);
1040
+ /** The cells a whole body row has (its indent included). */
1041
+ const bodyRowRoom = (W) => Math.max(1, slabPaints() ? cardInner(W) - CARD_RIGHT : W);
1189
1042
  /**
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.
1043
+ * Graphite §7.4 / §3.1 — a card paints only where the ground is known:
1044
+ * there its surfaces are real backgrounds. On the unknown ground they
1045
+ * would be reverse video, and eight inverted output rows are a black slab
1046
+ * in the middle of the transcript, so the card keeps its content and
1047
+ * loses its surface.
1211
1048
  */
1212
1049
  function slabPaints() {
1050
+ return palette().washDone !== "" && currentGround() !== "unknown";
1051
+ }
1052
+ function cardPaint(state) {
1213
1053
  const p = palette();
1214
- return p.wash !== "" && currentGround() !== "unknown";
1054
+ if (state === "fail")
1055
+ return { bg: p.washFail, edge: p.failEdge };
1056
+ if (state === "ask")
1057
+ return { bg: p.washAsk, edge: p.askEdge };
1058
+ return { bg: p.washRun, edge: p.runEdge };
1215
1059
  }
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) {
1060
+ /** One painted card row, the full width: the edge cell in column 0, the
1061
+ * mark cell in column 1, the content from the content edge, the state's
1062
+ * ground to the right edge, padded by DISPLAY width. A reset inside the
1063
+ * content would strand the ground for the rest of the row, so every
1064
+ * reset re-opens it (the selection bar's discipline). */
1065
+ function slabRow(inner, W, paint, mark = " ") {
1221
1066
  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}`;
1067
+ const room = cardInner(W);
1068
+ const fitted = visibleWidth(inner) > room ? cutLine(inner, room) : inner;
1069
+ const body = fitted.replaceAll(p.reset, `${p.reset}${paint.bg}`);
1070
+ const pad = Math.max(0, room - visibleWidth(fitted));
1071
+ return `${paint.edge} ${paint.bg}${mark.replaceAll(p.reset, `${p.reset}${paint.bg}`)}${body}${paint.bg}${" ".repeat(pad)}${p.washEnd}`;
1072
+ }
1073
+ /** A card's pad: a whole row of its ground (§1.5), its edge cell too. */
1074
+ function padRow(paint, W) {
1075
+ const p = palette();
1076
+ return `${paint.edge} ${paint.bg}${" ".repeat(Math.max(0, W - 1))}${p.washEnd}`;
1225
1077
  }
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
1078
  /** The widest form that fits the row, or the last one — the head row's
1229
1079
  * own discipline (TUI2-R1.5 ⑤, pin 4) applied to the slab's two
1230
1080
  * metadata rows: the parts give way in a PINNED ORDER, and the part
@@ -1232,65 +1082,177 @@ function slabRow(inner, W) {
1232
1082
  function pickTier(tiers, room) {
1233
1083
  return firstFit(tiers, room) ?? tiers[tiers.length - 1];
1234
1084
  }
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. */
1085
+ /** The widest form that fits the row, or null when none does. */
1240
1086
  function firstFit(tiers, room) {
1241
1087
  for (const t of tiers)
1242
1088
  if (visibleWidth(t) <= room)
1243
1089
  return t;
1244
1090
  return null;
1245
1091
  }
1092
+ /** A metadata row inside a card's body (a cut note, the tool's own cap):
1093
+ * cut, never folded — a metadata row that wraps costs the block a row it
1094
+ * did not budget. */
1246
1095
  function noteRow(text, W, tone) {
1247
1096
  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
1097
  const open = tone === "body" ? p.washDim : p.dim;
1253
1098
  const close = tone === "body" ? p.washDimEnd : p.reset;
1254
- return [cutLine(`${open}${noteIndent()}${text}${close}`, W)];
1099
+ return [cutLine(`${open}${noteIndent()}${text}${close}`, bodyRowRoom(W))];
1100
+ }
1101
+ /** `text` elided in its MIDDLE to `room` cells — a path keeps its head
1102
+ * and its file name, which is how a reader recognises it (§7.5). */
1103
+ function elideMiddle(text, room) {
1104
+ if (visibleWidth(text) <= room)
1105
+ return text;
1106
+ if (room <= 1)
1107
+ return "\u2026".slice(0, Math.max(0, room));
1108
+ const right = Math.floor((room - 1) / 2);
1109
+ const left = room - 1 - right;
1110
+ const chars = Array.from(text);
1111
+ let tail = "";
1112
+ for (let i = chars.length - 1; i >= 0; i -= 1) {
1113
+ if (displayWidth(chars[i] + tail) > right)
1114
+ break;
1115
+ tail = chars[i] + tail;
1116
+ }
1117
+ return `${widthCut(text, left)}\u2026${tail}`;
1118
+ }
1119
+ /** §7.5 — only the outcome WORD takes colour: the first segment of the
1120
+ * outcome — `exit 0` in the success colour, a failure's word in the
1121
+ * failure colour, `needs you` in gold (the card round: it stands with
1122
+ * its `❯` now) — and the rest is `dim`. */
1123
+ function outcomeStyled(text, error) {
1124
+ const p = palette();
1125
+ if (text === "")
1126
+ return "";
1127
+ const at = text.indexOf(" \u00b7 ");
1128
+ const first = at < 0 ? text : text.slice(0, at);
1129
+ const rest = at < 0 ? "" : text.slice(at);
1130
+ const tone = error ? p.red : /^exit 0$/.test(first) ? p.green : first === "needs you" ? p.gold : "";
1131
+ return tone === "" ? `${p.dim}${text}${p.reset}` : `${tone}${first}${p.reset}${rest === "" ? "" : `${p.dim}${rest}${p.reset}`}`;
1255
1132
  }
1256
1133
  /**
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.
1134
+ * §7.4 / §7.5 — a card's head row, without the card's edge cell: the verb
1135
+ * (upper case, `dim`), the target, and the outcome right-aligned in
1136
+ * `room` cells, `mark` (one styled cell, or "") standing in front of the
1137
+ * outcome's words. It gives way in a pinned order: the outcome's tiers
1138
+ * first (the attribution, then the count), then the target elides in its
1139
+ * middle; the outcome word is never cut. The row never folds; a command
1140
+ * folds in `headRows`.
1141
+ */
1142
+ function headCore(verb, target, tiers, room, error, mark = "") {
1143
+ const p = palette();
1144
+ // the verb and ONE space, then the target (owner, 2026-09-29: the verb
1145
+ // column padded to seven left the target stranded between the verb and
1146
+ // the body under it — 0.44's `shell pwd && ls -la` reads as one line)
1147
+ const lead = `${p.dim}${verb}${p.reset} `;
1148
+ const avail = Math.max(1, room - (verb.length + 1));
1149
+ // the card round (owner, 2026-10-05): the running `●` and the waiting
1150
+ // `❯` stand where the settled outcome will, so the state is read in one
1151
+ // place and nothing sits against the verb
1152
+ const marked = (out) => `${mark === "" ? "" : `${mark} `}${outcomeStyled(out, error)}`;
1153
+ const mw = mark === "" ? 0 : 2;
1154
+ const compose = (tg, out) => `${lead}${tg}${" ".repeat(Math.max(2, avail - visibleWidth(tg) - visibleWidth(out) - mw))}${marked(out)}`;
1155
+ for (const t of tiers)
1156
+ if (visibleWidth(target) + 2 + visibleWidth(t) + mw <= avail)
1157
+ return compose(target, t);
1158
+ const last = tiers[tiers.length - 1] ?? "";
1159
+ const tRoom = avail - 2 - visibleWidth(last) - mw;
1160
+ if (tRoom >= 4)
1161
+ return compose(elideMiddle(target, tRoom), last);
1162
+ // A NARROW row: the target gives way entirely, then the verb's padding
1163
+ // and the verb, then everything but the outcome — and the outcome's
1164
+ // last word, how long it took, is the one thing kept to the end (pin 4:
1165
+ // what happened and how long it took is never cut open).
1166
+ const bareLead = `${p.dim}${verb}${p.reset} `;
1167
+ // the outcome's segments give way from the FRONT — the count, then
1168
+ // the outcome word (and the mark with it) — so how long it took, and
1169
+ // the key where there is one, are the last to go
1170
+ const segs = last.split(" \u00b7 ");
1171
+ const suffixes = segs.map((_, k) => segs.slice(k).join(" \u00b7 "));
1172
+ for (const row of [`${lead}${marked(last)}`, `${bareLead}${marked(last)}`, ...suffixes.map((x, k) => (k === 0 ? marked(x) : `${p.dim}${x}${p.reset}`))]) {
1173
+ if (visibleWidth(row) <= room)
1174
+ return row;
1175
+ }
1176
+ return cutLine(`${p.dim}${suffixes[suffixes.length - 1]}${p.reset}`, room);
1177
+ }
1178
+ /** The card round — the cells the head's first row keeps for the outcome
1179
+ * when a command folds, whatever the outcome says now: `● running · 59m
1180
+ * 59s` and `exit 127 · 59m 59s` both fit. The fold is measured against
1181
+ * this, never against the words of the moment, so a card neither
1182
+ * re-folds while it runs nor changes height when it settles (DC-46: a
1183
+ * live card never shrinks). */
1184
+ const OUTCOME_ROOM = 20;
1185
+ /** The least room a command's first row keeps; narrower, the head is one
1186
+ * row and the command elides (the narrow ladder, `headCore`). */
1187
+ const FOLD_MIN = 12;
1188
+ /** A collapsed card's command shows at most this many rows. */
1189
+ const HEAD_ROWS = 3;
1190
+ /**
1191
+ * The card round (owner, 2026-10-05) — the head's rows. A shell command
1192
+ * is code the person approves and audits, so it is never cut in its
1193
+ * middle: when it does not fit beside the outcome it folds at its spaces,
1194
+ * each further row hanging under its own first character, for at most
1195
+ * `max` rows — past that the last row ends in `…` and ctrl+o shows the
1196
+ * rest. Every other target (a path keeps its head and its file name) is
1197
+ * one row, `headCore`'s. DECLARED REVERSAL of §7.5's "the row never
1198
+ * folds" for a command.
1199
+ */
1200
+ function headRows(verb, target, tiers, room, error, mark, max) {
1201
+ const single = [headCore(verb, target, tiers, room, error, mark)];
1202
+ if (max <= 1)
1203
+ return single;
1204
+ const lead = verb.length + 1;
1205
+ const avail = room - lead;
1206
+ const last = tiers[tiers.length - 1] ?? "";
1207
+ const first = avail - 2 - Math.max(OUTCOME_ROOM, visibleWidth(last) + (mark === "" ? 0 : 2));
1208
+ if (first < FOLD_MIN || visibleWidth(target) <= first)
1209
+ return single;
1210
+ const head = widthCut(target, first);
1211
+ const space = head.lastIndexOf(" ");
1212
+ const cut = space > 0 ? space + 1 : Math.max(1, head.length);
1213
+ const rest = target.slice(cut).trimStart();
1214
+ const rows = [target.slice(0, cut).trimEnd(), ...(rest === "" ? [] : foldAtSpaces(rest, avail))];
1215
+ const shown = rows.length <= max ? rows : [...rows.slice(0, max - 1), `${widthCut(rows.slice(max - 1).join(" "), avail - 1)}\u2026`];
1216
+ return [headCore(verb, shown[0], tiers, room, error, mark), ...shown.slice(1).map((r) => `${" ".repeat(lead)}${r}`)];
1217
+ }
1218
+ /** DC-43 — the head row ALONE, its mark in the mark column: a running
1219
+ * call's form when the room left is too small for a card. `mark` is two
1220
+ * cells (the glyph and a space, or two spaces). */
1221
+ function cardHeadRow(mark, verb, target, tiers, W, _painted, error) {
1222
+ return cutLine(`${mark}${headCore(verb, target, tiers, Math.max(1, W - EDGE.length), error)}`, W);
1223
+ }
1224
+ /** The card's foot: the key, right-aligned. */
1225
+ function footRow(key, room) {
1226
+ const p = palette();
1227
+ return `${" ".repeat(Math.max(0, room - visibleWidth(key)))}${p.dim}${key}${p.reset}`;
1228
+ }
1229
+ /**
1230
+ * Graphite §7.4 — assemble a card: pad · head · body · foot · pad on the
1231
+ * state's ground, full width, its edge cell in column 0, column 1 blank;
1232
+ * or, off the surface, the same content without it — the mark in the
1233
+ * mark column, the head at the content edge, the body under it, the foot
1234
+ * dim. `mark` is two cells: the glyph and a space, or two spaces. Inside
1235
+ * a card the glyph stands in front of the outcome's words (the card
1236
+ * round: in column 1 it sat against the verb). `fold` is how many rows
1237
+ * the head's command may take (0: the target never folds).
1263
1238
  */
1264
- function slabBlock(head, body, outcome, W) {
1239
+ function card(state, mark, verb, target, tiers, body, foot, W, error, fold = 0) {
1265
1240
  if (!slabPaints()) {
1266
- const out = [head, ...body];
1267
- if (outcome !== null)
1268
- out.push(...noteRow(outcome, W, "dim"));
1241
+ const [first, ...more] = headRows(verb, target, tiers, Math.max(1, W - EDGE.length), error, "", fold);
1242
+ const out = [cutLine(`${mark}${first}`, W), ...more.map((r) => cutLine(`${EDGE}${r}`, W)), ...body];
1243
+ if (foot !== null)
1244
+ out.push(cutLine(footRow(foot, W), W));
1269
1245
  return out;
1270
1246
  }
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)];
1247
+ const paint = cardPaint(state);
1248
+ const inner = cardInner(W);
1249
+ const glyph = mark.replace(/ $/, "");
1250
+ const head = headRows(verb, target, tiers, inner - CARD_RIGHT, error, glyph.trim() === "" ? "" : glyph, fold);
1251
+ const rows = [padRow(paint, W), ...head.map((r) => slabRow(r, W, paint)), ...body.map((r) => slabRow(r, W, paint))];
1252
+ if (foot !== null)
1253
+ rows.push(slabRow(footRow(foot, inner - CARD_RIGHT), W, paint));
1254
+ rows.push(padRow(paint, W));
1255
+ return rows;
1294
1256
  }
1295
1257
  /*
1296
1258
  * RETIRED (DC-50 / R14, 2026-09-05) — `expandedCard`.
@@ -1307,7 +1269,7 @@ function slabBlock(head, body, outcome, W) {
1307
1269
  * `r13-rhythm-surface.test.ts`.
1308
1270
  */
1309
1271
  /** 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
1272
+ * the content edge, no glyph, the SAME shape a thinking paragraph takes so that
1311
1273
  * whatever arrives replaces it in place. Never committed — see the
1312
1274
  * compositor's #project for why that is what makes it allowed. */
1313
1275
  export function thinkingRow() {
@@ -1331,10 +1293,12 @@ function openBlock(rows) {
1331
1293
  return rows;
1332
1294
  // the corner REPLACES two of the four indent columns, so the text
1333
1295
  // 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)];
1296
+ 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
1297
  }
1336
1298
  const blockMemo = new WeakMap();
1337
- /** The block's body rows below the header (memoized, W9). */
1299
+ /** The block's body rows below the header (memoized, W9), and whether
1300
+ * the preview CUT anything — which is what puts the key on a card's
1301
+ * foot (§7.4). */
1338
1302
  function toolBlockParts(c, W, ctx) {
1339
1303
  const memo = blockMemo.get(c);
1340
1304
  // the SURFACE is part of the key: the same cell renders different rows
@@ -1342,80 +1306,111 @@ function toolBlockParts(c, W, ctx) {
1342
1306
  // would otherwise be served the pre-ground shape forever.
1343
1307
  const liveRows = ctx.liveWindow ?? CAP_PREVIEW;
1344
1308
  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;
1309
+ // R2a: an edit or a write shows what it changed while it runs and once
1310
+ // it has — never after a refusal (nothing changed) or an error
1311
+ const edit = !c.isError && c.reason === null && (c.state === "running" || c.state === "done") ? editDiffOf(c) : null;
1312
+ const content = c.state === "approval" ? (c.diff ?? null) : edit !== null ? edit : c.resultText;
1346
1313
  if (memo !== undefined && memo.width === W && memo.state === state && memo.content === content)
1347
1314
  return memo;
1348
1315
  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);
1316
+ const tone = slabPaints() ? "body" : "dim";
1317
+ let cut = false;
1318
+ const keyed = c.state === "done" && !c.expanded;
1319
+ const capped = (all, dir, starts = []) => {
1320
+ if (all.length <= CAP_PREVIEW)
1321
+ return all;
1322
+ cut = true;
1323
+ // R9 P2 / D4: FIVE output rows, and the note is a row of its own.
1324
+ // A shell's conclusion is at the bottom, so its note goes ABOVE the
1325
+ // tail; everything else answers at the top, so its note closes it.
1326
+ // The card round: a settled, collapsed card's key rides the note.
1327
+ return dir === "tail"
1328
+ ? [...cutNote(all.length - CAP_PREVIEW, "earlier", W, tone, keyed), ...tailWindow(all, CAP_PREVIEW, starts)]
1329
+ : [...all.slice(0, CAP_PREVIEW), ...cutNote(all.length - CAP_PREVIEW, "more", W, tone, keyed)];
1330
+ };
1331
+ const shellTail = (out, dir) => capped(out.rows, dir, out.starts);
1332
+ // a diff inside a card: its own rows, the head of them until the key
1333
+ const diffCapped = (lines, cap) => {
1334
+ const all = lines === null ? [] : diffRows(lines, W);
1335
+ if (c.expanded || all.length <= cap)
1336
+ return all;
1337
+ cut = true;
1338
+ return [...all.slice(0, cap), ...cutNote(all.length - cap, "more", W, tone, keyed)];
1339
+ };
1340
+ const rows = edit !== null
1341
+ ? diffCapped(edit.lines, edit.cap)
1342
+ : c.state === "approval" && slabPaints()
1343
+ ? diffCapped(c.diff, CAP_DIFF)
1344
+ : c.expanded
1345
+ ? // W15: the WHOLE body, no cap, no cut note (nothing is cut).
1346
+ c.state === "approval"
1347
+ ? diffBody(c.diff, W, true)
1348
+ : c.name === "delegate"
1349
+ ? c.state === "running"
1350
+ ? delegateRunning(c, W)
1351
+ : delegateSettled(c, W)
1352
+ : blockRows(c.resultText, W, tone)
1353
+ : c.state === "done"
1354
+ ? c.isError
1355
+ ? capped(errorRows(c, W, tone), "head")
1356
+ : c.name === "delegate"
1357
+ ? delegateSettled(c, W)
1358
+ : // R13 — EVERY settled call previews: a shell its tail,
1359
+ // everything else its head. read_file is the single
1360
+ // exception (E1): its result is the file, and the key
1361
+ // opens the whole thing.
1362
+ noPreview(c)
1363
+ ? []
1364
+ : shellTail(blockLines(c.resultText, W, tone), c.name === "shell" ? "tail" : "head")
1365
+ : c.state === "running"
1366
+ ? c.name === "delegate"
1367
+ ? delegateRunning(c, W)
1368
+ : // R13 E1 — the call with no preview settled has none
1369
+ // while it runs either.
1370
+ noPreview({ ...c, state: "done" })
1371
+ ? []
1372
+ : liveWindow(c, W, liveCap(c, liveRows), tone)
1373
+ : c.state === "approval"
1374
+ ? diffBody(c.diff, W)
1375
+ : [];
1376
+ // E1 governs the PREVIEW, not kiso's sentence about the result: a read
1377
+ // the TOOL capped takes one body row (`offset=201 for the rest`).
1378
+ const note = c.expanded || edit !== null ? null : toolCutNote(c.name, c.resultText);
1396
1379
  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.
1380
+ rows.push(...noteRow(note, W, "dim"));
1381
+ // R8a: `└` opens a block that has no surface; inside a card the surface
1382
+ // IS the container.
1414
1383
  const opened = slabPaints() ? rows : openBlock(rows);
1415
- const parts = { width: W, state, content, rows: opened };
1384
+ const parts = { width: W, state, content, rows: opened, cut };
1416
1385
  blockMemo.set(c, parts);
1417
1386
  return parts;
1418
1387
  }
1388
+ /** The tasks round — a background delegation's children, from what the
1389
+ * call returned (`started 2 background children: t1 explorer (session …),
1390
+ * t2 reviewer (session …). They read …`) and what it was asked (each
1391
+ * task's role and words): the task id, the role, the task's first line.
1392
+ * Null for anything else — a foreground delegation, a refusal. */
1393
+ function backgroundChildren(c) {
1394
+ const m = /^started \d+ background (?:child|children): (.*?)\. They read the workspace/s.exec(c.resultText);
1395
+ if (m === null)
1396
+ return null;
1397
+ let asked = {};
1398
+ try {
1399
+ asked = JSON.parse(c.inputFull);
1400
+ }
1401
+ catch {
1402
+ // the full input is always JSON (stringified at toolStart)
1403
+ }
1404
+ const pool = (asked.tasks ?? []).map((t) => ({ role: String(t.role ?? ""), task: oneRow(escapeTerminal(String(t.task ?? "").split("\n")[0] ?? "")), used: false }));
1405
+ const out = [];
1406
+ for (const s of m[1].matchAll(/(\S+) (\S+) \(session [^)]*\)/g)) {
1407
+ const at = pool.findIndex((t) => !t.used && t.role === s[2]);
1408
+ if (at >= 0)
1409
+ pool[at].used = true;
1410
+ out.push({ id: escapeTerminal(s[1]), role: escapeTerminal(s[2]), task: at >= 0 ? pool[at].task : "" });
1411
+ }
1412
+ return out.length === 0 ? null : out;
1413
+ }
1419
1414
  /** R13 E1 — the one settled call that previews NOTHING. A read's result
1420
1415
  * IS the file; five lines of it tell a reader less than the head row
1421
1416
  * already does, and the key opens the whole thing. (The reference
@@ -1424,96 +1419,110 @@ function toolBlockParts(c, W, ctx) {
1424
1419
  function noPreview(c) {
1425
1420
  return c.state === "done" && !c.expanded && !c.isError && c.reason === null && c.name === "read_file";
1426
1421
  }
1427
- /** The block's body rows. */
1428
- function toolBlockBody(c, W, ctx) {
1429
- return toolBlockParts(c, W, ctx).rows;
1430
- }
1422
+ /** How far a wrapped output line's continuation rows stand in. */
1423
+ const HANG = 2;
1431
1424
  /** 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. */
1425
+ * split, fold each line in the body's text width; trailing empty rows
1426
+ * (the result's final newline) drop. Inside a card the output is
1427
+ * `ink2` (§2: tool output); off the surface it is dim. */
1434
1428
  function blockRows(text, W, tone = "dim") {
1429
+ return blockLines(text, W, tone).rows;
1430
+ }
1431
+ /** `blockRows`, with which rows begin a line of output (a wrapped line's
1432
+ * continuation rows do not) — what a tail window needs to start on a
1433
+ * whole line. */
1434
+ function blockLines(text, W, tone = "dim") {
1435
1435
  const p = palette();
1436
- const textW = Math.max(1, W - visibleWidth(bodyRow()));
1436
+ const textW = bodyTextWidth(W);
1437
1437
  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 : "";
1438
+ const starts = [];
1439
+ const open = tone === "dim" ? p.dim : p.ink2;
1440
+ const close = tone === "dim" ? p.reset : p.ink2 === "" ? "" : p.fgEnd;
1444
1441
  // 0.40.0: the output's own styling is dropped whole before the escape
1442
+ // The card round (owner, 2026-10-05): a line too long for the row
1443
+ // continues HANG cells in, so one line of output reads as one.
1444
+ const hang = textW > HANG * 4 ? HANG : 0;
1445
1445
  for (const raw of escapeTerminal(stripAnsi(text)).split("\n")) {
1446
- for (const row of foldLine(raw, textW))
1447
- rows.push(`${open}${bodyRow()}${row}${close}`);
1446
+ const [first = "", ...more] = foldLine(raw, textW);
1447
+ rows.push(`${bodyRow()}${open}${first}${close}`);
1448
+ starts.push(true);
1449
+ if (more.length === 0)
1450
+ continue;
1451
+ for (const row of foldLine(more.join(""), textW - hang)) {
1452
+ rows.push(`${bodyRow()}${" ".repeat(hang)}${open}${row}${close}`);
1453
+ starts.push(false);
1454
+ }
1448
1455
  }
1449
- while (rows.length > 0 && visibleWidth(rows[rows.length - 1]) === visibleWidth(bodyRow()))
1456
+ while (rows.length > 0 && visibleWidth(rows[rows.length - 1]) === visibleWidth(bodyRow())) {
1450
1457
  rows.pop();
1451
- return rows;
1452
- }
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)];
1458
+ starts.pop();
1459
+ }
1460
+ return { rows, starts };
1488
1461
  }
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) {
1462
+ /** The preview's cut note — how much was cut and in which direction.
1463
+ * With `key` (a settled card, collapsed) the key that shows the rest
1464
+ * stands at the same row's right margin: what was cut at the left, how
1465
+ * to see it at the right (the card round, owner 2026-10-05). DECLARED
1466
+ * REVERSAL of §7.4's foot for a collapsed card — the note and the key
1467
+ * were two rows for one fact. The key gives way after the count's word,
1468
+ * the count never. */
1469
+ function cutNote(cut, word, W, tone, key = false) {
1494
1470
  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);
1471
+ const room = bodyTextWidth(W);
1472
+ if (key) {
1473
+ for (const [left, k] of [[`\u2026 ${n}`, EXPAND_ROW], [`\u2026 ${cut}`, EXPAND_ROW], [`\u2026 ${cut}`, "ctrl+o"]]) {
1474
+ const gap = room - visibleWidth(left) - visibleWidth(k);
1475
+ if (gap >= 2)
1476
+ return noteRow(`${left}${" ".repeat(gap)}${k}`, W, tone);
1477
+ }
1478
+ }
1479
+ return noteRow(pickTier([`\u2026 ${n}`, `\u2026 ${cut}`], room), W, tone);
1496
1480
  }
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");
1481
+ /** The card round — a shell's tail never opens on a blank row or in the
1482
+ * middle of a wrapped line, and keeps its height (DC-46: a live window
1483
+ * never shrinks, and the settle moves nothing). When the window's top row
1484
+ * would be either, the window starts at an earlier line's first row and
1485
+ * blank rows inside it give way to make the room, the top-most first: a
1486
+ * blank row carries no fact, and the conclusion is at the bottom. It
1487
+ * looks back at most `TAIL_REACH` rows (a bounded walk, never a scan of
1488
+ * the whole output); when no line start can be reached, the window is
1489
+ * the plain tail. The rows it hides are counted in the note. `starts`
1490
+ * marks each row that begins a line of output. */
1491
+ const TAIL_REACH = 12;
1492
+ function tailWindow(rows, cap, starts) {
1493
+ const n = rows.length;
1494
+ if (n <= cap)
1495
+ return [...rows];
1496
+ const blank = (i) => visibleWidth(rows[i]) <= visibleWidth(bodyRow());
1497
+ let blanks = 0;
1498
+ for (let i = n - cap; i < n; i += 1)
1499
+ if (blank(i))
1500
+ blanks += 1;
1501
+ // s stops at 1: the window reaches only while a row above it stays
1502
+ // cut, so the note never counts nothing but the blanks it skipped
1503
+ for (let s = n - cap; s >= Math.max(1, n - cap - TAIL_REACH); s -= 1) {
1504
+ if (s < n - cap && blank(s))
1505
+ blanks += 1;
1506
+ const extra = n - s - cap;
1507
+ if (blank(s) || starts[s] === false || blanks < extra)
1508
+ continue;
1509
+ const out = [];
1510
+ let dropped = 0;
1511
+ for (let i = s; i < n; i += 1) {
1512
+ if (dropped < extra && blank(i))
1513
+ dropped += 1;
1514
+ else
1515
+ out.push(rows[i]);
1516
+ }
1517
+ return out;
1518
+ }
1519
+ return rows.slice(n - cap);
1520
+ }
1521
+ /** The error text, uncapped: the answer is at the start, and the whole
1522
+ * of it is the body — the head row carries only the outcome word
1523
+ * (W4, W19: never hide information). */
1524
+ function errorRows(c, W, tone) {
1525
+ return blockRows(c.resultText, W, tone);
1517
1526
  }
1518
1527
  /**
1519
1528
  * DC-46 — THE RUNNING WINDOW GROWS, and nothing pads it.
@@ -1544,7 +1553,7 @@ function liveWindow(c, W, cap, tone) {
1544
1553
  // TUI2-R1.5 ④(b) (VD-4): leading empty lines in the sidecar (a
1545
1554
  // 4096-byte tail can begin on a line boundary) are skipped, so the
1546
1555
  // output starts under its own header.
1547
- const all = blockRows(c.resultText, W, tone);
1556
+ const { rows: all, starts } = blockLines(c.resultText, W, tone);
1548
1557
  const from = all.findIndex((r) => visibleWidth(r) > visibleWidth(bodyRow()));
1549
1558
  // DC-46, derived — NOTHING YET IS NO WINDOW AT ALL, so a running call
1550
1559
  // with no output is the same THREE-ROW card as a settled one with
@@ -1563,7 +1572,7 @@ function liveWindow(c, W, cap, tone) {
1563
1572
  if (rows.length <= cap)
1564
1573
  return rows;
1565
1574
  return c.name === "shell"
1566
- ? [...cutNote(rows.length - cap, "earlier", W, tone), ...rows.slice(rows.length - cap)]
1575
+ ? [...cutNote(rows.length - cap, "earlier", W, tone), ...tailWindow(rows, cap, starts.slice(from))]
1567
1576
  : [...rows.slice(0, cap), ...cutNote(rows.length - cap, "more", W, tone)];
1568
1577
  }
1569
1578
  /** DC-46 — the window's HIGH-WATER, per cell: the room a frame leaves
@@ -1600,24 +1609,31 @@ function delegateRunning(c, W) {
1600
1609
  const p = palette();
1601
1610
  const n = c.childRoles.length;
1602
1611
  const text = n === 0 ? "children running…" : `${n === 1 ? "1 child" : `${n} children`} · ${c.childRoles.join(" · ")}`;
1603
- return [oneLineRow(p, text, W)];
1612
+ return [bodyLineRow(p, text, W)];
1604
1613
  }
1605
1614
  function delegateSettled(c, W) {
1606
1615
  const p = palette();
1607
1616
  const m = /^summary: (.+)$/m.exec(c.resultText);
1608
1617
  if (m === null)
1609
1618
  return [];
1610
- return [oneLineRow(p, `${m[1]} · /last for the report`, W)];
1619
+ return [bodyLineRow(p, `${m[1]} · /last for the report`, W)];
1611
1620
  }
1612
1621
  /** ONE row at the left gutter, truncated to fit the width — never a
1613
1622
  * fold (a fold would wrap into TWO rows and break the one-line height
1614
1623
  * contract). */
1615
1624
  function oneLineRow(p, text, W) {
1616
1625
  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}`;
1626
+ if (visibleWidth(`${p.dim}\u2514 ${esc}${p.reset}`) <= W)
1627
+ return `${p.dim}\u2514 ${esc}${p.reset}`;
1628
+ const w = Math.max(1, W - 2);
1629
+ return `${p.dim}\u2514 ${esc.slice(0, w - 1)}\u2026${p.reset}`;
1630
+ }
1631
+ /** Graphite §7.4 — a card body's ONE row (the delegate's): at the body
1632
+ * column like every body row, dim, cut to its room. */
1633
+ function bodyLineRow(p, text, W) {
1634
+ const esc = escapeTerminal(text);
1635
+ const room = bodyTextWidth(W);
1636
+ return `${bodyRow()}${p.dim}${visibleWidth(esc) <= room ? esc : `${widthCut(esc, Math.max(1, room - 1))}\u2026`}${p.reset}`;
1621
1637
  }
1622
1638
  /** The approval mini-diff (W7): capped at 12 folded rows — the head +
1623
1639
  * the named middle (the renderer cut — what was cut, how to expand) +
@@ -1676,6 +1692,102 @@ export function diffBody(diff, W, expanded = false) {
1676
1692
  return [...rows.slice(0, CAP_DIFF - 1), cut(rows.length - (CAP_DIFF - 1))];
1677
1693
  return [...rows.slice(0, head), cut(rows.length - head - tail), ...rows.slice(rows.length - tail)];
1678
1694
  }
1695
+ /** Graphite §6 (R2a) — the rows a card shows for a diff: a `-` / `+` line
1696
+ * on its tint (a background, §1.5) from the content edge to the card's
1697
+ * inner margin, the marker in its colour, the changed words on a deeper
1698
+ * tint; a kept line `dim`; kiso's own notes (`···` between hunks, a miss)
1699
+ * `dim` in the marker column. A long line folds under its text with the
1700
+ * tint on every row. Off a painted card the lines keep the flat form:
1701
+ * marker and text in the line's colour, the changed words underlined. */
1702
+ function diffRows(lines, W) {
1703
+ const p = palette();
1704
+ const painted = slabPaints();
1705
+ const room = bodyRowRoom(W) - bodyRow().length;
1706
+ const textW = Math.max(1, bodyTextWidth(W) - 2);
1707
+ const out = [];
1708
+ for (const d of lines) {
1709
+ const text = escapeTerminal(d.text);
1710
+ if (d.kind === "note") {
1711
+ out.push(...noteRow(text, W, "dim"));
1712
+ continue;
1713
+ }
1714
+ if (d.kind === " ") {
1715
+ for (const r of foldLine(text, textW))
1716
+ out.push(`${bodyRow()}${p.dim} ${r}${p.reset}`);
1717
+ continue;
1718
+ }
1719
+ const del = d.kind === "-";
1720
+ const rows = foldLine(text, textW);
1721
+ // the marks index the line's own text: they hold only where the
1722
+ // escape left it as it was and the fold cut it without loss
1723
+ const marks = text === d.text && rows.join("") === text ? (d.marks ?? []) : [];
1724
+ const base = del ? p.del : p.add;
1725
+ const [on, off] = painted ? [del ? p.delWord : p.addWord, base] : [p.underline, p.underlineEnd];
1726
+ let at = 0;
1727
+ for (const [i, r] of rows.entries()) {
1728
+ let body = "";
1729
+ let k = 0;
1730
+ for (const [s0, e0] of marks) {
1731
+ const s1 = Math.max(s0, at) - at;
1732
+ const e1 = Math.min(e0, at + r.length) - at;
1733
+ if (e1 <= s1)
1734
+ continue;
1735
+ body += `${r.slice(k, s1)}${on}${r.slice(s1, e1)}${off}`;
1736
+ k = e1;
1737
+ }
1738
+ body += r.slice(k);
1739
+ at += r.length;
1740
+ const lead = i === 0 ? d.kind : " ";
1741
+ if (painted) {
1742
+ const pad = " ".repeat(Math.max(0, room - 2 - visibleWidth(r)));
1743
+ out.push(`${bodyRow()}${base}${del ? p.fail : p.ok}${lead}${p.fgEnd} ${body}${pad}`);
1744
+ }
1745
+ else {
1746
+ out.push(`${bodyRow()}${del ? p.red : p.green}${lead} ${body}${p.reset}`);
1747
+ }
1748
+ }
1749
+ }
1750
+ return out;
1751
+ }
1752
+ /** How many of a write's lines its card shows before the key. */
1753
+ const WRITE_HEAD = 5;
1754
+ const editDiffMemo = new WeakMap();
1755
+ function editDiffOf(c) {
1756
+ if (c.name !== "edit_file" && c.name !== "write_file")
1757
+ return null;
1758
+ const known = editDiffMemo.get(c);
1759
+ if (known !== undefined)
1760
+ return known;
1761
+ let input = null;
1762
+ try {
1763
+ const v = JSON.parse(c.inputFull);
1764
+ input = typeof v === "object" && v !== null && !Array.isArray(v) ? v : null;
1765
+ }
1766
+ catch {
1767
+ input = null;
1768
+ }
1769
+ let out = null;
1770
+ if (input !== null && c.name === "edit_file") {
1771
+ const hunks = hunksOf(input);
1772
+ const d = hunks === null ? null : hunksDiff(hunks);
1773
+ if (hunks !== null && d !== null && d.lines.length > 0) {
1774
+ const counts = `+${d.added} -${d.removed}`;
1775
+ out = { lines: d.lines, meta: hunks.length > 1 ? `${counts} \u00b7 ${hunks.length} hunks` : counts, cap: CAP_DIFF };
1776
+ }
1777
+ }
1778
+ else if (input !== null && typeof input.content === "string") {
1779
+ const content = input.content.replace(/\n$/, "");
1780
+ const all = content === "" ? [] : content.split("\n");
1781
+ // a write that cites "absent" creates the file; any other rewrites
1782
+ // one whose old text is not in the log, so its lines are shown as
1783
+ // they now read, not as additions
1784
+ const created = input.expectedRevision === "absent";
1785
+ const n = `${all.length} line${all.length === 1 ? "" : "s"}`;
1786
+ out = { lines: all.map((text) => ({ kind: created ? "+" : " ", text })), meta: created ? `new file \u00b7 ${n}` : `rewrote \u00b7 ${n}`, cap: WRITE_HEAD };
1787
+ }
1788
+ editDiffMemo.set(c, out);
1789
+ return out;
1790
+ }
1679
1791
  /** The TOOL's OWN truncation note (W10) — a different fact from the
1680
1792
  * renderer's cut: the tools truncate and append a continuation note
1681
1793
  * (packages/tools-node/src/index.ts — read_file's "call again with
@@ -1722,18 +1834,133 @@ class MarkdownBlock {
1722
1834
  // holds by construction. A block's own leading blank (its `gap`)
1723
1835
  // stays EMPTY: an indented blank row is trailing whitespace, and
1724
1836
  // §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}`));
1837
+ // Graphite §1.8: at the content edge, 92 columns at most (§7.15).
1838
+ const rows = renderBlock(this.cell.block, proseRoom(W)).map((r) => (r === "" ? r : `${PROSE_COL}${r}`));
1839
+ // Graphite §5 (R2b): a `##` heading carries `§` in the mark column
1840
+ // (`rail`), outside the block, the way the prototype hangs it
1841
+ const p = palette();
1842
+ if (headingLevel(this.cell.block) === 2 && p.rail !== "") {
1843
+ const at = rows.findIndex((r) => r !== "");
1844
+ if (at >= 0)
1845
+ rows[at] = `${p.rail}\u00a7${p.fgEnd} ${rows[at].slice(PROSE_COL.length)}`;
1846
+ }
1847
+ return rows;
1726
1848
  }
1727
1849
  }
1728
- /** The notice lines — the error surface. */
1850
+ /** Graphite §7.12 — the label column of a meta row: the longest label
1851
+ * (`INTERRUPTED`) and one space, so the sentences line up with a card's
1852
+ * body (column 16). */
1853
+ const META_LABEL = 12;
1854
+ /** The labels that name an outcome, and so take its colour (§1.2). */
1855
+ const META_FAIL = new Set(["FAILED", "UNCERTAIN"]);
1856
+ /**
1857
+ * Graphite §7.12 — kiso's own sentences are META ROWS: a bold label at
1858
+ * the content edge and the sentence beside it, folded by word under
1859
+ * itself (VD-10: a notice is a sentence addressed to a human). No card
1860
+ * and no ground — they are not the machine's work. A notice with no
1861
+ * kind of its own (a command's confirmation) has no label and stays
1862
+ * whole at the content edge.
1863
+ */
1729
1864
  class ErrorLine {
1730
1865
  cell;
1731
1866
  constructor(cell) {
1732
1867
  this.cell = cell;
1733
1868
  }
1734
1869
  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);
1870
+ const p = palette();
1871
+ const c = this.cell;
1872
+ // a sentence with no kind of its own stays whole at the content edge
1873
+ if (c.label === undefined)
1874
+ return foldWords(escapeTerminal(c.sentence ?? c.text), Math.max(1, W - EDGE.length)).map((r) => `${EDGE}${r}`);
1875
+ const sentence = escapeTerminal(c.sentence ?? "");
1876
+ const room = Math.max(1, W - EDGE.length - META_LABEL);
1877
+ const tone = META_FAIL.has(c.label) ? p.red : p.dim;
1878
+ const head = `${EDGE}${p.bold}${tone}${c.label.padEnd(META_LABEL - 1)}${p.reset} `;
1879
+ // a label wider than the row's room is cut like any row (invariant ①)
1880
+ if (sentence === "")
1881
+ return [cutLine(head.trimEnd(), W)];
1882
+ const folded = foldWords(sentence, room);
1883
+ // Graphite R3e (the MODE row): one word of the sentence carries the
1884
+ // weight — the mode switched to, bold, bypass in the failure colour
1885
+ const m = c.mark;
1886
+ const lit = (r) => {
1887
+ if (m === undefined || !r.includes(m.text))
1888
+ return `${p.dim}${r}${p.reset}`;
1889
+ const tone = m.tone === "fail" ? p.red : m.tone === "blue" ? p.blue : m.tone === "ok" ? p.green : m.tone === "gold" ? p.gold : "";
1890
+ // the word after the arrow (the mode switched TO), not an earlier one
1891
+ const arrow = r.indexOf(`\u2192 ${m.text}`);
1892
+ const at = arrow >= 0 ? arrow + 2 : r.indexOf(m.text);
1893
+ 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}`;
1894
+ };
1895
+ // Graphite R3e (owner, 2026-09-29): the MODE switch is the person's own
1896
+ // choice, so its label is GOLD — the colour of what the person says —
1897
+ // and its short `from → to` follows two spaces after it on the same
1898
+ // row, not the meta rows' twelve-column label column
1899
+ if (c.stacked === true) {
1900
+ const label = `${EDGE}${p.bold}${p.gold}${c.label}${p.reset} `;
1901
+ const lead = visibleWidth(label);
1902
+ return foldWords(sentence, Math.max(1, W - lead)).map((r, i) => cutLine(i === 0 ? `${label}${lit(r)}` : `${" ".repeat(lead)}${lit(r)}`, W));
1903
+ }
1904
+ // the tasks round: a row that names a thing (a task, a project) is ONE
1905
+ // row — what ran is cut with an ellipsis, never folded under itself
1906
+ if (c.oneRow === true) {
1907
+ const rows = [cutLine(`${head}${lit(sentence)}`, W)];
1908
+ for (const r of c.also ?? [])
1909
+ rows.push(...new ErrorLine({ text: c.text, label: r.label, sentence: r.sentence, ...(r.mark !== undefined ? { mark: r.mark } : {}), oneRow: true }).render(W, _ctx));
1910
+ return rows;
1911
+ }
1912
+ return folded.map((r, i) => (i === 0 ? cutLine(`${head}${lit(r)}`, W) : `${EDGE}${" ".repeat(META_LABEL)}${p.dim}${r}${p.reset}`));
1913
+ }
1914
+ }
1915
+ /** The last sweep (owner, 2026-10-06) — `/think` and `/last` on the
1916
+ * terminal. They printed at column 0 under `--- … ---` rules, a blank row
1917
+ * between every part; now the row says what came back (`THINKING the last
1918
+ * block · 3 lines`, `LAST CALL LIST (root) · 2 lines`) and each part
1919
+ * follows in its own look: thinking as the thinking block draws it, a
1920
+ * call's input and output under dim titles, in `ink2`, hung by two. One
1921
+ * cell, so no blank row splits it. */
1922
+ class RecallBlock {
1923
+ cell;
1924
+ constructor(cell) {
1925
+ this.cell = cell;
1926
+ }
1927
+ render(W, ctx) {
1928
+ const p = palette();
1929
+ const rows = new ErrorLine({ text: "", label: this.cell.label, sentence: this.cell.sentence, oneRow: true }).render(W, ctx);
1930
+ for (const s of this.cell.sections) {
1931
+ if (s.title !== undefined)
1932
+ rows.push(cutLine(`${EDGE}${p.dim}${s.title}${p.reset}`, W));
1933
+ if (s.style === "thinking") {
1934
+ rows.push(...new ThinkingBlock({ text: s.text, done: true }).render(W, ctx));
1935
+ continue;
1936
+ }
1937
+ const room = Math.max(1, W - EDGE.length - HANG);
1938
+ const lines = escapeTerminal(stripAnsi(s.text)).replace(/\n+$/, "").split("\n").flatMap((l) => foldLine(l, room));
1939
+ for (const l of lines)
1940
+ rows.push(`${EDGE}${" ".repeat(HANG)}${p.ink2}${l}${p.fgEnd}`);
1941
+ }
1942
+ return rows;
1943
+ }
1944
+ }
1945
+ /**
1946
+ * Graphite §7.11 — THE SEAL: the turn's line — `✦` in gold hanging in
1947
+ * the mark column, the words `dim` at the content edge. Its forms come widest first (the cold
1948
+ * cache sheds its label before anything is cut, R3g); the widest that
1949
+ * fits is drawn, and the last is cut with `…` — one row at every width.
1950
+ */
1951
+ class SealLine {
1952
+ cell;
1953
+ constructor(cell) {
1954
+ this.cell = cell;
1955
+ }
1956
+ render(W, _ctx) {
1957
+ const p = palette();
1958
+ const mark = p.goldMark === "" ? `${p.bold}\u2726${p.reset}` : `${p.goldMark}\u2726${p.fgEnd}`;
1959
+ // the mark HANGS in the mark column (§1.8), the words at the edge
1960
+ const room = Math.max(1, W - EDGE.length);
1961
+ const text = pickTier(this.cell.tiers, room);
1962
+ const fitted = visibleWidth(text) <= room ? text : `${widthCut(text, Math.max(1, room - 1))}\u2026`;
1963
+ return [cutLine(`${mark} ${p.dim}${fitted}${p.reset}`, W)];
1737
1964
  }
1738
1965
  }
1739
1966
  /** 4c — the folded history's ONE row. Dim, like every row that is about
@@ -1746,7 +1973,7 @@ class FoldRow {
1746
1973
  }
1747
1974
  render(W, _ctx) {
1748
1975
  const p = palette();
1749
- return [cutLine(` ${p.dim}${escapeTerminal(this.cell.label)}${p.reset}`, W)];
1976
+ return [cutLine(`${EDGE}${p.dim}${escapeTerminal(this.cell.label)}${p.reset}`, W)];
1750
1977
  }
1751
1978
  }
1752
1979
  /** The CLI's pre-rendered blocks (the banner, the recap, slash-command
@@ -1880,20 +2107,28 @@ export function idleHint(room, expand = null) {
1880
2107
  }
1881
2108
  export function statusLine(status, tail, W, hint, expand = null) {
1882
2109
  const p = palette();
1883
- const text = `${status}${tail === "" ? "" : ` · ${tail}`}`;
2110
+ // Graphite §8.7 (R3b): a status that waits for the person (`❯ …`, a
2111
+ // panel's own words) says so first — `❯ needs you · run paused` — the
2112
+ // `❯` gold where there is colour, the words as they were after it. The
2113
+ // window title already says `needs you` (§8.10); now the screen does.
2114
+ const waiting = status.startsWith("\u276f ");
2115
+ const said = waiting ? `\u276f needs you \u00b7 ${status.slice(2)}` : status;
2116
+ const text = `${said}${tail === "" ? "" : ` · ${tail}`}`;
2117
+ // the gold goes on the `❯` only while the cut row still starts with it
2118
+ const lead = (s) => (waiting && p.gold !== "" && s.startsWith("\u276f") ? `${p.gold}\u276f${p.fgEnd}${p.dim}${s.slice(1)}` : `${p.dim}${s}`);
1884
2119
  // W18: the hint is a parameter — the compacting row right-aligns its
1885
2120
  // "esc to cancel" (the same one-line-bounded shape as W12's delegate
1886
2121
  // row; the #16g rule still cuts the HINT first, then the status with
1887
2122
  // a "…" — never a fold).
1888
2123
  const statusW = visibleWidth(text);
1889
2124
  if (statusW > W) {
1890
- return `${p.dim}${widthCut(text, W - 1)}…${p.reset}`;
2125
+ return `${lead(widthCut(text, W - 1))}…${p.reset}`;
1891
2126
  }
1892
2127
  const hintText = hint ?? idleHint(Math.max(0, W - statusW), expand);
1893
2128
  const hintW = visibleWidth(hintText);
1894
2129
  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}`;
2130
+ return `${lead(text)}${p.reset}`;
2131
+ return `${lead(text)}${" ".repeat(Math.max(0, W - statusW - hintW))}${hintText}${p.reset}`;
1897
2132
  }
1898
2133
  /**
1899
2134
  * TUI2-R3v2 ① — THE selection bar. One engine, every selection surface.
@@ -1924,6 +2159,19 @@ export function statusLine(status, tail, W, hint, expand = null) {
1924
2159
  */
1925
2160
  export function selectionBar(styled, visible, W) {
1926
2161
  const p = palette();
2162
+ // Graphite §8.2 (R3a) — on a known ground the selected row is a card's
2163
+ // head in the colour that waits for the person: its `askEdge` cell in
2164
+ // column 0, the row on `washAsk` to the right edge, and a row's `→`
2165
+ // marker (the approval, question, pick and command lists carry one in
2166
+ // column 1) drawn as a gold `›`. DECLARED REVERSAL of the reverse-video
2167
+ // bar and its `→` there; `dim` stays (it meets the floor on washAsk).
2168
+ // Off a known ground the reverse-video bar below stays — one row, so no
2169
+ // seam.
2170
+ if (slabPaints()) {
2171
+ const marked = styled.replace(/^((?:\x1b\[[0-9;]*m)*)\u2192/, `$1${p.gold}\u203a${p.fgEnd}`);
2172
+ const inner = marked.replaceAll(p.reset, `${p.reset}${p.washAsk}`);
2173
+ return `${p.askEdge} ${p.washAsk}${inner}${" ".repeat(Math.max(0, W - 1 - visible))}${p.washEnd}`;
2174
+ }
1927
2175
  // R2 (design §2.1 — nothing dim ever sits on the wash): the bar IS a
1928
2176
  // wash. A dim span inside it renders grey-on-grey — 3.91:1 on the
1929
2177
  // light ground, under the 4.5 floor — and the dim spans are exactly
@@ -1955,17 +2203,33 @@ export function selectionBar(styled, visible, W) {
1955
2203
  * columns the walls were taking.
1956
2204
  */
1957
2205
  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.
2206
+ // Graphite §7.8: the one rule that carries colour — gold at its left
2207
+ // end, where the person's edge is, fading to the hairline.
2208
+ return fadeRule(W);
2209
+ }
2210
+ /** Graphite R3e — a read-only sheet over the input (`/status`): the band's
2211
+ * named hairline, one fact per row — its label dim in a column, its value
2212
+ * folded by word under itself — and the row that says how it closes.
2213
+ * It closes like the keys sheet, but what is typed after it is typed. */
2214
+ export function infoSheetRows(title, facts, W) {
1961
2215
  const p = palette();
1962
- return `${p.dim}${"\u2500".repeat(Math.max(0, W))}${p.reset}`;
2216
+ const labelW = Math.max(0, ...facts.map((f) => f.label.length)) + 2;
2217
+ const room = Math.max(1, W - 2 - labelW);
2218
+ const rows = [bandHeader(title, W)];
2219
+ for (const f of facts) {
2220
+ const folded = foldWords(escapeTerminal(f.value), room);
2221
+ for (const [i, line] of folded.entries())
2222
+ rows.push(` ${i === 0 ? `${p.dim}${f.label.padEnd(labelW)}${p.reset}` : " ".repeat(labelW)}${line}`);
2223
+ }
2224
+ rows.push(` ${p.dim}${SHEET_CLOSE}${p.reset}`);
2225
+ return rows.map((r) => cutLine(r, W));
1963
2226
  }
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. */
2227
+ /** R2 — the same rule below, in the hairline colour (§1.1). Named for its
2228
+ * POSITION, not its shape, so the compositor's two call sites did not
2229
+ * have to move. */
1966
2230
  export function boxBottom(W) {
1967
2231
  const p = palette();
1968
- return `${p.dim}${"\u2500".repeat(Math.max(0, W))}${p.reset}`;
2232
+ return `${p.line === "" ? p.dim : p.line}${"\u2500".repeat(Math.max(0, W))}${p.line === "" ? p.reset : p.fgEnd}`;
1969
2233
  }
1970
2234
  /** The terminal label + rhythm gap (the pipe path's v2c bytes — the
1971
2235
  * exact render the passthrough needs). */
@@ -1974,3 +2238,108 @@ export function terminalPipe(label, statusLineText) {
1974
2238
  }
1975
2239
  /** The pipe-path pieces the passthrough reuses (byte-identical). */
1976
2240
  export { foldThinking, foldResult, renderToolSummary, TOOL_SUMMARY_MAX };
2241
+ // ── Graphite §8.2 — what every list band shares ─────────────────────
2242
+ // Moved from the @ picker (P3) so the pick panels, which draw in this
2243
+ // package, read the same window, marks, key row and matcher.
2244
+ /**
2245
+ * The subsequence embedding, TIGHTENED — the two-pass walk every good
2246
+ * fuzzy finder uses, and the reason `@ra` emboldens the "ra" of
2247
+ * "src/range.js" rather than the r of "src" and the a of "range".
2248
+ *
2249
+ * Pass 1 walks forward and stops at the EARLIEST index that completes
2250
+ * the query — this both answers "does it match at all" and fixes the
2251
+ * right-hand edge. Pass 2 walks backward from that edge, taking the
2252
+ * LATEST position for each query character in turn, which slides every
2253
+ * matched character as far right as it can go without crossing the
2254
+ * next one. The result is the most clustered embedding that ends where
2255
+ * the earliest match ends.
2256
+ *
2257
+ * Case-insensitive: both sides are lowercased by the caller once per
2258
+ * query rather than once per character.
2259
+ *
2260
+ * Returns the ascending match indices, or null when the query is not a
2261
+ * subsequence of the path at all.
2262
+ */
2263
+ export function atEmbed(lowerPath, lowerQuery) {
2264
+ if (lowerQuery === "")
2265
+ return [];
2266
+ // pass 1 — forward, to the earliest completing index
2267
+ let qi = 0;
2268
+ let end = -1;
2269
+ for (let pi = 0; pi < lowerPath.length; pi += 1) {
2270
+ if (lowerPath[pi] === lowerQuery[qi]) {
2271
+ qi += 1;
2272
+ if (qi === lowerQuery.length) {
2273
+ end = pi;
2274
+ break;
2275
+ }
2276
+ }
2277
+ }
2278
+ if (end === -1)
2279
+ return null; // not a subsequence — no embedding exists
2280
+ // pass 2 — backward from that edge, sliding each character right
2281
+ const hit = new Array(lowerQuery.length);
2282
+ let pi = end;
2283
+ for (let j = lowerQuery.length - 1; j >= 0; j -= 1) {
2284
+ while (lowerPath[pi] !== lowerQuery[j])
2285
+ pi -= 1;
2286
+ hit[j] = pi;
2287
+ pi -= 1;
2288
+ }
2289
+ return hit;
2290
+ }
2291
+ /**
2292
+ * Graphite P2 (owner, 2026-10-03) — the band's window, shared by the file
2293
+ * picker, the command list and the session picker: EIGHT rows on a
2294
+ * terminal 30 rows or taller, five below.
2295
+ */
2296
+ export function bandVisible(height) {
2297
+ return height >= 30 ? 8 : 5;
2298
+ }
2299
+ /** The window over a list, with a scroll-off of one: while more lies past
2300
+ * an edge the cursor stays a row inside it, so the edge row that carries
2301
+ * a more-mark is never the selected one. Stateless, like bandWindow. */
2302
+ export function bandWindow(total, selected, visible) {
2303
+ const count = Math.min(total, visible);
2304
+ return { first: Math.max(0, Math.min(selected - count + 2, total - count)), count };
2305
+ }
2306
+ /** The more-mark for row `i` of a window: `↑` on its first row when the
2307
+ * list goes on above, `↓` on its last when it goes on below. */
2308
+ export function moreMark(i, first, count, total) {
2309
+ return i === first && first > 0 ? "↑" : i === first + count - 1 && first + count < total ? "↓" : null;
2310
+ }
2311
+ /** The key row that closes a band: what the keys do, then the selection's
2312
+ * place in the list at the right edge. The keys give way from the least
2313
+ * needed (the first in `keys`); the counter stays. */
2314
+ export function bandKeyRow(keys, selected, total, W) {
2315
+ const p = palette();
2316
+ const count = total === 0 ? "0/0" : `${selected + 1}/${total}`;
2317
+ let kept = [...keys];
2318
+ while (kept.length > 0 && 2 + visibleWidth(kept.join(" · ")) + 2 + count.length + 1 > W)
2319
+ kept = kept.slice(1);
2320
+ const text = kept.join(" · ");
2321
+ const gap = Math.max(1, W - 1 - 2 - visibleWidth(text) - count.length);
2322
+ return `${p.dim}${widthCut(` ${text}${" ".repeat(gap)}${count}`, Math.max(0, W))}${p.reset}`;
2323
+ }
2324
+ /** Text with the given code-unit positions in bold gold — what the person
2325
+ * typed, in the colour of what the person says — and the rest in `base`. */
2326
+ export function goldHits(text, hits, base) {
2327
+ const p = palette();
2328
+ // off a known ground there is no gold: the warn tint carries it (as
2329
+ // /resume's title does), so a selected row — bold throughout — still
2330
+ // shows which letters matched
2331
+ const gold = p.gold !== "" ? p.gold : p.warn;
2332
+ // one span per RUN of hits, not per letter: a typed prefix is a run
2333
+ let out = "";
2334
+ let inHit = null;
2335
+ let i = 0;
2336
+ for (const ch of text) {
2337
+ const hit = hits.has(i);
2338
+ if (hit !== inHit)
2339
+ out += hit ? `${p.reset}${p.bold}${gold}` : `${inHit === null ? "" : p.reset}${base}`;
2340
+ inHit = hit;
2341
+ out += ch;
2342
+ i += ch.length;
2343
+ }
2344
+ return `${out}${p.reset}`;
2345
+ }