pi-fold 4.1.0 → 4.1.2

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
@@ -31,6 +31,17 @@ A fold takes a contiguous span of session entries and replaces it, in the window
31
31
 
32
32
  The package registers one tool, `pi_fold_context`, with nine actions over the transcript: `status`, `peek`, `brief`, `expand`, `refold`, `pin`, `unpin`, `reboundary` and `unmark`. A session that never calls the tool still folds; it degrades into lossless hierarchical compaction.
33
33
 
34
+ ### The fold bar
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:
37
+
38
+ <picture>
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">
41
+ </picture>
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.
44
+
34
45
  ## Results
35
46
 
36
47
  One 64-stage assignment over the curl C repository, run as a single agentic turn. Same model, same 251,520-token serving budget, every run. After all 64 stages landed, one withheld message asked sixteen questions about material from across the whole session.
@@ -35,6 +35,7 @@ import {
35
35
  selectAutomaticRung,
36
36
  setFoldProjectionState,
37
37
  withExpandLease,
38
+ renderFoldParts,
38
39
  } from "./lib/folding.ts";
39
40
  import {
40
41
  admissionVerdict,
@@ -54,6 +55,7 @@ import {
54
55
  parseNativeCompactionDecision,
55
56
  parseProviderContextMeasurementReceipt,
56
57
  persistenceProjection,
58
+ pricedBytes,
57
59
  protectedStaleMass,
58
60
  providerContextMeasurement,
59
61
  providerTokens,
@@ -61,6 +63,7 @@ import {
61
63
  stringIds,
62
64
  toolPayload,
63
65
  } from "./lib/measurement.ts";
66
+ import { renderFoldBar, type FoldBarModel } from "./lib/status-widget.ts";
64
67
  import type {
65
68
  NativeCompactionCompletionReceipt,
66
69
  NativeCompactionDecisionReceipt,
@@ -177,7 +180,6 @@ import {
177
180
  peekedSourceFoldIds,
178
181
  snapFoldCandidate,
179
182
  snapToFoldBoundaries,
180
- spanBytes,
181
183
  } from "./lib/selection.ts";
182
184
  import type { SpanCorrection } from "./lib/selection.ts";
183
185
  import {
@@ -666,6 +668,12 @@ export function registerActiveContext(pi: any, options: {
666
668
 
667
669
  const deliverReceipt = (receipt: ContextReceipt): void => {
668
670
  curation.receipts = withReceipt(curation.receipts, receipt);
671
+ // THE BAR'S READING GOES STALE AT A COMMIT. Occupancy is the provider's last count,
672
+ // which does not move until the next response, so after a commit the bar went on
673
+ // showing the pre-commit number as if it were current (38% beside "10 folds hold
674
+ // 8.6M", Shane 2026-09-03). Remember which measurement the commit landed against;
675
+ // while it is still the latest, the bar says the number is from before the commit.
676
+ if (receipt.foldsCommitted > 0) foldBar.staleSince = measurements.lastProviderMeasurement;
669
677
  // MONOTONIC, unlike `curation.receipts.length`, which saturates at MAX_CONTEXT_RECEIPTS
670
678
  // and would stop distinguishing one approach from the next once the session is long
671
679
  // enough to matter. This is the only counter the notice latch reads.
@@ -706,6 +714,83 @@ export function registerActiveContext(pi: any, options: {
706
714
  * leads now, occupancy is a percentage of the serving budget, and the commit point is
707
715
  * named only when it is close enough to matter.
708
716
  */
717
+ /**
718
+ * THE FOLD BAR (2026-09-02, dogfooding). One widget row directly above the footer,
719
+ * drawn by `renderFoldBar` from a model this function rebuilds on every status update.
720
+ * The component reads the model and the LIVE theme at render time, so it repaints on
721
+ * a theme switch, which the status string cannot; the host is asked to render after
722
+ * each rebuild. Installed once per host UI, on the first update that finds one.
723
+ */
724
+ const foldBar = {
725
+ model: null as FoldBarModel | null,
726
+ installed: false,
727
+ requestRender: null as (() => void) | null,
728
+ /** The provider measurement the last landed commit ran against; the reading is
729
+ * stale while it is still the latest one. */
730
+ staleSince: null as unknown,
731
+ /** Each standing fold's source priced by the image law, keyed by the fold object. */
732
+ pricedSource: new WeakMap<ActiveFold, number>(),
733
+ };
734
+ const installFoldBar = (ctx: any): void => {
735
+ if (foldBar.installed || typeof ctx.ui?.setWidget !== "function") return;
736
+ foldBar.installed = true;
737
+ ctx.ui.setWidget(entryTypePrefix, (tui: any) => {
738
+ foldBar.requestRender = () => { try { tui.requestRender(); } catch { } };
739
+ return {
740
+ render(width: number): string[] {
741
+ if (!foldBar.model) return [];
742
+ try { return [renderFoldBar(foldBar.model, width, ctx.ui.theme)]; }
743
+ catch { return []; }
744
+ },
745
+ invalidate() { },
746
+ };
747
+ }, { placement: "belowEditor" });
748
+ };
749
+ const foldBarModel = (input: {
750
+ share: number | null; budgetTokens: number; staged: number; roots: number; weighed: boolean;
751
+ }): FoldBarModel => {
752
+ const state = persistence.state;
753
+ const snapshot = lifecycle.latestSnapshot;
754
+ let stagedTokens = 0;
755
+ let placeholderChars = 0;
756
+ let hiddenChars = 0;
757
+ if (state && snapshot) {
758
+ for (const mark of pendingMarks(state)) stagedTokens += estimatedTokens(markFreedBytes(snapshot, state, mark));
759
+ for (const root of orderedRoots(state, snapshot)) placeholderChars += root.fold.placeholderChars;
760
+ // WHAT FOLDING BOUGHT, IN WINDOW TERMS. `sourceChars` is the transcript count, base64
761
+ // and all, and read as tokens it said "8.6M" over a 1M window. Each standing fold's
762
+ // source is priced by the image law once per fold OBJECT (folds are immutable and
763
+ // replaced, never mutated, so a WeakMap memo is exact) and summed over the whole
764
+ // forest, which is additive because a parent's source is its children's placeholders.
765
+ for (const fold of state.folds) {
766
+ let priced = foldBar.pricedSource.get(fold);
767
+ if (priced === undefined) {
768
+ const source = renderFoldParts(fold.parts, state, snapshot);
769
+ priced = source ? pricedBytes(source) : fold.sourceChars;
770
+ foldBar.pricedSource.set(fold, priced);
771
+ }
772
+ hiddenChars += Math.max(0, priced - fold.placeholderChars);
773
+ }
774
+ }
775
+ const budget = input.budgetTokens > 0 ? input.budgetTokens : 1;
776
+ return {
777
+ brand: brandNoun,
778
+ share: input.share,
779
+ commitShare: thresholds.maxTarget,
780
+ aimShare: thresholds.minTarget,
781
+ stagedShare: stagedTokens / budget,
782
+ stagedMarks: input.staged,
783
+ stagedTokens,
784
+ foldedShare: estimatedTokens(placeholderChars) / budget,
785
+ folds: input.roots,
786
+ hiddenTokens: estimatedTokens(hiddenChars),
787
+ weighed: input.weighed,
788
+ staleAfterCommit: input.share !== null && foldBar.staleSince !== null &&
789
+ foldBar.staleSince === measurements.lastProviderMeasurement,
790
+ stopped: ladder.automaticFailure?.message ?? null,
791
+ };
792
+ };
793
+
709
794
  const updateStatus = (ctx: any): void => {
710
795
  try {
711
796
  const roots = persistence.state && lifecycle.latestSnapshot ? orderedRoots(persistence.state, lifecycle.latestSnapshot).length : 0;
@@ -713,6 +798,17 @@ export function registerActiveContext(pi: any, options: {
713
798
  const capacity = currentCapacity(ctx);
714
799
  const used = measurements.lastProviderMeasurement?.tokens ?? null;
715
800
  const share = used !== null && capacity.budgetTokens > 0 ? used / capacity.budgetTokens : null;
801
+ try {
802
+ installFoldBar(ctx);
803
+ if (foldBar.installed) {
804
+ foldBar.model = foldBarModel({
805
+ share, budgetTokens: capacity.budgetTokens, staged, roots,
806
+ weighed: ladder.bandTopMeasurement !== null &&
807
+ ladder.bandTopMeasurement === measurements.lastProviderMeasurement,
808
+ });
809
+ foldBar.requestRender?.();
810
+ }
811
+ } catch { }
716
812
  const parts: string[] = [];
717
813
  // FAILURE LEADS. This line is cut to whatever width the host gives it, so the last
718
814
  // segment is the first one a narrow terminal removes, and folding having stopped is
@@ -754,7 +850,10 @@ export function registerActiveContext(pi: any, options: {
754
850
  }
755
851
  if (staged > 0) parts.push(`${staged} staged`);
756
852
  if (roots > 0) parts.push(`${roots} fold${roots === 1 ? "" : "s"}`);
757
- ctx.ui?.setStatus?.(entryTypePrefix, `${brandNoun} ${parts.join(" · ")}`);
853
+ // ONE SURFACE, NOT TWO (Shane 2026-09-02, from a screenshot showing both). Where
854
+ // the fold bar widget is installed it carries every fact this string carries, so
855
+ // the string is withdrawn; a host without widgets keeps the string exactly as it was.
856
+ ctx.ui?.setStatus?.(entryTypePrefix, foldBar.installed ? undefined : `${brandNoun} ${parts.join(" · ")}`);
758
857
  } catch { }
759
858
  };
760
859
 
@@ -2580,7 +2679,7 @@ export function registerActiveContext(pi: any, options: {
2580
2679
  });
2581
2680
  return null;
2582
2681
  }
2583
- const bytesBefore = bytes(projectActiveContext(snapshot, state));
2682
+ const bytesBefore = pricedBytes(projectActiveContext(snapshot, state));
2584
2683
  // THE COMMIT CUTS ONLY AS DEEP AS THE BUDGET ASKS (Shane, 2026-08-23). The frontier
2585
2684
  // stages as material arrives, so by now the pending set is most of the window; without
2586
2685
  // this bound the first commit would fold all of it. `freeingTarget` is a share of the
@@ -2663,7 +2762,7 @@ export function registerActiveContext(pi: any, options: {
2663
2762
  }
2664
2763
  }
2665
2764
  persistence.state = result.state;
2666
- const bytesAfter = bytes(projectActiveContext(snapshot, persistence.state));
2765
+ const bytesAfter = pricedBytes(projectActiveContext(snapshot, persistence.state));
2667
2766
  const freedBytes = Math.max(0, bytesBefore - bytesAfter);
2668
2767
  const pinHeld = protectedStaleMass(snapshot, result.state);
2669
2768
  const commitEvent = emit("context.commit", {
@@ -4427,7 +4526,11 @@ export function registerActiveContext(pi: any, options: {
4427
4526
  safeNotify(
4428
4527
  ctx,
4429
4528
  committed
4430
- ? `Committed ${ownValue(committed, "applied_marks") ?? 0} mark(s)${topUp ? " with automatic top-up" : ""}. ` +
4529
+ // THE EPOCH RECORD IS camelCase; `applied_marks` is the EVENT's spelling. Reading
4530
+ // the event's name off the record printed "Committed 0 mark(s)" after a /fold that
4531
+ // had just applied forty (Shane, 2026-09-03, session 01a06746: 378,216 tokens to
4532
+ // 97,577 with the notice saying nothing happened).
4533
+ ? `Committed ${ownValue(committed, "appliedMarks") ?? 0} mark(s)${topUp ? " with automatic top-up" : ""}. ` +
4431
4534
  "Exact source remains expandable."
4432
4535
  : "Nothing to commit: no staged mark could apply (pinned or already-folded spans hold).",
4433
4536
  "info",
@@ -4514,7 +4617,7 @@ export function registerActiveContext(pi: any, options: {
4514
4617
  // what the staged mark answers with after it.
4515
4618
  const spanCost = (from: number, to: number) => ({
4516
4619
  entries: Math.abs(to - from) + 1,
4517
- tokens: estimatedTokens(spanBytes(snapshot, Math.min(from, to), Math.max(from, to) + 1)),
4620
+ tokens: estimatedTokens(pricedBytes(snapshot.messages.slice(Math.min(from, to), Math.max(from, to) + 1))),
4518
4621
  });
4519
4622
 
4520
4623
  /**
@@ -4791,6 +4894,8 @@ export function registerActiveContext(pi: any, options: {
4791
4894
  lifecycle.latestSnapshot = null;
4792
4895
  curation.receipts = [];
4793
4896
  try { ctx.ui?.setStatus?.(entryTypePrefix, undefined); } catch { }
4897
+ foldBar.model = null;
4898
+ foldBar.requestRender?.();
4794
4899
  });
4795
4900
 
4796
4901
  return { projectionCandidates };
@@ -23,6 +23,8 @@ import {
23
23
  import {
24
24
  ACTIVE_CONTEXT_POLICY,
25
25
  DEFAULT_CONTEXT_WINDOW,
26
+ ESTIMATED_BYTES_PER_TOKEN,
27
+ IMAGE_ESTIMATED_TOKENS,
26
28
  servingBudgetTokens,
27
29
  } from "./policy.ts";
28
30
  import type {
@@ -594,6 +596,25 @@ export function parseNativeCompactionCompletion(value: unknown, expectedSessionI
594
596
  * needs to be the right order of magnitude instead of wrong by seventy times, and the
595
597
  * provider anchor corrects the remainder the moment a real measurement lands.
596
598
  */
599
+ /**
600
+ * WHAT A SPAN COSTS THE WINDOW, in the byte unit every estimator already speaks.
601
+ *
602
+ * `bytes()` counts a message's serialized characters, and a pasted screenshot serializes
603
+ * as hundreds of thousands of base64 characters the provider prices as one image. The
604
+ * occupancy estimate has applied that law since gate 156; the pricing of what a fold
605
+ * FREES had not, so a session holding 83 screenshots (67.3M base64 characters against
606
+ * 1.5M of text) told its owner a commit had "5.8M to free" against a 1M window, and a
607
+ * commit that moved the window 280,000 tokens recorded 8.6M freed (Shane, 2026-09-03,
608
+ * session 01a06746). Text is counted as its bytes; each image is counted as the bytes
609
+ * that estimate to IMAGE_ESTIMATED_TOKENS, so `estimatedTokens(pricedBytes(m))` is the
610
+ * same reading gate 156 gives the projection. Every "frees N" figure derives from this.
611
+ */
612
+ export function pricedBytes(messages: unknown[]): number {
613
+ const images = imageMass(messages);
614
+ return Math.max(0, bytes(messages) - images.base64Chars) +
615
+ images.images * IMAGE_ESTIMATED_TOKENS * ESTIMATED_BYTES_PER_TOKEN;
616
+ }
617
+
597
618
  export function imageMass(projected: unknown[]): { base64Chars: number; images: number } {
598
619
  let base64Chars = 0;
599
620
  let images = 0;
@@ -27,6 +27,7 @@ import {
27
27
  orderedRoots,
28
28
  protectedStaleMass,
29
29
  refsProtected,
30
+ pricedBytes,
30
31
  } from "./measurement.ts";
31
32
  import {
32
33
  childFoldIds,
@@ -179,13 +180,15 @@ export function markFreedBytes(
179
180
  ...state,
180
181
  expanded: state.expanded.filter((id) => id !== mark.id),
181
182
  }, snapshot);
182
- return expanded && folded ? Math.max(0, bytes(expanded) - bytes(folded)) : 0;
183
+ return expanded && folded ? Math.max(0, pricedBytes(expanded) - pricedBytes(folded)) : 0;
183
184
  }
184
185
  const source = renderFoldParts(mark.parts, state, snapshot);
185
186
  if (!source) return 0;
186
187
  const placeholders = mark.kind === "tool-result" ? mark.parts.length : 1;
187
188
  const placeholder = placeholders * (mark.brief.length + ESTIMATED_PLACEHOLDER_OVERHEAD_BYTES);
188
- return Math.max(0, bytes(source) - placeholder);
189
+ // PRICED, NOT COUNTED: a span holding a screenshot is worth one image to the
190
+ // window, not its base64 length (measurement.ts, pricedBytes).
191
+ return Math.max(0, pricedBytes(source) - placeholder);
189
192
  }
190
193
 
191
194
  export function markSpanStart(
@@ -0,0 +1,185 @@
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
+ /** A commit landed after the count `share` reads; the number is from before it. */
59
+ staleAfterCommit: boolean;
60
+ /** The suspension message when automatic folding has stopped, else null. */
61
+ stopped: string | null;
62
+ }
63
+
64
+ /** The subset of pi's Theme the row uses. */
65
+ export interface FoldBarTheme {
66
+ fg(color: "dim" | "muted" | "text" | "warning" | "error", text: string): string;
67
+ bold(text: string): string;
68
+ getColorMode?(): "truecolor" | "256color";
69
+ }
70
+
71
+ // Crameri batlow10, classes 3, 6 and 8 (0-indexed). The two darkest classes are unusable
72
+ // as fill on a dark terminal, and the lightest washes out on a light one.
73
+ const BATLOW = { raw: "#3C6D56", staged: "#D29343", folded: "#FDB7BC" } as const;
74
+
75
+ const truecolor = (hex: string, text: string): string => {
76
+ const r = parseInt(hex.slice(1, 3), 16);
77
+ const g = parseInt(hex.slice(3, 5), 16);
78
+ const b = parseInt(hex.slice(5, 7), 16);
79
+ return `\x1b[38;2;${r};${g};${b}m${text}\x1b[39m`;
80
+ };
81
+
82
+ export function formatTokens(count: number): string {
83
+ if (count < 1000) return String(count);
84
+ if (count < 10_000) return `${(count / 1000).toFixed(1)}k`;
85
+ if (count < 1_000_000) return `${Math.round(count / 1000)}k`;
86
+ return `${(count / 1_000_000).toFixed(1)}M`;
87
+ }
88
+
89
+ type Cell = "folded" | "staged" | "raw" | "empty";
90
+ // SCORED CELLS (Shane 2026-09-03): the README figure draws every cell as its own rectangle
91
+ // with a hairline gap, and the full-width block fused adjacent cells into one solid run,
92
+ // so the terminal read as a different figure. The left seven-eighths block leaves an
93
+ // eighth of each cell empty on the right, which is the same score in a glyph. Staged and
94
+ // raw share the glyph and differ by colour; the shade glyph is gone because at a small
95
+ // font its texture read as a solid block anyway.
96
+ const GLYPH: Record<Cell, string> = { folded: "▂", staged: "▉", raw: "▉", empty: "░" };
97
+
98
+ /** The bar's cells, oldest on the left, before any tick is laid over them. */
99
+ export function foldBarCells(model: FoldBarModel, width: number = FOLD_BAR_WIDTH): Cell[] {
100
+ const share = model.share ?? 0;
101
+ const filled = Math.max(0, Math.min(width, Math.round(share * width)));
102
+ const folded = Math.min(filled, Math.round(model.foldedShare * width));
103
+ const staged = Math.min(filled - folded, Math.round(model.stagedShare * width));
104
+ const cells: Cell[] = [];
105
+ for (let i = 0; i < width; i += 1) {
106
+ if (i < folded) cells.push("folded");
107
+ else if (i < folded + staged) cells.push("staged");
108
+ else if (i < filled) cells.push("raw");
109
+ else cells.push("empty");
110
+ }
111
+ return cells;
112
+ }
113
+
114
+ /** Tick positions: the cell whose right edge is the share. */
115
+ export function foldBarTicks(model: FoldBarModel, width: number = FOLD_BAR_WIDTH): Map<number, "aim" | "commit"> {
116
+ const at = (share: number): number => Math.max(0, Math.min(width - 1, Math.round(share * width) - 1));
117
+ const ticks = new Map<number, "aim" | "commit">();
118
+ ticks.set(at(model.aimShare), "aim");
119
+ ticks.set(at(model.commitShare), "commit");
120
+ return ticks;
121
+ }
122
+
123
+ /** The row without colour, for tests and for a host that strips ANSI. */
124
+ export function foldBarPlainText(model: FoldBarModel): string {
125
+ return renderFoldBar(model, Number.POSITIVE_INFINITY, {
126
+ fg: (_c, t) => t, bold: (t) => t,
127
+ });
128
+ }
129
+
130
+ export function renderFoldBar(model: FoldBarModel, width: number, theme: FoldBarTheme): string {
131
+ const brand = theme.fg("dim", model.brand);
132
+ if (model.stopped) {
133
+ const line = `${brand} ${theme.fg("error", theme.bold("FOLDING STOPPED"))}` +
134
+ (model.share === null ? "" : theme.fg("text", ` · ${Math.round(model.share * 100)}% full`));
135
+ return truncateToWidth(line, width, theme.fg("dim", "..."));
136
+ }
137
+ if (model.share === null) {
138
+ return truncateToWidth(
139
+ `${brand} ${theme.fg("muted", `not measured yet · folds automatically at ${Math.round(model.commitShare * 100)}%`)}`,
140
+ width, theme.fg("dim", "..."));
141
+ }
142
+ const rich = theme.getColorMode?.() === "truecolor";
143
+ const ink: Record<Cell, (text: string) => string> = {
144
+ raw: (t) => rich ? truecolor(BATLOW.raw, t) : theme.fg("muted", t),
145
+ staged: (t) => rich ? truecolor(BATLOW.staged, t) : theme.fg("text", t),
146
+ folded: (t) => rich ? truecolor(BATLOW.folded, t) : theme.fg("dim", t),
147
+ empty: (t) => theme.fg("dim", t),
148
+ };
149
+ const cells = foldBarCells(model);
150
+ const ticks = foldBarTicks(model);
151
+ let bar = "";
152
+ for (let i = 0; i < cells.length; i += 1) {
153
+ const tick = ticks.get(i);
154
+ if (tick === "commit") bar += theme.fg("warning", "│");
155
+ else if (tick === "aim") bar += theme.fg("dim", "│");
156
+ else bar += ink[cells[i]](GLYPH[cells[i]]);
157
+ }
158
+ const pct = Math.round(model.share * 100);
159
+ const parts: string[] = [];
160
+ // A READING FROM BEFORE THE COMMIT SAYS SO. The count only moves when the provider
161
+ // answers, so right after a commit the bar showed the pre-commit percentage in the
162
+ // same type as a live one and read as a commit that did nothing. No estimate is
163
+ // drawn in its place: the freed bytes are transcript mass and the arithmetic would
164
+ // go negative against the window.
165
+ if (model.staleAfterCommit) {
166
+ parts.push(theme.fg("muted", `${pct}% before the commit`));
167
+ } else {
168
+ parts.push(theme.fg("text", `${pct}%`));
169
+ if (model.share < model.commitShare) parts.push(theme.fg("muted", `commit at ${Math.round(model.commitShare * 100)}%`));
170
+ else if (model.weighed) parts.push(theme.fg("muted", "commit held"));
171
+ else parts.push(theme.fg("warning", "COMMIT DUE"));
172
+ }
173
+ // A COUNT FOR WHAT IS STAGED, TOKENS FOR WHAT FOLDING BOUGHT. Both figures are priced
174
+ // by the image law now (measurement.ts pricedBytes): "5.8M to free" beside a 1M window
175
+ // was 83 screenshots' base64 counted as text (Shane 2026-09-03). The unit is written
176
+ // out because pi's k/M format read as megabytes.
177
+ if (model.stagedMarks > 0) parts.push(ink.staged(`${model.stagedMarks} staged`));
178
+ if (model.folds > 0) {
179
+ parts.push(ink.folded(`${model.folds} fold${model.folds === 1 ? "" : "s"} hide ${formatTokens(model.hiddenTokens)} tokens`));
180
+ }
181
+ // NO BRAND IN FRONT OF THE BAR (Shane 2026-09-02): the bar identifies itself, and the
182
+ // two prose states above keep the brand because nothing else on them says whose they are.
183
+ const line = `${bar} ${parts.join(theme.fg("dim", " · "))}`;
184
+ return truncateToWidth(line, width, theme.fg("dim", "..."));
185
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-fold",
3
- "version": "4.1.0",
3
+ "version": "4.1.2",
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",