pi-fold 2.0.0 → 3.0.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.
@@ -0,0 +1,739 @@
1
+ // /fold-editor: the user's map of the active context window. V2.1: the DEFAULT view
2
+ // is the active window's TOP LEVEL -- raw gaps and ROOT folds only. Nested folds do
3
+ // not render until their parent is expanded, and an expanded fold exposes its actual
4
+ // MESSAGES as selectable rows, so navigation reaches entry level. Colored bookends
5
+ // carry the structure: theme success opens a fold, theme error closes it, accent/
6
+ // warning mark PROPOSED (staged) blocks, dim renders raw. V2.2: RAW ENTRIES ARE MARK
7
+ // POINTS. `m` anchors one, moving shows the live would-be fold size, and `m` again
8
+ // stages a USER mark through the callback the command handler supplies -- which runs
9
+ // the SAME validated staging path the agent's tool uses, origin "user". The view
10
+ // itself still holds no runtime reference and no mutation path: it computes nothing
11
+ // but the arithmetic it is handed (spanCost) and reports one intent (onStageMark).
12
+ // Everything else must stay skins over existing validated paths.
13
+ //
14
+ // Keys go through the INJECTED keybindings manager (/tree action names), so user
15
+ // rebinding works natively; the global pi-tui manager is the fallback. Raw byte
16
+ // matching froze the first settings editor on application-cursor-mode terminals.
17
+
18
+ import { getKeybindings, truncateToWidth } from "@earendil-works/pi-tui";
19
+
20
+ export interface FoldEditorEntry {
21
+ id: string;
22
+ role: string;
23
+ preview: string;
24
+ /** Position in the mapped window; present on raw entries, which are the mark points. */
25
+ index?: number;
26
+ }
27
+
28
+ export interface FoldEditorBlock {
29
+ type: "fold" | "proposed" | "raw";
30
+ id: string;
31
+ startPosition: number;
32
+ endPosition: number;
33
+ kind?: string;
34
+ origin?: string;
35
+ brief?: string;
36
+ sourceCount?: number;
37
+ rolesSummary?: string;
38
+ /** Estimated tokens this staged mark frees at the next commit; proposed blocks only. */
39
+ tokens?: number;
40
+ /** Direct source entries of this fold (its messages), for drill-in. */
41
+ entries: FoldEditorEntry[];
42
+ /** Folds nested inside this one; hidden until the parent is expanded. */
43
+ children: FoldEditorBlock[];
44
+ }
45
+
46
+ export interface FoldEditorData {
47
+ title: string;
48
+ occupancy: {
49
+ usedTokens: number | null;
50
+ budgetTokens: number;
51
+ commitOccupancy: number;
52
+ commitDue: boolean;
53
+ };
54
+ /** Top-level blocks only: raw gaps and ROOT folds, in window order. */
55
+ blocks: FoldEditorBlock[];
56
+ pending: {
57
+ count: number;
58
+ agentMarks: number;
59
+ ladderMarks: number;
60
+ userMarks: number;
61
+ freedTokens: number;
62
+ };
63
+ pinned: string[];
64
+ }
65
+
66
+ const BAR_WIDTH = 24;
67
+ const MAX_VISIBLE_ROWS = 24;
68
+ const EXPANDED_ENTRY_CAP = 40;
69
+
70
+ export function occupancyBar(usedTokens: number | null, budgetTokens: number): string {
71
+ const ratio = usedTokens !== null && budgetTokens > 0
72
+ ? Math.max(0, Math.min(1, usedTokens / budgetTokens))
73
+ : 0;
74
+ const filled = Math.round(ratio * BAR_WIDTH);
75
+ return `${"█".repeat(filled)}${"·".repeat(BAR_WIDTH - filled)}`;
76
+ }
77
+
78
+ function shortId(id: string): string {
79
+ return id.length > 12 ? id.slice(0, 12) : id;
80
+ }
81
+
82
+ /**
83
+ * Assemble the top-level window stream. Committed folds nest by containment (a fold
84
+ * whose span sits inside another becomes a CHILD of the innermost container), so the
85
+ * view can keep nested folds hidden until their ancestors expand. Staged marks stay
86
+ * top-level: they propose RAW spans, and the user reads them against the gaps.
87
+ */
88
+ export function buildFoldEditorData(
89
+ snapshot: any,
90
+ state: any,
91
+ helpers: {
92
+ foldRows: () => Array<{
93
+ id: string; kind: string; brief: string; sourceCount: number;
94
+ startPosition: number; endPosition: number;
95
+ entries: FoldEditorEntry[];
96
+ }>;
97
+ pendingMarkRefs: () => Array<{ id: string; origin: string; brief: string; entryIds: string[] }>;
98
+ mappedRange: (from: number, to: number) => FoldEditorEntry[];
99
+ entryCount: number;
100
+ },
101
+ ): FoldEditorData["blocks"] {
102
+ const nodes = new Map<string, FoldEditorBlock>();
103
+ for (const row of helpers.foldRows()) {
104
+ if (!Number.isFinite(row.startPosition) || !Number.isFinite(row.endPosition)) continue;
105
+ nodes.set(row.id, {
106
+ type: "fold",
107
+ id: row.id,
108
+ startPosition: row.startPosition,
109
+ endPosition: row.endPosition,
110
+ kind: row.kind,
111
+ brief: row.brief,
112
+ sourceCount: row.sourceCount,
113
+ entries: row.entries,
114
+ children: [],
115
+ });
116
+ }
117
+ // Nest by containment: each node's parent is the SMALLEST other node whose span
118
+ // contains it. Containment is NON-STRICT at both edges ON PURPOSE: a consolidated
119
+ // parent spans exactly first-child-start..last-child-end, so children share their
120
+ // boundary edges with it, and a strict test made every consolidated child render
121
+ // top-level (the "ton of folds"). Equal spans tie-break by id for determinism.
122
+ const ordered = [...nodes.values()].sort((a, b) =>
123
+ a.startPosition - b.startPosition || a.endPosition - b.endPosition || a.id.localeCompare(b.id));
124
+ const spanOf = (block: FoldEditorBlock): number => block.endPosition - block.startPosition;
125
+ for (const node of ordered) {
126
+ let parent: FoldEditorBlock | null = null;
127
+ for (const candidate of ordered) {
128
+ if (candidate === node) continue;
129
+ const contains = candidate.startPosition <= node.startPosition &&
130
+ candidate.endPosition >= node.endPosition;
131
+ if (!contains) continue;
132
+ const better = !parent ||
133
+ spanOf(candidate) < spanOf(parent) ||
134
+ (spanOf(candidate) === spanOf(parent) && candidate.id.localeCompare(parent.id) < 0);
135
+ if (better) parent = candidate;
136
+ }
137
+ if (parent) parent.children.push(node);
138
+ }
139
+ const blocks: FoldEditorBlock[] = [];
140
+ let cursor = 0;
141
+ const pushGap = (to: number): void => {
142
+ if (to < cursor) return;
143
+ blocks.push({
144
+ type: "raw",
145
+ id: `raw:${cursor}-${to}`,
146
+ startPosition: cursor,
147
+ endPosition: to,
148
+ sourceCount: to - cursor + 1,
149
+ rolesSummary: "",
150
+ entries: helpers.mappedRange(cursor, to),
151
+ children: [],
152
+ });
153
+ };
154
+ const childIds = new Set(nodes.size ? [] : []);
155
+ for (const node of nodes.values()) for (const child of node.children) childIds.add(child.id);
156
+ const topLevel = ordered.filter((node) => !childIds.has(node.id));
157
+ for (const root of topLevel) {
158
+ pushGap(root.startPosition - 1);
159
+ blocks.push(root);
160
+ cursor = Math.max(cursor, root.endPosition + 1);
161
+ }
162
+ pushGap(helpers.entryCount - 1);
163
+
164
+ for (const block of blocks) {
165
+ if (block.type === "raw") {
166
+ const roles = new Map<string, number>();
167
+ 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(" ");
169
+ }
170
+ }
171
+
172
+ for (const mark of helpers.pendingMarkRefs()) {
173
+ let start = Number.POSITIVE_INFINITY;
174
+ let end = Number.NEGATIVE_INFINITY;
175
+ const entries: FoldEditorEntry[] = [];
176
+ for (const entryId of mark.entryIds) {
177
+ const at = snapshot.mapped.findIndex((item: any) => item.ref?.entryId === entryId);
178
+ if (at === -1) continue;
179
+ start = Math.min(start, at);
180
+ end = Math.max(end, at);
181
+ // The mapped window already knows what this entry IS; a proposed row that
182
+ // names only an id makes the user peek elsewhere to judge the mark.
183
+ const [mappedEntry] = helpers.mappedRange(at, at);
184
+ entries.push({ id: entryId, role: mappedEntry?.role ?? "", preview: mappedEntry?.preview ?? "" });
185
+ }
186
+ if (entries.length && Number.isFinite(start)) {
187
+ blocks.push({
188
+ type: "proposed",
189
+ id: mark.id,
190
+ startPosition: start,
191
+ endPosition: end,
192
+ origin: mark.origin,
193
+ brief: mark.brief,
194
+ tokens: (mark as { tokens?: number }).tokens,
195
+ sourceCount: entries.length,
196
+ entries,
197
+ children: [],
198
+ });
199
+ }
200
+ }
201
+ blocks.sort((a, b) => a.startPosition - b.startPosition || a.id.localeCompare(b.id));
202
+ return blocks;
203
+ }
204
+
205
+ /** Split a brief into width-agnostic whitespace chunks so long briefs wrap. */
206
+ function briefChunks(brief: string): string[] {
207
+ const flat = brief.replace(/\s+/g, " ").trim();
208
+ if (!flat) return [];
209
+ const chunks: string[] = [];
210
+ let current = "";
211
+ for (const word of flat.split(" ")) {
212
+ if (current && `${current} ${word}`.length > 72) {
213
+ chunks.push(current);
214
+ current = word;
215
+ } else {
216
+ current = current ? `${current} ${word}` : word;
217
+ }
218
+ }
219
+ if (current) chunks.push(current);
220
+ return chunks;
221
+ }
222
+
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. */
226
+ function detailChunks(preview: string): string[] {
227
+ return briefChunks(preview.slice(0, 480));
228
+ }
229
+
230
+ interface RenderRow {
231
+ key: string;
232
+ text: string;
233
+ /** Rows that answer Enter with a toggle (blocks) versus detail rows (entries). */
234
+ toggleId: string | null;
235
+ /** An entry row: Enter shows its full preview. */
236
+ entryPreview?: string;
237
+ /** A raw entry row: a mark point. Carries its mapped index and id for marking. */
238
+ markable?: boolean;
239
+ entryIndex?: number;
240
+ entryId?: string;
241
+ /** A PROPOSED row: withdrawable with u. */
242
+ proposedMarkId?: string;
243
+ }
244
+
245
+ /** What laying a mark over a span WOULD commit: the size the user sees before staging. */
246
+ export interface SpanCost {
247
+ entries: number;
248
+ tokens: number;
249
+ }
250
+
251
+ export interface FoldEditorActions {
252
+ /** Live would-be fold size for a span of mapped indices, any order. */
253
+ spanCost?: (from: number, to: number) => SpanCost;
254
+ /** Stage a USER mark over two raw entry ids through the validated path. The
255
+ * brief is what the user typed; empty means the deterministic brief. */
256
+ onStageMark?: (fromId: string, toId: string, brief?: string) => Promise<void>;
257
+ /** Withdraw one staged mark by id (the tool's unmark path). */
258
+ onWithdrawMark?: (markId: string) => Promise<void>;
259
+ /** Pin or unpin one raw entry; the handler decides which from current state. */
260
+ onTogglePin?: (entryId: string) => Promise<void>;
261
+ }
262
+
263
+ type ThemeFn = (color: string, text: string) => string;
264
+
265
+ export class FoldEditorView {
266
+ private data: FoldEditorData;
267
+ private done: () => void;
268
+ private theme: { fg?: ThemeFn };
269
+ private kb: { matches(data: string, action: string): boolean };
270
+ private actions: FoldEditorActions;
271
+ private selectedKey: string;
272
+ private expanded: Set<string> = new Set();
273
+ private detailedEntry: string | null = null;
274
+ private scroll = 0;
275
+ /** The anchored mark point: first boundary of a span the user is laying down. */
276
+ private anchor: { key: string; id: string; index: number } | null = null;
277
+ /** Brief capture: after the second m, the user types an optional brief; Enter
278
+ * stages with it (empty keeps the deterministic brief), Esc returns to the span. */
279
+ private briefMode = false;
280
+ private briefBuffer = "";
281
+ /** True while onStageMark is in flight; the view stages nothing itself. */
282
+ staging = false;
283
+ /** Last staging outcome, rendered until the next action replaces it. */
284
+ notice: string | null = null;
285
+ closed = false;
286
+
287
+ constructor(
288
+ data: FoldEditorData,
289
+ done: () => void,
290
+ keybindings?: { matches(data: string, action: string): boolean } | null,
291
+ theme?: unknown,
292
+ actions?: FoldEditorActions | null,
293
+ ) {
294
+ this.data = data;
295
+ this.done = done;
296
+ this.kb = keybindings ?? getKeybindings();
297
+ this.theme = (theme as { fg?: ThemeFn }) ?? {};
298
+ this.actions = actions ?? {};
299
+ this.selectedKey = this.visibleRows()[0]?.key ?? "";
300
+ }
301
+
302
+ /** Swap in rebuilt data after a stage or an external state change, keeping the
303
+ * selection when the row still exists. The command handler owns rebuilding. */
304
+ refresh(data: FoldEditorData): void {
305
+ this.data = data;
306
+ if (!this.visibleRows().some((row) => row.key === this.selectedKey)) {
307
+ this.selectedKey = this.visibleRows()[0]?.key ?? "";
308
+ }
309
+ }
310
+
311
+ private color(color: string, text: string): string {
312
+ return this.theme.fg ? this.theme.fg(color, text) : text;
313
+ }
314
+
315
+ private pinnedBadge(entryId: string): string {
316
+ return this.data.pinned.includes(entryId) ? " 📌" : "";
317
+ }
318
+
319
+ private renderFold(block: FoldEditorBlock, depth: number, rows: RenderRow[], keyPrefix: string): void {
320
+ // COLLAPSED = ONE ROW (Shane, 2026-08-22): "fold {id}" alone, no END bookend.
321
+ // Start/end bookends exist only in the EXPANDED state. ROW KEYS ARE UNIQUE PER
322
+ // LINE (ordinal-suffixed): sharing a key made every brief chunk of a fold carry
323
+ // the selection cursor at once and broke up/down stepping through them.
324
+ // NO NESTED TEMPLATE LITERALS: jiti mishandles `x === \`${prefix}\`` inside
325
+ // template text expressions; keys are precomputed into locals.
326
+ const foldKey = `${keyPrefix}${block.id}`;
327
+ const isOpen = this.expanded.has(foldKey);
328
+ const pad = " ".repeat(depth);
329
+ const kindLabel = `${block.kind} \u00d7${block.sourceCount}`;
330
+ const marker = isOpen ? "\u25bc" : "\u25b8";
331
+ const foldCursor = this.selectedKey === foldKey ? "\u276f" : " ";
332
+ rows.push({
333
+ key: foldKey,
334
+ text: `${foldCursor}${this.color("success", `${pad}${marker} fold ${shortId(block.id)}`)} ${kindLabel}`,
335
+ toggleId: foldKey,
336
+ });
337
+ if (!isOpen) return;
338
+ let ordinal = 0;
339
+ for (const chunk of briefChunks(block.brief ?? "")) {
340
+ const briefKey = `${foldKey}:b${ordinal}`;
341
+ ordinal += 1;
342
+ const briefCursor = this.selectedKey === briefKey ? "\u276f" : " ";
343
+ rows.push({ key: briefKey, text: `${briefCursor}${pad} ${chunk}`, toggleId: null });
344
+ }
345
+ for (const child of block.children ?? []) {
346
+ this.renderFold(child, depth + 1, rows, `${foldKey}>`);
347
+ }
348
+ const entries = (block.entries ?? []).slice(0, EXPANDED_ENTRY_CAP);
349
+ for (const entry of entries) {
350
+ const entryKey = `${foldKey}:${entry.id}`;
351
+ const detailed = this.detailedEntry === entryKey;
352
+ const quoted = !detailed && entry.preview
353
+ ? this.color("dim", ` \u201c${entry.preview.slice(0, 48)}\u201d`)
354
+ : "";
355
+ const entryCursor = this.selectedKey === entryKey ? "\u276f" : " ";
356
+ rows.push({
357
+ key: entryKey,
358
+ text: `${entryCursor}${pad} ${this.color("dim", `\u00b7 ${entry.role} ${shortId(entry.id)}${this.pinnedBadge(entry.id)}`)}${quoted}`,
359
+ toggleId: null,
360
+ entryPreview: entry.preview,
361
+ });
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
+ }
373
+ }
374
+ const endCursor = this.selectedKey === `${foldKey}:end` ? "\u276f" : " ";
375
+ rows.push({
376
+ key: `${foldKey}:end`,
377
+ text: `${endCursor}${this.color("error", `${pad}\u25b2 end ${shortId(block.id)}`)}`,
378
+ toggleId: foldKey,
379
+ });
380
+ }
381
+
382
+ private visibleRows(): RenderRow[] {
383
+ const rows: RenderRow[] = [];
384
+ for (const block of this.data.blocks) {
385
+ if (block.type === "fold") {
386
+ this.renderFold(block, 0, rows, "");
387
+ continue;
388
+ }
389
+ const isOpen = this.expanded.has(block.id);
390
+ const cursor = this.selectedKey === block.id ? "\u276f" : " ";
391
+ if (block.type === "proposed") {
392
+ const isLadder = block.origin === "ladder";
393
+ const color = isLadder ? "warning" : "accent";
394
+ const sizeNote = typeof block.tokens === "number" ? ` · ~${block.tokens.toLocaleString("en-US")} tok` : "";
395
+ rows.push({
396
+ 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" : ""}`,
398
+ toggleId: block.id,
399
+ proposedMarkId: block.id,
400
+ });
401
+ if (isOpen) {
402
+ let ordinal = 0;
403
+ for (const chunk of briefChunks(block.brief ?? "")) {
404
+ const briefKey = `${block.id}:b${ordinal}`;
405
+ ordinal += 1;
406
+ rows.push({ key: briefKey, text: `${this.selectedKey === briefKey ? "\u276f" : " "} ${chunk}`, toggleId: null });
407
+ }
408
+ for (const entry of block.entries.slice(0, 60)) {
409
+ const entryKey = `${block.id}:${entry.id}`;
410
+ // Role and preview come off the mapped window (buildFoldEditorData
411
+ // resolves them); an entry that fell off the map renders id-only.
412
+ const label = ["\u00b7", entry.role, `${shortId(entry.id)}${this.pinnedBadge(entry.id)}`]
413
+ .filter(Boolean).join(" ");
414
+ const quoted = entry.preview ? ` \u201c${entry.preview.slice(0, 48)}\u201d` : "";
415
+ rows.push({
416
+ key: entryKey,
417
+ text: `${this.selectedKey === entryKey ? "\u276f" : " "} ${this.color("dim", `${label}${quoted}`)}`,
418
+ toggleId: null,
419
+ entryPreview: entry.preview,
420
+ });
421
+ }
422
+ }
423
+ } else {
424
+ const label = `\u00b7 raw \u00d7${block.sourceCount}` +
425
+ `${block.rolesSummary ? ` ${block.rolesSummary}` : ""}`;
426
+ rows.push({
427
+ key: block.id,
428
+ text: `${cursor}${this.color("dim", label)}`,
429
+ toggleId: block.id,
430
+ });
431
+ if (isOpen) {
432
+ let ordinal = 0;
433
+ for (const entry of block.entries.slice(0, 60)) {
434
+ const pinBadge = this.data.pinned.includes(entry.id) ? " \ud83d\udccc" : "";
435
+ const entryKey = `${block.id}:${entry.id}:r${ordinal}`;
436
+ ordinal += 1;
437
+ const cursor = this.selectedKey === entryKey ? "\u276f" : " ";
438
+ // A RAW ENTRY IS A MARK POINT (V2.2): the anchor wears a diamond, and
439
+ // every raw entry can become one. Entries without a mapped index are
440
+ // not markable because their span arithmetic would be a guess.
441
+ const markable = typeof entry.index === "number";
442
+ const anchoredHere = this.anchor !== null && this.anchor.key === entryKey;
443
+ const badge = anchoredHere ? this.color("warning", " \u25c6 start") : "";
444
+ // A MARK POINT SHOWS WHAT IT IS (2026-08-23): the row carries its content
445
+ // preview, because this is where the user decides what to fold. Enter
446
+ // deepens it in place, exactly like a fold's entries; before this the
447
+ // toggle set detail state no raw row ever read back.
448
+ const detailed = this.detailedEntry === entryKey;
449
+ const quoted = !detailed && entry.preview
450
+ ? this.color("dim", ` \u201c${entry.preview.slice(0, 48)}\u201d`)
451
+ : "";
452
+ rows.push({
453
+ key: entryKey,
454
+ text: `${cursor} ${this.color("dim", `\u00b7 ${entry.role} ${shortId(entry.id)}${pinBadge}`)}${badge}${quoted}`,
455
+ toggleId: null,
456
+ entryPreview: entry.preview,
457
+ ...(markable ? { markable: true, entryIndex: entry.index, entryId: entry.id } : {}),
458
+ });
459
+ 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
+ }
469
+ }
470
+ }
471
+ }
472
+ }
473
+ }
474
+ return rows;
475
+ }
476
+
477
+ private move(delta: number): void {
478
+ const rows = this.visibleRows();
479
+ if (!rows.length) return;
480
+ const at = rows.findIndex((row) => row.key === this.selectedKey);
481
+ const next = at === -1 ? 0 : Math.max(0, Math.min(rows.length - 1, at + delta));
482
+ this.selectedKey = rows[next].key;
483
+ this.scrollToSelection();
484
+ }
485
+
486
+ private scrollToSelection(): void {
487
+ const rows = this.visibleRows();
488
+ const at = rows.findIndex((row) => row.key === this.selectedKey);
489
+ if (at === -1) return;
490
+ if (at < this.scroll) this.scroll = at;
491
+ else if (at >= this.scroll + MAX_VISIBLE_ROWS) this.scroll = at - MAX_VISIBLE_ROWS + 1;
492
+ }
493
+
494
+ invalidate(): void {}
495
+
496
+ handleInput(data: string): void {
497
+ if (this.closed || !data) return;
498
+ if (this.briefMode) {
499
+ this.handleBriefInput(data);
500
+ return;
501
+ }
502
+ if (data === "u") {
503
+ this.handleWithdrawKey();
504
+ return;
505
+ }
506
+ if (data === "p") {
507
+ this.handlePinKey();
508
+ return;
509
+ }
510
+ if (data === "m") {
511
+ this.handleMarkKey();
512
+ return;
513
+ }
514
+ 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.
517
+ if (this.anchor !== null) {
518
+ this.anchor = null;
519
+ this.notice = null;
520
+ return;
521
+ }
522
+ this.closed = true;
523
+ this.done();
524
+ return;
525
+ }
526
+ if (this.kb.matches(data, "tui.select.up")) this.move(-1);
527
+ else if (this.kb.matches(data, "tui.select.down")) this.move(1);
528
+ else if (this.kb.matches(data, "tui.select.pageUp")) this.move(-10);
529
+ else if (this.kb.matches(data, "tui.select.pageDown")) this.move(10);
530
+ else if (this.kb.matches(data, "tui.editor.cursorLeft")) {
531
+ const target = this.innermostOpenFold(this.selectedKey);
532
+ if (target) {
533
+ this.expanded.delete(target);
534
+ this.selectedKey = target;
535
+ this.scrollToSelection();
536
+ }
537
+ }
538
+ else if (this.kb.matches(data, "tui.select.confirm") ||
539
+ this.kb.matches(data, "tui.editor.cursorRight")) {
540
+ const row = this.visibleRows().find((candidate) => candidate.key === this.selectedKey);
541
+ if (!row) return;
542
+ if (row.toggleId) {
543
+ if (this.expanded.has(row.toggleId)) this.expanded.delete(row.toggleId);
544
+ else this.expanded.add(row.toggleId);
545
+ this.scrollToSelection();
546
+ } else if (row.entryPreview !== undefined) {
547
+ this.detailedEntry = this.detailedEntry === row.key ? null : row.key;
548
+ this.scrollToSelection();
549
+ }
550
+ }
551
+ }
552
+
553
+ /** The deepest expanded fold named in a row key (keys look like "a>b:c"). */
554
+ private innermostOpenFold(key: string): string | null {
555
+ const chain = key.split(":")[0].split(">");
556
+ for (let index = chain.length - 1; index >= 0; index -= 1) {
557
+ if (this.expanded.has(chain[index])) return chain[index];
558
+ }
559
+ return null;
560
+ }
561
+
562
+ /** THE MARK KEY. On a raw entry: first press anchors, second press opens the BRIEF
563
+ * LINE. Enter stages (typed brief; empty keeps the deterministic one), Esc steps
564
+ * back to the span. The view never mutates state -- staging goes through the
565
+ * injected callback, which is the SAME validated path the agent's tool action runs,
566
+ * origin "user". */
567
+ private handleMarkKey(): void {
568
+ if (this.staging) return;
569
+ const row = this.visibleRows().find((candidate) => candidate.key === this.selectedKey);
570
+ if (!row || !row.markable || row.entryIndex === undefined || !row.entryId) return;
571
+ if (this.anchor === null) {
572
+ this.anchor = { key: row.key, id: row.entryId, index: row.entryIndex };
573
+ this.notice = null;
574
+ return;
575
+ }
576
+ if (this.anchor.key === row.key) {
577
+ this.anchor = null;
578
+ return;
579
+ }
580
+ // SECOND BOUNDARY LAID DOWN: capture an optional brief before staging.
581
+ this.briefMode = true;
582
+ this.briefBuffer = "";
583
+ }
584
+
585
+ private handleBriefInput(data: string): void {
586
+ if (data === "\r" || data === "\n") {
587
+ const brief = this.briefBuffer.trim();
588
+ this.briefMode = false;
589
+ void this.stageSpan(brief === "" ? undefined : brief);
590
+ return;
591
+ }
592
+ if (data === "\x1b") {
593
+ this.briefMode = false;
594
+ this.briefBuffer = "";
595
+ return;
596
+ }
597
+ if (data === "\x7f" || data === "\b") {
598
+ this.briefBuffer = this.briefBuffer.slice(0, -1);
599
+ return;
600
+ }
601
+ // Printable characters only; multi-byte escape sequences are refused whole.
602
+ if (data.length === 1 && data >= " " && data !== "\x7f") {
603
+ if (this.briefBuffer.length < 2_000) this.briefBuffer += data;
604
+ }
605
+ }
606
+
607
+ private stageSpan(brief?: string): Promise<void> {
608
+ if (!this.anchor) return Promise.resolve();
609
+ if (!this.actions.onStageMark) {
610
+ this.notice = "staging is not wired in this view";
611
+ return Promise.resolve();
612
+ }
613
+ const fromId = this.anchor.id;
614
+ // The selection cannot move while the brief line holds it: handleBriefInput
615
+ // consumes every key, so the selected row IS the span's second boundary.
616
+ const current = this.visibleRows().find((candidate) => candidate.key === this.selectedKey);
617
+ const toId = current?.entryId ?? "";
618
+ if (!toId) return Promise.resolve();
619
+ this.staging = true;
620
+ this.notice = null;
621
+ return this.actions.onStageMark(fromId, toId, brief)
622
+ .then(() => {
623
+ this.staging = false;
624
+ this.anchor = null;
625
+ })
626
+ .catch((error: unknown) => {
627
+ this.staging = false;
628
+ this.notice = error instanceof Error ? error.message : String(error);
629
+ });
630
+ }
631
+
632
+ /** u ON A PROPOSED ROW withdraws that staged mark (the tool's unmark path). */
633
+ private handleWithdrawKey(): void {
634
+ if (this.staging || this.briefMode) return;
635
+ const row = this.visibleRows().find((candidate) => candidate.key === this.selectedKey);
636
+ if (!row || !row.proposedMarkId) return;
637
+ if (!this.actions.onWithdrawMark) {
638
+ this.notice = "withdrawing is not wired in this view";
639
+ return;
640
+ }
641
+ this.staging = true;
642
+ this.notice = null;
643
+ void this.actions.onWithdrawMark(row.proposedMarkId)
644
+ .then(() => {
645
+ this.staging = false;
646
+ })
647
+ .catch((error: unknown) => {
648
+ this.staging = false;
649
+ this.notice = error instanceof Error ? error.message : String(error);
650
+ });
651
+ }
652
+
653
+ /** p ON A RAW ENTRY toggles its pin (the tool's pin/unpin path). */
654
+ private handlePinKey(): void {
655
+ if (this.staging || this.briefMode) return;
656
+ const row = this.visibleRows().find((candidate) => candidate.key === this.selectedKey);
657
+ if (!row || !row.markable || !row.entryId) return;
658
+ if (!this.actions.onTogglePin) {
659
+ this.notice = "pinning is not wired in this view";
660
+ return;
661
+ }
662
+ this.staging = true;
663
+ this.notice = null;
664
+ void this.actions.onTogglePin(row.entryId)
665
+ .then(() => {
666
+ this.staging = false;
667
+ })
668
+ .catch((error: unknown) => {
669
+ this.staging = false;
670
+ this.notice = error instanceof Error ? error.message : String(error);
671
+ });
672
+ }
673
+
674
+ private headerLines(width: number): string[] {
675
+ const lines: string[] = [];
676
+ const { occupancy, pending } = this.data;
677
+ 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
+ : "?";
684
+ lines.push(truncateToWidth(
685
+ `${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}`,
696
+ width,
697
+ ));
698
+ return lines;
699
+ }
700
+
701
+ render(width: number): string[] {
702
+ const header = this.headerLines(width);
703
+ const rows = this.visibleRows();
704
+ if (!rows.length) {
705
+ return [...header, "", truncateToWidth("(empty window)", width)];
706
+ }
707
+ this.scroll = Math.max(0, Math.min(this.scroll, Math.max(0, rows.length - MAX_VISIBLE_ROWS)));
708
+ const body = rows
709
+ .slice(this.scroll, this.scroll + MAX_VISIBLE_ROWS)
710
+ .map((row) => truncateToWidth(row.text, width, ""));
711
+ // THE WOULD-BE FOLD SIZE (Shane, 2026-08-23): while a span is being laid down,
712
+ // the footer states what the second mark point would commit, in the runtime's
713
+ // own token arithmetic -- the same numbers the staged mark will answer with.
714
+ const footers: string[] = [];
715
+ if (this.anchor !== null && !this.briefMode && this.actions.spanCost) {
716
+ const current = rows.find((row) => row.key === this.selectedKey);
717
+ if (current && current.markable && current.entryIndex !== undefined) {
718
+ const cost = this.actions.spanCost(this.anchor.index, current.entryIndex);
719
+ footers.push(truncateToWidth(
720
+ `\u25c6 mark from ${shortId(this.anchor.id)} \u2192 ${shortId(String(current.entryId))}: ` +
721
+ `would fold ${cost.entries} ${cost.entries === 1 ? "entry" : "entries"} · ~${cost.tokens.toLocaleString("en-US")} tokens · m:stage`,
722
+ width, ""));
723
+ }
724
+ }
725
+ if (this.staging) footers.push(truncateToWidth("staging\u2026", width, ""));
726
+ else if (this.briefMode) {
727
+ footers.push(truncateToWidth(
728
+ `brief: ${this.briefBuffer}\u2588 · Enter:stage${this.briefBuffer ? " with brief" : " deterministic"} · Esc:back to span`,
729
+ width, ""));
730
+ } else if (this.notice) footers.push(truncateToWidth(this.notice, width, ""));
731
+ return [
732
+ ...header,
733
+ "",
734
+ ...body,
735
+ ...footers,
736
+ truncateToWidth("enter:expand/detail · arrows:navigate · m:mark raw span · esc:close", width, ""),
737
+ ];
738
+ }
739
+ }