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.
- package/README.md +26 -28
- package/extensions/active-context.ts +645 -412
- package/extensions/index.js +11 -2
- package/extensions/lib/brief-text.ts +116 -0
- package/extensions/lib/canonical.ts +3 -18
- package/extensions/lib/curation.ts +208 -74
- package/extensions/lib/editor-ui.ts +595 -66
- 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/measurement.ts +47 -0
- package/extensions/lib/persistence.ts +42 -39
- package/extensions/lib/policy.ts +72 -51
- package/extensions/lib/selection.ts +9 -44
- package/extensions/lib/tool-surface.ts +2 -32
- package/extensions/lib/transcript.ts +43 -0
- package/extensions/settings.ts +200 -32
- 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. */
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
224
|
-
*
|
|
225
|
-
*
|
|
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
|
|
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
|
-
|
|
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}
|
|
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
|
-
|
|
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.
|
|
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"
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
425
|
-
|
|
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,
|
|
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
|
-
|
|
692
|
+
entryId: entry.id,
|
|
693
|
+
...(markable ? { markable: true, entryIndex: entry.index } : {}),
|
|
458
694
|
});
|
|
459
695
|
if (detailed) {
|
|
460
|
-
|
|
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
|
|
516
|
-
// 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
|
+
}
|
|
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
|
-
|
|
544
|
-
else this.expanded.add(row.toggleId);
|
|
879
|
+
this.toggleBlock(row);
|
|
545
880
|
this.scrollToSelection();
|
|
546
881
|
} else if (row.entryPreview !== undefined) {
|
|
547
|
-
this.
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
|
1120
|
+
const { occupancy } = this.data;
|
|
677
1121
|
lines.push(truncateToWidth(`── ${this.data.title} ──`, width));
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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}
|
|
687
|
-
`
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|