pi-fold 4.1.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.
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, 118k to free, 3 folds hold 412k." 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 what they free, and ends with what folding has already bought: the tokens the standing folds hide. 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.
@@ -61,6 +61,7 @@ import {
61
61
  stringIds,
62
62
  toolPayload,
63
63
  } from "./lib/measurement.ts";
64
+ import { renderFoldBar, type FoldBarModel } from "./lib/status-widget.ts";
64
65
  import type {
65
66
  NativeCompactionCompletionReceipt,
66
67
  NativeCompactionDecisionReceipt,
@@ -706,6 +707,63 @@ export function registerActiveContext(pi: any, options: {
706
707
  * leads now, occupancy is a percentage of the serving budget, and the commit point is
707
708
  * named only when it is close enough to matter.
708
709
  */
710
+ /**
711
+ * THE FOLD BAR (2026-09-02, dogfooding). One widget row directly above the footer,
712
+ * drawn by `renderFoldBar` from a model this function rebuilds on every status update.
713
+ * The component reads the model and the LIVE theme at render time, so it repaints on
714
+ * a theme switch, which the status string cannot; the host is asked to render after
715
+ * each rebuild. Installed once per host UI, on the first update that finds one.
716
+ */
717
+ const foldBar = {
718
+ model: null as FoldBarModel | null,
719
+ installed: false,
720
+ requestRender: null as (() => void) | null,
721
+ };
722
+ const installFoldBar = (ctx: any): void => {
723
+ if (foldBar.installed || typeof ctx.ui?.setWidget !== "function") return;
724
+ foldBar.installed = true;
725
+ ctx.ui.setWidget(entryTypePrefix, (tui: any) => {
726
+ foldBar.requestRender = () => { try { tui.requestRender(); } catch { } };
727
+ return {
728
+ render(width: number): string[] {
729
+ if (!foldBar.model) return [];
730
+ try { return [renderFoldBar(foldBar.model, width, ctx.ui.theme)]; }
731
+ catch { return []; }
732
+ },
733
+ invalidate() { },
734
+ };
735
+ }, { placement: "belowEditor" });
736
+ };
737
+ const foldBarModel = (input: {
738
+ share: number | null; budgetTokens: number; staged: number; roots: number; weighed: boolean;
739
+ }): FoldBarModel => {
740
+ const state = persistence.state;
741
+ const snapshot = lifecycle.latestSnapshot;
742
+ let stagedTokens = 0;
743
+ let placeholderChars = 0;
744
+ let hiddenChars = 0;
745
+ if (state && snapshot) {
746
+ for (const mark of pendingMarks(state)) stagedTokens += estimatedTokens(markFreedBytes(snapshot, state, mark));
747
+ for (const root of orderedRoots(state, snapshot)) placeholderChars += root.fold.placeholderChars;
748
+ for (const fold of state.folds) hiddenChars += Math.max(0, fold.sourceChars - fold.placeholderChars);
749
+ }
750
+ const budget = input.budgetTokens > 0 ? input.budgetTokens : 1;
751
+ return {
752
+ brand: brandNoun,
753
+ share: input.share,
754
+ commitShare: thresholds.maxTarget,
755
+ aimShare: thresholds.minTarget,
756
+ stagedShare: stagedTokens / budget,
757
+ stagedMarks: input.staged,
758
+ stagedTokens,
759
+ foldedShare: estimatedTokens(placeholderChars) / budget,
760
+ folds: input.roots,
761
+ hiddenTokens: estimatedTokens(hiddenChars),
762
+ weighed: input.weighed,
763
+ stopped: ladder.automaticFailure?.message ?? null,
764
+ };
765
+ };
766
+
709
767
  const updateStatus = (ctx: any): void => {
710
768
  try {
711
769
  const roots = persistence.state && lifecycle.latestSnapshot ? orderedRoots(persistence.state, lifecycle.latestSnapshot).length : 0;
@@ -713,6 +771,17 @@ export function registerActiveContext(pi: any, options: {
713
771
  const capacity = currentCapacity(ctx);
714
772
  const used = measurements.lastProviderMeasurement?.tokens ?? null;
715
773
  const share = used !== null && capacity.budgetTokens > 0 ? used / capacity.budgetTokens : null;
774
+ try {
775
+ installFoldBar(ctx);
776
+ if (foldBar.installed) {
777
+ foldBar.model = foldBarModel({
778
+ share, budgetTokens: capacity.budgetTokens, staged, roots,
779
+ weighed: ladder.bandTopMeasurement !== null &&
780
+ ladder.bandTopMeasurement === measurements.lastProviderMeasurement,
781
+ });
782
+ foldBar.requestRender?.();
783
+ }
784
+ } catch { }
716
785
  const parts: string[] = [];
717
786
  // FAILURE LEADS. This line is cut to whatever width the host gives it, so the last
718
787
  // segment is the first one a narrow terminal removes, and folding having stopped is
@@ -754,7 +823,10 @@ export function registerActiveContext(pi: any, options: {
754
823
  }
755
824
  if (staged > 0) parts.push(`${staged} staged`);
756
825
  if (roots > 0) parts.push(`${roots} fold${roots === 1 ? "" : "s"}`);
757
- ctx.ui?.setStatus?.(entryTypePrefix, `${brandNoun} ${parts.join(" · ")}`);
826
+ // ONE SURFACE, NOT TWO (Shane 2026-09-02, from a screenshot showing both). Where
827
+ // the fold bar widget is installed it carries every fact this string carries, so
828
+ // the string is withdrawn; a host without widgets keeps the string exactly as it was.
829
+ ctx.ui?.setStatus?.(entryTypePrefix, foldBar.installed ? undefined : `${brandNoun} ${parts.join(" · ")}`);
758
830
  } catch { }
759
831
  };
760
832
 
@@ -4791,6 +4863,8 @@ export function registerActiveContext(pi: any, options: {
4791
4863
  lifecycle.latestSnapshot = null;
4792
4864
  curation.receipts = [];
4793
4865
  try { ctx.ui?.setStatus?.(entryTypePrefix, undefined); } catch { }
4866
+ foldBar.model = null;
4867
+ foldBar.requestRender?.();
4794
4868
  });
4795
4869
 
4796
4870
  return { projectionCandidates };
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-fold",
3
- "version": "4.1.0",
3
+ "version": "4.1.1",
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",