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 }