@vincemakes/kiso-tui 0.7.0 → 0.9.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.
@@ -45,10 +45,13 @@
45
45
  import { truncateDiff } from "./diff.js";
46
46
  import { displayWidth } from "./editor.js";
47
47
  import { leadWidth } from "./width.js"; // W23: the ONE width authority (the editor, #inputRow, and editCol share it)
48
- import { panelAffordance, panelBlockRows, panelLead, panelStatus } from "./approval-panel.js";
49
- import { atPanelRows } from "./at-picker.js";
50
- import { Container, ROLLUP_NOUN, SPINNER, bodySpacing, boxBottom, boxTop, cellComponent, foldLine, pendingQueueRows, statusLine, turnFold, visibleWidth, } from "./components.js";
48
+ // KC3.5: the panel-slot reads come from the DISPATCHERS — one source
49
+ // for four reads, so an ask can never render half as an approval.
50
+ import { panelAffordanceOf, panelLeadOf, panelRowsOf, panelStatusOf } from "./ask-panel.js";
51
+ import { atPanelRows, bandHeader } from "./at-picker.js";
52
+ import { Container, ROLLUP_NOUN, SPINNER, bodySpacing, boxBottom, boxTop, cellComponent, exploreCounts, exploreRows, foldLine, isExploreTool, pendingQueueRows, statusLine, turnFold, visibleWidth, } from "./components.js";
51
53
  import { bannerLines, escapeTerminal, foldResult, foldThinking, palette, renderTerminalGap, renderToolSummary, toolTarget } from "./render.js";
54
+ import { keysSheetRows } from "./strings.js";
52
55
  /** The cursor marker — an APC private sequence the focus component
53
56
  * embeds at the edit position; the compositor strips it and moves
54
57
  * relatively (it never reaches the terminal). */
@@ -116,6 +119,15 @@ export class Body {
116
119
  // panel is up it replaces the live region, owns the input lead, and
117
120
  // derives the status row (the CLI's painting status yields).
118
121
  #panelState = null;
122
+ /** TUI2-R1 (D): the keys sheet's slot read — the editor's boolean.
123
+ * Unbound, the sheet cannot render and every frame is byte-identical
124
+ * to before the round. */
125
+ #sheetState = null;
126
+ /** TUI2-R1.5 7(a): the sheet's previous up/down state — a transition
127
+ * in either direction takes the full-redraw path. */
128
+ #sheetWasUp = false;
129
+ /** This frame is an overlay open or close — it must not scroll. */
130
+ #overlayFrame = false;
119
131
  #inputState = () => ({ line: "", cursor: 0 });
120
132
  #inputPrompt = "";
121
133
  #menuState = null;
@@ -151,6 +163,7 @@ export class Body {
151
163
  if (dockBindings.at !== null)
152
164
  this.#atState = dockBindings.at;
153
165
  this.#panelState = dockBindings.panel;
166
+ this.#sheetState = dockBindings.sheet;
154
167
  if (dockBindings.queue !== null)
155
168
  this.#queueState = dockBindings.queue;
156
169
  }
@@ -210,6 +223,19 @@ export class Body {
210
223
  this.#write(`→ ${escapeTerminal(name)}(${escapeTerminal(JSON.stringify(input).slice(0, 200))})\n`);
211
224
  return;
212
225
  }
226
+ // TUI2-R1.5 ① (VD-1): the tool's start CLOSES an open text block —
227
+ // the inactive path above has always done this; the active path
228
+ // forgot, and the consequence was structural. textAppend only ever
229
+ // grows the LAST cell, so a text block with a tool cell after it can
230
+ // never receive another byte: it is finished in fact while its
231
+ // `done` flag says otherwise. The commit loop takes leading DONE
232
+ // cells, so that one stale flag parked the whole rest of the turn
233
+ // behind it — every tool cell then reached the screen through the
234
+ // FORCE-commit path, which by design bypasses the fold-hold. That is
235
+ // why the walkthrough saw nine individual rows: not a fold that
236
+ // declined to form, a fold that was never consulted.
237
+ this.#closeOpenThinking();
238
+ this.#closeOpenText();
213
239
  // W12: the cell carries the delegate's child roles from the FULL
214
240
  // input — the display summary is sliced at 60 chars (unparseable);
215
241
  // the roles are the only running-state data the parent holds (there
@@ -280,6 +306,30 @@ export class Body {
280
306
  }
281
307
  this.#mark();
282
308
  }
309
+ /**
310
+ * TUI2-R1 (C) — the RUNNING call's observed output.
311
+ *
312
+ * The CLI tails the shell tool's progress sidecar and hands what it
313
+ * read to the cell. Deliberately narrow: only a cell that is still
314
+ * RUNNING accepts it, so an observation can never overwrite a real
315
+ * result, and an unchanged read costs no frame at all (a poller
316
+ * fires far more often than the output changes).
317
+ *
318
+ * This adds no event and no durable state. The text lands in the
319
+ * cell's live rendering and is replaced wholesale by the tool's own
320
+ * result at settle — which is the only text anything else ever reads.
321
+ */
322
+ toolProgress(callId, text) {
323
+ if (!this.#isActive())
324
+ return; // the pipe path has no live region
325
+ const cell = this.#toolCell(callId);
326
+ if (cell === null || cell.kind !== "tool" || cell.state !== "running" || cell.done)
327
+ return;
328
+ if (cell.resultText === text)
329
+ return;
330
+ cell.resultText = text;
331
+ this.#mark();
332
+ }
283
333
  toolSucceeded(callId) {
284
334
  if (!this.#isActive())
285
335
  this.#write(" ok\n");
@@ -490,7 +540,7 @@ export class Body {
490
540
  this.#cells.push({ kind: "banner", version, extensionsText, resume, done: true });
491
541
  this.#mark();
492
542
  }
493
- raw(lines) {
543
+ raw(lines, wrap) {
494
544
  if (!this.#isActive()) {
495
545
  this.#closeOpenThinking();
496
546
  this.#closeOpenText();
@@ -500,7 +550,7 @@ export class Body {
500
550
  }
501
551
  this.#closeOpenThinking();
502
552
  this.#closeOpenText();
503
- this.#cells.push({ kind: "raw", lines, done: true });
553
+ this.#cells.push({ kind: "raw", lines, done: true, ...(wrap === undefined ? {} : { wrap }) });
504
554
  this.#mark();
505
555
  }
506
556
  /** The last COMPLETE thinking block, for /think. */
@@ -553,8 +603,16 @@ export class Body {
553
603
  // land as NEW content, history is never rewritten, ADR-0046).
554
604
  const turnsBack = this.#cells.slice(idx + 1).filter((c) => c.kind === "user").length;
555
605
  const p = palette();
606
+ const back = `${turnsBack} ${turnsBack === 1 ? "turn" : "turns"} back`;
607
+ // TUI2-R1 (B): an EXPLORATION head lists per TOOL — the counts
608
+ // the row showed, then one row per tool with its subjects. The
609
+ // header keeps W15's shape; only the subject changes.
610
+ if (cell.rolled.parts !== undefined) {
611
+ const header = `${p.bold}▞${p.reset} expanded · ${escapeTerminal(`explored ${exploreCounts(cell.rolled.parts)}`)} · ${back}`;
612
+ return { kind: "appended", lines: [header, ...exploreRows(cell.rolled.parts, this.#opts.width())] };
613
+ }
556
614
  const noun = ROLLUP_NOUN[cell.name] ?? "calls";
557
- const header = `${p.bold}▞${p.reset} expanded · ${escapeTerminal(`${cell.name.replace("_file", "")} ${cell.rolled.count} ${noun}`)} · ${turnsBack} ${turnsBack === 1 ? "turn" : "turns"} back`;
615
+ const header = `${p.bold}▞${p.reset} expanded · ${escapeTerminal(`${cell.name.replace("_file", "")} ${cell.rolled.count} ${noun}`)} · ${back}`;
558
616
  return {
559
617
  kind: "appended",
560
618
  lines: [header, ...cell.rolled.targets.map((t) => ` ${p.dim}└ ${escapeTerminal(t)}${p.reset}`)],
@@ -657,7 +715,7 @@ export class Body {
657
715
  #statusSource() {
658
716
  const panel = this.#panelState?.() ?? null;
659
717
  if (panel !== null)
660
- return { status: panelStatus(panel.view, panel.phase, panel.sel), hint: panelAffordance(panel.view, panel.phase, panel.sel) };
718
+ return { status: panelStatusOf(panel), hint: panelAffordanceOf(panel) };
661
719
  // W22: while turns wait in the queue, the right hint shows the
662
720
  // count — the chips below carry the lines themselves.
663
721
  const queued = this.#queueState?.().length ?? 0;
@@ -678,6 +736,11 @@ export class Body {
678
736
  }
679
737
  /** Bind the editor's slash-command menu state — the MenuSelect slot
680
738
  * occupant (the menu replaces the editor's view while open). */
739
+ /** TUI2-R1 (D): bind the editor's keys-sheet flag. */
740
+ bindSheet(state) {
741
+ this.#sheetState = state;
742
+ this.#mark();
743
+ }
681
744
  bindMenu(state) {
682
745
  this.#menuState = state;
683
746
  }
@@ -704,7 +767,7 @@ export class Body {
704
767
  // + 1 — the SAME formula the marker embeds at (the panel lead when
705
768
  // the panel owns the row; the old prompt-only math desynced the
706
769
  // panel rows' edit column; leadWidth is the ONE authority)
707
- const lead = panel !== null ? panelLead(panel.view, panel.phase, panel.sel) : this.#inputPrompt;
770
+ const lead = panel !== null ? panelLeadOf(panel) : this.#inputPrompt;
708
771
  return 3 + leadWidth(lead) + st.cursor;
709
772
  }
710
773
  /** The old dock's redraw — the editor's onRender target: mark + the
@@ -770,15 +833,25 @@ export class Body {
770
833
  * screen rows), threaded against the previous sibling's OWN rows. */
771
834
  liveCount() {
772
835
  const panel = this.#panelState?.() ?? null;
836
+ const sheet = this.#sheetState?.() === true;
773
837
  const queueRows = this.#queueRows(this.#opts.width(), this.#opts.height());
774
838
  // KC1 §6: the composer's extra rows are chrome too — the scalar
775
839
  // counts them exactly like the menu/queue bands (N = 1 ⇒ +0)
776
840
  const inputExtra = this.#inputRows(this.#opts.width(), this.#opts.height(), this.#menuRows(this.#opts.width()).length, queueRows.length).rows.length - 1;
841
+ // TUI2-R1 (D): the sheet occupies the live region, exactly like the
842
+ // panel — the scalar must say so, or the cap arithmetic disagrees
843
+ // with the screen.
844
+ if (sheet) {
845
+ return (keysSheetRows(this.#opts.width()).slice(0, Math.max(1, this.#opts.height() - 4 - inputExtra - queueRows.length)).length +
846
+ CHROME_ROWS +
847
+ inputExtra +
848
+ queueRows.length);
849
+ }
777
850
  if (panel !== null) {
778
851
  // W21: the panel's own rows (the cap is exact — the scalar
779
852
  // reflects the screen). W22: the queue chips occupy their
780
853
  // own band — the panel's cap shrinks by their rows.
781
- return (panelBlockRows(panel.view, panel.phase, panel.sel, this.#opts.width(), Math.max(1, this.#opts.height() - 4 - inputExtra - queueRows.length)).length +
854
+ return (panelRowsOf(panel, this.#opts.width(), Math.max(1, this.#opts.height() - 4 - inputExtra - queueRows.length)).length +
782
855
  CHROME_ROWS +
783
856
  inputExtra +
784
857
  queueRows.length);
@@ -859,13 +932,32 @@ export class Body {
859
932
  const chromeRows = CHROME_ROWS + inputExtra + menuRows.length + queueRows.length;
860
933
  let liveLines = [];
861
934
  const panel = this.#panelState?.() ?? null;
862
- if (panel !== null) {
935
+ // TUI2-R1.5 ⑦(a) (VD-8): the sheet is an OVERLAY, and the frame it
936
+ // opens on — and the one it closes on — take the full-redraw path.
937
+ // The sheet REPLACES the live region, so on an idle composer (where
938
+ // the live region is empty) opening it GROWS the model by its own
939
+ // height; the frame's skip grows with it and the difference is paid
940
+ // in real LFs — rows scrolled permanently into the terminal's
941
+ // scrollback, which closing cannot undo, because the scrollback is
942
+ // not ours to rewrite. Measured: three rows per open on a full
943
+ // screen. The overlay below displaces content on screen instead.
944
+ const sheetUp = this.#sheetState?.() === true;
945
+ this.#overlayFrame = sheetUp || this.#sheetWasUp;
946
+ this.#sheetWasUp = sheetUp;
947
+ if (sheetUp) {
948
+ // TUI2-R1 (D): the sheet REPLACES the live region — the same
949
+ // slot the panel uses, for the same reason (it is what the
950
+ // human is reading right now). It cannot coexist with a panel:
951
+ // the editor only opens it from an idle composer.
952
+ liveLines = keysSheetRows(W).slice(0, Math.max(1, H - 4 - inputExtra - queueRows.length));
953
+ }
954
+ else if (panel !== null) {
863
955
  // W21: the panel REPLACES the running tool's live window — the
864
956
  // bounded block, capped at H−4 (the panel IS the live region;
865
957
  // the W11 blank would separate it from the frozen content).
866
958
  // The cap is exact, so the force-commit loop never fires. W22:
867
959
  // the queue band sits below the panel — the cap shrinks by it.
868
- liveLines = panelBlockRows(panel.view, panel.phase, panel.sel, W, Math.max(1, H - 4 - inputExtra - queueRows.length));
960
+ liveLines = panelRowsOf(panel, W, Math.max(1, H - 4 - inputExtra - queueRows.length));
869
961
  }
870
962
  else {
871
963
  let prev = this.#committed > 0 ? this.#lineCache[this.#committed - 1] : null;
@@ -938,14 +1030,17 @@ export class Body {
938
1030
  #commitCell(i, W, ctx) {
939
1031
  const cell = this.#cells[i];
940
1032
  const lines = this.#foldOrRollup(cell, i, W, ctx);
941
- // W15: a tool cell whose last committed row carried the "ctrl+r"
942
- // affordance (the renderer cut "└ ctrl+r") joins the expand
943
- // history the detection is the renderer's OWN output, so the
944
- // read's "/last"-only cut note never lands here.
1033
+ // W15: a tool cell whose committed rows carried the "ctrl+r"
1034
+ // affordance joins the expand history the detection is the
1035
+ // renderer's OWN output, so the read's "/last"-only cut note never
1036
+ // lands here. TUI2-R1 (A/B): the affordance is no longer only the
1037
+ // renderer cut's "└ … ctrl+r" — the self-naming head suffix and
1038
+ // the exploration row carry it on the HEAD row, and a promise the
1039
+ // key does not answer would be the one thing worse than silence.
945
1040
  // unshift: the cells commit oldest-first, so the NEWEST cut lands
946
1041
  // at the front — the expand pointer's "newest back" walk starts
947
1042
  // where the user's last key press would aim.
948
- if (cell.kind === "tool" && /└ .*ctrl\+r/.test(lines[lines.length - 1] ?? ""))
1043
+ if (cell.kind === "tool" && lines.some((l) => l.includes("ctrl+r")))
949
1044
  this.#collapsed.unshift(i);
950
1045
  this.#lineCache[i] = lines;
951
1046
  const placed = bodySpacing(i > 0 ? this.#lineCache[i - 1] : null, lines);
@@ -965,7 +1060,51 @@ export class Body {
965
1060
  const turn = cell.turn >= 0 ? this.#turns[cell.turn] : undefined;
966
1061
  if (turn === undefined || turn !== this.#turns[this.#turns.length - 1])
967
1062
  return false;
968
- return !turn.ended && !turn.hasText;
1063
+ if (!turn.ended && !turn.hasText)
1064
+ return true;
1065
+ // the turn's END releases every hold — the settle is where the run
1066
+ // is decided, and a held cell at settle would never commit at all.
1067
+ if (turn.ended)
1068
+ return false;
1069
+ return this.#growingRun(i);
1070
+ }
1071
+ /** TUI2-R1.5 ① (VD-1) — the explore-run hold. W14's hold covers the
1072
+ * QUIET turn only, and the model's own narration ("let me look at the
1073
+ * parser area") sets hasText before the first read even starts: from
1074
+ * there each completion committed in its OWN frame, the head committed
1075
+ * alone, and `members.every(done)` — the fold's gate — could never be
1076
+ * true again. Every real session therefore degraded to one row per
1077
+ * call while the unit suite, which feeds the burst synchronously,
1078
+ * stayed green (the walkthrough's frame s1-06).
1079
+ *
1080
+ * The hold is the smallest honest fix: a DONE explore cell whose run
1081
+ * can still GROW does not commit yet — its committed form is not
1082
+ * decided until the run is closed. The run closes at the first
1083
+ * non-explore cell (the model's next word, an edit, a shell) or at the
1084
+ * turn's end, and the whole run then commits in ONE frame, which is
1085
+ * exactly the shape the fold was written for.
1086
+ *
1087
+ * The force-commit path never consults this (see #held's callers): the
1088
+ * screen's hard cap still wins, so the screen never sticks — a run
1089
+ * under real screen pressure degrades mid-turn, and the rows it
1090
+ * already froze stay frozen (history is never rewritten, ADR-0046). */
1091
+ #growingRun(i) {
1092
+ const cell = this.#cells[i];
1093
+ if (cell.kind !== "tool" || !isExploreTool(cell.name))
1094
+ return false;
1095
+ // the run is still growing while NOTHING but explore cells follow —
1096
+ // the turn-less noise cells (permission raws, ⚠ notices) are
1097
+ // transparent here for the same reason the run scan sees through
1098
+ // them: the streaming execution interleaves them between the calls.
1099
+ for (let j = i + 1; j < this.#cells.length; j += 1) {
1100
+ const next = this.#cells[j];
1101
+ if (next.kind === "raw" || next.kind === "notice")
1102
+ continue;
1103
+ if (next.kind === "tool" && isExploreTool(next.name))
1104
+ continue;
1105
+ return false; // a non-explore cell closed the run — commit now
1106
+ }
1107
+ return true;
969
1108
  }
970
1109
  /** W14/W13 — the release-time decision at a commit, BEFORE the cell's
971
1110
  * own render: the folded-turn fold first (a QUIET turn — ended, no
@@ -994,26 +1133,38 @@ export class Body {
994
1133
  return [];
995
1134
  }
996
1135
  }
997
- if (cell.kind !== "tool" || ROLLUP_NOUN[cell.name] === undefined)
1136
+ if (cell.kind !== "tool" || !isExploreTool(cell.name))
998
1137
  return cellComponent(cell).render(W, ctx);
999
- // the maximal same-name run around i forward/backward scans over
1138
+ // TUI2-R1 (B): the run is over the READ-ONLY SET, not one name —
1139
+ // a model exploring mixes read/list/search, and the same-name scan
1140
+ // split every real burst into fragments. Writes, edits, shells and
1141
+ // extension tools still break the run at the first one.
1142
+ // the maximal read-only run around i — forward/backward scans over
1000
1143
  // the cells. The turn-less noise cells (the permission raws, the ⚠
1001
1144
  // notices) are TRANSPARENT: the streaming execution (loop.ts launch)
1002
1145
  // interleaves them BETWEEN the calls of one burst, so the run must
1003
1146
  // see through them. It never crosses a user/text/thinking cell —
1004
1147
  // those separate turns and contexts.
1148
+ // TUI2-R1.5 ① (VD-1): the backward scan stops at the cells this
1149
+ // FRAME is committing. A cell committed in an earlier frame is
1150
+ // frozen — its rows are on the screen and in the scrollback — so it
1151
+ // can never become the head of a rollup now, and a run that
1152
+ // force-committed its first rows mid-turn must not have the rest
1153
+ // silently absorbed into a summary that was computed without them.
1154
+ // The degraded head keeps its individual row; the rest of the run
1155
+ // rolls on its own.
1005
1156
  let s = i;
1006
1157
  let head = i;
1007
- while (s > 0) {
1158
+ while (s > this.#committedAtFrameStart) {
1008
1159
  const prev = this.#cells[s - 1];
1009
1160
  if (prev.kind === "raw" || prev.kind === "notice") {
1010
1161
  s -= 1;
1011
1162
  continue;
1012
1163
  }
1013
- if (prev.kind !== "tool" || prev.name !== cell.name)
1164
+ if (prev.kind !== "tool" || !isExploreTool(prev.name))
1014
1165
  break;
1015
1166
  s -= 1;
1016
- head = s; // a same-name tool precedes — it is the group's head
1167
+ head = s; // a read-only tool precedes — it is the group's head
1017
1168
  }
1018
1169
  let e = i;
1019
1170
  while (e + 1 < this.#cells.length) {
@@ -1022,7 +1173,7 @@ export class Body {
1022
1173
  e += 1;
1023
1174
  continue;
1024
1175
  }
1025
- if (next.kind !== "tool" || next.name !== cell.name)
1176
+ if (next.kind !== "tool" || !isExploreTool(next.name))
1026
1177
  break;
1027
1178
  e += 1;
1028
1179
  }
@@ -1040,13 +1191,18 @@ export class Body {
1040
1191
  this.#rolledHeads.add(head);
1041
1192
  let total = 0;
1042
1193
  const targets = [];
1194
+ // TUI2-R1 (B): the per-tool parts, in first-call order — the
1195
+ // exploration row's counts and its expanded list both read them.
1196
+ // A search's subject is the PATTERN it looked for (quoted); a
1197
+ // read's or a list's is the path it named.
1198
+ const parts = [];
1043
1199
  for (const m of members) {
1044
1200
  // the lines count, excluding the tool's OWN truncation note
1045
1201
  // (read_file's "… N more lines") — the per-cell meta's rule
1046
1202
  const noteAt = m.resultText.lastIndexOf("\n… ");
1047
1203
  const shown = noteAt >= 0 ? m.resultText.slice(0, noteAt) : m.resultText;
1048
- const parts = shown.split("\n");
1049
- total += parts[parts.length - 1] === "" ? parts.length - 1 : parts.length;
1204
+ const rows = shown.split("\n");
1205
+ total += rows[rows.length - 1] === "" ? rows.length - 1 : rows.length;
1050
1206
  let input = {};
1051
1207
  try {
1052
1208
  input = JSON.parse(m.inputFull);
@@ -1057,11 +1213,19 @@ export class Body {
1057
1213
  }
1058
1214
  const target = toolTarget(m.name, input);
1059
1215
  targets.push(target.split("/").pop() ?? target);
1216
+ const subject = m.name === "search_text" ? `"${String(input.pattern ?? "")}"` : target;
1217
+ const part = parts.find((x) => x.name === m.name);
1218
+ if (part === undefined)
1219
+ parts.push({ name: m.name, subjects: [subject] });
1220
+ else
1221
+ part.subjects.push(subject);
1060
1222
  }
1061
1223
  const first = members[0];
1062
1224
  const last = members[members.length - 1];
1063
1225
  const elapsed = first.startedAt !== null && last.doneAt !== null ? ((last.doneAt - first.startedAt) / 1000).toFixed(1) : "?";
1064
- cell.rolled = { count: members.length, lines: total, elapsed, targets };
1226
+ // TUI2-R1 (B): `parts` rides ONLY a mixed run — a single-name
1227
+ // run keeps W13's row, byte for byte (the generalization adds).
1228
+ cell.rolled = { count: members.length, lines: total, elapsed, targets, ...(parts.length > 1 ? { parts } : {}) };
1065
1229
  return cellComponent(cell).render(W, ctx);
1066
1230
  }
1067
1231
  // a MEMBER of an already-rolled run → [] (its rows live in the
@@ -1132,7 +1296,11 @@ export class Body {
1132
1296
  if (menu === null || menu === undefined || menu.items.length === 0)
1133
1297
  return [];
1134
1298
  const p = palette();
1135
- const rows = [];
1299
+ // TUI2-R1.5 ⑦(b) (VD-8): the band NAMES itself, the same way the @
1300
+ // picker's does. Both render frameless directly above the composer,
1301
+ // so with scrollback behind them there was nothing to say where the
1302
+ // surface began — the rows read as more history.
1303
+ const rows = [bandHeader("commands", W)];
1136
1304
  for (let i = 0; i < menu.items.length; i += 1) {
1137
1305
  const item = menu.items[i];
1138
1306
  const text = i === menu.selected
@@ -1224,7 +1392,7 @@ export class Body {
1224
1392
  // the lead — the panel's phase lead when the panel owns the row
1225
1393
  // (1-3> / the rule input's "2 Yes, don't ask again for " / the
1226
1394
  // amend "feedback (deny): "), the bound prompt otherwise
1227
- const lead = panel !== null ? panelLead(panel.view, panel.phase, panel.sel) : this.#inputPrompt;
1395
+ const lead = panel !== null ? panelLeadOf(panel) : this.#inputPrompt;
1228
1396
  const leadW = leadWidth(lead);
1229
1397
  // a LEGACY one-row provider (the old {line, cursor} shape) keeps
1230
1398
  // working: its single line is the composer's single row
@@ -1266,6 +1434,7 @@ export class Body {
1266
1434
  * EVERY row is idempotent: N consecutive resizes end with the same
1267
1435
  * screen as a single jump to the same size. */
1268
1436
  #drawFull(out, W, H, liveTop, liveLines, queueRows, menuRows, editor) {
1437
+ const overlay = this.#overlayFrame;
1269
1438
  const inputExtra = editor.rows.length - 1; // KC1: the composer's rows above the retired single input row
1270
1439
  const committed = this.#committedLinesThisFrame;
1271
1440
  // 0. the FROZEN rows — the re-folded committed content (re-flowed
@@ -1293,7 +1462,15 @@ export class Body {
1293
1462
  // window (the committed share + the live + the chrome), r
1294
1463
  // monotone, every row 1..H re-painted (the V6-1 every-row rule).
1295
1464
  const all = [...frozen, ...committed, ...liveLines];
1296
- const skip = Math.max(0, all.length + CHROME_ROWS + inputExtra + queueRows.length + menuRows.length - H);
1465
+ // TUI2-R1.5 7(a) (VD-8): while the sheet is up the window does NOT
1466
+ // move. skip is frozen at its pre-open value and #lastSkip is left
1467
+ // alone, so no LF is emitted and nothing enters the scrollback; the
1468
+ // march below is clamped to the window instead, which makes the
1469
+ // sheet displace content ON SCREEN. Closing takes the full-redraw
1470
+ // path with the same #lastSkip and every displaced row comes back.
1471
+ const skip = overlay
1472
+ ? this.#lastSkip
1473
+ : Math.max(0, all.length + CHROME_ROWS + inputExtra + queueRows.length + menuRows.length - H);
1297
1474
  // A8b (the shrink-trigger's completion): the rows that LEAVE the
1298
1475
  // window scroll into the terminal's scrollback — the LF mechanism
1299
1476
  // (the steady path's own). Only the rows the paint re-covers (the
@@ -1305,7 +1482,7 @@ export class Body {
1305
1482
  // shrink EVERY frame) loses the scrolled-away turns from the
1306
1483
  // terminal's scrollback entirely (finding #A8b — the queued-flood
1307
1484
  // content loss).
1308
- if (skip > 0) {
1485
+ if (skip > 0 && !overlay) {
1309
1486
  const leaving = Math.max(0, skip - this.#lastSkip);
1310
1487
  // A8b (the fresh leaving share): a leaving row whose old-screen
1311
1488
  // copy is stale — the committed-this-frame lines (their old rows
@@ -1329,9 +1506,16 @@ export class Body {
1329
1506
  for (let i = 0; i < skip; i += 1)
1330
1507
  out.push("\n");
1331
1508
  }
1332
- this.#lastSkip = skip;
1509
+ if (!overlay)
1510
+ this.#lastSkip = skip;
1333
1511
  let r = 1;
1334
- for (const line of all.slice(skip)) {
1512
+ // the window's content rows: everything above the chrome. With the
1513
+ // overlay up `all` can exceed it, and the rows that give way are the
1514
+ // OLDEST on screen — they are still in the model and come back on
1515
+ // the close.
1516
+ const contentRows = Math.max(0, H - CHROME_ROWS - inputExtra - queueRows.length - menuRows.length);
1517
+ const march = all.slice(skip);
1518
+ for (const line of march.length > contentRows ? march.slice(march.length - contentRows) : march) {
1335
1519
  out.push(`\x1b[${r};1H\x1b[0K${this.#checked(line, W)}`);
1336
1520
  r += 1;
1337
1521
  }
@@ -1382,8 +1566,12 @@ export class Body {
1382
1566
  // frozenCount, so the lines at [frozenCount..skip−1] are the fresh
1383
1567
  // leaving share (their old-screen copies are stale).
1384
1568
  const frozenCount = this.#committedLines - committed.length;
1385
- const skip = Math.max(0, this.#committedLines + liveLines.length + CHROME_ROWS + inputExtra + queueRows.length + menuRows.length - H);
1386
- const leaving = Math.max(0, skip - this.#lastSkip);
1569
+ // TUI2-R1.5 7(a): an overlay frame never moves the window (see
1570
+ // #drawFull) the sheet's rows displace content on screen instead
1571
+ // of pushing it into the scrollback.
1572
+ const overlay = this.#overlayFrame;
1573
+ const skip = overlay ? this.#lastSkip : Math.max(0, this.#committedLines + liveLines.length + CHROME_ROWS + inputExtra + queueRows.length + menuRows.length - H);
1574
+ const leaving = overlay ? 0 : Math.max(0, skip - this.#lastSkip);
1387
1575
  // the jump to the bottom row H, then N real LFs scroll the screen
1388
1576
  // exactly N rows — ONE per committed line (the bookkeeping; the
1389
1577
  // stale 1B anchor jumped to H−1 and the N LFs scrolled only N−1 —
@@ -1603,6 +1791,15 @@ export class Dock {
1603
1791
  }
1604
1792
  compositorRef.bindApproval(state);
1605
1793
  }
1794
+ /** TUI2-R1 (D): bind the editor's keys-sheet flag — the slot read for
1795
+ * the ? overlay (the menu/picker binding pattern). */
1796
+ bindSheet(state) {
1797
+ if (compositorRef === null) {
1798
+ dockBindings.sheet = state;
1799
+ return;
1800
+ }
1801
+ compositorRef.bindSheet(state);
1802
+ }
1606
1803
  bindInput(state, prompt) {
1607
1804
  if (compositorRef === null) {
1608
1805
  dockBindings.state = state; // the live buffer — order-agnostic
@@ -1653,4 +1850,4 @@ let compositorRef = null;
1653
1850
  * — the old snapshot froze `menu` at bindInput time and the slash-
1654
1851
  * command menu silently never bound in the real CLI (the e2e gates
1655
1852
  * bind the Body directly and could not see it). */
1656
- const dockBindings = { state: null, prompt: "", menu: null, at: null, panel: null, queue: null };
1853
+ const dockBindings = { state: null, prompt: "", menu: null, at: null, panel: null, sheet: null, queue: null };
@@ -0,0 +1,66 @@
1
+ /**
2
+ * TUI2-R1 (E) — /context's attribution rows.
3
+ *
4
+ * The question "where did my context go?" has had an answer since E3:
5
+ * the trace sidecar's rent ledger records, per request, exactly what
6
+ * each static surface costs, and the context manifest records what the
7
+ * conversation costs. Until now that answer was only readable by
8
+ * someone willing to parse JSONL.
9
+ *
10
+ * This module is the presentation half and nothing else — a pure
11
+ * function from counts to rows, with no idea where the counts came
12
+ * from. That matters for the purity gate: the trace surface is an
13
+ * OBSERVATION surface (ADR-0051 §6), correctness never reads it, and
14
+ * keeping the reader in the CLI and the renderer here means this module
15
+ * cannot accidentally become a second correctness path.
16
+ *
17
+ * Every number is a count the ledger already carries. Nothing here
18
+ * estimates, projects, or predicts.
19
+ */
20
+ /** The counts one request's ledger yields, already grouped by surface.
21
+ * Estimated tokens throughout (the rent ledger's own chars/4 convention
22
+ * — R6), because that is the unit the ledger records. */
23
+ export interface ContextLedger {
24
+ /** The model's context window, as the session is configured. */
25
+ readonly window: number;
26
+ /** system:base + every system:ext:* append EXCEPT skills. */
27
+ readonly systemPrompt: number;
28
+ /** system:base alone — the detail behind the row. */
29
+ readonly systemBase: number;
30
+ /** how many extensions appended (the detail's count). */
31
+ readonly appends: number;
32
+ /** the sum of the tool:* lines. */
33
+ readonly toolTable: number;
34
+ readonly tools: number;
35
+ /** system:ext:skills — broken out because it is an INDEX of content
36
+ * rather than an instruction, and it grows with the workspace rather
37
+ * than with the build. 0 when the extension is not loaded. */
38
+ readonly skillsIndex: number;
39
+ /** how many skills the index lists — 0 when the caller cannot know
40
+ * (the rent ledger records surfaces, never their contents). */
41
+ readonly skills: number;
42
+ /** the per-request skeleton (the `envelope` rent line). */
43
+ readonly envelope: number;
44
+ /** the context manifest's turn segments — the conversation itself. */
45
+ readonly messages: number;
46
+ readonly turns: number;
47
+ }
48
+ /**
49
+ * The rows: the header, the bar, one row per surface that EXISTS, and
50
+ * the free remainder.
51
+ *
52
+ * An absent surface is an absent row — the rent ledger's own R9 rule
53
+ * ("not paid = no rent"), carried into the display: a session with no
54
+ * skills extension should not read a "skills index 0" row, because the
55
+ * zero would look like a measurement rather than an absence.
56
+ *
57
+ * The columns are fixed so the numbers line up as a column of numbers;
58
+ * the detail text rides after them, dim, and is cut by the caller's
59
+ * width if it must be.
60
+ */
61
+ export declare function contextRows(ledger: ContextLedger): string[];
62
+ /** TUI2-R1 (E) — the honest fallback. The ledger is written PER REQUEST:
63
+ * a session that has not called the model yet has no sidecar, and the
64
+ * right thing to show is that fact and the one step that produces one.
65
+ * Never an empty bar — an empty bar reads as "measured zero". */
66
+ export declare function contextUnavailableRows(reason: string): string[];
@@ -0,0 +1,87 @@
1
+ /**
2
+ * TUI2-R1 (E) — /context's attribution rows.
3
+ *
4
+ * The question "where did my context go?" has had an answer since E3:
5
+ * the trace sidecar's rent ledger records, per request, exactly what
6
+ * each static surface costs, and the context manifest records what the
7
+ * conversation costs. Until now that answer was only readable by
8
+ * someone willing to parse JSONL.
9
+ *
10
+ * This module is the presentation half and nothing else — a pure
11
+ * function from counts to rows, with no idea where the counts came
12
+ * from. That matters for the purity gate: the trace surface is an
13
+ * OBSERVATION surface (ADR-0051 §6), correctness never reads it, and
14
+ * keeping the reader in the CLI and the renderer here means this module
15
+ * cannot accidentally become a second correctness path.
16
+ *
17
+ * Every number is a count the ledger already carries. Nothing here
18
+ * estimates, projects, or predicts.
19
+ */
20
+ import { palette } from "./render.js";
21
+ const BAR_CELLS = 12;
22
+ /** k-units for the ledger's columns: 25700 → 25.7k, 300 → 300, 11 → 11.
23
+ *
24
+ * TUI2-R1.5 ⑤ (VD-15): the floor was 100, which put `11`, `0.3k` and
25
+ * `25.7k` in one right-aligned column — two unit systems stacked, and
26
+ * the reader has to switch between them row by row to compare. The
27
+ * repo already had a k-formatter with a 1000 floor (render.ts's kUnit,
28
+ * which the status row and every settled card use); this now agrees
29
+ * with it, so /context speaks the same number language as the rest of
30
+ * the product. It still differs from kUnit in never having a null to
31
+ * report — every ledger figure is a measured count. */
32
+ function k(n) {
33
+ return n >= 1000 ? `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k` : String(Math.round(n));
34
+ }
35
+ /**
36
+ * The rows: the header, the bar, one row per surface that EXISTS, and
37
+ * the free remainder.
38
+ *
39
+ * An absent surface is an absent row — the rent ledger's own R9 rule
40
+ * ("not paid = no rent"), carried into the display: a session with no
41
+ * skills extension should not read a "skills index 0" row, because the
42
+ * zero would look like a measurement rather than an absence.
43
+ *
44
+ * The columns are fixed so the numbers line up as a column of numbers;
45
+ * the detail text rides after them, dim, and is cut by the caller's
46
+ * width if it must be.
47
+ */
48
+ export function contextRows(ledger) {
49
+ const p = palette();
50
+ const used = ledger.systemPrompt + ledger.toolTable + ledger.skillsIndex + ledger.envelope + ledger.messages;
51
+ const free = Math.max(0, ledger.window - used);
52
+ const ratio = ledger.window > 0 ? Math.min(1, used / ledger.window) : 1;
53
+ const filled = Math.max(0, Math.min(BAR_CELLS, Math.round(ratio * BAR_CELLS)));
54
+ const rows = [
55
+ `${p.bold}context — ${k(used)} / ${k(ledger.window)} tokens (${Math.round(ratio * 100)}%)${p.reset}`,
56
+ `${p.bold}${"▰".repeat(filled)}${p.reset}${p.dim}${"▱".repeat(BAR_CELLS - filled)}${p.reset}`,
57
+ ];
58
+ /** One surface row: the label at 14 columns, the count right-aligned
59
+ * at 5, then the dim detail. */
60
+ const row = (label, value, detail) => ` ${p.bold}▰${p.reset} ${label.padEnd(14)}${p.bold}${k(value).padStart(5)}${p.reset}${detail === "" ? "" : ` ${p.dim}${detail}${p.reset}`}`;
61
+ if (ledger.systemPrompt > 0) {
62
+ rows.push(row("system prompt", ledger.systemPrompt, `(base ${k(ledger.systemBase)}${ledger.appends > 0 ? ` + ${ledger.appends} extension append${ledger.appends === 1 ? "" : "s"}` : ""})`));
63
+ }
64
+ if (ledger.toolTable > 0)
65
+ rows.push(row("tool table", ledger.toolTable, `${ledger.tools} tool${ledger.tools === 1 ? "" : "s"}`));
66
+ if (ledger.skillsIndex > 0) {
67
+ // the skill COUNT is not in the ledger (rent records surfaces, not
68
+ // their contents) — a caller that knows it passes it, and a caller
69
+ // that does not gets the honest half of the sentence rather than a
70
+ // fabricated number.
71
+ rows.push(row("skills index", ledger.skillsIndex, `${ledger.skills > 0 ? `${ledger.skills} skill${ledger.skills === 1 ? "" : "s"}, ` : ""}tier-1 lines only`));
72
+ }
73
+ if (ledger.envelope > 0)
74
+ rows.push(row("envelope", ledger.envelope, ""));
75
+ if (ledger.messages > 0)
76
+ rows.push(row("messages", ledger.messages, `${ledger.turns} turn${ledger.turns === 1 ? "" : "s"}`));
77
+ rows.push(` ${p.dim}▱ ${"free".padEnd(14)}${k(free).padStart(5)}${p.reset}`);
78
+ return rows;
79
+ }
80
+ /** TUI2-R1 (E) — the honest fallback. The ledger is written PER REQUEST:
81
+ * a session that has not called the model yet has no sidecar, and the
82
+ * right thing to show is that fact and the one step that produces one.
83
+ * Never an empty bar — an empty bar reads as "measured zero". */
84
+ export function contextUnavailableRows(reason) {
85
+ const p = palette();
86
+ return [`${p.bold}context — no ledger yet${p.reset}`, ` ${p.dim}${reason}${p.reset}`];
87
+ }