pi-fold 4.0.0 → 4.1.1

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.
@@ -31,6 +31,7 @@ import {
31
31
  refsProtected,
32
32
  } from "./measurement.ts";
33
33
  import {
34
+ assertFoldForestValid,
34
35
  childFoldIds,
35
36
  clearPrepared,
36
37
  flattenFoldRefs,
@@ -908,7 +909,7 @@ export function projectActiveContext(
908
909
  snapshot: ActiveContextSnapshot,
909
910
  state: ActiveContextState,
910
911
  ): unknown[] {
911
- validateFoldForest(state.folds);
912
+ assertFoldForestValid(state.folds);
912
913
  const replacements = new Map<number, { end: number; messages: unknown[] }>();
913
914
  for (const root of orderedRoots(state, snapshot)) {
914
915
  const rendered = renderFold(root.fold, state, snapshot);
@@ -921,7 +922,24 @@ export function projectActiveContext(
921
922
  output.push(...replacement.messages);
922
923
  index = replacement.end + 1;
923
924
  } else {
924
- output.push(clone(snapshot.messages[index]));
925
+ // A PASS-THROUGH MESSAGE IS SHARED, NOT COPIED (gate 158, 2026-08-29). This used to
926
+ // push `clone(...)`, which was in the first commit of the runtime and never load
927
+ // bearing: `snapshot.messages` is ALREADY `clone(input.eventMessages)`
928
+ // (transcript.ts:531), against an array Pi itself structuredClones before it calls
929
+ // any handler, so the copy was a third generation guarding objects the host never
930
+ // sees. It cost 54.3ms of a 298.5ms projection, 18 percent, on a sealed 3,364
931
+ // message session, and it is the only remaining per-message cost of that size.
932
+ //
933
+ // What makes sharing indistinguishable from copying: NOTHING writes a field into a
934
+ // projected message at any depth. Every rewrite builds a fresh object and replaces
935
+ // an array SLOT, here in `applyToolClips` and in `withdrawConsumedEphemeralPeeks`
936
+ // and `carryWithdrawalIntoFreeze` one layer up, so a shared element is never the
937
+ // thing that gets edited. Gate 158 pins that by deep-freezing `snapshot.messages`
938
+ // and driving the readers against it, so a future in-place write throws in the
939
+ // suite instead of silently corrupting every later projection through the frozen
940
+ // prefix. Folded spans keep their own copies: line 796 and `renderFoldParts` still
941
+ // clone, because those objects are built for the projection rather than read from it.
942
+ output.push(snapshot.messages[index]);
925
943
  index += 1;
926
944
  }
927
945
  }
@@ -94,10 +94,12 @@ export type ContextEventKind =
94
94
  | "context.absorb"
95
95
  | "context.recovery"
96
96
  | "context.suspend"
97
+ | "context.settings"
97
98
  | "context.rollback"
98
99
  | "context.projection"
99
100
  | "context.usage"
100
- | "context.prefix";
101
+ | "context.prefix"
102
+ | "context.anchor";
101
103
 
102
104
  export function prefixDivergence(
103
105
  previous: string | null,
@@ -0,0 +1,73 @@
1
+ // THE CHANNEL FROM /fold-settings TO THE RUNNING RUNTIME.
2
+ //
3
+ // THE NEED, from a live session (2026-08-29). /fold-settings wrote the file and nothing
4
+ // else: `registerPiFold` resolves thresholds, toolFoldThreshold and the notice settings
5
+ // ONCE at registration, so a session kept the settings pi booted with while the screen showed
6
+ // what the person had just chosen. Shane's terminal held both at once, "Start folding at
7
+ // 40%" on the screen over "commit at 90%" on the status line, and neither surface said
8
+ // why. The file was the durable truth and the running session was not reading it.
9
+ //
10
+ // The two halves are registered independently, `registerFoldSettings(pi)` and
11
+ // `registerPiFold(pi, settings)`, in either order, and neither returns a handle the other
12
+ // can hold. What they do share is the HOST OBJECT, so that is the channel: the runtime
13
+ // hangs an applier off `pi` under a registry symbol, and the settings screen looks for it
14
+ // when a person saves an edit, which is long after both registrations have run. Late
15
+ // binding is what makes the order irrelevant.
16
+ //
17
+ // A MODULE-LEVEL SLOT WAS THE OTHER CANDIDATE AND IT IS WORSE: the applier would then be
18
+ // process-global, every gate that registers a runtime would leave one standing, and a
19
+ // screen in one host could push settings into a runtime in another. Keeping it on the
20
+ // host means an unregistered host simply has no applier, which is a state the screen can
21
+ // read and state honestly rather than a state it has to guess at.
22
+ //
23
+ // Symbol.for, not a fresh Symbol: the global registry keys by string, so a package loaded
24
+ // twice through different module caches (jiti in the suite, pi's own loader in the
25
+ // deployment) still agrees on the property.
26
+
27
+ import type { ActiveContextThresholds } from "./policy.ts";
28
+
29
+ /**
30
+ * What the settings screen hands the runtime: exactly the settings FILE's shape, where an
31
+ * absent field means the package default rather than "leave it alone". The runtime
32
+ * resolves it the same way it resolves its registration options, through one code path,
33
+ * so a value cannot mean one thing at boot and another at an edit.
34
+ */
35
+ export interface LiveFoldSettings {
36
+ thresholds?: ActiveContextThresholds;
37
+ toolFoldThreshold?: number;
38
+ preCommitNotice?: boolean;
39
+ noticeLeadShare?: number;
40
+ }
41
+
42
+ /** Refuses by throwing, exactly as registration does, and leaves the runtime untouched. */
43
+ export type LiveSettingsApplier = (settings: LiveFoldSettings, ctx?: unknown) => void;
44
+
45
+ const LIVE_SETTINGS = Symbol.for("pi-fold.live-settings");
46
+
47
+ export function publishLiveSettings(host: any, apply: LiveSettingsApplier): void {
48
+ // A HOST THAT REFUSES THE PROPERTY IS NOT A FAILURE. Nothing about folding depends on
49
+ // this channel: without it the file is still written and the next start still reads
50
+ // it, which is the behaviour every build before this one had.
51
+ try { host[LIVE_SETTINGS] = apply; } catch { }
52
+ }
53
+
54
+ /** Whether an edit saved now would reach a running runtime. The settings screen states
55
+ * which of the two it is instead of promising the one it cannot deliver. */
56
+ export function liveSettingsReachable(host: any): boolean {
57
+ try { return typeof host?.[LIVE_SETTINGS] === "function"; }
58
+ catch { return false; }
59
+ }
60
+
61
+ /**
62
+ * Push settings into the runtime registered on this host. Returns false when there is
63
+ * none, and lets the applier's own refusal THROW: the caller decides what a refusal
64
+ * means, and swallowing it here would leave a screen believing it had applied a value
65
+ * the runtime rejected.
66
+ */
67
+ export function applyLiveSettings(host: any, settings: LiveFoldSettings, ctx?: unknown): boolean {
68
+ let apply: LiveSettingsApplier | undefined;
69
+ try { apply = host?.[LIVE_SETTINGS]; } catch { return false; }
70
+ if (typeof apply !== "function") return false;
71
+ apply(settings, ctx);
72
+ return true;
73
+ }
@@ -22,9 +22,6 @@ import {
22
22
  EXPAND_LEASE_GENERATIONS,
23
23
  MAX_EXPAND_LEASES,
24
24
  MAX_PENDING_MARKS,
25
- MEMORY_BODY_CHARS,
26
- MEMORY_KEY_CHARS,
27
- MEMORY_KEYS_MAX,
28
25
  } from "./policy.ts";
29
26
  import type {
30
27
  ActiveContextCheckpointV2,
@@ -314,6 +311,43 @@ export function validateFoldForest(folds: ActiveFold[]): ActiveFold[] {
314
311
  return values;
315
312
  }
316
313
 
314
+ // A FOREST THAT VALIDATED ONCE IS NOT VALIDATED AGAIN (gate 160, 2026-08-29). Profiling
315
+ // twelve projections of a sealed 681-fold session put 51.2 percent of ALL self time on one
316
+ // call path, and caller attribution found every serialization sample on it:
317
+ //
318
+ // projectActiveContext -> validateFoldForest -> sha256Value -> stableStringify -> safeJson
319
+ //
320
+ // The cost is this function's closing digest-drift loop, which flattens every fold's refs
321
+ // (for a consolidation parent, the union of all descendants) and hashes the array. The
322
+ // projection opens with `validateFoldForest(state.folds)` on EVERY context event and throws
323
+ // the result away: it wants the throw, not the normalized array. So it paid a full forest
324
+ // rehash per event to re-prove something it had already proven.
325
+ //
326
+ // THIS WRAPPER IS THE ONLY THING THAT CHANGES. `validateFoldForest` is untouched, still
327
+ // clones, still normalizes, still returns; the three callers that CONSUME its return value
328
+ // (parseActiveContextState, stateFromFoldRefs, commitPreparedFold) still call it directly
329
+ // and are unaffected. Each of those hands it a freshly built array anyway, so a memo could
330
+ // never have hit there; the projection is the one caller that reads the same array again
331
+ // and again, which is exactly why it is the one that pays.
332
+ //
333
+ // KEYED ON THE INPUT ARRAY, and sound because a state is rebuilt immutably: a changed
334
+ // forest is always a new array, and nothing anywhere mutates a folds array in place (no
335
+ // push, splice, sort, reverse or slot assignment on any `.folds` in extensions/). Nothing
336
+ // is shared out, because nothing is returned, so this carries none of gate 158's aliasing
337
+ // question.
338
+ //
339
+ // THE ONE COST, stated rather than hidden: a folds array mutated IN PLACE keeps its first
340
+ // verdict and a corruption introduced that way would go unreported. Gate 160 asserts that
341
+ // cost directly rather than pretending it away, and still requires a corrupt NEW forest to
342
+ // raise on first sight.
343
+ const VALIDATED_FORESTS = new WeakSet<readonly ActiveFold[]>();
344
+
345
+ export function assertFoldForestValid(folds: ActiveFold[]): void {
346
+ if (VALIDATED_FORESTS.has(folds)) return;
347
+ validateFoldForest(folds);
348
+ VALIDATED_FORESTS.add(folds);
349
+ }
350
+
317
351
  export function parseActiveContextState(
318
352
  value: unknown,
319
353
  sessionId: string,
@@ -34,14 +34,37 @@ export const ACTIVE_CONTEXT_STATE_ENTRY = `${DEFAULT_ACTIVE_CONTEXT_ENTRY_TYPE_P
34
34
  export const ACTIVE_CONTEXT_FOLD_RECORD_ENTRY = `${DEFAULT_ACTIVE_CONTEXT_ENTRY_TYPE_PREFIX}-fold-record`;
35
35
  export const PROVIDER_CONTEXT_MEASUREMENT_ENTRY = `${DEFAULT_ENTRY_NAMESPACE}-provider-context-measurement`;
36
36
  /**
37
- * How many unbriefed pending folds stand before the runtime says so.
37
+ * THE NOTICE'S DEFAULT LEAD, as a share of the serving budget below the commit trigger
38
+ * (Shane, 2026-08-22: "around 10% from the commit threshold"; built 2026-08-30).
38
39
  *
39
- * A CONSTANT, not a threshold the deployment can turn (Shane's KISS rule): it is a
40
- * batching size, and the only thing a knob here would buy is a deployment that never
41
- * speaks. Three is the smallest batch that reads as a batch rather than as a running
42
- * commentary on every cut.
40
+ * A DISTANCE, NOT A POINT. Expressed as how far BELOW maxTarget the notice speaks, so it
41
+ * tracks a band that moves: a deployment that drops its trigger to 0.40 gets its notice
42
+ * at 0.30 without touching this, where an absolute 0.70 would have put the warning after
43
+ * the thing it warns about. The fire point is `maxTarget - noticeLeadShare`, floored at
44
+ * zero, so a lead wider than the trigger means "speak from the first measurement".
45
+ *
46
+ * IT REPLACED A COUNT. Until 2026-08-30 the notice fired at UNBRIEFED_FOLDS_BEFORE_NOTICE
47
+ * = 3 unbriefed pending folds, which is a batching size and not an occasion: the frontier
48
+ * cuts continuously and ungated by occupancy, so three folds stand within a few turns of
49
+ * a session's start and the notice spoke a long way from any commit. The share is the
50
+ * occasion, and the edge latch is what batches it: one notice per approach.
43
51
  */
44
- export const UNBRIEFED_FOLDS_BEFORE_NOTICE = 3;
52
+ export const DEFAULT_NOTICE_LEAD_SHARE = 0.10;
53
+
54
+ /**
55
+ * The lead is a share of the serving budget, so it is bounded the way every other share
56
+ * on this surface is: strictly inside 0 and 1. Zero would mean the notice fires exactly
57
+ * at the trigger, which is the same pass the commit fires on and therefore never a
58
+ * warning; 1 and above is not a share.
59
+ */
60
+ export function resolveNoticeLeadShare(value: unknown): number {
61
+ if (value === undefined) return DEFAULT_NOTICE_LEAD_SHARE;
62
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0 || value >= 1) {
63
+ throw new Error("noticeLeadShare must be a share above 0 and below 1: how far below the " +
64
+ "commit trigger, as a fraction of the serving budget, the notice speaks");
65
+ }
66
+ return value;
67
+ }
45
68
 
46
69
  /**
47
70
  * How many spans the frontier cuts on one projection pass.
@@ -287,15 +310,35 @@ export const MAX_CONTEXT_RECEIPTS = 3;
287
310
  export const CONTEXT_RECEIPT_BLOCK_BYTES = 900;
288
311
 
289
312
  /**
290
- * The post-fold notice's bound, and its own rather than the receipt block's.
313
+ * The pre-commit notice's bound, and its own rather than the receipt block's.
291
314
  *
292
- * It is larger because it carries a variable-length list the receipt does not: three
293
- * fixed sentences of instruction plus one entry per unbriefed fold, and the pending set
294
- * grows past MAX_FRONTIER_CUTS_PER_PASS whenever the frontier stages faster than the
295
- * agent answers. 1,200 seats the instruction and roughly eight folds; past that the list
296
- * gives way and states how many it could not name.
315
+ * It is larger because it carries a variable-length list the receipt does not: the fixed
316
+ * status and verb block plus one entry per staged fold, and the pending set grows past
317
+ * MAX_FRONTIER_CUTS_PER_PASS whenever the frontier stages faster than a commit lands.
318
+ * 1,200 seated the old instruction and roughly eight folds; past the bound the list gives
319
+ * way and states how many it could not name.
297
320
  */
298
321
  export const FOLD_NOTICE_BYTES = 2_400;
322
+ /**
323
+ * WHAT ONE /fold-editor ROW SUMMARIZES, and what one page of its detail serves
324
+ * (2026-08-30).
325
+ *
326
+ * The editor's whole reading of a message was 300 characters of its first text block,
327
+ * with a detail row that sliced to 480 and therefore could never bind. Two numbers replace
328
+ * it because they answer two different questions. EDITOR_ROW_PREVIEW_CHARS is a SUMMARY
329
+ * on a line that also carries a role, an id and a badge, so it is generous enough that a
330
+ * reader can tell two rows apart and no more. EDITOR_DETAIL_PAGE_CHARS is a READ, served
331
+ * a page at a time by an injected accessor with `total` and `hasMore` travelling beside
332
+ * it, so depth is bounded by the reader's patience rather than by a constant: there is no
333
+ * ceiling on what the editor can reach, only on what it hands over at once.
334
+ *
335
+ * THIS IS A READER'S BOUND, NOT A WINDOW'S. Nothing here reaches the projection: paging
336
+ * an entry to its end in the editor moves no bytes and the model never learns it
337
+ * happened. `maxSourceChars` 200,000 is the bound on a read that DOES reach the agent.
338
+ */
339
+ export const EDITOR_ROW_PREVIEW_CHARS = 240;
340
+ export const EDITOR_DETAIL_PAGE_CHARS = 4_000;
341
+
299
342
  export const CONTEXT_STATUS_RESPONSE_BYTES = 24_000;
300
343
  export const MAX_UNMARKED_CANDIDATES = 3;
301
344
 
@@ -1,4 +1,6 @@
1
1
  import type { EvidenceRef } from "../json.ts";
2
+ import { boundedSubject, oneLine, seatSubjects, toolClipHead } from "./brief-text.ts";
3
+ export { oneLine, seatSubjects, toolClipHead };
2
4
  import {
3
5
  denseOwnArrayValues,
4
6
  objectRefKey,
@@ -397,13 +399,6 @@ function assistantNoteText(message: unknown): string {
397
399
  // "content continues in the exact source", and a reader never mistakes a cut
398
400
  // value for a complete one. oneLine collapses whitespace then delegates;
399
401
  // factualValue sanitizes then delegates through oneLine. No other site slices.
400
- function boundedSubject(text: string, budget: number): string {
401
- if (text.length <= budget) return text;
402
- let kept = text.slice(0, Math.max(0, budget - 3)).trimEnd();
403
- const finalCode = kept.charCodeAt(kept.length - 1);
404
- if (finalCode >= 0xd800 && finalCode <= 0xdbff) kept = kept.slice(0, -1);
405
- return `${kept}...`;
406
- }
407
402
 
408
403
  export function deterministicConsolidationBrief(
409
404
  candidate: FoldCandidate,
@@ -467,29 +462,16 @@ export function deterministicConsolidationBrief(
467
462
  const noteCount = notes.length;
468
463
  const subjects = [...childSubjects, ...notes];
469
464
  if (!subjects.length) return "Grouped completed context covering no readable folds.";
470
- const separator = " | ";
471
465
  const lead = noteCount
472
466
  ? `Grouped completed context covering ${foldCount} folds and ${noteCount} agent notes: `
473
467
  : `Grouped completed context covering ${subjects.length} folds: `;
474
- const room = ACTIVE_CONTEXT_POLICY.maxBriefChars - lead.length - 1;
475
- const named = Math.max(1, Math.min(subjects.length,
476
- Math.floor((room + separator.length) / (MIN_SUBJECT_CHARS + separator.length))));
477
- const omitted = subjects.length - named;
478
- // With notes in the division, what the slice drops may be a note rather than
479
- // a fold, so the tail only claims folds when folds are all it holds.
480
- const tail = omitted ? `${separator}${omitted} more ${noteCount ? "in this group" : "folds in this group"}` : "";
481
- const budget = room - tail.length - separator.length * (named - 1);
482
- const order = subjects.slice(0, named).map((text, index) => ({ text, index })).sort((a, b) =>
483
- a.text.length - b.text.length || a.index - b.index);
484
- const bounded = new Array<string>(named);
485
- let left = budget;
486
- for (let taken = 0; taken < order.length; taken += 1) {
487
- const owed = Math.max(1, Math.floor(left / (order.length - taken)));
488
- const kept = boundedSubject(order[taken].text, owed);
489
- bounded[order[taken].index] = kept;
490
- left -= kept.length;
491
- }
492
- return `${lead}${bounded.join(separator)}${tail}.`.replace(/\s+/g, " ").trim();
468
+ // With notes in the division, what the count-slice drops may be a note rather
469
+ // than a fold, so the tail only claims folds when folds are all it holds.
470
+ return seatSubjects(subjects, lead, {
471
+ total: ACTIVE_CONTEXT_POLICY.maxBriefChars,
472
+ minSubjectChars: MIN_SUBJECT_CHARS,
473
+ omittedNoun: noteCount ? "in this group" : "folds in this group",
474
+ });
493
475
  }
494
476
 
495
477
  export const ALL_FOLD_KINDS: ReadonlySet<FoldKind> =
@@ -1106,23 +1088,6 @@ export function manualFoldCandidate(
1106
1088
  // blank line is kept whole up to the cap; a result that is bulk from line one keeps its
1107
1089
  // first cap's worth. The caller states the cut, per gate 136: the marker rides beside
1108
1090
  // the count of what it hides, never silently.
1109
- export function toolClipHead(text: string, cap: number): string {
1110
- const lines = String(text ?? "").split(/\r?\n/);
1111
- const kept: string[] = [];
1112
- for (const line of lines) {
1113
- if (line.trim()) kept.push(line);
1114
- else if (kept.length) break;
1115
- }
1116
- const paragraph = kept.join("\n");
1117
- const head = paragraph.length > 0 && paragraph.length <= cap
1118
- ? paragraph
1119
- : String(text ?? "").slice(0, cap);
1120
- return head;
1121
- }
1122
-
1123
- export function oneLine(value: string, maximum: number): string {
1124
- return boundedSubject(value.replace(/\s+/g, " ").trim(), maximum);
1125
- }
1126
1091
 
1127
1092
  export function deterministicChapterBrief(
1128
1093
  refs: EvidenceRef[],
@@ -0,0 +1,175 @@
1
+ /**
2
+ * THE FOLD BAR: one widget row above the footer, drawn by us rather than by the host.
3
+ *
4
+ * The always-on status line is a STRING handed to `ctx.ui.setStatus`, and everything a
5
+ * bar would need is denied to a string: the host sorts statuses by key and joins them,
6
+ * collapses runs of spaces, truncates from the right, and bakes ANSI that goes stale on a
7
+ * theme switch. `ctx.ui.setWidget` hands us a component instead: `render(width)` receives
8
+ * the terminal width, the row is ours alone, and the theme is read at render time so a
9
+ * `/theme` switch repaints it. Placed below the editor it sits directly above the footer,
10
+ * which is where Claude Code draws the same bar.
11
+ *
12
+ * WHAT THE BAR ARGUES: how full the window is against the two points where something
13
+ * happens to it. The fill is read against ticks ON the bar at the aim (minTarget) and the
14
+ * commit point (maxTarget). Inside the fill, oldest on the left: `▂` for the placeholders
15
+ * of standing folds, `▓` for staged raw spans (what the next commit collapses), `▇` for
16
+ * raw the ladder has not claimed. The label states what folding has already bought.
17
+ *
18
+ * THE BAR IS A FIXED WIDTH. A bar sized to the leftover width moves the commit tick
19
+ * between renders, and the tick is the landmark the whole row exists to place the fill
20
+ * against. What varies with the terminal is the label, cut from the right.
21
+ *
22
+ * WHAT IT WILL NOT DRAW: an unmeasured window (an empty bar is a guess drawn; law 2 of
23
+ * the human surfaces), a suspended session (no bar promising a commit beside FOLDING
24
+ * STOPPED; law 5), fold positions (a placeholder is a few hundred tokens against cells of
25
+ * tens of thousands), and fold depth, which /fold-editor shows with a row per fold.
26
+ *
27
+ * COLOUR: raw, staged and folded form an ordered ladder of how much of the window each
28
+ * state costs, so they are three classes sampled off Crameri's batlow, a perceptually
29
+ * uniform, colour-vision-deficiency-safe sequential map; on a 256-colour terminal they
30
+ * fall back to the theme's own names. Scaffolding (empty cells, the aim tick, the brand)
31
+ * stays on the theme's dim ink; the commit tick takes the theme's warning colour, which
32
+ * is what the host uses for its own context percentage past 70.
33
+ */
34
+
35
+ import { truncateToWidth } from "@earendil-works/pi-tui";
36
+
37
+ export const FOLD_BAR_WIDTH = 40;
38
+
39
+ /** Everything the row reads. Built by the runtime on every status update. */
40
+ export interface FoldBarModel {
41
+ brand: string;
42
+ /** The provider's own count against the serving budget, or null when unmeasured. */
43
+ share: number | null;
44
+ /** maxTarget and minTarget of the live thresholds, as shares. */
45
+ commitShare: number;
46
+ aimShare: number;
47
+ /** Share of the budget standing marks would free at the next commit (estimated). */
48
+ stagedShare: number;
49
+ stagedMarks: number;
50
+ stagedTokens: number;
51
+ /** Share of the budget the standing folds' placeholders occupy (estimated). */
52
+ foldedShare: number;
53
+ folds: number;
54
+ /** Tokens the standing folds hide: source minus placeholder, summed over the forest. */
55
+ hiddenTokens: number;
56
+ /** Whether a band-top commit has already been weighed against the current count. */
57
+ weighed: boolean;
58
+ /** The suspension message when automatic folding has stopped, else null. */
59
+ stopped: string | null;
60
+ }
61
+
62
+ /** The subset of pi's Theme the row uses. */
63
+ export interface FoldBarTheme {
64
+ fg(color: "dim" | "muted" | "text" | "warning" | "error", text: string): string;
65
+ bold(text: string): string;
66
+ getColorMode?(): "truecolor" | "256color";
67
+ }
68
+
69
+ // Crameri batlow10, classes 3, 6 and 8 (0-indexed). The two darkest classes are unusable
70
+ // as fill on a dark terminal, and the lightest washes out on a light one.
71
+ const BATLOW = { raw: "#3C6D56", staged: "#D29343", folded: "#FDB7BC" } as const;
72
+
73
+ const truecolor = (hex: string, text: string): string => {
74
+ const r = parseInt(hex.slice(1, 3), 16);
75
+ const g = parseInt(hex.slice(3, 5), 16);
76
+ const b = parseInt(hex.slice(5, 7), 16);
77
+ return `\x1b[38;2;${r};${g};${b}m${text}\x1b[39m`;
78
+ };
79
+
80
+ export function formatTokens(count: number): string {
81
+ if (count < 1000) return String(count);
82
+ if (count < 10_000) return `${(count / 1000).toFixed(1)}k`;
83
+ if (count < 1_000_000) return `${Math.round(count / 1000)}k`;
84
+ return `${(count / 1_000_000).toFixed(1)}M`;
85
+ }
86
+
87
+ type Cell = "folded" | "staged" | "raw" | "empty";
88
+ // SCORED CELLS (Shane 2026-09-03): the README figure draws every cell as its own rectangle
89
+ // with a hairline gap, and the full-width block fused adjacent cells into one solid run,
90
+ // so the terminal read as a different figure. The left seven-eighths block leaves an
91
+ // eighth of each cell empty on the right, which is the same score in a glyph. Staged and
92
+ // raw share the glyph and differ by colour; the shade glyph is gone because at a small
93
+ // font its texture read as a solid block anyway.
94
+ const GLYPH: Record<Cell, string> = { folded: "▂", staged: "▉", raw: "▉", empty: "░" };
95
+
96
+ /** The bar's cells, oldest on the left, before any tick is laid over them. */
97
+ export function foldBarCells(model: FoldBarModel, width: number = FOLD_BAR_WIDTH): Cell[] {
98
+ const share = model.share ?? 0;
99
+ const filled = Math.max(0, Math.min(width, Math.round(share * width)));
100
+ const folded = Math.min(filled, Math.round(model.foldedShare * width));
101
+ const staged = Math.min(filled - folded, Math.round(model.stagedShare * width));
102
+ const cells: Cell[] = [];
103
+ for (let i = 0; i < width; i += 1) {
104
+ if (i < folded) cells.push("folded");
105
+ else if (i < folded + staged) cells.push("staged");
106
+ else if (i < filled) cells.push("raw");
107
+ else cells.push("empty");
108
+ }
109
+ return cells;
110
+ }
111
+
112
+ /** Tick positions: the cell whose right edge is the share. */
113
+ export function foldBarTicks(model: FoldBarModel, width: number = FOLD_BAR_WIDTH): Map<number, "aim" | "commit"> {
114
+ const at = (share: number): number => Math.max(0, Math.min(width - 1, Math.round(share * width) - 1));
115
+ const ticks = new Map<number, "aim" | "commit">();
116
+ ticks.set(at(model.aimShare), "aim");
117
+ ticks.set(at(model.commitShare), "commit");
118
+ return ticks;
119
+ }
120
+
121
+ /** The row without colour, for tests and for a host that strips ANSI. */
122
+ export function foldBarPlainText(model: FoldBarModel): string {
123
+ return renderFoldBar(model, Number.POSITIVE_INFINITY, {
124
+ fg: (_c, t) => t, bold: (t) => t,
125
+ });
126
+ }
127
+
128
+ export function renderFoldBar(model: FoldBarModel, width: number, theme: FoldBarTheme): string {
129
+ const brand = theme.fg("dim", model.brand);
130
+ if (model.stopped) {
131
+ const line = `${brand} ${theme.fg("error", theme.bold("FOLDING STOPPED"))}` +
132
+ (model.share === null ? "" : theme.fg("text", ` · ${Math.round(model.share * 100)}% full`));
133
+ return truncateToWidth(line, width, theme.fg("dim", "..."));
134
+ }
135
+ if (model.share === null) {
136
+ return truncateToWidth(
137
+ `${brand} ${theme.fg("muted", `not measured yet · folds automatically at ${Math.round(model.commitShare * 100)}%`)}`,
138
+ width, theme.fg("dim", "..."));
139
+ }
140
+ const rich = theme.getColorMode?.() === "truecolor";
141
+ const ink: Record<Cell, (text: string) => string> = {
142
+ raw: (t) => rich ? truecolor(BATLOW.raw, t) : theme.fg("muted", t),
143
+ staged: (t) => rich ? truecolor(BATLOW.staged, t) : theme.fg("text", t),
144
+ folded: (t) => rich ? truecolor(BATLOW.folded, t) : theme.fg("dim", t),
145
+ empty: (t) => theme.fg("dim", t),
146
+ };
147
+ const cells = foldBarCells(model);
148
+ const ticks = foldBarTicks(model);
149
+ let bar = "";
150
+ for (let i = 0; i < cells.length; i += 1) {
151
+ const tick = ticks.get(i);
152
+ if (tick === "commit") bar += theme.fg("warning", "│");
153
+ else if (tick === "aim") bar += theme.fg("dim", "│");
154
+ else bar += ink[cells[i]](GLYPH[cells[i]]);
155
+ }
156
+ const pct = Math.round(model.share * 100);
157
+ const parts: string[] = [theme.fg("text", `${pct}%`)];
158
+ if (model.share < model.commitShare) parts.push(theme.fg("muted", `commit at ${Math.round(model.commitShare * 100)}%`));
159
+ else if (model.weighed) parts.push(theme.fg("muted", "commit held"));
160
+ else parts.push(theme.fg("warning", "COMMIT DUE"));
161
+ // A staged count with nothing priced behind it reads "0 to free", which is a
162
+ // contradiction on the face of the row; state the count alone until the marks price.
163
+ if (model.stagedMarks > 0) {
164
+ parts.push(ink.staged(model.stagedTokens > 0
165
+ ? `${model.stagedMarks} staged, ${formatTokens(model.stagedTokens)} to free`
166
+ : `${model.stagedMarks} staged`));
167
+ }
168
+ if (model.folds > 0) {
169
+ parts.push(ink.folded(`${model.folds} fold${model.folds === 1 ? "" : "s"} hold ${formatTokens(model.hiddenTokens)}`));
170
+ }
171
+ // NO BRAND IN FRONT OF THE BAR (Shane 2026-09-02): the bar identifies itself, and the
172
+ // two prose states above keep the brand because nothing else on them says whose they are.
173
+ const line = `${bar} ${parts.join(theme.fg("dim", " · "))}`;
174
+ return truncateToWidth(line, width, theme.fg("dim", "..."));
175
+ }
@@ -7,7 +7,6 @@
7
7
  * nothing: the next model call that reads a result, a natural boundary in the work.
8
8
  */
9
9
 
10
-
11
10
  type ToolHandler = (
12
11
  toolCallId: string,
13
12
  params: Record<string, unknown>,
@@ -31,6 +31,49 @@ import type {
31
31
  MappedMessage,
32
32
  } from "./policy.ts";
33
33
 
34
+ /**
35
+ * ONE MESSAGE, AS THE TEXT A PERSON WOULD READ (2026-08-30).
36
+ *
37
+ * WHAT THIS REPLACED. `/fold-editor` built its entry previews inline, and the reading was
38
+ * `content.find((part) => part.type === "text")` sliced to 300 characters. So a message
39
+ * whose first block is not text previewed as the empty string, a tool result carrying its
40
+ * body in a later block previewed as its first block alone, and everything past 300
41
+ * characters was unreachable from the editor at any depth: the detail row sliced to 480,
42
+ * a bound the 300-character preview could never reach, so the deeper number was dead.
43
+ * Shane, 2026-08-30: "the amount of detail we can expand out to is a bit limited".
44
+ *
45
+ * EVERY BLOCK IS ACCOUNTED FOR, and the ones that are not text are NAMED rather than
46
+ * dropped. A window a person is auditing may not show them an empty row where an image or
47
+ * a tool call stands; naming it is how the row stays honest about what it could not
48
+ * render. The result is UNBOUNDED here on purpose. Bounding belongs to the caller, which
49
+ * knows whether it is drawing a one-line row or serving a paged read, and a cut applied
50
+ * here would be a cut nobody could state.
51
+ */
52
+ export function entryText(message: unknown): string {
53
+ const content = ownValue(message, "content");
54
+ if (typeof content === "string") return content;
55
+ const parts = denseOwnArrayValues(content);
56
+ if (!parts) return "";
57
+ const out: string[] = [];
58
+ for (const part of parts) {
59
+ if (typeof part === "string") { out.push(part); continue; }
60
+ const type = ownValue(part, "type");
61
+ const text = ownValue(part, "text");
62
+ if (typeof text === "string" && text) { out.push(text); continue; }
63
+ if (type === "toolCall") {
64
+ const name = ownValue(part, "name");
65
+ // The ARGUMENTS are the content of a tool call, and they are what a person auditing
66
+ // a window needs to see: the call is why the result below it exists.
67
+ const args = ownValue(part, "arguments");
68
+ out.push(`[calls ${typeof name === "string" ? name : "a tool"}` +
69
+ `${args === undefined ? "" : ` ${stableStringify(args)}`}]`);
70
+ continue;
71
+ }
72
+ out.push(`[${typeof type === "string" && type ? type : "content"}]`);
73
+ }
74
+ return out.join("\n");
75
+ }
76
+
34
77
  export function terminalAssistant(message: unknown): boolean {
35
78
  if (messageRole(message) !== "assistant") return false;
36
79
  const stop = ownValue(message, "stopReason");