1450f718ba9b90c04f8e452604c229eec1583fbf

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

Message

Add multi-select to pi-tree-pruning

Diff

This diff is truncated to protect this page.

  1diff --git a/dot_pi/agent/extensions/pi-tree-pruning/index.ts b/dot_pi/agent/extensions/pi-tree-pruning/index.ts
  2index 4a95e8325e9b1634fb628d27e4e8b7734d5d1f85..4e22f76ea2d2db556de5e55cb89b2f61fc885ca5 100644
  3--- a/dot_pi/agent/extensions/pi-tree-pruning/index.ts
  4+++ b/dot_pi/agent/extensions/pi-tree-pruning/index.ts
  5@@ -2,20 +2,25 @@ import { basename } from "node:path";
  6 import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
  7 import {
  8   captureSessionFile,
  9+  countPruneEntries,
 10   createBackupPath,
 11   listSessionBackups,
 12   pruneSessionFile,
 13   restoreSessionBackup,
 14 } from "./session-file.ts";
 15-import { selectPruneCandidate } from "./prune-selector.ts";
 16-import { findInactiveBranchCandidates } from "./tree-model.ts";
 17-import { buildPruneTreeItems, formatEntryCount } from "./tree-view.ts";
 18+import { selectPruneCandidates } from "./prune-selector.ts";
 19+import { findInactiveBranchCandidates, normalizePruneCandidates } from "./tree-model.ts";
 20+import {
 21+  buildPruneTreeItems,
 22+  formatEntryCount,
 23+  formatPruneTreeItem,
 24+} from "./tree-view.ts";
 25 
 26 const COMMAND_NAME = "tree-prune";
 27 
 28 export default function registerTreePruning(pi: ExtensionAPI): void {
 29   pi.registerCommand(COMMAND_NAME, {
 30-    description: "Delete an inactive session-tree branch with a backup; use /tree-prune undo to restore",
 31+    description: "Delete inactive session-tree branches with a backup; use /tree-prune undo to restore",
 32     handler: async (args, ctx) => {
 33       await runTreePruneCommand(args, ctx);
 34     },
 35@@ -71,22 +76,29 @@ async function runPrune(ctx: ExtensionCommandContext): Promise<void> {
 36     }
 37 
 38     const treeItems = buildPruneTreeItems(candidates, (entryId) => ctx.sessionManager.getLabel(entryId));
 39-    const candidate = await selectPruneCandidate(ctx, treeItems);
 40-    if (!candidate) return;
 41-
 42+    const selectedCandidates = await selectPruneCandidates(ctx, treeItems);
 43+    if (!selectedCandidates || selectedCandidates.length === 0) return;
 44+
 45+    const candidatesToPrune = normalizePruneCandidates(selectedCandidates);
 46+    const removedEntryCount = countPruneEntries(candidatesToPrune);
 47+    const branchesToPrune = candidatesToPrune.map((candidate) => {
 48+      const item = treeItems.find((treeItem) => treeItem.candidate.rootId === candidate.rootId);
 49+      return `• ${item ? formatPruneTreeItem(item) : candidate.preview}`;
 50+    });
 51     const backupPath = createBackupPath(sessionFile);
 52     const confirmed = await ctx.ui.confirm(
 53-      "Delete inactive branch?",
 54+      "Delete inactive branches?",
 55       [
 56-        `Branch: ${candidate.preview}`,
 57-        `Entries to delete: ${candidate.entryCount}`,
 58+        "Branches to delete:",
 59+        ...branchesToPrune,
 60+        `Entries to delete: ${formatEntryCount(removedEntryCount)}`,
 61         `Backup: ${backupPath}`,
 62-        "The branch will be removed from this session file.",
 63+        "The branches will be removed from this session file.",
 64       ].join("\n"),
 65     );
 66     if (!confirmed) return;
 67 
 68-    const result = await pruneSessionFile(snapshot, candidate, backupPath);
 69+    const result = await pruneSessionFile(snapshot, candidatesToPrune, backupPath);
 70     const switchResult = await ctx.switchSession(sessionFile, {
 71       withSession: async (replacement) => {
 72         replacement.ui.notify(
 73diff --git a/dot_pi/agent/extensions/pi-tree-pruning/package-lock.json b/dot_pi/agent/extensions/pi-tree-pruning/package-lock.json
 74new file mode 100644
 75index 0000000000000000000000000000000000000000..5602dffa2670211769a814791d54ddca57f2499d
 76--- /dev/null
 77+++ b/dot_pi/agent/extensions/pi-tree-pruning/package-lock.json
 78@@ -0,0 +1,1927 @@
 79+{
 80+  "name": "pi-tree-pruning",
 81+  "version": "0.1.0",
 82+  "lockfileVersion": 3,
 83+  "requires": true,
 84+  "packages": {
 85+    "": {
 86+      "name": "pi-tree-pruning",
 87+      "version": "0.1.0",
 88+      "devDependencies": {
 89+        "@earendil-works/pi-coding-agent": "0.84.2",
 90+        "@earendil-works/pi-tui": "0.84.2",
 91+        "@types/node": "^24.5.2",
 92+        "typescript": "^5.9.2"
 93+      },
 94+      "peerDependencies": {
 95+        "@earendil-works/pi-coding-agent": "^0.84.2",
 96+        "@earendil-works/pi-tui": "^0.84.2"
 97+      }
 98+    },
 99+    "node_modules/@anthropic-ai/sdk": {
100+      "version": "0.91.1",
101+      "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz",
102+      "integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==",
103+      "dev": true,
104+      "license": "MIT",
105+      "dependencies": {
106+        "json-schema-to-ts": "^3.1.1"
107+      },
108+      "bin": {
109+        "anthropic-ai-sdk": "bin/cli"
110+      },
111+      "peerDependencies": {
112+        "zod": "^3.25.0 || ^4.0.0"
113+      },
114+      "peerDependenciesMeta": {
115+        "zod": {
116+          "optional": true
117+        }
118+      }
119+    },
120+    "node_modules/@aws-crypto/sha256-browser": {
121+      "version": "5.2.0",
122+      "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz",
123+      "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==",
124+      "dev": true,
125+      "license": "Apache-2.0",
126+      "dependencies": {
127+        "@aws-crypto/sha256-js": "^5.2.0",
128+        "@aws-crypto/supports-web-crypto": "^5.2.0",
129+        "@aws-crypto/util": "^5.2.0",
130+        "@aws-sdk/types": "^3.222.0",
131+        "@aws-sdk/util-locate-window": "^3.0.0",
132+        "@smithy/util-utf8": "^2.0.0",
133+        "tslib": "^2.6.2"
134+      }
135+    },
136+    "node_modules/@aws-crypto/sha256-js": {
137+      "version": "5.2.0",
138+      "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz",
139+      "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==",
140+      "dev": true,
141+      "license": "Apache-2.0",
142+      "dependencies": {
143+        "@aws-crypto/util": "^5.2.0",
144+        "@aws-sdk/types": "^3.222.0",
145+        "tslib": "^2.6.2"
146+      },
147+      "engines": {
148+        "node": ">=16.0.0"
149+      }
150+    },
151+    "node_modules/@aws-crypto/supports-web-crypto": {
152+      "version": "5.2.0",
153+      "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz",
154+      "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==",
155+      "dev": true,
156+      "license": "Apache-2.0",
157+      "dependencies": {
158+        "tslib": "^2.6.2"
159+      }
160+    },
161+    "node_modules/@aws-crypto/util": {
162+      "version": "5.2.0",
163+      "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz",
164+      "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==",
165+      "dev": true,
166+      "license": "Apache-2.0",
167+      "dependencies": {
168+        "@aws-sdk/types": "^3.222.0",
169+        "@smithy/util-utf8": "^2.0.0",
170+        "tslib": "^2.6.2"
171+      }
172+    },
173+    "node_modules/@aws-sdk/client-bedrock-runtime": {
174+      "version": "3.1048.0",
175+      "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1048.0.tgz",
176+      "integrity": "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==",
177+      "dev": true,
178diff --git a/dot_pi/agent/extensions/pi-tree-pruning/package.json b/dot_pi/agent/extensions/pi-tree-pruning/package.json
179index bbcf59847315555fa7bb05f4482ed9c159ff9815..3763c9b0ac5eb8a67516a13b313a1cd7b20fb86c 100644
180--- a/dot_pi/agent/extensions/pi-tree-pruning/package.json
181+++ b/dot_pi/agent/extensions/pi-tree-pruning/package.json
182@@ -4,9 +4,8 @@
183   "version": "0.1.0",
184   "type": "module",
185   "scripts": {
186-    "test": "node --test test/*.test.ts",
187     "typecheck": "tsc --noEmit",
188-    "check": "npm run typecheck && npm test"
189+    "check": "npm run typecheck"
190   },
191   "pi": {
192     "extensions": [
193@@ -14,10 +13,12 @@
194     ]
195   },
196   "peerDependencies": {
197-    "@earendil-works/pi-coding-agent": "*",
198-    "@earendil-works/pi-tui": "*"
199+    "@earendil-works/pi-coding-agent": "^0.84.2",
200+    "@earendil-works/pi-tui": "^0.84.2"
201   },
202   "devDependencies": {
203+    "@earendil-works/pi-coding-agent": "0.84.2",
204+    "@earendil-works/pi-tui": "0.84.2",
205     "@types/node": "^24.5.2",
206     "typescript": "^5.9.2"
207   }
208diff --git a/dot_pi/agent/extensions/pi-tree-pruning/prune-selector.ts b/dot_pi/agent/extensions/pi-tree-pruning/prune-selector.ts
209index abdacaf07d2e58f793c1d5973796c403177357d4..e07ad6dfc25731207d4cc4b0c20be3639f9976fc 100644
210--- a/dot_pi/agent/extensions/pi-tree-pruning/prune-selector.ts
211+++ b/dot_pi/agent/extensions/pi-tree-pruning/prune-selector.ts
212@@ -1,28 +1,30 @@
213 import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
214-import { type BranchCandidate } from "./tree-model.ts";
215+import {
216+  pruneCandidatesOverlap,
217+  type BranchCandidate,
218+} from "./tree-model.ts";
219 import { formatPruneTreeItem, type PruneTreeItem } from "./tree-view.ts";
220 
221-export async function selectPruneCandidate(
222+export async function selectPruneCandidates(
223   ctx: ExtensionCommandContext,
224   items: readonly PruneTreeItem[],
225-): Promise<BranchCandidate | undefined> {
226+): Promise<BranchCandidate[] | undefined> {
227   if (ctx.mode === "tui") {
228     return selectWithTreeUi(ctx, items);
229   }
230 
231-  const choices = makeUniqueChoices(items);
232-  const selected = await ctx.ui.select("Prune inactive branch:", choices.map((choice) => choice.label));
233-  return choices.find((choice) => choice.label === selected)?.item.candidate;
234+  return selectWithFallback(ctx, items);
235 }
236 
237 async function selectWithTreeUi(
238   ctx: ExtensionCommandContext,
239   items: readonly PruneTreeItem[],
240-): Promise<BranchCandidate | undefined> {
241+): Promise<BranchCandidate[] | undefined> {
242   const { Key, matchesKey, truncateToWidth } = await import("@earendil-works/pi-tui");
243-  const selectedRootId = await ctx.ui.custom<string | undefined>((tui, theme, _keybindings, done) => {
244+  const selectedRootIds = await ctx.ui.custom<readonly string[] | undefined>((tui, theme, _keybindings, done) => {
245     let selectedIndex = 0;
246     let scrollOffset = 0;
247+    let selectedRootIds = new Set<string>();
248 
249     const visibleItemCount = (): number => Math.max(1, (tui.terminal.rows ?? 24) - 5);
250     const keepSelectionVisible = (): void => {
251@@ -52,16 +54,26 @@ async function selectWithTreeUi(
252           moveSelection(1);
253           return;
254         }
255-        if (matchesKey(data, Key.enter)) {
256-          done(items[selectedIndex]?.candidate.rootId);
257+        if (matchesKey(data, Key.space)) {
258+          const candidate = items[selectedIndex]?.candidate;
259+          if (!candidate) return;
260+          selectedRootIds = togglePruneCandidateSelection(selectedRootIds, candidate, items);
261+          tui.requestRender();
262+          return;
263+        }
264+        if (matchesKey(data, Key.enter) && selectedRootIds.size > 0) {
265+          done([...selectedRootIds]);
266         }
267       },
268       render(width: number): string[] {
269         const visibleCount = visibleItemCount();
270         const visibleItems = items.slice(scrollOffset, scrollOffset + visibleCount);
271         const lines = [
272-          theme.fg("accent", theme.bold("Prune inactive branch:")),
273-          theme.fg("dim", "↑↓ move • enter select • esc cancel"),
274+          theme.fg("accent", theme.bold("Prune inactive branches:")),
275+          theme.fg(
276+            "dim",
277+            `↑↓ move • space toggle • enter confirm (${formatSelectedBranchCount(selectedRootIds.size)}) • esc cancel`,
278+          ),
279         ];
280 
281         if (scrollOffset > 0) lines.push(theme.fg("dim", `… ${scrollOffset} earlier branches`));
282@@ -71,7 +83,8 @@ async function selectWithTreeUi(
283           if (!item) continue;
284 
285           const itemIndex = scrollOffset + index;
286-          const row = `${itemIndex === selectedIndex ? "→" : " "} ${item.treePrefix}${formatPruneTreeItem(item)}`;
287+          const marker = selectedRootIds.has(item.candidate.rootId) ? "[x]" : "[ ]";
288+          const row = `${itemIndex === selectedIndex ? "→" : " "} ${marker} ${item.treePrefix}${formatPruneTreeItem(item)}`;
289           const rendered = itemIndex === selectedIndex ? theme.fg("accent", theme.bold(row)) : row;
290           lines.push(rendered);
291         }
292@@ -85,7 +98,77 @@ async function selectWithTreeUi(
293     };
294   });
295 
296-  return items.find((item) => item.candidate.rootId === selectedRootId)?.candidate;
297+  return selectedRootIds ? selectedCandidates(items, new Set(selectedRootIds)) : undefined;
298+}
299+
300+async function selectWithFallback(
301+  ctx: ExtensionCommandContext,
302+  items: readonly PruneTreeItem[],
303+): Promise<BranchCandidate[] | undefined> {
304+  const choices = makeUniqueChoices(items);
305+  let selectedRootIds = new Set<string>();
306+
307+  while (true) {
308+    const selectableChoices = choices.filter(
309+      (choice) =>
310+        !selectedRootIds.has(choice.item.candidate.rootId) &&
311+        ![...selectedRootIds].some((rootId) => {
312diff --git a/dot_pi/agent/extensions/pi-tree-pruning/session-file.ts b/dot_pi/agent/extensions/pi-tree-pruning/session-file.ts
313index 6017175b3d145c0ced784043f1884abe4466dc4b..99b6ec58ce17a0303d5fb3bb00c935544419c01d 100644
314--- a/dot_pi/agent/extensions/pi-tree-pruning/session-file.ts
315+++ b/dot_pi/agent/extensions/pi-tree-pruning/session-file.ts
316@@ -47,18 +47,9 @@ export async function assertSessionFileUnchanged(snapshot: SessionFileSnapshot):
317 
318 export function buildPrunedSessionContent(
319   snapshot: SessionFileSnapshot,
320-  candidate: BranchCandidate,
321+  candidates: readonly BranchCandidate[],
322 ): string {
323-  const expectedEntries = new Map<string, TreeEntry>();
324-  for (const entry of candidate.entries) {
325-    if (expectedEntries.has(entry.id)) {
326-      throw new Error(`Branch has duplicate entry ID ${entry.id}`);
327-    }
328-    expectedEntries.set(entry.id, entry);
329-  }
330-
331-  if (expectedEntries.size === 0) throw new Error("Branch has no entries");
332-
333+  const expectedEntries = collectPruneEntries(candidates);
334   const rawLines = splitRawLines(snapshot.content);
335   const linesByEntryId = new Map<string, number[]>();
336 
337@@ -119,17 +110,22 @@ export async function createSessionBackup(
338   return path;
339 }
340 
341+export function countPruneEntries(candidates: readonly BranchCandidate[]): number {
342+  return collectPruneEntries(candidates).size;
343+}
344+
345 export async function pruneSessionFile(
346   snapshot: SessionFileSnapshot,
347-  candidate: BranchCandidate,
348+  candidates: readonly BranchCandidate[],
349   backupPath = createBackupPath(snapshot.path),
350 ): Promise<{ backupPath: string; removedEntryCount: number }> {
351-  const prunedContent = buildPrunedSessionContent(snapshot, candidate);
352+  const removedEntryCount = countPruneEntries(candidates);
353+  const prunedContent = buildPrunedSessionContent(snapshot, candidates);
354   await assertSessionFileUnchanged(snapshot);
355   const createdBackupPath = await createSessionBackup(snapshot, backupPath);
356   await replaceSessionFile(snapshot, prunedContent);
357 
358-  return { backupPath: createdBackupPath, removedEntryCount: candidate.entryCount };
359+  return { backupPath: createdBackupPath, removedEntryCount };
360 }
361 
362 export async function restoreSessionBackup(
363@@ -259,10 +255,31 @@ function parseRecord(line: string): Record<string, unknown> | undefined {
364   }
365 }
366 
367+function collectPruneEntries(candidates: readonly BranchCandidate[]): Map<string, TreeEntry> {
368+  const entriesById = new Map<string, TreeEntry>();
369+
370+  for (const candidate of candidates) {
371+    for (const entry of candidate.entries) {
372+      const existingEntry = entriesById.get(entry.id);
373+      if (existingEntry && !sameTreeEntry(existingEntry, entry)) {
374+        throw new Error(`Selected branches disagree about entry ${entry.id}`);
375+      }
376+      entriesById.set(entry.id, entry);
377+    }
378+  }
379+
380+  if (entriesById.size === 0) throw new Error("Select at least one branch");
381+  return entriesById;
382+}
383+
384 function matchesEntry(record: Record<string, unknown>, entry: TreeEntry): boolean {
385   return record.id === entry.id && record.parentId === entry.parentId && record.type === entry.type;
386 }
387 
388+function sameTreeEntry(left: TreeEntry, right: TreeEntry): boolean {
389+  return left.id === right.id && left.parentId === right.parentId && left.type === right.type;
390+}
391+
392 function temporaryPathFor(path: string): string {
393   return join(dirname(path), `.${basename(path)}.tree-prune-tmp-${process.pid}-${randomUUID()}`);
394 }
395diff --git a/dot_pi/agent/extensions/pi-tree-pruning/test/command.test.ts b/dot_pi/agent/extensions/pi-tree-pruning/test/command.test.ts
396deleted file mode 100644
397index 60c6f31aafed54975c0e0acbc474b869f7d96988..0000000000000000000000000000000000000000
398--- a/dot_pi/agent/extensions/pi-tree-pruning/test/command.test.ts
399+++ /dev/null
400@@ -1,75 +0,0 @@
401-import assert from "node:assert/strict";
402-import { mkdtemp, readdir, readFile, writeFile } from "node:fs/promises";
403-import { tmpdir } from "node:os";
404-import { join } from "node:path";
405-import test from "node:test";
406-import type {
407-  ExtensionAPI,
408-  ExtensionCommandContext,
409-  RegisteredCommand,
410-} from "@earendil-works/pi-coding-agent";
411-import registerTreePruning from "../index.ts";
412-
413-const originalContent = [
414-  '{"type":"session","version":3,"id":"session-id","timestamp":"now","cwd":"/tmp"}\n',
415-  '{"type":"message","id":"root","parentId":null,"timestamp":"now","message":{"role":"user","content":"root"}}\n',
416-  '{"type":"message","id":"active","parentId":"root","timestamp":"now","message":{"role":"user","content":"active"}}\n',
417-  '{"type":"message","id":"inactive","parentId":"root","timestamp":"now","message":{"role":"user","content":"inactive"}}\n',
418-].join("");
419-
420-const entries = [
421-  { id: "root", parentId: null, type: "message", message: { role: "user", content: "root" } },
422-  { id: "active", parentId: "root", type: "message", message: { role: "user", content: "active" } },
423-  { id: "inactive", parentId: "root", type: "message", message: { role: "user", content: "inactive" } },
424-];
425-
426-test("prunes a selected branch, creates a backup, and reloads the session", async () => {
427-  const directory = await mkdtemp(join(tmpdir(), "pi-tree-pruning-command-"));
428-  const sessionPath = join(directory, "session.jsonl");
429-  await writeFile(sessionPath, originalContent, "utf8");
430-
431-  let handler: ((args: string, ctx: ExtensionCommandContext) => Promise<void>) | undefined;
432-  registerTreePruning({
433-    registerCommand(
434-      _name: string,
435-      command: Omit<RegisteredCommand, "name" | "sourceInfo">,
436-    ) {
437-      handler = command.handler;
438-    },
439-  } as unknown as ExtensionAPI);
440-
441-  const notifications: string[] = [];
442-  let reloadedSession: string | undefined;
443-  const context = {
444-    hasUI: true,
445-    waitForIdle: async () => {},
446-    sessionManager: {
447-      getSessionFile: () => sessionPath,
448-      getLeafId: () => "active",
449-      getEntries: () => entries,
450-      getLabel: () => undefined,
451-    },
452-    ui: {
453-      select: async (_title: string, options: string[]) => options[0],
454-      confirm: async () => true,
455-      notify: (message: string) => notifications.push(message),
456-    },
457-    switchSession: async (path: string, options?: { withSession?: (ctx: { ui: { notify: (message: string) => void } }) => Promise<void> }) => {
458-      reloadedSession = path;
459-      await options?.withSession?.({ ui: { notify: (message) => notifications.push(message) } });
460-      return { cancelled: false };
461-    },
462-  } as unknown as ExtensionCommandContext;
463-
464-  assert.ok(handler);
465-  await handler("", context);
466-
467-  assert.equal(reloadedSession, sessionPath);
468-  assert.doesNotMatch(await readFile(sessionPath, "utf8"), /"inactive"/);
469-  assert.match(notifications.join("\n"), /Tree Prune removed 1 entry/);
470-
471-  const files = await readdir(directory);
472-  const backupName = files.find((file) => file.startsWith("session.jsonl.tree-prune-backup-"));
473-  assert.ok(backupName);
474-  assert.equal(await readFile(join(directory, backupName), "utf8"), originalContent);
475-});
476diff --git a/dot_pi/agent/extensions/pi-tree-pruning/test/session-file.test.ts b/dot_pi/agent/extensions/pi-tree-pruning/test/session-file.test.ts
477deleted file mode 100644
478index 3cf76bfcdc7e98b8c525f78c17fa90d41d4a5b0b..0000000000000000000000000000000000000000
479--- a/dot_pi/agent/extensions/pi-tree-pruning/test/session-file.test.ts
480+++ /dev/null
481@@ -1,110 +0,0 @@
482-import assert from "node:assert/strict";
483-import { mkdtemp, readFile, writeFile } from "node:fs/promises";
484-import { tmpdir } from "node:os";
485-import { join } from "node:path";
486-import test from "node:test";
487-import {
488-  assertSessionFileUnchanged,
489-  buildPrunedSessionContent,
490-  captureSessionFile,
491-  createBackupPath,
492-  createSessionBackup,
493-  pruneSessionFile,
494-  restoreSessionBackup,
495-} from "../session-file.ts";
496-import { findInactiveBranchCandidates, type TreeEntry } from "../tree-model.ts";
497-
498-const sessionContent = [
499-  '{"type":"session","version":3,"id":"session-id","timestamp":"now","cwd":"/tmp"}\n',
500-  '{"type":"message","id":"root","parentId":null,"timestamp":"now","message":{"role":"user","content":"root"}}\n',
501-  'this is not json\n',
502-  '{"type":"message","id":"active","parentId":"root","timestamp":"now","message":{"role":"user","content":"active"}}\n',
503-  '{"type":"message","id":"inactive","parentId":"root","timestamp":"now","message":{"role":"user","content":"inactive"}}\n',
504-  '{"type":"custom","id":"metadata","parentId":"inactive","timestamp":"now","customType":"example"}\n',
505-].join("");
506-
507-const prunedContent = [
508-  '{"type":"session","version":3,"id":"session-id","timestamp":"now","cwd":"/tmp"}\n',
509-  '{"type":"message","id":"root","parentId":null,"timestamp":"now","message":{"role":"user","content":"root"}}\n',
510-  'this is not json\n',
511-  '{"type":"message","id":"active","parentId":"root","timestamp":"now","message":{"role":"user","content":"active"}}\n',
512-].join("");
513-
514-function treeEntries(): TreeEntry[] {
515-  return [
516-    { id: "root", parentId: null, type: "message", message: { role: "user", content: "root" } },
517-    { id: "active", parentId: "root", type: "message", message: { role: "user", content: "active" } },
518-    { id: "inactive", parentId: "root", type: "message", message: { role: "user", content: "inactive" } },
519-    { id: "metadata", parentId: "inactive", type: "custom" },
520-  ];
521-}
522-
523-async function createFixture(): Promise<string> {
524-  const directory = await mkdtemp(join(tmpdir(), "pi-tree-pruning-"));
525-  const sessionPath = join(directory, "session.jsonl");
526-  await writeFile(sessionPath, sessionContent, "utf8");
527-  return sessionPath;
528-}
529-
530-function inactiveCandidate() {
531-  const candidate = findInactiveBranchCandidates(treeEntries(), "active")[0];
532-  assert.ok(candidate);
533-  return candidate;
534-}
535-
536-test("removes only the selected JSONL lines and retains unrelated raw lines", async () => {
537-  const sessionPath = await createFixture();
538-  const snapshot = await captureSessionFile(sessionPath);
539-
540-  assert.equal(buildPrunedSessionContent(snapshot, inactiveCandidate()), prunedContent);
541-});
542-
543-test("creates a same-directory backup without changing the session", async () => {
544-  const sessionPath = await createFixture();
545-  const snapshot = await captureSessionFile(sessionPath);
546-  const backupPath = createBackupPath(sessionPath);
547-
548-  const createdBackupPath = await createSessionBackup(snapshot, backupPath);
549-
550-  assert.equal(createdBackupPath, backupPath);
551-  assert.match(backupPath, /session\.jsonl\.tree-prune-backup-/);
552-  assert.equal(await readFile(backupPath, "utf8"), sessionContent);
553-  assert.equal(await readFile(sessionPath, "utf8"), sessionContent);
554-});
555-
556-test("detects a changed session file before mutation", async () => {
557-  const sessionPath = await createFixture();
558-  const snapshot = await captureSessionFile(sessionPath);
559-  await writeFile(sessionPath, `${sessionContent}changed\n`, "utf8");
560-
561-  await assert.rejects(assertSessionFileUnchanged(snapshot), /changed while the dialog was open/);
562-});
563-
564-test("prunes with a backup and can restore that backup with a backup of the pruned session", async () => {
565-  const sessionPath = await createFixture();
566-  const originalSnapshot = await captureSessionFile(sessionPath);
567-  const originalBackupPath = createBackupPath(sessionPath);
568-
569-  const pruneResult = await pruneSessionFile(
570-    originalSnapshot,
571-    inactiveCandidate(),
572-    originalBackupPath,
573-  );
574-
575-  assert.equal(pruneResult.removedEntryCount, 2);
576-  assert.equal(await readFile(sessionPath, "utf8"), prunedContent);
577-  assert.equal(await readFile(originalBackupPath, "utf8"), sessionContent);
578-
579-  const prunedSnapshot = await captureSessionFile(sessionPath);
580-  const originalBackupSnapshot = await captureSessionFile(originalBackupPath);
581diff --git a/dot_pi/agent/extensions/pi-tree-pruning/test/tree-model.test.ts b/dot_pi/agent/extensions/pi-tree-pruning/test/tree-model.test.ts
582deleted file mode 100644
583index f81e4524a301d64febd9c3da6181fd27c787fb92..0000000000000000000000000000000000000000
584--- a/dot_pi/agent/extensions/pi-tree-pruning/test/tree-model.test.ts
585+++ /dev/null
586@@ -1,83 +0,0 @@
587-import assert from "node:assert/strict";
588-import test from "node:test";
589-import { findInactiveBranchCandidates, type TreeEntry } from "../tree-model.ts";
590-
591-function entry(id: string, parentId: string | null, type = "message"): TreeEntry {
592-  if (type !== "message") return { id, parentId, type };
593-  return { id, parentId, type, message: { role: "user", content: `message ${id}` } };
594-}
595-
596-test("lists complete inactive subtrees and protects the active subtree", () => {
597-  const entries = [
598-    entry("root", null),
599-    entry("active", "root"),
600-    entry("active-leaf", "active"),
601-    entry("inactive", "root"),
602-    entry("inactive-child", "inactive"),
603-  ];
604-
605-  const candidates = findInactiveBranchCandidates(entries, "active-leaf");
606-
607-  assert.equal(candidates.length, 1);
608-  assert.equal(candidates[0]?.rootId, "inactive");
609-  assert.deepEqual(candidates[0]?.entryIds, ["inactive", "inactive-child"]);
610-  assert.equal(candidates[0]?.entryCount, 2);
611-  assert.match(candidates[0]?.preview ?? "", /^user: message inactive$/);
612-});
613-
614-test("keeps metadata in a removable subtree and supports multiple roots", () => {
615-  const entries = [
616-    entry("root-a", null),
617-    entry("active", "root-a"),
618-    entry("inactive", "root-a"),
619-    entry("label", "inactive", "label"),
620-    entry("root-b", null),
621-    entry("branch-b1", "root-b"),
622-    entry("branch-b2", "root-b"),
623-  ];
624-
625-  const candidates = findInactiveBranchCandidates(entries, "active");
626-
627-  assert.deepEqual(
628-    candidates.map((candidate) => [candidate.rootId, candidate.entryIds]),
629-    [
630-      ["inactive", ["inactive", "label"]],
631-      ["branch-b1", ["branch-b1"]],
632-      ["branch-b2", ["branch-b2"]],
633-    ],
634-  );
635-});
636-
637-test("rejects ambiguous children, cycles, duplicates, and a missing active leaf", () => {
638-  const ambiguousChild = { id: "broken", parentId: "root", type: 42 };
639-  const entries = [
640-    entry("root", null),
641-    entry("active", "root"),
642-    entry("inactive", "root"),
643-    ambiguousChild,
644-    entry("duplicate", "inactive"),
645-    entry("duplicate", "inactive"),
646-    entry("cycle-a", "cycle-b"),
647-    entry("cycle-b", "cycle-a"),
648-  ] as unknown as TreeEntry[];
649-
650-  assert.deepEqual(findInactiveBranchCandidates(entries, "active"), []);
651-  assert.deepEqual(findInactiveBranchCandidates(entries, "missing"), []);
652-});
653-
654-test("does not offer a nested candidate that reaches a cycle", () => {
655-  const entries = [
656-    entry("root", null),
657-    entry("active", "root"),
658-    entry("inactive", "root"),
659-    entry("fork", "inactive"),
660-    entry("safe", "fork"),
661-    entry("cycle-a", "fork"),
662-    entry("cycle-b", "cycle-a"),
663-    entry("cycle-a", "cycle-b"),
664-  ] as unknown as TreeEntry[];
665-
666-  const candidates = findInactiveBranchCandidates(entries, "active");
667-
668-  assert.deepEqual(candidates, []);
669-});
670diff --git a/dot_pi/agent/extensions/pi-tree-pruning/test/tree-view.test.ts b/dot_pi/agent/extensions/pi-tree-pruning/test/tree-view.test.ts
671deleted file mode 100644
672index 67fe657929ac3f9c83c9529e3e878bfae6a4af8e..0000000000000000000000000000000000000000
673--- a/dot_pi/agent/extensions/pi-tree-pruning/test/tree-view.test.ts
674+++ /dev/null
675@@ -1,42 +0,0 @@
676-import assert from "node:assert/strict";
677-import test from "node:test";
678-import type { BranchCandidate } from "../tree-model.ts";
679-import {
680-  buildPruneTreeItems,
681-  formatEntryCount,
682-  formatPruneTreeItem,
683-} from "../tree-view.ts";
684-
685-function candidate(
686-  rootId: string,
687-  forkPointId: string,
688-  entryIds: string[],
689-  preview: string,
690-): BranchCandidate {
691-  return {
692-    rootId,
693-    forkPointId,
694-    entries: entryIds.map((id) => ({ id, parentId: null, type: "message" })),
695-    entryIds,
696-    entryCount: entryIds.length,
697-    preview,
698-  };
699-}
700-
701-test("renders nested branches with labels and correct entry counts", () => {
702-  const main = candidate("main", "active-fork", ["main", "nested-fork", "quick"], "user: main work");
703-  const quick = candidate("quick", "nested-fork", ["quick", "quick-reply"], "user: check implementation");
704-
705-  const items = buildPruneTreeItems([main, quick], (entryId) =>
706-    entryId === "quick" ? "quick check" : undefined,
707-  );
708-
709-  assert.deepEqual(
710-    items.map((item) => item.treePrefix),
711-    ["", "   └─ "],
712-  );
713-  assert.equal(formatPruneTreeItem(items[0]!), "user: main work · 3 entries");
714-  assert.equal(formatPruneTreeItem(items[1]!), "[quick check] user: check implementation · 2 entries");
715-  assert.equal(formatEntryCount(1), "1 entry");
716-  assert.equal(formatEntryCount(2), "2 entries");
717-});
718diff --git a/dot_pi/agent/extensions/pi-tree-pruning/tree-model.ts b/dot_pi/agent/extensions/pi-tree-pruning/tree-model.ts
719index a73d083dd3080d2846508ab78445db2f75ab6c0b..0259ff06f65fdbe261c8f0112122d3696a4c6fd0 100644
720--- a/dot_pi/agent/extensions/pi-tree-pruning/tree-model.ts
721+++ b/dot_pi/agent/extensions/pi-tree-pruning/tree-model.ts
722@@ -56,6 +56,32 @@ export function findInactiveBranchCandidates(
723   return candidates;
724 }
725 
726+export function normalizePruneCandidates(
727+  candidates: readonly BranchCandidate[],
728+): BranchCandidate[] {
729+  const candidatesByRootId = new Map<string, BranchCandidate>();
730+  for (const candidate of candidates) {
731+    if (!candidatesByRootId.has(candidate.rootId)) {
732+      candidatesByRootId.set(candidate.rootId, candidate);
733+    }
734+  }
735+
736+  const uniqueCandidates = [...candidatesByRootId.values()];
737+  return uniqueCandidates.filter(
738+    (candidate) =>
739+      !uniqueCandidates.some(
740+        (possibleParent) =>
741+          possibleParent.rootId !== candidate.rootId &&
742+          possibleParent.entryIds.includes(candidate.rootId),
743+      ),
744+  );
745+}
746+
747+export function pruneCandidatesOverlap(left: BranchCandidate, right: BranchCandidate): boolean {
748+  if (left.rootId === right.rootId) return true;
749+  return left.entryIds.some((entryId) => right.entryIds.includes(entryId));
750+}
751+
752 function buildTreeIndex(entries: readonly TreeEntry[]): TreeIndex {
753   const validEntries = entries.filter(isTreeEntry);
754   const occurrences = new Map<string, number>();
755diff --git a/dot_pi/agent/extensions/pi-tree-pruning/tsconfig.json b/dot_pi/agent/extensions/pi-tree-pruning/tsconfig.json
756index 86e5d35b67366531cab4135639e3eef377e6945a..ea2b147fee7ae840125b3ade94280e9d9a8ecbcf 100644
757--- a/dot_pi/agent/extensions/pi-tree-pruning/tsconfig.json
758+++ b/dot_pi/agent/extensions/pi-tree-pruning/tsconfig.json
759@@ -14,5 +14,5 @@
760     "skipLibCheck": true,
761     "types": ["node"]
762   },
763-  "include": ["./*.ts", "./test/**/*.ts"]
764+  "include": ["./*.ts"]
765 }