pi-fold 4.1.4 → 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,
@@ -717,7 +718,7 @@ export function registerActiveContext(pi: any, options: {
717
718
  * named only when it is close enough to matter.
718
719
  */
719
720
  /**
720
- * 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,
721
722
  * drawn by `renderFoldBar` from a model this function rebuilds on every status update.
722
723
  * The component reads the model and the LIVE theme at render time, so it repaints on
723
724
  * a theme switch, which the status string cannot; the host is asked to render after
@@ -730,8 +731,8 @@ export function registerActiveContext(pi: any, options: {
730
731
  /** The provider measurement the last landed commit ran against; the reading is
731
732
  * stale while it is still the latest one. */
732
733
  staleSince: null as unknown,
733
- /** Each standing fold's source priced by the image law, keyed by the fold object. */
734
- pricedSource: new WeakMap<ActiveFold, number>(),
734
+ /** Display-only reconstruction while waiting for the first real context event. */
735
+ startupSnapshot: null as ActiveContextSnapshot | null,
735
736
  };
736
737
  const installFoldBar = (ctx: any): void => {
737
738
  if (foldBar.installed || typeof ctx.ui?.setWidget !== "function") return;
@@ -739,6 +740,8 @@ export function registerActiveContext(pi: any, options: {
739
740
  ctx.ui.setWidget(entryTypePrefix, (tui: any) => {
740
741
  foldBar.requestRender = () => { try { tui.requestRender(); } catch { } };
741
742
  return {
743
+ /** The model behind the row, for the lab and the gates. */
744
+ get model(): FoldBarModel | null { return foldBar.model; },
742
745
  render(width: number): string[] {
743
746
  if (!foldBar.model) return [];
744
747
  try { return [renderFoldBar(foldBar.model, width, ctx.ui.theme)]; }
@@ -749,7 +752,7 @@ export function registerActiveContext(pi: any, options: {
749
752
  }, { placement: "belowEditor" });
750
753
  };
751
754
  const foldBarModel = (ctx: any, input: {
752
- share: number | null; budgetTokens: number; staged: number; roots: number; weighed: boolean;
755
+ share: number | null; staged: number; weighed: boolean;
753
756
  }): FoldBarModel => {
754
757
  const state = persistence.state;
755
758
  // THE SAME SNAPSHOT /fold-status PRICES AGAINST. `markFreedBytes` answers ZERO, silently,
@@ -758,46 +761,81 @@ export function registerActiveContext(pi: any, options: {
758
761
  // "5 staged, 0 to free" beside a status command pricing the same marks at 41,877
759
762
  // tokens (Shane, 2026-09-02, session 01a06272). The authoritative snapshot is memoized
760
763
  // on the branch length, so reading it here costs nothing while nothing has changed.
761
- let snapshot = lifecycle.latestSnapshot;
764
+ let snapshot = lifecycle.latestSnapshot ?? foldBar.startupSnapshot;
762
765
  try { snapshot = authoritativeSnapshotFor(ctx); } catch { }
763
- let stagedTokens = 0;
764
- let placeholderChars = 0;
765
- let hiddenChars = 0;
766
- if (state && snapshot) {
767
- for (const mark of pendingMarks(state)) stagedTokens += estimatedTokens(markFreedBytes(snapshot, state, mark));
768
- for (const root of orderedRoots(state, snapshot)) placeholderChars += root.fold.placeholderChars;
769
- // WHAT FOLDING BOUGHT, IN WINDOW TERMS. `sourceChars` is the transcript count, base64
770
- // and all, and read as tokens it said "8.6M" over a 1M window. Each standing fold's
771
- // source is priced by the image law once per fold OBJECT (folds are immutable and
772
- // replaced, never mutated, so a WeakMap memo is exact) and summed over the whole
773
- // forest, which is additive because a parent's source is its children's placeholders.
774
- for (const fold of state.folds) {
775
- let priced = foldBar.pricedSource.get(fold);
776
- if (priced === undefined) {
777
- const source = renderFoldParts(fold.parts, state, snapshot);
778
- priced = source ? pricedBytes(source) : fold.sourceChars;
779
- foldBar.pricedSource.set(fold, priced);
780
- }
781
- hiddenChars += Math.max(0, priced - fold.placeholderChars);
782
- }
783
- }
784
- const budget = input.budgetTokens > 0 ? input.budgetTokens : 1;
785
- return {
766
+ const model: FoldBarModel = {
786
767
  brand: brandNoun,
787
768
  share: input.share,
788
769
  commitShare: thresholds.maxTarget,
789
770
  aimShare: thresholds.minTarget,
790
- stagedShare: stagedTokens / budget,
771
+ mapped: false, mass: emptyFoldBarMass(),
791
772
  stagedMarks: input.staged,
792
- stagedTokens,
793
- foldedShare: estimatedTokens(placeholderChars) / budget,
794
- folds: input.roots,
795
- hiddenTokens: estimatedTokens(hiddenChars),
773
+ folds: 0, foldSpans: 0, foldTruncations: 0, foldConsolidations: 0,
774
+ unplacedItems: 0,
775
+ pinnedRefs: state ? state.protected.length : 0,
796
776
  weighed: input.weighed,
797
777
  staleAfterCommit: input.share !== null && foldBar.staleSince !== null &&
798
778
  foldBar.staleSince === measurements.lastProviderMeasurement,
799
779
  stopped: ladder.automaticFailure?.message ?? null,
800
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;
801
839
  };
802
840
 
803
841
  const updateStatus = (ctx: any): void => {
@@ -811,7 +849,7 @@ export function registerActiveContext(pi: any, options: {
811
849
  installFoldBar(ctx);
812
850
  if (foldBar.installed) {
813
851
  foldBar.model = foldBarModel(ctx, {
814
- share, budgetTokens: capacity.budgetTokens, staged, roots,
852
+ share, staged,
815
853
  weighed: ladder.bandTopMeasurement !== null &&
816
854
  ladder.bandTopMeasurement === measurements.lastProviderMeasurement,
817
855
  });
@@ -1042,6 +1080,7 @@ export function registerActiveContext(pi: any, options: {
1042
1080
  cancelPreparation();
1043
1081
  lifecycle.latestSnapshot = null;
1044
1082
  lifecycle.latestSnapshotError = null;
1083
+ foldBar.startupSnapshot = null;
1045
1084
  measurements.latestRatio = null;
1046
1085
  measurements.lastProviderMeasurement = null;
1047
1086
  measurements.wallInflowSteps.length = 0;
@@ -1144,6 +1183,13 @@ export function registerActiveContext(pi: any, options: {
1144
1183
  );
1145
1184
  measurements.latestRatio = contextUsageRatio(measurements.lastProviderMeasurement);
1146
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
+ }
1147
1193
  if (restoreError) safeNotify(
1148
1194
  ctx,
1149
1195
  `Active-context state was ignored; Pi native context remains authoritative: ${String(restoreError)}`,
@@ -3160,6 +3206,7 @@ export function registerActiveContext(pi: any, options: {
3160
3206
  try {
3161
3207
  const snapshot = snapshotForEvent(ctx, event.messages);
3162
3208
  lifecycle.latestSnapshot = snapshot;
3209
+ foldBar.startupSnapshot = null;
3163
3210
  if (ladder.automaticFailure) {
3164
3211
  ladder.automaticFailure.suppressedCallbacks = Math.min(
3165
3212
  Number.MAX_SAFE_INTEGER,
@@ -4920,6 +4967,7 @@ export function registerActiveContext(pi: any, options: {
4920
4967
  curation.receipts = [];
4921
4968
  try { ctx.ui?.setStatus?.(entryTypePrefix, undefined); } catch { }
4922
4969
  foldBar.model = null;
4970
+ foldBar.startupSnapshot = null;
4923
4971
  foldBar.requestRender?.();
4924
4972
  });
4925
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 };
@@ -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.4",
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",