@vincemakes/kiso-tui 0.46.2 → 0.47.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -50,20 +50,32 @@ import { MENU_ITEMS, displayWidth } from "./editor.js";
50
50
  import { leadWidth } from "./width.js"; // W23: the ONE width authority (the editor, #inputRow, and editCol share it)
51
51
  // KC3.5: the panel-slot reads come from the DISPATCHERS — one source
52
52
  // for four reads, so an ask can never render half as an approval.
53
- import { panelFrameOf, panelLeadOf, panelStatusOf } from "./ask-panel.js";
53
+ import { panelFrameOf, panelHintOf, panelStatusOf } from "./ask-panel.js";
54
54
  import { MOUSE_OFF } from "./editor.js";
55
55
  import { pickWindowOf } from "./approval-panel.js";
56
- import { atPanelRows, bandHeader } from "./at-picker.js";
56
+ import { atPanelRows, bandHeader, bandKeyRow, bandVisible, bandWindow, moreMark } from "./at-picker.js";
57
57
  // TUI2-R2 ②: the session picker's rows — the band's third occupant.
58
- import { sessionPickerRows } from "./session-picker.js";
59
- import { CAP_PREVIEW, thinkingRow, Container, MOTION_FRAMES, MdStream, bodySpacing, boxBottom, boxTop, cellComponent, gutterCut, cutLine, pendingQueueRows, statusLine, visibleWidth, breathFrame, } from "./components.js";
58
+ import { RESUME_FILTER_HINT, sessionPickerRows } from "./session-picker.js";
59
+ import { CAP_PREVIEW, thinkingRow, Container, MOTION_FRAMES, MdStream, bodySpacing, boxBottom, boxTop, cellComponent, cutLine, pendingQueueRows, selectionBar, statusLine, visibleWidth, breathFrame, widthCut, } from "./components.js";
60
60
  import { bannerLines, escapeTerminal, foldResult, foldThinking, palette, renderTerminalGap, renderToolSummary } from "./lines.js";
61
61
  import { oneRow } from "@vincemakes/kiso-tui-cells/render";
62
62
  import { displayVerb, keysSheetRows } from "./strings.js";
63
+ import { noticeMeta } from "./notice-meta.js";
64
+ import { statusBar } from "./status.js";
65
+ /** Graphite §7.8 — the drawn caret: a block in the terminal's own ink,
66
+ * reverse video on every ground (owner, 2026-09-29 — the 0.44 caret; R1c
67
+ * had painted it gold). */
68
+ const caretOn = () => "\x1b[7m";
69
+ const caretOff = () => "\x1b[27m";
63
70
  /** DC-56: does a rendered row carry any visible text once its SGR is
64
71
  * stripped? A washed pad row is spaces under a background colour — width
65
72
  * without words. */
66
- const saysSomething = (row) => row.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "").trim() !== "";
73
+ /** A row that says something: not blank, and not a card's half-row pad
74
+ * (Graphite §7.4 — a `▄`/`▀` row is the card's edge, not its head). */
75
+ const saysSomething = (row) => {
76
+ const t = row.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "").trim();
77
+ return t !== "" && !/^[\u2584\u2580]+$/.test(t);
78
+ };
67
79
  // R5 — the transcript viewer's PURE projection. The compositor supplies
68
80
  // the entries (it holds the cells); the arrangement lives there.
69
81
  import { VIEWER_GUTTER, viewerFlat, viewerHint, viewerInit, viewerMove, viewerRows, viewerScroll, viewerTitle, viewerToggle, viewerToggleAll, } from "./transcript.js";
@@ -86,9 +98,6 @@ const CHROME_ROWS = 4; // box top + input + box bottom + status — the design
86
98
  * same unbounded height the projection exists to remove; the rest are
87
99
  * COUNTED, never dropped silently. */
88
100
  const LIVE_ACT_HEADS = 3;
89
- /** R8 — the command band's window: five rows plus a counter, the same
90
- * budget the composer's own ceiling can afford above it. */
91
- const MENU_WINDOW = 5;
92
101
  export class Body {
93
102
  #opts;
94
103
  #cells = [];
@@ -230,6 +239,17 @@ export class Body {
230
239
  // the chrome state (the Dock façade)
231
240
  #status = "";
232
241
  #statusHint = null;
242
+ /** Graphite §8.9 — the status bar's facts, handed over by the CLI and
243
+ * composed at paint time (the drop order spans both sides of the row,
244
+ * and only the paint knows the width and the `ctrl+o` state). Null:
245
+ * the CLI's own status text is the row (setStatus). */
246
+ #bar = null;
247
+ /** Graphite §8.7 — the live row above the composer while a turn runs
248
+ * (working, retrying, compacting), or null. */
249
+ #live = null;
250
+ /** A short confirmation (the clipboard's) on the live row while nothing
251
+ * is live; the next keypress clears it. */
252
+ #flash = null;
233
253
  #tail = "";
234
254
  // W21: the panel's bound state — the PanelSelect slot occupant (the
235
255
  // old ApprovalPrompt's question slot retires with it): while a
@@ -716,7 +736,103 @@ export class Body {
716
736
  }
717
737
  this.#closeOpenThinking();
718
738
  this.#closeOpenText();
719
- this.#cells.push({ kind: "notice", text, done: true });
739
+ // Graphite §7.12: a meta row on the terminal; the pipe above keeps
740
+ // the text as written.
741
+ this.#cells.push({ kind: "notice", text, done: true, ...noticeMeta(text) });
742
+ this.#mark();
743
+ }
744
+ /** The tasks round (owner, 2026-10-06) — a notice whose terminal form
745
+ * is one meta row per thing it names (a task's end, a project's trust):
746
+ * each row a label (`TASK` once, `""` under it), the sentence cut to one
747
+ * row, its outcome word marked. A pipe keeps `text`, byte for byte. */
748
+ metaNotice(text, rows) {
749
+ if (!this.#isActive()) {
750
+ this.#closeOpenThinking();
751
+ this.#closeOpenText();
752
+ this.#write(`${text}\n`);
753
+ return;
754
+ }
755
+ this.#closeOpenThinking();
756
+ this.#closeOpenText();
757
+ const [first, ...also] = rows;
758
+ if (first === undefined)
759
+ return;
760
+ // ONE cell for the notice, so its rows stay together (D1 puts a blank
761
+ // between two cells)
762
+ this.#cells.push({ kind: "notice", text, done: true, label: first.label, sentence: first.sentence, ...(first.mark !== undefined ? { mark: first.mark } : {}), oneRow: true, ...(also.length > 0 ? { also } : {}) });
763
+ this.#mark();
764
+ }
765
+ /** The last sweep (owner, 2026-10-06) — what `/think` or `/last` brings
766
+ * back: on the terminal ONE cell, a meta row (`label`, `sentence`) and
767
+ * the sections under it; a pipe keeps `text`, byte for byte. */
768
+ recall(text, label, sentence, sections) {
769
+ if (!this.#isActive()) {
770
+ this.#closeOpenThinking();
771
+ this.#closeOpenText();
772
+ this.#write(`${text}\n`);
773
+ return;
774
+ }
775
+ this.#closeOpenThinking();
776
+ this.#closeOpenText();
777
+ this.#cells.push({ kind: "recall", text, label, sentence, sections, done: true });
778
+ this.#mark();
779
+ }
780
+ /** Graphite R3e (owner, 2026-09-29) — a mode switch: on the terminal
781
+ * `MODE` on a row of its own and `from → to` under it, the new tier
782
+ * bold (full access — the old bypass — in the failure colour, plan in
783
+ * blue) — no explanation:
784
+ * the picker that switched it says what each tier does. A pipe keeps
785
+ * the confirmation it always printed, byte for byte (`text`). */
786
+ modeNotice(text, from, to) {
787
+ if (!this.#isActive()) {
788
+ this.#closeOpenThinking();
789
+ this.#closeOpenText();
790
+ this.#write(`${text}\n`);
791
+ return;
792
+ }
793
+ this.#closeOpenThinking();
794
+ this.#closeOpenText();
795
+ // the main-sync round: `full access` (the old bypass) keeps the failure colour
796
+ const tone = to === "full access" || to === "bypass" ? "fail" : to === "plan" ? "blue" : "ink";
797
+ this.#cells.push({ kind: "notice", text, done: true, label: "MODE", sentence: `${from} \u2192 ${to}`, mark: { text: to, tone }, stacked: true });
798
+ this.#mark();
799
+ }
800
+ /** The main-sync round (owner, 2026-09-30) — the don't-ask switch, the
801
+ * MODE row's sibling: `DON'T ASK` gold (the person's choice) and
802
+ * `off → on` with the new state bold. It is a switch on top of the tier,
803
+ * not a tier, so it has a row of its own. A pipe keeps #203's words
804
+ * (`don't ask → on`), byte for byte. */
805
+ dontAskNotice(text, on) {
806
+ if (!this.#isActive()) {
807
+ this.#closeOpenThinking();
808
+ this.#closeOpenText();
809
+ this.#write(`${text}\n`);
810
+ return;
811
+ }
812
+ this.#closeOpenThinking();
813
+ this.#closeOpenText();
814
+ const to = on ? "on" : "off";
815
+ this.#cells.push({ kind: "notice", text, done: true, label: "DON'T ASK", sentence: `${on ? "off" : "on"} \u2192 ${to}`, mark: { text: to, tone: "ink" }, stacked: true });
816
+ this.#mark();
817
+ }
818
+ /**
819
+ * Graphite §7.11 — the turn's SEAL. On the terminal a cell of its own,
820
+ * drawn at the current width (widest tier that fits); a pipe gets
821
+ * `pipe` — today's recap line — byte for byte.
822
+ */
823
+ seal(tiers, pipe) {
824
+ if (!this.#isActive()) {
825
+ this.#closeOpenThinking();
826
+ this.#closeOpenText();
827
+ // byte for byte what `raw(pipe.split("\n"))` wrote before the
828
+ // seal had a cell of its own — the trailing newline included
829
+ for (const line of pipe.split("\n"))
830
+ this.#write(`${line}\n`);
831
+ return;
832
+ }
833
+ this.#closeOpenThinking();
834
+ this.#closeOpenText();
835
+ this.#cells.push({ kind: "seal", tiers, done: true });
720
836
  this.#mark();
721
837
  }
722
838
  /** The startup banner (W1): a LIVE cell — the tier re-derives per
@@ -756,6 +872,33 @@ export class Body {
756
872
  this.#cells.push({ kind: "raw", lines, done: true, ...(wrap === undefined ? {} : { wrap }) });
757
873
  this.#mark();
758
874
  }
875
+ /** G3 (R2d) — the person's `!!cmd`: run, shown here, never sent. Off a
876
+ * dock the command and its output are written as they always were. */
877
+ bang(command, output, isError) {
878
+ if (!this.#isActive()) {
879
+ this.#closeOpenThinking();
880
+ this.#closeOpenText();
881
+ this.#write(`$ ${escapeTerminal(command)}\n${escapeTerminal(output)}\n`);
882
+ return;
883
+ }
884
+ this.#closeOpenThinking();
885
+ this.#closeOpenText();
886
+ this.#cells.push({ kind: "bang", command, output, isError, done: true });
887
+ this.#mark();
888
+ }
889
+ /** G3 (R2d) — the input's lead: a gold `$ ` while the line starts with
890
+ * `!` (the person's shell, run by `!` / `!!`); the bound prompt
891
+ * otherwise. One line only — a pasted block that begins with `!` is
892
+ * prose (dispatch's rule). */
893
+ #composerLead() {
894
+ const st = this.#inputState();
895
+ const lines = st.lines !== undefined && st.lines.length > 0 ? st.lines : [st.line];
896
+ if (lines.length === 1 && lines[0].startsWith("!")) {
897
+ const p = palette();
898
+ return `${p.gold}$${p.gold === "" ? "" : p.fgEnd} ${this.#inputPrompt}`;
899
+ }
900
+ return this.#inputPrompt;
901
+ }
759
902
  /** The last COMPLETE thinking block, for /think. */
760
903
  lastThinking() {
761
904
  return this.#lastThinking;
@@ -929,7 +1072,7 @@ export class Body {
929
1072
  // a light terminal showed "5 folds" over five empty grey rows.
930
1073
  const first = rows.findIndex((r) => saysSomething(r));
931
1074
  const headAt = first < 0 ? 0 : first;
932
- out.push({ head: rows[headAt] ?? "", body: rows.slice(headAt + 1) });
1075
+ out.push({ head: rows[headAt] ?? "", body: rows.slice(headAt + 1).filter((r) => saysSomething(r) || r.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "").trim() === "") });
933
1076
  }
934
1077
  return out;
935
1078
  }
@@ -1246,14 +1389,28 @@ export class Body {
1246
1389
  * which time the first frame is already painted with the no-ground
1247
1390
  * palette. Rather than delay the opening to wait for an answer that
1248
1391
  * may never come, the frame is painted at once and repainted when the
1249
- * answer lands. Invalidating the held screen is exactly what a resize
1250
- * does, for exactly the same reason — every row's bytes are wrong —
1251
- * so it rides the settle the resize already owns and costs one
1252
- * repaint, once per session.
1392
+ * answer lands.
1393
+ *
1394
+ * It is NOT a resize, and must not ride one: the settle returns early
1395
+ * when the geometry did not change — which it never does here — so the
1396
+ * old path cleared the held screen and painted nothing, leaving the
1397
+ * no-ground palette up until the first keystroke. Nor is it a reprint:
1398
+ * `3J` would erase the shell's scrollback at every launch, since the
1399
+ * answer arrives at every launch. So: the committed cells' cached rows
1400
+ * are dropped (their bytes carry the old palette; their geometry does
1401
+ * not change, the palette sets no width), the held screen is voided,
1402
+ * and one frame repaints what is on screen. Rows already in the
1403
+ * terminal's scrollback keep the palette they were written in.
1253
1404
  */
1254
1405
  onGroundChange() {
1255
- this.#screen = [];
1256
- this.onResize();
1406
+ if (!this.#isActive())
1407
+ return;
1408
+ for (let i = 0; i < this.#committed; i += 1)
1409
+ this.#lineCache[i] = null;
1410
+ this.#screen = new Array(Math.max(1, this.#opts.height())).fill(NOT_PAINTED);
1411
+ this.#fullRedraw = true;
1412
+ this.#dirty = true;
1413
+ this.render();
1257
1414
  }
1258
1415
  /**
1259
1416
  * R14 / route B — THE ONE REPAINT A DRAG EARNS IS A REPRINT.
@@ -1432,6 +1589,26 @@ export class Body {
1432
1589
  setStatus(text, hint = null) {
1433
1590
  this.#status = text;
1434
1591
  this.#statusHint = hint;
1592
+ // a caller that paints its own text takes the row
1593
+ this.#bar = null;
1594
+ this.redraw();
1595
+ }
1596
+ /** Graphite §8.9 — hand the status bar its facts. */
1597
+ setBar(bar) {
1598
+ this.#bar = bar;
1599
+ this.redraw();
1600
+ }
1601
+ /** Graphite §8.7 — the live row (already composed for the width the
1602
+ * caller measured; cut here if the terminal narrowed since), or null
1603
+ * when nothing is live. */
1604
+ setLive(row) {
1605
+ this.#live = row;
1606
+ this.redraw();
1607
+ }
1608
+ /** A confirmation that is true for a moment — `copied 212 chars` — on the
1609
+ * live row, until the next keypress. It never displaces a live row. */
1610
+ flash(text) {
1611
+ this.#flash = text;
1435
1612
  this.redraw();
1436
1613
  }
1437
1614
  setTail(tail) {
@@ -1462,12 +1639,26 @@ export class Body {
1462
1639
  // block carries its own row (approval-panel.ts pushes it for the
1463
1640
  // approval and the pick, ask-panel.ts for the ask), so nothing is
1464
1641
  // lost anywhere.
1465
- if (panel !== null)
1466
- return { status: panelStatusOf(panel), hint: undefined, expand: null };
1467
- // ADR-0057: while steers wait for the run's next quiescent boundary,
1468
- // the right hint counts them — the chips carry the lines. Short on
1469
- // purpose: the running status shares the row, and a hint that does
1470
- // not fit is dropped whole.
1642
+ // Graphite P3/P4 (owner, 2026-10-04): a panel leaves the bar where it
1643
+ // is — the session's facts do not stop being true while a panel is up,
1644
+ // and what the panel is doing is said in its band. The panel's own
1645
+ // status line is the form before a bar is bound (the trust question
1646
+ // comes before the session exists): its words, and not the key ladder
1647
+ // §7.8 retired — an empty hint, so the row offers none.
1648
+ if (panel !== null && this.#bar === null)
1649
+ return { status: panelStatusOf(panel), hint: "", expand: null };
1650
+ // Graphite §8.9: the bar, when the CLI handed one over — the steers
1651
+ // are on screen in the band above the input (§8.7), so the bar does
1652
+ // not count them (the main-sync round, owner 2026-09-30: no `+N
1653
+ // steer` on a Graphite bar).
1654
+ if (this.#bar !== null) {
1655
+ const expand = this.#collapsed.length > 0 ? (this.#expandedAll ? "collapse all" : "expand all") : null;
1656
+ return { status: "", hint: undefined, expand, bar: this.#bar };
1657
+ }
1658
+ // ADR-0057: without a bar, while steers wait for the run's next
1659
+ // quiescent boundary, the right hint counts them — the rows carry the
1660
+ // lines. Short on purpose: the running status shares the row, and a
1661
+ // hint that does not fit is dropped whole.
1471
1662
  const queued = this.#queueState?.().length ?? 0;
1472
1663
  if (queued > 0)
1473
1664
  return { status: this.#status, hint: `+${queued} steer`, expand: null };
@@ -1514,8 +1705,9 @@ export class Body {
1514
1705
  this.#pickState = state;
1515
1706
  }
1516
1707
  /** Bind the pending-turn queue — the CLI's live slots (chat.ts):
1517
- * the chips render in the menu-rows family, the live caps shrink
1518
- * by their rows, and the +N queued hint rides the status row. */
1708
+ * the queued rows render in the menu-rows family and the live caps
1709
+ * shrink by their rows (Graphite §8.7). The status bar does not
1710
+ * count them; without a bar the status row's hint still does. */
1519
1711
  bindQueue(state) {
1520
1712
  this.#queueState = state;
1521
1713
  }
@@ -1532,7 +1724,8 @@ export class Body {
1532
1724
  // panel rows' edit column; leadWidth is the ONE authority).
1533
1725
  // R2: wallL is 0 — the box is retired, so the row starts at column
1534
1726
  // one and the frame's marker and this formula share the constant.
1535
- const lead = panel !== null ? panelLeadOf(panel) : this.#inputPrompt;
1727
+ // Graphite P3/P4: a panel leaves the input row to the composer
1728
+ const lead = this.#composerLead();
1536
1729
  return 1 + leadWidth(lead) + st.cursor;
1537
1730
  }
1538
1731
  /** The old dock's redraw — the editor's onRender target: mark + the
@@ -1553,6 +1746,7 @@ export class Body {
1553
1746
  return;
1554
1747
  this.#dirty = true;
1555
1748
  if (fromKey) {
1749
+ this.#flash = null;
1556
1750
  this.#scheduleInputFrame();
1557
1751
  return;
1558
1752
  }
@@ -1892,7 +2086,7 @@ export class Body {
1892
2086
  * freezes, #emitScroll is skipped, and the close repaints from
1893
2087
  * #lastSkip. */
1894
2088
  #noteOverlay() {
1895
- const sheetUp = this.#sheetState?.() === true;
2089
+ const sheetUp = (this.#sheetState?.() ?? false) !== false;
1896
2090
  const viewerUp = this.#viewer !== null;
1897
2091
  this.#overlayFrame = sheetUp || this.#sheetWasUp || viewerUp || this.#viewerWasUp;
1898
2092
  this.#sheetWasUp = sheetUp;
@@ -1913,19 +2107,21 @@ export class Body {
1913
2107
  const capped = Math.max(1, cap);
1914
2108
  if (this.#viewer !== null)
1915
2109
  return { lines: this.#viewerBand(W).slice(0, capped), panelSpan: null };
1916
- if (this.#sheetState?.() === true)
1917
- return { lines: keysSheetRows(W).slice(0, capped), panelSpan: null };
2110
+ // Graphite R3e: the keys sheet, or a sheet of the caller's rows (`/status`)
2111
+ const sheet = this.#sheetState?.() ?? false;
2112
+ if (sheet !== false)
2113
+ return { lines: (sheet === true ? keysSheetRows(W) : sheet(W)).slice(0, capped), panelSpan: null };
1918
2114
  const panel = this.#panelState?.() ?? null;
1919
2115
  if (panel !== null) {
1920
2116
  // W21: the panel's cap is exact, so the force-commit loop never
1921
2117
  // fires on it. W22: the queue band sits below the panel — the
1922
2118
  // cap shrinks by it.
1923
- const frame = panelFrameOf(panel, W, capped);
2119
+ const frame = panelFrameOf(panel, W, capped, this.#opts.height());
1924
2120
  // B (review of this round): the pick window THIS frame draws. The size
1925
2121
  // depends on the budget, so the renderer is the only producer — the
1926
2122
  // digit keys are handed this value instead of deriving a second one.
1927
2123
  const pick = panel.pick ?? null;
1928
- return { lines: frame.rows, panelSpan: frame.options, pickWin: pick === null ? null : pickWindowOf(panel.view, pick.cursor, pick.phase, capped) };
2124
+ return { lines: frame.rows, panelSpan: frame.options, pickWin: pick === null ? null : pickWindowOf(panel.view, pick, capped, this.#opts.height()) };
1929
2125
  }
1930
2126
  return { lines: this.#liveProjection(W, ctx, cap), panelSpan: null };
1931
2127
  }
@@ -2132,6 +2328,18 @@ export class Body {
2132
2328
  * occupant (the queue is dense, like the menu; each line is its
2133
2329
  * own chip with the □ gutter). */
2134
2330
  #queueRows(W, H) {
2331
+ // Graphite §8.7: the live row opens the band above the composer; the
2332
+ // queued messages sit under it, next to the input they wait on.
2333
+ const panelUp = (this.#panelState?.() ?? null) !== null;
2334
+ const p = palette();
2335
+ // a blank row above the live row keeps it off the streaming content
2336
+ // (owner, 2026-09-29: the words ran right up to `working`)
2337
+ const live = panelUp ? [] : this.#live !== null ? ["", cutLine(this.#live, W)] : this.#flash !== null ? ["", cutLine(` ${p.dim}${this.#flash}${p.reset}`, W)] : [];
2338
+ return [...live, ...this.#queuedRows(W, H - live.length)];
2339
+ }
2340
+ /** The queued messages' rows; `H` is the height they may share with
2341
+ * the content, the live row's already taken out. */
2342
+ #queuedRows(W, H) {
2135
2343
  const queued = this.#queueState?.() ?? [];
2136
2344
  if (queued.length === 0)
2137
2345
  return [];
@@ -2153,14 +2361,17 @@ export class Body {
2153
2361
  // scroll — the scrollback lost the turns entirely (finding #A8b —
2154
2362
  // the queued-flood content loss). The band keeps the first H−9
2155
2363
  // chips + one "…N more" row (≤ H−8 rows — the content keeps ≥ 4);
2156
- // the status hint's "+N queued" already carries the count, so the
2157
- // cap hides nothing the status doesn't show.
2364
+ // that row carries the count, so the cap hides nothing without
2365
+ // saying so.
2366
+ // the main-sync round (owner, 2026-09-30): a steer waits for an open
2367
+ // approval — it never answers one — and its row says so
2368
+ const waiting = this.#panelState?.()?.view.flavor === "approval" ? "approval" : "step";
2158
2369
  const keep = Math.max(1, H - 9);
2159
2370
  if (lines.length <= keep)
2160
- return pendingQueueRows(lines, W);
2371
+ return pendingQueueRows(lines, W, waiting);
2161
2372
  const p = palette();
2162
2373
  const hidden = lines.length - keep;
2163
- return [...pendingQueueRows(lines.slice(0, keep), W), `${p.dim}□ …${hidden} more${p.reset}`];
2374
+ return [...pendingQueueRows(lines.slice(0, keep), W, waiting), ` ${p.dim}\u2026${hidden} more${p.reset}`];
2164
2375
  }
2165
2376
  /**
2166
2377
  * The band ABOVE the box top. Two occupants share it — the slash
@@ -2185,10 +2396,10 @@ export class Body {
2185
2396
  // composer, redrawn with the frame.
2186
2397
  const pick = this.#pickState?.() ?? null;
2187
2398
  if (pick !== null)
2188
- return sessionPickerRows(pick, W, Date.now());
2399
+ return sessionPickerRows(pick, W, Date.now(), this.#opts.height());
2189
2400
  const at = this.#atState?.() ?? null;
2190
2401
  if (at !== null)
2191
- return atPanelRows(at, W);
2402
+ return atPanelRows(at, W, this.#opts.height());
2192
2403
  const menu = this.#menuState?.();
2193
2404
  if (menu === null || menu === undefined || menu.items.length === 0)
2194
2405
  return [];
@@ -2215,22 +2426,46 @@ export class Body {
2215
2426
  // - a description is CUT, never folded — a folded row would
2216
2427
  // break the window's height, which is the thing being bought.
2217
2428
  const items = menu.items;
2218
- const col = MENU_ITEMS.reduce((n, m) => Math.max(n, m.name.length - 1), 0);
2219
- const windowed = items.length > MENU_WINDOW;
2220
- // the window's top is derived from the selection alone (this
2221
- // method is re-entered per frame and keeps no state): centre it,
2222
- // clamped to the ends.
2223
- const top = windowed ? Math.max(0, Math.min(menu.selected - ((MENU_WINDOW - 1) >> 1), items.length - MENU_WINDOW)) : 0;
2224
- const rows = [bandHeader("commands", W)];
2225
- for (let i = top; i < Math.min(items.length, top + MENU_WINDOW); i += 1) {
2429
+ // Graphite P2 (owner, 2026-10-03): the list takes /resume's shape —
2430
+ // the count in the band, the window (eight rows from a 30-row
2431
+ // terminal, five below) with its more-marks, the typed prefix gold,
2432
+ // and one key row with the counter. The selected row keeps R3a's gold
2433
+ // `›` in column 1 (owner: kept).
2434
+ const total = menu.total ?? items.length;
2435
+ const query = menu.query ?? "";
2436
+ const col = Math.max(menu.nameCol ?? 0, ...items.map((m) => m.name.length - 1));
2437
+ const rows = [bandHeader(`commands \u00b7 ${query === "" ? `${total}` : `${items.length} of ${total} match`}`, W)];
2438
+ const { first, count } = bandWindow(items.length, menu.selected, bandVisible(this.#opts.height()));
2439
+ for (let i = first; i < first + count; i += 1) {
2226
2440
  const item = items[i];
2227
- const label = `${item.name.slice(1).padEnd(col)} ${item.desc}`;
2228
- rows.push(...(i === menu.selected ? gutterCut(`${p.bold}▸${p.reset} `, `${p.bold}${label}${p.reset}`, W) : gutterCut(" ", `${p.dim}${label}${p.reset}`, W)));
2441
+ const name = item.name.slice(1);
2442
+ const selected = i === menu.selected;
2443
+ // the prefix the person typed, gold — the filter is a prefix match
2444
+ // (off a known ground the warn tint stands in, as in the @ band)
2445
+ const typed = Math.min(query.length, name.length);
2446
+ const gold = p.gold !== "" ? p.gold : p.warn;
2447
+ const shownName = `${typed > 0 ? `${p.reset}${p.bold}${gold}${name.slice(0, typed)}${p.reset}${selected ? p.bold : ""}` : ""}${name.slice(typed)}`;
2448
+ // THE FIX (found capturing the real terminal, 2026-10-03): the
2449
+ // description is cut as TEXT, by cells, before it is styled. It was
2450
+ // cut after — by a cutter that counts escape bytes as cells — so
2451
+ // Graphite's 19-byte dim took 19 cells off every row, and a cut that
2452
+ // landed inside the reset left a bare ESC in the output.
2453
+ const room = Math.max(0, W - 2 - col - 2 - 1);
2454
+ const desc = visibleWidth(item.desc) <= room ? item.desc : room <= 1 ? "" : `${widthCut(item.desc, room - 1)}\u2026`;
2455
+ const label = `${shownName}${" ".repeat(Math.max(0, col - name.length))} ${p.reset}${p.dim}${desc}${p.reset}`;
2456
+ const width = col + 2 + visibleWidth(desc);
2457
+ if (selected) {
2458
+ // Graphite §8.2 (R3a): the selected command wears the one selected
2459
+ // row every list has — its `→` is the gold `›` in column 1, the
2460
+ // name from the content edge
2461
+ rows.push(selectionBar(`${p.bold}\u2192${label}`, width + 1, W));
2462
+ }
2463
+ else {
2464
+ const mark = moreMark(i, first, count, items.length);
2465
+ rows.push(`${mark === null ? " " : `${p.dim}${mark}${p.reset}`} ${label}`);
2466
+ }
2229
2467
  }
2230
- // the counter earns its row only when the list is CUT — over a
2231
- // list you can see all of, it says nothing the rows do not.
2232
- if (windowed)
2233
- rows.push(` ${p.dim}(${menu.selected + 1}/${items.length})${p.reset}`);
2468
+ rows.push(bandKeyRow(["\u2191\u2193 move", "\u23ce completes", "esc"], menu.selected, items.length, W));
2234
2469
  return rows;
2235
2470
  }
2236
2471
  /** ONE input row's bytes — the marker embedded at `embedAt` (the
@@ -2316,7 +2551,7 @@ export class Body {
2316
2551
  // the cursor rests past the content — an inverse space,
2317
2552
  // taken OUT of the pad (the walk capped content at W, so
2318
2553
  // the pad absorbs it and the row still totals W)
2319
- stripped0 = `${before}\x1b[7m \x1b[27m`;
2554
+ stripped0 = `${before}${caretOn()} ${caretOff()}`;
2320
2555
  cursorPad = 1;
2321
2556
  }
2322
2557
  else if (after[0] === "\x1b" || (after.codePointAt(0) >= 0xd800 && after.codePointAt(0) <= 0xdfff)) {
@@ -2327,7 +2562,7 @@ export class Body {
2327
2562
  }
2328
2563
  else {
2329
2564
  const glyph = String.fromCodePoint(after.codePointAt(0));
2330
- stripped0 = `${before}\x1b[7m${glyph}\x1b[27m${after.slice(glyph.length)}`;
2565
+ stripped0 = `${before}${caretOn()}${glyph}${caretOff()}${after.slice(glyph.length)}`;
2331
2566
  }
2332
2567
  }
2333
2568
  // R2 — the box is retired (see boxTop): the composer is two dashed
@@ -2354,11 +2589,22 @@ export class Body {
2354
2589
  // the lead — the panel's phase lead when the panel owns the row
2355
2590
  // (1-3> / the rule input's "2 Yes, don't ask again for " / the
2356
2591
  // amend "feedback (deny): "), the bound prompt otherwise
2357
- const lead = panel !== null ? panelLeadOf(panel) : this.#inputPrompt;
2592
+ const p = palette();
2593
+ // Graphite §7.8: the prompt `›` is gold (the edge of a turn); the
2594
+ // bound lead is plain text and the colour is the paint's, so a ground
2595
+ // resolved after the first frame reaches it.
2596
+ // Graphite P3/P4: a panel leaves the input row its own lead — a filter,
2597
+ // a note or an answer is typed there, and a list says its keys on its
2598
+ // key row (DECLARED REVERSAL of `1-4>`, `pick>`, `amend›`)
2599
+ const lead = this.#composerLead().replace("\u203a", `${p.gold}\u203a${p.gold === "" ? "" : p.fgEnd}`);
2358
2600
  const leadW = leadWidth(lead);
2359
2601
  // a LEGACY one-row provider (the old {line, cursor} shape) keeps
2360
2602
  // working: its single line is the composer's single row
2361
2603
  let rows = st.lines !== undefined && st.lines.length > 0 ? [...st.lines] : [st.line];
2604
+ // Graphite §7.8 — DECLARED REMOVAL (owner, 2026-09-29): the empty
2605
+ // input carries no placeholder. R1c put the key ladder there; the
2606
+ // row is kept empty — `?` lists the keys, and the empty input is
2607
+ // where later work (follow-up suggestions) will speak.
2362
2608
  let cursorRow = Math.min(st.cursorRow ?? 0, rows.length - 1);
2363
2609
  const cursorCol = st.cursorCol ?? st.cursor;
2364
2610
  // KC1 §5's N_visible, re-applied against the frame's REAL bands:
@@ -2382,6 +2628,21 @@ export class Body {
2382
2628
  if (r === cursorRow)
2383
2629
  markerCol = 1 + bytes.markerCell;
2384
2630
  }
2631
+ // Graphite P1 (owner, 2026-09-30) — DECLARED EXCEPTION to §7.8's empty
2632
+ // input: while the /resume picker is up and nothing is typed, the
2633
+ // input says what typing there does. The input IS the picker's filter,
2634
+ // so the hint names a key the person is about to press; everywhere
2635
+ // else the empty input stays empty. It sits after the drawn cursor,
2636
+ // in the row's pad, so the cursor and the columns do not move.
2637
+ // Graphite P3: the same exception for a pick that filters (/model) —
2638
+ // its input is the filter too, and its hint is the caller's words.
2639
+ const hint = panel === null ? (this.#pickState?.() != null ? RESUME_FILTER_HINT : null) : panelHintOf(panel);
2640
+ if (hint !== null && rows.length === 1 && rows[0] === "") {
2641
+ const pad = / +$/.exec(out[0]);
2642
+ const hintW = visibleWidth(hint);
2643
+ if (pad !== null && pad[0].length > hintW)
2644
+ out[0] = `${out[0].slice(0, pad.index)}${p.dim}${hint}${p.reset}${" ".repeat(pad[0].length - hintW)}`;
2645
+ }
2385
2646
  return { rows: out, markerRow: cursorRow, markerCol };
2386
2647
  }
2387
2648
  /** The full-redraw path (the first frame, the resize repaint) — CUP
@@ -2544,7 +2805,16 @@ export class Body {
2544
2805
  // stood here until R14: a resize now reprints from the model, so
2545
2806
  // there is no stale fold for a width change to re-index.
2546
2807
  const march = all.slice(skip);
2547
- for (const line of march.length > contentRows ? march.slice(march.length - contentRows) : march) {
2808
+ // The sheets round (owner, 2026-10-06): a sheet sits ON the
2809
+ // composer. When the window's top cannot come back down (R13 — a
2810
+ // taller band, the `/` list, pushed rows into the scrollback) the
2811
+ // rows a shorter sheet leaves free go ABOVE it, between the
2812
+ // conversation and the sheet — not between the sheet and the
2813
+ // composer's rail, where they read as the sheet stopping short.
2814
+ const sheetUp = (this.#sheetState?.() ?? false) !== false;
2815
+ const short = contentRows - march.length;
2816
+ const placed = sheetUp && short > 0 && liveLines.length > 0 && liveLines.length <= march.length ? [...march.slice(0, march.length - liveLines.length), ...new Array(short).fill(""), ...march.slice(march.length - liveLines.length)] : march;
2817
+ for (const line of placed.length > contentRows ? placed.slice(placed.length - contentRows) : placed) {
2548
2818
  desired[r - 1] = this.#checked(line, W);
2549
2819
  r += 1;
2550
2820
  }
@@ -2567,7 +2837,7 @@ export class Body {
2567
2837
  desired[H - 2 - inputExtra + i - 1] = this.#checked(editor.rows[i], W);
2568
2838
  desired[H - 1 - 1] = boxBottom(W);
2569
2839
  const statusRow = this.#statusSource();
2570
- desired[H - 1] = this.#checked(statusLine(statusRow.status, this.#tail, W, statusRow.hint, statusRow.expand), W);
2840
+ desired[H - 1] = this.#checked(statusRow.bar !== undefined ? statusBar(statusRow.bar, W, statusRow.expand) : statusLine(statusRow.status, this.#tail, W, statusRow.hint, statusRow.expand), W);
2571
2841
  this.#emitDiff(out, W, H, desired);
2572
2842
  // REL-0152-R1: park from where the cursor ACTUALLY is — see
2573
2843
  // #cursorRow. It used to be parked from H, which the bottom-up
@@ -2849,6 +3119,18 @@ export class Dock {
2849
3119
  setStatus(text, hint) {
2850
3120
  compositorRef?.setStatus(text, hint ?? null);
2851
3121
  }
3122
+ /** Graphite §8.9 — the status bar's facts. */
3123
+ setBar(bar) {
3124
+ compositorRef?.setBar(bar);
3125
+ }
3126
+ /** Graphite §8.7 — the live row, or null. */
3127
+ setLive(row) {
3128
+ compositorRef?.setLive(row);
3129
+ }
3130
+ /** A short confirmation on the live row, gone at the next keypress. */
3131
+ flash(text) {
3132
+ compositorRef?.flash(text);
3133
+ }
2852
3134
  setTail(tail) {
2853
3135
  compositorRef?.setTail(tail);
2854
3136
  }
@@ -2919,8 +3201,8 @@ export class Dock {
2919
3201
  }
2920
3202
  compositorRef.bindPick(state);
2921
3203
  }
2922
- /** W22: bind the pending-turn queue — the chips + the +N queued
2923
- * hint (the CLI binds it from chat(); the editor's pop keys ride
3204
+ /** W22: bind the pending-turn queue — the queued rows (the CLI
3205
+ * binds it from chat(); the editor's pop keys ride
2924
3206
  * the LineInput's own bindQueue). */
2925
3207
  bindQueue(state) {
2926
3208
  if (compositorRef === null) {
@@ -69,3 +69,31 @@ export declare function contextRows(ledger: ContextLedger): string[];
69
69
  * right thing to show is that fact and the one step that produces one.
70
70
  * Never an empty bar — an empty bar reads as "measured zero". */
71
71
  export declare function contextUnavailableRows(reason: string): string[];
72
+ /** §8.9 — the meter's cells, ONE rule for the status bar's ctx meter and
73
+ * the `/context` sheet (the sheets round, owner 2026-10-06: the two
74
+ * meters cannot disagree). Ten `▆` cells filled to the percentage SHOWN
75
+ * (`0%` is empty; from 1% at least one cell), `ink2` below the soft
76
+ * compaction tier, gold to the hard one, the failure colour past it; the
77
+ * empty cells `track`. "" off a known ground: the colours carry nothing
78
+ * there, and the percentage says it alone. `used`, `soft` and `hard`
79
+ * are shares of the window. */
80
+ export declare const METER_CELLS = 10;
81
+ export declare function meterCells(used: number, soft: number, hard: number): string;
82
+ /**
83
+ * The sheets round (owner, 2026-10-06) — `/context` on a dock is a sheet
84
+ * over the input, the shape `/status` has (§8.16): the band names the
85
+ * total (`context · 2.7k of 200k · 1%`), the bar's meter with when
86
+ * compaction happens beside it, the surfaces as a table — the label, the
87
+ * count right-aligned, the detail dim — the free remainder dim, and the
88
+ * closing row. No blank rows: the printed form spent a cell per row and
89
+ * came out double-spaced. On a pipe `/context` prints `contextRows`,
90
+ * unchanged. `tiers` are the compaction thresholds as shares of the
91
+ * window, or null when the window is not known.
92
+ */
93
+ export declare function contextSheetRows(ledger: ContextLedger, tiers: {
94
+ readonly soft: number;
95
+ readonly hard: number;
96
+ } | null, W: number): string[];
97
+ /** The sheets round — `/context` before any request, as a sheet: the band
98
+ * says there is no ledger yet, the row says what produces one. */
99
+ export declare function contextUnavailableSheetRows(reason: string, W: number): string[];