pi-fold 4.1.3 → 4.1.5

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 CHANGED
@@ -33,14 +33,18 @@ The package registers one tool, `pi_fold_context`, with nine actions over the tr
33
33
 
34
34
  ### The fold bar
35
35
 
36
- In an interactive session one row sits directly above Pi's footer and shows the window against the two points where something happens to it:
36
+ In an interactive session one compact, full-height bar sits directly above Pi's footer.
37
37
 
38
38
  <picture>
39
39
  <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/shaneconner/fold/main/media/fold-bar-dark.svg">
40
- <img alt="The fold bar: forty cells of the serving budget, oldest on the left, with ticks at the aim and the commit point. Staged spans, raw material and fold placeholders fill it in three colours, and the label reads 42%, commit at 80%, 12 staged, 3 folds hide 412k tokens." src="https://raw.githubusercontent.com/shaneconner/fold/main/media/fold-bar-light.svg">
40
+ <img alt="A single full-height usage bar. Batlow colours group consolidated folds, span folds, tool folds, pinned content, marked content, and raw context in that order. The suffix shows percentage, commit point, and fold, pin, and mark counts." src="https://raw.githubusercontent.com/shaneconner/fold/main/media/fold-bar-light.svg">
41
41
  </picture>
42
42
 
43
- The bar is the serving budget, forty cells wide, oldest material on the left. The two ticks are the band: the left one is `minTarget`, where a commit aims to land, and the right one is `maxTarget`, where the commit fires. The fill is the provider's own count of what is in the window, never an estimate, so before the first response the row says `not measured yet` and draws nothing. One cell is one fortieth of the budget, and the fill reads left to right in three colours: the placeholders of standing folds first, drawn as low slivers and small by design; then staged spans, the raw material the next commit collapses; then raw material the runtime has not claimed yet. The label repeats the percentage, names the commit point (or reads `COMMIT DUE` once the fill has crossed it and `commit held` once that count has been weighed), states how many spans are staged, and ends with what folding has already bought: the tokens the standing folds hide from the window. Every token figure prices a pasted image as one image rather than as its base64 text, the same way the occupancy estimate does. Right after a commit the percentage is still the provider's last count, so the label says it is from before the commit until the next response measures the window again. If automatic folding ever stops, the row reads `FOLDING STOPPED` in place of the bar. On a truecolor terminal the three fill classes take three steps of Crameri's batlow colour map; elsewhere they use the theme's own colours.
43
+ The forty-column bar uses full-height half-blocks for eighty steps of fill, following the provider's measured count against the serving budget. Within that fill, colours show the estimated visible composition, grouped in this order: **Consolidated Folds, Span Folds, Tool Folds, Pinned, Marked, Raw**. Compressed content uses the first half of [Crameri's Batlow palette](https://www.fabiocrameri.ch/colourmaps/); still-raw content uses the second half. Foreground lightness is adjusted for dark and light backgrounds, and label colours match the bar. Each nonempty category keeps at least one half-cell when the measured fill has room; remaining width follows relative mass. This visibility floor makes small compressed categories visible, so individual category widths are not token-exact. At very low occupancy, the largest shares take the available space. There are no individual fold edges, scores, or height differences.
44
+
45
+ The suffix reads `34% · commit at 55% · N Folds (X Cons., Y Span, Z Tool, X Pin, Y Mark)`. `N Folds` counts visible compressed folds; `Pin` counts protected entries and `Mark` counts pending marks, not additional compressed folds. A tool fold is lossless compression of tool-result context, not a count of tool calls. `/fold-status` retains the detailed token and nesting figures.
46
+
47
+ Matching dashed guides show `minTarget`, where a commit aims to land, and `maxTarget`, where automatic folding is triggered. Before a measurement the row says `not measured yet`; after a commit it identifies the old reading until the provider measures again. Crossing the target is routine runtime work, not a user action item. On reload, saved session context supplies the fold counts and composition without a new model message; the next real context event replaces that read-only view. Unavailable composition is stated as `mapping`; if folding stops, `FOLDING STOPPED` replaces the bar. Non-truecolor terminals use the theme's own colours.
44
48
 
45
49
  ## Results
46
50
 
@@ -29,13 +29,13 @@ import {
29
29
  peekFoldSource,
30
30
  prepareFold,
31
31
  projectActiveContext,
32
+ renderFold,
32
33
  projectionSlateCandidates,
33
34
  protectEvidence,
34
35
  requireActiveFold,
35
36
  selectAutomaticRung,
36
37
  setFoldProjectionState,
37
38
  withExpandLease,
38
- renderFoldParts,
39
39
  } from "./lib/folding.ts";
40
40
  import {
41
41
  admissionVerdict,
@@ -52,6 +52,7 @@ import {
52
52
  orderedRoots,
53
53
  parseNativeCompactionCompletion,
54
54
  exactMapped,
55
+ foldInterval,
55
56
  parseNativeCompactionDecision,
56
57
  parseProviderContextMeasurementReceipt,
57
58
  persistenceProjection,
@@ -63,7 +64,7 @@ import {
63
64
  stringIds,
64
65
  toolPayload,
65
66
  } from "./lib/measurement.ts";
66
- import { renderFoldBar, type FoldBarModel } from "./lib/status-widget.ts";
67
+ import { emptyFoldBarMass, renderFoldBar, type FoldBarKind, type FoldBarModel } from "./lib/status-widget.ts";
67
68
  import type {
68
69
  NativeCompactionCompletionReceipt,
69
70
  NativeCompactionDecisionReceipt,
@@ -89,6 +90,7 @@ import {
89
90
  sameStateProjection,
90
91
  semanticStateSha256,
91
92
  topologySha256,
93
+ adoptDurableFoldRecords,
92
94
  } from "./lib/persistence.ts";
93
95
  import type { MaterializedStatePersistence } from "./lib/persistence.ts";
94
96
  import {
@@ -716,7 +718,7 @@ export function registerActiveContext(pi: any, options: {
716
718
  * named only when it is close enough to matter.
717
719
  */
718
720
  /**
719
- * THE FOLD BAR (2026-09-02, dogfooding). One widget row directly above the footer,
721
+ * THE FOLD BAR. One coloured composition row directly above the footer,
720
722
  * drawn by `renderFoldBar` from a model this function rebuilds on every status update.
721
723
  * The component reads the model and the LIVE theme at render time, so it repaints on
722
724
  * a theme switch, which the status string cannot; the host is asked to render after
@@ -729,8 +731,8 @@ export function registerActiveContext(pi: any, options: {
729
731
  /** The provider measurement the last landed commit ran against; the reading is
730
732
  * stale while it is still the latest one. */
731
733
  staleSince: null as unknown,
732
- /** Each standing fold's source priced by the image law, keyed by the fold object. */
733
- pricedSource: new WeakMap<ActiveFold, number>(),
734
+ /** Display-only reconstruction while waiting for the first real context event. */
735
+ startupSnapshot: null as ActiveContextSnapshot | null,
734
736
  };
735
737
  const installFoldBar = (ctx: any): void => {
736
738
  if (foldBar.installed || typeof ctx.ui?.setWidget !== "function") return;
@@ -738,6 +740,8 @@ export function registerActiveContext(pi: any, options: {
738
740
  ctx.ui.setWidget(entryTypePrefix, (tui: any) => {
739
741
  foldBar.requestRender = () => { try { tui.requestRender(); } catch { } };
740
742
  return {
743
+ /** The model behind the row, for the lab and the gates. */
744
+ get model(): FoldBarModel | null { return foldBar.model; },
741
745
  render(width: number): string[] {
742
746
  if (!foldBar.model) return [];
743
747
  try { return [renderFoldBar(foldBar.model, width, ctx.ui.theme)]; }
@@ -748,7 +752,7 @@ export function registerActiveContext(pi: any, options: {
748
752
  }, { placement: "belowEditor" });
749
753
  };
750
754
  const foldBarModel = (ctx: any, input: {
751
- share: number | null; budgetTokens: number; staged: number; roots: number; weighed: boolean;
755
+ share: number | null; staged: number; weighed: boolean;
752
756
  }): FoldBarModel => {
753
757
  const state = persistence.state;
754
758
  // THE SAME SNAPSHOT /fold-status PRICES AGAINST. `markFreedBytes` answers ZERO, silently,
@@ -757,46 +761,81 @@ export function registerActiveContext(pi: any, options: {
757
761
  // "5 staged, 0 to free" beside a status command pricing the same marks at 41,877
758
762
  // tokens (Shane, 2026-09-02, session 01a06272). The authoritative snapshot is memoized
759
763
  // on the branch length, so reading it here costs nothing while nothing has changed.
760
- let snapshot = lifecycle.latestSnapshot;
764
+ let snapshot = lifecycle.latestSnapshot ?? foldBar.startupSnapshot;
761
765
  try { snapshot = authoritativeSnapshotFor(ctx); } catch { }
762
- let stagedTokens = 0;
763
- let placeholderChars = 0;
764
- let hiddenChars = 0;
765
- if (state && snapshot) {
766
- for (const mark of pendingMarks(state)) stagedTokens += estimatedTokens(markFreedBytes(snapshot, state, mark));
767
- for (const root of orderedRoots(state, snapshot)) placeholderChars += root.fold.placeholderChars;
768
- // WHAT FOLDING BOUGHT, IN WINDOW TERMS. `sourceChars` is the transcript count, base64
769
- // and all, and read as tokens it said "8.6M" over a 1M window. Each standing fold's
770
- // source is priced by the image law once per fold OBJECT (folds are immutable and
771
- // replaced, never mutated, so a WeakMap memo is exact) and summed over the whole
772
- // forest, which is additive because a parent's source is its children's placeholders.
773
- for (const fold of state.folds) {
774
- let priced = foldBar.pricedSource.get(fold);
775
- if (priced === undefined) {
776
- const source = renderFoldParts(fold.parts, state, snapshot);
777
- priced = source ? pricedBytes(source) : fold.sourceChars;
778
- foldBar.pricedSource.set(fold, priced);
779
- }
780
- hiddenChars += Math.max(0, priced - fold.placeholderChars);
781
- }
782
- }
783
- const budget = input.budgetTokens > 0 ? input.budgetTokens : 1;
784
- return {
766
+ const model: FoldBarModel = {
785
767
  brand: brandNoun,
786
768
  share: input.share,
787
769
  commitShare: thresholds.maxTarget,
788
770
  aimShare: thresholds.minTarget,
789
- stagedShare: stagedTokens / budget,
771
+ mapped: false, mass: emptyFoldBarMass(),
790
772
  stagedMarks: input.staged,
791
- stagedTokens,
792
- foldedShare: estimatedTokens(placeholderChars) / budget,
793
- folds: input.roots,
794
- hiddenTokens: estimatedTokens(hiddenChars),
773
+ folds: 0, foldSpans: 0, foldTruncations: 0, foldConsolidations: 0,
774
+ unplacedItems: 0,
775
+ pinnedRefs: state ? state.protected.length : 0,
795
776
  weighed: input.weighed,
796
777
  staleAfterCommit: input.share !== null && foldBar.staleSince !== null &&
797
778
  foldBar.staleSince === measurements.lastProviderMeasurement,
798
779
  stopped: ladder.automaticFailure?.message ?? null,
799
780
  };
781
+ if (!state || !snapshot) return model;
782
+ model.mapped = true;
783
+ // Price the visible projection once per category, never a hidden fold's original
784
+ // source. Revealing a parent reveals its parts, not necessarily every descendant.
785
+ // Correct inclusive intervals prevent the last hidden message being priced as raw.
786
+ const visibleAt = new Map<number, { fold: ActiveFold; end: number }>();
787
+ const byId = new Map(state.folds.map((fold) => [fold.id, fold]));
788
+ const visit = (fold: ActiveFold, interval: { start: number; end: number } | null): void => {
789
+ if (!interval) { model.unplacedItems += 1; return; }
790
+ const revealed = state.expanded.includes(fold.id) || refsProtected(flattenFoldRefs(fold, state), state, snapshot);
791
+ if (revealed) {
792
+ for (const part of fold.parts) {
793
+ if (part.kind !== "fold") continue;
794
+ const child = byId.get(part.foldId);
795
+ if (child) visit(child, foldInterval(child, state, snapshot));
796
+ }
797
+ return;
798
+ }
799
+ visibleAt.set(interval.start, { fold, end: interval.end });
800
+ model.folds += 1;
801
+ if (fold.kind === "chapter") model.foldSpans += 1;
802
+ else if (fold.kind === "tool-result") model.foldTruncations += 1;
803
+ else model.foldConsolidations += 1;
804
+ };
805
+ const roots = orderedRoots(state, snapshot);
806
+ const placedRoots = new Set(roots.map((root) => root.fold.id));
807
+ model.unplacedItems += state.folds.filter((fold) => !fold.parentId && !placedRoots.has(fold.id)).length;
808
+ for (const root of roots) visit(root.fold, root);
809
+
810
+ // Resolve all marks before walking roots: a pending consolidation can begin at a
811
+ // root and include raw gaps farther on. Membership, not an encountered start edge,
812
+ // determines Mark. A set also prevents overlapping marks charging a message twice.
813
+ const marked = new Set<number>();
814
+ for (const mark of pendingMarks(state)) {
815
+ const span = markSpanRefs(state, mark);
816
+ const indices = span.refs.map((ref) => exactMapped(snapshot, ref)?.index ?? -1);
817
+ if (span.unresolved || !indices.length || indices.some((index) => index < 0)) {
818
+ model.unplacedItems += 1;
819
+ continue;
820
+ }
821
+ for (const index of indices) marked.add(index);
822
+ }
823
+ const pinnedKeys = new Set(state.protected.map(objectRefKey));
824
+ for (let index = 0; index < snapshot.messages.length; index += 1) {
825
+ const root = visibleAt.get(index);
826
+ if (root) {
827
+ const kind: FoldBarKind = root.fold.kind === "chapter" ? "span"
828
+ : root.fold.kind === "tool-result" ? "tool" : "consolidated";
829
+ model.mass[kind] += pricedBytes(renderFold(root.fold, state, snapshot) ?? []);
830
+ index = root.end; // inclusive: advance PAST the final hidden message
831
+ continue;
832
+ }
833
+ const ref = snapshot.mapped[index]?.ref;
834
+ const kind: FoldBarKind = ref && pinnedKeys.has(objectRefKey(ref)) ? "pinned"
835
+ : marked.has(index) ? "marked" : "raw";
836
+ model.mass[kind] += pricedBytes([snapshot.messages[index]]);
837
+ }
838
+ return model;
800
839
  };
801
840
 
802
841
  const updateStatus = (ctx: any): void => {
@@ -810,7 +849,7 @@ export function registerActiveContext(pi: any, options: {
810
849
  installFoldBar(ctx);
811
850
  if (foldBar.installed) {
812
851
  foldBar.model = foldBarModel(ctx, {
813
- share, budgetTokens: capacity.budgetTokens, staged, roots,
852
+ share, staged,
814
853
  weighed: ladder.bandTopMeasurement !== null &&
815
854
  ladder.bandTopMeasurement === measurements.lastProviderMeasurement,
816
855
  });
@@ -1041,6 +1080,7 @@ export function registerActiveContext(pi: any, options: {
1041
1080
  cancelPreparation();
1042
1081
  lifecycle.latestSnapshot = null;
1043
1082
  lifecycle.latestSnapshotError = null;
1083
+ foldBar.startupSnapshot = null;
1044
1084
  measurements.latestRatio = null;
1045
1085
  measurements.lastProviderMeasurement = null;
1046
1086
  measurements.wallInflowSteps.length = 0;
@@ -1143,6 +1183,13 @@ export function registerActiveContext(pi: any, options: {
1143
1183
  );
1144
1184
  measurements.latestRatio = contextUsageRatio(measurements.lastProviderMeasurement);
1145
1185
  persistence.persisted = clone(durableRestored);
1186
+ // Reuse Pi's own reconstructed messages, including custom messages and branch
1187
+ // selection. This is ONLY a view for the bar: do not populate latestSnapshot,
1188
+ // advance the frontier, persist a projection, or pretend a provider event occurred.
1189
+ if (typeof ctx.ui?.setWidget === "function" && Array.isArray(restoredMessages)) {
1190
+ try { foldBar.startupSnapshot = snapshotForEvent(ctx, restoredMessages); }
1191
+ catch { /* Keep the honest mapping fallback if the host cannot supply a view. */ }
1192
+ }
1146
1193
  if (restoreError) safeNotify(
1147
1194
  ctx,
1148
1195
  `Active-context state was ignored; Pi native context remains authoritative: ${String(restoreError)}`,
@@ -2528,6 +2575,11 @@ export function registerActiveContext(pi: any, options: {
2528
2575
  state: persistence.state!,
2529
2576
  generation: generationAtStart,
2530
2577
  });
2578
+ const adoption = adoptDurableFoldRecords(nextState.folds, persistence.persistedFoldRecords);
2579
+ if (adoption.adopted.length) {
2580
+ emit("context.adopt", { adopted: adoption.adopted.length, fold_ids: adoption.adopted.slice(0, 64) });
2581
+ return { preparedFold, nextState: { ...nextState, folds: adoption.folds } };
2582
+ }
2531
2583
  return { preparedFold, nextState };
2532
2584
  };
2533
2585
 
@@ -2769,6 +2821,16 @@ export function registerActiveContext(pi: any, options: {
2769
2821
  clips: [...(result.state.clips ?? []), ...clipAdditions] } };
2770
2822
  }
2771
2823
  }
2824
+ // A RE-CUT SPAN ADOPTS ITS DURABLE RECORD (persistence.ts, adoptDurableFoldRecords):
2825
+ // a fold minted here under an id the store already holds, with the same source, takes
2826
+ // the record's bytes before the projection is measured, so the persist path finds
2827
+ // equal digests where session 01a06746 found "Conflicting durable active-context
2828
+ // fold" and stopped folding for good.
2829
+ const adoption = adoptDurableFoldRecords(result.state.folds, persistence.persistedFoldRecords);
2830
+ if (adoption.adopted.length) {
2831
+ result = { ...result, state: { ...result.state, folds: adoption.folds } };
2832
+ emit("context.adopt", { adopted: adoption.adopted.length, fold_ids: adoption.adopted.slice(0, 64) });
2833
+ }
2772
2834
  persistence.state = result.state;
2773
2835
  const bytesAfter = pricedBytes(projectActiveContext(snapshot, persistence.state));
2774
2836
  const freedBytes = Math.max(0, bytesBefore - bytesAfter);
@@ -2804,6 +2866,7 @@ export function registerActiveContext(pi: any, options: {
2804
2866
  closing_consolidation_marks: closingAdded,
2805
2867
  absorbed_wedges: wedges.absorbed.length,
2806
2868
  clipped_results: clippedResults,
2869
+ adopted_records: adoption.adopted.length,
2807
2870
  freed_bytes: freedBytes,
2808
2871
  freed_tokens: estimatedTokens(freedBytes),
2809
2872
  rewrite_tokens: rewriteTokens,
@@ -3143,6 +3206,7 @@ export function registerActiveContext(pi: any, options: {
3143
3206
  try {
3144
3207
  const snapshot = snapshotForEvent(ctx, event.messages);
3145
3208
  lifecycle.latestSnapshot = snapshot;
3209
+ foldBar.startupSnapshot = null;
3146
3210
  if (ladder.automaticFailure) {
3147
3211
  ladder.automaticFailure.suppressedCallbacks = Math.min(
3148
3212
  Number.MAX_SAFE_INTEGER,
@@ -4903,6 +4967,7 @@ export function registerActiveContext(pi: any, options: {
4903
4967
  curation.receipts = [];
4904
4968
  try { ctx.ui?.setStatus?.(entryTypePrefix, undefined); } catch { }
4905
4969
  foldBar.model = null;
4970
+ foldBar.startupSnapshot = null;
4906
4971
  foldBar.requestRender?.();
4907
4972
  });
4908
4973
 
@@ -1181,12 +1181,17 @@ export function activeContextStatus(
1181
1181
  sourceIdsTruncated: eligibleSourceIds.length > 64,
1182
1182
  startId: eligibleSourceIds[0],
1183
1183
  endId: eligibleSourceIds.at(-1),
1184
- action: { action: "fold", ids: eligibleEndpoints, brief: "<factual brief, at most 1000 characters>" },
1184
+ action: { action: "reboundary", ids: eligibleEndpoints },
1185
1185
  } : null,
1186
1186
  currentTurnRequiresBoundary: false,
1187
+ // EVERY ACTION ADVERTISED HERE IS ONE THE SCHEMA ACCEPTS (gate 168). This block named a
1188
+ // "fold" action with a 1,000-character brief long after the schema had replaced it
1189
+ // with reboundary and the cap had moved to the policy's; the model followed the page,
1190
+ // was refused by the schema, and had to read the schema to learn the real name.
1187
1191
  actions: {
1188
1192
  status: { action: "status", offset, limit },
1189
- fold: { action: "fold", ids: ["<source-or-fold-id>"], brief: "<optional factual brief, at most 1000 characters>" },
1193
+ reboundary: { action: "reboundary", ids: ["<source-or-fold-id>"] },
1194
+ brief: { action: "brief", id: "<pending-fold-id>", brief: `<factual brief, at most ${ACTIVE_CONTEXT_POLICY.maxBriefChars} characters>` },
1190
1195
  pin: { action: "pin", ids: ["<source-or-fold-id>"] },
1191
1196
  },
1192
1197
  };
@@ -405,7 +405,9 @@ export function explicitProtectedMass(
405
405
  let refs = 0;
406
406
  for (const item of snapshot.mapped) {
407
407
  if (!item.ref || !explicit.has(objectRefKey(item.ref))) continue;
408
- total += bytes(item.message);
408
+ // Priced, not counted: a pinned screenshot is one image against the pin cap and on
409
+ // the bar, not its base64 length (pricedBytes, gate 166's law).
410
+ total += pricedBytes([item.message]);
409
411
  refs += 1;
410
412
  }
411
413
  return { bytes: total, refs };
@@ -789,11 +789,44 @@ export function parseActiveContextStateV2(value: unknown, sessionId: string): Ac
789
789
  return parsed;
790
790
  }
791
791
 
792
+ /**
793
+ * A RE-CUT SPAN ADOPTS ITS DURABLE RECORD (2026-09-04).
794
+ *
795
+ * A fold's id is derived from its parts, so the same span cut twice mints the same id,
796
+ * and a durable record under an id is immutable by design. Those two facts met in
797
+ * session 01a06746: a persistence projection on the first pass after a restart could
798
+ * not seat one consolidation root and dropped it with its 30 children, the spans read
799
+ * as raw, the frontier cut them again, and the re-cut folds differed from their records
800
+ * by 25 bytes of placeholder text. `normalizeFoldsForPersistedRecords` and the persist
801
+ * path both refused the conflict, folding suspended, and every restart walked into the
802
+ * same refusal because the durable state had already lost the folds.
803
+ *
804
+ * The record is the canonical fold for its source. A fold whose id has a record with
805
+ * the SAME kind and SAME source hash takes the record's bytes (brief, provenance,
806
+ * placeholder size, createdAt) and keeps only its own parent link, so the persist path
807
+ * finds equal digests and the session goes on. A record under the same id with a
808
+ * DIFFERENT source is a corrupt store and still refuses by name.
809
+ */
810
+ export function adoptDurableFoldRecords(
811
+ folds: ActiveFold[],
812
+ records: Map<string, FoldRecordEntry>,
813
+ ): { folds: ActiveFold[]; adopted: string[] } {
814
+ const adopted: string[] = [];
815
+ const next = folds.map((fold) => {
816
+ const record = records.get(fold.id);
817
+ if (!record || sameFoldRecordIdentity(fold, record.fold)) return fold;
818
+ if (record.fold.kind !== fold.kind || record.fold.sourceSha256 !== fold.sourceSha256) return fold;
819
+ adopted.push(fold.id);
820
+ return { ...clone(record.fold), parentId: fold.parentId };
821
+ });
822
+ return { folds: next, adopted };
823
+ }
824
+
792
825
  export function normalizeFoldsForPersistedRecords(
793
826
  folds: ActiveFold[],
794
827
  records: Map<string, FoldRecordEntry>,
795
828
  ): ActiveFold[] {
796
- const normalized = folds.map((fold) => {
829
+ const normalized = adoptDurableFoldRecords(folds, records).folds.map((fold) => {
797
830
  const record = records.get(fold.id);
798
831
  if (!record) return clone(fold);
799
832
  if (!sameFoldRecordIdentity(fold, record.fold)) {
@@ -1,91 +1,55 @@
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
-
1
+ /** One full-height usage bar, coloured by six disjoint content categories. */
35
2
  import { truncateToWidth } from "@earendil-works/pi-tui";
36
3
 
37
4
  export const FOLD_BAR_WIDTH = 40;
5
+ // The first half of Batlow is compressed; the second half is still raw.
6
+ export const FOLD_BAR_KINDS = ["consolidated", "span", "tool", "pinned", "marked", "raw"] as const;
7
+ export type FoldBarKind = typeof FOLD_BAR_KINDS[number];
8
+ export type FoldBarMass = Record<FoldBarKind, number>;
9
+ export const emptyFoldBarMass = (): FoldBarMass => ({ consolidated: 0, span: 0, tool: 0, pinned: 0, marked: 0, raw: 0 });
38
10
 
39
- /** Everything the row reads. Built by the runtime on every status update. */
40
11
  export interface FoldBarModel {
41
12
  brand: string;
42
- /** The provider's own count against the serving budget, or null when unmeasured. */
43
13
  share: number | null;
44
- /** maxTarget and minTarget of the live thresholds, as shares. */
45
14
  commitShare: number;
46
15
  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;
16
+ /** Estimated visible mass, counted exactly once, in the category order above. */
17
+ mass: FoldBarMass;
18
+ mapped: boolean;
53
19
  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. */
20
+ foldSpans: number;
21
+ foldTruncations: number;
22
+ foldConsolidations: number;
23
+ pinnedRefs: number;
24
+ stagedMarks: number;
25
+ unplacedItems: number;
57
26
  weighed: boolean;
58
- /** A commit landed after the count `share` reads; the number is from before it. */
59
27
  staleAfterCommit: boolean;
60
- /** The suspension message when automatic folding has stopped, else null. */
61
28
  stopped: string | null;
62
29
  }
63
-
64
- /** The subset of pi's Theme the row uses. */
65
30
  export interface FoldBarTheme {
66
- fg(color: "dim" | "muted" | "text" | "warning" | "error", text: string): string;
31
+ fg(color: "dim" | "muted" | "text" | "warning" | "error" | "accent", text: string): string;
67
32
  bold(text: string): string;
68
33
  getColorMode?(): "truecolor" | "256color";
69
- getFgAnsi?(color: "text"): string;
34
+ getFgAnsi?(color: "text" | "muted" | "accent"): string;
70
35
  name?: string;
71
36
  }
72
37
 
73
- // Crameri batlow10, three classes per background. Classes 3, 6 and 8 (0-indexed) read on
74
- // a dark terminal; the lightest of them is 1.7:1 against white, so a light terminal steps
75
- // two classes darker (2, 5 and 6), the same triple the README's light figure uses.
76
- const BATLOW_ON_DARK = { raw: "#3C6D56", staged: "#D29343", folded: "#FDB7BC" } as const;
77
- const BATLOW_ON_LIGHT = { raw: "#1C5A62", staged: "#9D892B", folded: "#D29343" } as const;
38
+ // Batlow samples at 0, .2, .4 | .6, .8, 1, in the requested category order.
39
+ // Static foreground lightness adjustments retain readable labels on dark/white
40
+ // reference backgrounds. No palette generator or new dependency ships.
41
+ const ON_DARK: Record<FoldBarKind, string> = {
42
+ consolidated: "#6C89C7", span: "#5B919F", tool: "#729261",
43
+ pinned: "#B38E2F", marked: "#FBA689", raw: "#FACCFA",
44
+ };
45
+ const ON_LIGHT: Record<FoldBarKind, string> = {
46
+ consolidated: "#011959", span: "#185562", tool: "#577647",
47
+ pinned: "#927012", marked: "#AA624A", raw: "#8F6891",
48
+ };
78
49
 
79
- /**
80
- * WHICH WAY THE BACKGROUND GOES, read off the theme's own text colour rather than its
81
- * name: a theme whose body text is dark is drawn on a light background. pi hands the
82
- * text colour out as a truecolor escape, and a theme that does not (256-colour mode,
83
- * or a host without the accessor) falls back to the name, then to dark.
84
- */
85
50
  export function lightBackground(theme: FoldBarTheme): boolean {
86
51
  try {
87
- const ansi = theme.getFgAnsi?.("text") ?? "";
88
- const match = /38;2;(\d+);(\d+);(\d+)/.exec(ansi);
52
+ const match = /38;2;(\d+);(\d+);(\d+)/.exec(theme.getFgAnsi?.("text") ?? "");
89
53
  if (match) {
90
54
  const [r, g, b] = match.slice(1).map((v) => Number(v) / 255);
91
55
  return 0.2126 * r + 0.7152 * g + 0.0722 * b < 0.5;
@@ -93,116 +57,117 @@ export function lightBackground(theme: FoldBarTheme): boolean {
93
57
  } catch { }
94
58
  return /light/i.test(theme.name ?? "");
95
59
  }
96
-
97
60
  const truecolor = (hex: string, text: string): string => {
98
- const r = parseInt(hex.slice(1, 3), 16);
99
- const g = parseInt(hex.slice(3, 5), 16);
100
- const b = parseInt(hex.slice(5, 7), 16);
101
- return `\x1b[38;2;${r};${g};${b}m${text}\x1b[39m`;
61
+ const rgb = [1, 3, 5].map((at) => parseInt(hex.slice(at, at + 2), 16));
62
+ return `\x1b[38;2;${rgb.join(";")}m${text}\x1b[39m`;
102
63
  };
103
64
 
104
- export function formatTokens(count: number): string {
105
- if (count < 1000) return String(count);
106
- if (count < 10_000) return `${(count / 1000).toFixed(1)}k`;
107
- if (count < 1_000_000) return `${Math.round(count / 1000)}k`;
108
- return `${(count / 1_000_000).toFixed(1)}M`;
109
- }
110
-
111
- type Cell = "folded" | "staged" | "raw" | "empty";
112
- // SCORED CELLS (Shane 2026-09-03): the README figure draws every cell as its own rectangle
113
- // with a hairline gap, and the full-width block fused adjacent cells into one solid run,
114
- // so the terminal read as a different figure. The left seven-eighths block leaves an
115
- // eighth of each cell empty on the right, which is the same score in a glyph. Staged and
116
- // raw share the glyph and differ by colour; the shade glyph is gone because at a small
117
- // font its texture read as a solid block anyway.
118
- const GLYPH: Record<Cell, string> = { folded: "▂", staged: "▉", raw: "▉", empty: "░" };
119
-
120
- /** The bar's cells, oldest on the left, before any tick is laid over them. */
121
- export function foldBarCells(model: FoldBarModel, width: number = FOLD_BAR_WIDTH): Cell[] {
122
- const share = model.share ?? 0;
123
- const filled = Math.max(0, Math.min(width, Math.round(share * width)));
124
- const folded = Math.min(filled, Math.round(model.foldedShare * width));
125
- const staged = Math.min(filled - folded, Math.round(model.stagedShare * width));
126
- const cells: Cell[] = [];
127
- for (let i = 0; i < width; i += 1) {
128
- if (i < folded) cells.push("folded");
129
- else if (i < folded + staged) cells.push("staged");
130
- else if (i < filled) cells.push("raw");
131
- else cells.push("empty");
65
+ /** Two half-cell samples per terminal column. The measured fill never grows to fit
66
+ * inventory. Each nonempty CATEGORY gets one half-cell when there is room, then quotas
67
+ * follow relative mass. This is a visibility floor, not a per-fold width or token-exact
68
+ * proportion. Both halves of a guide column are reserved so it cannot erase a category.
69
+ */
70
+ export function foldBarCells(model: FoldBarModel, width = FOLD_BAR_WIDTH): Array<FoldBarKind | "empty" | "unknown" | "tick"> {
71
+ const samples = 2 * width;
72
+ const filled = Math.max(0, Math.min(samples, Math.round((model.share ?? 0) * samples)));
73
+ const ticks = foldBarTicks(model, width);
74
+ const total = FOLD_BAR_KINDS.reduce((sum, kind) => sum + model.mass[kind], 0);
75
+ const cells: Array<FoldBarKind | "empty" | "unknown" | "tick"> = Array.from({ length: samples }, (_, i) =>
76
+ ticks.has(Math.floor(i / 2)) ? "tick" : i < filled ? "unknown" : "empty");
77
+ const slots = cells.flatMap((kind, i) => kind === "unknown" ? [i] : []);
78
+ if (!model.mapped || total <= 0 || !slots.length) return cells;
79
+ const allocation = FOLD_BAR_KINDS.filter((kind) => model.mass[kind] > 0)
80
+ .map((kind) => ({ kind, quota: model.mass[kind] * slots.length / total, count: 0 }));
81
+ if (slots.length < allocation.length) {
82
+ // At very low occupancy there may be fewer slots than kinds. Keep the largest
83
+ // shares rather than inventing occupancy; ties keep the declared category order.
84
+ for (const item of [...allocation].sort((a, b) => b.quota - a.quota).slice(0, slots.length)) item.count = 1;
85
+ } else {
86
+ for (const item of allocation) item.count = Math.max(1, Math.floor(item.quota));
87
+ let assigned = allocation.reduce((sum, item) => sum + item.count, 0);
88
+ while (assigned !== slots.length) {
89
+ const direction = assigned < slots.length ? 1 : -1;
90
+ const candidates = allocation.filter((item) => direction > 0 || item.count > 1);
91
+ candidates.sort((a, b) => direction * ((b.quota - b.count) - (a.quota - a.count)));
92
+ candidates[0].count += direction;
93
+ assigned += direction;
94
+ }
95
+ }
96
+ let next = 0;
97
+ for (const item of allocation) {
98
+ for (let n = 0; n < item.count; n += 1) cells[slots[next++]] = item.kind;
132
99
  }
133
100
  return cells;
134
101
  }
135
-
136
- /** Tick positions: the cell whose right edge is the share. */
137
- export function foldBarTicks(model: FoldBarModel, width: number = FOLD_BAR_WIDTH): Map<number, "aim" | "commit"> {
102
+ export function foldBarTicks(model: FoldBarModel, width = FOLD_BAR_WIDTH): Map<number, "aim" | "commit"> {
138
103
  const at = (share: number): number => Math.max(0, Math.min(width - 1, Math.round(share * width) - 1));
139
- const ticks = new Map<number, "aim" | "commit">();
140
- ticks.set(at(model.aimShare), "aim");
141
- ticks.set(at(model.commitShare), "commit");
142
- return ticks;
104
+ return new Map([[at(model.aimShare), "aim"], [at(model.commitShare), "commit"]]);
143
105
  }
144
-
145
- /** The row without colour, for tests and for a host that strips ANSI. */
146
106
  export function foldBarPlainText(model: FoldBarModel): string {
147
- return renderFoldBar(model, Number.POSITIVE_INFINITY, {
148
- fg: (_c, t) => t, bold: (t) => t,
149
- });
107
+ return renderFoldBar(model, Number.POSITIVE_INFINITY, { fg: (_c, t) => t, bold: (t) => t });
150
108
  }
151
109
 
152
110
  export function renderFoldBar(model: FoldBarModel, width: number, theme: FoldBarTheme): string {
111
+ const cut = (text: string): string => truncateToWidth(text, width, theme.fg("dim", "..."));
112
+ const neutral = (text: string): string => theme.fg("text", text);
113
+ const muted = (text: string): string => theme.fg("muted", text);
153
114
  const brand = theme.fg("dim", model.brand);
154
- if (model.stopped) {
155
- const line = `${brand} ${theme.fg("error", theme.bold("FOLDING STOPPED"))}` +
156
- (model.share === null ? "" : theme.fg("text", ` · ${Math.round(model.share * 100)}% full`));
157
- return truncateToWidth(line, width, theme.fg("dim", "..."));
158
- }
159
- if (model.share === null) {
160
- return truncateToWidth(
161
- `${brand} ${theme.fg("muted", `not measured yet · folds automatically at ${Math.round(model.commitShare * 100)}%`)}`,
162
- width, theme.fg("dim", "..."));
163
- }
164
- const rich = theme.getColorMode?.() === "truecolor";
165
- const BATLOW = lightBackground(theme) ? BATLOW_ON_LIGHT : BATLOW_ON_DARK;
166
- const ink: Record<Cell, (text: string) => string> = {
167
- raw: (t) => rich ? truecolor(BATLOW.raw, t) : theme.fg("muted", t),
168
- staged: (t) => rich ? truecolor(BATLOW.staged, t) : theme.fg("text", t),
169
- folded: (t) => rich ? truecolor(BATLOW.folded, t) : theme.fg("dim", t),
170
- empty: (t) => theme.fg("dim", t),
115
+ if (model.stopped) return cut(`${brand} ${theme.fg("error", theme.bold("FOLDING STOPPED"))}` +
116
+ (model.share === null ? "" : neutral(` · ${Math.round(model.share * 100)}% full`)));
117
+ if (model.share === null) return cut(`${brand} ${muted(`not measured yet · folds automatically at ${Math.round(model.commitShare * 100)}%`)}`);
118
+ const palette = lightBackground(theme) ? ON_LIGHT : ON_DARK;
119
+ const ink = (kind: FoldBarKind, text: string): string => {
120
+ if (theme.getColorMode?.() === "truecolor") return truecolor(palette[kind], text);
121
+ if (kind === "pinned") return theme.fg("accent", text);
122
+ return theme.fg(kind === "raw" ? "muted" : "text", text);
171
123
  };
172
124
  const cells = foldBarCells(model);
173
- const ticks = foldBarTicks(model);
125
+ const solid = (kind: typeof cells[number]): string => {
126
+ if (kind === "tick") return neutral(theme.bold("┆"));
127
+ if (kind === "empty") return theme.fg("dim", "░");
128
+ if (kind === "unknown") return muted("█");
129
+ return ink(kind, "█");
130
+ };
131
+ const background = (kind: FoldBarKind): string | null => {
132
+ if (theme.getColorMode?.() === "truecolor") return truecolor(palette[kind], "").replace("[38;", "[48;");
133
+ // Pi exposes the same theme ink in 256-colour mode. Convert its foreground escape
134
+ // to background, without assuming an RGB theme or leaking styles into the label.
135
+ const fg = theme.getFgAnsi?.(kind === "pinned" ? "accent" : kind === "raw" ? "muted" : "text") ?? "";
136
+ const extended = /^\x1b\[38;(?:2;\d+;\d+;\d+|5;\d+)m$/.test(fg);
137
+ if (extended) return fg.replace("[38;", "[48;");
138
+ const basic = /^\x1b\[(3[0-7]|9[0-7])m$/.exec(fg);
139
+ return basic ? `\x1b[${Number(basic[1]) + 10}m` : null;
140
+ };
174
141
  let bar = "";
175
- for (let i = 0; i < cells.length; i += 1) {
176
- const tick = ticks.get(i);
177
- if (tick === "commit") bar += theme.fg("warning", "│");
178
- else if (tick === "aim") bar += theme.fg("dim", "│");
179
- else bar += ink[cells[i]](GLYPH[cells[i]]);
142
+ for (let i = 0; i < cells.length; i += 2) {
143
+ const left = cells[i], right = cells[i + 1];
144
+ if (left === right) { bar += solid(left); continue; }
145
+ // One full-height left-half glyph, right colour supplied by the cell background:
146
+ // two colours in one column, never a notch, gap, score, or extra terminal column.
147
+ if (right === "empty") {
148
+ bar += left === "unknown" ? muted("▌") : ink(left as FoldBarKind, "▌");
149
+ } else {
150
+ const bg = background(right as FoldBarKind);
151
+ bar += bg ? `${bg}${ink(left as FoldBarKind, "▌")}\x1b[49m` : solid(left);
152
+ }
180
153
  }
181
154
  const pct = Math.round(model.share * 100);
182
155
  const parts: string[] = [];
183
- // A READING FROM BEFORE THE COMMIT SAYS SO. The count only moves when the provider
184
- // answers, so right after a commit the bar showed the pre-commit percentage in the
185
- // same type as a live one and read as a commit that did nothing. No estimate is
186
- // drawn in its place: the freed bytes are transcript mass and the arithmetic would
187
- // go negative against the window.
188
- if (model.staleAfterCommit) {
189
- parts.push(theme.fg("muted", `${pct}% before the commit`));
190
- } else {
191
- parts.push(theme.fg("text", `${pct}%`));
192
- if (model.share < model.commitShare) parts.push(theme.fg("muted", `commit at ${Math.round(model.commitShare * 100)}%`));
193
- else if (model.weighed) parts.push(theme.fg("muted", "commit held"));
194
- else parts.push(theme.fg("warning", "COMMIT DUE"));
195
- }
196
- // A COUNT FOR WHAT IS STAGED, TOKENS FOR WHAT FOLDING BOUGHT. Both figures are priced
197
- // by the image law now (measurement.ts pricedBytes): "5.8M to free" beside a 1M window
198
- // was 83 screenshots' base64 counted as text (Shane 2026-09-03). The unit is written
199
- // out because pi's k/M format read as megabytes.
200
- if (model.stagedMarks > 0) parts.push(ink.staged(`${model.stagedMarks} staged`));
201
- if (model.folds > 0) {
202
- parts.push(ink.folded(`${model.folds} fold${model.folds === 1 ? "" : "s"} hide ${formatTokens(model.hiddenTokens)} tokens`));
156
+ if (model.staleAfterCommit) parts.push(muted(`${pct}% before the commit`));
157
+ else {
158
+ const when = model.share < model.commitShare ? `commit at ${Math.round(model.commitShare * 100)}%`
159
+ : model.weighed ? "commit held" : "at commit point";
160
+ parts.push(neutral(`${pct}%`), muted(when));
203
161
  }
204
- // NO BRAND IN FRONT OF THE BAR (Shane 2026-09-02): the bar identifies itself, and the
205
- // two prose states above keep the brand because nothing else on them says whose they are.
206
- const line = `${bar} ${parts.join(theme.fg("dim", " · "))}`;
207
- return truncateToWidth(line, width, theme.fg("dim", "..."));
162
+ if (model.mapped) {
163
+ // N counts compressed folds. Pin/Mark are accompanying states, not added to N.
164
+ const counts = [
165
+ ink("consolidated", `${model.foldConsolidations} Cons.`), ink("span", `${model.foldSpans} Span`),
166
+ ink("tool", `${model.foldTruncations} Tool`), ink("pinned", `${model.pinnedRefs} Pin`),
167
+ ink("marked", `${model.stagedMarks} Mark`),
168
+ ];
169
+ parts.push(neutral(`${model.folds} Folds (`) + counts.join(muted(", ")) + neutral(")"));
170
+ if (model.unplacedItems > 0) parts.push(muted(`${model.unplacedItems} not mapped`));
171
+ } else parts.push(muted("mapping"));
172
+ return cut(`${bar} ${parts.join(theme.fg("dim", " · "))}`);
208
173
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-fold",
3
- "version": "4.1.3",
3
+ "version": "4.1.5",
4
4
  "description": "Lossless context folding for the Pi coding agent: only the oldest context changes; the fresh window stays untouched.",
5
5
  "keywords": [
6
6
  "pi-package",