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