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.
@@ -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 full preview (to 480 chars) wrapped like a brief, under the
254
- * entry's own row. One long truncated line plus a mid-word continuation hid the
255
- * middle of the text; wrapping shows all of it in reading order. */
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.slice(0, 480));
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
- private visibleRows(): RenderRow[] {
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.expanded.has(block.id);
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 quoted = entry.preview ? ` \u201c${entry.preview.slice(0, 48)}\u201d` : "";
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
- const label = `\u00b7 raw · ${block.sourceCount} ` +
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, 60)) {
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
- ...(markable ? { markable: true, entryIndex: entry.index, entryId: entry.id } : {}),
692
+ entryId: entry.id,
693
+ ...(markable ? { markable: true, entryIndex: entry.index } : {}),
509
694
  });
510
695
  if (detailed) {
511
- let detailOrdinal = 0;
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 anchor, and
567
- // only then the editor. Nothing the user laid down is lost by one press.
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
- if (this.expanded.has(row.toggleId)) this.expanded.delete(row.toggleId);
595
- else this.expanded.add(row.toggleId);
879
+ this.toggleBlock(row);
596
880
  this.scrollToSelection();
597
881
  } else if (row.entryPreview !== undefined) {
598
- this.detailedEntry = this.detailedEntry === row.key ? null : row.key;
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
- /** p ON A RAW ENTRY toggles its pin (the tool's pin/unpin path). */
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
- if (!row || !row.markable || !row.entryId) return;
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 this.actions.onTogglePin(row.entryId)
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, pending } = this.data;
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("(empty window)", width)];
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
- // p AND u CHANGE STATE AND WERE NAMED NOWHERE, while the header prints a pin count
842
- // on every render. "raw span" stays: it is the word explaining why m does
843
- // nothing on a fold row. The width comes out of enter and arrows instead.
844
- truncateToWidth("enter:open · arrows:move · m:mark raw span · p:pin · u:withdraw · esc:close", width),
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
  }