ac3c5c58d7b2423fbda2e7052ef8c3c302a7e420

Author
TheEdgeOfRage <git@theedgeofrage.com>
Committer
TheEdgeOfRage <git@theedgeofrage.com>
Date

Message

Initial commit

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+}