@vincemakes/kiso-tui 0.17.0 → 0.19.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.
@@ -184,6 +184,49 @@ export declare class Body {
184
184
  isError: boolean;
185
185
  };
186
186
  } | null;
187
+ /**
188
+ * R4 (C4d) — THE APPEND-ONLY RE-WRAP.
189
+ *
190
+ * The owner's report: resize the window and the reference
191
+ * implementation's text re-wraps to the new width while kiso's does
192
+ * not. It is true, and it is not a bug to be fixed — it is the price
193
+ * of ADR-0046, and the price is worth naming precisely.
194
+ *
195
+ * A terminal can only reflow a SOFT-wrapped line: one long logical
196
+ * line the terminal itself wrapped as the cursor flowed past the last
197
+ * column. Every row kiso commits is either painted by cursor
198
+ * addressing (#emitDiff) or scrolled out by a bare LF (#emitScroll),
199
+ * and frames run with autowrap OFF — so no byte kiso commits can ever
200
+ * carry a continuation flag, and nothing downstream can rejoin rows an
201
+ * application hard-split. That same LF is what makes the transcript
202
+ * the TERMINAL's: it survives kiso's death, a pipe, and tmux. A
203
+ * product whose transcript reflows is a product that repaints its
204
+ * transcript from its own memory, and that transcript dies with it.
205
+ *
206
+ * What kiso can do — and this is all it can do — is APPEND. The
207
+ * committed cells are still in memory; re-render them at the current
208
+ * width and put them at the BOTTOM, where writing is allowed. Nothing
209
+ * above is rewritten, so ADR-0046 holds exactly.
210
+ *
211
+ * Scoped to PROSE. Text is what reads badly at the wrong width — a
212
+ * paragraph folded for 120 columns and read at 60 is the complaint.
213
+ * Tool rows, folds and chips are short, already carry their own
214
+ * width ladders, and re-printing them would duplicate work the folds
215
+ * exist to state once.
216
+ */
217
+ rewrap(): {
218
+ lines: string[];
219
+ blocks: number;
220
+ skipped: number;
221
+ };
222
+ /** Whether the viewer owns the live region right now. */
223
+ viewerOpen(): boolean;
224
+ /** ctrl+o — open on the newest fold, or close. */
225
+ viewerToggleMode(): void;
226
+ /** The viewer's keys. Everything the surface can do, a key does —
227
+ * there is no pointer, so there is nothing a pointer could reach
228
+ * that a keyboard cannot. */
229
+ viewerKey(cmd: "up" | "down" | "toggle" | "all" | "pageUp" | "pageDown" | "home" | "end"): void;
187
230
  expandNext(): {
188
231
  kind: "toggled";
189
232
  } | {
@@ -328,6 +371,13 @@ export declare class Dock {
328
371
  /** TUI2-R1 (D): bind the editor's keys-sheet flag — the slot read for
329
372
  * the ? overlay (the menu/picker binding pattern). */
330
373
  bindSheet(state: () => boolean): void;
374
+ /** R5 — the transcript viewer's three doors, forwarded to the live
375
+ * compositor. Unlike the other bindings there is no buffer: the
376
+ * viewer cannot be open before a compositor exists, so a call with
377
+ * no compositor is a no-op rather than a queued intent. */
378
+ viewerOpen(): boolean;
379
+ viewerToggleMode(): void;
380
+ viewerKey(cmd: "up" | "down" | "toggle" | "all" | "pageUp" | "pageDown" | "home" | "end"): void;
331
381
  bindInput(state: () => InputState, prompt: string): void;
332
382
  bindMenu(state: () => {
333
383
  items: readonly MenuItem[];
@@ -52,9 +52,12 @@ import { MOUSE_OFF } from "./editor.js";
52
52
  import { atPanelRows, bandHeader } from "./at-picker.js";
53
53
  // TUI2-R2 ②: the session picker's rows — the band's third occupant.
54
54
  import { sessionPickerRows } from "./session-picker.js";
55
- import { Container, ROLLUP_NOUN, MOTION_FRAMES, MdStream, bodySpacing, boxBottom, boxTop, cellComponent, exploreCounts, foldCountsObjects, foldTerms, focusToken, exploreRows, foldLine, isExploreTool, pendingQueueRows, statusLine, stretchLine, turnFold, visibleWidth, twinkleFrame, } from "./components.js";
55
+ import { ACT_SLOT_ROWS, Container, ROLLUP_NOUN, MOTION_FRAMES, MdStream, bodySpacing, boxBottom, boxTop, cellComponent, exploreCounts, foldCountsObjects, foldTerms, focusToken, exploreRows, foldLine, cutLine, isExploreTool, moreRunningRow, pendingQueueRows, slotPad, slotTail, statusLine, stretchLine, turnFold, visibleWidth, twinkleFrame, } from "./components.js";
56
56
  import { bannerLines, escapeTerminal, foldResult, foldThinking, palette, renderTerminalGap, renderToolSummary, toolTarget } from "./render.js";
57
57
  import { displayVerb, keysSheetRows } from "./strings.js";
58
+ // R5 — the transcript viewer's PURE projection. The compositor supplies
59
+ // the entries (it holds the cells); the arrangement lives there.
60
+ import { VIEWER_GUTTER, viewerFlat, viewerHint, viewerInit, viewerMove, viewerRows, viewerScroll, viewerTitle, viewerToggle, viewerToggleAll, } from "./transcript.js";
58
61
  /** The cursor marker — an APC private sequence the focus component
59
62
  * embeds at the edit position; the compositor strips it and moves
60
63
  * relatively (it never reaches the terminal). */
@@ -314,7 +317,15 @@ export class Body {
314
317
  // rendered row carried the "ctrl+r" affordance; the expand key's
315
318
  // cycling pointer walks this list from the newest back.
316
319
  #collapsed = [];
317
- #expandPtr = 0;
320
+ /** R4 (C1) — the ring walk is by IDENTITY, not by a modular pointer.
321
+ * `#collapsed` is unshifted on every commit that carries the key, so
322
+ * a numeric pointer's target silently CHANGED whenever a new fold
323
+ * landed mid-cycle: the ring was not stable under itself, and the
324
+ * next press opened something other than what the last press
325
+ * implied. This set records what the current cycle has already
326
+ * opened; the walk takes the newest entry not in it, and empties it
327
+ * when every entry has been seen. */
328
+ #opened = new Set();
318
329
  // W14: the turn records — one per userLine, the fold-hold's state
319
330
  // machine (ended / hasText / folded) plus the folded-turn line's
320
331
  // counts (accumulated at toolStart). The cells carry the record's
@@ -341,6 +352,11 @@ export class Body {
341
352
  * Unbound, the sheet cannot render and every frame is byte-identical
342
353
  * to before the round. */
343
354
  #sheetState = null;
355
+ /** R5 — the transcript viewer's state, or null when it is closed. It
356
+ * lives HERE rather than in the editor because its entries are the
357
+ * compositor's cells; the editor only sends it commands. */
358
+ #viewer = null;
359
+ #viewerWasUp = false;
344
360
  /** TUI2-R1.5 7(a): the sheet's previous up/down state — a transition
345
361
  * in either direction takes the full-redraw path. */
346
362
  #sheetWasUp = false;
@@ -1024,6 +1040,283 @@ export class Body {
1024
1040
  }
1025
1041
  return -1;
1026
1042
  }
1043
+ /**
1044
+ * R4 (C4d) — THE APPEND-ONLY RE-WRAP.
1045
+ *
1046
+ * The owner's report: resize the window and the reference
1047
+ * implementation's text re-wraps to the new width while kiso's does
1048
+ * not. It is true, and it is not a bug to be fixed — it is the price
1049
+ * of ADR-0046, and the price is worth naming precisely.
1050
+ *
1051
+ * A terminal can only reflow a SOFT-wrapped line: one long logical
1052
+ * line the terminal itself wrapped as the cursor flowed past the last
1053
+ * column. Every row kiso commits is either painted by cursor
1054
+ * addressing (#emitDiff) or scrolled out by a bare LF (#emitScroll),
1055
+ * and frames run with autowrap OFF — so no byte kiso commits can ever
1056
+ * carry a continuation flag, and nothing downstream can rejoin rows an
1057
+ * application hard-split. That same LF is what makes the transcript
1058
+ * the TERMINAL's: it survives kiso's death, a pipe, and tmux. A
1059
+ * product whose transcript reflows is a product that repaints its
1060
+ * transcript from its own memory, and that transcript dies with it.
1061
+ *
1062
+ * What kiso can do — and this is all it can do — is APPEND. The
1063
+ * committed cells are still in memory; re-render them at the current
1064
+ * width and put them at the BOTTOM, where writing is allowed. Nothing
1065
+ * above is rewritten, so ADR-0046 holds exactly.
1066
+ *
1067
+ * Scoped to PROSE. Text is what reads badly at the wrong width — a
1068
+ * paragraph folded for 120 columns and read at 60 is the complaint.
1069
+ * Tool rows, folds and chips are short, already carry their own
1070
+ * width ladders, and re-printing them would duplicate work the folds
1071
+ * exist to state once.
1072
+ */
1073
+ rewrap() {
1074
+ const W = this.#opts.width();
1075
+ const H = this.#opts.height();
1076
+ const ctx = { spinnerI: this.#spinnerI, now: Date.now(), height: H };
1077
+ // two screens is the bound: enough to re-read what a resize just
1078
+ // made awkward, short enough that the append is not its own wall
1079
+ // of text. A silent cap would read as "this is all of it".
1080
+ const budget = Math.max(H, 2 * H);
1081
+ const chunks = [];
1082
+ let rows = 0;
1083
+ let blocks = 0;
1084
+ let skipped = 0;
1085
+ for (let i = this.#committed - 1; i >= 0; i -= 1) {
1086
+ const cell = this.#cells[i];
1087
+ if (cell.kind !== "md")
1088
+ continue;
1089
+ blocks += 1;
1090
+ if (rows >= budget) {
1091
+ skipped += 1;
1092
+ continue;
1093
+ }
1094
+ const lines = cellComponent(cell).render(W, ctx);
1095
+ chunks.unshift(lines);
1096
+ rows += lines.length;
1097
+ }
1098
+ return { lines: chunks.flat(), blocks: blocks - skipped, skipped };
1099
+ }
1100
+ /**
1101
+ * R5 — the rows a fold stands for, as a PURE projection.
1102
+ *
1103
+ * Extracted from expandNext so the transcript viewer and the
1104
+ * expand key open the same work by construction rather than by
1105
+ * two copies agreeing. It renders cells; it mutates none of
1106
+ * them beyond the head.rolled save/restore the rollup path has
1107
+ * always used, which does not outlive this synchronous call.
1108
+ */
1109
+ #foldBody(seg, idx, W, ctx) {
1110
+ const p = palette();
1111
+ const rows = [];
1112
+ let run = [];
1113
+ const flush = () => {
1114
+ if (run.length === 0)
1115
+ return;
1116
+ // the same threshold the commit-time rollup uses: below it a
1117
+ // "run" is just some rows
1118
+ if (run.length > 2) {
1119
+ // the run renders through the ROLLUP's own projection —
1120
+ // literally the same function the commit path uses — so
1121
+ // a single-name run keeps W13's row and a mixed one gets
1122
+ // the exploration line, exactly as they would have if the
1123
+ // segment had never folded.
1124
+ const head = run[0];
1125
+ const saved = head.rolled;
1126
+ head.rolled = rolledOf(run);
1127
+ // the run OPENS. The fold's key already asked to see the
1128
+ // work, so what lands is the same rows `ctrl+r` on the
1129
+ // run itself would have opened — its title, then its
1130
+ // detail — never its collapsed row, which would make the
1131
+ // reader press a second time for what the first press
1132
+ // was for.
1133
+ rows.push(` ${p.dim}${escapeTerminal(rolledTitle(head))}${p.reset}`);
1134
+ rows.push(...rolledDetail(head, W));
1135
+ head.rolled = saved;
1136
+ }
1137
+ else {
1138
+ for (const c of run)
1139
+ rows.push(...cellComponent(c).render(W, ctx));
1140
+ }
1141
+ run = [];
1142
+ };
1143
+ // R3f — the expansion covers the WHOLE TURN, every segment.
1144
+ //
1145
+ // R3d moved the fold to the turn while the expansion kept
1146
+ // walking one segment, so a turn that spoke between calls
1147
+ // folded to a line claiming `3 reads · 1 edit · 1 shell` whose
1148
+ // key opened only the reads: the edit and the shell were on no
1149
+ // surface and reachable by no key. That is the one thing this
1150
+ // round's own first gate forbids — the work is never
1151
+ // unreachable — and it is worse than never folding, because the
1152
+ // line names work it then withholds.
1153
+ //
1154
+ // A run still BREAKS at a non-explore cell, so the segment
1155
+ // boundaries survive where they carry meaning (the write that
1156
+ // splits two explore runs); they simply no longer bound what
1157
+ // the key can reach.
1158
+ // DECLARED SUPERSESSION (R3i phase 3) — the expansion covers THIS
1159
+ // STRETCH, and only this stretch.
1160
+ //
1161
+ // R3f widened it to the whole turn, and had to: R3d had made
1162
+ // the fold the TURN's while the expansion still walked one
1163
+ // segment, so a line claiming `read 3 files · edited 1 file`
1164
+ // opened only the reads — work named and then withheld, the one
1165
+ // thing this file's first gate forbids. R3i moves the fold back
1166
+ // to the stretch, so the pairing is exact again: every stretch
1167
+ // has its OWN line and its own key, and each key opens the work
1168
+ // its line named. Keeping the turn walk would break the same
1169
+ // rule from the other side — two lines, each opening
1170
+ // everything, each header describing rows the other also shows.
1171
+ for (const j of seg.cells) {
1172
+ if (j < idx)
1173
+ continue;
1174
+ const c = this.#cells[j];
1175
+ if (c.kind === "tool" && isExploreTool(c.name)) {
1176
+ run.push(c);
1177
+ continue;
1178
+ }
1179
+ flush();
1180
+ rows.push(...cellComponent(c).render(W, ctx));
1181
+ }
1182
+ flush();
1183
+ return rows;
1184
+ }
1185
+ // ─── R5: the transcript viewer ──────────────────────────────────
1186
+ //
1187
+ // The viewer occupies the LIVE REGION, exactly as the keys sheet
1188
+ // does. It is not the alternate buffer and never will be: while an
1189
+ // overlay is up the window is frozen, no LF is emitted, nothing
1190
+ // enters the scrollback, and the close takes the full-redraw path
1191
+ // and restores every displaced row (TUI2-R1.5 7(a), and its gate).
1192
+ /** Whether the viewer owns the live region right now. */
1193
+ viewerOpen() {
1194
+ return this.#viewer !== null;
1195
+ }
1196
+ /** ctrl+o — open on the newest fold, or close. */
1197
+ viewerToggleMode() {
1198
+ if (this.#viewer !== null) {
1199
+ this.#viewer = null;
1200
+ }
1201
+ else {
1202
+ const ctx = { spinnerI: this.#spinnerI, now: Date.now(), height: this.#opts.height() };
1203
+ this.#viewer = viewerInit(this.#viewerEntries(this.#opts.width(), ctx));
1204
+ }
1205
+ this.#mark();
1206
+ }
1207
+ /** The viewer's keys. Everything the surface can do, a key does —
1208
+ * there is no pointer, so there is nothing a pointer could reach
1209
+ * that a keyboard cannot. */
1210
+ viewerKey(cmd) {
1211
+ if (this.#viewer === null)
1212
+ return;
1213
+ const W = this.#opts.width();
1214
+ const ctx = { spinnerI: this.#spinnerI, now: Date.now(), height: this.#opts.height() };
1215
+ const entries = this.#viewerEntries(W, ctx);
1216
+ const rows = this.#viewerBandRows();
1217
+ const s = this.#viewer;
1218
+ switch (cmd) {
1219
+ case "up":
1220
+ this.#viewer = viewerMove(entries, s, -1, rows);
1221
+ break;
1222
+ case "down":
1223
+ this.#viewer = viewerMove(entries, s, +1, rows);
1224
+ break;
1225
+ case "home":
1226
+ this.#viewer = viewerMove(entries, s, -entries.length, rows);
1227
+ break;
1228
+ case "end":
1229
+ this.#viewer = viewerMove(entries, s, entries.length, rows);
1230
+ break;
1231
+ case "pageUp":
1232
+ this.#viewer = viewerScroll(entries, s, -rows, rows);
1233
+ break;
1234
+ case "pageDown":
1235
+ this.#viewer = viewerScroll(entries, s, rows, rows);
1236
+ break;
1237
+ case "toggle":
1238
+ this.#viewer = viewerToggle(entries, s, rows);
1239
+ break;
1240
+ case "all":
1241
+ this.#viewer = viewerToggleAll(entries, s, rows);
1242
+ break;
1243
+ }
1244
+ this.#mark();
1245
+ }
1246
+ /** How many rows the viewer's LIST gets: the content cap, less its
1247
+ * own title and hint rows. */
1248
+ #viewerBandRows() {
1249
+ const H = this.#opts.height();
1250
+ const W = this.#opts.width();
1251
+ const queueRows = this.#queueRows(W, H);
1252
+ const inputExtra = this.#inputRows(W, H, this.#menuRows(W).length, queueRows.length).rows.length - 1;
1253
+ return Math.max(1, H - 4 - inputExtra - queueRows.length - 2);
1254
+ }
1255
+ /**
1256
+ * The expandable things in the transcript, oldest first.
1257
+ *
1258
+ * The set is `#collapsed` — the SAME ring `ctrl+r` walks — so the two
1259
+ * mechanisms can never disagree about what is reachable. The ring is
1260
+ * newest-first (it is unshifted on commit); reading order is the
1261
+ * other way, so it is reversed here.
1262
+ *
1263
+ * Every entry is rendered at the CURRENT width, which is what makes
1264
+ * this surface the answer to "I resized and want to re-read the
1265
+ * history": the scrollback copy stays the immutable original at its
1266
+ * original widths, and this is where you go to read it at today's.
1267
+ */
1268
+ #viewerEntries(W, ctx) {
1269
+ const inner = Math.max(1, W - VIEWER_GUTTER);
1270
+ const out = [];
1271
+ for (const idx of [...this.#collapsed].reverse()) {
1272
+ const cell = this.#cells[idx];
1273
+ if (cell === undefined)
1274
+ continue;
1275
+ const seg = this.#segmentOf(idx);
1276
+ if (seg !== null && seg.headCell === idx) {
1277
+ out.push({
1278
+ head: stretchLine({ ...this.#stretchTerms(seg), phase: "settled" }, inner)[0] ?? "",
1279
+ body: this.#foldBody(seg, idx, inner, ctx),
1280
+ });
1281
+ continue;
1282
+ }
1283
+ if (cell.kind !== "tool")
1284
+ continue;
1285
+ // the tool card's FULL body — the same rows its own ctrl+r
1286
+ // opens. The expanded flag is saved and restored inside this
1287
+ // synchronous call, the pattern the rollup path has always
1288
+ // used for head.rolled; it never outlives the render, so the
1289
+ // committed geometry #committedLines derives can never see it.
1290
+ const saved = cell.expanded;
1291
+ cell.expanded = true;
1292
+ const rows = cellComponent(cell).render(inner, ctx);
1293
+ cell.expanded = saved;
1294
+ out.push({ head: rows[0] ?? "", body: rows.slice(1) });
1295
+ }
1296
+ return out;
1297
+ }
1298
+ /** The viewer's band: its title, its list, its keys. */
1299
+ #viewerBand(W) {
1300
+ if (this.#viewer === null)
1301
+ return [];
1302
+ const p = palette();
1303
+ const ctx = { spinnerI: this.#spinnerI, now: Date.now(), height: this.#opts.height() };
1304
+ const entries = this.#viewerEntries(W, ctx);
1305
+ const rows = this.#viewerBandRows();
1306
+ const title = viewerTitle(entries);
1307
+ const shown = viewerRows(entries, this.#viewer, W, rows);
1308
+ const flat = viewerFlat(entries, this.#viewer).length;
1309
+ const more = flat > rows ? ` · ${this.#viewer.top + 1}-${Math.min(flat, this.#viewer.top + rows)} of ${flat}` : "";
1310
+ // the rule is MEASURED, not over-generated and cut — a title row
1311
+ // ending in `…` says the title was truncated, which it was not.
1312
+ const label = `── ${escapeTerminal(title)}${more} `;
1313
+ const fill = "─".repeat(Math.max(0, W - visibleWidth(label)));
1314
+ return [
1315
+ cutLine(`${p.dim}${label}${fill}${p.reset}`, W),
1316
+ ...shown,
1317
+ cutLine(`${p.dim} ${viewerHint(this.#viewer, entries)}${p.reset}`, W),
1318
+ ];
1319
+ }
1027
1320
  expandNext() {
1028
1321
  for (let i = this.#cells.length - 1; i >= this.#committed; i -= 1) {
1029
1322
  const cell = this.#cells[i];
@@ -1045,8 +1338,14 @@ export class Body {
1045
1338
  }
1046
1339
  if (this.#collapsed.length === 0)
1047
1340
  return { kind: "none" };
1048
- const idx = this.#collapsed[this.#expandPtr % this.#collapsed.length];
1049
- this.#expandPtr += 1;
1341
+ // R4 (C1) — the newest entry this cycle has not opened yet. When
1342
+ // every entry has been seen the cycle restarts, so the walk is
1343
+ // still "newest back" — it is simply immune to the ring growing
1344
+ // underneath it.
1345
+ if (this.#collapsed.every((i) => this.#opened.has(i)))
1346
+ this.#opened.clear();
1347
+ const idx = this.#collapsed.find((i) => !this.#opened.has(i)) ?? this.#collapsed[0];
1348
+ this.#opened.add(idx);
1050
1349
  const cell = this.#cells[idx];
1051
1350
  // R3b — a folded SEGMENT expands to the work it stands for.
1052
1351
  //
@@ -1078,78 +1377,7 @@ export class Body {
1078
1377
  // stay two runs. Merging every explore tool of the segment
1079
1378
  // would have been simpler and would have quietly deleted that
1080
1379
  // rule.
1081
- const rows = [];
1082
- let run = [];
1083
- const flush = () => {
1084
- if (run.length === 0)
1085
- return;
1086
- // the same threshold the commit-time rollup uses: below it a
1087
- // "run" is just some rows
1088
- if (run.length > 2) {
1089
- // the run renders through the ROLLUP's own projection —
1090
- // literally the same function the commit path uses — so
1091
- // a single-name run keeps W13's row and a mixed one gets
1092
- // the exploration line, exactly as they would have if the
1093
- // segment had never folded.
1094
- const head = run[0];
1095
- const saved = head.rolled;
1096
- head.rolled = rolledOf(run);
1097
- // the run OPENS. The fold's key already asked to see the
1098
- // work, so what lands is the same rows `ctrl+r` on the
1099
- // run itself would have opened — its title, then its
1100
- // detail — never its collapsed row, which would make the
1101
- // reader press a second time for what the first press
1102
- // was for.
1103
- rows.push(` ${p.dim}${escapeTerminal(rolledTitle(head))}${p.reset}`);
1104
- rows.push(...rolledDetail(head, W));
1105
- head.rolled = saved;
1106
- }
1107
- else {
1108
- for (const c of run)
1109
- rows.push(...cellComponent(c).render(W, ctx));
1110
- }
1111
- run = [];
1112
- };
1113
- // R3f — the expansion covers the WHOLE TURN, every segment.
1114
- //
1115
- // R3d moved the fold to the turn while the expansion kept
1116
- // walking one segment, so a turn that spoke between calls
1117
- // folded to a line claiming `3 reads · 1 edit · 1 shell` whose
1118
- // key opened only the reads: the edit and the shell were on no
1119
- // surface and reachable by no key. That is the one thing this
1120
- // round's own first gate forbids — the work is never
1121
- // unreachable — and it is worse than never folding, because the
1122
- // line names work it then withholds.
1123
- //
1124
- // A run still BREAKS at a non-explore cell, so the segment
1125
- // boundaries survive where they carry meaning (the write that
1126
- // splits two explore runs); they simply no longer bound what
1127
- // the key can reach.
1128
- // DECLARED SUPERSESSION (R3i phase 3) — the expansion covers THIS
1129
- // STRETCH, and only this stretch.
1130
- //
1131
- // R3f widened it to the whole turn, and had to: R3d had made
1132
- // the fold the TURN's while the expansion still walked one
1133
- // segment, so a line claiming `read 3 files · edited 1 file`
1134
- // opened only the reads — work named and then withheld, the one
1135
- // thing this file's first gate forbids. R3i moves the fold back
1136
- // to the stretch, so the pairing is exact again: every stretch
1137
- // has its OWN line and its own key, and each key opens the work
1138
- // its line named. Keeping the turn walk would break the same
1139
- // rule from the other side — two lines, each opening
1140
- // everything, each header describing rows the other also shows.
1141
- for (const j of seg.cells) {
1142
- if (j < idx)
1143
- continue;
1144
- const c = this.#cells[j];
1145
- if (c.kind === "tool" && isExploreTool(c.name)) {
1146
- run.push(c);
1147
- continue;
1148
- }
1149
- flush();
1150
- rows.push(...cellComponent(c).render(W, ctx));
1151
- }
1152
- flush();
1380
+ const rows = this.#foldBody(seg, idx, W, ctx);
1153
1381
  // the header NAMES the segment. When the segment is exactly one
1154
1382
  // explore run, "explored 8 files · 14 searches" is what that run
1155
1383
  // is called everywhere else in the product, and the header says
@@ -1176,9 +1404,13 @@ export class Body {
1176
1404
  return {
1177
1405
  kind: "appended",
1178
1406
  lines: [
1407
+ // R4a — the header names the fold in WORDS (its own terms
1408
+ // and how far back it is), not by an ordinal. The ordinal
1409
+ // existed to be typed and never was; the words were
1410
+ // always the part a reader could use.
1179
1411
  `${p.bold}✦${p.reset} expanded · ${escapeTerminal(head.length === 0 ? "thinking" : head.join(" · "))} · ${back}`,
1180
1412
  ...body,
1181
- ` ${p.dim}└ end of expansion · ctrl+r opens the next fold${p.reset}`,
1413
+ ` ${p.dim}└ end of expansion · ctrl+r opens the one before it${p.reset}`,
1182
1414
  ],
1183
1415
  };
1184
1416
  }
@@ -1680,7 +1912,21 @@ export class Body {
1680
1912
  * settle still produces the same fold it did before. That is the
1681
1913
  * charter's line between this phase and the next.
1682
1914
  */
1683
- #liveProjection(W, ctx) {
1915
+ #liveProjection(W, ctx, cap) {
1916
+ const rows = this.#project(W, ctx, ACT_SLOT_ROWS);
1917
+ if (cap === undefined || rows.length <= cap)
1918
+ return rows;
1919
+ // R4 — the slot gives way BEFORE any cell is force-committed.
1920
+ // A standing slot that could overflow the content cap would make
1921
+ // the force-commit loop push REAL cells into the scrollback to
1922
+ // relieve rows that are, at the bottom of the slot, blank padding.
1923
+ // So the slot shrinks first, in the pinned order slotPad already
1924
+ // implements (the pad rows are last, so they go first, then the
1925
+ // tail, then the heads beyond the first) and the floor is one row.
1926
+ return this.#project(W, ctx, Math.max(1, ACT_SLOT_ROWS - (rows.length - cap)));
1927
+ }
1928
+ /** R4 — one pass of the live projection at a given slot budget. */
1929
+ #project(W, ctx, budget) {
1684
1930
  const out = [];
1685
1931
  const focus = this.#focusIndex();
1686
1932
  const turn = this.#turns[this.#turns.length - 1];
@@ -1688,30 +1934,29 @@ export class Body {
1688
1934
  const openSeg = open !== null && open.closedAt === null ? open : null;
1689
1935
  let prev = this.#committed > 0 ? this.#lineCache[this.#committed - 1] : null;
1690
1936
  let stretchDrawn = false;
1691
- let runningShown = 0;
1692
- let runningHidden = 0;
1693
1937
  for (let i = this.#committed; i < this.#cells.length; i += 1) {
1694
1938
  const cell = this.#cells[i];
1695
1939
  const inOpen = openSeg !== null && openSeg.cells.includes(i);
1696
1940
  if (inOpen) {
1697
- // the stretch's ONE line, drawn once, at its first cell
1698
- if (!stretchDrawn) {
1699
- stretchDrawn = true;
1700
- const rows = stretchLine({ ...this.#stretchTerms(openSeg), phase: this.#stretchPhase(openSeg), mark: twinkleFrame(this.#spinnerI) }, W);
1701
- out.push(...this.#space(i, prev, rows));
1702
- prev = rows;
1703
- }
1704
- // a DONE cell's row is gone; its count is on the line above.
1705
- // A cell still in flight keeps its row and its output —
1706
- // hiding the work in flight would be the opposite defect.
1707
- const flight = cell.kind === "tool" && !cell.done;
1708
- if (!flight)
1709
- continue;
1710
- if (runningShown >= LIVE_ACT_HEADS) {
1711
- runningHidden += 1;
1941
+ // R4 — the open stretch is ONE contiguous block: its line
1942
+ // plus the standing act slot, spaced once, at the segment's
1943
+ // first live cell. Every other cell of the segment draws
1944
+ // nothing; its work is counted on the line and its output,
1945
+ // if it is the current thing, is in the slot.
1946
+ //
1947
+ // R3i drew the line here and then let each cell decide for
1948
+ // itself whether it still had rows which is why the
1949
+ // region's height moved between every pair of calls.
1950
+ if (stretchDrawn)
1712
1951
  continue;
1713
- }
1714
- runningShown += 1;
1952
+ stretchDrawn = true;
1953
+ const rows = [
1954
+ ...stretchLine({ ...this.#stretchTerms(openSeg), liveNames: this.#liveNames(openSeg), phase: this.#stretchPhase(openSeg), mark: twinkleFrame(this.#spinnerI) }, W),
1955
+ ...this.#actSlot(openSeg, W, ctx, budget, focus),
1956
+ ];
1957
+ out.push(...this.#space(i, prev, rows));
1958
+ prev = rows;
1959
+ continue;
1715
1960
  }
1716
1961
  const rows = cellComponent(cell).render(W, ctx);
1717
1962
  // the head row carries the affordance; the tint lands on it and
@@ -1721,12 +1966,120 @@ export class Body {
1721
1966
  out.push(...this.#space(i, prev, rows));
1722
1967
  prev = rows;
1723
1968
  }
1724
- if (runningHidden > 0) {
1725
- const p = palette();
1726
- out.push(` ${p.dim}└ +${runningHidden} more running${p.reset}`);
1727
- }
1728
1969
  return out;
1729
1970
  }
1971
+ /**
1972
+ * R4 — the standing act slot's rows. EXACTLY `budget` rows in every
1973
+ * phase, so the live region's height changes twice per stretch (once
1974
+ * when it opens, once when it folds) instead of twice per call.
1975
+ *
1976
+ * The phases, in the order they are tested:
1977
+ * - an EXPANDED live cell outranks the slot (W15 — "the user asked
1978
+ * for it"): it renders in full, variable height. This is also
1979
+ * DC-28's cure: mid-stretch `ctrl+r` had a target it toggled and
1980
+ * never drew, so the press did nothing visible now and changed a
1981
+ * later expansion's shape;
1982
+ * - CALLS IN FLIGHT: one head row each within the budget, the tail
1983
+ * of the LAST head shown filling what is left, and the overflow
1984
+ * row inside the slot. The tail belongs to the last head by
1985
+ * construction — never call N's output under call N+1's header;
1986
+ * - the GAP between two calls: the call that just finished keeps its
1987
+ * settled head and its tail. This is the frame R3i collapsed, and
1988
+ * collapsing it is most of the jump;
1989
+ * - THINKING, before any call: the thinking's own tail (R3i ruling
1990
+ * 5, wired at last).
1991
+ */
1992
+ #actSlot(seg, W, ctx, budget, focus) {
1993
+ const tint = (i, rows) => {
1994
+ if (i === focus && rows.length > 0)
1995
+ rows[0] = focusToken(rows[0], W);
1996
+ return rows;
1997
+ };
1998
+ const live = seg.cells.filter((i) => i >= this.#committed);
1999
+ const tools = [];
2000
+ for (const i of live)
2001
+ if (this.#cells[i]?.kind === "tool")
2002
+ tools.push(i);
2003
+ const toolAt = (i) => this.#cells[i];
2004
+ // An APPROVAL and an EXPANSION both outrank the slot, for the same
2005
+ // reason: their height is the human's business, not the renderer's.
2006
+ // W21 gives a pending approval the live region wholesale — its
2007
+ // diff is the thing being decided about, and a diff clamped to
2008
+ // four rows is a decision made on partial evidence. W15 gives an
2009
+ // expanded cell its full body — "the user asked for it". The slot
2010
+ // exists to stop the height moving ON ITS OWN; a height a human
2011
+ // asked for is not the oscillation it was built against.
2012
+ //
2013
+ // (The approval half is a regression this round caused and its
2014
+ // gate caught: the first draft treated a pending approval as a
2015
+ // call in flight, so `⏸ edit x.ts` lost its diff tail and the
2016
+ // `ctrl+r to expand` note with it.)
2017
+ const owned = tools.filter((i) => toolAt(i).expanded || toolAt(i).state === "approval");
2018
+ if (owned.length > 0) {
2019
+ // In CELL ORDER, so the frame reads the way the work happened:
2020
+ // an owned cell in full, every OTHER call still in flight
2021
+ // keeping its head row. An approval pausing one call must never
2022
+ // hide the others — the v2d parallel-frame gate caught exactly
2023
+ // that: with the shell running and asky_read at its panel, the
2024
+ // first draft returned the panel alone and the running shell's
2025
+ // `● shell sleep 1; echo hi · 1s` row vanished from the screen.
2026
+ const shown = tools.filter((i) => owned.includes(i) || !toolAt(i).done);
2027
+ const out = [];
2028
+ let heads = 0;
2029
+ for (const i of shown) {
2030
+ const rows = tint(i, cellComponent(this.#cells[i]).render(W, ctx));
2031
+ if (owned.includes(i)) {
2032
+ out.push(...rows);
2033
+ continue;
2034
+ }
2035
+ if (heads >= LIVE_ACT_HEADS)
2036
+ continue;
2037
+ heads += 1;
2038
+ out.push(rows[0] ?? "");
2039
+ }
2040
+ const hidden = shown.length - owned.length - heads;
2041
+ if (hidden > 0)
2042
+ out.push(moreRunningRow(hidden, W));
2043
+ return out;
2044
+ }
2045
+ const flight = tools.filter((i) => !toolAt(i).done);
2046
+ if (flight.length > 0) {
2047
+ // the commonest frame — exactly one call, the full budget — is
2048
+ // the W8 block verbatim, which is what 0.17.0 already drew.
2049
+ if (flight.length === 1 && budget >= ACT_SLOT_ROWS)
2050
+ return slotPad(tint(flight[0], cellComponent(this.#cells[flight[0]]).render(W, ctx)), budget);
2051
+ const heads = flight.slice(0, Math.max(1, Math.min(flight.length, budget - 1, LIVE_ACT_HEADS)));
2052
+ const hidden = flight.length - heads.length;
2053
+ const rows = [];
2054
+ for (const i of heads)
2055
+ rows.push(tint(i, cellComponent(this.#cells[i]).render(W, ctx))[0] ?? "");
2056
+ const rest = budget - rows.length - (hidden > 0 ? 1 : 0);
2057
+ if (rest > 0)
2058
+ rows.push(...slotTail(toolAt(heads[heads.length - 1]).resultText, W, rest));
2059
+ if (hidden > 0)
2060
+ rows.push(moreRunningRow(hidden, W));
2061
+ return slotPad(rows, budget);
2062
+ }
2063
+ const settled = tools.length > 0 ? tools[tools.length - 1] : null;
2064
+ if (settled !== null) {
2065
+ const head = tint(settled, cellComponent(this.#cells[settled]).render(W, ctx))[0] ?? "";
2066
+ return slotPad([head, ...slotTail(toolAt(settled).resultText, W, budget - 1)], budget);
2067
+ }
2068
+ const think = [...live].reverse().find((i) => this.#cells[i]?.kind === "thinking");
2069
+ return slotPad(think === undefined ? [] : slotTail(this.#cells[think].text, W, budget), budget);
2070
+ }
2071
+ /** R4 — the tool names with a call still IN FLIGHT in this segment.
2072
+ * The stretch line's tense is per term, so a finished shell reads
2073
+ * `ran 1 shell command` while a read is still running. */
2074
+ #liveNames(seg) {
2075
+ const names = new Set();
2076
+ for (const i of seg.cells) {
2077
+ const c = this.#cells[i];
2078
+ if (c !== undefined && c.kind === "tool" && !c.done)
2079
+ names.add(c.name);
2080
+ }
2081
+ return [...names];
2082
+ }
1730
2083
  /** R3i — the open stretch's phase. It is THINKING while a thinking
1731
2084
  * cell of it is still open and no call has started; otherwise it is
1732
2085
  * ACTING. The tense follows the phase, and the phase is what the
@@ -1781,6 +2134,13 @@ export class Body {
1781
2134
  // TUI2-R1 (D): the sheet occupies the live region, exactly like the
1782
2135
  // panel — the scalar must say so, or the cap arithmetic disagrees
1783
2136
  // with the screen.
2137
+ // R5 — the viewer occupies the live region exactly like the sheet,
2138
+ // so the scalar must say so, or the cap arithmetic disagrees with
2139
+ // the screen (the same rule DC-27 was about).
2140
+ if (this.#viewer !== null) {
2141
+ const capV = Math.max(1, this.#opts.height() - 4 - inputExtra - queueRows.length);
2142
+ return this.#viewerBand(this.#opts.width()).slice(0, capV).length + CHROME_ROWS + inputExtra + queueRows.length;
2143
+ }
1784
2144
  if (sheet) {
1785
2145
  return (keysSheetRows(this.#opts.width()).slice(0, Math.max(1, this.#opts.height() - 4 - inputExtra - queueRows.length)).length +
1786
2146
  CHROME_ROWS +
@@ -1796,16 +2156,25 @@ export class Body {
1796
2156
  inputExtra +
1797
2157
  queueRows.length);
1798
2158
  }
2159
+ // DC-27 — the scalar measures the PROJECTION, not a second render
2160
+ // of its own. This loop used to walk every live cell and render it
2161
+ // in full: no open-segment collapse, no flight rule, no act-slot
2162
+ // budget. After R3i that described a screen the compositor had
2163
+ // stopped drawing — for an open stretch with five finished calls
2164
+ // it counted five four-row blocks that were not there. Nothing
2165
+ // broke, because the force-commit loop measures liveLines.length
2166
+ // and the over-count is conservative; but the cap and geometry
2167
+ // gates were asserting a property of a function nothing paints
2168
+ // from, so a real regression in the region's height could not
2169
+ // have moved them. The rule this file already states for the
2170
+ // sheet ("the scalar must say so, or the cap arithmetic disagrees
2171
+ // with the screen") is the same rule here.
1799
2172
  const ctx = { spinnerI: this.#spinnerI, now: Date.now(), height: this.#opts.height() };
1800
2173
  const W = this.#opts.width();
1801
- let lines = 0;
1802
- let prev = this.#committed > 0 ? this.#lineCache[this.#committed - 1] : null;
1803
- for (let i = this.#committed; i < this.#cells.length; i += 1) {
1804
- const rows = cellComponent(this.#cells[i]).render(W, ctx);
1805
- lines += this.#space(i, prev, rows).length;
1806
- prev = rows;
1807
- }
1808
- return lines + CHROME_ROWS + inputExtra + this.#menuRows(W).length + queueRows.length;
2174
+ // the SAME content cap the force-commit loop applies, so the
2175
+ // scalar sees the same slot budget the screen gets.
2176
+ const rows = this.#liveProjection(W, ctx, this.#opts.height() - 4 - inputExtra - queueRows.length);
2177
+ return rows.length + CHROME_ROWS + inputExtra + this.#menuRows(W).length + queueRows.length;
1809
2178
  }
1810
2179
  /** The lines committed THIS frame — the writes land in the frame's
1811
2180
  * committed section (the rows just above the live region). */
@@ -1885,9 +2254,22 @@ export class Body {
1885
2254
  // not ours to rewrite. Measured: three rows per open on a full
1886
2255
  // screen. The overlay below displaces content on screen instead.
1887
2256
  const sheetUp = this.#sheetState?.() === true;
1888
- this.#overlayFrame = sheetUp || this.#sheetWasUp;
2257
+ // R5 the viewer is an overlay of exactly the same kind, so it
2258
+ // joins the same flag. That one word is what buys it the whole
2259
+ // zero-litter discipline below: the window freezes, #emitScroll
2260
+ // is skipped, and the close repaints from #lastSkip.
2261
+ const viewerUp = this.#viewer !== null;
2262
+ this.#overlayFrame = sheetUp || this.#sheetWasUp || viewerUp || this.#viewerWasUp;
1889
2263
  this.#sheetWasUp = sheetUp;
1890
- if (sheetUp) {
2264
+ this.#viewerWasUp = viewerUp;
2265
+ if (viewerUp) {
2266
+ // R5: the viewer REPLACES the live region — the same slot the
2267
+ // sheet and the panel use, for the same reason (it is what the
2268
+ // human is reading right now). It is opened only from an idle
2269
+ // composer, so it cannot coexist with a panel.
2270
+ liveLines = this.#viewerBand(W).slice(0, Math.max(1, H - 4 - inputExtra - queueRows.length));
2271
+ }
2272
+ else if (sheetUp) {
1891
2273
  // TUI2-R1 (D): the sheet REPLACES the live region — the same
1892
2274
  // slot the panel uses, for the same reason (it is what the
1893
2275
  // human is reading right now). It cannot coexist with a panel:
@@ -1914,7 +2296,7 @@ export class Body {
1914
2296
  // by construction), so the marker can never point at a cell the
1915
2297
  // key would not take — which is the only way a focus marker is
1916
2298
  // worth having.
1917
- liveLines = this.#liveProjection(W, ctx);
2299
+ liveLines = this.#liveProjection(W, ctx, H - 4 - inputExtra - queueRows.length);
1918
2300
  }
1919
2301
  // 3. the FORCE commits — the live region's hard cap H−1: overflow
1920
2302
  // commits the oldest live cell UNCONDITIONALLY (the one sharp
@@ -1935,7 +2317,7 @@ export class Body {
1935
2317
  this.#commitCell(this.#committed, W, ctx);
1936
2318
  // TUI2-R2 ⑤: the focus re-derives after a commit — the cell it
1937
2319
  // pointed at may have just left the live region.
1938
- liveLines = this.#liveProjection(W, ctx);
2320
+ liveLines = this.#liveProjection(W, ctx, H - 4 - inputExtra - queueRows.length);
1939
2321
  }
1940
2322
  // 4. the geometry — the live region's first row:
1941
2323
  // liveTop = min(totalCommitted, H - liveRows) + 1 — the screen
@@ -2122,8 +2504,25 @@ export class Body {
2122
2504
  // tool cell, and a segment's fold can be emitted at a thinking
2123
2505
  // cell — which would have left the whole segment unreachable by
2124
2506
  // the very key its own row advertises.
2125
- if ((cell.kind === "tool" || this.#segmentOf(i)?.headCell === i) && lines.some((l) => l.includes("ctrl+r")))
2507
+ // R4a the ring captures by IDENTITY, not by searching our own
2508
+ // printed bytes.
2509
+ //
2510
+ // This used to require the rendered rows to contain the literal
2511
+ // "ctrl+r", which made the affordance LOAD-BEARING: retiring the
2512
+ // printed key (the owner's ruling) would have silently emptied the
2513
+ // ring and taken the expand key with it — not a missing hint, a
2514
+ // missing feature. A fold head is a fold head because the segment
2515
+ // says so; a tool cell is expandable when it is hiding rows.
2516
+ const isFoldHead = this.#segmentOf(i)?.headCell === i;
2517
+ const hidesRows = cell.kind === "tool" && lines.some((l) => l.includes("ctrl+r"));
2518
+ if (isFoldHead || hidesRows) {
2126
2519
  this.#collapsed.unshift(i);
2520
+ // R4a — a new fold resets the walk, so the FIRST press after any
2521
+ // new work always opens the most recent one. That is the whole
2522
+ // of the owner's "which one does it open": the answer is always
2523
+ // "the last one", and repeats walk back from there.
2524
+ this.#opened.clear();
2525
+ }
2127
2526
  this.#lineCache[i] = lines;
2128
2527
  const placed = this.#space(i, i > 0 ? this.#lineCache[i - 1] : null, lines);
2129
2528
  this.#committed += 1;
@@ -3158,6 +3557,19 @@ export class Dock {
3158
3557
  }
3159
3558
  compositorRef.bindSheet(state);
3160
3559
  }
3560
+ /** R5 — the transcript viewer's three doors, forwarded to the live
3561
+ * compositor. Unlike the other bindings there is no buffer: the
3562
+ * viewer cannot be open before a compositor exists, so a call with
3563
+ * no compositor is a no-op rather than a queued intent. */
3564
+ viewerOpen() {
3565
+ return compositorRef !== null && compositorRef.viewerOpen();
3566
+ }
3567
+ viewerToggleMode() {
3568
+ compositorRef?.viewerToggleMode();
3569
+ }
3570
+ viewerKey(cmd) {
3571
+ compositorRef?.viewerKey(cmd);
3572
+ }
3161
3573
  bindInput(state, prompt) {
3162
3574
  if (compositorRef === null) {
3163
3575
  dockBindings.state = state; // the live buffer — order-agnostic
package/dist/editor.d.ts CHANGED
@@ -35,6 +35,16 @@ import { type SessionCardView, type SessionPickState } from "./session-picker.js
35
35
  * go on together and come off together; a terminal left with either one
36
36
  * set is a terminal that prints escape bytes at the shell prompt.
37
37
  */
38
+ /**
39
+ * R5 — the viewer's key table, as a pure function of the input chunk.
40
+ *
41
+ * Pure so it can be gated without a terminal. Anything not in the table
42
+ * returns null and is SWALLOWED by the caller: a surface that owns the
43
+ * screen must not let stray bytes fall through into the composer behind
44
+ * it, which is the defect the sheet's whole-chunk dismissal avoids by a
45
+ * different route.
46
+ */
47
+ export declare function viewerCommand(text: string): "up" | "down" | "toggle" | "all" | "pageUp" | "pageDown" | "home" | "end" | "close" | null;
38
48
  export declare const MOUSE_ON = "\u001B[?1000h\u001B[?1006h";
39
49
  export declare const MOUSE_OFF = "\u001B[?1000l\u001B[?1006l";
40
50
  export declare const PROMPT = "\u258C ";
@@ -92,6 +102,7 @@ export declare class Editor {
92
102
  bindQueue(state: () => readonly string[], pop: () => string | null): void;
93
103
  /** The whole buffer as text (the CLI's line()/clearLine()). */
94
104
  line(): string;
105
+ bindViewer(isUp: () => boolean, cb: (cmd: "open" | "up" | "down" | "toggle" | "all" | "pageUp" | "pageDown" | "home" | "end" | "close") => void): void;
95
106
  /** TUI2-R1 (D): whether the keys sheet is up — the compositor's slot
96
107
  * read (bound like the menu and the picker). */
97
108
  sheetOpen(): boolean;
package/dist/editor.js CHANGED
@@ -48,6 +48,47 @@ import { sessionFilter } from "./session-picker.js";
48
48
  * go on together and come off together; a terminal left with either one
49
49
  * set is a terminal that prints escape bytes at the shell prompt.
50
50
  */
51
+ /**
52
+ * R5 — the viewer's key table, as a pure function of the input chunk.
53
+ *
54
+ * Pure so it can be gated without a terminal. Anything not in the table
55
+ * returns null and is SWALLOWED by the caller: a surface that owns the
56
+ * screen must not let stray bytes fall through into the composer behind
57
+ * it, which is the defect the sheet's whole-chunk dismissal avoids by a
58
+ * different route.
59
+ */
60
+ export function viewerCommand(text) {
61
+ switch (text) {
62
+ case "\x1b[A":
63
+ case "k":
64
+ return "up";
65
+ case "\x1b[B":
66
+ case "j":
67
+ return "down";
68
+ case "\r":
69
+ case "\n":
70
+ case " ":
71
+ return "toggle";
72
+ case "a":
73
+ return "all";
74
+ case "\x1b[5~":
75
+ return "pageUp";
76
+ case "\x1b[6~":
77
+ return "pageDown";
78
+ case "\x1b[H":
79
+ case "g":
80
+ return "home";
81
+ case "\x1b[F":
82
+ case "G":
83
+ return "end";
84
+ case "\x1b":
85
+ case "q":
86
+ case "\x0f": // ctrl+o — the key that opens it also puts it away
87
+ return "close";
88
+ default:
89
+ return null;
90
+ }
91
+ }
51
92
  export const MOUSE_ON = "\x1b[?1000h\x1b[?1006h";
52
93
  export const MOUSE_OFF = "\x1b[?1000l\x1b[?1006l";
53
94
  export const PROMPT = "▌ ";
@@ -61,6 +102,9 @@ export const MENU_ITEMS = [
61
102
  { name: "/resume", desc: "switch to another session; /resume <id> goes directly" },
62
103
  { name: "/think", desc: "show the last full thinking block" },
63
104
  { name: "/last", desc: "show the most recent tool call's input and output" },
105
+ // R4 (C4d): the committed transcript belongs to the terminal and can
106
+ // never be re-wrapped in place (ADR-0046); this appends it re-folded.
107
+ { name: "/rewrap", desc: "re-print the recent prose at the current width" },
64
108
  { name: "/status", desc: "show session id, event count, and context estimate" },
65
109
  // TUI2-R1 (E): the rent-ledger attribution — where the context went
66
110
  { name: "/context", desc: "show where the context went — the last request's rent ledger" },
@@ -386,6 +430,20 @@ export class Editor {
386
430
  line() {
387
431
  return String.fromCodePoint(...this.#chars);
388
432
  }
433
+ /** R5 — the transcript viewer's key routing. The editor owns no
434
+ * viewer STATE (the compositor does, because the entries are its
435
+ * cells); it only reports whether the viewer is up and forwards the
436
+ * commands, the same shape the expand key already uses. */
437
+ #viewerUp = null;
438
+ #viewerCbs = new Set();
439
+ bindViewer(isUp, cb) {
440
+ this.#viewerUp = isUp;
441
+ this.#viewerCbs.add(cb);
442
+ }
443
+ #viewerSend(cmd) {
444
+ for (const cb of [...this.#viewerCbs])
445
+ cb(cmd);
446
+ }
389
447
  /** TUI2-R1 (D): whether the keys sheet is up — the compositor's slot
390
448
  * read (bound like the menu and the picker). */
391
449
  sheetOpen() {
@@ -858,6 +916,18 @@ export class Editor {
858
916
  this.#onRender();
859
917
  return;
860
918
  }
919
+ // R5 — while the viewer is up it OWNS the keyboard. Unlike the
920
+ // sheet (which any key dismisses) this surface is INTERACTIVE, so
921
+ // the chunk is matched against its own bindings and anything
922
+ // unrecognised is swallowed rather than typed into the composer
923
+ // behind it. esc closes; ctrl+o closes too, so the key that opens
924
+ // it also puts it away.
925
+ if (this.#viewerUp?.() === true) {
926
+ const cmd = viewerCommand(text);
927
+ if (cmd !== null)
928
+ this.#viewerSend(cmd);
929
+ return;
930
+ }
861
931
  let i = 0;
862
932
  while (i < text.length) {
863
933
  const c = text[i];
@@ -1331,6 +1401,15 @@ export class Editor {
1331
1401
  cb();
1332
1402
  i += 1;
1333
1403
  }
1404
+ else if (c === "\x0f" && this.#composerIdle() && this.#chars.length === 0) {
1405
+ // R5 — ctrl+o opens the transcript viewer. Free in kiso, and
1406
+ // the same key the reference implementation uses, so the
1407
+ // muscle memory transfers even though what it opens is not
1408
+ // the same surface. Idle composer only, exactly like `?`:
1409
+ // mid-text it would be a keystroke stolen from the human.
1410
+ this.#viewerSend("open");
1411
+ i += 1;
1412
+ }
1334
1413
  else if (c === "?" && this.#composerIdle() && this.#chars.length === 0) {
1335
1414
  // TUI2-R1 (D): `?` opens the keys sheet — but ONLY on an
1336
1415
  // empty composer with nobody else holding the keys. Mid-text
@@ -0,0 +1,88 @@
1
+ /**
2
+ * R5 — THE TRANSCRIPT VIEWER, on the PRIMARY screen.
3
+ *
4
+ * The owner's question was never "how do I click" — it was "with that
5
+ * many folds, which one am I about to open?". R4 answered it with a
6
+ * printed ordinal and R4a retired that: a number you cannot type is not
7
+ * a selector. The real bind is narrower and has nothing to do with the
8
+ * mouse:
9
+ *
10
+ * a COMMITTED row cannot be marked, because kiso never repaints it.
11
+ *
12
+ * So any "this one" marker has to live on a surface kiso DOES repaint.
13
+ * That surface is this viewer, and inside it a keyboard cursor answers
14
+ * the question completely — no mouse, no ordinal, no mode key to
15
+ * memorise per fold. The pointer becomes optional decoration rather
16
+ * than the mechanism, which is why this round ships without it.
17
+ *
18
+ * NOT the alternate buffer. The viewer occupies the live region exactly
19
+ * as the keys sheet does (TUI2-R1.5 7(a)): while an overlay is up the
20
+ * window is frozen, no LF is emitted, nothing enters the scrollback,
21
+ * and closing takes the full-redraw path and restores every displaced
22
+ * row. pi's viewer and the reference implementation's both take the
23
+ * alternate screen; kiso does not have to, and a kill -9 inside a
24
+ * primary-screen viewer leaves ordinary bytes and an intact scrollback
25
+ * where an alt-screen death would strand the reader in the wrong
26
+ * buffer.
27
+ *
28
+ * This module is PURE: entries + state + width → rows. It holds no
29
+ * cells, mutates nothing, and never touches `cell.expanded` — the
30
+ * viewer's expansion set is its OWN (see ViewerState.open). That is not
31
+ * tidiness: the compositor recomputes `#committedLines` from cell
32
+ * renders on every full redraw, and that number feeds the scroll floor,
33
+ * so a viewer that expanded a committed cell in place would corrupt the
34
+ * window arithmetic for the rest of the session.
35
+ */
36
+ /** The gutter every viewer row carries: the cursor mark and its space. */
37
+ export declare const VIEWER_GUTTER = 3;
38
+ /** One expandable thing in the transcript. The compositor renders these
39
+ * from its committed cells at W − VIEWER_GUTTER; the viewer only
40
+ * arranges them. */
41
+ export interface ViewerEntry {
42
+ /** the fold's own committed row, as the reader saw it */
43
+ readonly head: string;
44
+ /** the rows it stands for */
45
+ readonly body: readonly string[];
46
+ }
47
+ export interface ViewerState {
48
+ /** which entry the cursor is on */
49
+ readonly cursor: number;
50
+ /** the first flat row shown — the scroll position */
51
+ readonly top: number;
52
+ /** the entries expanded IN THE VIEWER. Never a cell mutation. */
53
+ readonly open: ReadonlySet<number>;
54
+ }
55
+ export declare function viewerInit(entries: readonly ViewerEntry[]): ViewerState;
56
+ /** The flat row model: every row the viewer would show if it had room,
57
+ * each tagged with the entry it belongs to and whether it is that
58
+ * entry's head. The window is a slice of this. */
59
+ export declare function viewerFlat(entries: readonly ViewerEntry[], state: ViewerState): {
60
+ entry: number;
61
+ head: boolean;
62
+ text: string;
63
+ }[];
64
+ /** Move the cursor by `delta` entries, keeping it inside the window. */
65
+ export declare function viewerMove(entries: readonly ViewerEntry[], state: ViewerState, delta: number, rows: number): ViewerState;
66
+ /** Scroll by `delta` ROWS without moving the cursor's entry — PgUp/PgDn
67
+ * and the wheel, if a pointer ever arrives. */
68
+ export declare function viewerScroll(entries: readonly ViewerEntry[], state: ViewerState, delta: number, rows: number): ViewerState;
69
+ /** Toggle the entry under the cursor. Viewer-local, always. */
70
+ export declare function viewerToggle(entries: readonly ViewerEntry[], state: ViewerState, rows: number): ViewerState;
71
+ /** `a` — every entry at once, or none if they are all already open. */
72
+ export declare function viewerToggleAll(entries: readonly ViewerEntry[], state: ViewerState, rows: number): ViewerState;
73
+ /**
74
+ * The viewer's rows, at most `rows` of them, every one exactly one
75
+ * physical row no wider than W (invariant ① — the same crash gate the
76
+ * live region obeys, because these rows go through the same emitter).
77
+ *
78
+ * The marks: `▸` the cursor on a closed entry, `▾` the cursor on an
79
+ * open one, `│` an open entry's body. Under NO_COLOR the marks ARE the
80
+ * state — the tint is emphasis over a fact the characters already
81
+ * carry (law 1.3), so a pipe of this surface loses nothing.
82
+ */
83
+ export declare function viewerRows(entries: readonly ViewerEntry[], state: ViewerState, W: number, rows: number): string[];
84
+ /** The viewer's affordance row — what the keys do, where they are
85
+ * useful (the PICKER_HINT convention). */
86
+ export declare function viewerHint(state: ViewerState, entries: readonly ViewerEntry[]): string;
87
+ /** The viewer's band header — what surface this is, and how much of it. */
88
+ export declare function viewerTitle(entries: readonly ViewerEntry[]): string;
@@ -0,0 +1,192 @@
1
+ /**
2
+ * R5 — THE TRANSCRIPT VIEWER, on the PRIMARY screen.
3
+ *
4
+ * The owner's question was never "how do I click" — it was "with that
5
+ * many folds, which one am I about to open?". R4 answered it with a
6
+ * printed ordinal and R4a retired that: a number you cannot type is not
7
+ * a selector. The real bind is narrower and has nothing to do with the
8
+ * mouse:
9
+ *
10
+ * a COMMITTED row cannot be marked, because kiso never repaints it.
11
+ *
12
+ * So any "this one" marker has to live on a surface kiso DOES repaint.
13
+ * That surface is this viewer, and inside it a keyboard cursor answers
14
+ * the question completely — no mouse, no ordinal, no mode key to
15
+ * memorise per fold. The pointer becomes optional decoration rather
16
+ * than the mechanism, which is why this round ships without it.
17
+ *
18
+ * NOT the alternate buffer. The viewer occupies the live region exactly
19
+ * as the keys sheet does (TUI2-R1.5 7(a)): while an overlay is up the
20
+ * window is frozen, no LF is emitted, nothing enters the scrollback,
21
+ * and closing takes the full-redraw path and restores every displaced
22
+ * row. pi's viewer and the reference implementation's both take the
23
+ * alternate screen; kiso does not have to, and a kill -9 inside a
24
+ * primary-screen viewer leaves ordinary bytes and an intact scrollback
25
+ * where an alt-screen death would strand the reader in the wrong
26
+ * buffer.
27
+ *
28
+ * This module is PURE: entries + state + width → rows. It holds no
29
+ * cells, mutates nothing, and never touches `cell.expanded` — the
30
+ * viewer's expansion set is its OWN (see ViewerState.open). That is not
31
+ * tidiness: the compositor recomputes `#committedLines` from cell
32
+ * renders on every full redraw, and that number feeds the scroll floor,
33
+ * so a viewer that expanded a committed cell in place would corrupt the
34
+ * window arithmetic for the rest of the session.
35
+ */
36
+ import { palette } from "./render.js";
37
+ import { visibleWidth } from "./components.js";
38
+ /** The gutter every viewer row carries: the cursor mark and its space. */
39
+ export const VIEWER_GUTTER = 3;
40
+ export function viewerInit(entries) {
41
+ // the cursor starts on the NEWEST fold — the one ctrl+r would have
42
+ // opened, so the two mechanisms agree on their first answer.
43
+ return { cursor: Math.max(0, entries.length - 1), top: 0, open: new Set() };
44
+ }
45
+ /** The flat row model: every row the viewer would show if it had room,
46
+ * each tagged with the entry it belongs to and whether it is that
47
+ * entry's head. The window is a slice of this. */
48
+ export function viewerFlat(entries, state) {
49
+ const out = [];
50
+ for (const [i, e] of entries.entries()) {
51
+ out.push({ entry: i, head: true, text: e.head });
52
+ if (state.open.has(i))
53
+ for (const row of e.body)
54
+ out.push({ entry: i, head: false, text: row });
55
+ }
56
+ return out;
57
+ }
58
+ /** The flat index of an entry's head row. */
59
+ function headRow(entries, state, entry) {
60
+ return viewerFlat(entries, state).findIndex((r) => r.entry === entry && r.head);
61
+ }
62
+ /** Move the cursor by `delta` entries, keeping it inside the window. */
63
+ export function viewerMove(entries, state, delta, rows) {
64
+ if (entries.length === 0)
65
+ return state;
66
+ const cursor = Math.max(0, Math.min(entries.length - 1, state.cursor + delta));
67
+ const next = { ...state, cursor };
68
+ return { ...next, top: clampTop(entries, next, rows) };
69
+ }
70
+ /** Scroll by `delta` ROWS without moving the cursor's entry — PgUp/PgDn
71
+ * and the wheel, if a pointer ever arrives. */
72
+ export function viewerScroll(entries, state, delta, rows) {
73
+ const flat = viewerFlat(entries, state).length;
74
+ const top = Math.max(0, Math.min(Math.max(0, flat - rows), state.top + delta));
75
+ return { ...state, top };
76
+ }
77
+ /** Toggle the entry under the cursor. Viewer-local, always. */
78
+ export function viewerToggle(entries, state, rows) {
79
+ const open = new Set(state.open);
80
+ if (open.has(state.cursor))
81
+ open.delete(state.cursor);
82
+ else
83
+ open.add(state.cursor);
84
+ const next = { ...state, open };
85
+ return { ...next, top: clampTop(entries, next, rows) };
86
+ }
87
+ /** `a` — every entry at once, or none if they are all already open. */
88
+ export function viewerToggleAll(entries, state, rows) {
89
+ const all = entries.length > 0 && entries.every((_, i) => state.open.has(i));
90
+ const open = all ? new Set() : new Set(entries.map((_, i) => i));
91
+ const next = { ...state, open };
92
+ return { ...next, top: clampTop(entries, next, rows) };
93
+ }
94
+ /** Keep the cursor's head row inside the window. */
95
+ function clampTop(entries, state, rows) {
96
+ const flat = viewerFlat(entries, state);
97
+ const at = headRow(entries, state, state.cursor);
98
+ if (at < 0)
99
+ return 0;
100
+ const maxTop = Math.max(0, flat.length - rows);
101
+ let top = Math.min(state.top, maxTop);
102
+ if (at < top)
103
+ top = at;
104
+ if (at >= top + rows)
105
+ top = at - rows + 1;
106
+ return Math.max(0, Math.min(top, maxTop));
107
+ }
108
+ /**
109
+ * The viewer's rows, at most `rows` of them, every one exactly one
110
+ * physical row no wider than W (invariant ① — the same crash gate the
111
+ * live region obeys, because these rows go through the same emitter).
112
+ *
113
+ * The marks: `▸` the cursor on a closed entry, `▾` the cursor on an
114
+ * open one, `│` an open entry's body. Under NO_COLOR the marks ARE the
115
+ * state — the tint is emphasis over a fact the characters already
116
+ * carry (law 1.3), so a pipe of this surface loses nothing.
117
+ */
118
+ export function viewerRows(entries, state, W, rows) {
119
+ const p = palette();
120
+ const flat = viewerFlat(entries, state);
121
+ const out = [];
122
+ for (const line of flat.slice(state.top, state.top + rows)) {
123
+ const onCursor = line.entry === state.cursor && line.head;
124
+ const open = state.open.has(line.entry);
125
+ // The mark carries STATE, the weight carries the CURSOR. Two facts,
126
+ // two channels — and the first draft collapsed them: a non-cursor
127
+ // head printed a blank, so an entry that was OPEN stopped saying
128
+ // so the moment you moved off it. Law 1.3 wants the fact in the
129
+ // characters, and it is the characters that survive a pipe.
130
+ //
131
+ // ▾ open (always, cursor or not)
132
+ // ▸ the cursor, on a closed entry
133
+ // │ an open entry's body
134
+ // a closed entry nobody is pointing at
135
+ const mark = line.head ? (open ? "▾" : onCursor ? "▸" : " ") : "│";
136
+ const gutter = onCursor ? `${p.bold}${mark}${p.reset} ` : `${p.dim}${mark}${p.reset} `;
137
+ const row = ` ${gutter}${line.text}`;
138
+ out.push(open ? shade(row, W) : cut(row, W));
139
+ }
140
+ return out;
141
+ }
142
+ /**
143
+ * An open entry's rows take the VERBATIM SURFACE — `wash`, the same
144
+ * background DC-3 gave the human's own words and inline code. It is
145
+ * ground-resolved already, and with no ground it degrades to reverse
146
+ * video, which is correct on any ground (ground.ts rung 4).
147
+ *
148
+ * Padded to the full width so the block reads as ONE thing rather than
149
+ * a ragged stack. Under NO_COLOR `wash` is empty and the row's bytes
150
+ * are untouched — the ▾ and │ marks carry the state on their own, which
151
+ * is law 1.3's requirement, not a consolation.
152
+ */
153
+ function shade(row, W) {
154
+ const p = palette();
155
+ const body = cut(row, W);
156
+ if (p.wash === "")
157
+ return body;
158
+ return `${p.wash}${body}${" ".repeat(Math.max(0, W - visibleWidth(body)))}${p.washEnd}`;
159
+ }
160
+ /** The last resort — the row is cut at W rather than overflowing it. */
161
+ function cut(row, W) {
162
+ if (visibleWidth(row) <= W)
163
+ return row;
164
+ let out = "";
165
+ let n = 0;
166
+ for (let i = 0; i < row.length;) {
167
+ if (row[i] === "\x1b") {
168
+ const j = row.indexOf("m", i);
169
+ if (j < 0)
170
+ break;
171
+ out += row.slice(i, j + 1);
172
+ i = j + 1;
173
+ continue;
174
+ }
175
+ if (n >= W - 1)
176
+ break;
177
+ out += row[i];
178
+ n += 1;
179
+ i += 1;
180
+ }
181
+ return `${out}…${palette().reset}`;
182
+ }
183
+ /** The viewer's affordance row — what the keys do, where they are
184
+ * useful (the PICKER_HINT convention). */
185
+ export function viewerHint(state, entries) {
186
+ const openHere = state.open.has(state.cursor);
187
+ return `↑↓ move · ⏎ ${openHere ? "collapses" : "expands"} · a ${entries.every((_, i) => state.open.has(i)) && entries.length > 0 ? "collapses all" : "expands all"} · esc closes`;
188
+ }
189
+ /** The viewer's band header — what surface this is, and how much of it. */
190
+ export function viewerTitle(entries) {
191
+ return `transcript · ${entries.length} ${entries.length === 1 ? "fold" : "folds"}`;
192
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-tui",
3
- "version": "0.17.0",
3
+ "version": "0.19.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.17.0"
38
+ "@vincemakes/kiso-tui-cells": "0.19.0"
39
39
  }
40
40
  }