@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.
- package/dist/compositor.d.ts +15 -0
- package/dist/compositor.js +290 -100
- package/dist/editor.d.ts +11 -0
- package/dist/editor.js +76 -0
- package/dist/transcript.d.ts +88 -0
- package/dist/transcript.js +192 -0
- package/package.json +2 -2
package/dist/compositor.d.ts
CHANGED
|
@@ -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[];
|
package/dist/compositor.js
CHANGED
|
@@ -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,
|
|
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
|
-
/**
|
|
1095
|
-
*
|
|
1096
|
-
*
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
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
|
-
//
|
|
1263
|
-
//
|
|
1264
|
-
//
|
|
1265
|
-
|
|
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 · ${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
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.
|
|
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.
|
|
38
|
+
"@vincemakes/kiso-tui-cells": "0.19.0"
|
|
39
39
|
}
|
|
40
40
|
}
|