pi-fold 4.2.3 → 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 +5 -1
- package/extensions/active-context.ts +107 -4
- package/extensions/lib/transcript-mirror.ts +207 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -40,12 +40,16 @@ 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
|
|
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
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
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.
|
|
52
|
+
|
|
49
53
|
## Results
|
|
50
54
|
|
|
51
55
|
One 64-stage assignment over the curl C repository, run as a single agentic turn. Same model, same 251,520-token serving budget, every run. After all 64 stages landed, one withheld message asked sixteen questions about material from across the whole session.
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
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
|
|
744
|
-
*
|
|
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
860
|
const foldBarUsage = (ctx: any): Pick<FoldBarModel, "share" | "percent" | "commitShare" | "aimShare"> => {
|
|
761
|
-
|
|
762
|
-
try { usage = ctx.getContextUsage?.(); } catch { }
|
|
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 =>
|
|
@@ -787,12 +886,14 @@ export function registerActiveContext(pi: any, options: {
|
|
|
787
886
|
foldBar.installed = true;
|
|
788
887
|
ctx.ui.setWidget(entryTypePrefix, (tui: any) => {
|
|
789
888
|
foldBar.requestRender = () => { try { tui.requestRender(); } catch { } };
|
|
889
|
+
transcriptMirror.tui = tui;
|
|
790
890
|
return {
|
|
791
891
|
/** The model behind the row, for the lab and the gates. */
|
|
792
892
|
get model(): FoldBarModel | null {
|
|
793
893
|
return foldBar.model ? { ...foldBar.model, ...foldBarUsage(ctx) } : null;
|
|
794
894
|
},
|
|
795
895
|
render(width: number): string[] {
|
|
896
|
+
syncTranscript();
|
|
796
897
|
const model = this.model;
|
|
797
898
|
if (!model) return [];
|
|
798
899
|
try { return [renderFoldBar(model, width, ctx.ui.theme)]; }
|
|
@@ -907,6 +1008,7 @@ export function registerActiveContext(pi: any, options: {
|
|
|
907
1008
|
weighed: ladder.bandTopMeasurement !== null &&
|
|
908
1009
|
ladder.bandTopMeasurement === measurements.lastProviderMeasurement,
|
|
909
1010
|
});
|
|
1011
|
+
planTranscript(ctx);
|
|
910
1012
|
foldBar.requestRender?.();
|
|
911
1013
|
}
|
|
912
1014
|
} catch { }
|
|
@@ -5071,6 +5173,7 @@ export function registerActiveContext(pi: any, options: {
|
|
|
5071
5173
|
try { ctx.ui?.setStatus?.(entryTypePrefix, undefined); } catch { }
|
|
5072
5174
|
foldBar.model = null;
|
|
5073
5175
|
foldBar.startupSnapshot = null;
|
|
5176
|
+
restoreTranscript();
|
|
5074
5177
|
foldBar.requestRender?.();
|
|
5075
5178
|
});
|
|
5076
5179
|
|
|
@@ -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
|
+
}
|