@vincemakes/kiso-tui 0.8.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.
package/dist/ask-panel.js CHANGED
@@ -197,7 +197,15 @@ export function askBlockRows(view, state, W, maxRows) {
197
197
  rows.push(...body);
198
198
  }
199
199
  rows.push(`${gutter}${p.dim}${askAffordance(state)}${p.reset}`);
200
- rows.push(`${p.dim}└ ${p.reset}`);
200
+ // TUI2-R1.5 11 (VD-13), shared with the approval panel: a real bottom RULE, in the block's own edge
201
+ // vocabulary — the same box-drawing run its divider already uses —
202
+ // anchored at the gutter column. It used to be `\u2514 `: a two-cell stub
203
+ // floating at column 1, with no rule running from it and no corner
204
+ // above it to answer. Worse, `\u2514 ` is the cut-notice prefix everywhere
205
+ // else in the product, so a CAPPED panel emitted two elbow rows in a
206
+ // row meaning entirely different things. The rule reads as an edge,
207
+ // and the cut notice above it reads as a notice.
208
+ rows.push(`${p.dim}\u2514${"\u2500".repeat(Math.max(0, W - 1))}${p.reset}`);
201
209
  return rows;
202
210
  }
203
211
  /** The status row's right-hand hint — the phase's keys. */
@@ -157,3 +157,7 @@ export declare function atPanelRows(state: {
157
157
  selected: number;
158
158
  capped: boolean;
159
159
  }, W: number): string[];
160
+ /** TUI2-R1.5 ⑦(b) — the one-row dim header that turns a band into a
161
+ * surface. Shared by the @ picker and the / menu so the two read the
162
+ * same way. */
163
+ export declare function bandHeader(label: string, W: number): string;
package/dist/at-picker.js CHANGED
@@ -182,20 +182,41 @@ function splitPath(path) {
182
182
  export function atRow(match, selected, W) {
183
183
  const p = palette();
184
184
  const { dir, name } = splitPath(escapeTerminal(match.path));
185
- const lead = selected ? `${p.rv}→ ${p.rvEnd}` : " ";
186
185
  // the name's own matched positions, mapped out of the full path
187
186
  const marks = new Set(match.hit.filter((i) => i >= dir.length).map((i) => i - dir.length));
188
- // room: W the 2-cell lead 1 separating space before the directory
189
- const nameRoom = Math.max(1, W - 2 - (dir === "" ? 0 : visibleWidth(dir) + 1));
187
+ // TUI2-R1.5 (VD-9): the directory rides NEXT TO the name it
188
+ // qualifies. It used to be right-aligned to the band's far edge, which
189
+ // on a 100-column terminal put `src/` some eighty columns from the
190
+ // `parser.ts` it belongs to: the eye had to cross the whole row to
191
+ // learn which parser.ts this was, and the column read as a second list.
192
+ // Adjacent and dim, it is what it always meant to be — a qualifier.
193
+ // the name is the flexible column and the qualifier gives way first: a
194
+ // narrow window keeps the thing being aimed at.
195
+ const room = Math.max(1, W - 2);
196
+ const suffix = dir === "" ? "" : widthCut(` — ${dir}`, Math.max(0, room - 4));
197
+ const nameRoom = Math.max(1, room - visibleWidth(suffix));
190
198
  const shownName = widthCut(name, nameRoom);
191
199
  let painted = "";
192
200
  for (let i = 0; i < shownName.length; i += 1) {
193
201
  painted += marks.has(i) ? `${p.bold}${shownName[i]}${p.reset}` : shownName[i];
194
202
  }
195
- if (dir === "")
196
- return `${lead}${painted}`;
197
- const pad = Math.max(1, W - 2 - visibleWidth(shownName) - visibleWidth(dir));
198
- return `${lead}${painted}${" ".repeat(pad)}${p.dim}${dir}${p.reset}`;
203
+ const text = `${painted}${suffix === "" ? "" : `${p.dim}${suffix}${p.reset}`}`;
204
+ const width = visibleWidth(shownName) + visibleWidth(suffix);
205
+ if (!selected)
206
+ return ` ${text}`;
207
+ // TUI2-R1.5 ⑧ (VD-9): the selection is a FULL-ROW bar — the W16 chip
208
+ // mechanism, which the user chip and the turn fold already use. A
209
+ // two-cell `→ ` marker on the inverse band is one character of
210
+ // highlight in an eighty-column row, and the walkthrough could barely
211
+ // find it. The bar spans the row's whole width so the selection is
212
+ // visible from anywhere on the line. Mono discipline: reverse video,
213
+ // no new colours.
214
+ //
215
+ // The inner spans close with rvEnd (SGR 27), never SGR 0 — a reset
216
+ // inside the bar would punch a hole in it. `painted`'s bold marks and
217
+ // the dim suffix both end in SGR 0, so the bar is re-opened after each.
218
+ const inner = `${painted.replaceAll(p.reset, `${p.reset}${p.rv}`)}${suffix === "" ? "" : `${p.dim}${suffix}${p.reset}${p.rv}`}`;
219
+ return `${p.rv} ${inner}${" ".repeat(Math.max(0, W - width - 2))} ${p.rvEnd}`;
199
220
  }
200
221
  /**
201
222
  * KC3 §4 — the counter row: `(n/total)`, where n is the 1-based
@@ -220,9 +241,21 @@ export function atCounterRow(selected, total, capped, W) {
220
241
  */
221
242
  export function atPanelRows(state, W) {
222
243
  const { first, count } = atWindow(state.matches.length, state.selected);
223
- const rows = [];
244
+ // TUI2-R1.5 ⑦(b) (VD-8): the band NAMES itself. It renders frameless
245
+ // directly above the composer, so with scrollback behind it there was
246
+ // nothing to say where the surface began — the rows read as more
247
+ // history. One dim row is enough to make it UI. Mono discipline: dim
248
+ // text, no rule, no colour.
249
+ const rows = [bandHeader("files", W)];
224
250
  for (let i = first; i < first + count; i += 1)
225
251
  rows.push(atRow(state.matches[i], i === state.selected, W));
226
252
  rows.push(atCounterRow(state.selected, state.matches.length, state.capped, W));
227
253
  return rows;
228
254
  }
255
+ /** TUI2-R1.5 ⑦(b) — the one-row dim header that turns a band into a
256
+ * surface. Shared by the @ picker and the / menu so the two read the
257
+ * same way. */
258
+ export function bandHeader(label, W) {
259
+ const p = palette();
260
+ return `${p.dim}${widthCut(label, Math.max(1, W))}${p.reset}`;
261
+ }
@@ -109,6 +109,20 @@ export declare class Body {
109
109
  * outcome. */
110
110
  toolVerdict(callId: string, decision: "approved" | "denied", decidedBy?: string, reason?: string): void;
111
111
  toolRunning(callId: string): void;
112
+ /**
113
+ * TUI2-R1 (C) — the RUNNING call's observed output.
114
+ *
115
+ * The CLI tails the shell tool's progress sidecar and hands what it
116
+ * read to the cell. Deliberately narrow: only a cell that is still
117
+ * RUNNING accepts it, so an observation can never overwrite a real
118
+ * result, and an unchanged read costs no frame at all (a poller
119
+ * fires far more often than the output changes).
120
+ *
121
+ * This adds no event and no durable state. The text lands in the
122
+ * cell's live rendering and is replaced wholesale by the tool's own
123
+ * result at settle — which is the only text anything else ever reads.
124
+ */
125
+ toolProgress(callId: string, text: string): void;
112
126
  toolSucceeded(callId: string): void;
113
127
  toolFailed(callId: string, error: string): void;
114
128
  toolResult(callId: string, result: {
@@ -152,7 +166,7 @@ export declare class Body {
152
166
  * frame. The inactive path keeps the historical bytes (no resume —
153
167
  * the pipe contract). */
154
168
  banner(version: string, extensionsText: string, resume?: ResumeMeta[]): void;
155
- raw(lines: string[]): void;
169
+ raw(lines: string[], wrap?: "words"): void;
156
170
  /** The last COMPLETE thinking block, for /think. */
157
171
  lastThinking(): string | null;
158
172
  /** The last completed tool call, for /last. */
@@ -208,6 +222,8 @@ export declare class Body {
208
222
  bindInput(state: () => InputState, prompt: string): void;
209
223
  /** Bind the editor's slash-command menu state — the MenuSelect slot
210
224
  * occupant (the menu replaces the editor's view while open). */
225
+ /** TUI2-R1 (D): bind the editor's keys-sheet flag. */
226
+ bindSheet(state: () => boolean): void;
211
227
  bindMenu(state: () => {
212
228
  items: readonly MenuItem[];
213
229
  selected: number;
@@ -252,6 +268,9 @@ export declare class Dock {
252
268
  * occupant (the panel replaces the live region + the input lead
253
269
  * while up; the old ApprovalPrompt's question slot retires). */
254
270
  bindApproval(state: () => PanelState | null): void;
271
+ /** TUI2-R1 (D): bind the editor's keys-sheet flag — the slot read for
272
+ * the ? overlay (the menu/picker binding pattern). */
273
+ bindSheet(state: () => boolean): void;
255
274
  bindInput(state: () => InputState, prompt: string): void;
256
275
  bindMenu(state: () => {
257
276
  items: readonly MenuItem[];
@@ -48,9 +48,10 @@ import { leadWidth } from "./width.js"; // W23: the ONE width authority (the edi
48
48
  // KC3.5: the panel-slot reads come from the DISPATCHERS — one source
49
49
  // for four reads, so an ask can never render half as an approval.
50
50
  import { panelAffordanceOf, panelLeadOf, panelRowsOf, panelStatusOf } from "./ask-panel.js";
51
- import { atPanelRows } from "./at-picker.js";
52
- import { Container, ROLLUP_NOUN, SPINNER, bodySpacing, boxBottom, boxTop, cellComponent, foldLine, pendingQueueRows, statusLine, turnFold, visibleWidth, } from "./components.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";
53
53
  import { bannerLines, escapeTerminal, foldResult, foldThinking, palette, renderTerminalGap, renderToolSummary, toolTarget } from "./render.js";
54
+ import { keysSheetRows } from "./strings.js";
54
55
  /** The cursor marker — an APC private sequence the focus component
55
56
  * embeds at the edit position; the compositor strips it and moves
56
57
  * relatively (it never reaches the terminal). */
@@ -118,6 +119,15 @@ export class Body {
118
119
  // panel is up it replaces the live region, owns the input lead, and
119
120
  // derives the status row (the CLI's painting status yields).
120
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;
121
131
  #inputState = () => ({ line: "", cursor: 0 });
122
132
  #inputPrompt = "";
123
133
  #menuState = null;
@@ -153,6 +163,7 @@ export class Body {
153
163
  if (dockBindings.at !== null)
154
164
  this.#atState = dockBindings.at;
155
165
  this.#panelState = dockBindings.panel;
166
+ this.#sheetState = dockBindings.sheet;
156
167
  if (dockBindings.queue !== null)
157
168
  this.#queueState = dockBindings.queue;
158
169
  }
@@ -212,6 +223,19 @@ export class Body {
212
223
  this.#write(`→ ${escapeTerminal(name)}(${escapeTerminal(JSON.stringify(input).slice(0, 200))})\n`);
213
224
  return;
214
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();
215
239
  // W12: the cell carries the delegate's child roles from the FULL
216
240
  // input — the display summary is sliced at 60 chars (unparseable);
217
241
  // the roles are the only running-state data the parent holds (there
@@ -282,6 +306,30 @@ export class Body {
282
306
  }
283
307
  this.#mark();
284
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
+ }
285
333
  toolSucceeded(callId) {
286
334
  if (!this.#isActive())
287
335
  this.#write(" ok\n");
@@ -492,7 +540,7 @@ export class Body {
492
540
  this.#cells.push({ kind: "banner", version, extensionsText, resume, done: true });
493
541
  this.#mark();
494
542
  }
495
- raw(lines) {
543
+ raw(lines, wrap) {
496
544
  if (!this.#isActive()) {
497
545
  this.#closeOpenThinking();
498
546
  this.#closeOpenText();
@@ -502,7 +550,7 @@ export class Body {
502
550
  }
503
551
  this.#closeOpenThinking();
504
552
  this.#closeOpenText();
505
- this.#cells.push({ kind: "raw", lines, done: true });
553
+ this.#cells.push({ kind: "raw", lines, done: true, ...(wrap === undefined ? {} : { wrap }) });
506
554
  this.#mark();
507
555
  }
508
556
  /** The last COMPLETE thinking block, for /think. */
@@ -555,8 +603,16 @@ export class Body {
555
603
  // land as NEW content, history is never rewritten, ADR-0046).
556
604
  const turnsBack = this.#cells.slice(idx + 1).filter((c) => c.kind === "user").length;
557
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
+ }
558
614
  const noun = ROLLUP_NOUN[cell.name] ?? "calls";
559
- 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}`;
560
616
  return {
561
617
  kind: "appended",
562
618
  lines: [header, ...cell.rolled.targets.map((t) => ` ${p.dim}└ ${escapeTerminal(t)}${p.reset}`)],
@@ -680,6 +736,11 @@ export class Body {
680
736
  }
681
737
  /** Bind the editor's slash-command menu state — the MenuSelect slot
682
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
+ }
683
744
  bindMenu(state) {
684
745
  this.#menuState = state;
685
746
  }
@@ -772,10 +833,20 @@ export class Body {
772
833
  * screen rows), threaded against the previous sibling's OWN rows. */
773
834
  liveCount() {
774
835
  const panel = this.#panelState?.() ?? null;
836
+ const sheet = this.#sheetState?.() === true;
775
837
  const queueRows = this.#queueRows(this.#opts.width(), this.#opts.height());
776
838
  // KC1 §6: the composer's extra rows are chrome too — the scalar
777
839
  // counts them exactly like the menu/queue bands (N = 1 ⇒ +0)
778
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
+ }
779
850
  if (panel !== null) {
780
851
  // W21: the panel's own rows (the cap is exact — the scalar
781
852
  // reflects the screen). W22: the queue chips occupy their
@@ -861,7 +932,26 @@ export class Body {
861
932
  const chromeRows = CHROME_ROWS + inputExtra + menuRows.length + queueRows.length;
862
933
  let liveLines = [];
863
934
  const panel = this.#panelState?.() ?? null;
864
- 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) {
865
955
  // W21: the panel REPLACES the running tool's live window — the
866
956
  // bounded block, capped at H−4 (the panel IS the live region;
867
957
  // the W11 blank would separate it from the frozen content).
@@ -940,14 +1030,17 @@ export class Body {
940
1030
  #commitCell(i, W, ctx) {
941
1031
  const cell = this.#cells[i];
942
1032
  const lines = this.#foldOrRollup(cell, i, W, ctx);
943
- // W15: a tool cell whose last committed row carried the "ctrl+r"
944
- // affordance (the renderer cut "└ ctrl+r") joins the expand
945
- // history the detection is the renderer's OWN output, so the
946
- // 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.
947
1040
  // unshift: the cells commit oldest-first, so the NEWEST cut lands
948
1041
  // at the front — the expand pointer's "newest back" walk starts
949
1042
  // where the user's last key press would aim.
950
- if (cell.kind === "tool" && /└ .*ctrl\+r/.test(lines[lines.length - 1] ?? ""))
1043
+ if (cell.kind === "tool" && lines.some((l) => l.includes("ctrl+r")))
951
1044
  this.#collapsed.unshift(i);
952
1045
  this.#lineCache[i] = lines;
953
1046
  const placed = bodySpacing(i > 0 ? this.#lineCache[i - 1] : null, lines);
@@ -967,7 +1060,51 @@ export class Body {
967
1060
  const turn = cell.turn >= 0 ? this.#turns[cell.turn] : undefined;
968
1061
  if (turn === undefined || turn !== this.#turns[this.#turns.length - 1])
969
1062
  return false;
970
- 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;
971
1108
  }
972
1109
  /** W14/W13 — the release-time decision at a commit, BEFORE the cell's
973
1110
  * own render: the folded-turn fold first (a QUIET turn — ended, no
@@ -996,26 +1133,38 @@ export class Body {
996
1133
  return [];
997
1134
  }
998
1135
  }
999
- if (cell.kind !== "tool" || ROLLUP_NOUN[cell.name] === undefined)
1136
+ if (cell.kind !== "tool" || !isExploreTool(cell.name))
1000
1137
  return cellComponent(cell).render(W, ctx);
1001
- // 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
1002
1143
  // the cells. The turn-less noise cells (the permission raws, the ⚠
1003
1144
  // notices) are TRANSPARENT: the streaming execution (loop.ts launch)
1004
1145
  // interleaves them BETWEEN the calls of one burst, so the run must
1005
1146
  // see through them. It never crosses a user/text/thinking cell —
1006
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.
1007
1156
  let s = i;
1008
1157
  let head = i;
1009
- while (s > 0) {
1158
+ while (s > this.#committedAtFrameStart) {
1010
1159
  const prev = this.#cells[s - 1];
1011
1160
  if (prev.kind === "raw" || prev.kind === "notice") {
1012
1161
  s -= 1;
1013
1162
  continue;
1014
1163
  }
1015
- if (prev.kind !== "tool" || prev.name !== cell.name)
1164
+ if (prev.kind !== "tool" || !isExploreTool(prev.name))
1016
1165
  break;
1017
1166
  s -= 1;
1018
- 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
1019
1168
  }
1020
1169
  let e = i;
1021
1170
  while (e + 1 < this.#cells.length) {
@@ -1024,7 +1173,7 @@ export class Body {
1024
1173
  e += 1;
1025
1174
  continue;
1026
1175
  }
1027
- if (next.kind !== "tool" || next.name !== cell.name)
1176
+ if (next.kind !== "tool" || !isExploreTool(next.name))
1028
1177
  break;
1029
1178
  e += 1;
1030
1179
  }
@@ -1042,13 +1191,18 @@ export class Body {
1042
1191
  this.#rolledHeads.add(head);
1043
1192
  let total = 0;
1044
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 = [];
1045
1199
  for (const m of members) {
1046
1200
  // the lines count, excluding the tool's OWN truncation note
1047
1201
  // (read_file's "… N more lines") — the per-cell meta's rule
1048
1202
  const noteAt = m.resultText.lastIndexOf("\n… ");
1049
1203
  const shown = noteAt >= 0 ? m.resultText.slice(0, noteAt) : m.resultText;
1050
- const parts = shown.split("\n");
1051
- 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;
1052
1206
  let input = {};
1053
1207
  try {
1054
1208
  input = JSON.parse(m.inputFull);
@@ -1059,11 +1213,19 @@ export class Body {
1059
1213
  }
1060
1214
  const target = toolTarget(m.name, input);
1061
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);
1062
1222
  }
1063
1223
  const first = members[0];
1064
1224
  const last = members[members.length - 1];
1065
1225
  const elapsed = first.startedAt !== null && last.doneAt !== null ? ((last.doneAt - first.startedAt) / 1000).toFixed(1) : "?";
1066
- 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 } : {}) };
1067
1229
  return cellComponent(cell).render(W, ctx);
1068
1230
  }
1069
1231
  // a MEMBER of an already-rolled run → [] (its rows live in the
@@ -1134,7 +1296,11 @@ export class Body {
1134
1296
  if (menu === null || menu === undefined || menu.items.length === 0)
1135
1297
  return [];
1136
1298
  const p = palette();
1137
- 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)];
1138
1304
  for (let i = 0; i < menu.items.length; i += 1) {
1139
1305
  const item = menu.items[i];
1140
1306
  const text = i === menu.selected
@@ -1268,6 +1434,7 @@ export class Body {
1268
1434
  * EVERY row is idempotent: N consecutive resizes end with the same
1269
1435
  * screen as a single jump to the same size. */
1270
1436
  #drawFull(out, W, H, liveTop, liveLines, queueRows, menuRows, editor) {
1437
+ const overlay = this.#overlayFrame;
1271
1438
  const inputExtra = editor.rows.length - 1; // KC1: the composer's rows above the retired single input row
1272
1439
  const committed = this.#committedLinesThisFrame;
1273
1440
  // 0. the FROZEN rows — the re-folded committed content (re-flowed
@@ -1295,7 +1462,15 @@ export class Body {
1295
1462
  // window (the committed share + the live + the chrome), r
1296
1463
  // monotone, every row 1..H re-painted (the V6-1 every-row rule).
1297
1464
  const all = [...frozen, ...committed, ...liveLines];
1298
- 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);
1299
1474
  // A8b (the shrink-trigger's completion): the rows that LEAVE the
1300
1475
  // window scroll into the terminal's scrollback — the LF mechanism
1301
1476
  // (the steady path's own). Only the rows the paint re-covers (the
@@ -1307,7 +1482,7 @@ export class Body {
1307
1482
  // shrink EVERY frame) loses the scrolled-away turns from the
1308
1483
  // terminal's scrollback entirely (finding #A8b — the queued-flood
1309
1484
  // content loss).
1310
- if (skip > 0) {
1485
+ if (skip > 0 && !overlay) {
1311
1486
  const leaving = Math.max(0, skip - this.#lastSkip);
1312
1487
  // A8b (the fresh leaving share): a leaving row whose old-screen
1313
1488
  // copy is stale — the committed-this-frame lines (their old rows
@@ -1331,9 +1506,16 @@ export class Body {
1331
1506
  for (let i = 0; i < skip; i += 1)
1332
1507
  out.push("\n");
1333
1508
  }
1334
- this.#lastSkip = skip;
1509
+ if (!overlay)
1510
+ this.#lastSkip = skip;
1335
1511
  let r = 1;
1336
- 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) {
1337
1519
  out.push(`\x1b[${r};1H\x1b[0K${this.#checked(line, W)}`);
1338
1520
  r += 1;
1339
1521
  }
@@ -1384,8 +1566,12 @@ export class Body {
1384
1566
  // frozenCount, so the lines at [frozenCount..skip−1] are the fresh
1385
1567
  // leaving share (their old-screen copies are stale).
1386
1568
  const frozenCount = this.#committedLines - committed.length;
1387
- const skip = Math.max(0, this.#committedLines + liveLines.length + CHROME_ROWS + inputExtra + queueRows.length + menuRows.length - H);
1388
- 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);
1389
1575
  // the jump to the bottom row H, then N real LFs scroll the screen
1390
1576
  // exactly N rows — ONE per committed line (the bookkeeping; the
1391
1577
  // stale 1B anchor jumped to H−1 and the N LFs scrolled only N−1 —
@@ -1605,6 +1791,15 @@ export class Dock {
1605
1791
  }
1606
1792
  compositorRef.bindApproval(state);
1607
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
+ }
1608
1803
  bindInput(state, prompt) {
1609
1804
  if (compositorRef === null) {
1610
1805
  dockBindings.state = state; // the live buffer — order-agnostic
@@ -1655,4 +1850,4 @@ let compositorRef = null;
1655
1850
  * — the old snapshot froze `menu` at bindInput time and the slash-
1656
1851
  * command menu silently never bound in the real CLI (the e2e gates
1657
1852
  * bind the Body directly and could not see it). */
1658
- 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
+ }
package/dist/editor.d.ts CHANGED
@@ -60,6 +60,9 @@ export declare class Editor {
60
60
  bindQueue(state: () => readonly string[], pop: () => string | null): void;
61
61
  /** The whole buffer as text (the CLI's line()/clearLine()). */
62
62
  line(): string;
63
+ /** TUI2-R1 (D): whether the keys sheet is up — the compositor's slot
64
+ * read (bound like the menu and the picker). */
65
+ sheetOpen(): boolean;
63
66
  clearLine(): void;
64
67
  /** The dock's input-row state — ADDITIVE (§5): `line` + `cursor` keep
65
68
  * their legacy meaning (the CURSOR LINE's visible slice and the
package/dist/editor.js CHANGED
@@ -42,6 +42,8 @@ export const MENU_ITEMS = [
42
42
  { name: "/think", desc: "show the last full thinking block" },
43
43
  { name: "/last", desc: "show the most recent tool call's input and output" },
44
44
  { name: "/status", desc: "show session id, event count, and context estimate" },
45
+ // TUI2-R1 (E): the rent-ledger attribution — where the context went
46
+ { name: "/context", desc: "show where the context went — the last request's rent ledger" },
45
47
  { name: "/help", desc: "print this list of commands" },
46
48
  ];
47
49
  /** KC1 §3 — the newline code point. Every source (paste, Ctrl+J, the
@@ -109,6 +111,12 @@ export class Editor {
109
111
  // coexist; the editor never interprets the key itself.
110
112
  #expandCbs = [];
111
113
  #onRender;
114
+ /** TUI2-R1 (D): the keys sheet — a static one-screen overlay opened by
115
+ * `?` on an empty composer and closed by the next key, whatever it
116
+ * is. Deliberately a BOOLEAN and not a panel: the panel machinery
117
+ * exists for interactions (a lead, a status, a reducer, a stashed
118
+ * buffer), and the sheet has no interaction to speak of. */
119
+ #sheetOpen = false;
112
120
  #menuOpen = false; // v3 §04: the slash-command menu
113
121
  #menuSel = 0;
114
122
  // KC3 §3 — the @ file picker. THREE fields and no more: the armed
@@ -194,6 +202,11 @@ export class Editor {
194
202
  line() {
195
203
  return String.fromCodePoint(...this.#chars);
196
204
  }
205
+ /** TUI2-R1 (D): whether the keys sheet is up — the compositor's slot
206
+ * read (bound like the menu and the picker). */
207
+ sheetOpen() {
208
+ return this.#sheetOpen;
209
+ }
197
210
  clearLine() {
198
211
  this.#chars = [];
199
212
  this.#cursor = 0;
@@ -510,6 +523,17 @@ export class Editor {
510
523
  feed(raw) {
511
524
  const text = this.#pending + this.#decoder.decode(raw, { stream: true });
512
525
  this.#pending = "";
526
+ // TUI2-R1 (D): the sheet is up — ANY key closes it, and the key
527
+ // that closed it is CONSUMED. The whole chunk goes, deliberately:
528
+ // an arrow key is three bytes, and closing on the first while
529
+ // letting `[A` fall through as literal text would be a sheet that
530
+ // types into your composer on the way out. A dismissal costs one
531
+ // keystroke; that is the entire contract.
532
+ if (this.#sheetOpen) {
533
+ this.#sheetOpen = false;
534
+ this.#onRender();
535
+ return;
536
+ }
513
537
  let i = 0;
514
538
  while (i < text.length) {
515
539
  const c = text[i];
@@ -752,6 +776,17 @@ export class Editor {
752
776
  cb();
753
777
  i += 1;
754
778
  }
779
+ else if (c === "?" && this.#composerIdle() && this.#chars.length === 0) {
780
+ // TUI2-R1 (D): `?` opens the keys sheet — but ONLY on an
781
+ // empty composer with nobody else holding the keys. Mid-text
782
+ // it is the question mark a human is typing, and #composerIdle
783
+ // already encodes "no panel, no menu, no picker, no browse".
784
+ // The precedence can only ever ADD: every state that used to
785
+ // insert a `?` still inserts one.
786
+ this.#sheetOpen = true;
787
+ this.#onRender();
788
+ i += 1;
789
+ }
755
790
  else if (c !== undefined && c < " ") {
756
791
  i += 1; // other control — ignored
757
792
  }
package/dist/index.d.ts CHANGED
@@ -7,12 +7,13 @@
7
7
  */
8
8
  export { Body, Dock, CURSOR_MARKER, type BodyOptions } from "./compositor.js";
9
9
  export { panelAffordance, panelBlockRows, panelLead, panelLeadPlain, panelLeadWidth, panelStatus, type PanelArgs, type PanelFlavor, type PanelPhase, type PanelSel, type PanelState, type PanelVerdict, type PanelView, } from "./approval-panel.js";
10
- export { Container, foldLine, visibleWidth, SPINNER, type Component, type FrameCtx } from "./components.js";
10
+ export { Container, foldLine, foldWords, visibleWidth, SPINNER, type Component, type FrameCtx } from "./components.js";
11
11
  export { Editor, MENU_ITEMS, PROMPT, PROMPT_WIDTH, displayWidth, charWidth, widthOf, type MenuItem, } from "./editor.js";
12
12
  export { bannerLines, COLOR_OFF, COLOR_ON, escapeTerminal, foldResult, foldThinking, kUnit, palette, renderEvent, renderRecap, renderResumeList, renderSessionLine, renderStatusLine, relativeTime, renderTerminalGap, renderToolSummary, TAGLINE, toolTarget, truncateRow, type Palette, type PathResolver, type RecapStats, type ResumeMeta, type RenderInput, type RenderResult, type RunUsage, } from "./render.js";
13
13
  export { editFileDiff, truncateDiff, writeFileDiff, type DiffLine, type DiffResult } from "./diff.js";
14
- export { STATUS_GLYPHS, idleStatus, runningStatus } from "./status.js";
14
+ export { STATUS_GLYPHS, cacheHitPct, idleStatus, runningStatus, type StatusMeter } from "./status.js";
15
+ export { contextRows, contextUnavailableRows, type ContextLedger } from "./context-ledger.js";
15
16
  export { interactivePrompt, projectTrustRows, projectTrustView, projectUntrustedNote, uncertainView, type TrustArtifact } from "./strings.js";
16
17
  export { AT_CAP, AT_SKIP, AT_VISIBLE, atEmbed, atFilter, atPanelRows, atWindow, longestRun, type AtItem, type AtMatch } from "./at-picker.js";
17
18
  export { ASK_HEADER_CAP, ASK_MAX_OPTIONS, ASK_MAX_QUESTIONS, ASK_MIN_OPTIONS, askAffordance, askAnswers, askBlockRows, askCommitCustom, askDeclineAll, askDeclineList, askKey, askLeadPlain, askStart, askStatus, askView, type AskAnswer, type AskOption, type AskQuestion, type AskResult, type AskRuntime, type AskSpec, type AskStep, } from "./ask-panel.js";
18
- export { extensionsBannerText, helpRows, unansweredAskView, type BannerExtension } from "./strings.js";
19
+ export { KEY_BINDINGS, PANEL_KEYS_ROW, extensionsBannerText, helpRows, keysHelpRow, keysSheetRows, unansweredAskView, type BannerExtension, type KeyBinding } from "./strings.js";
package/dist/index.js CHANGED
@@ -10,13 +10,16 @@ export { Body, Dock, CURSOR_MARKER } from "./compositor.js";
10
10
  // that replaces the running tool's live window while a human-chain
11
11
  // approval is pending (the shape authority is the committed preview).
12
12
  export { panelAffordance, panelBlockRows, panelLead, panelLeadPlain, panelLeadWidth, panelStatus, } from "./approval-panel.js";
13
- export { Container, foldLine, visibleWidth, SPINNER } from "./components.js";
13
+ export { Container, foldLine, foldWords, visibleWidth, SPINNER } from "./components.js";
14
14
  export { Editor, MENU_ITEMS, PROMPT, PROMPT_WIDTH, displayWidth, charWidth, widthOf, } from "./editor.js";
15
15
  export { bannerLines, COLOR_OFF, COLOR_ON, escapeTerminal, foldResult, foldThinking, kUnit, palette, renderEvent, renderRecap, renderResumeList, renderSessionLine, renderStatusLine, relativeTime, renderTerminalGap, renderToolSummary, TAGLINE, toolTarget, truncateRow, } from "./render.js";
16
16
  export { editFileDiff, truncateDiff, writeFileDiff } from "./diff.js";
17
17
  // KC2 §5: the status rows' formatters — the CLI keeps the state and the
18
18
  // repaint, the terminal layer owns what the row says.
19
- export { STATUS_GLYPHS, idleStatus, runningStatus } from "./status.js";
19
+ export { STATUS_GLYPHS, cacheHitPct, idleStatus, runningStatus } from "./status.js";
20
+ // TUI2-R1 (E): /context's attribution rows — a pure function of the
21
+ // counts the trace sidecar already records (the CLI reads, this renders).
22
+ export { contextRows, contextUnavailableRows } from "./context-ledger.js";
20
23
  // KC3 §1 (the extraction): the human-facing strings — the prompt, the
21
24
  // project-trust listing/view/note, the uncertain execution's view. The
22
25
  // FLOW (who is asked, what a verdict means) stays in the cli.
@@ -30,4 +33,6 @@ export { AT_CAP, AT_SKIP, AT_VISIBLE, atEmbed, atFilter, atPanelRows, atWindow,
30
33
  export { ASK_HEADER_CAP, ASK_MAX_OPTIONS, ASK_MAX_QUESTIONS, ASK_MIN_OPTIONS, askAffordance, askAnswers, askBlockRows, askCommitCustom, askDeclineAll, askDeclineList, askKey, askLeadPlain, askStart, askStatus, askView, } from "./ask-panel.js";
31
34
  // KC3.5 §4: the interrupted-ask copy — the SAME uncertainty gate, said
32
35
  // honestly for a question nobody answered (the ① probe's surface).
33
- export { extensionsBannerText, helpRows, unansweredAskView } from "./strings.js";
36
+ // TUI2-R1 (D): the keys sheet + THE key table — one source for the ?
37
+ // overlay and /help's keys row.
38
+ export { KEY_BINDINGS, PANEL_KEYS_ROW, extensionsBannerText, helpRows, keysHelpRow, keysSheetRows, unansweredAskView } from "./strings.js";
package/dist/status.d.ts CHANGED
@@ -33,6 +33,36 @@ export declare const STATUS_GLYPHS: readonly ["▖", "▘", "▝", "▗"];
33
33
  * because it is on screen exactly when the gesture is useful.
34
34
  */
35
35
  export declare function runningStatus(glyph: string, since: number, outTokens: number | null, ctxRatio: number): string;
36
+ /**
37
+ * TUI2-R1 (E) — the idle row's meter: what the session has SPENT, next
38
+ * to what it has left.
39
+ *
40
+ * Both fields are optional and both are omitted when unknown, because
41
+ * the row's job is to be true rather than complete:
42
+ *
43
+ * - `cacheHitPct` is cacheRead / (fresh + cacheRead) — the E2
44
+ * denominator (the pinned sentence: it cannot exceed 100%). A
45
+ * session with no usage yet has no cache hit rate, and an
46
+ * unmeasured cache is NOT a 0% cache, so it renders nothing.
47
+ * - `costUsd` is the CANONICAL cost, which is null whenever the
48
+ * pricing table has no rate for the route. Null renders nothing.
49
+ * No rate table, no number — kiso does not invent a price.
50
+ */
51
+ export interface StatusMeter {
52
+ readonly cacheHitPct: number | null;
53
+ readonly costUsd: number | null;
54
+ }
36
55
  /** The IDLE row: the approval tier as the CALLER names it, the /mode
37
- * hint, the model driving the session, and the ctx estimate. */
38
- export declare function idleStatus(tier: string, model: string, ctxRatio: number): string;
56
+ * hint, the model driving the session, the TUI2-R1 meter when there is
57
+ * one, and the ctx estimate. Called without a meter — or with one that
58
+ * knows nothing — the row is byte-identical to the pre-round row. */
59
+ export declare function idleStatus(tier: string, model: string, ctxRatio: number, meter?: StatusMeter): string;
60
+ /** TUI2-R1 (E) — the cache hit rate the status row shows, from the usage
61
+ * the CLI already tracks. The denominator is the TOTAL the model was
62
+ * given (fresh + cacheRead), which is the E2 ruling's own: cacheRead
63
+ * over fresh alone once rendered 923%. No input at all → null, never a
64
+ * zero. */
65
+ export declare function cacheHitPct(usage: {
66
+ in: number | null;
67
+ cache: number | null;
68
+ }): number | null;
package/dist/status.js CHANGED
@@ -46,7 +46,28 @@ export function runningStatus(glyph, since, outTokens, ctxRatio) {
46
46
  return `${glyph} working ${seconds}s${out} · esc stop · alt+⏎ redirect · ctx left ~${ctxLeft(ctxRatio)}%`;
47
47
  }
48
48
  /** The IDLE row: the approval tier as the CALLER names it, the /mode
49
- * hint, the model driving the session, and the ctx estimate. */
50
- export function idleStatus(tier, model, ctxRatio) {
51
- return `▸ ${tier} · /mode to switch · ${model} · ctx left ~${ctxLeft(ctxRatio)}%`;
49
+ * hint, the model driving the session, the TUI2-R1 meter when there is
50
+ * one, and the ctx estimate. Called without a meter — or with one that
51
+ * knows nothing the row is byte-identical to the pre-round row. */
52
+ export function idleStatus(tier, model, ctxRatio, meter) {
53
+ const parts = [`▸ ${tier}`, "/mode to switch", model];
54
+ if (meter?.cacheHitPct != null)
55
+ parts.push(`CH ${Math.round(meter.cacheHitPct)}%`);
56
+ if (meter?.costUsd != null)
57
+ parts.push(`$${meter.costUsd.toFixed(4)}`);
58
+ parts.push(`ctx left ~${ctxLeft(ctxRatio)}%`);
59
+ return parts.join(" · ");
60
+ }
61
+ /** TUI2-R1 (E) — the cache hit rate the status row shows, from the usage
62
+ * the CLI already tracks. The denominator is the TOTAL the model was
63
+ * given (fresh + cacheRead), which is the E2 ruling's own: cacheRead
64
+ * over fresh alone once rendered 923%. No input at all → null, never a
65
+ * zero. */
66
+ export function cacheHitPct(usage) {
67
+ const fresh = usage.in;
68
+ const cached = usage.cache;
69
+ if (fresh === null || cached === null)
70
+ return null;
71
+ const total = fresh + cached;
72
+ return total > 0 ? (cached / total) * 100 : null;
52
73
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-tui",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "kiso tui — the pure terminal layer (cell renderer, dock, raw editor, diff, palette). Zero runtime dependencies: input is data, output is bytes.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -35,6 +35,6 @@
35
35
  },
36
36
  "homepage": "https://github.com/vincemakes/kiso/tree/main/packages/tui#readme",
37
37
  "dependencies": {
38
- "@vincemakes/kiso-tui-cells": "0.8.0"
38
+ "@vincemakes/kiso-tui-cells": "0.9.0"
39
39
  }
40
40
  }