pi-fold 3.1.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. */
@@ -50,6 +58,9 @@ export interface FoldEditorData {
50
58
  budgetTokens: number;
51
59
  commitOccupancy: number;
52
60
  commitDue: boolean;
61
+ /** Automatic folding has stopped. The editor is the surface a person opens in order
62
+ * to ACT, and without this it priced a countdown to a commit that cannot fire. */
63
+ suspended?: boolean;
53
64
  };
54
65
  /** Top-level blocks only: raw gaps and ROOT folds, in window order. */
55
66
  blocks: FoldEditorBlock[];
@@ -61,24 +72,57 @@ export interface FoldEditorData {
61
72
  freedTokens: number;
62
73
  };
63
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[];
64
78
  }
65
79
 
66
80
  const BAR_WIDTH = 24;
67
81
  const MAX_VISIBLE_ROWS = 24;
68
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;
69
88
 
70
89
  export function occupancyBar(usedTokens: number | null, budgetTokens: number): string {
71
90
  const ratio = usedTokens !== null && budgetTokens > 0
72
91
  ? Math.max(0, Math.min(1, usedTokens / budgetTokens))
73
92
  : 0;
74
93
  const filled = Math.round(ratio * BAR_WIDTH);
75
- return `${"█".repeat(filled)}${"·".repeat(BAR_WIDTH - filled)}`;
94
+ // THE SAME ALPHABET /fold-status USES. The editor drew its empty cells with the middle
95
+ // dot that also separates the fields on that very line, so the bar's tail and the
96
+ // line's punctuation were the same glyph doing two jobs.
97
+ return `${"▇".repeat(filled)}${"░".repeat(BAR_WIDTH - filled)}`;
76
98
  }
77
99
 
78
100
  function shortId(id: string): string {
79
101
  return id.length > 12 ? id.slice(0, 12) : id;
80
102
  }
81
103
 
104
+ /**
105
+ * MARK ORIGINS IN THE READER'S OWN WORDS. The state's names are for the state: "ladder"
106
+ * is this runtime's internal name for its own automatic marking pass, and a person
107
+ * reading a proposed row needs to know WHO proposed the fold, not which code path
108
+ * staged it. An unrecognized origin passes through rather than being hidden, because a
109
+ * name we do not have a word for is still better than a blank.
110
+ *
111
+ * ONE WORD FOR ONE THING (2026-08-28): the rows said PROPOSED while the header said
112
+ * staged, for the same marks on the same screen, which asks a reader to learn that two
113
+ * words mean one thing before they can read either line.
114
+ */
115
+ const ORIGIN_WORDS: Record<string, string> = {
116
+ ladder: "automatic",
117
+ agent: "the model",
118
+ user: "you",
119
+ };
120
+
121
+ function originWord(origin: string | undefined): string {
122
+ if (!origin) return "staged";
123
+ return ORIGIN_WORDS[origin] ?? origin;
124
+ }
125
+
82
126
  /**
83
127
  * Assemble the top-level window stream. Committed folds nest by containment (a fold
84
128
  * whose span sits inside another becomes a CHILD of the innermost container), so the
@@ -92,6 +136,7 @@ export function buildFoldEditorData(
92
136
  foldRows: () => Array<{
93
137
  id: string; kind: string; brief: string; sourceCount: number;
94
138
  startPosition: number; endPosition: number;
139
+ provenance?: string; expandedInContext?: boolean;
95
140
  entries: FoldEditorEntry[];
96
141
  }>;
97
142
  pendingMarkRefs: () => Array<{ id: string; origin: string; brief: string; entryIds: string[] }>;
@@ -109,6 +154,8 @@ export function buildFoldEditorData(
109
154
  endPosition: row.endPosition,
110
155
  kind: row.kind,
111
156
  brief: row.brief,
157
+ provenance: row.provenance,
158
+ expandedInContext: row.expandedInContext,
112
159
  sourceCount: row.sourceCount,
113
160
  entries: row.entries,
114
161
  children: [],
@@ -165,7 +212,9 @@ export function buildFoldEditorData(
165
212
  if (block.type === "raw") {
166
213
  const roles = new Map<string, number>();
167
214
  for (const entry of block.entries) roles.set(entry.role, (roles.get(entry.role) ?? 0) + 1);
168
- block.rolesSummary = [...roles.entries()].map(([role, count]) => `${role}×${count}`).join(" ");
215
+ // "user×2 assistant×3" put the count behind the same bare multiplication sign the
216
+ // fold rows just lost. Count first, noun second, like everything else here.
217
+ block.rolesSummary = [...roles.entries()].map(([role, count]) => `${count} ${role}`).join(", ");
169
218
  }
170
219
  }
171
220
 
@@ -220,11 +269,16 @@ function briefChunks(brief: string): string[] {
220
269
  return chunks;
221
270
  }
222
271
 
223
- /** Detail rows: the full preview (to 480 chars) wrapped like a brief, under the
224
- * entry's own row. One long truncated line plus a mid-word continuation hid the
225
- * 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. */
226
280
  function detailChunks(preview: string): string[] {
227
- return briefChunks(preview.slice(0, 480));
281
+ return briefChunks(preview);
228
282
  }
229
283
 
230
284
  interface RenderRow {
@@ -240,6 +294,16 @@ interface RenderRow {
240
294
  entryId?: string;
241
295
  /** A PROPOSED row: withdrawable with u. */
242
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;
243
307
  }
244
308
 
245
309
  /** What laying a mark over a span WOULD commit: the size the user sees before staging. */
@@ -248,16 +312,33 @@ export interface SpanCost {
248
312
  tokens: number;
249
313
  }
250
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
+
251
323
  export interface FoldEditorActions {
252
324
  /** Live would-be fold size for a span of mapped indices, any order. */
253
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;
254
330
  /** Stage a USER mark over two raw entry ids through the validated path. The
255
331
  * brief is what the user typed; empty means the deterministic brief. */
256
332
  onStageMark?: (fromId: string, toId: string, brief?: string) => Promise<void>;
257
333
  /** Withdraw one staged mark by id (the tool's unmark path). */
258
334
  onWithdrawMark?: (markId: string) => Promise<void>;
259
- /** 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. */
260
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>;
261
342
  }
262
343
 
263
344
  type ThemeFn = (color: string, text: string) => string;
@@ -270,7 +351,34 @@ export class FoldEditorView {
270
351
  private actions: FoldEditorActions;
271
352
  private selectedKey: string;
272
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;
273
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;
274
382
  private scroll = 0;
275
383
  /** The anchored mark point: first boundary of a span the user is laying down. */
276
384
  private anchor: { key: string; id: string; index: number } | null = null;
@@ -316,6 +424,76 @@ export class FoldEditorView {
316
424
  return this.data.pinned.includes(entryId) ? " 📌" : "";
317
425
  }
318
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
+
319
497
  private renderFold(block: FoldEditorBlock, depth: number, rows: RenderRow[], keyPrefix: string): void {
320
498
  // COLLAPSED = ONE ROW (Shane, 2026-08-22): "fold {id}" alone, no END bookend.
321
499
  // Start/end bookends exist only in the EXPANDED state. ROW KEYS ARE UNIQUE PER
@@ -326,13 +504,41 @@ export class FoldEditorView {
326
504
  const foldKey = `${keyPrefix}${block.id}`;
327
505
  const isOpen = this.expanded.has(foldKey);
328
506
  const pad = " ".repeat(depth);
329
- const kindLabel = `${block.kind} \u00d7${block.sourceCount}`;
507
+ // THE ROW SAYS WHAT WAS FOLDED (2026-08-28). It read "fold fold_a1b2c3 chapter ×9":
508
+ // the word "fold" twice (the ▸ glyph is already the word), thirteen columns of an
509
+ // id nothing in this view accepts as input, and the brief that says what the fold
510
+ // COVERS hidden behind Enter. Twelve roots differed only by hex, which recreates
511
+ // inside the editor the exact defect /fold-status was rewritten to remove.
512
+ //
513
+ // The id is dropped rather than trailed: it is unusable here, the expanded state
514
+ // still carries it on the end bookend, and a trailing id is what truncation eats
515
+ // first anyway. "×9" becomes a counted noun, because ×9 could be entries, nesting
516
+ // depth or generations and the reader has no way to tell which.
517
+ const kindLabel = `${block.kind} · ${block.sourceCount} ` +
518
+ `${block.sourceCount === 1 ? "entry" : "entries"}`;
330
519
  const marker = isOpen ? "\u25bc" : "\u25b8";
331
520
  const foldCursor = this.selectedKey === foldKey ? "\u276f" : " ";
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
+ : "";
332
534
  rows.push({
333
535
  key: foldKey,
334
- text: `${foldCursor}${this.color("success", `${pad}${marker} fold ${shortId(block.id)}`)} ${kindLabel}`,
536
+ text: `${foldCursor}${this.color("success", `${pad}${marker} ${kindLabel}`)}` +
537
+ `${this.foldPinBadge(block)}${contextBadge}${provenanceBadge}` +
538
+ `${foldHead ? ` · ${foldHead}` : ""}`,
335
539
  toggleId: foldKey,
540
+ foldId: block.id,
541
+ foldExpandedInContext: Boolean(block.expandedInContext),
336
542
  });
337
543
  if (!isOpen) return;
338
544
  let ordinal = 0;
@@ -353,23 +559,19 @@ export class FoldEditorView {
353
559
  ? this.color("dim", ` \u201c${entry.preview.slice(0, 48)}\u201d`)
354
560
  : "";
355
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.
356
567
  rows.push({
357
568
  key: entryKey,
358
569
  text: `${entryCursor}${pad} ${this.color("dim", `\u00b7 ${entry.role} ${shortId(entry.id)}${this.pinnedBadge(entry.id)}`)}${quoted}`,
359
570
  toggleId: null,
360
571
  entryPreview: entry.preview,
572
+ entryId: entry.id,
361
573
  });
362
- if (detailed) {
363
- let detailOrdinal = 0;
364
- for (const chunk of detailChunks(entry.preview)) {
365
- rows.push({
366
- key: `${entryKey}:d${detailOrdinal}`,
367
- text: `${pad} ${this.color("dim", chunk)}`,
368
- toggleId: null,
369
- });
370
- detailOrdinal += 1;
371
- }
372
- }
574
+ if (detailed) this.pushDetail(rows, entryKey, entry, pad);
373
575
  }
374
576
  const endCursor = this.selectedKey === `${foldKey}:end` ? "\u276f" : " ";
375
577
  rows.push({
@@ -379,22 +581,43 @@ export class FoldEditorView {
379
581
  });
380
582
  }
381
583
 
382
- 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[] {
383
599
  const rows: RenderRow[] = [];
384
600
  for (const block of this.data.blocks) {
385
601
  if (block.type === "fold") {
386
602
  this.renderFold(block, 0, rows, "");
387
603
  continue;
388
604
  }
389
- const isOpen = this.expanded.has(block.id);
605
+ const isOpen = this.blockIsOpen(block);
390
606
  const cursor = this.selectedKey === block.id ? "\u276f" : " ";
391
607
  if (block.type === "proposed") {
392
608
  const isLadder = block.origin === "ladder";
393
609
  const color = isLadder ? "warning" : "accent";
394
- const sizeNote = typeof block.tokens === "number" ? ` · ~${block.tokens.toLocaleString("en-US")} tok` : "";
610
+ const sizeNote = typeof block.tokens === "number"
611
+ ? ` · frees ~${block.tokens.toLocaleString("en-US")} tokens` : "";
612
+ const [stagedHead] = briefChunks(block.brief ?? "");
395
613
  rows.push({
396
614
  key: block.id,
397
- text: `${cursor}${this.color(color, `\u25c7 PROPOSED (${block.origin}) ${shortId(block.id)}`)} \u00d7${block.sourceCount}${sizeNote}${this.selectedKey === block.id ? " · u:withdraw" : ""}`,
615
+ // Same treatment as the fold row: no id, a counted noun, and the brief seated
616
+ // as the tail so it is what degrades if the terminal is narrow.
617
+ text: `${cursor}${this.color(color, `\u25c7 staged by ${originWord(block.origin)}`)}` +
618
+ ` · ${block.sourceCount} ${block.sourceCount === 1 ? "entry" : "entries"}${sizeNote}` +
619
+ `${stagedHead ? ` · ${stagedHead}` : ""}` +
620
+ `${this.selectedKey === block.id ? " · u:withdraw" : ""}`,
398
621
  toggleId: block.id,
399
622
  proposedMarkId: block.id,
400
623
  });
@@ -411,26 +634,38 @@ export class FoldEditorView {
411
634
  // resolves them); an entry that fell off the map renders id-only.
412
635
  const label = ["\u00b7", entry.role, `${shortId(entry.id)}${this.pinnedBadge(entry.id)}`]
413
636
  .filter(Boolean).join(" ");
414
- 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` : "";
415
640
  rows.push({
416
641
  key: entryKey,
417
642
  text: `${this.selectedKey === entryKey ? "\u276f" : " "} ${this.color("dim", `${label}${quoted}`)}`,
418
643
  toggleId: null,
419
644
  entryPreview: entry.preview,
645
+ entryId: entry.id,
420
646
  });
647
+ if (detailed) this.pushDetail(rows, entryKey, entry, "");
421
648
  }
422
649
  }
423
650
  } else {
424
- const label = `\u00b7 raw \u00d7${block.sourceCount}` +
425
- `${block.rolesSummary ? ` ${block.rolesSummary}` : ""}`;
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} ` +
657
+ `${block.sourceCount === 1 ? "entry" : "entries"}` +
658
+ `${block.rolesSummary ? ` ${block.rolesSummary}` : ""}` +
659
+ `${isOpen ? "" : " · hidden from this view only"}`;
426
660
  rows.push({
427
661
  key: block.id,
428
662
  text: `${cursor}${this.color("dim", label)}`,
429
663
  toggleId: block.id,
664
+ rawBlockId: block.id,
430
665
  });
431
666
  if (isOpen) {
432
667
  let ordinal = 0;
433
- for (const entry of block.entries.slice(0, 60)) {
668
+ for (const entry of block.entries.slice(0, RAW_ENTRY_CAP)) {
434
669
  const pinBadge = this.data.pinned.includes(entry.id) ? " \ud83d\udccc" : "";
435
670
  const entryKey = `${block.id}:${entry.id}:r${ordinal}`;
436
671
  ordinal += 1;
@@ -454,18 +689,11 @@ export class FoldEditorView {
454
689
  text: `${cursor} ${this.color("dim", `\u00b7 ${entry.role} ${shortId(entry.id)}${pinBadge}`)}${badge}${quoted}`,
455
690
  toggleId: null,
456
691
  entryPreview: entry.preview,
457
- ...(markable ? { markable: true, entryIndex: entry.index, entryId: entry.id } : {}),
692
+ entryId: entry.id,
693
+ ...(markable ? { markable: true, entryIndex: entry.index } : {}),
458
694
  });
459
695
  if (detailed) {
460
- let detailOrdinal = 0;
461
- for (const chunk of detailChunks(entry.preview)) {
462
- rows.push({
463
- key: `${entryKey}:d${detailOrdinal}`,
464
- text: ` ${this.color("dim", chunk)}`,
465
- toggleId: null,
466
- });
467
- detailOrdinal += 1;
468
- }
696
+ this.pushDetail(rows, entryKey, entry, "");
469
697
  }
470
698
  }
471
699
  }
@@ -474,6 +702,65 @@ export class FoldEditorView {
474
702
  return rows;
475
703
  }
476
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
+
477
764
  private move(delta: number): void {
478
765
  const rows = this.visibleRows();
479
766
  if (!rows.length) return;
@@ -499,6 +786,19 @@ export class FoldEditorView {
499
786
  this.handleBriefInput(data);
500
787
  return;
501
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
+ }
502
802
  if (data === "u") {
503
803
  this.handleWithdrawKey();
504
804
  return;
@@ -507,13 +807,34 @@ export class FoldEditorView {
507
807
  this.handlePinKey();
508
808
  return;
509
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
+ }
510
822
  if (data === "m") {
511
823
  this.handleMarkKey();
512
824
  return;
513
825
  }
514
826
  if (this.kb.matches(data, "tui.select.cancel")) {
515
- // ESCAPE CANCELS WORK FIRST, STEPWISE: the brief line, then the anchor, and
516
- // 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
+ }
517
838
  if (this.anchor !== null) {
518
839
  this.anchor = null;
519
840
  this.notice = null;
@@ -533,6 +854,21 @@ export class FoldEditorView {
533
854
  this.expanded.delete(target);
534
855
  this.selectedKey = target;
535
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
+ }
536
872
  }
537
873
  }
538
874
  else if (this.kb.matches(data, "tui.select.confirm") ||
@@ -540,17 +876,53 @@ export class FoldEditorView {
540
876
  const row = this.visibleRows().find((candidate) => candidate.key === this.selectedKey);
541
877
  if (!row) return;
542
878
  if (row.toggleId) {
543
- if (this.expanded.has(row.toggleId)) this.expanded.delete(row.toggleId);
544
- else this.expanded.add(row.toggleId);
879
+ this.toggleBlock(row);
545
880
  this.scrollToSelection();
546
881
  } else if (row.entryPreview !== undefined) {
547
- this.detailedEntry = this.detailedEntry === row.key ? null : row.key;
882
+ this.toggleDetail(row.key);
548
883
  this.scrollToSelection();
549
884
  }
550
885
  }
551
886
  }
552
887
 
553
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
+
554
926
  private innermostOpenFold(key: string): string | null {
555
927
  const chain = key.split(":")[0].split(">");
556
928
  for (let index = chain.length - 1; index >= 0; index -= 1) {
@@ -650,18 +1022,90 @@ export class FoldEditorView {
650
1022
  });
651
1023
  }
652
1024
 
653
- /** 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
+ */
654
1036
  private handlePinKey(): void {
655
1037
  if (this.staging || this.briefMode) return;
656
1038
  const row = this.visibleRows().find((candidate) => candidate.key === this.selectedKey);
657
- if (!row || !row.markable || !row.entryId) return;
1039
+ const target = row?.entryId ?? row?.foldId;
1040
+ if (!target) return;
658
1041
  if (!this.actions.onTogglePin) {
659
1042
  this.notice = "pinning is not wired in this view";
660
1043
  return;
661
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 {
662
1106
  this.staging = true;
663
1107
  this.notice = null;
664
- void this.actions.onTogglePin(row.entryId)
1108
+ void work
665
1109
  .then(() => {
666
1110
  this.staging = false;
667
1111
  })
@@ -673,41 +1117,115 @@ export class FoldEditorView {
673
1117
 
674
1118
  private headerLines(width: number): string[] {
675
1119
  const lines: string[] = [];
676
- const { occupancy, pending } = this.data;
1120
+ const { occupancy } = this.data;
677
1121
  lines.push(truncateToWidth(`── ${this.data.title} ──`, width));
678
- const percent = occupancy.usedTokens !== null && occupancy.budgetTokens > 0
679
- ? ((occupancy.usedTokens / occupancy.budgetTokens) * 100).toFixed(1)
680
- : "?";
681
- const usedTokensText = occupancy.usedTokens !== null
682
- ? occupancy.usedTokens.toLocaleString("en-US")
683
- : "?";
1122
+ // UNMEASURED IS PROSE, NEVER A BAR. An empty bar beside "?%" reads to the eye as
1123
+ // a window that is 0 percent full, which is a guess wearing the costume of a
1124
+ // measurement: the exact failure the other three surfaces already refuse, on the
1125
+ // one surface the rule was never checked against. Nothing here is estimated.
1126
+ if (occupancy.usedTokens === null || !(occupancy.budgetTokens > 0)) {
1127
+ lines.push(truncateToWidth(
1128
+ "window not measured yet; the first model response will measure it", width));
1129
+ for (const line of this.stagedLines()) lines.push(truncateToWidth(line, width));
1130
+ return lines;
1131
+ }
1132
+ // A WHOLE PERCENT. The tenth was false precision on a figure that moves with every
1133
+ // message, and it read as a measurement finer than the thing being measured. The
1134
+ // other three human surfaces round, so this one does too. Past the guard above,
1135
+ // both values are known, so neither can render as "?".
1136
+ const percent = Math.round((occupancy.usedTokens / occupancy.budgetTokens) * 100);
1137
+ const usedTokensText = occupancy.usedTokens.toLocaleString("en-US");
1138
+ // The DISTANCE to the commit, not just the share it fires at. "commit at 80%" is
1139
+ // a property of the configuration; "29,674 to go" is the answer to the question
1140
+ // the reader actually has, and it is the same number /fold-status states.
1141
+ const headroom = occupancy.usedTokens !== null && occupancy.budgetTokens > 0 && !occupancy.commitDue
1142
+ ? ` (${Math.round(occupancy.commitOccupancy * occupancy.budgetTokens - occupancy.usedTokens)
1143
+ .toLocaleString("en-US")} to go)`
1144
+ : "";
1145
+ // Same ordering rule as the line below it: the raw token pair is the most
1146
+ // expendable thing here, because the percentage in front of it already says what
1147
+ // it says. At 80 columns the first draft cut the headroom instead.
1148
+ // A STOPPED WINDOW IS TOLD SO, NOT GIVEN A COUNTDOWN. Every other surface reports
1149
+ // the suspension and this one alone kept pricing the distance to a commit that
1150
+ // will not happen, which is the one state here that can cost somebody a session.
1151
+ const timing = occupancy.suspended
1152
+ ? " · FOLDING STOPPED"
1153
+ : ` · commit at ${(occupancy.commitOccupancy * 100).toFixed(0)}%${headroom}` +
1154
+ `${occupancy.commitDue ? " · COMMIT DUE" : ""}`;
684
1155
  lines.push(truncateToWidth(
685
1156
  `${occupancyBar(occupancy.usedTokens, occupancy.budgetTokens)} ` +
686
- `${percent}% · ${usedTokensText}/${occupancy.budgetTokens.toLocaleString("en-US")} tokens · ` +
687
- `commit at ${(occupancy.commitOccupancy * 100).toFixed(0)}%` +
688
- `${occupancy.commitDue ? " · COMMIT DUE" : ""}`,
689
- width,
690
- ));
691
- lines.push(truncateToWidth(
692
- `staged marks: ${pending.count} (${pending.agentMarks} agent, ${pending.ladderMarks} ladder` +
693
- `${pending.userMarks > 0 ? `, ${pending.userMarks} you` : ""})` +
694
- ` · about ${pending.freedTokens.toLocaleString("en-US")} tokens freed when committed · ` +
695
- `pinned: ${this.data.pinned.length}`,
1157
+ `${percent}%${timing}` +
1158
+ ` · ${usedTokensText}/${occupancy.budgetTokens.toLocaleString("en-US")}`,
696
1159
  width,
697
1160
  ));
1161
+ for (const line of this.stagedLines()) lines.push(truncateToWidth(line, width));
698
1162
  return lines;
699
1163
  }
700
1164
 
1165
+ /**
1166
+ * ORDERED SO TRUNCATION COSTS THE LEAST. This line is cut to the terminal's width, and
1167
+ * at 100 columns the first draft lost the pin count off the end while spending its
1168
+ * width on who staged what. What the marks free and what is pinned are facts about the
1169
+ * window; the origin breakdown is colour, so it goes last.
1170
+ */
1171
+ private stagedLines(): string[] {
1172
+ const { pending } = this.data;
1173
+ // The same vocabulary as ORIGIN_WORDS above, inflected for a count rather than a
1174
+ // label: "1 automatic" and "2 by the model" read where "2 the model" does not.
1175
+ // Only origins that actually staged something are listed, because a run of zeroes
1176
+ // spends the line's width saying nothing happened three different ways.
1177
+ const origins = [
1178
+ pending.ladderMarks > 0 ? `${pending.ladderMarks} automatic` : "",
1179
+ pending.agentMarks > 0 ? `${pending.agentMarks} by the model` : "",
1180
+ pending.userMarks > 0 ? `${pending.userMarks} yours` : "",
1181
+ ].filter((part) => part !== "").join(", ");
1182
+ // TWO LINES RATHER THAN ONE THAT RUNS OFF THE TERMINAL. At 97 characters the single
1183
+ // line ended mid-phrase at "2 by the model" on any normal window; vertical space is
1184
+ // cheaper than a sentence cut in half. The facts about the window come first and
1185
+ // who staged what follows, so a narrow terminal still keeps the first line whole.
1186
+ // "folds" not "marks": /fold-status and the always-on line both say folds, and one
1187
+ // object carrying two nouns on adjacent surfaces is a thing to learn for nothing.
1188
+ const staged = [`staged: ${pending.count} fold${pending.count === 1 ? "" : "s"}` +
1189
+ ` · frees ~${pending.freedTokens.toLocaleString("en-US")} tokens at commit` +
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` : ""}`];
1196
+ if (origins) staged.push(`staged by: ${origins}`);
1197
+ return staged;
1198
+ }
1199
+
701
1200
  render(width: number): string[] {
702
1201
  const header = this.headerLines(width);
703
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
+ }
704
1216
  if (!rows.length) {
705
- return [...header, "", truncateToWidth("(empty window)", width)];
1217
+ return [...header, "", truncateToWidth(this.filter
1218
+ ? `nothing matches "${this.filter}" \u00b7 Esc:clear`
1219
+ : "(empty window)", width)];
706
1220
  }
707
1221
  this.scroll = Math.max(0, Math.min(this.scroll, Math.max(0, rows.length - MAX_VISIBLE_ROWS)));
708
1222
  const body = rows
709
1223
  .slice(this.scroll, this.scroll + MAX_VISIBLE_ROWS)
710
- .map((row) => truncateToWidth(row.text, width, ""));
1224
+ // THE PROJECT'S OWN BOUNDING LAW (gate 136): "..." means content continues in the
1225
+ // source and never appears when nothing was cut. Header lines already take the
1226
+ // default marker; body rows passed "" and were cut mid-word in silence, so the
1227
+ // display layer contradicted the rule the briefs it displays all obey.
1228
+ .map((row) => truncateToWidth(row.text, width));
711
1229
  // THE WOULD-BE FOLD SIZE (Shane, 2026-08-23): while a span is being laid down,
712
1230
  // the footer states what the second mark point would commit, in the runtime's
713
1231
  // own token arithmetic -- the same numbers the staged mark will answer with.
@@ -733,7 +1251,18 @@ export class FoldEditorView {
733
1251
  "",
734
1252
  ...body,
735
1253
  ...footers,
736
- truncateToWidth("enter:expand/detail · arrows:navigate · m:mark raw span · 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),
737
1266
  ];
738
1267
  }
739
1268
  }