ac3c5c58d7b2423fbda2e7052ef8c3c302a7e420
- Author
- TheEdgeOfRage <git@theedgeofrage.com>
- Committer
- TheEdgeOfRage <git@theedgeofrage.com>
- Date
Message
Diff
This diff is truncated to protect this page.
1diff --git a/.gitignore b/.gitignore
2new file mode 100644
3index 0000000000000000000000000000000000000000..c2658d7d1b31848c3b71960543cb0368e56cd4c7
4--- /dev/null
5+++ b/.gitignore
6@@ -0,0 +1 @@
7+node_modules/
8diff --git a/bun.lock b/bun.lock
9new file mode 100644
10index 0000000000000000000000000000000000000000..f8543febda56ff627a3b0ea97af5bfa1d1ffdcd3
11--- /dev/null
12+++ b/bun.lock
13@@ -0,0 +1,296 @@
14+{
15+ "lockfileVersion": 2,
16+ "configVersion": 1,
17+ "workspaces": {
18+ "": {
19+ "name": "pi-tree-pruning",
20+ "devDependencies": {
21+ "@earendil-works/pi-coding-agent": "0.84.2",
22+ "@earendil-works/pi-tui": "0.84.2",
23+ "@types/node": "^24.5.2",
24+ "typescript": "^5.9.2",
25+ },
26+ "peerDependencies": {
27+ "@earendil-works/pi-coding-agent": "^0.84.2",
28+ "@earendil-works/pi-tui": "^0.84.2",
29+ },
30+ },
31+ },
32+ "packages": {
33diff --git a/index.ts b/index.ts
34new file mode 100644
35index 0000000000000000000000000000000000000000..ad3aff71dd5fa0e23b191e6b8716c387aac0e5db
36--- /dev/null
37+++ b/index.ts
38@@ -0,0 +1,264 @@
39+import { basename } from "node:path";
40+import type {
41+ ExtensionAPI,
42+ ExtensionCommandContext,
43+ SessionEntry,
44+} from "@earendil-works/pi-coding-agent";
45+import {
46+ captureSessionFile,
47+ countPruneEntries,
48+ getSessionTreeEntries,
49+ createBackupPath,
50+ listSessionBackups,
51+ pruneSessionFile,
52+ restoreSessionBackup,
53+ toSessionFilePath,
54+ type SessionFilePath,
55+} from "./session-file.ts";
56+import { selectPruneCandidates } from "./prune-selector.ts";
57+import {
58+ findInactiveBranchCandidates,
59+ normalizePruneCandidates,
60+ toEntryId,
61+ type EntryId,
62+} from "./tree-model.ts";
63+import {
64+ buildPruneTreeItems,
65+ formatEntryCount,
66+ formatPruneTreeItem,
67+} from "./tree-view.ts";
68+
69+const COMMAND_NAME = "tree-prune";
70+
71+export default function registerTreePruning(pi: ExtensionAPI): void {
72+ pi.registerCommand(COMMAND_NAME, {
73+ description: "Delete inactive session-tree branches with a backup; use /tree-prune undo to restore",
74+ handler: runTreePruneCommand,
75+ });
76+}
77+
78+export async function runTreePruneCommand(
79+ args: string,
80+ ctx: ExtensionCommandContext,
81+): Promise<void> {
82+ const mode = args.trim();
83+
84+ if (!ctx.hasUI) {
85+ ctx.ui.notify("/tree-prune requires a user interface", "error");
86+ return;
87+ }
88+
89+ if (mode === "") {
90+ await runPrune(ctx);
91+ return;
92+ }
93+
94+ if (mode === "undo") {
95+ await runUndo(ctx);
96+ return;
97+ }
98+
99+ ctx.ui.notify("Usage: /tree-prune [undo]", "error");
100+}
101+
102+async function runPrune(ctx: ExtensionCommandContext): Promise<void> {
103+ try {
104+ await ctx.waitForIdle();
105+
106+ const sessionFile = requireSessionFile(ctx);
107+ const currentLeafId = ctx.sessionManager.getLeafId();
108+ if (!currentLeafId) {
109+ ctx.ui.notify("Tree Prune needs a current active leaf", "warning");
110+ return;
111+ }
112+
113+ const activeLeafId = toEntryId(currentLeafId);
114+ const snapshot = await captureSessionFile(sessionFile);
115+ const entries = getSessionTreeEntries(snapshot);
116+ if (!entries.some((entry) => entry.id === activeLeafId)) {
117+ ctx.ui.notify("Tree Prune cannot find the active leaf in this session file", "error");
118+ return;
119+ }
120+
121+ const candidates = findInactiveBranchCandidates(entries, activeLeafId);
122+ if (candidates.length === 0) {
123+ ctx.ui.notify("Tree Prune found no inactive branches", "info");
124+ return;
125+ }
126+
127+ const treeItems = buildPruneTreeItems(candidates, (entryId) => ctx.sessionManager.getLabel(entryId));
128+ const selectedCandidates = await selectPruneCandidates(ctx, treeItems);
129+ if (!selectedCandidates || selectedCandidates.length === 0) return;
130+
131+ const candidatesToPrune = normalizePruneCandidates(selectedCandidates);
132+ const removedEntryCount = countPruneEntries(candidatesToPrune);
133+ const branchesToPrune = candidatesToPrune.map((candidate) => {
134+ const item = treeItems.find((treeItem) => treeItem.candidate.rootId === candidate.rootId);
135+ return `• ${item ? formatPruneTreeItem(item) : candidate.preview}`;
136+ });
137+ const backupPath = createBackupPath(sessionFile);
138diff --git a/package.json b/package.json
139new file mode 100644
140index 0000000000000000000000000000000000000000..d39e6821f75a4779450573ac12b11f6ee8a511b4
141--- /dev/null
142+++ b/package.json
143@@ -0,0 +1,26 @@
144+{
145+ "name": "pi-tree-pruning",
146+ "private": true,
147+ "version": "0.1.0",
148+ "type": "module",
149+ "packageManager": "bun@1.4.0",
150+ "scripts": {
151+ "typecheck": "tsc --noEmit",
152+ "check": "bun run typecheck"
153+ },
154+ "pi": {
155+ "extensions": [
156+ "./index.ts"
157+ ]
158+ },
159+ "peerDependencies": {
160+ "@earendil-works/pi-coding-agent": "^0.84.2",
161+ "@earendil-works/pi-tui": "^0.84.2"
162+ },
163+ "devDependencies": {
164+ "@earendil-works/pi-coding-agent": "0.84.2",
165+ "@earendil-works/pi-tui": "0.84.2",
166+ "@types/node": "^24.5.2",
167+ "typescript": "^5.9.2"
168+ }
169+}
170diff --git a/prune-selector.ts b/prune-selector.ts
171new file mode 100644
172index 0000000000000000000000000000000000000000..e07ad6dfc25731207d4cc4b0c20be3639f9976fc
173--- /dev/null
174+++ b/prune-selector.ts
175@@ -0,0 +1,186 @@
176+import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
177+import {
178+ pruneCandidatesOverlap,
179+ type BranchCandidate,
180+} from "./tree-model.ts";
181+import { formatPruneTreeItem, type PruneTreeItem } from "./tree-view.ts";
182+
183+export async function selectPruneCandidates(
184+ ctx: ExtensionCommandContext,
185+ items: readonly PruneTreeItem[],
186+): Promise<BranchCandidate[] | undefined> {
187+ if (ctx.mode === "tui") {
188+ return selectWithTreeUi(ctx, items);
189+ }
190+
191+ return selectWithFallback(ctx, items);
192+}
193+
194+async function selectWithTreeUi(
195+ ctx: ExtensionCommandContext,
196+ items: readonly PruneTreeItem[],
197+): Promise<BranchCandidate[] | undefined> {
198+ const { Key, matchesKey, truncateToWidth } = await import("@earendil-works/pi-tui");
199+ const selectedRootIds = await ctx.ui.custom<readonly string[] | undefined>((tui, theme, _keybindings, done) => {
200+ let selectedIndex = 0;
201+ let scrollOffset = 0;
202+ let selectedRootIds = new Set<string>();
203+
204+ const visibleItemCount = (): number => Math.max(1, (tui.terminal.rows ?? 24) - 5);
205+ const keepSelectionVisible = (): void => {
206+ const visibleCount = visibleItemCount();
207+ if (selectedIndex < scrollOffset) scrollOffset = selectedIndex;
208+ if (selectedIndex >= scrollOffset + visibleCount) {
209+ scrollOffset = selectedIndex - visibleCount + 1;
210+ }
211+ };
212+ const moveSelection = (offset: number): void => {
213+ selectedIndex = Math.max(0, Math.min(items.length - 1, selectedIndex + offset));
214+ keepSelectionVisible();
215+ tui.requestRender();
216+ };
217+
218+ return {
219+ handleInput(data: string): void {
220+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
221+ done(undefined);
222+ return;
223+ }
224+ if (matchesKey(data, Key.up)) {
225+ moveSelection(-1);
226+ return;
227+ }
228+ if (matchesKey(data, Key.down)) {
229+ moveSelection(1);
230+ return;
231+ }
232+ if (matchesKey(data, Key.space)) {
233+ const candidate = items[selectedIndex]?.candidate;
234+ if (!candidate) return;
235+ selectedRootIds = togglePruneCandidateSelection(selectedRootIds, candidate, items);
236+ tui.requestRender();
237+ return;
238+ }
239+ if (matchesKey(data, Key.enter) && selectedRootIds.size > 0) {
240+ done([...selectedRootIds]);
241+ }
242+ },
243+ render(width: number): string[] {
244+ const visibleCount = visibleItemCount();
245+ const visibleItems = items.slice(scrollOffset, scrollOffset + visibleCount);
246+ const lines = [
247+ theme.fg("accent", theme.bold("Prune inactive branches:")),
248+ theme.fg(
249+ "dim",
250+ `↑↓ move • space toggle • enter confirm (${formatSelectedBranchCount(selectedRootIds.size)}) • esc cancel`,
251+ ),
252+ ];
253+
254+ if (scrollOffset > 0) lines.push(theme.fg("dim", `… ${scrollOffset} earlier branches`));
255+
256+ for (let index = 0; index < visibleItems.length; index += 1) {
257+ const item = visibleItems[index];
258+ if (!item) continue;
259+
260+ const itemIndex = scrollOffset + index;
261+ const marker = selectedRootIds.has(item.candidate.rootId) ? "[x]" : "[ ]";
262+ const row = `${itemIndex === selectedIndex ? "→" : " "} ${marker} ${item.treePrefix}${formatPruneTreeItem(item)}`;
263+ const rendered = itemIndex === selectedIndex ? theme.fg("accent", theme.bold(row)) : row;
264+ lines.push(rendered);
265+ }
266+
267+ const hiddenAfter = items.length - scrollOffset - visibleItems.length;
268+ if (hiddenAfter > 0) lines.push(theme.fg("dim", `… ${hiddenAfter} later branches`));
269+
270+ return lines.map((line) => truncateToWidth(line, width));
271+ },
272+ invalidate(): void {},
273+ };
274+ });
275diff --git a/session-file.ts b/session-file.ts
276new file mode 100644
277index 0000000000000000000000000000000000000000..e665d251c1823e08cdffdf57b5b84ced6ec7bbe4
278--- /dev/null
279+++ b/session-file.ts
280@@ -0,0 +1,338 @@
281+import { randomUUID } from "node:crypto";
282+import { chmod, link, lstat, open, readdir, readFile, rename, unlink } from "node:fs/promises";
283+import { basename, dirname, join, resolve } from "node:path";
284+import {
285+ toEntryId,
286+ treeEntryFromUnknown,
287+ type BranchCandidate,
288+ type EntryId,
289+ type TreeEntry,
290+} from "./tree-model.ts";
291+
292+declare const sessionFilePathBrand: unique symbol;
293+
294+export type SessionFilePath = string & { readonly [sessionFilePathBrand]: "SessionFilePath" };
295+
296+export interface FileIdentity {
297+ readonly dev: number;
298+ readonly ino: number;
299+ readonly mode: number;
300+ readonly mtimeMs: number;
301+ readonly size: number;
302+}
303+
304+export interface SessionFileSnapshot {
305+ readonly path: SessionFilePath;
306+ readonly content: string;
307+ readonly identity: FileIdentity;
308+}
309+
310+interface RawLine {
311+ readonly content: string;
312+ readonly record?: Record<string, unknown>;
313+}
314+
315+export function toSessionFilePath(path: string): SessionFilePath {
316+ return resolve(path) as SessionFilePath;
317+}
318+
319+export async function captureSessionFile(sessionFile: string): Promise<SessionFileSnapshot> {
320+ const path = toSessionFilePath(sessionFile);
321+
322+ for (let attempt = 0; attempt < 2; attempt += 1) {
323+ const before = await getFileIdentity(path);
324+ const content = await readFile(path, "utf8");
325+ const after = await getFileIdentity(path);
326+
327+ if (sameIdentity(before, after) && Buffer.byteLength(content) === after.size) {
328+ return { path, content, identity: after };
329+ }
330+ }
331+
332+ throw new Error("Session file changed while it was read");
333+}
334+
335+export async function assertSessionFileUnchanged(snapshot: SessionFileSnapshot): Promise<void> {
336+ const current = await captureSessionFile(snapshot.path);
337+ if (!sameIdentity(snapshot.identity, current.identity) || snapshot.content !== current.content) {
338+ throw new Error("Session file changed while the dialog was open");
339+ }
340+}
341+
342+export function getSessionTreeEntries(snapshot: SessionFileSnapshot): TreeEntry[] {
343+ const entries: TreeEntry[] = [];
344+ for (const rawLine of splitRawLines(snapshot.content)) {
345+ const entry = treeEntryFromUnknown(rawLine.record);
346+ if (entry) entries.push(entry);
347+ }
348+ return entries;
349+}
350+
351+export function buildPrunedSessionContent(
352+ snapshot: SessionFileSnapshot,
353+ candidates: readonly BranchCandidate[],
354+): string {
355+ const expectedEntries = collectPruneEntries(candidates);
356+ const rawLines = splitRawLines(snapshot.content);
357+ const linesByEntryId = new Map<EntryId, number[]>();
358+
359+ for (let index = 0; index < rawLines.length; index += 1) {
360+ const rawLine = rawLines[index];
361+ if (!rawLine?.record) continue;
362+
363+ const id = rawLine.record.id;
364+ if (typeof id !== "string" || id.length === 0) continue;
365+
366+ const entryId = toEntryId(id);
367+ if (!expectedEntries.has(entryId)) continue;
368+
369+ const indexes = linesByEntryId.get(entryId) ?? [];
370+ indexes.push(index);
371+ linesByEntryId.set(entryId, indexes);
372+ }
373+
374+ const lineIndexesToRemove = new Set<number>();
375+ for (const [id, entry] of expectedEntries) {
376+ const indexes = linesByEntryId.get(id) ?? [];
377+ if (indexes.length !== 1) {
378+ throw new Error(`Could not safely locate exactly one JSONL line for entry ${id}`);
379+ }
380diff --git a/tree-model.ts b/tree-model.ts
381new file mode 100644
382index 0000000000000000000000000000000000000000..429945c7d32aeb19ad70b353f68f82908e00f0f4
383--- /dev/null
384+++ b/tree-model.ts
385@@ -0,0 +1,284 @@
386+import type { SessionEntry } from "@earendil-works/pi-coding-agent";
387+
388+declare const entryIdBrand: unique symbol;
389+
390+export type EntryId = string & { readonly [entryIdBrand]: "EntryId" };
391+
392+interface UnknownTreeEntry {
393+ readonly id: EntryId;
394+ readonly parentId: EntryId | null;
395+ readonly type: string;
396+ readonly message?: unknown;
397+ readonly summary?: unknown;
398+}
399+
400+export type TreeEntry = (SessionEntry & {
401+ readonly id: EntryId;
402+ readonly parentId: EntryId | null;
403+}) | UnknownTreeEntry;
404+
405+export interface BranchCandidate {
406+ readonly rootId: EntryId;
407+ readonly forkPointId: EntryId;
408+ readonly entries: readonly TreeEntry[];
409+ readonly entryIds: readonly EntryId[];
410+ readonly entryCount: number;
411+ readonly preview: string;
412+}
413+
414+interface TreeIndex {
415+ readonly entriesById: ReadonlyMap<EntryId, TreeEntry>;
416+ readonly childrenByParentId: ReadonlyMap<EntryId | null, readonly EntryId[]>;
417+ readonly uncertainChildrenByParentId: ReadonlySet<EntryId>;
418+}
419+
420+const PREVIEW_LIMIT = 72;
421+
422+export function findInactiveBranchCandidates(
423+ entries: readonly TreeEntry[],
424+ activeLeafId: EntryId,
425+): BranchCandidate[] {
426+ const tree = buildTreeIndex(entries);
427+ if (!tree.entriesById.has(activeLeafId)) return [];
428+
429+ const candidates: BranchCandidate[] = [];
430+ for (const [forkPointId, childIds] of tree.childrenByParentId) {
431+ if (childIds.length < 2 || (forkPointId !== null && tree.uncertainChildrenByParentId.has(forkPointId))) continue;
432+
433+ for (const rootId of childIds) {
434+ const branchEntries = collectSubtree(rootId, tree);
435+ if (!branchEntries) continue;
436+ if (branchEntries.some((entry) => entry.id === activeLeafId)) continue;
437+
438+ const root = tree.entriesById.get(rootId);
439+ if (!root) continue;
440+
441+ candidates.push({
442+ rootId,
443+ forkPointId: forkPointId ?? rootId,
444+ entries: branchEntries,
445+ entryIds: branchEntries.map((entry) => entry.id),
446+ entryCount: branchEntries.length,
447+ preview: previewBranch(branchEntries),
448+ });
449+ }
450+ }
451+
452+ return candidates;
453+}
454+
455+export function normalizePruneCandidates(
456+ candidates: readonly BranchCandidate[],
457+): BranchCandidate[] {
458+ const candidatesByRootId = new Map<EntryId, BranchCandidate>();
459+ for (const candidate of candidates) {
460+ if (!candidatesByRootId.has(candidate.rootId)) {
461+ candidatesByRootId.set(candidate.rootId, candidate);
462+ }
463+ }
464+
465+ const uniqueCandidates = [...candidatesByRootId.values()];
466+ return uniqueCandidates.filter(
467+ (candidate) =>
468+ !uniqueCandidates.some(
469+ (possibleParent) =>
470+ possibleParent.rootId !== candidate.rootId &&
471+ possibleParent.entryIds.includes(candidate.rootId),
472+ ),
473+ );
474+}
475+
476+export function pruneCandidatesOverlap(left: BranchCandidate, right: BranchCandidate): boolean {
477+ if (left.rootId === right.rootId) return true;
478+ return left.entryIds.some((entryId) => right.entryIds.includes(entryId));
479+}
480+
481+function buildTreeIndex(entries: readonly TreeEntry[]): TreeIndex {
482+ const validEntries = entries.filter(isTreeEntry);
483+ const occurrences = new Map<EntryId, number>();
484+ const uncertainChildrenByParentId = new Set<EntryId>();
485diff --git a/tree-view.ts b/tree-view.ts
486new file mode 100644
487index 0000000000000000000000000000000000000000..acf6db3e7c8dc38338135e8db22a0fb6e20d3ddb
488--- /dev/null
489+++ b/tree-view.ts
490@@ -0,0 +1,76 @@
491+import type { BranchCandidate } from "./tree-model.ts";
492+
493+export interface PruneTreeItem {
494+ readonly candidate: BranchCandidate;
495+ readonly label: string | undefined;
496+ readonly treePrefix: string;
497+}
498+
499+export function buildPruneTreeItems(
500+ candidates: readonly BranchCandidate[],
501+ getLabel: (entryId: string) => string | undefined,
502+): PruneTreeItem[] {
503+ const parentByRootId = new Map<string, string>();
504+
505+ for (const candidate of candidates) {
506+ const parent = findParentCandidate(candidate, candidates);
507+ if (parent) parentByRootId.set(candidate.rootId, parent.rootId);
508+ }
509+
510+ const childrenByRootId = new Map<string | undefined, BranchCandidate[]>();
511+ for (const candidate of candidates) {
512+ const parentRootId = parentByRootId.get(candidate.rootId);
513+ const children = childrenByRootId.get(parentRootId) ?? [];
514+ children.push(candidate);
515+ childrenByRootId.set(parentRootId, children);
516+ }
517+
518+ const items: PruneTreeItem[] = [];
519+ const visit = (candidate: BranchCandidate, ancestorIsLast: readonly boolean[], isLast: boolean): void => {
520+ const parentRootId = parentByRootId.get(candidate.rootId);
521+ const treePrefix = parentRootId
522+ ? `${ancestorIsLast.map((last) => (last ? " " : "│ ")).join("")}${isLast ? "└─ " : "├─ "}`
523+ : "";
524+
525+ items.push({ candidate, label: getLabel(candidate.rootId), treePrefix });
526+
527+ const children = childrenByRootId.get(candidate.rootId) ?? [];
528+ for (let index = 0; index < children.length; index += 1) {
529+ const child = children[index];
530+ if (!child) continue;
531+ visit(child, [...ancestorIsLast, isLast], index === children.length - 1);
532+ }
533+ };
534+
535+ const roots = childrenByRootId.get(undefined) ?? [];
536+ for (let index = 0; index < roots.length; index += 1) {
537+ const root = roots[index];
538+ if (!root) continue;
539+ visit(root, [], index === roots.length - 1);
540+ }
541+
542+ return items;
543+}
544+
545+export function formatPruneTreeItem(item: PruneTreeItem): string {
546+ const label = item.label?.replace(/\s+/g, " ").trim();
547+ const preview = label ? `[${label}] ${item.candidate.preview}` : item.candidate.preview;
548+ return `${preview} · ${formatEntryCount(item.candidate.entryCount)}`;
549+}
550+
551+export function formatEntryCount(count: number): string {
552+ return `${count} ${count === 1 ? "entry" : "entries"}`;
553+}
554+
555+function findParentCandidate(
556+ candidate: BranchCandidate,
557+ candidates: readonly BranchCandidate[],
558+): BranchCandidate | undefined {
559+ return candidates
560+ .filter(
561+ (possibleParent) =>
562+ possibleParent.rootId !== candidate.rootId &&
563+ possibleParent.entryIds.includes(candidate.forkPointId),
564+ )
565+ .sort((left, right) => left.entryCount - right.entryCount)[0];
566+}
567diff --git a/tsconfig.json b/tsconfig.json
568new file mode 100644
569index 0000000000000000000000000000000000000000..ea2b147fee7ae840125b3ade94280e9d9a8ecbcf
570--- /dev/null
571+++ b/tsconfig.json
572@@ -0,0 +1,18 @@
573+{
574+ "compilerOptions": {
575+ "target": "ESNext",
576+ "module": "NodeNext",
577+ "moduleResolution": "NodeNext",
578+ "lib": ["ESNext"],
579+ "allowImportingTsExtensions": true,
580+ "noEmit": true,
581+ "strict": true,
582+ "exactOptionalPropertyTypes": true,
583+ "noUncheckedIndexedAccess": true,
584+ "noImplicitOverride": true,
585+ "noFallthroughCasesInSwitch": true,
586+ "skipLibCheck": true,
587+ "types": ["node"]
588+ },
589+ "include": ["./*.ts"]
590+}