pi-fold 4.2.2 → 4.2.4

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
@@ -40,11 +40,15 @@ In an interactive session one compact, full-height bar sits directly above Pi's
40
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 forty-column bar uses full-height half-blocks for eighty steps of fill, reading Pi's `getContextUsage()` directly on every render. Its percentage uses Pi's full context window, the same scale as Pi's footer, including Pi's estimate of messages since the last provider response. 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.
43
+ The forty-column bar uses full-height half-blocks for eighty steps of fill, reading Pi's `getContextUsage()` directly whenever the session moves, on the same key Pi's footer re-reads on (session, leaf entry, entry count, model), so a repaint never pays for a fresh reading. Its percentage uses Pi's full context window, the same scale as Pi's footer, including Pi's estimate of messages since the last provider response. 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
44
 
45
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. Its `Budget` row and the editor's `Budget` header report the separate serving budget and the tokens remaining before a commit.
46
46
 
47
- Two dotted marks stand in the band's edge cells: one where a commit aims to land and one where automatic folding is triggered. `minTarget` and `maxTarget` remain shares of the serving budget; the marks and displayed commit point are converted to Pi's full-window scale, so the trigger does not move. With a 272,000-token window and the default 255,616-token serving budget, the 80% trigger appears at about 75% of Pi's window. Percentages are rounded to whole numbers; a custom footer may round differently. 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. When Pi reports unknown usage, including immediately after native compaction, the row says `not measured yet`; no saved count or local estimate fills the gap. After a fold 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.
47
+ Two dotted marks stand in the band's edge cells: one where a commit aims to land and one where automatic folding is triggered. `minTarget` and `maxTarget` remain shares of the serving budget; the marks and displayed commit point are converted to Pi's full-window scale, so the trigger does not move. With a 272,000-token window and the default 255,616-token serving budget, the 80% trigger appears at about 75% of Pi's window. The usage label rounds down to a whole percentage, matching pi-cc's footer; bar fill and folding decisions keep the unrounded value. The displayed commit point rounds to the nearest whole percentage. 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. When Pi reports unknown usage, including immediately after native compaction, the row says `not measured yet`; no saved count or local estimate fills the gap. After a fold 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.
48
+
49
+ ### The chat
50
+
51
+ The chat shows the model's window. Pi paints its chat from every session entry and only native compaction clears it, so without this a long folded session would keep its whole transcript on screen. Each fold the model holds collapsed appears as one muted row, `▸ consolidation · 59 entries · <brief>`, in place of the messages it covers. When the model expands a fold, its messages return where they were. The view follows the model one way: it changes when a commit, an expansion or a refold changes what the model sees, or when Pi repaints the chat, and nothing done in the chat changes the model's context. `/fold-editor` remains the place to read inside a fold.
48
52
 
49
53
  ## Results
50
54
 
@@ -35,6 +35,7 @@ import {
35
35
  requireActiveFold,
36
36
  selectAutomaticRung,
37
37
  setFoldProjectionState,
38
+ visibleCollapsedFolds,
38
39
  withExpandLease,
39
40
  } from "./lib/folding.ts";
40
41
  import {
@@ -206,6 +207,12 @@ import type {
206
207
  import { buildActiveContextCommands, buildActiveContextTool } from "./lib/tool-surface.ts";
207
208
  import { buildFoldEditorData, FoldEditorView } from "./lib/editor-ui.ts";
208
209
  import { publishLiveSettings } from "./lib/live-settings.ts";
210
+ import {
211
+ foldedChildren,
212
+ locateChatContainer,
213
+ type MirrorFold,
214
+ unfoldedChildren,
215
+ } from "./lib/transcript-mirror.ts";
209
216
  import {
210
217
  entryText,
211
218
  mapActiveContext,
@@ -740,8 +747,8 @@ export function registerActiveContext(pi: any, options: {
740
747
  * THE FOLD BAR. One coloured composition row directly above the footer,
741
748
  * drawn by `renderFoldBar` from a model this function rebuilds on every status update.
742
749
  * The component reads Pi's context usage and the LIVE theme at render time. Neither
743
- * reading is cached between status events; the host is asked to render after
744
- * each rebuild. Installed once per host UI, on the first update that finds one.
750
+ * reading waits for a status event; the host is asked to render after each rebuild.
751
+ * Installed once per host UI, on the first update that finds one.
745
752
  */
746
753
  const foldBar = {
747
754
  model: null as FoldBarModel | null,
@@ -752,14 +759,106 @@ export function registerActiveContext(pi: any, options: {
752
759
  staleSince: null as unknown,
753
760
  /** Display-only reconstruction while waiting for the first real context event. */
754
761
  startupSnapshot: null as ActiveContextSnapshot | null,
762
+ /** Pi's last usage reading and the session state it was read against. */
763
+ usageKey: null as unknown[] | null,
764
+ usage: undefined as unknown,
765
+ };
766
+ // MIRROR THE FOOTER'S READING, ON THE FOOTER'S KEY. getContextUsage rebuilds the whole
767
+ // session projection per call (29 ms on a 16,218-entry session) and the bar renders
768
+ // every frame. Pi's own footer re-reads only when the session, session id, leaf, entry
769
+ // count or model moves (FooterComponent.getSessionStats), since entries are append-only
770
+ // and every append moves the leaf. Read on exactly those moves and we show the same
771
+ // value at the same moments, without paying for it per frame.
772
+ const piContextUsage = (ctx: any): unknown => {
773
+ let key: unknown[] | null = null;
774
+ try {
775
+ // No leaf to key on means no way to know the reading moved: read every time.
776
+ const manager = ctx.sessionManager;
777
+ if (typeof manager?.getLeafId !== "function") throw new Error("no session leaf");
778
+ key = [manager, manager.getSessionId?.(), manager.getLeafId(),
779
+ manager.getEntryCount?.(), ctx.model];
780
+ const cached = foldBar.usageKey;
781
+ if (cached && cached.length === key.length && cached.every((value, i) => value === key![i])) {
782
+ return foldBar.usage;
783
+ }
784
+ } catch { key = null; }
785
+ let usage: unknown;
786
+ try { usage = ctx.getContextUsage?.(); } catch { }
787
+ foldBar.usageKey = key;
788
+ foldBar.usage = usage;
789
+ return usage;
790
+ };
791
+ /**
792
+ * THE CHAT SHOWS THE MODEL'S WINDOW (2026-10-04, lib/transcript-mirror.ts). The PLAN
793
+ * is the folds the model holds collapsed, rebuilt on every status update and keyed by
794
+ * which folds those are, so a commit, an expansion and a refold each move it. The
795
+ * chat is re-folded only when the key moved or Pi repainted the chat (a repaint swaps
796
+ * the container's children array), checked on the fold bar's frame: two comparisons
797
+ * per frame, one pass over the chat per change. DISPLAY ONLY: it reads runtime state
798
+ * and writes nothing but Pi's chat container, and a failure leaves Pi's own transcript.
799
+ */
800
+ const transcriptMirror = {
801
+ tui: null as unknown,
802
+ chat: null as { children: unknown[] } | null,
803
+ ctx: null as any,
804
+ plan: [] as MirrorFold[],
805
+ planKey: "",
806
+ applied: null as unknown[] | null,
807
+ appliedKey: "",
808
+ };
809
+ const planTranscript = (ctx: any): void => {
810
+ transcriptMirror.ctx = ctx;
811
+ const state = persistence.state;
812
+ let snapshot = lifecycle.latestSnapshot ?? foldBar.startupSnapshot;
813
+ try { snapshot = authoritativeSnapshotFor(ctx); } catch { }
814
+ const folds = state && snapshot ? visibleCollapsedFolds(state, snapshot) : [];
815
+ const key = folds.map((fold) => fold.id).join(",");
816
+ if (key === transcriptMirror.planKey) return;
817
+ const plan = folds.map((fold) => {
818
+ const row = foldIndexRow(fold, state!, snapshot!);
819
+ return {
820
+ id: fold.id,
821
+ kind: String(row.kind ?? ""),
822
+ brief: String(row.brief ?? ""),
823
+ sourceCount: Number(row.sourceCount ?? 0),
824
+ entryIds: flattenFoldRefs(fold, state!).map((ref) => ref.entryId),
825
+ };
826
+ });
827
+ transcriptMirror.plan = plan;
828
+ transcriptMirror.planKey = key;
829
+ };
830
+ const syncTranscript = (): void => {
831
+ const mirror = transcriptMirror;
832
+ if (!mirror.tui || !mirror.ctx) return;
833
+ try {
834
+ mirror.chat ??= locateChatContainer(mirror.tui);
835
+ const chat = mirror.chat;
836
+ if (!chat) return;
837
+ if (chat.children === mirror.applied && mirror.planKey === mirror.appliedKey) return;
838
+ // Settle the attempt BEFORE making it: a failure is retried when something moves,
839
+ // never on every frame.
840
+ mirror.appliedKey = mirror.planKey;
841
+ mirror.applied = chat.children;
842
+ const theme = () => mirror.ctx?.ui?.theme;
843
+ const next = foldedChildren(chat.children, mirror.ctx.sessionManager.getBranch(), mirror.plan,
844
+ (text) => { try { return theme()?.fg?.("muted", text) ?? text; } catch { return text; } });
845
+ chat.children = next;
846
+ mirror.applied = next;
847
+ foldBar.requestRender?.();
848
+ } catch { }
849
+ };
850
+ const restoreTranscript = (): void => {
851
+ const chat = transcriptMirror.chat;
852
+ try { if (chat) chat.children = unfoldedChildren(chat.children); } catch { }
853
+ Object.assign(transcriptMirror, { chat: null, ctx: null, plan: [], planKey: "",
854
+ applied: null, appliedKey: "" });
755
855
  };
756
856
  // DISPLAY ONLY. Pi owns the live reading, including its unmeasured trailing messages
757
857
  // and post-compaction unknown state. Never substitute our saved provider measurement
758
858
  // or projection estimate. The band remains a share of the SERVING budget: convert its
759
859
  // points onto Pi's full-window axis, without changing any scheduling arithmetic.
760
- const foldBarUsage = (ctx: any): Pick<FoldBarModel, "share" | "commitShare" | "aimShare"> => {
761
- let usage: unknown;
762
- try { usage = ctx.getContextUsage?.(); } catch { }
860
+ const foldBarUsage = (ctx: any): Pick<FoldBarModel, "share" | "percent" | "commitShare" | "aimShare"> => {
861
+ const usage = piContextUsage(ctx);
763
862
  const reportedWindow = ownValue(usage, "contextWindow");
764
863
  const modelWindow = ownValue(ctx.model, "contextWindow");
765
864
  const validWindow = (value: unknown): value is number =>
@@ -778,7 +877,8 @@ export function registerActiveContext(pi: any, options: {
778
877
  usedTokens: null,
779
878
  });
780
879
  const budgetShare = capacity.budgetTokens / window;
781
- return { share, commitShare: thresholds.maxTarget * budgetShare,
880
+ return { share, percent: share === null ? null : percent as number,
881
+ commitShare: thresholds.maxTarget * budgetShare,
782
882
  aimShare: thresholds.minTarget * budgetShare };
783
883
  };
784
884
  const installFoldBar = (ctx: any): void => {
@@ -786,12 +886,14 @@ export function registerActiveContext(pi: any, options: {
786
886
  foldBar.installed = true;
787
887
  ctx.ui.setWidget(entryTypePrefix, (tui: any) => {
788
888
  foldBar.requestRender = () => { try { tui.requestRender(); } catch { } };
889
+ transcriptMirror.tui = tui;
789
890
  return {
790
891
  /** The model behind the row, for the lab and the gates. */
791
892
  get model(): FoldBarModel | null {
792
893
  return foldBar.model ? { ...foldBar.model, ...foldBarUsage(ctx) } : null;
793
894
  },
794
895
  render(width: number): string[] {
896
+ syncTranscript();
795
897
  const model = this.model;
796
898
  if (!model) return [];
797
899
  try { return [renderFoldBar(model, width, ctx.ui.theme)]; }
@@ -802,7 +904,8 @@ export function registerActiveContext(pi: any, options: {
802
904
  }, { placement: "belowEditor" });
803
905
  };
804
906
  const foldBarModel = (ctx: any, input: {
805
- share: number | null; commitShare: number; aimShare: number; staged: number; weighed: boolean;
907
+ share: number | null; percent?: number | null;
908
+ commitShare: number; aimShare: number; staged: number; weighed: boolean;
806
909
  }): FoldBarModel => {
807
910
  const state = persistence.state;
808
911
  // THE SAME SNAPSHOT /fold-status PRICES AGAINST. `markFreedBytes` answers ZERO, silently,
@@ -816,6 +919,7 @@ export function registerActiveContext(pi: any, options: {
816
919
  const model: FoldBarModel = {
817
920
  brand: brandNoun,
818
921
  share: input.share,
922
+ percent: input.percent,
819
923
  commitShare: input.commitShare,
820
924
  aimShare: input.aimShare,
821
925
  mapped: false, mass: emptyFoldBarMass(),
@@ -904,6 +1008,7 @@ export function registerActiveContext(pi: any, options: {
904
1008
  weighed: ladder.bandTopMeasurement !== null &&
905
1009
  ladder.bandTopMeasurement === measurements.lastProviderMeasurement,
906
1010
  });
1011
+ planTranscript(ctx);
907
1012
  foldBar.requestRender?.();
908
1013
  }
909
1014
  } catch { }
@@ -914,7 +1019,7 @@ export function registerActiveContext(pi: any, options: {
914
1019
  if (ladder.automaticFailure) parts.push("FOLDING STOPPED");
915
1020
  // Unmeasured is stated, never guessed: an estimate rendered as a fact is the habit
916
1021
  // that cost a live session, and this line is the most-read surface there is.
917
- parts.push(share === null ? "not measured" : `${Math.round(share * 100)}% full`);
1022
+ parts.push(share === null ? "not measured" : `${Math.floor(usage.percent ?? share * 100)}% full`);
918
1023
  // THE LIVE BAND, NOT THE LAST SNAPSHOT'S (2026-08-29). This read the threshold off
919
1024
  // `lifecycle.latestSnapshot`, which is derived per context event, so a band changed
920
1025
  // in /fold-settings went on being announced at its old value until the next event
@@ -5068,6 +5173,7 @@ export function registerActiveContext(pi: any, options: {
5068
5173
  try { ctx.ui?.setStatus?.(entryTypePrefix, undefined); } catch { }
5069
5174
  foldBar.model = null;
5070
5175
  foldBar.startupSnapshot = null;
5176
+ restoreTranscript();
5071
5177
  foldBar.requestRender?.();
5072
5178
  });
5073
5179
 
@@ -254,6 +254,8 @@ export function foldBarShades(palette: FoldBarPalette, dark: boolean): Record<Fo
254
254
  export interface FoldBarModel {
255
255
  brand: string;
256
256
  share: number | null;
257
+ /** Pi's original percent avoids round-trip drift when flooring the usage label. */
258
+ percent?: number | null;
257
259
  commitShare: number;
258
260
  aimShare: number;
259
261
  /** Estimated visible mass, counted exactly once, in the category order above. */
@@ -355,7 +357,7 @@ export function renderFoldBar(model: FoldBarModel, width: number, theme: FoldBar
355
357
  const muted = (text: string): string => theme.fg("muted", text);
356
358
  const brand = theme.fg("dim", model.brand);
357
359
  if (model.stopped) return cut(`${brand} ${theme.fg("error", theme.bold("FOLDING STOPPED"))}` +
358
- (model.share === null ? "" : neutral(` · ${Math.round(model.share * 100)}% full`)));
360
+ (model.share === null ? "" : neutral(` · ${Math.floor(model.percent ?? model.share * 100)}% full`)));
359
361
  if (model.share === null) return cut(`${brand} ${muted(`not measured yet · folds automatically at ${Math.round(model.commitShare * 100)}%`)}`);
360
362
  const truecolorMode = theme.getColorMode?.() === "truecolor";
361
363
  const dark = !lightBackground(theme);
@@ -429,7 +431,7 @@ export function renderFoldBar(model: FoldBarModel, width: number, theme: FoldBar
429
431
  bar += bg ? `${bg}${ink(left as FoldBarKind, "▌")}\x1b[49m` : solid(left);
430
432
  }
431
433
  }
432
- const pct = Math.round(model.share * 100);
434
+ const pct = Math.floor(model.percent ?? model.share * 100);
433
435
  const parts: string[] = [];
434
436
  if (model.staleAfterCommit) parts.push(muted(`${pct}% before the commit`));
435
437
  else {
@@ -0,0 +1,207 @@
1
+ // THE TRANSCRIPT MIRRORS THE MODEL'S WINDOW (2026-10-04).
2
+ //
3
+ // Pi paints the chat from raw session entries and walks every component on every frame,
4
+ // and only a native compaction ever clears it. pi-fold cancels native compaction, so the
5
+ // chat grew for the whole life of a session: 16,218 entries painted per frame on
6
+ // session 01a10161. The model, meanwhile, sees each fold as one placeholder.
7
+ //
8
+ // The chat now shows what the model sees: every fold the model holds collapsed becomes
9
+ // ONE row, and the components it covers leave the chat until the model expands it.
10
+ // Read-only and one-way, as in memex's applyTranscriptFolds: the model's structure moves
11
+ // the view, nothing in the view moves the model. Pi gives an extension no transcript API,
12
+ // so this edits Pi's chat container in place. It never builds a message component: the
13
+ // rows Pi painted stay Pi's own objects, live references and all, and the ones a fold
14
+ // covers are held by its row so an expansion hands them back unchanged.
15
+
16
+ import {
17
+ AssistantMessageComponent,
18
+ CustomMessageComponent,
19
+ ToolExecutionComponent,
20
+ UserMessageComponent,
21
+ } from "@earendil-works/pi-coding-agent";
22
+ import { Spacer, truncateToWidth } from "@earendil-works/pi-tui";
23
+
24
+ export interface MirrorFold {
25
+ id: string;
26
+ kind: string;
27
+ brief: string;
28
+ sourceCount: number;
29
+ /** Every session entry the fold covers, nested folds included. */
30
+ entryIds: string[];
31
+ }
32
+
33
+ interface ChatContainer {
34
+ children: unknown[];
35
+ }
36
+
37
+ /** One collapsed fold in the chat. Holds the components it covers, in order. */
38
+ export class FoldRow {
39
+ readonly foldId: string;
40
+ readonly hidden: unknown[] = [];
41
+ private readonly text: string;
42
+ private readonly style: (text: string) => string;
43
+
44
+ constructor(fold: MirrorFold, style: (text: string) => string) {
45
+ this.foldId = fold.id;
46
+ this.style = style;
47
+ const [head] = fold.brief.split("\n").map((line) => line.trim()).filter(Boolean);
48
+ const count = `${fold.sourceCount} ${fold.sourceCount === 1 ? "entry" : "entries"}`;
49
+ this.text = `\u25b8 ${fold.kind ? `${fold.kind} · ` : ""}${count}${head ? ` · ${head}` : ""}`;
50
+ }
51
+
52
+ render(width: number): string[] {
53
+ return ["", this.style(truncateToWidth(` ${this.text}`, Math.max(1, width)))];
54
+ }
55
+
56
+ invalidate(): void { }
57
+ }
58
+
59
+ /** Pi's chat container: the container holding the most message components directly.
60
+ * Pi moves the same container between its regular and fullscreen hosts, so it is found
61
+ * once. Null until the chat holds a message, which is before any fold exists. */
62
+ export function locateChatContainer(root: unknown): ChatContainer | null {
63
+ let best: ChatContainer | null = null;
64
+ let bestCount = 0;
65
+ const seen = new Set<unknown>();
66
+ const visit = (node: unknown, depth: number): void => {
67
+ const children = (node as { children?: unknown })?.children;
68
+ if (depth > 4 || seen.has(node) || !Array.isArray(children)) return;
69
+ seen.add(node);
70
+ let count = 0;
71
+ for (const child of children) {
72
+ // A message component's own parts are never the chat: do not descend into one.
73
+ if (anchorKind(child) !== null) count += 1;
74
+ else visit(child, depth + 1);
75
+ }
76
+ if (count > bestCount) { best = node as ChatContainer; bestCount = count; }
77
+ };
78
+ visit(root, 0);
79
+ return best;
80
+ }
81
+
82
+ function anchorKind(component: unknown): "assistant" | "tool" | "user" | "custom" | null {
83
+ if (component instanceof AssistantMessageComponent) return "assistant";
84
+ if (component instanceof ToolExecutionComponent) return "tool";
85
+ if (component instanceof UserMessageComponent) return "user";
86
+ if (component instanceof CustomMessageComponent) return "custom";
87
+ return null;
88
+ }
89
+
90
+ function userText(content: unknown): string {
91
+ if (typeof content === "string") return content;
92
+ if (!Array.isArray(content)) return "";
93
+ return content.filter((part) => part?.type === "text").map((part) => String(part.text ?? "")).join("");
94
+ }
95
+
96
+ /** What a component shows, in the terms its session entry can be found by. */
97
+ function componentSignature(component: unknown): string | null {
98
+ const any = component as Record<string, any>;
99
+ switch (anchorKind(component)) {
100
+ case "assistant": return `a:${any.lastMessage?.timestamp}`;
101
+ case "user": return `u:${any.text}`;
102
+ case "custom": return `c:${any.message?.customType}`;
103
+ default: return null;
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Which session entry painted each chat component. Anchors are found by what they show:
109
+ * an assistant message by its timestamp, a tool call by its call id, a user message by
110
+ * its text and a custom message by its type, each searched FORWARD from the last match so
111
+ * a repeated text binds to the right occurrence. Everything else (spacers, notices)
112
+ * belongs to its neighbour: a run of spacers directly before an anchor leads that anchor,
113
+ * anything else trails the anchor before it. Unowned components are never folded.
114
+ */
115
+ export function chatOwners(children: unknown[], branch: any[]): Array<string | null> {
116
+ const positions = new Map<string, number[]>();
117
+ const callOwner = new Map<string, number>();
118
+ branch.forEach((entry, index) => {
119
+ const message = entry?.type === "message" ? entry.message : null;
120
+ let signature: string | null = null;
121
+ if (message?.role === "assistant") {
122
+ signature = `a:${message.timestamp}`;
123
+ for (const part of Array.isArray(message.content) ? message.content : []) {
124
+ if (part?.type === "toolCall" && typeof part.id === "string") callOwner.set(part.id, index);
125
+ }
126
+ } else if (message?.role === "user") signature = `u:${userText(message.content)}`;
127
+ else if (entry?.type === "custom_message") signature = `c:${entry.customType}`;
128
+ if (signature === null) return;
129
+ const list = positions.get(signature);
130
+ if (list) list.push(index); else positions.set(signature, [index]);
131
+ });
132
+ const nextAt = (signature: string, from: number): number => {
133
+ const list = positions.get(signature);
134
+ if (!list) return -1;
135
+ let low = 0;
136
+ let high = list.length;
137
+ while (low < high) {
138
+ const mid = (low + high) >> 1;
139
+ if (list[mid] < from) low = mid + 1; else high = mid;
140
+ }
141
+ return low < list.length ? list[low] : -1;
142
+ };
143
+
144
+ const owners: Array<string | null> = new Array(children.length).fill(null);
145
+ let cursor = 0;
146
+ let previous: string | null = null;
147
+ let pending: number[] = [];
148
+ const settle = (lead: string | null): void => {
149
+ // Spacers immediately before an anchor lead it; the rest trail the previous anchor.
150
+ let split = pending.length;
151
+ while (split > 0 && children[pending[split - 1]] instanceof Spacer) split -= 1;
152
+ for (let i = 0; i < pending.length; i += 1) owners[pending[i]] = i < split ? previous : lead;
153
+ pending = [];
154
+ };
155
+ children.forEach((component, index) => {
156
+ let at = -1;
157
+ if (component instanceof ToolExecutionComponent) {
158
+ at = callOwner.get(String((component as unknown as { toolCallId: unknown }).toolCallId)) ?? -1;
159
+ } else {
160
+ const signature = componentSignature(component);
161
+ if (signature === null) { pending.push(index); return; }
162
+ at = nextAt(signature, cursor);
163
+ }
164
+ const owner = at >= 0 && typeof branch[at]?.id === "string" ? branch[at].id as string : null;
165
+ settle(owner);
166
+ owners[index] = owner;
167
+ if (at >= 0) cursor = Math.max(cursor, at);
168
+ previous = owner;
169
+ });
170
+ settle(previous);
171
+ return owners;
172
+ }
173
+
174
+ /** Hand every row's components back, in place: the chat as Pi painted it. */
175
+ export function unfoldedChildren(children: unknown[]): unknown[] {
176
+ return children.flatMap((child) => child instanceof FoldRow ? child.hidden : [child]);
177
+ }
178
+
179
+ /** The chat with each collapsed fold reduced to its row. A fold none of whose entries
180
+ * are on screen gets no row. */
181
+ export function foldedChildren(
182
+ children: unknown[],
183
+ branch: any[],
184
+ folds: MirrorFold[],
185
+ style: (text: string) => string,
186
+ ): unknown[] {
187
+ const raw = unfoldedChildren(children);
188
+ if (!folds.length) return raw;
189
+ const foldOf = new Map<string, MirrorFold>();
190
+ for (const fold of folds) for (const entryId of fold.entryIds) foldOf.set(entryId, fold);
191
+ const owners = chatOwners(raw, branch);
192
+ const rows = new Map<string, FoldRow>();
193
+ const output: unknown[] = [];
194
+ raw.forEach((component, index) => {
195
+ const owner = owners[index];
196
+ const fold = owner === null ? undefined : foldOf.get(owner);
197
+ if (!fold) { output.push(component); return; }
198
+ let row = rows.get(fold.id);
199
+ if (!row) {
200
+ row = new FoldRow(fold, style);
201
+ rows.set(fold.id, row);
202
+ output.push(row);
203
+ }
204
+ row.hidden.push(component);
205
+ });
206
+ return output;
207
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-fold",
3
- "version": "4.2.2",
3
+ "version": "4.2.4",
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",