pi-fold 4.0.0 → 4.1.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/README.md +11 -10
- package/extensions/active-context.ts +510 -133
- package/extensions/index.js +7 -2
- package/extensions/lib/brief-text.ts +116 -0
- package/extensions/lib/canonical.ts +3 -18
- package/extensions/lib/curation.ts +78 -39
- package/extensions/lib/editor-ui.ts +468 -47
- package/extensions/lib/folding.ts +20 -2
- package/extensions/lib/instrumentation.ts +3 -1
- package/extensions/lib/live-settings.ts +73 -0
- package/extensions/lib/persistence.ts +37 -3
- package/extensions/lib/policy.ts +55 -12
- package/extensions/lib/selection.ts +9 -44
- package/extensions/lib/tool-surface.ts +0 -1
- package/extensions/lib/transcript.ts +43 -0
- package/extensions/settings.ts +104 -30
- package/package.json +5 -5
|
@@ -33,6 +33,14 @@ export interface FoldEditorBlock {
|
|
|
33
33
|
kind?: string;
|
|
34
34
|
origin?: string;
|
|
35
35
|
brief?: string;
|
|
36
|
+
/** Where this fold's brief came from: deterministic, supplied or augmented. Given the
|
|
37
|
+
* campaign verdict on agent-written briefs, which sentence is the runtime's and which
|
|
38
|
+
* is the model's is the fact a person auditing this window most needs. */
|
|
39
|
+
provenance?: string;
|
|
40
|
+
/** The fold is EXPANDED IN THE MODEL'S WINDOW: its raw bytes are in the projection
|
|
41
|
+
* right now, reclaimed at the next commit unless pinned. Distinct in every way from
|
|
42
|
+
* the view's own open/closed state, which the reader owns and the model never sees. */
|
|
43
|
+
expandedInContext?: boolean;
|
|
36
44
|
sourceCount?: number;
|
|
37
45
|
rolesSummary?: string;
|
|
38
46
|
/** Estimated tokens this staged mark frees at the next commit; proposed blocks only. */
|
|
@@ -64,11 +72,19 @@ export interface FoldEditorData {
|
|
|
64
72
|
freedTokens: number;
|
|
65
73
|
};
|
|
66
74
|
pinned: string[];
|
|
75
|
+
/** Fold ids the AGENT has standing raw in its window (state.expanded), not the view's
|
|
76
|
+
* own open rows. Absent on a host that predates the field. */
|
|
77
|
+
expandedInContext?: string[];
|
|
67
78
|
}
|
|
68
79
|
|
|
69
80
|
const BAR_WIDTH = 24;
|
|
70
81
|
const MAX_VISIBLE_ROWS = 24;
|
|
71
82
|
const EXPANDED_ENTRY_CAP = 40;
|
|
83
|
+
/** How many raw messages one stretch renders. Raw stretches open by default now, so this
|
|
84
|
+
* is the bound on the DEFAULT view rather than on a gesture, and it is high because the
|
|
85
|
+
* claim the view makes is that it shows the model's window. A stretch that reaches it
|
|
86
|
+
* says so on its summary row rather than stopping quietly. */
|
|
87
|
+
const RAW_ENTRY_CAP = 500;
|
|
72
88
|
|
|
73
89
|
export function occupancyBar(usedTokens: number | null, budgetTokens: number): string {
|
|
74
90
|
const ratio = usedTokens !== null && budgetTokens > 0
|
|
@@ -120,6 +136,7 @@ export function buildFoldEditorData(
|
|
|
120
136
|
foldRows: () => Array<{
|
|
121
137
|
id: string; kind: string; brief: string; sourceCount: number;
|
|
122
138
|
startPosition: number; endPosition: number;
|
|
139
|
+
provenance?: string; expandedInContext?: boolean;
|
|
123
140
|
entries: FoldEditorEntry[];
|
|
124
141
|
}>;
|
|
125
142
|
pendingMarkRefs: () => Array<{ id: string; origin: string; brief: string; entryIds: string[] }>;
|
|
@@ -137,6 +154,8 @@ export function buildFoldEditorData(
|
|
|
137
154
|
endPosition: row.endPosition,
|
|
138
155
|
kind: row.kind,
|
|
139
156
|
brief: row.brief,
|
|
157
|
+
provenance: row.provenance,
|
|
158
|
+
expandedInContext: row.expandedInContext,
|
|
140
159
|
sourceCount: row.sourceCount,
|
|
141
160
|
entries: row.entries,
|
|
142
161
|
children: [],
|
|
@@ -250,11 +269,16 @@ function briefChunks(brief: string): string[] {
|
|
|
250
269
|
return chunks;
|
|
251
270
|
}
|
|
252
271
|
|
|
253
|
-
/** Detail rows: the
|
|
254
|
-
*
|
|
255
|
-
*
|
|
272
|
+
/** Detail rows: the entry's text wrapped like a brief, under its own row. One long
|
|
273
|
+
* truncated line plus a mid-word continuation hid the middle of the text; wrapping shows
|
|
274
|
+
* all of it in reading order.
|
|
275
|
+
*
|
|
276
|
+
* NO SECOND BOUND OF ITS OWN (2026-08-30). This sliced to 480 characters against a
|
|
277
|
+
* preview the data layer had already cut to 300, so the deeper number could never bind
|
|
278
|
+
* and the whole surface stopped at 300 characters of one text block. What arrives here is
|
|
279
|
+
* now whatever the caller chose to hand over, and the caller states its own cut. */
|
|
256
280
|
function detailChunks(preview: string): string[] {
|
|
257
|
-
return briefChunks(preview
|
|
281
|
+
return briefChunks(preview);
|
|
258
282
|
}
|
|
259
283
|
|
|
260
284
|
interface RenderRow {
|
|
@@ -270,6 +294,16 @@ interface RenderRow {
|
|
|
270
294
|
entryId?: string;
|
|
271
295
|
/** A PROPOSED row: withdrawable with u. */
|
|
272
296
|
proposedMarkId?: string;
|
|
297
|
+
/** A RAW-STRETCH summary row. Its open state lives in the INVERTED set, because raw
|
|
298
|
+
* stretches open by default; sniffing the key prefix for this would put the two
|
|
299
|
+
* opposite defaults one typo apart. */
|
|
300
|
+
rawBlockId?: string;
|
|
301
|
+
/** A FOLD row: pinnable, expandable into the model's window, dissolvable. Carries the
|
|
302
|
+
* fold's own id, which is not the row key: the key is path-qualified so a nested fold
|
|
303
|
+
* renders once per ancestor chain, and the runtime only ever accepts the bare id. */
|
|
304
|
+
foldId?: string;
|
|
305
|
+
/** The fold is expanded IN THE MODEL'S WINDOW right now, which is what e toggles. */
|
|
306
|
+
foldExpandedInContext?: boolean;
|
|
273
307
|
}
|
|
274
308
|
|
|
275
309
|
/** What laying a mark over a span WOULD commit: the size the user sees before staging. */
|
|
@@ -278,16 +312,33 @@ export interface SpanCost {
|
|
|
278
312
|
tokens: number;
|
|
279
313
|
}
|
|
280
314
|
|
|
315
|
+
/** One page of an entry's exact text, with the cut STATED rather than silent. */
|
|
316
|
+
export interface EntryDetailPage {
|
|
317
|
+
text: string;
|
|
318
|
+
offset: number;
|
|
319
|
+
total: number;
|
|
320
|
+
hasMore: boolean;
|
|
321
|
+
}
|
|
322
|
+
|
|
281
323
|
export interface FoldEditorActions {
|
|
282
324
|
/** Live would-be fold size for a span of mapped indices, any order. */
|
|
283
325
|
spanCost?: (from: number, to: number) => SpanCost;
|
|
326
|
+
/** One page of an entry's exact text. A PURE READ: it moves no bytes, writes no state
|
|
327
|
+
* and never touches the projection, so paging an entry to its end is invisible to the
|
|
328
|
+
* model. This is the reader's depth, and it is not the window's. */
|
|
329
|
+
entryDetail?: (entryId: string, offset?: number, chars?: number) => EntryDetailPage | null;
|
|
284
330
|
/** Stage a USER mark over two raw entry ids through the validated path. The
|
|
285
331
|
* brief is what the user typed; empty means the deterministic brief. */
|
|
286
332
|
onStageMark?: (fromId: string, toId: string, brief?: string) => Promise<void>;
|
|
287
333
|
/** Withdraw one staged mark by id (the tool's unmark path). */
|
|
288
334
|
onWithdrawMark?: (markId: string) => Promise<void>;
|
|
289
|
-
/** Pin or unpin one raw entry; the handler decides which from current state. */
|
|
335
|
+
/** Pin or unpin one raw entry or one fold; the handler decides which from current state. */
|
|
290
336
|
onTogglePin?: (entryId: string) => Promise<void>;
|
|
337
|
+
/** Put a fold's raw bytes back into the MODEL's window, or take them out again. The
|
|
338
|
+
* view's own open/close key is a different act and never reaches this. */
|
|
339
|
+
onSetExpanded?: (foldId: string, expand: boolean) => Promise<void>;
|
|
340
|
+
/** Return a fold to raw so its boundary can be re-marked (the tool's reboundary path). */
|
|
341
|
+
onDissolveFold?: (foldId: string) => Promise<void>;
|
|
291
342
|
}
|
|
292
343
|
|
|
293
344
|
type ThemeFn = (color: string, text: string) => string;
|
|
@@ -300,7 +351,34 @@ export class FoldEditorView {
|
|
|
300
351
|
private actions: FoldEditorActions;
|
|
301
352
|
private selectedKey: string;
|
|
302
353
|
private expanded: Set<string> = new Set();
|
|
354
|
+
/**
|
|
355
|
+
* RAW STRETCHES THE READER HAS CLOSED, which is the exception rather than the rule.
|
|
356
|
+
*
|
|
357
|
+
* THE DEFAULT VIEW IS WHAT THE MODEL SEES (2026-08-30). Raw entries used to hide behind
|
|
358
|
+
* a "raw · 14 entries" summary row that had to be opened one stretch at a time, so the
|
|
359
|
+
* one surface built to show a person the model's window opened showing almost none of
|
|
360
|
+
* it. The model sees every one of those messages in full; it sees a fold as ONE
|
|
361
|
+
* placeholder. So the editor now opens the same way: a row per raw message, a row per
|
|
362
|
+
* fold, and digging into a fold reveals what the model is NOT being shown. Shane,
|
|
363
|
+
* 2026-08-30, on pi's /tree: "I basically want something similar but suited to our
|
|
364
|
+
* extension's needs."
|
|
365
|
+
*
|
|
366
|
+
* The summary row stays as a group label and a collapse target, because a long raw
|
|
367
|
+
* stretch a reader has finished with is worth folding away FOR THE READER, which is a
|
|
368
|
+
* different act from folding it for the model and costs the model nothing.
|
|
369
|
+
*/
|
|
370
|
+
private collapsedRaw: Set<string> = new Set();
|
|
371
|
+
/** The search term, or null when the reader is not searching. Narrowing NEVER hides a
|
|
372
|
+
* row silently: the count of what it removed is stated in the footer. */
|
|
373
|
+
private filter: string | null = null;
|
|
374
|
+
private searchMode = false;
|
|
375
|
+
private rowCache: { data: FoldEditorData; signature: string; rows: RenderRow[] } | null = null;
|
|
303
376
|
private detailedEntry: string | null = null;
|
|
377
|
+
/** Where the open detail is being read from, in characters. THE READER'S POSITION AND
|
|
378
|
+
* NOTHING ELSE: paging it moves no bytes, writes no state and the model never learns
|
|
379
|
+
* it happened. Reset whenever the open entry changes, so a short entry cannot inherit
|
|
380
|
+
* a long one's offset and render blank. */
|
|
381
|
+
private detailOffset = 0;
|
|
304
382
|
private scroll = 0;
|
|
305
383
|
/** The anchored mark point: first boundary of a span the user is laying down. */
|
|
306
384
|
private anchor: { key: string; id: string; index: number } | null = null;
|
|
@@ -346,6 +424,76 @@ export class FoldEditorView {
|
|
|
346
424
|
return this.data.pinned.includes(entryId) ? " 📌" : "";
|
|
347
425
|
}
|
|
348
426
|
|
|
427
|
+
/**
|
|
428
|
+
* A FOLD IS PINNED THROUGH ITS MEMBERS, so its badge is counted rather than looked up.
|
|
429
|
+
*
|
|
430
|
+
* `protectEvidence` takes a fold id and holds every ref underneath it, so the pinned set
|
|
431
|
+
* only ever contains ENTRY ids and a fold id is never in it. Reading the fold's own id
|
|
432
|
+
* against that set is how a fold pinned by this very view rendered unpinned.
|
|
433
|
+
*
|
|
434
|
+
* PARTIAL IS STATED, not rounded to either end. A fold whose entries were pinned
|
|
435
|
+
* individually, or one that lost some of its hold to an unpin, is neither held nor free,
|
|
436
|
+
* and a badge that showed nothing there would hide exactly the state a person opened
|
|
437
|
+
* this surface to find.
|
|
438
|
+
*/
|
|
439
|
+
private foldPinBadge(block: FoldEditorBlock): string {
|
|
440
|
+
const entries = block.entries ?? [];
|
|
441
|
+
if (!entries.length) return "";
|
|
442
|
+
const held = entries.filter((entry) => this.data.pinned.includes(entry.id)).length;
|
|
443
|
+
if (!held) return "";
|
|
444
|
+
return held === entries.length ? " 📌" : ` 📌 ${held}/${entries.length}`;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* THE OPEN ENTRY'S TEXT, ONE PAGE AT A TIME, AND THE CUT IS STATED (2026-08-30).
|
|
449
|
+
*
|
|
450
|
+
* Three sites rendered detail rows and all three read `entry.preview`, which the data
|
|
451
|
+
* layer had cut to 300 characters of the first text block; `detailChunks` then sliced
|
|
452
|
+
* to 480, a bound the shorter one could never reach. So this is the whole depth the
|
|
453
|
+
* editor had. It now asks the injected accessor, which reads the exact message and
|
|
454
|
+
* hands back a page with `total` and `hasMore` beside it, and falls back to the row
|
|
455
|
+
* preview on a host that supplies no accessor.
|
|
456
|
+
*
|
|
457
|
+
* PAGING HERE IS NOT EXPANDING. Nothing on this path touches the projection: the
|
|
458
|
+
* reader walks the entry to its end and the model's window is byte-identical
|
|
459
|
+
* afterwards. Putting a fold's bytes back in front of the model is `e`, a different
|
|
460
|
+
* key on a different path with a different cost.
|
|
461
|
+
*/
|
|
462
|
+
private pushDetail(rows: RenderRow[], entryKey: string, entry: FoldEditorEntry, pad: string): void {
|
|
463
|
+
const page = this.actions.entryDetail?.(entry.id, this.detailOffset) ?? null;
|
|
464
|
+
const body = page ? page.text : entry.preview;
|
|
465
|
+
let ordinal = 0;
|
|
466
|
+
for (const chunk of detailChunks(body)) {
|
|
467
|
+
rows.push({
|
|
468
|
+
key: `${entryKey}:d${ordinal}`,
|
|
469
|
+
text: `${pad} ${this.color("dim", chunk)}`,
|
|
470
|
+
toggleId: null,
|
|
471
|
+
});
|
|
472
|
+
ordinal += 1;
|
|
473
|
+
}
|
|
474
|
+
if (!page) return;
|
|
475
|
+
const shown = page.offset + page.text.length;
|
|
476
|
+
if (shown >= page.total && page.offset === 0) return;
|
|
477
|
+
// A LIST THAT STOPS EARLY AND SAYS NOTHING READS AS A COMPLETE LIST (gate 136's
|
|
478
|
+
// law, on a surface a person reads rather than a brief a model reads). The
|
|
479
|
+
// position is stated in characters because that is the unit the keys move in.
|
|
480
|
+
rows.push({
|
|
481
|
+
key: `${entryKey}:more`,
|
|
482
|
+
text: `${pad} ${this.color("warning",
|
|
483
|
+
`characters ${page.offset.toLocaleString("en-US")}-${shown.toLocaleString("en-US")} ` +
|
|
484
|
+
`of ${page.total.toLocaleString("en-US")}` +
|
|
485
|
+
`${page.hasMore ? " · ]:more" : ""}${page.offset > 0 ? " · [:back" : ""}`)}`,
|
|
486
|
+
toggleId: null,
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Open, close or re-target the detail rows under one entry row. The offset resets on
|
|
491
|
+
* every change of subject, so a short entry never inherits a long one's position. */
|
|
492
|
+
private toggleDetail(rowKey: string): void {
|
|
493
|
+
this.detailedEntry = this.detailedEntry === rowKey ? null : rowKey;
|
|
494
|
+
this.detailOffset = 0;
|
|
495
|
+
}
|
|
496
|
+
|
|
349
497
|
private renderFold(block: FoldEditorBlock, depth: number, rows: RenderRow[], keyPrefix: string): void {
|
|
350
498
|
// COLLAPSED = ONE ROW (Shane, 2026-08-22): "fold {id}" alone, no END bookend.
|
|
351
499
|
// Start/end bookends exist only in the EXPANDED state. ROW KEYS ARE UNIQUE PER
|
|
@@ -371,11 +519,26 @@ export class FoldEditorView {
|
|
|
371
519
|
const marker = isOpen ? "\u25bc" : "\u25b8";
|
|
372
520
|
const foldCursor = this.selectedKey === foldKey ? "\u276f" : " ";
|
|
373
521
|
const [foldHead] = briefChunks(block.brief ?? "");
|
|
522
|
+
// WHAT THE MODEL IS ACTUALLY HOLDING (2026-08-30). Two facts the row could not
|
|
523
|
+
// carry, and both are what a person opens this surface to learn. A fold the agent
|
|
524
|
+
// has EXPANDED is standing raw in its window and costing it, and rendered
|
|
525
|
+
// identically to a placeholder; and a brief the MODEL wrote is a different kind of
|
|
526
|
+
// claim from one the runtime derived, which is the fact the campaign verdict made
|
|
527
|
+
// load-bearing. Neither is stated with an id: the badge is the word.
|
|
528
|
+
const contextBadge = block.expandedInContext
|
|
529
|
+
? ` ${this.color("warning", "· raw in the model's window")}`
|
|
530
|
+
: "";
|
|
531
|
+
const provenanceBadge = block.provenance && block.provenance !== "deterministic"
|
|
532
|
+
? ` ${this.color("accent", `· brief ${block.provenance}`)}`
|
|
533
|
+
: "";
|
|
374
534
|
rows.push({
|
|
375
535
|
key: foldKey,
|
|
376
536
|
text: `${foldCursor}${this.color("success", `${pad}${marker} ${kindLabel}`)}` +
|
|
537
|
+
`${this.foldPinBadge(block)}${contextBadge}${provenanceBadge}` +
|
|
377
538
|
`${foldHead ? ` · ${foldHead}` : ""}`,
|
|
378
539
|
toggleId: foldKey,
|
|
540
|
+
foldId: block.id,
|
|
541
|
+
foldExpandedInContext: Boolean(block.expandedInContext),
|
|
379
542
|
});
|
|
380
543
|
if (!isOpen) return;
|
|
381
544
|
let ordinal = 0;
|
|
@@ -396,23 +559,19 @@ export class FoldEditorView {
|
|
|
396
559
|
? this.color("dim", ` \u201c${entry.preview.slice(0, 48)}\u201d`)
|
|
397
560
|
: "";
|
|
398
561
|
const entryCursor = this.selectedKey === entryKey ? "\u276f" : " ";
|
|
562
|
+
// AN ENTRY IS AN ENTRY WHEREVER IT SITS (2026-08-30). These rows carried no id
|
|
563
|
+
// the view could act on, so `p` was inert on every row inside an expanded fold
|
|
564
|
+
// while working one row over in a raw gap, for no reason a reader could see.
|
|
565
|
+
// They are not mark points: their span is already inside a fold, and gate 112
|
|
566
|
+
// refuses that by name, so `markable` stays off and only the pin id travels.
|
|
399
567
|
rows.push({
|
|
400
568
|
key: entryKey,
|
|
401
569
|
text: `${entryCursor}${pad} ${this.color("dim", `\u00b7 ${entry.role} ${shortId(entry.id)}${this.pinnedBadge(entry.id)}`)}${quoted}`,
|
|
402
570
|
toggleId: null,
|
|
403
571
|
entryPreview: entry.preview,
|
|
572
|
+
entryId: entry.id,
|
|
404
573
|
});
|
|
405
|
-
if (detailed)
|
|
406
|
-
let detailOrdinal = 0;
|
|
407
|
-
for (const chunk of detailChunks(entry.preview)) {
|
|
408
|
-
rows.push({
|
|
409
|
-
key: `${entryKey}:d${detailOrdinal}`,
|
|
410
|
-
text: `${pad} ${this.color("dim", chunk)}`,
|
|
411
|
-
toggleId: null,
|
|
412
|
-
});
|
|
413
|
-
detailOrdinal += 1;
|
|
414
|
-
}
|
|
415
|
-
}
|
|
574
|
+
if (detailed) this.pushDetail(rows, entryKey, entry, pad);
|
|
416
575
|
}
|
|
417
576
|
const endCursor = this.selectedKey === `${foldKey}:end` ? "\u276f" : " ";
|
|
418
577
|
rows.push({
|
|
@@ -422,14 +581,28 @@ export class FoldEditorView {
|
|
|
422
581
|
});
|
|
423
582
|
}
|
|
424
583
|
|
|
425
|
-
|
|
584
|
+
/**
|
|
585
|
+
* ONE PREDICATE FOR TWO OPPOSITE DEFAULTS, so they cannot drift apart.
|
|
586
|
+
*
|
|
587
|
+
* A FOLD IS CLOSED BY DEFAULT because that is what the model sees: one placeholder.
|
|
588
|
+
* A RAW STRETCH IS OPEN BY DEFAULT for exactly the same reason: the model sees every
|
|
589
|
+
* message in it. The editor's default view is the model's own window, and opening a
|
|
590
|
+
* fold is the one gesture that shows the reader something the model is not being
|
|
591
|
+
* shown. Inverting the raw default is what turned this from a map of blocks into a
|
|
592
|
+
* map of the window.
|
|
593
|
+
*/
|
|
594
|
+
private blockIsOpen(block: FoldEditorBlock): boolean {
|
|
595
|
+
return block.type === "raw" ? !this.collapsedRaw.has(block.id) : this.expanded.has(block.id);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
private allRows(): RenderRow[] {
|
|
426
599
|
const rows: RenderRow[] = [];
|
|
427
600
|
for (const block of this.data.blocks) {
|
|
428
601
|
if (block.type === "fold") {
|
|
429
602
|
this.renderFold(block, 0, rows, "");
|
|
430
603
|
continue;
|
|
431
604
|
}
|
|
432
|
-
const isOpen = this.
|
|
605
|
+
const isOpen = this.blockIsOpen(block);
|
|
433
606
|
const cursor = this.selectedKey === block.id ? "\u276f" : " ";
|
|
434
607
|
if (block.type === "proposed") {
|
|
435
608
|
const isLadder = block.origin === "ladder";
|
|
@@ -461,27 +634,38 @@ export class FoldEditorView {
|
|
|
461
634
|
// resolves them); an entry that fell off the map renders id-only.
|
|
462
635
|
const label = ["\u00b7", entry.role, `${shortId(entry.id)}${this.pinnedBadge(entry.id)}`]
|
|
463
636
|
.filter(Boolean).join(" ");
|
|
464
|
-
const
|
|
637
|
+
const detailed = this.detailedEntry === entryKey;
|
|
638
|
+
const quoted = !detailed && entry.preview
|
|
639
|
+
? ` \u201c${entry.preview.slice(0, 48)}\u201d` : "";
|
|
465
640
|
rows.push({
|
|
466
641
|
key: entryKey,
|
|
467
642
|
text: `${this.selectedKey === entryKey ? "\u276f" : " "} ${this.color("dim", `${label}${quoted}`)}`,
|
|
468
643
|
toggleId: null,
|
|
469
644
|
entryPreview: entry.preview,
|
|
645
|
+
entryId: entry.id,
|
|
470
646
|
});
|
|
647
|
+
if (detailed) this.pushDetail(rows, entryKey, entry, "");
|
|
471
648
|
}
|
|
472
649
|
}
|
|
473
650
|
} else {
|
|
474
|
-
|
|
651
|
+
// THE MARKER SAYS WHICH WAY THIS ROW GOES, because raw stretches now open by
|
|
652
|
+
// default and a reader meeting a closed one has to be able to tell it apart from
|
|
653
|
+
// a stretch that is simply empty. "hidden from this view only" is the whole
|
|
654
|
+
// distinction the surface rests on, said where it is easiest to get wrong.
|
|
655
|
+
const marker = isOpen ? "\u25bc" : "\u25b8";
|
|
656
|
+
const label = `${marker} raw · ${block.sourceCount} ` +
|
|
475
657
|
`${block.sourceCount === 1 ? "entry" : "entries"}` +
|
|
476
|
-
`${block.rolesSummary ? ` ${block.rolesSummary}` : ""}
|
|
658
|
+
`${block.rolesSummary ? ` ${block.rolesSummary}` : ""}` +
|
|
659
|
+
`${isOpen ? "" : " · hidden from this view only"}`;
|
|
477
660
|
rows.push({
|
|
478
661
|
key: block.id,
|
|
479
662
|
text: `${cursor}${this.color("dim", label)}`,
|
|
480
663
|
toggleId: block.id,
|
|
664
|
+
rawBlockId: block.id,
|
|
481
665
|
});
|
|
482
666
|
if (isOpen) {
|
|
483
667
|
let ordinal = 0;
|
|
484
|
-
for (const entry of block.entries.slice(0,
|
|
668
|
+
for (const entry of block.entries.slice(0, RAW_ENTRY_CAP)) {
|
|
485
669
|
const pinBadge = this.data.pinned.includes(entry.id) ? " \ud83d\udccc" : "";
|
|
486
670
|
const entryKey = `${block.id}:${entry.id}:r${ordinal}`;
|
|
487
671
|
ordinal += 1;
|
|
@@ -505,18 +689,11 @@ export class FoldEditorView {
|
|
|
505
689
|
text: `${cursor} ${this.color("dim", `\u00b7 ${entry.role} ${shortId(entry.id)}${pinBadge}`)}${badge}${quoted}`,
|
|
506
690
|
toggleId: null,
|
|
507
691
|
entryPreview: entry.preview,
|
|
508
|
-
|
|
692
|
+
entryId: entry.id,
|
|
693
|
+
...(markable ? { markable: true, entryIndex: entry.index } : {}),
|
|
509
694
|
});
|
|
510
695
|
if (detailed) {
|
|
511
|
-
|
|
512
|
-
for (const chunk of detailChunks(entry.preview)) {
|
|
513
|
-
rows.push({
|
|
514
|
-
key: `${entryKey}:d${detailOrdinal}`,
|
|
515
|
-
text: ` ${this.color("dim", chunk)}`,
|
|
516
|
-
toggleId: null,
|
|
517
|
-
});
|
|
518
|
-
detailOrdinal += 1;
|
|
519
|
-
}
|
|
696
|
+
this.pushDetail(rows, entryKey, entry, "");
|
|
520
697
|
}
|
|
521
698
|
}
|
|
522
699
|
}
|
|
@@ -525,6 +702,65 @@ export class FoldEditorView {
|
|
|
525
702
|
return rows;
|
|
526
703
|
}
|
|
527
704
|
|
|
705
|
+
/**
|
|
706
|
+
* WHAT THE READER IS LOOKING FOR, AND WHAT THAT HID.
|
|
707
|
+
*
|
|
708
|
+
* pi's /tree narrows by typing and the editor had no way to narrow at all, which is
|
|
709
|
+
* survivable at a dozen blocks and useless at the density this view now opens with.
|
|
710
|
+
* The term matches a row's rendered text, colour codes and all removed, so what is
|
|
711
|
+
* searched is exactly what is on screen.
|
|
712
|
+
*
|
|
713
|
+
* A ROW KEEPS ITS PARENTS. A matching entry three levels inside a fold, shown alone,
|
|
714
|
+
* is a line of text with no statement of where it came from, so every ancestor of a
|
|
715
|
+
* match is kept: the reader sees the match seated in the structure that explains it.
|
|
716
|
+
*
|
|
717
|
+
* AND THE OMISSION IS COUNTED, never silent, which is gate 136's law on a surface a
|
|
718
|
+
* person acts from. The footer states how many rows the term removed.
|
|
719
|
+
*/
|
|
720
|
+
private filterRows(rows: RenderRow[]): RenderRow[] {
|
|
721
|
+
const term = this.filter?.trim().toLowerCase();
|
|
722
|
+
if (!term) return rows;
|
|
723
|
+
const plain = (text: string): string => text.replace(/\[\/?[a-z]+\]/g, "").toLowerCase();
|
|
724
|
+
const keep = new Set<number>();
|
|
725
|
+
for (let index = 0; index < rows.length; index += 1) {
|
|
726
|
+
if (!plain(rows[index].text).includes(term)) continue;
|
|
727
|
+
keep.add(index);
|
|
728
|
+
// Walk back to the ancestors: a row's key is path-qualified, so any earlier row
|
|
729
|
+
// whose key is a prefix of this one is one of its parents.
|
|
730
|
+
for (let back = index - 1; back >= 0; back -= 1) {
|
|
731
|
+
if (rows[index].key.startsWith(rows[back].key)) keep.add(back);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
return rows.filter((_row, index) => keep.has(index));
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* THE ROW LIST, BUILT ONCE PER STATE. Every keystroke asks for it two or three times
|
|
739
|
+
* (the move, the scroll clamp and the render), and the default view is now a row per
|
|
740
|
+
* message rather than a row per block, so a session of a few thousand entries would
|
|
741
|
+
* rebuild a few thousand strings three times for one arrow key. The signature is every
|
|
742
|
+
* piece of view state a row's text reads; `data` is compared by IDENTITY, because
|
|
743
|
+
* `refresh` always hands over a freshly built object and a mutated one would be a
|
|
744
|
+
* defect one layer up.
|
|
745
|
+
*/
|
|
746
|
+
private visibleRows(): RenderRow[] {
|
|
747
|
+
const signature = [
|
|
748
|
+
this.selectedKey,
|
|
749
|
+
this.detailedEntry ?? "",
|
|
750
|
+
this.detailOffset,
|
|
751
|
+
this.filter ?? "",
|
|
752
|
+
this.anchor?.key ?? "",
|
|
753
|
+
[...this.expanded].sort().join(","),
|
|
754
|
+
[...this.collapsedRaw].sort().join(","),
|
|
755
|
+
].join("|");
|
|
756
|
+
if (this.rowCache && this.rowCache.data === this.data && this.rowCache.signature === signature) {
|
|
757
|
+
return this.rowCache.rows;
|
|
758
|
+
}
|
|
759
|
+
const rows = this.filterRows(this.allRows());
|
|
760
|
+
this.rowCache = { data: this.data, signature, rows };
|
|
761
|
+
return rows;
|
|
762
|
+
}
|
|
763
|
+
|
|
528
764
|
private move(delta: number): void {
|
|
529
765
|
const rows = this.visibleRows();
|
|
530
766
|
if (!rows.length) return;
|
|
@@ -550,6 +786,19 @@ export class FoldEditorView {
|
|
|
550
786
|
this.handleBriefInput(data);
|
|
551
787
|
return;
|
|
552
788
|
}
|
|
789
|
+
// SEARCH IS A MODE, NOT A KEY, because every letter here already means something.
|
|
790
|
+
// pi's /tree can narrow on any keystroke since it has no single-letter actions;
|
|
791
|
+
// this view has m, p, e, r and u, so typing has to be entered deliberately. `/`
|
|
792
|
+
// opens it, which is what a reader coming from less, vim or a browser will try.
|
|
793
|
+
if (this.searchMode) {
|
|
794
|
+
this.handleSearchInput(data);
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
if (data === "/") {
|
|
798
|
+
this.searchMode = true;
|
|
799
|
+
this.filter = this.filter ?? "";
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
553
802
|
if (data === "u") {
|
|
554
803
|
this.handleWithdrawKey();
|
|
555
804
|
return;
|
|
@@ -558,13 +807,34 @@ export class FoldEditorView {
|
|
|
558
807
|
this.handlePinKey();
|
|
559
808
|
return;
|
|
560
809
|
}
|
|
810
|
+
if (data === "e") {
|
|
811
|
+
this.handleExpandKey();
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
if (data === "r") {
|
|
815
|
+
this.handleDissolveKey();
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
if (data === "]" || data === "[") {
|
|
819
|
+
this.handlePageKey(data === "]" ? 1 : -1);
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
561
822
|
if (data === "m") {
|
|
562
823
|
this.handleMarkKey();
|
|
563
824
|
return;
|
|
564
825
|
}
|
|
565
826
|
if (this.kb.matches(data, "tui.select.cancel")) {
|
|
566
|
-
// ESCAPE CANCELS WORK FIRST, STEPWISE: the brief line, then the
|
|
567
|
-
// only then the editor. Nothing the user laid down is lost by one
|
|
827
|
+
// ESCAPE CANCELS WORK FIRST, STEPWISE: the search, then the brief line, then the
|
|
828
|
+
// anchor, and only then the editor. Nothing the user laid down is lost by one
|
|
829
|
+
// press, and a reader who narrowed the view and forgot gets the window back
|
|
830
|
+
// before they get the exit.
|
|
831
|
+
if (this.filter !== null) {
|
|
832
|
+
this.filter = null;
|
|
833
|
+
this.searchMode = false;
|
|
834
|
+
this.selectedKey = this.visibleRows()[0]?.key ?? "";
|
|
835
|
+
this.scrollToSelection();
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
568
838
|
if (this.anchor !== null) {
|
|
569
839
|
this.anchor = null;
|
|
570
840
|
this.notice = null;
|
|
@@ -584,6 +854,21 @@ export class FoldEditorView {
|
|
|
584
854
|
this.expanded.delete(target);
|
|
585
855
|
this.selectedKey = target;
|
|
586
856
|
this.scrollToSelection();
|
|
857
|
+
} else {
|
|
858
|
+
// LEFT CLOSES AN OPEN RAW STRETCH for the same reason it collapses a fold:
|
|
859
|
+
// it is the key that means "less of this". Without it raw stretches could be
|
|
860
|
+
// closed only from their own summary row, which is off screen for a reader
|
|
861
|
+
// standing deep inside a long one.
|
|
862
|
+
const rows = this.visibleRows();
|
|
863
|
+
const at = rows.findIndex((candidate) => candidate.key === this.selectedKey);
|
|
864
|
+
for (let back = at; back >= 0; back -= 1) {
|
|
865
|
+
const owner = rows[back].rawBlockId;
|
|
866
|
+
if (!owner || !this.selectedKey.startsWith(owner)) continue;
|
|
867
|
+
this.collapsedRaw.add(owner);
|
|
868
|
+
this.selectedKey = owner;
|
|
869
|
+
this.scrollToSelection();
|
|
870
|
+
break;
|
|
871
|
+
}
|
|
587
872
|
}
|
|
588
873
|
}
|
|
589
874
|
else if (this.kb.matches(data, "tui.select.confirm") ||
|
|
@@ -591,17 +876,53 @@ export class FoldEditorView {
|
|
|
591
876
|
const row = this.visibleRows().find((candidate) => candidate.key === this.selectedKey);
|
|
592
877
|
if (!row) return;
|
|
593
878
|
if (row.toggleId) {
|
|
594
|
-
|
|
595
|
-
else this.expanded.add(row.toggleId);
|
|
879
|
+
this.toggleBlock(row);
|
|
596
880
|
this.scrollToSelection();
|
|
597
881
|
} else if (row.entryPreview !== undefined) {
|
|
598
|
-
this.
|
|
882
|
+
this.toggleDetail(row.key);
|
|
599
883
|
this.scrollToSelection();
|
|
600
884
|
}
|
|
601
885
|
}
|
|
602
886
|
}
|
|
603
887
|
|
|
604
888
|
/** The deepest expanded fold named in a row key (keys look like "a>b:c"). */
|
|
889
|
+
/** Typing while the search line is open. Enter leaves the line with the term standing,
|
|
890
|
+
* so the reader gets their action keys back without losing the narrowing; Escape
|
|
891
|
+
* drops the term as well, one press per thing given up. */
|
|
892
|
+
private handleSearchInput(data: string): void {
|
|
893
|
+
if (data === "\r" || data === "\n") {
|
|
894
|
+
this.searchMode = false;
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
if (data === "\x7f" || data === "\b") {
|
|
898
|
+
this.filter = (this.filter ?? "").slice(0, -1);
|
|
899
|
+
} else if (data === "\x1b") {
|
|
900
|
+
this.filter = null;
|
|
901
|
+
this.searchMode = false;
|
|
902
|
+
} else if (!data.startsWith("\x1b") && data >= " ") {
|
|
903
|
+
this.filter = `${this.filter ?? ""}${data}`;
|
|
904
|
+
} else {
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
const rows = this.visibleRows();
|
|
908
|
+
if (!rows.some((row) => row.key === this.selectedKey)) {
|
|
909
|
+
this.selectedKey = rows[0]?.key ?? "";
|
|
910
|
+
}
|
|
911
|
+
this.scroll = 0;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/** Open or close one block, in whichever sense that block's default runs. */
|
|
915
|
+
private toggleBlock(row: RenderRow): void {
|
|
916
|
+
if (row.rawBlockId) {
|
|
917
|
+
if (this.collapsedRaw.has(row.rawBlockId)) this.collapsedRaw.delete(row.rawBlockId);
|
|
918
|
+
else this.collapsedRaw.add(row.rawBlockId);
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
const id = row.toggleId!;
|
|
922
|
+
if (this.expanded.has(id)) this.expanded.delete(id);
|
|
923
|
+
else this.expanded.add(id);
|
|
924
|
+
}
|
|
925
|
+
|
|
605
926
|
private innermostOpenFold(key: string): string | null {
|
|
606
927
|
const chain = key.split(":")[0].split(">");
|
|
607
928
|
for (let index = chain.length - 1; index >= 0; index -= 1) {
|
|
@@ -701,18 +1022,90 @@ export class FoldEditorView {
|
|
|
701
1022
|
});
|
|
702
1023
|
}
|
|
703
1024
|
|
|
704
|
-
/**
|
|
1025
|
+
/**
|
|
1026
|
+
* p TOGGLES A PIN, on any row that names something the runtime can hold.
|
|
1027
|
+
*
|
|
1028
|
+
* IT USED TO REQUIRE `markable` (2026-08-30). That flag means "this raw entry can be a
|
|
1029
|
+
* mark point", which is a fact about FOLDING a span, and it has nothing to do with
|
|
1030
|
+
* holding one. The effect was that `p` worked on a raw entry inside a raw gap, did
|
|
1031
|
+
* nothing at all on the identical entry inside an expanded fold, and did nothing on a
|
|
1032
|
+
* fold row, while the tool's own pin action has always taken either an entry id or a
|
|
1033
|
+
* fold id. The view was narrower than the path it is a skin over, for no reason a
|
|
1034
|
+
* reader could see.
|
|
1035
|
+
*/
|
|
705
1036
|
private handlePinKey(): void {
|
|
706
1037
|
if (this.staging || this.briefMode) return;
|
|
707
1038
|
const row = this.visibleRows().find((candidate) => candidate.key === this.selectedKey);
|
|
708
|
-
|
|
1039
|
+
const target = row?.entryId ?? row?.foldId;
|
|
1040
|
+
if (!target) return;
|
|
709
1041
|
if (!this.actions.onTogglePin) {
|
|
710
1042
|
this.notice = "pinning is not wired in this view";
|
|
711
1043
|
return;
|
|
712
1044
|
}
|
|
1045
|
+
this.runAction(this.actions.onTogglePin(target));
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
/**
|
|
1049
|
+
* e PUTS A FOLD'S BYTES BACK IN FRONT OF THE MODEL, or takes them out again.
|
|
1050
|
+
*
|
|
1051
|
+
* THE OTHER EXPANSION, and the distinction is the point (Shane, 2026-08-30: the reader
|
|
1052
|
+
* should be able to "page through/expand for their own viewing purposes" while
|
|
1053
|
+
* "setting an expanded fold is a different choice"). Enter and the arrow keys open a
|
|
1054
|
+
* fold FOR THE READER: they move a key in a Set this view owns, cost nothing and are
|
|
1055
|
+
* invisible to the model. This restores the fold's raw source to the PROJECTION, which
|
|
1056
|
+
* costs the window and is reclaimed at the next commit unless the fold is pinned. Two
|
|
1057
|
+
* acts, two keys, and the row says which state it is in.
|
|
1058
|
+
*/
|
|
1059
|
+
private handleExpandKey(): void {
|
|
1060
|
+
if (this.staging || this.briefMode) return;
|
|
1061
|
+
const row = this.visibleRows().find((candidate) => candidate.key === this.selectedKey);
|
|
1062
|
+
if (!row?.foldId) return;
|
|
1063
|
+
if (!this.actions.onSetExpanded) {
|
|
1064
|
+
this.notice = "expanding into the window is not wired in this view";
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
this.runAction(this.actions.onSetExpanded(row.foldId, !row.foldExpandedInContext));
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/** r RETURNS A FOLD TO RAW so its boundary can be re-marked (the tool's reboundary
|
|
1071
|
+
* path). Re-cutting is this plus `m` on the two entries the person meant, which the
|
|
1072
|
+
* view already has, so this is the half that was missing. */
|
|
1073
|
+
private handleDissolveKey(): void {
|
|
1074
|
+
if (this.staging || this.briefMode) return;
|
|
1075
|
+
const row = this.visibleRows().find((candidate) => candidate.key === this.selectedKey);
|
|
1076
|
+
if (!row?.foldId) return;
|
|
1077
|
+
if (!this.actions.onDissolveFold) {
|
|
1078
|
+
this.notice = "re-cutting is not wired in this view";
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
this.runAction(this.actions.onDissolveFold(row.foldId));
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
/** ] and [ page the OPEN DETAIL. A pure read: the offset is the reader's position and
|
|
1085
|
+
* nothing on this path reaches the projection. */
|
|
1086
|
+
private handlePageKey(direction: number): void {
|
|
1087
|
+
if (this.staging || this.briefMode || !this.detailedEntry) return;
|
|
1088
|
+
const row = this.visibleRows().find((candidate) => candidate.key === this.detailedEntry);
|
|
1089
|
+
const entryId = row?.entryId;
|
|
1090
|
+
if (!entryId || !this.actions.entryDetail) return;
|
|
1091
|
+
const page = this.actions.entryDetail(entryId, this.detailOffset);
|
|
1092
|
+
if (!page) return;
|
|
1093
|
+
if (direction > 0) {
|
|
1094
|
+
if (!page.hasMore) return;
|
|
1095
|
+
this.detailOffset = page.offset + page.text.length;
|
|
1096
|
+
} else {
|
|
1097
|
+
if (page.offset === 0) return;
|
|
1098
|
+
this.detailOffset = Math.max(0, page.offset - Math.max(1, page.text.length));
|
|
1099
|
+
}
|
|
1100
|
+
this.scrollToSelection();
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/** One in-flight edit at a time, with the refusal shown rather than thrown: every
|
|
1104
|
+
* action here is a skin over a validated path, and those refuse by name. */
|
|
1105
|
+
private runAction(work: Promise<void>): void {
|
|
713
1106
|
this.staging = true;
|
|
714
1107
|
this.notice = null;
|
|
715
|
-
void
|
|
1108
|
+
void work
|
|
716
1109
|
.then(() => {
|
|
717
1110
|
this.staging = false;
|
|
718
1111
|
})
|
|
@@ -724,7 +1117,7 @@ export class FoldEditorView {
|
|
|
724
1117
|
|
|
725
1118
|
private headerLines(width: number): string[] {
|
|
726
1119
|
const lines: string[] = [];
|
|
727
|
-
const { occupancy
|
|
1120
|
+
const { occupancy } = this.data;
|
|
728
1121
|
lines.push(truncateToWidth(`── ${this.data.title} ──`, width));
|
|
729
1122
|
// UNMEASURED IS PROSE, NEVER A BAR. An empty bar beside "?%" reads to the eye as
|
|
730
1123
|
// a window that is 0 percent full, which is a guess wearing the costume of a
|
|
@@ -794,7 +1187,12 @@ export class FoldEditorView {
|
|
|
794
1187
|
// object carrying two nouns on adjacent surfaces is a thing to learn for nothing.
|
|
795
1188
|
const staged = [`staged: ${pending.count} fold${pending.count === 1 ? "" : "s"}` +
|
|
796
1189
|
` · frees ~${pending.freedTokens.toLocaleString("en-US")} tokens at commit` +
|
|
797
|
-
` · ${this.data.pinned.length} pinned`
|
|
1190
|
+
` · ${this.data.pinned.length} pinned` +
|
|
1191
|
+
// EXPANDED IS A CLAIM ON THE WINDOW and belongs beside pinned, which is the
|
|
1192
|
+
// other one. Both hold raw bytes in front of the model; only one of them is
|
|
1193
|
+
// reclaimed automatically, and a reader cannot tell how much is being held
|
|
1194
|
+
// without seeing both counts together.
|
|
1195
|
+
`${this.data.expandedInContext?.length ? ` · ${this.data.expandedInContext.length} expanded` : ""}`];
|
|
798
1196
|
if (origins) staged.push(`staged by: ${origins}`);
|
|
799
1197
|
return staged;
|
|
800
1198
|
}
|
|
@@ -802,8 +1200,23 @@ export class FoldEditorView {
|
|
|
802
1200
|
render(width: number): string[] {
|
|
803
1201
|
const header = this.headerLines(width);
|
|
804
1202
|
const rows = this.visibleRows();
|
|
1203
|
+
// WHAT THE SEARCH REMOVED IS COUNTED, never silently absent. A narrowed view that
|
|
1204
|
+
// says nothing reads as the whole window, which is gate 136's law on the surface a
|
|
1205
|
+
// person acts from: they would mark, pin or dissolve believing they had seen
|
|
1206
|
+
// everything. The term is echoed because a filter left standing from three minutes
|
|
1207
|
+
// ago is the other half of the same mistake.
|
|
1208
|
+
if (this.filter !== null) {
|
|
1209
|
+
const total = this.allRows().length;
|
|
1210
|
+
const cursorMark = this.searchMode ? "\u2588" : "";
|
|
1211
|
+
header.push(truncateToWidth(
|
|
1212
|
+
`search: ${this.filter}${cursorMark} \u00b7 ${rows.length} of ${total} rows` +
|
|
1213
|
+
`${this.searchMode ? " \u00b7 Enter:keep \u00b7 Esc:clear" : " \u00b7 Esc:clear"}`,
|
|
1214
|
+
width));
|
|
1215
|
+
}
|
|
805
1216
|
if (!rows.length) {
|
|
806
|
-
return [...header, "", truncateToWidth(
|
|
1217
|
+
return [...header, "", truncateToWidth(this.filter
|
|
1218
|
+
? `nothing matches "${this.filter}" \u00b7 Esc:clear`
|
|
1219
|
+
: "(empty window)", width)];
|
|
807
1220
|
}
|
|
808
1221
|
this.scroll = Math.max(0, Math.min(this.scroll, Math.max(0, rows.length - MAX_VISIBLE_ROWS)));
|
|
809
1222
|
const body = rows
|
|
@@ -838,10 +1251,18 @@ export class FoldEditorView {
|
|
|
838
1251
|
"",
|
|
839
1252
|
...body,
|
|
840
1253
|
...footers,
|
|
841
|
-
//
|
|
842
|
-
//
|
|
843
|
-
//
|
|
844
|
-
|
|
1254
|
+
// EVERY KEY THAT CHANGES SOMETHING IS NAMED. p and u changed state and were named
|
|
1255
|
+
// nowhere while the header printed a pin count on every render; e and r joined
|
|
1256
|
+
// them on 2026-08-30. "raw span" stays: it is the word explaining why m does
|
|
1257
|
+
// nothing on a fold row.
|
|
1258
|
+
//
|
|
1259
|
+
// ORDERED BY WHAT TRUNCATION SHOULD COST LEAST, and the ordering carries the
|
|
1260
|
+
// distinction the surface is built on: enter opens a fold FOR THE READER and e
|
|
1261
|
+
// opens it INTO THE MODEL'S WINDOW, so "open" and "show model" sit apart and are
|
|
1262
|
+
// worded apart. A reader who learns only that enter opens things has learned the
|
|
1263
|
+
// free one, which is the safe half to learn first.
|
|
1264
|
+
truncateToWidth("enter:open · ]/[:page · arrows:move · m:mark raw span · p:pin · " +
|
|
1265
|
+
"e:show model · r:re-cut · u:withdraw · esc:close", width),
|
|
845
1266
|
];
|
|
846
1267
|
}
|
|
847
1268
|
}
|