pi-fold 4.1.4 → 4.1.6

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, marked content, raw context and pinned content in that order. The suffix shows percentage, commit point, and fold, mark, and pin 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 by distance from compression: **Consolidated Folds, Span Folds, Tool Folds, Marked, Raw, Pinned**. The first three are compressed now, deepest first; marked content folds at the next commit; raw content is eligible later; pinned content is held against every commit until released. The six shades, from consolidated to pinned, are read off a colour map, [Crameri's Batlow](https://www.fabiocrameri.ch/colourmaps/) end to end by default, so the bar reads as a gradient from most compressed to most settled. `/fold-settings` offers Batlow, viridis, plasma, magma and cividis, and where on the chosen map the two ends sit, so a slice of a map or a reversed one is a keypress away; the bar repaints on each step. Whatever the choice, each shade's lightness is moved away from the background, hue held, until it clears 4.5:1 on dark and light terminals, 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 Mark, Y Pin)`, naming only the kinds with something to count, in the bar's order; a fresh session reads `0 Folds`. `N Folds` counts visible compressed folds; `Mark` counts pending marks and `Pin` counts protected entries, 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
+ Two dotted marks stand in the band's edge cells: one at `minTarget`, where a commit aims to land, and one at `maxTarget`, where automatic folding is triggered. Each is drawn over the fill's own colour in a deeper tone of that same hue, so the fill continues behind the mark; on the empty track they stand in muted ink. Neither takes a colour of its own or a warning colour. 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
 
@@ -76,7 +80,7 @@ Four papers with figure sources, redacted per-request ledgers and campaign logs
76
80
 
77
81
  The one thing worth adding is a long-term memory store. Rotation and retention are different jobs: folding gets stale material out of the window, a store keeps what the session learned. A handoff summary tries to do both in one record, written once under time pressure, bounded by the window it has to fit in. Folding is the rotation half and only that half.
78
82
 
79
- Six options, and every other name is refused.
83
+ Seven options, and every other name is refused.
80
84
 
81
85
  | Option | Default | Effect |
82
86
  | --- | --- | --- |
@@ -86,6 +90,7 @@ Six options, and every other name is refused.
86
90
  | `toolFoldThreshold` | `0.50` | The tool-call diet: a share in `[0, 1)` naming the oldest fraction of the window whose tool results are clipped in view. `0` turns it off. |
87
91
  | `preCommitNotice` | `false` | The notice switch. False is the shape that won the campaign above: no carrier reaches the projection and every fold ships the runtime's own words. `true` states the window's status to the agent once per approach to a commit: where the commit fires, how far away it is, what is staged, what that frees, what is pinned and what the pin costs, with the verbs listed and no argument made for using any of them. |
88
92
  | `noticeLeadShare` | `0.10` | How far below the commit trigger that notice speaks, as a share of the serving budget. A distance rather than a point, so it follows a band that moves. Read only when `preCommitNotice` is on. |
93
+ | `palette` | `{ map: "batlow", start: 0, end: 1 }` | The fold bar's colours, and nothing about folding. `map` is one of `batlow`, `viridis`, `plasma`, `magma`, `cividis`; `start` and `end` are positions on it in `[0, 1]` for the most compressed and the pinned shade, which may not coincide and may run backwards. Set whole or not at all. |
89
94
 
90
95
  Four commands ship for the human: `/fold-status`, `/fold` (commit every staged mark now), `/fold-editor` (the interactive map of the window) and `/fold-settings` (edit the options above; each change is saved to `~/.config/pi-fold/settings.json` and takes effect in the running session).
91
96
 
@@ -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,10 @@ 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 {
68
+ emptyFoldBarMass, renderFoldBar, resolveFoldBarPalette,
69
+ type FoldBarKind, type FoldBarModel, type FoldBarPalette,
70
+ } from "./lib/status-widget.ts";
67
71
  import type {
68
72
  NativeCompactionCompletionReceipt,
69
73
  NativeCompactionDecisionReceipt,
@@ -302,6 +306,12 @@ export function registerActiveContext(pi: any, options: {
302
306
  * rather than a point, so it tracks a band that moves. Read only when preCommitNotice
303
307
  * is true. */
304
308
  noticeLeadShare?: number;
309
+ /** THE FOLD BAR'S COLOURS (2026-09-06, Shane): which colour map the status bar shades
310
+ * its five folded-to-raw categories from and where on that map the two ends of the
311
+ * ramp sit. A display preference: it moves nothing about folding, and it is here only
312
+ * because the settings file is handed to registration whole. Absent means Batlow end
313
+ * to end. */
314
+ palette?: FoldBarPalette;
305
315
  }): {
306
316
  projectionCandidates: (ctx: any) => Array<Record<string, unknown>>;
307
317
  } {
@@ -319,6 +329,7 @@ export function registerActiveContext(pi: any, options: {
319
329
  let preCommitNotice = resolvedPreCommitNotice(options.preCommitNotice);
320
330
  let noticeLeadShare = resolveNoticeLeadShare(options.noticeLeadShare);
321
331
  let toolFoldThreshold = resolvedToolFoldThreshold(options.toolFoldThreshold);
332
+ let palette = resolveFoldBarPalette(options.palette);
322
333
  // THE RENAMED OPTION IS REFUSED BY NAME, not ignored (2026-08-30). postFoldNotice was
323
334
  // public for three days and named a carrier that no longer exists in that shape: it
324
335
  // fired on a fold count, after the cut, and asked for briefs. Silently accepting the
@@ -717,7 +728,7 @@ export function registerActiveContext(pi: any, options: {
717
728
  * named only when it is close enough to matter.
718
729
  */
719
730
  /**
720
- * THE FOLD BAR (2026-09-02, dogfooding). One widget row directly above the footer,
731
+ * THE FOLD BAR. One coloured composition row directly above the footer,
721
732
  * drawn by `renderFoldBar` from a model this function rebuilds on every status update.
722
733
  * The component reads the model and the LIVE theme at render time, so it repaints on
723
734
  * a theme switch, which the status string cannot; the host is asked to render after
@@ -730,8 +741,8 @@ export function registerActiveContext(pi: any, options: {
730
741
  /** The provider measurement the last landed commit ran against; the reading is
731
742
  * stale while it is still the latest one. */
732
743
  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>(),
744
+ /** Display-only reconstruction while waiting for the first real context event. */
745
+ startupSnapshot: null as ActiveContextSnapshot | null,
735
746
  };
736
747
  const installFoldBar = (ctx: any): void => {
737
748
  if (foldBar.installed || typeof ctx.ui?.setWidget !== "function") return;
@@ -739,6 +750,8 @@ export function registerActiveContext(pi: any, options: {
739
750
  ctx.ui.setWidget(entryTypePrefix, (tui: any) => {
740
751
  foldBar.requestRender = () => { try { tui.requestRender(); } catch { } };
741
752
  return {
753
+ /** The model behind the row, for the lab and the gates. */
754
+ get model(): FoldBarModel | null { return foldBar.model; },
742
755
  render(width: number): string[] {
743
756
  if (!foldBar.model) return [];
744
757
  try { return [renderFoldBar(foldBar.model, width, ctx.ui.theme)]; }
@@ -749,7 +762,7 @@ export function registerActiveContext(pi: any, options: {
749
762
  }, { placement: "belowEditor" });
750
763
  };
751
764
  const foldBarModel = (ctx: any, input: {
752
- share: number | null; budgetTokens: number; staged: number; roots: number; weighed: boolean;
765
+ share: number | null; staged: number; weighed: boolean;
753
766
  }): FoldBarModel => {
754
767
  const state = persistence.state;
755
768
  // THE SAME SNAPSHOT /fold-status PRICES AGAINST. `markFreedBytes` answers ZERO, silently,
@@ -758,46 +771,82 @@ export function registerActiveContext(pi: any, options: {
758
771
  // "5 staged, 0 to free" beside a status command pricing the same marks at 41,877
759
772
  // tokens (Shane, 2026-09-02, session 01a06272). The authoritative snapshot is memoized
760
773
  // on the branch length, so reading it here costs nothing while nothing has changed.
761
- let snapshot = lifecycle.latestSnapshot;
774
+ let snapshot = lifecycle.latestSnapshot ?? foldBar.startupSnapshot;
762
775
  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 {
776
+ const model: FoldBarModel = {
786
777
  brand: brandNoun,
787
778
  share: input.share,
788
779
  commitShare: thresholds.maxTarget,
789
780
  aimShare: thresholds.minTarget,
790
- stagedShare: stagedTokens / budget,
781
+ mapped: false, mass: emptyFoldBarMass(),
791
782
  stagedMarks: input.staged,
792
- stagedTokens,
793
- foldedShare: estimatedTokens(placeholderChars) / budget,
794
- folds: input.roots,
795
- hiddenTokens: estimatedTokens(hiddenChars),
783
+ folds: 0, foldSpans: 0, foldTruncations: 0, foldConsolidations: 0,
784
+ unplacedItems: 0,
785
+ pinnedRefs: state ? state.protected.length : 0,
796
786
  weighed: input.weighed,
797
787
  staleAfterCommit: input.share !== null && foldBar.staleSince !== null &&
798
788
  foldBar.staleSince === measurements.lastProviderMeasurement,
799
789
  stopped: ladder.automaticFailure?.message ?? null,
790
+ palette,
800
791
  };
792
+ if (!state || !snapshot) return model;
793
+ model.mapped = true;
794
+ // Price the visible projection once per category, never a hidden fold's original
795
+ // source. Revealing a parent reveals its parts, not necessarily every descendant.
796
+ // Correct inclusive intervals prevent the last hidden message being priced as raw.
797
+ const visibleAt = new Map<number, { fold: ActiveFold; end: number }>();
798
+ const byId = new Map(state.folds.map((fold) => [fold.id, fold]));
799
+ const visit = (fold: ActiveFold, interval: { start: number; end: number } | null): void => {
800
+ if (!interval) { model.unplacedItems += 1; return; }
801
+ const revealed = state.expanded.includes(fold.id) || refsProtected(flattenFoldRefs(fold, state), state, snapshot);
802
+ if (revealed) {
803
+ for (const part of fold.parts) {
804
+ if (part.kind !== "fold") continue;
805
+ const child = byId.get(part.foldId);
806
+ if (child) visit(child, foldInterval(child, state, snapshot));
807
+ }
808
+ return;
809
+ }
810
+ visibleAt.set(interval.start, { fold, end: interval.end });
811
+ model.folds += 1;
812
+ if (fold.kind === "chapter") model.foldSpans += 1;
813
+ else if (fold.kind === "tool-result") model.foldTruncations += 1;
814
+ else model.foldConsolidations += 1;
815
+ };
816
+ const roots = orderedRoots(state, snapshot);
817
+ const placedRoots = new Set(roots.map((root) => root.fold.id));
818
+ model.unplacedItems += state.folds.filter((fold) => !fold.parentId && !placedRoots.has(fold.id)).length;
819
+ for (const root of roots) visit(root.fold, root);
820
+
821
+ // Resolve all marks before walking roots: a pending consolidation can begin at a
822
+ // root and include raw gaps farther on. Membership, not an encountered start edge,
823
+ // determines Mark. A set also prevents overlapping marks charging a message twice.
824
+ const marked = new Set<number>();
825
+ for (const mark of pendingMarks(state)) {
826
+ const span = markSpanRefs(state, mark);
827
+ const indices = span.refs.map((ref) => exactMapped(snapshot, ref)?.index ?? -1);
828
+ if (span.unresolved || !indices.length || indices.some((index) => index < 0)) {
829
+ model.unplacedItems += 1;
830
+ continue;
831
+ }
832
+ for (const index of indices) marked.add(index);
833
+ }
834
+ const pinnedKeys = new Set(state.protected.map(objectRefKey));
835
+ for (let index = 0; index < snapshot.messages.length; index += 1) {
836
+ const root = visibleAt.get(index);
837
+ if (root) {
838
+ const kind: FoldBarKind = root.fold.kind === "chapter" ? "span"
839
+ : root.fold.kind === "tool-result" ? "tool" : "consolidated";
840
+ model.mass[kind] += pricedBytes(renderFold(root.fold, state, snapshot) ?? []);
841
+ index = root.end; // inclusive: advance PAST the final hidden message
842
+ continue;
843
+ }
844
+ const ref = snapshot.mapped[index]?.ref;
845
+ const kind: FoldBarKind = ref && pinnedKeys.has(objectRefKey(ref)) ? "pinned"
846
+ : marked.has(index) ? "marked" : "raw";
847
+ model.mass[kind] += pricedBytes([snapshot.messages[index]]);
848
+ }
849
+ return model;
801
850
  };
802
851
 
803
852
  const updateStatus = (ctx: any): void => {
@@ -811,7 +860,7 @@ export function registerActiveContext(pi: any, options: {
811
860
  installFoldBar(ctx);
812
861
  if (foldBar.installed) {
813
862
  foldBar.model = foldBarModel(ctx, {
814
- share, budgetTokens: capacity.budgetTokens, staged, roots,
863
+ share, staged,
815
864
  weighed: ladder.bandTopMeasurement !== null &&
816
865
  ladder.bandTopMeasurement === measurements.lastProviderMeasurement,
817
866
  });
@@ -890,6 +939,7 @@ export function registerActiveContext(pi: any, options: {
890
939
  const nextToolFoldThreshold = resolvedToolFoldThreshold(settings?.toolFoldThreshold);
891
940
  const nextPreCommitNotice = resolvedPreCommitNotice(settings?.preCommitNotice);
892
941
  const nextNoticeLeadShare = resolveNoticeLeadShare(settings?.noticeLeadShare);
942
+ const nextPalette = resolveFoldBarPalette(settings?.palette);
893
943
  const changed: string[] = [];
894
944
  for (const field of ["maxTarget", "minTarget", "consolidateAfter", "minFoldChars"] as const) {
895
945
  if (nextThresholds[field] !== thresholds[field]) changed.push(field);
@@ -897,6 +947,7 @@ export function registerActiveContext(pi: any, options: {
897
947
  if (nextToolFoldThreshold !== toolFoldThreshold) changed.push("toolFoldThreshold");
898
948
  if (nextPreCommitNotice !== preCommitNotice) changed.push("preCommitNotice");
899
949
  if (nextNoticeLeadShare !== noticeLeadShare) changed.push("noticeLeadShare");
950
+ if (nextPalette.map !== palette.map || nextPalette.start !== palette.start || nextPalette.end !== palette.end) changed.push("palette");
900
951
  // A SAVE THAT MOVED NOTHING IS NOT AN EVENT. The screen saves every keystroke that
901
952
  // lands, including a row stepped back to where it started.
902
953
  if (!changed.length) return;
@@ -904,6 +955,7 @@ export function registerActiveContext(pi: any, options: {
904
955
  toolFoldThreshold = nextToolFoldThreshold;
905
956
  preCommitNotice = nextPreCommitNotice;
906
957
  noticeLeadShare = nextNoticeLeadShare;
958
+ palette = nextPalette;
907
959
  dropDerivationMemo();
908
960
  // THE STREAM CARRIES IT, because from here on the session's commits fire at a point
909
961
  // no earlier record explains, and an archive reading this run later has no other way
@@ -917,6 +969,7 @@ export function registerActiveContext(pi: any, options: {
917
969
  tool_fold_threshold: toolFoldThreshold,
918
970
  pre_commit_notice: preCommitNotice,
919
971
  notice_lead_share: noticeLeadShare,
972
+ palette: { ...palette },
920
973
  });
921
974
  // The person is standing in the settings screen, so the line they will look at next
922
975
  // is the status line. Without this it keeps the old number until a context event.
@@ -1042,6 +1095,7 @@ export function registerActiveContext(pi: any, options: {
1042
1095
  cancelPreparation();
1043
1096
  lifecycle.latestSnapshot = null;
1044
1097
  lifecycle.latestSnapshotError = null;
1098
+ foldBar.startupSnapshot = null;
1045
1099
  measurements.latestRatio = null;
1046
1100
  measurements.lastProviderMeasurement = null;
1047
1101
  measurements.wallInflowSteps.length = 0;
@@ -1144,6 +1198,13 @@ export function registerActiveContext(pi: any, options: {
1144
1198
  );
1145
1199
  measurements.latestRatio = contextUsageRatio(measurements.lastProviderMeasurement);
1146
1200
  persistence.persisted = clone(durableRestored);
1201
+ // Reuse Pi's own reconstructed messages, including custom messages and branch
1202
+ // selection. This is ONLY a view for the bar: do not populate latestSnapshot,
1203
+ // advance the frontier, persist a projection, or pretend a provider event occurred.
1204
+ if (typeof ctx.ui?.setWidget === "function" && Array.isArray(restoredMessages)) {
1205
+ try { foldBar.startupSnapshot = snapshotForEvent(ctx, restoredMessages); }
1206
+ catch { /* Keep the honest mapping fallback if the host cannot supply a view. */ }
1207
+ }
1147
1208
  if (restoreError) safeNotify(
1148
1209
  ctx,
1149
1210
  `Active-context state was ignored; Pi native context remains authoritative: ${String(restoreError)}`,
@@ -3160,6 +3221,7 @@ export function registerActiveContext(pi: any, options: {
3160
3221
  try {
3161
3222
  const snapshot = snapshotForEvent(ctx, event.messages);
3162
3223
  lifecycle.latestSnapshot = snapshot;
3224
+ foldBar.startupSnapshot = null;
3163
3225
  if (ladder.automaticFailure) {
3164
3226
  ladder.automaticFailure.suppressedCallbacks = Math.min(
3165
3227
  Number.MAX_SAFE_INTEGER,
@@ -4920,6 +4982,7 @@ export function registerActiveContext(pi: any, options: {
4920
4982
  curation.receipts = [];
4921
4983
  try { ctx.ui?.setStatus?.(entryTypePrefix, undefined); } catch { }
4922
4984
  foldBar.model = null;
4985
+ foldBar.startupSnapshot = null;
4923
4986
  foldBar.requestRender?.();
4924
4987
  });
4925
4988
 
@@ -11,7 +11,7 @@ export {
11
11
 
12
12
  const PUBLIC_OPTIONS = Object.freeze([
13
13
  "thresholds", "providerInputBudget", "blacklistAutoFoldTools", "toolFoldThreshold",
14
- "preCommitNotice", "noticeLeadShare",
14
+ "preCommitNotice", "noticeLeadShare", "palette",
15
15
  ]);
16
16
 
17
17
  const GENERATOR_DELETED = "the model brief generator is deleted (2026-08-14): across 15 sealed " +
@@ -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
  };
@@ -25,6 +25,7 @@
25
25
  // deployment) still agrees on the property.
26
26
 
27
27
  import type { ActiveContextThresholds } from "./policy.ts";
28
+ import type { FoldBarPalette } from "./status-widget.ts";
28
29
 
29
30
  /**
30
31
  * What the settings screen hands the runtime: exactly the settings FILE's shape, where an
@@ -37,6 +38,7 @@ export interface LiveFoldSettings {
37
38
  toolFoldThreshold?: number;
38
39
  preCommitNotice?: boolean;
39
40
  noticeLeadShare?: number;
41
+ palette?: FoldBarPalette;
40
42
  }
41
43
 
42
44
  /** Refuses by throwing, exactly as registration does, and leaves the runtime untouched. */
@@ -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,288 @@
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
+ // GROUPED BY DISTANCE FROM COMPRESSION, left to right (Shane 2026-09-06). Consolidated,
6
+ // span and tool folds are compressed now, deepest first. Marked content is raw now and
7
+ // folds at the next commit. Raw content is raw now and eligible later. Pinned content is
8
+ // raw and HELD against every commit until released, so it is the farthest from
9
+ // compression of all, and it sits at the fresh edge of the bar.
10
+ export const FOLD_BAR_KINDS = ["consolidated", "span", "tool", "marked", "raw", "pinned"] as const;
11
+ export type FoldBarKind = typeof FOLD_BAR_KINDS[number];
12
+ export type FoldBarMass = Record<FoldBarKind, number>;
13
+ export const emptyFoldBarMass = (): FoldBarMass => ({ consolidated: 0, span: 0, tool: 0, marked: 0, raw: 0, pinned: 0 });
14
+
15
+ // ALL SIX KINDS TAKE THEIR SHADE FROM THE MAP, in that order (Shane 2026-09-06, second
16
+ // pass). A pin sat off the ramp in the theme accent for one build; Shane put it back at
17
+ // the far end, because a pin is the MOST settled state of all, decided and held, and the
18
+ // map's last shade reads as separate from the rest of the fill on its own.
19
+
20
+ // THE COLOUR MAPS, sampled at 64 positions by lab/generate_colour_maps.py: Batlow from
21
+ // Crameri's own 256-step table, the other four from matplotlib. Five shades are read off
22
+ // the chosen map by linear interpolation between a start and an end position, so a
23
+ // person can use the whole map, a slice of it, or run it backwards. No palette library
24
+ // ships: these tables are the whole dependency.
25
+ export const COLOUR_MAP_NAMES = ["batlow", "viridis", "plasma", "magma", "cividis"] as const;
26
+ export type ColourMapName = typeof COLOUR_MAP_NAMES[number];
27
+ const COLOUR_MAPS: Record<ColourMapName, readonly string[]> = {
28
+ batlow: [
29
+ "#011959", "#051F5A", "#08265B", "#0B2B5C", "#0D315D", "#0E365E", "#0F3B5F", "#103F60",
30
+ "#114360", "#124761", "#134B61", "#154F62", "#175362", "#195662", "#1C5A62", "#1F5D61",
31
+ "#236061", "#27635F", "#2C665D", "#31695B", "#376B59", "#3C6D56", "#426F52", "#49714F",
32
+ "#4F734C", "#557548", "#5C7744", "#627941", "#697B3D", "#707D3A", "#777F36", "#7E8133",
33
+ "#858330", "#8D852E", "#94872C", "#9C892B", "#A48B2C", "#AC8C2D", "#B48E30", "#BC8F34",
34
+ "#C49138", "#CB923E", "#D29343", "#D9954A", "#E09651", "#E69858", "#EC9A61", "#F19C69",
35
+ "#F59F73", "#F8A17C", "#FAA486", "#FCA78F", "#FDAB98", "#FDAEA1", "#FDB1AA", "#FDB3B3",
36
+ "#FDB6BB", "#FDB9C4", "#FDBCCC", "#FCBFD5", "#FCC2DE", "#FBC6E7", "#FBC9F1", "#FACCFA",
37
+ ],
38
+ viridis: [
39
+ "#440154", "#46075A", "#470D60", "#471365", "#48186B", "#481E6F", "#482374", "#482878",
40
+ "#472D7C", "#46327F", "#453782", "#433C84", "#424186", "#404688", "#3E4A89", "#3C4F8A",
41
+ "#3A538B", "#38578C", "#365C8D", "#34608D", "#32648E", "#31688E", "#2F6C8E", "#2D708E",
42
+ "#2C748E", "#2A778E", "#297B8E", "#277F8E", "#26838E", "#24878E", "#238A8D", "#218E8D",
43
+ "#20928C", "#1F968B", "#1F9A8A", "#1F9D89", "#1FA187", "#21A586", "#23A984", "#26AC81",
44
+ "#2AB07F", "#2FB47C", "#35B779", "#3BBB75", "#42BE71", "#4AC26D", "#52C569", "#5AC864",
45
+ "#63CB5F", "#6CCE59", "#76D054", "#80D34D", "#8AD547", "#94D841", "#9FDA3A", "#A9DC33",
46
+ "#B4DD2C", "#BFDF25", "#CAE01F", "#D4E21A", "#DFE318", "#E9E41A", "#F3E61E", "#FDE725",
47
+ ],
48
+ plasma: [
49
+ "#0D0887", "#19068C", "#220690", "#2A0593", "#320597", "#39049A", "#40049C", "#46039F",
50
+ "#4D02A1", "#5402A3", "#5A01A5", "#6001A6", "#6700A7", "#6D00A8", "#7301A8", "#7902A8",
51
+ "#7F03A8", "#8506A7", "#8B0AA5", "#910EA3", "#9613A1", "#9C179E", "#A11C9B", "#A62098",
52
+ "#AB2594", "#B02991", "#B42E8D", "#B93289", "#BD3785", "#C13B82", "#C5407E", "#C9457A",
53
+ "#CD4976", "#D14E73", "#D4526F", "#D8576C", "#DB5C68", "#DE6065", "#E16561", "#E46A5E",
54
+ "#E76F5A", "#EA7457", "#ED7953", "#EF7E50", "#F2834C", "#F48849", "#F68D45", "#F89342",
55
+ "#F9993E", "#FA9E3B", "#FCA437", "#FDAA34", "#FDB031", "#FEB62E", "#FEBC2B", "#FDC328",
56
+ "#FDC926", "#FCD025", "#FBD624", "#F9DD25", "#F7E425", "#F5EB27", "#F2F227", "#F0F921",
57
+ ],
58
+ magma: [
59
+ "#000004", "#02010A", "#040312", "#06051A", "#0A0822", "#0F0B2B", "#130D34", "#180F3E",
60
+ "#1D1148", "#231151", "#29115B", "#301164", "#37106C", "#3E0F72", "#450F76", "#4B117A",
61
+ "#52137C", "#58157E", "#5F187F", "#651A80", "#6B1D81", "#721F81", "#782281", "#7E2482",
62
+ "#852681", "#8B2981", "#922B81", "#982D80", "#9F2F7F", "#A5317E", "#AC337C", "#B3357A",
63
+ "#B93878", "#C03A76", "#C63D74", "#CD3F71", "#D3436E", "#D9466B", "#DF4A68", "#E44F65",
64
+ "#E95462", "#ED5A5F", "#F1605D", "#F4675C", "#F66E5C", "#F8765C", "#FA7E5E", "#FB8560",
65
+ "#FC8D63", "#FD9567", "#FD9C6B", "#FEA470", "#FEAB75", "#FEB37A", "#FEBA80", "#FEC286",
66
+ "#FEC98D", "#FED193", "#FED89A", "#FDDFA1", "#FDE7A8", "#FCEEB0", "#FCF5B7", "#FCFDBF",
67
+ ],
68
+ cividis: [
69
+ "#00224E", "#002554", "#00285B", "#002B63", "#002E6A", "#003070", "#063370", "#123670",
70
+ "#1B386F", "#223B6E", "#283E6E", "#2D416D", "#32446D", "#37476C", "#3C496C", "#404C6C",
71
+ "#444F6C", "#48526C", "#4C556C", "#50576C", "#545A6D", "#575D6D", "#5B606E", "#5F636F",
72
+ "#62666F", "#666870", "#696B71", "#6D6E72", "#707173", "#747475", "#777776", "#7A7A78",
73
+ "#7E7D78", "#828078", "#868379", "#898678", "#8D8978", "#918C78", "#958F77", "#999277",
74
+ "#9D9576", "#A19875", "#A59C74", "#A99F73", "#ADA272", "#B2A570", "#B6A96F", "#BAAC6D",
75
+ "#BEAF6B", "#C2B369", "#C7B667", "#CBBA64", "#CFBD62", "#D4C15F", "#D8C45C", "#DDC859",
76
+ "#E1CC55", "#E5CF51", "#EAD34D", "#EFD748", "#F3DB42", "#F8DF3C", "#FDE234", "#FEE838",
77
+ ],
78
+ };
79
+
80
+ /** Which map the bar reads and where on it the two ends of the ramp sit. `start` is the
81
+ * position of the most compressed shade and `end` the position of raw; a start above
82
+ * its end runs the map backwards. Both are positions in [0, 1] and may not coincide,
83
+ * because six shades read at one position are one shade. */
84
+ export interface FoldBarPalette { map: ColourMapName; start: number; end: number; }
85
+ export const DEFAULT_FOLD_BAR_PALETTE: Readonly<FoldBarPalette> = Object.freeze({ map: "batlow", start: 0, end: 1 });
86
+
87
+ export class FoldBarPaletteError extends Error {}
88
+
89
+ /** One resolver for every path a palette arrives by: registration, the settings file and
90
+ * a live edit. Absent means the package default; anything present is validated whole. */
91
+ export function resolveFoldBarPalette(value: unknown): FoldBarPalette {
92
+ if (value === undefined) return { ...DEFAULT_FOLD_BAR_PALETTE };
93
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
94
+ throw new FoldBarPaletteError("palette must be an object { map, start, end }");
95
+ }
96
+ const candidate = value as Record<string, unknown>;
97
+ for (const key of Object.keys(candidate)) {
98
+ if (key !== "map" && key !== "start" && key !== "end") {
99
+ throw new FoldBarPaletteError(`palette has no ${key} field: the fields are map, start, end`);
100
+ }
101
+ }
102
+ const { map, start, end } = candidate;
103
+ if (typeof map !== "string" || !(COLOUR_MAP_NAMES as readonly string[]).includes(map)) {
104
+ throw new FoldBarPaletteError(`palette.map must be one of ${COLOUR_MAP_NAMES.join(", ")}`);
105
+ }
106
+ for (const [name, position] of [["start", start], ["end", end]] as const) {
107
+ if (typeof position !== "number" || !Number.isFinite(position) || position < 0 || position > 1) {
108
+ throw new FoldBarPaletteError(`palette.${name} must be a position on the map from 0 to 1`);
109
+ }
110
+ }
111
+ if (start === end) {
112
+ throw new FoldBarPaletteError("palette.start and palette.end must differ: six shades read at one position are one shade");
113
+ }
114
+ return { map: map as ColourMapName, start: start as number, end: end as number };
115
+ }
116
+
117
+ // sRGB <-> linear <-> OKLab, enough to hold a hue while moving its lightness.
118
+ const hexToRgb = (hex: string): number[] => [1, 3, 5].map((at) => parseInt(hex.slice(at, at + 2), 16) / 255);
119
+ const rgbToHex = (rgb: number[]): string =>
120
+ "#" + rgb.map((v) => Math.round(Math.max(0, Math.min(1, v)) * 255).toString(16).padStart(2, "0").toUpperCase()).join("");
121
+ const toLinear = (c: number): number => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
122
+ const fromLinear = (c: number): number => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);
123
+ const luminanceOf = (hex: string): number => {
124
+ const [r, g, b] = hexToRgb(hex).map(toLinear);
125
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
126
+ };
127
+ /** WCAG contrast ratio between two colours, the measure gate 165 has always applied. */
128
+ export function contrastRatio(a: string, b: string): number {
129
+ const [la, lb] = [luminanceOf(a), luminanceOf(b)];
130
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
131
+ }
132
+ const linearToOklab = ([r, g, b]: number[]): number[] => {
133
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
134
+ const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
135
+ const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
136
+ return [
137
+ 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s,
138
+ 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s,
139
+ 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s,
140
+ ];
141
+ };
142
+ const oklabToLinear = ([L, a, b]: number[]): number[] => {
143
+ const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
144
+ const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
145
+ const s = (L - 0.0894841775 * a - 1.2914855480 * b) ** 3;
146
+ return [
147
+ 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
148
+ -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
149
+ -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
150
+ ];
151
+ };
152
+ /** An OKLab colour brought into the sRGB gamut by shrinking its chroma, hue held. */
153
+ const inGamut = (L: number, a: number, b: number): number[] => {
154
+ const inside = (rgb: number[]): boolean => rgb.every((v) => v >= -1e-6 && v <= 1 + 1e-6);
155
+ let rgb = oklabToLinear([L, a, b]);
156
+ if (inside(rgb)) return rgb.map(fromLinear);
157
+ let lo = 0, hi = 1;
158
+ for (let i = 0; i < 16; i += 1) {
159
+ const mid = (lo + hi) / 2;
160
+ if (inside(oklabToLinear([L, a * mid, b * mid]))) lo = mid; else hi = mid;
161
+ }
162
+ rgb = oklabToLinear([L, a * lo, b * lo]);
163
+ return rgb.map((v) => fromLinear(Math.max(0, Math.min(1, v))));
164
+ };
165
+
166
+ /** The two reference backgrounds the readability floor is measured against. */
167
+ export const READABILITY = Object.freeze({ dark: "#202122", light: "#FFFFFF", contrast: 4.5 });
168
+
169
+ /** THE READABILITY CLAMP. A map's dark end vanishes on a dark terminal and its pale end
170
+ * on a light one, and the old build carried two hand-adjusted hex tables to cover that
171
+ * for one map at one range. With the map and the range a choice, the adjustment runs
172
+ * here: lightness moves away from the background, hue held and chroma shrunk only as far
173
+ * as the gamut needs, until the shade clears 4.5:1 against the reference background. A
174
+ * shade that already clears it is returned untouched. */
175
+ export function readableOn(hex: string, dark: boolean): string {
176
+ const background = dark ? READABILITY.dark : READABILITY.light;
177
+ if (contrastRatio(hex, background) >= READABILITY.contrast) return hex.toUpperCase();
178
+ const [L, a, b] = linearToOklab(hexToRgb(hex).map(toLinear));
179
+ // Lightness is monotone in luminance at held hue, so the boundary is found by bisection
180
+ // between the shade's own lightness and the far end, which always clears the floor.
181
+ let fails = L;
182
+ let passes = dark ? 1 : 0;
183
+ for (let i = 0; i < 24; i += 1) {
184
+ const mid = (fails + passes) / 2;
185
+ if (contrastRatio(rgbToHex(inGamut(mid, a, b)), background) >= READABILITY.contrast) passes = mid;
186
+ else fails = mid;
187
+ }
188
+ return rgbToHex(inGamut(passes, a, b));
189
+ }
190
+
191
+ /** The glyph a guide draws. Marks, not breaks: the fill's own colour stays behind them.
192
+ * DOTTED, NOT A LINE (Shane 2026-09-06, sixth pass): a full-height one-pixel stroke
193
+ * inside a solid colour is exactly what the boundary between two folds of that colour
194
+ * looks like, so the edge strokes "read as fold segments"; a mark that does not span
195
+ * the cell's height cannot be a boundary. The dotted vertical is centred in its cell,
196
+ * so each guide stands just inside the band's edge: the aim in the cell whose left edge
197
+ * is the aim share, the commit in the cell whose right edge is the commit share, a
198
+ * quarter cell in from each line. Earlier passes: a text-ink hairline "too pronounced",
199
+ * a notch "confusing", brackets off their lines because a font insets them. The ink is
200
+ * the fill's own hue toned toward the background, muted on the track. */
201
+ export const GUIDE_GLYPHS = Object.freeze({ aim: "┊", commit: "┊" });
202
+ /** How far a bracket's ink is pulled from the shade under it toward the reference
203
+ * background. Shane 2026-09-06, fourth pass: black brackets were too heavy, and no one
204
+ * fixed accent is legible over a map that runs from navy to pale pink (the theme's teal
205
+ * vanished over the teal span shade, violet over the blue, white over the pink). The
206
+ * fill's own hue, darkened on a dark theme and lightened on a light one, is legible over
207
+ * every shade because the readability clamp holds each shade at 4.5:1 against that
208
+ * background: at this depth the floor is 3:1 over the darkest shade a map can carry. */
209
+ export const GUIDE_TONE = 0.75;
210
+ /** A guide's ink over a shade: the same hue, most of the way to the reference
211
+ * background, so a bracket over salmon is a dark salmon and one over navy a deeper navy.
212
+ * Never a hue of its own, so no guide competes with a category. */
213
+ export function guideInk(under: string, dark: boolean): string {
214
+ const background = hexToRgb(dark ? READABILITY.dark : READABILITY.light);
215
+ return rgbToHex(hexToRgb(under).map((v, i) => v * (1 - GUIDE_TONE) + background[i] * GUIDE_TONE));
216
+ }
217
+ /** How far the dim ink is pulled toward the reference background to stand in for the
218
+ * ░ texture it replaces: a quarter-coverage glyph reads as about a quarter of its ink. */
219
+ export const TRACK_DEPTH = 0.7;
220
+ /** The empty track's one shade, or null when the theme's dim ink is not an RGB escape. */
221
+ export function trackShade(theme: FoldBarTheme, dark: boolean): string | null {
222
+ const match = /^\x1b\[38;2;(\d+);(\d+);(\d+)m$/.exec(theme.getFgAnsi?.("dim") ?? "");
223
+ if (!match) return null;
224
+ const dim = match.slice(1, 4).map((v) => Number(v) / 255);
225
+ const background = hexToRgb(dark ? READABILITY.dark : READABILITY.light);
226
+ return rgbToHex(dim.map((v, i) => v * (1 - TRACK_DEPTH) + background[i] * TRACK_DEPTH));
227
+ }
228
+
229
+ /** The shade a map holds at a position in [0, 1], interpolated between its samples. */
230
+ export function sampleColourMap(map: ColourMapName, position: number): string {
231
+ const table = COLOUR_MAPS[map];
232
+ const at = Math.max(0, Math.min(1, position)) * (table.length - 1);
233
+ const lo = Math.floor(at), hi = Math.min(table.length - 1, lo + 1), t = at - lo;
234
+ const a = hexToRgb(table[lo]), b = hexToRgb(table[hi]);
235
+ return rgbToHex(a.map((v, i) => v * (1 - t) + b[i] * t));
236
+ }
237
+
238
+ const shadeMemo = new Map<string, Record<FoldBarKind, string>>();
239
+ /** The six shades for a palette on a background: read off the map at evenly spaced
240
+ * positions from start to end, then brought up to the readability floor. Memoized on
241
+ * the palette and background, since the bar renders many times per choice. */
242
+ export function foldBarShades(palette: FoldBarPalette, dark: boolean): Record<FoldBarKind, string> {
243
+ const key = `${palette.map}:${palette.start}:${palette.end}:${dark ? "dark" : "light"}`;
244
+ const held = shadeMemo.get(key);
245
+ if (held) return held;
246
+ const shades = Object.fromEntries(FOLD_BAR_KINDS.map((kind, i) => {
247
+ const position = palette.start + (palette.end - palette.start) * (i / (FOLD_BAR_KINDS.length - 1));
248
+ return [kind, readableOn(sampleColourMap(palette.map, position), dark)];
249
+ })) as Record<FoldBarKind, string>;
250
+ shadeMemo.set(key, shades);
251
+ return shades;
252
+ }
38
253
 
39
- /** Everything the row reads. Built by the runtime on every status update. */
40
254
  export interface FoldBarModel {
41
255
  brand: string;
42
- /** The provider's own count against the serving budget, or null when unmeasured. */
43
256
  share: number | null;
44
- /** maxTarget and minTarget of the live thresholds, as shares. */
45
257
  commitShare: number;
46
258
  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;
259
+ /** Estimated visible mass, counted exactly once, in the category order above. */
260
+ mass: FoldBarMass;
261
+ mapped: boolean;
53
262
  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. */
263
+ foldSpans: number;
264
+ foldTruncations: number;
265
+ foldConsolidations: number;
266
+ pinnedRefs: number;
267
+ stagedMarks: number;
268
+ unplacedItems: number;
57
269
  weighed: boolean;
58
- /** A commit landed after the count `share` reads; the number is from before it. */
59
270
  staleAfterCommit: boolean;
60
- /** The suspension message when automatic folding has stopped, else null. */
61
271
  stopped: string | null;
272
+ /** Absent means the package default, exactly as it does in the settings file. */
273
+ palette?: FoldBarPalette;
62
274
  }
63
-
64
- /** The subset of pi's Theme the row uses. */
65
275
  export interface FoldBarTheme {
66
- fg(color: "dim" | "muted" | "text" | "warning" | "error", text: string): string;
276
+ fg(color: "dim" | "muted" | "text" | "warning" | "error" | "accent", text: string): string;
67
277
  bold(text: string): string;
68
278
  getColorMode?(): "truecolor" | "256color";
69
- getFgAnsi?(color: "text"): string;
279
+ getFgAnsi?(color: "text" | "muted" | "accent" | "dim"): string;
70
280
  name?: string;
71
281
  }
72
282
 
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;
78
-
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
283
  export function lightBackground(theme: FoldBarTheme): boolean {
86
284
  try {
87
- const ansi = theme.getFgAnsi?.("text") ?? "";
88
- const match = /38;2;(\d+);(\d+);(\d+)/.exec(ansi);
285
+ const match = /38;2;(\d+);(\d+);(\d+)/.exec(theme.getFgAnsi?.("text") ?? "");
89
286
  if (match) {
90
287
  const [r, g, b] = match.slice(1).map((v) => Number(v) / 255);
91
288
  return 0.2126 * r + 0.7152 * g + 0.0722 * b < 0.5;
@@ -93,116 +290,167 @@ export function lightBackground(theme: FoldBarTheme): boolean {
93
290
  } catch { }
94
291
  return /light/i.test(theme.name ?? "");
95
292
  }
96
-
97
293
  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`;
294
+ const rgb = [1, 3, 5].map((at) => parseInt(hex.slice(at, at + 2), 16));
295
+ return `\x1b[38;2;${rgb.join(";")}m${text}\x1b[39m`;
102
296
  };
103
297
 
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");
298
+ /** Two half-cell samples per terminal column. The measured fill never grows to fit
299
+ * inventory. Each nonempty CATEGORY gets one half-cell when there is room, then quotas
300
+ * follow relative mass. This is a visibility floor, not a per-fold width or token-exact
301
+ * proportion. A guide reserves the RIGHT half of its column only: the hairline is drawn
302
+ * at that column's right edge, which is the exact share the guide names, over whatever
303
+ * colour the left half holds, so a guide costs one half-cell and erases no category.
304
+ */
305
+ export function foldBarCells(model: FoldBarModel, width = FOLD_BAR_WIDTH): Array<FoldBarKind | "empty" | "unknown" | "tick"> {
306
+ const samples = 2 * width;
307
+ const filled = Math.max(0, Math.min(samples, Math.round((model.share ?? 0) * samples)));
308
+ const ticks = foldBarTicks(model, width);
309
+ const total = FOLD_BAR_KINDS.reduce((sum, kind) => sum + model.mass[kind], 0);
310
+ const cells: Array<FoldBarKind | "empty" | "unknown" | "tick"> = Array.from({ length: samples }, (_, i) =>
311
+ i % 2 === 1 && ticks.has(Math.floor(i / 2)) ? "tick" : i < filled ? "unknown" : "empty");
312
+ const slots = cells.flatMap((kind, i) => kind === "unknown" ? [i] : []);
313
+ if (!model.mapped || total <= 0 || !slots.length) return cells;
314
+ const allocation = FOLD_BAR_KINDS.filter((kind) => model.mass[kind] > 0)
315
+ .map((kind) => ({ kind, quota: model.mass[kind] * slots.length / total, count: 0 }));
316
+ if (slots.length < allocation.length) {
317
+ // At very low occupancy there may be fewer slots than kinds. Keep the largest
318
+ // shares rather than inventing occupancy; ties keep the declared category order.
319
+ for (const item of [...allocation].sort((a, b) => b.quota - a.quota).slice(0, slots.length)) item.count = 1;
320
+ } else {
321
+ for (const item of allocation) item.count = Math.max(1, Math.floor(item.quota));
322
+ let assigned = allocation.reduce((sum, item) => sum + item.count, 0);
323
+ while (assigned !== slots.length) {
324
+ const direction = assigned < slots.length ? 1 : -1;
325
+ const candidates = allocation.filter((item) => direction > 0 || item.count > 1);
326
+ candidates.sort((a, b) => direction * ((b.quota - b.count) - (a.quota - a.count)));
327
+ candidates[0].count += direction;
328
+ assigned += direction;
329
+ }
330
+ }
331
+ let next = 0;
332
+ for (const item of allocation) {
333
+ for (let n = 0; n < item.count; n += 1) cells[slots[next++]] = item.kind;
132
334
  }
133
335
  return cells;
134
336
  }
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"> {
138
- 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;
337
+ /** Each guide stands in the band's own edge cell: the aim in the column whose LEFT edge
338
+ * is the aim share, the commit in the column whose RIGHT edge is the commit share, so
339
+ * the band is the run of cells from one mark to the other inclusive. When both land on
340
+ * one column the commit wins, since it is the one the label also names. */
341
+ export function foldBarTicks(model: FoldBarModel, width = FOLD_BAR_WIDTH): Map<number, "aim" | "commit"> {
342
+ const column = (value: number): number => Math.max(0, Math.min(width - 1, value));
343
+ return new Map([
344
+ [column(Math.round(model.aimShare * width)), "aim"],
345
+ [column(Math.round(model.commitShare * width) - 1), "commit"],
346
+ ]);
143
347
  }
144
-
145
- /** The row without colour, for tests and for a host that strips ANSI. */
146
348
  export function foldBarPlainText(model: FoldBarModel): string {
147
- return renderFoldBar(model, Number.POSITIVE_INFINITY, {
148
- fg: (_c, t) => t, bold: (t) => t,
149
- });
349
+ return renderFoldBar(model, Number.POSITIVE_INFINITY, { fg: (_c, t) => t, bold: (t) => t });
150
350
  }
151
351
 
152
352
  export function renderFoldBar(model: FoldBarModel, width: number, theme: FoldBarTheme): string {
353
+ const cut = (text: string): string => truncateToWidth(text, width, theme.fg("dim", "..."));
354
+ const neutral = (text: string): string => theme.fg("text", text);
355
+ const muted = (text: string): string => theme.fg("muted", text);
153
356
  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),
357
+ if (model.stopped) return cut(`${brand} ${theme.fg("error", theme.bold("FOLDING STOPPED"))}` +
358
+ (model.share === null ? "" : neutral(` · ${Math.round(model.share * 100)}% full`)));
359
+ if (model.share === null) return cut(`${brand} ${muted(`not measured yet · folds automatically at ${Math.round(model.commitShare * 100)}%`)}`);
360
+ const truecolorMode = theme.getColorMode?.() === "truecolor";
361
+ const dark = !lightBackground(theme);
362
+ const shades = foldBarShades(model.palette ?? DEFAULT_FOLD_BAR_PALETTE, dark);
363
+ // Without truecolor the theme's own inks stand in: pinned takes the accent, raw the
364
+ // muted ink, everything compressed or marked the text ink.
365
+ const ink = (kind: FoldBarKind, text: string): string => {
366
+ if (truecolorMode) return truecolor(shades[kind], text);
367
+ return theme.fg(kind === "pinned" ? "accent" : kind === "raw" ? "muted" : "text", text);
171
368
  };
172
369
  const cells = foldBarCells(model);
173
370
  const ticks = foldBarTicks(model);
371
+ // THE TRACK IS SOLID (Shane 2026-09-06, fourth pass): a guide cell on a ░ track was a
372
+ // blank cell with a bracket in it, which read as a hole. With an RGB dim ink the empty
373
+ // track is one shade, the dim ink pulled most of the way to the reference background
374
+ // so it sits where ░ used to, and a bracket on it stands on that same shade. Without
375
+ // an RGB dim ink the ░ texture stays, and the guide cell there is bare.
376
+ const track = trackShade(theme, dark);
377
+ const trackBg = track ? truecolor(track, "").replace("[38;", "[48;") : null;
378
+ const solid = (kind: typeof cells[number]): string => {
379
+ if (kind === "empty") return trackBg ? `${trackBg} \x1b[49m` : theme.fg("dim", "░");
380
+ if (kind === "unknown") return muted("█");
381
+ return ink(kind as FoldBarKind, "█");
382
+ };
383
+ const background = (kind: FoldBarKind | "unknown"): string | null => {
384
+ if (truecolorMode && kind !== "unknown") return truecolor(shades[kind], "").replace("[38;", "[48;");
385
+ // Pi exposes the same theme ink in 256-colour mode, and the accent for a pin there.
386
+ // Convert its foreground escape to background, without assuming an RGB theme or
387
+ // leaking styles into the label.
388
+ const fg = theme.getFgAnsi?.(kind === "pinned" ? "accent" : kind === "raw" || kind === "unknown" ? "muted" : "text") ?? "";
389
+ const extended = /^\x1b\[38;(?:2;\d+;\d+;\d+|5;\d+)m$/.test(fg);
390
+ if (extended) return fg.replace("[38;", "[48;");
391
+ const basic = /^\x1b\[(3[0-7]|9[0-7])m$/.exec(fg);
392
+ return basic ? `\x1b[${Number(basic[1]) + 10}m` : null;
393
+ };
394
+ // THE GUIDES ARE MARKS ON THE AXIS, NOT BREAKS IN THE FILL (Shane 2026-09-06, third
395
+ // pass). A hairline in text ink read as a white bar; a notch cut into the fill read as
396
+ // a boundary between two kinds of content, which a threshold is not. The band is an
397
+ // interval, so a dotted mark stands in each of its edge cells, drawn over the fill's
398
+ // own colour in that colour's own hue pulled toward the background. No hue of its
399
+ // own, since a coloured guide competes with a category; never the warning ink, since
400
+ // folding is automatic and the person has nothing to do about it. On the track the
401
+ // muted ink stands on the track's shade; without truecolor the mark stands on the
402
+ // default background, because the theme's inks are the fill.
403
+ const shadeUnder = (left: typeof cells[number]): string | null =>
404
+ truecolorMode && (FOLD_BAR_KINDS as readonly string[]).includes(left) ? shades[left as FoldBarKind] : null;
405
+ const guide = (column: number, left: typeof cells[number]): string => {
406
+ const glyph = GUIDE_GLYPHS[ticks.get(column) ?? "aim"];
407
+ const under = shadeUnder(left);
408
+ if (under) return `${background(left as FoldBarKind)}${truecolor(guideInk(under, dark), glyph)}\x1b[49m`;
409
+ if (left === "empty" && trackBg) return `${trackBg}${theme.fg("muted", glyph)}\x1b[49m`;
410
+ return theme.fg("muted", glyph);
411
+ };
174
412
  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]]);
413
+ for (let i = 0; i < cells.length; i += 2) {
414
+ const left = cells[i], right = cells[i + 1];
415
+ if (right === "tick") {
416
+ // The mark over the left half's own colour: the guide stands in the band's edge
417
+ // cell and hides no fill. Off the fill it stands on the track.
418
+ bar += guide(i / 2, left);
419
+ continue;
420
+ }
421
+ if (left === right) { bar += solid(left); continue; }
422
+ // One full-height left-half glyph, right colour supplied by the cell background:
423
+ // two colours in one column, never a notch, gap, score, or extra terminal column.
424
+ if (right === "empty") {
425
+ const half = left === "unknown" ? muted("▌") : ink(left as FoldBarKind, "▌");
426
+ bar += trackBg ? `${trackBg}${half}\x1b[49m` : half;
427
+ } else {
428
+ const bg = background(right as FoldBarKind);
429
+ bar += bg ? `${bg}${ink(left as FoldBarKind, "▌")}\x1b[49m` : solid(left);
430
+ }
180
431
  }
181
432
  const pct = Math.round(model.share * 100);
182
433
  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`));
434
+ if (model.staleAfterCommit) parts.push(muted(`${pct}% before the commit`));
435
+ else {
436
+ const when = model.share < model.commitShare ? `commit at ${Math.round(model.commitShare * 100)}%`
437
+ : model.weighed ? "commit held" : "at commit point";
438
+ parts.push(neutral(`${pct}%`), muted(when));
203
439
  }
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", "..."));
440
+ if (model.mapped) {
441
+ // N counts compressed folds. Mark/Pin are accompanying states, not added to N, and
442
+ // they are named in the bar's own order. A kind with nothing to count is not named
443
+ // (Shane 2026-09-06): the row's width is the scarce thing, and "0 Tool" tells a
444
+ // person nothing the bar does not already show by its absence.
445
+ const counts = ([
446
+ ["consolidated", model.foldConsolidations, "Cons."], ["span", model.foldSpans, "Span"],
447
+ ["tool", model.foldTruncations, "Tool"], ["marked", model.stagedMarks, "Mark"],
448
+ ["pinned", model.pinnedRefs, "Pin"],
449
+ ] as const).filter(([, count]) => count > 0).map(([kind, count, word]) => ink(kind, `${count} ${word}`));
450
+ parts.push(counts.length
451
+ ? neutral(`${model.folds} Folds (`) + counts.join(muted(", ")) + neutral(")")
452
+ : neutral(`${model.folds} Folds`));
453
+ if (model.unplacedItems > 0) parts.push(muted(`${model.unplacedItems} not mapped`));
454
+ } else parts.push(muted("mapping"));
455
+ return cut(`${bar} ${parts.join(theme.fg("dim", " · "))}`);
208
456
  }
@@ -29,6 +29,13 @@ import {
29
29
  type ActiveContextThresholds,
30
30
  } from "./lib/policy.ts";
31
31
  import { applyLiveSettings, liveSettingsReachable } from "./lib/live-settings.ts";
32
+ import {
33
+ COLOUR_MAP_NAMES,
34
+ DEFAULT_FOLD_BAR_PALETTE,
35
+ FoldBarPaletteError,
36
+ resolveFoldBarPalette,
37
+ type FoldBarPalette,
38
+ } from "./lib/status-widget.ts";
32
39
 
33
40
  export const DEFAULT_FOLD_SETTINGS_PATH = join(
34
41
  homedir(),
@@ -47,6 +54,11 @@ export interface FoldSettingsFile {
47
54
  toolFoldThreshold?: number;
48
55
  preCommitNotice?: boolean;
49
56
  noticeLeadShare?: number;
57
+ // THE BAR'S COLOURS, stored whole for the same reason the thresholds are (2026-09-06):
58
+ // start and end may not coincide, which is a cross-field invariant, so a partial object
59
+ // would mean whatever the defaults are at read time. Absent means the package default.
60
+ // It is a display preference and moves nothing about folding.
61
+ palette?: FoldBarPalette;
50
62
  }
51
63
 
52
64
  // The four settings, named once. This was a hand-written union until the migration
@@ -63,11 +75,17 @@ const THRESHOLD_FIELDS = [
63
75
  // validation and no relationship to any other value on the screen.
64
76
  const SCALAR_FIELDS = ["toolFoldThreshold", "preCommitNotice", "noticeLeadShare"] as const;
65
77
 
66
- export type FoldSettingId = typeof THRESHOLD_FIELDS[number] | typeof SCALAR_FIELDS[number];
78
+ // The three rows that edit the palette object, each naming the field it moves.
79
+ const PALETTE_FIELDS = { paletteMap: "map", paletteStart: "start", paletteEnd: "end" } as const;
80
+
81
+ export type FoldSettingId = typeof THRESHOLD_FIELDS[number] | typeof SCALAR_FIELDS[number] | keyof typeof PALETTE_FIELDS;
67
82
 
68
83
  function isThresholdField(id: FoldSettingId): id is typeof THRESHOLD_FIELDS[number] {
69
84
  return (THRESHOLD_FIELDS as readonly string[]).includes(id);
70
85
  }
86
+ function isPaletteField(id: FoldSettingId): id is keyof typeof PALETTE_FIELDS {
87
+ return Object.hasOwn(PALETTE_FIELDS, id);
88
+ }
71
89
 
72
90
  // One edit, applied against the WHOLE draft: the merged thresholds object is
73
91
  // re-validated through resolveThresholds so cross-field invariants
@@ -105,6 +123,20 @@ export function applyFoldSettingsEdit(
105
123
  }
106
124
  return { ok: true, draft: { ...draft, toolFoldThreshold: value } };
107
125
  }
126
+ if (isPaletteField(id)) {
127
+ // The palette is re-validated WHOLE through the runtime's own resolver, so the
128
+ // cross-field rule (start and end differ) holds at every saved state.
129
+ const field = PALETTE_FIELDS[id];
130
+ const raw = rawValue.trim();
131
+ const value = field === "map" ? raw : Number(raw);
132
+ try {
133
+ const palette = resolveFoldBarPalette({ ...(draft.palette ?? DEFAULT_FOLD_BAR_PALETTE), [field]: value });
134
+ return { ok: true, draft: { ...draft, palette } };
135
+ } catch (error) {
136
+ if (error instanceof FoldBarPaletteError) return { ok: false, error: error.message };
137
+ throw error;
138
+ }
139
+ }
108
140
  try {
109
141
  const value = Number(rawValue.trim());
110
142
  if ((id === "consolidateAfter" || id === "minFoldChars") && !Number.isSafeInteger(value)) {
@@ -209,10 +241,10 @@ export function readFoldSettingsFile(path: string = DEFAULT_FOLD_SETTINGS_PATH):
209
241
  dropped = true;
210
242
  }
211
243
  for (const key of Object.keys(parsed)) {
212
- if (key === "thresholds" || (SCALAR_FIELDS as readonly string[]).includes(key)) continue;
244
+ if (key === "thresholds" || key === "palette" || (SCALAR_FIELDS as readonly string[]).includes(key)) continue;
213
245
  if (RETIRED_FILE_KEYS.includes(key)) { delete parsed[key]; dropped = true; continue; }
214
246
  return refused(`fold settings file has no ${key} field: the surface is ` +
215
- `thresholds, ${SCALAR_FIELDS.join(", ")}`);
247
+ `thresholds, ${SCALAR_FIELDS.join(", ")}, palette`);
216
248
  }
217
249
  // The two scalars are validated the same way the editor validates them, through the
218
250
  // one path, so a hand-edited file cannot hold a value the screen would refuse.
@@ -232,6 +264,12 @@ export function readFoldSettingsFile(path: string = DEFAULT_FOLD_SETTINGS_PATH):
232
264
  }
233
265
  Object.assign(scalars, applied.draft);
234
266
  }
267
+ // The palette goes through the one resolver the runtime and the editor use, so a
268
+ // hand-edited file cannot hold a map name or a range the screen would refuse.
269
+ if (parsed.palette !== undefined) {
270
+ try { scalars.palette = resolveFoldBarPalette(parsed.palette); }
271
+ catch (error: any) { return refused(`fold settings palette is invalid: ${error?.message ?? error}`); }
272
+ }
235
273
  if (parsed.thresholds === undefined) {
236
274
  if (dropped) { try { saveFoldSettingsFile(path, scalars); } catch { } }
237
275
  return { settings: scalars, refusal: null, migrated: dropped };
@@ -271,6 +309,7 @@ export function saveFoldSettingsFile(path: string, settings: FoldSettingsFile):
271
309
  if (settings.toolFoldThreshold !== undefined) clean.toolFoldThreshold = settings.toolFoldThreshold;
272
310
  if (settings.preCommitNotice !== undefined) clean.preCommitNotice = settings.preCommitNotice;
273
311
  if (settings.noticeLeadShare !== undefined) clean.noticeLeadShare = settings.noticeLeadShare;
312
+ if (settings.palette !== undefined) clean.palette = settings.palette;
274
313
  mkdirSync(dirname(path), { recursive: true });
275
314
  const temporary = `${path}.tmp`;
276
315
  writeFileSync(temporary, `${JSON.stringify(clean, null, 2)}\n`);
@@ -297,6 +336,12 @@ const EDITOR_ROWS: readonly EditorRow[] = [
297
336
  { id: "toolFoldThreshold", label: "Clip old tool results", description: "The oldest share of the window shows tool results shortened, still recoverable in full. Off keeps them whole (toolFoldThreshold)" },
298
337
  { id: "preCommitNotice", label: "Tell the model before folding", description: "Show the model the window's status once as a fold approaches: what is staged, what is pinned, what it can change. Off is what the measured runs used (preCommitNotice)" },
299
338
  { id: "noticeLeadShare", label: "How early to tell it", description: "How far below the folding point that status appears. Only used when the notice is on (noticeLeadShare)" },
339
+ // THE BAR'S COLOURS ARE A CHOICE (Shane 2026-09-06). Three rows over one stored object:
340
+ // which map, and where on it the two ends of the ramp sit. The status line repaints on
341
+ // every step, so the bar itself is the preview.
342
+ { id: "paletteMap", label: "Bar colours", description: "The colour map the status bar reads its six shades from, most compressed first and pinned last (palette.map)" },
343
+ { id: "paletteStart", label: "Folded shade at", description: "Where on that map the most compressed content sits. Put it past the pinned end to run the map backwards (palette.start)" },
344
+ { id: "paletteEnd", label: "Pinned shade at", description: "Where on that map pinned content sits, with raw just before it. The shades between are spaced evenly (palette.end)" },
300
345
  ];
301
346
 
302
347
  // The cycle lattice for both shares. Shares step in cents so no float drift reaches a
@@ -326,6 +371,9 @@ const MIN_FOLD_CHOICES = [2_000, 4_000, 6_000, 8_000, 12_000, 16_000, 24_000, 32
326
371
  // off: without a 0 here the only way to decline in-view clipping would be the file.
327
372
  const TOOL_FOLD_CHOICES = [0, 0.25, 0.35, 0.50, 0.65, 0.75, 0.90];
328
373
 
374
+ // Positions on a colour map, the whole of it in twentieths, ends included.
375
+ const MAP_POSITION_CHOICES = Array.from({ length: 21 }, (_, i) => i / 20);
376
+
329
377
  function shareCandidates(): number[] {
330
378
  const { min, max, step } = SHARE_LATTICE;
331
379
  const values: number[] = [];
@@ -337,8 +385,13 @@ function shareCandidates(): number[] {
337
385
 
338
386
  // The lattice a row steps along, as numbers. Display belongs to rowDisplayValue and
339
387
  // only to it: this list is stepped, never shown.
340
- function allowedValues(id: FoldSettingId, thresholds: ActiveContextThresholds): number[] {
388
+ function allowedValues(id: FoldSettingId, thresholds: ActiveContextThresholds, palette: FoldBarPalette): number[] {
341
389
  if (id === "toolFoldThreshold") return TOOL_FOLD_CHOICES;
390
+ // Each end of the ramp may step anywhere on the map except onto the other end: the
391
+ // cross-field rule is the filter, so stepping across the other end is one keypress.
392
+ if (id === "paletteStart") return MAP_POSITION_CHOICES.filter((v) => Math.abs(v - palette.end) > 1e-9);
393
+ if (id === "paletteEnd") return MAP_POSITION_CHOICES.filter((v) => Math.abs(v - palette.start) > 1e-9);
394
+ if (id === "paletteMap") return [];
342
395
  // THE LEAD STEPS THE ONE SHARE LATTICE, unfiltered. It answers to nothing but its own
343
396
  // range: a lead wider than the trigger is legal and means "speak from the first
344
397
  // measurement", which the runtime floors rather than refuses, so there is no
@@ -359,6 +412,7 @@ function rowRawValue(settings: FoldSettingsFile, id: FoldSettingId): string {
359
412
  // A SCALAR THAT IS ABSENT READS AS THE PACKAGE DEFAULT, never as zero or false. The
360
413
  // file omits what was never set on purpose, so the screen has to supply the same
361
414
  // value the runtime would, or the row would show a setting nobody chose.
415
+ if (isPaletteField(id)) return String((settings.palette ?? DEFAULT_FOLD_BAR_PALETTE)[PALETTE_FIELDS[id]]);
362
416
  if (!isThresholdField(id)) {
363
417
  if (id === "toolFoldThreshold") return String(settings.toolFoldThreshold ?? DEFAULT_TOOL_FOLD_THRESHOLD);
364
418
  if (id === "noticeLeadShare") return String(settings.noticeLeadShare ?? DEFAULT_NOTICE_LEAD_SHARE);
@@ -384,6 +438,13 @@ function rowDisplayValue(settings: FoldSettingsFile, id: FoldSettingId, budgetTo
384
438
  }
385
439
  if (id === "consolidateAfter") return raw;
386
440
  if (id === "minFoldChars") return `${Number(raw).toLocaleString("en-US")} characters`;
441
+ if (id === "paletteMap") return raw;
442
+ if (id === "paletteStart" || id === "paletteEnd") {
443
+ // A POSITION ON THE MAP, not a share of the window: the percentages elsewhere on this
444
+ // screen are occupancy, and this one is not, so it says what it is along.
445
+ const position = Number(raw);
446
+ return position === 0 ? "start of the map" : position === 1 ? "end of the map" : `${Math.round(position * 100)}% along the map`;
447
+ }
387
448
  // A PERCENTAGE, like every other human surface. The screen used to read "0.80" while
388
449
  // the status line, /fold-status and the editor header all said "80%", so the one place
389
450
  // a person CHANGES the number spoke a different dialect from the three that report it.
@@ -539,8 +600,14 @@ export class FoldSettingsEditor extends Container {
539
600
  this.applyAndSave(id, rowRawValue(this.draft, id) === "true" ? "false" : "true");
540
601
  return;
541
602
  }
603
+ // A NAMED LIST steps by index, clamped at its ends like every other row.
604
+ if (id === "paletteMap") {
605
+ const at = (COLOUR_MAP_NAMES as readonly string[]).indexOf(rowRawValue(this.draft, id)) + direction;
606
+ if (at >= 0 && at < COLOUR_MAP_NAMES.length) this.applyAndSave(id, COLOUR_MAP_NAMES[at]);
607
+ return;
608
+ }
542
609
  const thresholds = this.draft.thresholds ?? DEFAULT_THRESHOLDS;
543
- const candidates = allowedValues(id, thresholds);
610
+ const candidates = allowedValues(id, thresholds, this.draft.palette ?? DEFAULT_FOLD_BAR_PALETTE);
544
611
  const current = Number(rowRawValue(this.draft, id));
545
612
  const target = direction > 0
546
613
  ? candidates.find((candidate) => candidate > current + 1e-9)
@@ -595,7 +662,7 @@ export function registerFoldSettings(
595
662
  ): void {
596
663
  const settingsPath = options.settingsPath ?? DEFAULT_FOLD_SETTINGS_PATH;
597
664
  pi.registerCommand("fold-settings", {
598
- description: "Configure pi-fold: commit band, consolidation, fold size, tool-result clipping, brief invitation",
665
+ description: "Configure pi-fold: commit band, consolidation, fold size, tool-result clipping, the pre-commit notice, bar colours",
599
666
  handler: async (_args: string, ctx: any) => {
600
667
  if (typeof ctx.ui?.custom !== "function") {
601
668
  throw new Error("/fold-settings needs an interactive UI; set thresholds in the settings file instead");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-fold",
3
- "version": "4.1.4",
3
+ "version": "4.1.6",
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",