pi-fold 4.2.3 → 4.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md
CHANGED
|
@@ -27,7 +27,7 @@ Compaction replaces the transcript with a summary and discards the originals. Fo
|
|
|
27
27
|
<img alt="Compaction turns the transcript into a summary and discards the originals, so the session starts over. pi-fold turns it into a brief and keeps the exact originals on disk, one peek or expand away, so the session keeps going." src="https://raw.githubusercontent.com/shaneconner/fold/main/media/fold-vs-compaction-light.svg">
|
|
28
28
|
</picture>
|
|
29
29
|
|
|
30
|
-
A fold takes a contiguous span of session entries and replaces it, in the window only, with a short brief. The entries
|
|
30
|
+
A fold takes a contiguous span of session entries and replaces it, in the window only, with a short brief. The entries stay in Pi's session file byte for byte; the fold finds them by Pi's own entry ids and records each one's SHA-256 hash when it folds. The brief carries a handle, the handle resolves to the originals, and expansion restores them as Pi holds them. If an entry's bytes no longer match the recorded hash, the fold still stands and the change is logged as a `context.drift` event.
|
|
31
31
|
|
|
32
32
|
The package registers one tool, `pi_fold_context`, with nine actions over the transcript: `status`, `peek`, `brief`, `expand`, `refold`, `pin`, `unpin`, `reboundary` and `unmark`. A session that never calls the tool still folds; it degrades into lossless hierarchical compaction.
|
|
33
33
|
|
|
@@ -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 {
|
|
@@ -51,6 +52,7 @@ import {
|
|
|
51
52
|
latestProviderContextMeasurement,
|
|
52
53
|
orderedRoots,
|
|
53
54
|
parseNativeCompactionCompletion,
|
|
55
|
+
contentDrifted,
|
|
54
56
|
exactMapped,
|
|
55
57
|
foldInterval,
|
|
56
58
|
parseNativeCompactionDecision,
|
|
@@ -206,6 +208,12 @@ import type {
|
|
|
206
208
|
import { buildActiveContextCommands, buildActiveContextTool } from "./lib/tool-surface.ts";
|
|
207
209
|
import { buildFoldEditorData, FoldEditorView } from "./lib/editor-ui.ts";
|
|
208
210
|
import { publishLiveSettings } from "./lib/live-settings.ts";
|
|
211
|
+
import {
|
|
212
|
+
foldedChildren,
|
|
213
|
+
locateChatContainer,
|
|
214
|
+
type MirrorFold,
|
|
215
|
+
unfoldedChildren,
|
|
216
|
+
} from "./lib/transcript-mirror.ts";
|
|
209
217
|
import {
|
|
210
218
|
entryText,
|
|
211
219
|
mapActiveContext,
|
|
@@ -740,8 +748,8 @@ export function registerActiveContext(pi: any, options: {
|
|
|
740
748
|
* THE FOLD BAR. One coloured composition row directly above the footer,
|
|
741
749
|
* drawn by `renderFoldBar` from a model this function rebuilds on every status update.
|
|
742
750
|
* The component reads Pi's context usage and the LIVE theme at render time. Neither
|
|
743
|
-
* reading
|
|
744
|
-
*
|
|
751
|
+
* reading waits for a status event; the host is asked to render after each rebuild.
|
|
752
|
+
* Installed once per host UI, on the first update that finds one.
|
|
745
753
|
*/
|
|
746
754
|
const foldBar = {
|
|
747
755
|
model: null as FoldBarModel | null,
|
|
@@ -752,14 +760,106 @@ export function registerActiveContext(pi: any, options: {
|
|
|
752
760
|
staleSince: null as unknown,
|
|
753
761
|
/** Display-only reconstruction while waiting for the first real context event. */
|
|
754
762
|
startupSnapshot: null as ActiveContextSnapshot | null,
|
|
763
|
+
/** Pi's last usage reading and the session state it was read against. */
|
|
764
|
+
usageKey: null as unknown[] | null,
|
|
765
|
+
usage: undefined as unknown,
|
|
766
|
+
};
|
|
767
|
+
// MIRROR THE FOOTER'S READING, ON THE FOOTER'S KEY. getContextUsage rebuilds the whole
|
|
768
|
+
// session projection per call (29 ms on a 16,218-entry session) and the bar renders
|
|
769
|
+
// every frame. Pi's own footer re-reads only when the session, session id, leaf, entry
|
|
770
|
+
// count or model moves (FooterComponent.getSessionStats), since entries are append-only
|
|
771
|
+
// and every append moves the leaf. Read on exactly those moves and we show the same
|
|
772
|
+
// value at the same moments, without paying for it per frame.
|
|
773
|
+
const piContextUsage = (ctx: any): unknown => {
|
|
774
|
+
let key: unknown[] | null = null;
|
|
775
|
+
try {
|
|
776
|
+
// No leaf to key on means no way to know the reading moved: read every time.
|
|
777
|
+
const manager = ctx.sessionManager;
|
|
778
|
+
if (typeof manager?.getLeafId !== "function") throw new Error("no session leaf");
|
|
779
|
+
key = [manager, manager.getSessionId?.(), manager.getLeafId(),
|
|
780
|
+
manager.getEntryCount?.(), ctx.model];
|
|
781
|
+
const cached = foldBar.usageKey;
|
|
782
|
+
if (cached && cached.length === key.length && cached.every((value, i) => value === key![i])) {
|
|
783
|
+
return foldBar.usage;
|
|
784
|
+
}
|
|
785
|
+
} catch { key = null; }
|
|
786
|
+
let usage: unknown;
|
|
787
|
+
try { usage = ctx.getContextUsage?.(); } catch { }
|
|
788
|
+
foldBar.usageKey = key;
|
|
789
|
+
foldBar.usage = usage;
|
|
790
|
+
return usage;
|
|
791
|
+
};
|
|
792
|
+
/**
|
|
793
|
+
* THE CHAT SHOWS THE MODEL'S WINDOW (2026-10-04, lib/transcript-mirror.ts). The PLAN
|
|
794
|
+
* is the folds the model holds collapsed, rebuilt on every status update and keyed by
|
|
795
|
+
* which folds those are, so a commit, an expansion and a refold each move it. The
|
|
796
|
+
* chat is re-folded only when the key moved or Pi repainted the chat (a repaint swaps
|
|
797
|
+
* the container's children array), checked on the fold bar's frame: two comparisons
|
|
798
|
+
* per frame, one pass over the chat per change. DISPLAY ONLY: it reads runtime state
|
|
799
|
+
* and writes nothing but Pi's chat container, and a failure leaves Pi's own transcript.
|
|
800
|
+
*/
|
|
801
|
+
const transcriptMirror = {
|
|
802
|
+
tui: null as unknown,
|
|
803
|
+
chat: null as { children: unknown[] } | null,
|
|
804
|
+
ctx: null as any,
|
|
805
|
+
plan: [] as MirrorFold[],
|
|
806
|
+
planKey: "",
|
|
807
|
+
applied: null as unknown[] | null,
|
|
808
|
+
appliedKey: "",
|
|
809
|
+
};
|
|
810
|
+
const planTranscript = (ctx: any): void => {
|
|
811
|
+
transcriptMirror.ctx = ctx;
|
|
812
|
+
const state = persistence.state;
|
|
813
|
+
let snapshot = lifecycle.latestSnapshot ?? foldBar.startupSnapshot;
|
|
814
|
+
try { snapshot = authoritativeSnapshotFor(ctx); } catch { }
|
|
815
|
+
const folds = state && snapshot ? visibleCollapsedFolds(state, snapshot) : [];
|
|
816
|
+
const key = folds.map((fold) => fold.id).join(",");
|
|
817
|
+
if (key === transcriptMirror.planKey) return;
|
|
818
|
+
const plan = folds.map((fold) => {
|
|
819
|
+
const row = foldIndexRow(fold, state!, snapshot!);
|
|
820
|
+
return {
|
|
821
|
+
id: fold.id,
|
|
822
|
+
kind: String(row.kind ?? ""),
|
|
823
|
+
brief: String(row.brief ?? ""),
|
|
824
|
+
sourceCount: Number(row.sourceCount ?? 0),
|
|
825
|
+
entryIds: flattenFoldRefs(fold, state!).map((ref) => ref.entryId),
|
|
826
|
+
};
|
|
827
|
+
});
|
|
828
|
+
transcriptMirror.plan = plan;
|
|
829
|
+
transcriptMirror.planKey = key;
|
|
830
|
+
};
|
|
831
|
+
const syncTranscript = (): void => {
|
|
832
|
+
const mirror = transcriptMirror;
|
|
833
|
+
if (!mirror.tui || !mirror.ctx) return;
|
|
834
|
+
try {
|
|
835
|
+
mirror.chat ??= locateChatContainer(mirror.tui);
|
|
836
|
+
const chat = mirror.chat;
|
|
837
|
+
if (!chat) return;
|
|
838
|
+
if (chat.children === mirror.applied && mirror.planKey === mirror.appliedKey) return;
|
|
839
|
+
// Settle the attempt BEFORE making it: a failure is retried when something moves,
|
|
840
|
+
// never on every frame.
|
|
841
|
+
mirror.appliedKey = mirror.planKey;
|
|
842
|
+
mirror.applied = chat.children;
|
|
843
|
+
const theme = () => mirror.ctx?.ui?.theme;
|
|
844
|
+
const next = foldedChildren(chat.children, mirror.ctx.sessionManager.getBranch(), mirror.plan,
|
|
845
|
+
(text) => { try { return theme()?.fg?.("muted", text) ?? text; } catch { return text; } });
|
|
846
|
+
chat.children = next;
|
|
847
|
+
mirror.applied = next;
|
|
848
|
+
foldBar.requestRender?.();
|
|
849
|
+
} catch { }
|
|
850
|
+
};
|
|
851
|
+
const restoreTranscript = (): void => {
|
|
852
|
+
const chat = transcriptMirror.chat;
|
|
853
|
+
try { if (chat) chat.children = unfoldedChildren(chat.children); } catch { }
|
|
854
|
+
Object.assign(transcriptMirror, { chat: null, ctx: null, plan: [], planKey: "",
|
|
855
|
+
applied: null, appliedKey: "" });
|
|
755
856
|
};
|
|
756
857
|
// DISPLAY ONLY. Pi owns the live reading, including its unmeasured trailing messages
|
|
757
858
|
// and post-compaction unknown state. Never substitute our saved provider measurement
|
|
758
859
|
// or projection estimate. The band remains a share of the SERVING budget: convert its
|
|
759
860
|
// points onto Pi's full-window axis, without changing any scheduling arithmetic.
|
|
760
861
|
const foldBarUsage = (ctx: any): Pick<FoldBarModel, "share" | "percent" | "commitShare" | "aimShare"> => {
|
|
761
|
-
|
|
762
|
-
try { usage = ctx.getContextUsage?.(); } catch { }
|
|
862
|
+
const usage = piContextUsage(ctx);
|
|
763
863
|
const reportedWindow = ownValue(usage, "contextWindow");
|
|
764
864
|
const modelWindow = ownValue(ctx.model, "contextWindow");
|
|
765
865
|
const validWindow = (value: unknown): value is number =>
|
|
@@ -787,12 +887,14 @@ export function registerActiveContext(pi: any, options: {
|
|
|
787
887
|
foldBar.installed = true;
|
|
788
888
|
ctx.ui.setWidget(entryTypePrefix, (tui: any) => {
|
|
789
889
|
foldBar.requestRender = () => { try { tui.requestRender(); } catch { } };
|
|
890
|
+
transcriptMirror.tui = tui;
|
|
790
891
|
return {
|
|
791
892
|
/** The model behind the row, for the lab and the gates. */
|
|
792
893
|
get model(): FoldBarModel | null {
|
|
793
894
|
return foldBar.model ? { ...foldBar.model, ...foldBarUsage(ctx) } : null;
|
|
794
895
|
},
|
|
795
896
|
render(width: number): string[] {
|
|
897
|
+
syncTranscript();
|
|
796
898
|
const model = this.model;
|
|
797
899
|
if (!model) return [];
|
|
798
900
|
try { return [renderFoldBar(model, width, ctx.ui.theme)]; }
|
|
@@ -907,6 +1009,7 @@ export function registerActiveContext(pi: any, options: {
|
|
|
907
1009
|
weighed: ladder.bandTopMeasurement !== null &&
|
|
908
1010
|
ladder.bandTopMeasurement === measurements.lastProviderMeasurement,
|
|
909
1011
|
});
|
|
1012
|
+
planTranscript(ctx);
|
|
910
1013
|
foldBar.requestRender?.();
|
|
911
1014
|
}
|
|
912
1015
|
} catch { }
|
|
@@ -3294,6 +3397,24 @@ export function registerActiveContext(pi: any, options: {
|
|
|
3294
3397
|
return changed;
|
|
3295
3398
|
};
|
|
3296
3399
|
|
|
3400
|
+
// A FOLDED ENTRY WHOSE BYTES CHANGED (2026-10-05). The fold still stands over it, by Pi's
|
|
3401
|
+
// identity (lib/measurement exactMapped); what changed is recorded once per entry per
|
|
3402
|
+
// process, so the drift is on the record instead of silently unseating the fold.
|
|
3403
|
+
const reportedDrift = new Set<string>();
|
|
3404
|
+
const recordContentDrift = (snapshot: ActiveContextSnapshot): void => {
|
|
3405
|
+
const state = persistence.state;
|
|
3406
|
+
if (!state) return;
|
|
3407
|
+
const drifted: string[] = [];
|
|
3408
|
+
for (const fold of state.folds) {
|
|
3409
|
+
for (const part of fold.parts) {
|
|
3410
|
+
if (part.kind !== "raw" || reportedDrift.has(part.ref.entryId)) continue;
|
|
3411
|
+
if (!contentDrifted(snapshot, part.ref)) continue;
|
|
3412
|
+
reportedDrift.add(part.ref.entryId);
|
|
3413
|
+
drifted.push(part.ref.entryId);
|
|
3414
|
+
}
|
|
3415
|
+
}
|
|
3416
|
+
if (drifted.length) emit("context.drift", { entries: drifted.length, entry_ids: drifted.slice(0, 64) });
|
|
3417
|
+
};
|
|
3297
3418
|
const handleContext = async (event: { messages: unknown[] }, ctx: any) => {
|
|
3298
3419
|
if (lifecycle.shuttingDown) return { messages: event.messages };
|
|
3299
3420
|
beginMutationPass();
|
|
@@ -3310,6 +3431,7 @@ export function registerActiveContext(pi: any, options: {
|
|
|
3310
3431
|
const snapshot = snapshotForEvent(ctx, event.messages);
|
|
3311
3432
|
lifecycle.latestSnapshot = snapshot;
|
|
3312
3433
|
foldBar.startupSnapshot = null;
|
|
3434
|
+
recordContentDrift(snapshot);
|
|
3313
3435
|
if (ladder.automaticFailure) {
|
|
3314
3436
|
ladder.automaticFailure.suppressedCallbacks = Math.min(
|
|
3315
3437
|
Number.MAX_SAFE_INTEGER,
|
|
@@ -5071,6 +5193,7 @@ export function registerActiveContext(pi: any, options: {
|
|
|
5071
5193
|
try { ctx.ui?.setStatus?.(entryTypePrefix, undefined); } catch { }
|
|
5072
5194
|
foldBar.model = null;
|
|
5073
5195
|
foldBar.startupSnapshot = null;
|
|
5196
|
+
restoreTranscript();
|
|
5074
5197
|
foldBar.requestRender?.();
|
|
5075
5198
|
});
|
|
5076
5199
|
|
|
@@ -50,10 +50,23 @@ export function mappedByKey(snapshot: ActiveContextSnapshot): Map<string, Mapped
|
|
|
50
50
|
return indexed;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
// A FOLDED ENTRY IS FOUND BY PI'S IDENTITY, NOT BY ITS BYTES (2026-10-05). Pi names every
|
|
54
|
+
// entry (session, entry id, role) and the transcript it hands back is the truth for that
|
|
55
|
+
// entry. This also required the content digest recorded at fold time to match, and one
|
|
56
|
+
// assistant message in session 01a10834 hashed differently live than as persisted (1 of
|
|
57
|
+
// 3,686 refs, cause unidentified): on resume its root could not be seated, the commit
|
|
58
|
+
// that followed erased the root's 377 folds, and the request went out at ~1.49M tokens
|
|
59
|
+
// against a 1M window. A fold over an entry whose bytes changed now stands over Pi's
|
|
60
|
+
// current bytes; `contentDrifted` names the case so it is recorded rather than silent.
|
|
53
61
|
export function exactMapped(snapshot: ActiveContextSnapshot, ref: EvidenceRef): MappedMessage | null {
|
|
54
62
|
const item = mappedByKey(snapshot).get(objectRefKey(ref));
|
|
55
|
-
return item && sameObjectIdentity(item.ref!, ref)
|
|
56
|
-
|
|
63
|
+
return item && sameObjectIdentity(item.ref!, ref) ? item : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The entry is where the ref says, but its bytes are not the ones it was folded with. */
|
|
67
|
+
export function contentDrifted(snapshot: ActiveContextSnapshot, ref: EvidenceRef): boolean {
|
|
68
|
+
const item = exactMapped(snapshot, ref);
|
|
69
|
+
return item !== null && item.ref!.sha256 !== ref.sha256;
|
|
57
70
|
}
|
|
58
71
|
|
|
59
72
|
export function refsInOrder(snapshot: ActiveContextSnapshot, refs: EvidenceRef[]): number[] | null {
|
|
@@ -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
|
+
}
|