@vincemakes/kiso-tui 0.18.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.
@@ -219,6 +219,14 @@ export declare class Body {
219
219
  blocks: number;
220
220
  skipped: number;
221
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;
222
230
  expandNext(): {
223
231
  kind: "toggled";
224
232
  } | {
@@ -363,6 +371,13 @@ export declare class Dock {
363
371
  /** TUI2-R1 (D): bind the editor's keys-sheet flag — the slot read for
364
372
  * the ? overlay (the menu/picker binding pattern). */
365
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;
366
381
  bindInput(state: () => InputState, prompt: string): void;
367
382
  bindMenu(state: () => {
368
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 { ACT_SLOT_ROWS, Container, ROLLUP_NOUN, MOTION_FRAMES, MdStream, bodySpacing, boxBottom, boxTop, cellComponent, exploreCounts, foldCountsObjects, foldTerms, focusToken, exploreRows, foldLine, isExploreTool, moreRunningRow, pendingQueueRows, slotPad, slotTail, 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). */
@@ -179,7 +182,7 @@ function openSegment(turn, now) {
179
182
  const last = turn.segments[turn.segments.length - 1];
180
183
  if (last !== undefined && last.closedAt === null)
181
184
  return last;
182
- const fresh = { openedAt: now, closedAt: null, reads: 0, edits: 0, others: new Map(), seen: new Map(), thinkingMs: 0, thinkingSince: null, folded: false, spilled: false, headCell: null, foldKey: null, cells: [] };
185
+ const fresh = { openedAt: now, closedAt: null, reads: 0, edits: 0, others: new Map(), seen: new Map(), thinkingMs: 0, thinkingSince: null, folded: false, spilled: false, headCell: null, cells: [] };
183
186
  turn.segments.push(fresh);
184
187
  return fresh;
185
188
  }
@@ -323,8 +326,6 @@ export class Body {
323
326
  * opened; the walk takes the newest entry not in it, and empties it
324
327
  * when every entry has been seen. */
325
328
  #opened = new Set();
326
- /** R4 (C1) — the session's fold counter. Monotonic, never reused. */
327
- #foldSeq = 0;
328
329
  // W14: the turn records — one per userLine, the fold-hold's state
329
330
  // machine (ended / hasText / folded) plus the folded-turn line's
330
331
  // counts (accumulated at toolStart). The cells carry the record's
@@ -351,6 +352,11 @@ export class Body {
351
352
  * Unbound, the sheet cannot render and every frame is byte-identical
352
353
  * to before the round. */
353
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;
354
360
  /** TUI2-R1.5 7(a): the sheet's previous up/down state — a transition
355
361
  * in either direction takes the full-redraw path. */
356
362
  #sheetWasUp = false;
@@ -1091,15 +1097,225 @@ export class Body {
1091
1097
  }
1092
1098
  return { lines: chunks.flat(), blocks: blocks - skipped, skipped };
1093
1099
  }
1094
- /** R4 (C1) — what the NEXT press will open, by name. The walk is
1095
- * deterministic (newest unopened first), so this is a promise the
1096
- * key keeps rather than a guess. */
1097
- #nextFoldHint() {
1098
- const pending = this.#collapsed.filter((i) => !this.#opened.has(i));
1099
- const ring = pending.length > 0 ? pending : this.#collapsed;
1100
- const idx = ring[0];
1101
- const key = idx === undefined ? null : (this.#segmentOf(idx)?.foldKey ?? null);
1102
- return key === null ? "ctrl+r opens the next fold" : `ctrl+r opens fold ${key}`;
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
+ ];
1103
1319
  }
1104
1320
  expandNext() {
1105
1321
  for (let i = this.#cells.length - 1; i >= this.#committed; i -= 1) {
@@ -1161,78 +1377,7 @@ export class Body {
1161
1377
  // stay two runs. Merging every explore tool of the segment
1162
1378
  // would have been simpler and would have quietly deleted that
1163
1379
  // rule.
1164
- const rows = [];
1165
- let run = [];
1166
- const flush = () => {
1167
- if (run.length === 0)
1168
- return;
1169
- // the same threshold the commit-time rollup uses: below it a
1170
- // "run" is just some rows
1171
- if (run.length > 2) {
1172
- // the run renders through the ROLLUP's own projection —
1173
- // literally the same function the commit path uses — so
1174
- // a single-name run keeps W13's row and a mixed one gets
1175
- // the exploration line, exactly as they would have if the
1176
- // segment had never folded.
1177
- const head = run[0];
1178
- const saved = head.rolled;
1179
- head.rolled = rolledOf(run);
1180
- // the run OPENS. The fold's key already asked to see the
1181
- // work, so what lands is the same rows `ctrl+r` on the
1182
- // run itself would have opened — its title, then its
1183
- // detail — never its collapsed row, which would make the
1184
- // reader press a second time for what the first press
1185
- // was for.
1186
- rows.push(` ${p.dim}${escapeTerminal(rolledTitle(head))}${p.reset}`);
1187
- rows.push(...rolledDetail(head, W));
1188
- head.rolled = saved;
1189
- }
1190
- else {
1191
- for (const c of run)
1192
- rows.push(...cellComponent(c).render(W, ctx));
1193
- }
1194
- run = [];
1195
- };
1196
- // R3f — the expansion covers the WHOLE TURN, every segment.
1197
- //
1198
- // R3d moved the fold to the turn while the expansion kept
1199
- // walking one segment, so a turn that spoke between calls
1200
- // folded to a line claiming `3 reads · 1 edit · 1 shell` whose
1201
- // key opened only the reads: the edit and the shell were on no
1202
- // surface and reachable by no key. That is the one thing this
1203
- // round's own first gate forbids — the work is never
1204
- // unreachable — and it is worse than never folding, because the
1205
- // line names work it then withholds.
1206
- //
1207
- // A run still BREAKS at a non-explore cell, so the segment
1208
- // boundaries survive where they carry meaning (the write that
1209
- // splits two explore runs); they simply no longer bound what
1210
- // the key can reach.
1211
- // DECLARED SUPERSESSION (R3i phase 3) — the expansion covers THIS
1212
- // STRETCH, and only this stretch.
1213
- //
1214
- // R3f widened it to the whole turn, and had to: R3d had made
1215
- // the fold the TURN's while the expansion still walked one
1216
- // segment, so a line claiming `read 3 files · edited 1 file`
1217
- // opened only the reads — work named and then withheld, the one
1218
- // thing this file's first gate forbids. R3i moves the fold back
1219
- // to the stretch, so the pairing is exact again: every stretch
1220
- // has its OWN line and its own key, and each key opens the work
1221
- // its line named. Keeping the turn walk would break the same
1222
- // rule from the other side — two lines, each opening
1223
- // everything, each header describing rows the other also shows.
1224
- for (const j of seg.cells) {
1225
- if (j < idx)
1226
- continue;
1227
- const c = this.#cells[j];
1228
- if (c.kind === "tool" && isExploreTool(c.name)) {
1229
- run.push(c);
1230
- continue;
1231
- }
1232
- flush();
1233
- rows.push(...cellComponent(c).render(W, ctx));
1234
- }
1235
- flush();
1380
+ const rows = this.#foldBody(seg, idx, W, ctx);
1236
1381
  // the header NAMES the segment. When the segment is exactly one
1237
1382
  // explore run, "explored 8 files · 14 searches" is what that run
1238
1383
  // is called everywhere else in the product, and the header says
@@ -1259,12 +1404,13 @@ export class Body {
1259
1404
  return {
1260
1405
  kind: "appended",
1261
1406
  lines: [
1262
- // R4 (C1): the expansion names the fold it opened, in the
1263
- // same ordinal the fold row printed the answer to "which
1264
- // one did that open", stated rather than inferred.
1265
- `${p.bold}✦${p.reset} expanded${seg.foldKey === null ? "" : ` ${seg.foldKey}`} · ${escapeTerminal(head.length === 0 ? "thinking" : head.join(" · "))} · ${back}`,
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.
1411
+ `${p.bold}✦${p.reset} expanded · ${escapeTerminal(head.length === 0 ? "thinking" : head.join(" · "))} · ${back}`,
1266
1412
  ...body,
1267
- ` ${p.dim}└ end of expansion · ${this.#nextFoldHint()}${p.reset}`,
1413
+ ` ${p.dim}└ end of expansion · ctrl+r opens the one before it${p.reset}`,
1268
1414
  ],
1269
1415
  };
1270
1416
  }
@@ -1988,6 +2134,13 @@ export class Body {
1988
2134
  // TUI2-R1 (D): the sheet occupies the live region, exactly like the
1989
2135
  // panel — the scalar must say so, or the cap arithmetic disagrees
1990
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
+ }
1991
2144
  if (sheet) {
1992
2145
  return (keysSheetRows(this.#opts.width()).slice(0, Math.max(1, this.#opts.height() - 4 - inputExtra - queueRows.length)).length +
1993
2146
  CHROME_ROWS +
@@ -2101,9 +2254,22 @@ export class Body {
2101
2254
  // not ours to rewrite. Measured: three rows per open on a full
2102
2255
  // screen. The overlay below displaces content on screen instead.
2103
2256
  const sheetUp = this.#sheetState?.() === true;
2104
- 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;
2105
2263
  this.#sheetWasUp = sheetUp;
2106
- 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) {
2107
2273
  // TUI2-R1 (D): the sheet REPLACES the live region — the same
2108
2274
  // slot the panel uses, for the same reason (it is what the
2109
2275
  // human is reading right now). It cannot coexist with a panel:
@@ -2338,8 +2504,25 @@ export class Body {
2338
2504
  // tool cell, and a segment's fold can be emitted at a thinking
2339
2505
  // cell — which would have left the whole segment unreachable by
2340
2506
  // the very key its own row advertises.
2341
- 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) {
2342
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
+ }
2343
2526
  this.#lineCache[i] = lines;
2344
2527
  const placed = this.#space(i, i > 0 ? this.#lineCache[i - 1] : null, lines);
2345
2528
  this.#committed += 1;
@@ -2666,12 +2849,6 @@ export class Body {
2666
2849
  seg.folded = true;
2667
2850
  seg.headCell = i;
2668
2851
  turn.folded = true;
2669
- // R4 (C1): the ordinal is assigned HERE, before the row
2670
- // is rendered, because the settled line always carries
2671
- // the key — so the number is known without guessing
2672
- // whether the row will earn an affordance.
2673
- this.#foldSeq += 1;
2674
- seg.foldKey = this.#foldSeq;
2675
2852
  // DECLARED SUPERSESSION (R3i phase 3) — A9 NARROWS: the
2676
2853
  // fold carries WORK, never the human's words.
2677
2854
  //
@@ -2683,7 +2860,7 @@ export class Body {
2683
2860
  // the fold directly beneath it. The band is the record of
2684
2861
  // what was asked; this line is the record of what was
2685
2862
  // done. One fact, one row, each.
2686
- return stretchLine({ ...this.#stretchTerms(seg), phase: "settled", foldKey: seg.foldKey }, W);
2863
+ return stretchLine({ ...this.#stretchTerms(seg), phase: "settled" }, W);
2687
2864
  }
2688
2865
  return [];
2689
2866
  }
@@ -3380,6 +3557,19 @@ export class Dock {
3380
3557
  }
3381
3558
  compositorRef.bindSheet(state);
3382
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
+ }
3383
3573
  bindInput(state, prompt) {
3384
3574
  if (compositorRef === null) {
3385
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 = "▌ ";
@@ -389,6 +430,20 @@ export class Editor {
389
430
  line() {
390
431
  return String.fromCodePoint(...this.#chars);
391
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
+ }
392
447
  /** TUI2-R1 (D): whether the keys sheet is up — the compositor's slot
393
448
  * read (bound like the menu and the picker). */
394
449
  sheetOpen() {
@@ -861,6 +916,18 @@ export class Editor {
861
916
  this.#onRender();
862
917
  return;
863
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
+ }
864
931
  let i = 0;
865
932
  while (i < text.length) {
866
933
  const c = text[i];
@@ -1334,6 +1401,15 @@ export class Editor {
1334
1401
  cb();
1335
1402
  i += 1;
1336
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
+ }
1337
1413
  else if (c === "?" && this.#composerIdle() && this.#chars.length === 0) {
1338
1414
  // TUI2-R1 (D): `?` opens the keys sheet — but ONLY on an
1339
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.18.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.18.0"
38
+ "@vincemakes/kiso-tui-cells": "0.19.0"
39
39
  }
40
40
  }