/ extensions / anycopy / index.ts
index.ts
  1  /**
  2   * anycopy — browse session tree nodes with preview and copy any of them
  3   *
  4   * Layout: native TreeSelectorComponent at top, status bar, preview below
  5   *
  6   * Default keys (customizable via ./config.json):
  7   *   Shift+A   - select/unselect focused node for copy
  8   *   Shift+C   - copy selected nodes (or focused node if none selected)
  9   *   Shift+X   - clear selection
 10   *   Shift+L   - label node
 11   *   Shift+T   - toggle label timestamps for labeled nodes
 12   *   Shift+↑/↓ - scroll preview
 13   *   Shift+PageUp/PageDown - page preview
 14   *   Esc       - close
 15   */
 16  
 17  import type { ExtensionAPI, ExtensionCommandContext, SessionEntry } from "@mariozechner/pi-coding-agent";
 18  import {
 19  	copyToClipboard,
 20  	getLanguageFromPath,
 21  	getMarkdownTheme,
 22  	highlightCode,
 23  	TreeSelectorComponent,
 24  } from "@mariozechner/pi-coding-agent";
 25  
 26  import { getKeybindings, Markdown, matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
 27  import type { Focusable } from "@mariozechner/pi-tui";
 28  
 29  import { existsSync, readFileSync } from "fs";
 30  import { homedir } from "os";
 31  import { dirname, join } from "path";
 32  import { fileURLToPath } from "url";
 33  
 34  import { createAnycopyEnterNavigationLauncher, runAnycopyEnterNavigation } from "./enter-navigation.ts";
 35  import {
 36  	ANYCOPY_FOLD_STATE_CUSTOM_TYPE,
 37  	createFoldStateEntryData,
 38  	foldStateNodeIdListsEqual,
 39  	getSelectorFoldedNodeIds,
 40  	loadLatestFoldStateFromEntries,
 41  	mergeExplicitFoldMutation,
 42  	normalizeFoldedNodeIds,
 43  	setSelectorFoldedNodeIds,
 44  } from "./fold-state.ts";
 45  
 46  type SessionTreeNode = {
 47  	entry: SessionEntry;
 48  	children: SessionTreeNode[];
 49  	label?: string;
 50  };
 51  
 52  type anycopyTreeList = ReturnType<TreeSelectorComponent["getTreeList"]>;
 53  
 54  type anycopyTreeListInternals = anycopyTreeList & {
 55  	filteredNodes: Array<{ node: SessionTreeNode }>;
 56  	selectedIndex: number;
 57  	maxVisibleLines: number;
 58  	showLabelTimestamps: boolean;
 59  };
 60  
 61  type anycopyKeyConfig = {
 62  	toggleSelect: string;
 63  	copy: string;
 64  	clear: string;
 65  	toggleLabelTimestamps: string;
 66  	scrollDown: string;
 67  	scrollUp: string;
 68  	pageDown: string;
 69  	pageUp: string;
 70  };
 71  
 72  type TreeFilterMode = "default" | "no-tools" | "user-only" | "labeled-only" | "all";
 73  
 74  type anycopyConfig = {
 75  	keys?: Partial<anycopyKeyConfig>;
 76  	treeFilterMode?: TreeFilterMode;
 77  	persistFoldState?: boolean;
 78  };
 79  
 80  type anycopyRuntimeConfig = {
 81  	keys: anycopyKeyConfig;
 82  	treeFilterMode: TreeFilterMode;
 83  	persistFoldState: boolean;
 84  };
 85  
 86  type BranchSummarySettingsFile = {
 87  	branchSummary?: {
 88  		skipPrompt?: boolean;
 89  	};
 90  };
 91  
 92  const DEFAULT_KEYS: anycopyKeyConfig = {
 93  	toggleSelect: "shift+a",
 94  	copy: "shift+c",
 95  	clear: "shift+x",
 96  	toggleLabelTimestamps: "shift+t",
 97  	scrollDown: "shift+down",
 98  	scrollUp: "shift+up",
 99  	pageDown: "shift+pagedown",
100  	pageUp: "shift+pageup",
101  };
102  
103  const DEFAULT_TREE_FILTER_MODE: TreeFilterMode = "default";
104  const DEFAULT_PERSIST_FOLD_STATE = true;
105  
106  const getExtensionDir = (): string => {
107  	// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
108  	if (typeof __dirname !== "undefined") return __dirname;
109  	return dirname(fileURLToPath(import.meta.url));
110  };
111  
112  const getAgentDir = (): string => process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
113  
114  const readJsonFile = <T>(path: string): T | undefined => {
115  	if (!existsSync(path)) return undefined;
116  
117  	try {
118  		return JSON.parse(readFileSync(path, "utf8")) as T;
119  	} catch {
120  		return undefined;
121  	}
122  };
123  
124  const loadBranchSummarySkipPrompt = (cwd: string): boolean => {
125  	const globalSettings = readJsonFile<BranchSummarySettingsFile>(join(getAgentDir(), "settings.json"));
126  	const projectSettings = readJsonFile<BranchSummarySettingsFile>(join(cwd, ".pi", "settings.json"));
127  	const projectSkipPrompt = projectSettings?.branchSummary?.skipPrompt;
128  	if (typeof projectSkipPrompt === "boolean") return projectSkipPrompt;
129  
130  	const globalSkipPrompt = globalSettings?.branchSummary?.skipPrompt;
131  	return typeof globalSkipPrompt === "boolean" ? globalSkipPrompt : false;
132  };
133  
134  const loadConfig = (): anycopyRuntimeConfig => {
135  	const configPath = join(getExtensionDir(), "config.json");
136  	if (!existsSync(configPath)) {
137  		return {
138  			keys: { ...DEFAULT_KEYS },
139  			treeFilterMode: DEFAULT_TREE_FILTER_MODE,
140  			persistFoldState: DEFAULT_PERSIST_FOLD_STATE,
141  		};
142  	}
143  
144  	try {
145  		const raw = readFileSync(configPath, "utf8");
146  		const parsed = JSON.parse(raw) as anycopyConfig;
147  		const keys = parsed.keys ?? {};
148  		const treeFilterModeRaw = parsed.treeFilterMode;
149  		const validTreeFilterModes: TreeFilterMode[] = ["default", "no-tools", "user-only", "labeled-only", "all"];
150  		const treeFilterMode =
151  			typeof treeFilterModeRaw === "string" && validTreeFilterModes.includes(treeFilterModeRaw as TreeFilterMode)
152  				? (treeFilterModeRaw as TreeFilterMode)
153  				: DEFAULT_TREE_FILTER_MODE;
154  		const persistFoldState =
155  			typeof parsed.persistFoldState === "boolean" ? parsed.persistFoldState : DEFAULT_PERSIST_FOLD_STATE;
156  
157  		return {
158  			keys: {
159  				toggleSelect: typeof keys.toggleSelect === "string" ? keys.toggleSelect : DEFAULT_KEYS.toggleSelect,
160  				copy: typeof keys.copy === "string" ? keys.copy : DEFAULT_KEYS.copy,
161  				clear: typeof keys.clear === "string" ? keys.clear : DEFAULT_KEYS.clear,
162  				toggleLabelTimestamps:
163  					typeof keys.toggleLabelTimestamps === "string"
164  						? keys.toggleLabelTimestamps
165  						: DEFAULT_KEYS.toggleLabelTimestamps,
166  				scrollDown: typeof keys.scrollDown === "string" ? keys.scrollDown : DEFAULT_KEYS.scrollDown,
167  				scrollUp: typeof keys.scrollUp === "string" ? keys.scrollUp : DEFAULT_KEYS.scrollUp,
168  				pageDown: typeof keys.pageDown === "string" ? keys.pageDown : DEFAULT_KEYS.pageDown,
169  				pageUp: typeof keys.pageUp === "string" ? keys.pageUp : DEFAULT_KEYS.pageUp,
170  			},
171  			treeFilterMode,
172  			persistFoldState,
173  		};
174  	} catch {
175  		return {
176  			keys: { ...DEFAULT_KEYS },
177  			treeFilterMode: DEFAULT_TREE_FILTER_MODE,
178  			persistFoldState: DEFAULT_PERSIST_FOLD_STATE,
179  		};
180  	}
181  };
182  
183  const formatKeyHint = (key: string): string => {
184  	const normalized = key.trim().toLowerCase();
185  	if (normalized === "space") return "Space";
186  	const parts = normalized.split("+");
187  	return parts
188  		.map((part) => {
189  			if (part === "shift") return "Shift";
190  			if (part === "ctrl") return "Ctrl";
191  			if (part === "alt") return "Alt";
192  			if (part.length === 1) return part.toUpperCase();
193  			return part;
194  		})
195  		.join("+");
196  };
197  
198  const pluralizeNode = (count: number): string => (count === 1 ? "node" : "nodes");
199  
200  const MAX_PREVIEW_CHARS = 7000;
201  const MAX_PREVIEW_LINES = 200;
202  const FLASH_DURATION_MS = 2000;
203  
204  const getTextContent = (content: unknown): string => {
205  	if (typeof content === "string") return content;
206  	if (!Array.isArray(content)) return "";
207  	return content
208  		.filter(
209  			(b): b is { type: "text"; text: string } =>
210  				typeof b === "object" && b !== null && (b as { type?: string }).type === "text",
211  		)
212  		.map((b) => b.text)
213  		.join("");
214  };
215  
216  const clipTextForPreview = (text: string): string => {
217  	if (text.length <= MAX_PREVIEW_CHARS) return text;
218  	return `${text.slice(0, MAX_PREVIEW_CHARS)}\n… [truncated]`;
219  };
220  
221  /** Role/type label for clipboard display */
222  const getEntryRoleLabel = (entry: SessionEntry): string => {
223  	if (entry.type === "message") {
224  		return (entry.message as { role?: string }).role ?? "message";
225  	}
226  	if (entry.type === "custom_message") return entry.customType;
227  	return entry.type;
228  };
229  
230  /** Plain text content for clipboard and preview (no metadata) */
231  const getEntryContent = (entry: SessionEntry): string => {
232  	switch (entry.type) {
233  		case "message": {
234  			const msg = entry.message as {
235  				role?: string;
236  				content?: unknown;
237  				command?: string;
238  				errorMessage?: string;
239  			};
240  			if (msg.role === "bashExecution" && msg.command) return msg.command;
241  			if (msg.errorMessage) return `(error) ${msg.errorMessage}`;
242  			return getTextContent(msg.content).trim() || "(no text content)";
243  		}
244  		case "custom_message": {
245  			if (typeof entry.content === "string") {
246  				return entry.content || "(no text content)";
247  			}
248  			if (!Array.isArray(entry.content)) {
249  				return "(no text content)";
250  			}
251  
252  			const content = entry.content
253  				.filter(
254  					(b): b is { type: "text"; text: string } =>
255  						typeof b === "object" &&
256  						b !== null &&
257  						(b as { type?: string }).type === "text" &&
258  						typeof (b as { text?: unknown }).text === "string",
259  				)
260  				.map((b) => b.text)
261  				.join("");
262  			return content || "(no text content)";
263  		}
264  		case "compaction":
265  			return entry.summary;
266  		case "branch_summary":
267  			return entry.summary;
268  		case "custom":
269  			return `[custom: ${entry.customType}]`;
270  		case "label":
271  			return `label: ${entry.label ?? "(cleared)"}`;
272  		case "model_change":
273  			return `${entry.provider}/${entry.modelId}`;
274  		case "thinking_level_change":
275  			return entry.thinkingLevel;
276  		case "session_info":
277  			return entry.name ?? "(unnamed)";
278  		default:
279  			return "";
280  	}
281  };
282  
283  const replaceTabs = (text: string): string => text.replace(/\t/g, "   ");
284  
285  const MAX_PARENT_TRAVERSAL_DEPTH = 30;
286  
287  const getToolCallId = (entry: SessionEntry): string | null => {
288  	if (entry.type !== "message") return null;
289  	const msg = entry.message as { role?: string; toolCallId?: unknown };
290  	if (msg.role !== "toolResult") return null;
291  	return typeof msg.toolCallId === "string" ? msg.toolCallId : null;
292  };
293  
294  const getToolName = (entry: SessionEntry): string | null => {
295  	if (entry.type !== "message") return null;
296  	const msg = entry.message as { role?: string; toolName?: unknown };
297  	if (msg.role !== "toolResult") return null;
298  	return typeof msg.toolName === "string" ? msg.toolName : null;
299  };
300  
301  const resolveToolCallArgsFromParents = (
302  	entry: SessionEntry,
303  	nodeById: Map<string, SessionTreeNode>,
304  ): Record<string, unknown> | null => {
305  	const toolCallId = getToolCallId(entry);
306  	if (!toolCallId) return null;
307  
308  	let parentId = entry.parentId;
309  	for (let depth = 0; depth < MAX_PARENT_TRAVERSAL_DEPTH && parentId; depth += 1) {
310  		const parentNode = nodeById.get(parentId);
311  		if (!parentNode) return null;
312  
313  		const parentEntry = parentNode.entry;
314  		if (parentEntry.type === "message") {
315  			const parentMsg = parentEntry.message as { role?: string; content?: unknown };
316  			if (parentMsg.role === "assistant" && Array.isArray(parentMsg.content)) {
317  				const toolCall = parentMsg.content.find(
318  					(c: any) => c && c.type === "toolCall" && c.id === toolCallId,
319  				) as { arguments?: unknown } | undefined;
320  
321  				if (toolCall && typeof toolCall.arguments === "object" && toolCall.arguments !== null) {
322  					return toolCall.arguments as Record<string, unknown>;
323  				}
324  			}
325  		}
326  
327  		parentId = parentEntry.parentId;
328  	}
329  
330  	return null;
331  };
332  
333  const resolveReadToolLanguageFromParents = (
334  	entry: SessionEntry,
335  	nodeById: Map<string, SessionTreeNode>,
336  ): string | undefined => {
337  	if (getToolName(entry) !== "read") return undefined;
338  
339  	const args = resolveToolCallArgsFromParents(entry, nodeById);
340  	if (!args) return undefined;
341  
342  	const rawPath = args["file_path"] ?? args["path"];
343  	if (typeof rawPath !== "string" || !rawPath.trim()) return undefined;
344  	return getLanguageFromPath(rawPath);
345  };
346  
347  const renderPreviewBodyLines = (
348  	text: string,
349  	entry: SessionEntry,
350  	width: number,
351  	theme: any,
352  	nodeById: Map<string, SessionTreeNode>,
353  ): string[] => {
354  	if (entry.type === "message") {
355  		const msg = entry.message as { role?: string; command?: string };
356  
357  		// Bash execution nodes: highlight the command itself
358  		if (msg.role === "bashExecution" && typeof msg.command === "string") {
359  			return highlightCode(replaceTabs(text), "bash").map((line) => truncateToWidth(line, width));
360  		}
361  
362  		// Read tool results: use parent toolCall args to infer language from path, matching pi's own renderer
363  		if (getToolName(entry) === "read") {
364  			const normalized = replaceTabs(text);
365  			const lang = resolveReadToolLanguageFromParents(entry, nodeById);
366  
367  			const lines = lang
368  				? highlightCode(normalized, lang)
369  				: normalized.split("\n").map((line) => theme.fg("toolOutput", line));
370  
371  			return lines.map((line) => truncateToWidth(line, width));
372  		}
373  	}
374  
375  	// Everything else: render with pi's markdown renderer/theme (matches main UI)
376  	const markdown = new Markdown(text, 0, 0, getMarkdownTheme());
377  	return markdown.render(width);
378  };
379  
380  const buildNodeMap = (roots: SessionTreeNode[]): Map<string, SessionTreeNode> => {
381  	const map = new Map<string, SessionTreeNode>();
382  	const stack = [...roots];
383  	while (stack.length > 0) {
384  		const node = stack.pop()!;
385  		map.set(node.entry.id, node);
386  		for (const child of node.children) stack.push(child);
387  	}
388  	return map;
389  };
390  
391  /** Pre-order DFS index for chronological sorting of selected nodes */
392  const buildNodeOrder = (roots: SessionTreeNode[]): Map<string, number> => {
393  	const order = new Map<string, number>();
394  	let idx = 0;
395  	const visit = (nodes: SessionTreeNode[]) => {
396  		for (const node of nodes) {
397  			order.set(node.entry.id, idx++);
398  			visit(node.children);
399  		}
400  	};
401  	visit(roots);
402  	return order;
403  };
404  
405  const getTreeListInternals = (treeList: anycopyTreeList): anycopyTreeListInternals => {
406  	return treeList as anycopyTreeListInternals;
407  };
408  
409  /** Clipboard text omits role prefix for a single node and includes it for multi-node copies
410   * The preview pane is truncated for performance, while the clipboard copy is not
411   */
412  const buildClipboardText = (nodes: SessionTreeNode[]): string => {
413  	if (nodes.length === 1) {
414  		return getEntryContent(nodes[0]!.entry);
415  	}
416  
417  	return nodes
418  		.map((node) => {
419  			const label = getEntryRoleLabel(node.entry);
420  			const content = getEntryContent(node.entry);
421  			return `${label}:\n\n${content}`;
422  		})
423  		.join("\n\n---\n\n");
424  };
425  
426  class anycopyOverlay implements Focusable {
427  	private selectedNodeIds = new Set<string>();
428  	private flashMessage: string | null = null;
429  	private flashTimer: ReturnType<typeof setTimeout> | null = null;
430  	private _focused = false;
431  	private previewScrollOffset = 0;
432  	private lastPreviewHeight = 0;
433  	private previewCache: {
434  		entryId: string;
435  		width: number;
436  		bodyLines: string[];
437  		truncatedToMaxLines: boolean;
438  	} | null = null;
439  
440  	constructor(
441  		private selector: TreeSelectorComponent,
442  		private getTree: () => SessionTreeNode[],
443  		private nodeById: Map<string, SessionTreeNode>,
444  		private keys: anycopyKeyConfig,
445  		private onExplicitFoldMutation: ((
446  			beforeTransientFoldedNodeIds: string[],
447  			afterTransientFoldedNodeIds: string[],
448  		) => void) | null,
449  		private getTermHeight: () => number,
450  		private requestRender: () => void,
451  		private theme: any,
452  	) {}
453  
454  	get focused(): boolean {
455  		return this._focused;
456  	}
457  	set focused(value: boolean) {
458  		this._focused = value;
459  		this.selector.focused = value;
460  	}
461  
462  	getTreeList(): anycopyTreeList {
463  		return this.selector.getTreeList();
464  	}
465  
466  	private getTreeListInternals(): anycopyTreeListInternals {
467  		return getTreeListInternals(this.getTreeList());
468  	}
469  
470  	handleInput(data: string): void {
471  		if (this.isEditingNodeLabel()) {
472  			this.selector.handleInput(data);
473  			this.requestRender();
474  			return;
475  		}
476  
477  		if (matchesKey(data, this.keys.toggleSelect)) {
478  			this.toggleSelectedFocusedNode();
479  			return;
480  		}
481  		if (matchesKey(data, this.keys.copy)) {
482  			this.copySelectedOrFocusedNode();
483  			return;
484  		}
485  		if (matchesKey(data, this.keys.clear)) {
486  			this.clearSelection();
487  			return;
488  		}
489  		if (matchesKey(data, this.keys.toggleLabelTimestamps)) {
490  			const treeList = this.getTreeListInternals();
491  			treeList.showLabelTimestamps = !treeList.showLabelTimestamps;
492  			this.requestRender();
493  			return;
494  		}
495  
496  		const keybindings = getKeybindings();
497  		if (keybindings.matches(data, "app.tree.toggleLabelTimestamp")) {
498  			return;
499  		}
500  
501  		if (matchesKey(data, this.keys.scrollDown)) {
502  			this.previewScrollOffset += 1;
503  			this.requestRender();
504  			return;
505  		}
506  		if (matchesKey(data, this.keys.scrollUp)) {
507  			this.previewScrollOffset -= 1;
508  			this.requestRender();
509  			return;
510  		}
511  		if (matchesKey(data, this.keys.pageDown)) {
512  			const step = Math.max(1, (this.lastPreviewHeight > 0 ? this.lastPreviewHeight : 10) - 1);
513  			this.previewScrollOffset += step;
514  			this.requestRender();
515  			return;
516  		}
517  		if (matchesKey(data, this.keys.pageUp)) {
518  			const step = Math.max(1, (this.lastPreviewHeight > 0 ? this.lastPreviewHeight : 10) - 1);
519  			this.previewScrollOffset -= step;
520  			this.requestRender();
521  			return;
522  		}
523  
524  		const shouldTrackExplicitFoldMutation =
525  			this.onExplicitFoldMutation !== null &&
526  			(keybindings.matches(data, "app.tree.foldOrUp") || keybindings.matches(data, "app.tree.unfoldOrDown"));
527  		const beforeTransientFoldedNodeIds = shouldTrackExplicitFoldMutation ? getSelectorFoldedNodeIds(this.selector) : null;
528  
529  		this.selector.handleInput(data);
530  
531  		if (beforeTransientFoldedNodeIds) {
532  			this.onExplicitFoldMutation?.(beforeTransientFoldedNodeIds, getSelectorFoldedNodeIds(this.selector));
533  		}
534  
535  		this.requestRender();
536  	}
537  
538  	private isEditingNodeLabel(): boolean {
539  		return Boolean((this.selector as { labelInput?: unknown }).labelInput);
540  	}
541  
542  	invalidate(): void {
543  		// Preview is derived from focused entry + width; invalidate forces recompute
544  		this.previewCache = null;
545  		this.previewScrollOffset = 0;
546  		this.lastPreviewHeight = 0;
547  		this.selector.invalidate();
548  	}
549  
550  	private getFocusedNode(): SessionTreeNode | undefined {
551  		return this.selector.getTreeList().getSelectedNode();
552  	}
553  
554  	private flash(message: string): void {
555  		this.flashMessage = message;
556  		if (this.flashTimer) clearTimeout(this.flashTimer);
557  		this.flashTimer = setTimeout(() => {
558  			this.flashMessage = null;
559  			this.flashTimer = null;
560  			this.requestRender();
561  		}, FLASH_DURATION_MS);
562  		this.requestRender();
563  	}
564  
565  	toggleSelectedFocusedNode(): void {
566  		const focused = this.getFocusedNode();
567  		if (!focused) return;
568  		const id = focused.entry.id;
569  		if (this.selectedNodeIds.has(id)) {
570  			this.selectedNodeIds.delete(id);
571  			this.flash("Unselected node");
572  		} else {
573  			this.selectedNodeIds.add(id);
574  			this.flash(`Selected (${this.selectedNodeIds.size} ${pluralizeNode(this.selectedNodeIds.size)})`);
575  		}
576  	}
577  
578  	clearSelection(): void {
579  		if (this.selectedNodeIds.size === 0) {
580  			this.flash("Selection already empty");
581  			return;
582  		}
583  		this.selectedNodeIds.clear();
584  		this.flash("Cleared selection");
585  	}
586  
587  	isSelectedNode(id: string): boolean {
588  		return this.selectedNodeIds.has(id);
589  	}
590  
591  	copySelectedOrFocusedNode(): void {
592  		const focused = this.getFocusedNode();
593  		const ids =
594  			this.selectedNodeIds.size > 0
595  				? [...this.selectedNodeIds]
596  				: focused
597  					? [focused.entry.id]
598  					: [];
599  
600  		if (ids.length === 0) {
601  			this.flash("Nothing selected");
602  			return;
603  		}
604  
605  		const tree = this.getTree();
606  		const nodeById = buildNodeMap(tree);
607  		const nodeOrder = buildNodeOrder(tree);
608  		const nodes = ids
609  			.map((id) => nodeById.get(id))
610  			.filter((n): n is SessionTreeNode => Boolean(n))
611  			.sort((a, b) => {
612  				const oa = nodeOrder.get(a.entry.id) ?? Infinity;
613  				const ob = nodeOrder.get(b.entry.id) ?? Infinity;
614  				return oa - ob;
615  			});
616  
617  		copyToClipboard(buildClipboardText(nodes));
618  		this.flash(`Copied ${nodes.length} ${pluralizeNode(nodes.length)} to clipboard`);
619  	}
620  
621  	private renderStatusBar(width: number): string[] {
622  		const lines: string[] = [];
623  		lines.push(truncateToWidth(this.theme.fg("dim", "─".repeat(width)), width));
624  
625  		// Status only (selection count / flash)
626  		if (this.flashMessage) {
627  			lines.push(truncateToWidth(this.theme.fg("success", `  ${this.flashMessage}`), width));
628  		} else if (this.selectedNodeIds.size > 0) {
629  			lines.push(
630  				truncateToWidth(
631  					this.theme.fg(
632  						"accent",
633  						`  ${this.selectedNodeIds.size} selected ${pluralizeNode(this.selectedNodeIds.size)}`,
634  					),
635  					width,
636  				),
637  			);
638  		} else {
639  			lines.push("");
640  		}
641  
642  		// Preview-scrolling hints belong above the preview pane
643  		const previewHint =
644  			`  ${formatKeyHint(this.keys.scrollUp)}/${formatKeyHint(this.keys.scrollDown)}: scroll` +
645  			` • ${formatKeyHint(this.keys.pageUp)}/${formatKeyHint(this.keys.pageDown)}: page`;
646  		lines.push(truncateToWidth(this.theme.fg("dim", previewHint), width));
647  
648  		return lines;
649  	}
650  
651  	private renderTreeHeaderHint(width: number): string {
652  		const hint =
653  			`   │ Enter: navigate` +
654  			` • ${formatKeyHint(this.keys.toggleSelect)}: select` +
655  			` • ${formatKeyHint(this.keys.copy)}: copy` +
656  			` • ${formatKeyHint(this.keys.clear)}: clear` +
657  			` • ${formatKeyHint(this.keys.toggleLabelTimestamps)}: label time` +
658  			` • Esc: close`;
659  		return truncateToWidth(this.theme.fg("dim", hint), width);
660  	}
661  
662  	private renderPreview(width: number, height: number): string[] {
663  		if (height <= 0) return [];
664  
665  		this.lastPreviewHeight = height;
666  
667  		const focused = this.getFocusedNode();
668  		const lines: string[] = [];
669  		if (!focused) {
670  			lines.push(truncateToWidth(this.theme.fg("dim", "  (no node selected)"), width));
671  			while (lines.length < height) lines.push("");
672  			return lines;
673  		}
674  
675  		const entryId = focused.entry.id;
676  
677  		let bodyLines: string[];
678  		let truncatedToMaxLines: boolean;
679  
680  		if (this.previewCache && this.previewCache.entryId === entryId && this.previewCache.width === width) {
681  			({ bodyLines, truncatedToMaxLines } = this.previewCache);
682  		} else {
683  			const content = getEntryContent(focused.entry);
684  			const clipped = clipTextForPreview(content);
685  			const rendered = renderPreviewBodyLines(clipped, focused.entry, width, this.theme, this.nodeById);
686  
687  			truncatedToMaxLines = rendered.length > MAX_PREVIEW_LINES;
688  			bodyLines = rendered.slice(0, MAX_PREVIEW_LINES);
689  
690  			this.previewCache = { entryId, width, bodyLines, truncatedToMaxLines };
691  			this.previewScrollOffset = 0;
692  		}
693  
694  		// Clamp scroll offset based on available rendered lines
695  		const maxOffset = Math.max(0, bodyLines.length - height);
696  		this.previewScrollOffset = Math.max(0, Math.min(this.previewScrollOffset, maxOffset));
697  
698  		const start = this.previewScrollOffset;
699  		const end = Math.min(bodyLines.length, start + height);
700  		let visible = bodyLines.slice(start, end);
701  
702  		const above = start;
703  		const below = bodyLines.length - end;
704  
705  		if (height > 0) {
706  			if (above > 0) {
707  				const indicator = truncateToWidth(this.theme.fg("muted", `… ${above} line(s) above`), width);
708  				visible = height === 1 ? [indicator] : [indicator, ...visible.slice(0, height - 1)];
709  			}
710  
711  			if (below > 0) {
712  				const indicator = truncateToWidth(this.theme.fg("muted", `… ${below} more line(s)`), width);
713  				visible = height === 1 ? [indicator] : [...visible.slice(0, height - 1), indicator];
714  			} else if (truncatedToMaxLines) {
715  				const indicator = truncateToWidth(
716  					this.theme.fg("muted", `… [truncated to ${MAX_PREVIEW_LINES} lines]`),
717  					width,
718  				);
719  				visible = height === 1 ? [indicator] : [...visible.slice(0, height - 1), indicator];
720  			}
721  		}
722  
723  		for (let i = 0; i < Math.min(height, visible.length); i += 1) {
724  			lines.push(visible[i] ?? "");
725  		}
726  
727  		while (lines.length < height) lines.push("");
728  		return lines;
729  	}
730  
731  	render(width: number): string[] {
732  		const height = this.getTermHeight();
733  		const output: string[] = [];
734  
735  		const selectorLines = this.selector.render(width);
736  		const headerHint = this.renderTreeHeaderHint(width);
737  
738  		// Inject action hints near the tree header (above the list)
739  		const insertAfter = Math.max(0, selectorLines.findIndex((l) => l.includes("Type to search")));
740  		if (selectorLines.length > 0) {
741  			const idx = insertAfter >= 0 ? insertAfter + 1 : 1;
742  			selectorLines.splice(Math.min(idx, selectorLines.length), 0, headerHint);
743  		}
744  
745  		output.push(...selectorLines);
746  		output.push(...this.renderStatusBar(width));
747  
748  		const previewHeight = Math.max(0, height - output.length);
749  		if (previewHeight > 0) {
750  			output.push(...this.renderPreview(width, previewHeight));
751  		}
752  
753  		while (output.length < height) output.push("");
754  		if (output.length > height) output.length = height;
755  		return output;
756  	}
757  
758  	dispose(): void {
759  		if (this.flashTimer) {
760  			clearTimeout(this.flashTimer);
761  			this.flashTimer = null;
762  		}
763  		this.previewCache = null;
764  		this.previewScrollOffset = 0;
765  		this.lastPreviewHeight = 0;
766  		this.nodeById.clear();
767  	}
768  }
769  
770  export default function anycopyExtension(pi: ExtensionAPI) {
771  	const config = loadConfig();
772  	const keys = config.keys;
773  	const treeFilterMode = config.treeFilterMode;
774  	const persistFoldState = config.persistFoldState;
775  
776  	const openAnycopy = async (
777  		ctx: ExtensionCommandContext,
778  		opts?: { initialSelectedId?: string },
779  	) => {
780  		if (!ctx.hasUI) return;
781  
782  		const initialTree = ctx.sessionManager.getTree() as SessionTreeNode[];
783  		if (initialTree.length === 0) {
784  			ctx.ui.notify("No entries in session", "warning");
785  			return;
786  		}
787  
788  		const getTree = () => ctx.sessionManager.getTree() as SessionTreeNode[];
789  		const currentLeafId = ctx.sessionManager.getLeafId();
790  		const skipSummaryPrompt = loadBranchSummarySkipPrompt(ctx.cwd);
791  
792  		await ctx.ui.custom<void>((tui, theme, _kb, done) => {
793  			const termRows = tui.terminal?.rows ?? 40;
794  			const treeTermHeight = Math.floor(termRows * 0.65);
795  			const nodeById = buildNodeMap(initialTree);
796  			const validNodeIds = new Set(nodeById.keys());
797  			const restoredFoldState = persistFoldState
798  				? loadLatestFoldStateFromEntries(ctx.sessionManager.getEntries() as SessionEntry[], validNodeIds)
799  				: null;
800  			let durableFoldedNodeIds = restoredFoldState?.foldedNodeIds ?? [];
801  			let lastPersistedFoldedNodeIds = durableFoldedNodeIds;
802  			const currentLeafIdForNoop = currentLeafId;
803  
804  			const startEnterNavigation = createAnycopyEnterNavigationLauncher(async (entryId) =>
805  				runAnycopyEnterNavigation({
806  					entryId,
807  					currentLeafIdForNoop,
808  					skipSummaryPrompt,
809  					close: done,
810  					reopen: (reopenOpts) => {
811  						void openAnycopy(ctx, reopenOpts);
812  					},
813  					navigateTree: async (targetId, options) => ctx.navigateTree(targetId, options),
814  					ui: {
815  						select: (title, options) => ctx.ui.select(title, options),
816  						editor: (title) => ctx.ui.editor(title),
817  						setStatus: (source, message) => ctx.ui.setStatus(source, message),
818  						setWorkingMessage: (message) => ctx.ui.setWorkingMessage(message),
819  						notify: (message, level) => ctx.ui.notify(message, level),
820  					},
821  				}),
822  			);
823  
824  			const selector = new TreeSelectorComponent(
825  				initialTree,
826  				currentLeafId,
827  				treeTermHeight,
828  				startEnterNavigation,
829  				() => done(),
830  				(entryId, label) => {
831  					pi.setLabel(entryId, label);
832  				},
833  				opts?.initialSelectedId,
834  				treeFilterMode,
835  			);
836  
837  			if (persistFoldState) {
838  				const restoredFoldedNodeIds = normalizeFoldedNodeIds(
839  					setSelectorFoldedNodeIds(selector, durableFoldedNodeIds),
840  					validNodeIds,
841  				);
842  				durableFoldedNodeIds = restoredFoldedNodeIds;
843  				lastPersistedFoldedNodeIds = restoredFoldedNodeIds;
844  			}
845  
846  			const persistDurableFoldState = (nextDurableFoldedNodeIds: string[]): void => {
847  				if (!persistFoldState || foldStateNodeIdListsEqual(nextDurableFoldedNodeIds, lastPersistedFoldedNodeIds)) {
848  					return;
849  				}
850  
851  				try {
852  					pi.appendEntry(
853  						ANYCOPY_FOLD_STATE_CUSTOM_TYPE,
854  						createFoldStateEntryData(nextDurableFoldedNodeIds, validNodeIds),
855  					);
856  					lastPersistedFoldedNodeIds = nextDurableFoldedNodeIds;
857  				} catch (error) {
858  					ctx.ui.notify(
859  						error instanceof Error ? error.message : "Failed to persist /anycopy fold state",
860  						"error",
861  					);
862  				}
863  			};
864  
865  			const handleExplicitFoldMutation = (
866  				beforeTransientFoldedNodeIds: string[],
867  				afterTransientFoldedNodeIds: string[],
868  			): void => {
869  				const nextDurableFoldedNodeIds = mergeExplicitFoldMutation({
870  					durableFoldedNodeIds,
871  					beforeTransientFoldedNodeIds,
872  					afterTransientFoldedNodeIds,
873  					validNodeIds,
874  				});
875  				if (foldStateNodeIdListsEqual(nextDurableFoldedNodeIds, durableFoldedNodeIds)) {
876  					return;
877  				}
878  
879  				durableFoldedNodeIds = nextDurableFoldedNodeIds;
880  				persistDurableFoldState(nextDurableFoldedNodeIds);
881  			};
882  
883  			const overlay = new anycopyOverlay(
884  				selector,
885  				getTree,
886  				nodeById,
887  				keys,
888  				persistFoldState ? handleExplicitFoldMutation : null,
889  				() => tui.terminal?.rows ?? 40,
890  				() => tui.requestRender(),
891  				theme,
892  			);
893  
894  			const treeList = selector.getTreeList();
895  			const treeListInternals = getTreeListInternals(treeList);
896  			const originalRender = treeList.render.bind(treeList);
897  			treeList.render = (width: number) => {
898  				const innerWidth = Math.max(10, width - 2);
899  				const lines = originalRender(innerWidth);
900  				const filtered = treeListInternals.filteredNodes;
901  
902  				if (!Array.isArray(filtered) || filtered.length === 0) {
903  					return lines.map((line: string) => truncateToWidth(`  ${line}`, width));
904  				}
905  
906  				const maxVisible = Math.max(1, treeListInternals.maxVisibleLines);
907  				const startIdx = Math.max(
908  					0,
909  					Math.min(treeListInternals.selectedIndex - Math.floor(maxVisible / 2), filtered.length - maxVisible),
910  				);
911  				const treeRowCount = Math.max(0, lines.length - 1);
912  
913  				return lines.map((line: string, i: number) => {
914  					if (i >= treeRowCount) return truncateToWidth(`  ${line}`, width);
915  
916  					const nodeId = filtered[startIdx + i]?.node.entry.id;
917  					if (typeof nodeId !== "string") return truncateToWidth(`  ${line}`, width);
918  
919  					const marker = overlay.isSelectedNode(nodeId) ? theme.fg("success", "✓ ") : theme.fg("dim", "○ ");
920  					return truncateToWidth(marker + line, width);
921  				});
922  			};
923  
924  			tui.setFocus?.(overlay);
925  			return overlay;
926  		});
927  	};
928  
929  	pi.registerCommand("anycopy", {
930  		description: "Browse session tree with preview and copy any node(s) to clipboard",
931  		handler: async (_args, ctx: ExtensionCommandContext) => {
932  			await openAnycopy(ctx);
933  		},
934  	});
935  }