@mariozechner/pi-coding-agent 0.57.0 → 0.57.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +21 -1
  2. package/README.md +1 -1
  3. package/dist/core/export-html/index.d.ts +5 -2
  4. package/dist/core/export-html/index.d.ts.map +1 -1
  5. package/dist/core/export-html/index.js +4 -3
  6. package/dist/core/export-html/index.js.map +1 -1
  7. package/dist/core/export-html/template.js +11 -14
  8. package/dist/core/export-html/tool-renderer.d.ts +5 -2
  9. package/dist/core/export-html/tool-renderer.d.ts.map +1 -1
  10. package/dist/core/export-html/tool-renderer.js +12 -5
  11. package/dist/core/export-html/tool-renderer.js.map +1 -1
  12. package/dist/core/extensions/index.d.ts +1 -1
  13. package/dist/core/extensions/index.d.ts.map +1 -1
  14. package/dist/core/extensions/index.js.map +1 -1
  15. package/dist/core/extensions/types.d.ts +13 -1
  16. package/dist/core/extensions/types.d.ts.map +1 -1
  17. package/dist/core/extensions/types.js.map +1 -1
  18. package/dist/core/resource-loader.d.ts.map +1 -1
  19. package/dist/core/resource-loader.js +1 -1
  20. package/dist/core/resource-loader.js.map +1 -1
  21. package/dist/main.d.ts.map +1 -1
  22. package/dist/main.js +41 -11
  23. package/dist/main.js.map +1 -1
  24. package/dist/modes/interactive/components/extension-editor.d.ts.map +1 -1
  25. package/dist/modes/interactive/components/extension-editor.js +1 -0
  26. package/dist/modes/interactive/components/extension-editor.js.map +1 -1
  27. package/dist/modes/interactive/components/tree-selector.d.ts +17 -0
  28. package/dist/modes/interactive/components/tree-selector.d.ts.map +1 -1
  29. package/dist/modes/interactive/components/tree-selector.js +111 -6
  30. package/dist/modes/interactive/components/tree-selector.js.map +1 -1
  31. package/dist/modes/interactive/interactive-mode.d.ts +1 -0
  32. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  33. package/dist/modes/interactive/interactive-mode.js +45 -0
  34. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  35. package/docs/extensions.md +25 -2
  36. package/docs/keybindings.md +9 -1
  37. package/docs/terminal-setup.md +18 -1
  38. package/docs/tmux.md +30 -8
  39. package/docs/tree.md +9 -0
  40. package/examples/extensions/custom-provider-anthropic/package-lock.json +2 -2
  41. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  42. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  43. package/examples/extensions/custom-provider-qwen-cli/package.json +1 -1
  44. package/examples/extensions/with-deps/package-lock.json +2 -2
  45. package/examples/extensions/with-deps/package.json +1 -1
  46. package/package.json +4 -4
@@ -1 +1 @@
1
- {"version":3,"file":"tree-selector.d.ts","sourceRoot":"","sources":["../../../../src/modes/interactive/components/tree-selector.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n\ttype Component,\n\tContainer,\n\ttype Focusable,\n\tgetEditorKeybindings,\n\tInput,\n\tmatchesKey,\n\tSpacer,\n\tText,\n\tTruncatedText,\n\ttruncateToWidth,\n} from \"@mariozechner/pi-tui\";\nimport type { SessionTreeNode } from \"../../../core/session-manager.js\";\nimport { theme } from \"../theme/theme.js\";\nimport { DynamicBorder } from \"./dynamic-border.js\";\nimport { keyHint } from \"./keybinding-hints.js\";\n\n/** Gutter info: position (displayIndent where connector was) and whether to show │ */\ninterface GutterInfo {\n\tposition: number; // displayIndent level where the connector was shown\n\tshow: boolean; // true = show │, false = show spaces\n}\n\n/** Flattened tree node for navigation */\ninterface FlatNode {\n\tnode: SessionTreeNode;\n\t/** Indentation level (each level = 3 chars) */\n\tindent: number;\n\t/** Whether to show connector (├─ or └─) - true if parent has multiple children */\n\tshowConnector: boolean;\n\t/** If showConnector, true = last sibling (└─), false = not last (├─) */\n\tisLast: boolean;\n\t/** Gutter info for each ancestor branch point */\n\tgutters: GutterInfo[];\n\t/** True if this node is a root under a virtual branching root (multiple roots) */\n\tisVirtualRootChild: boolean;\n}\n\n/** Filter mode for tree display */\nexport type FilterMode = \"default\" | \"no-tools\" | \"user-only\" | \"labeled-only\" | \"all\";\n\n/**\n * Tree list component with selection and ASCII art visualization\n */\n/** Tool call info for lookup */\ninterface ToolCallInfo {\n\tname: string;\n\targuments: Record<string, unknown>;\n}\n\nclass TreeList implements Component {\n\tprivate flatNodes: FlatNode[] = [];\n\tprivate filteredNodes: FlatNode[] = [];\n\tprivate selectedIndex = 0;\n\tprivate currentLeafId: string | null;\n\tprivate maxVisibleLines: number;\n\tprivate filterMode: FilterMode = \"default\";\n\tprivate searchQuery = \"\";\n\tprivate toolCallMap: Map<string, ToolCallInfo> = new Map();\n\tprivate multipleRoots = false;\n\tprivate activePathIds: Set<string> = new Set();\n\tprivate lastSelectedId: string | null = null;\n\n\tpublic onSelect?: (entryId: string) => void;\n\tpublic onCancel?: () => void;\n\tpublic onLabelEdit?: (entryId: string, currentLabel: string | undefined) => void;\n\n\tconstructor(\n\t\ttree: SessionTreeNode[],\n\t\tcurrentLeafId: string | null,\n\t\tmaxVisibleLines: number,\n\t\tinitialSelectedId?: string,\n\t\tinitialFilterMode?: FilterMode,\n\t) {\n\t\tthis.currentLeafId = currentLeafId;\n\t\tthis.maxVisibleLines = maxVisibleLines;\n\t\tthis.filterMode = initialFilterMode ?? \"default\";\n\t\tthis.multipleRoots = tree.length > 1;\n\t\tthis.flatNodes = this.flattenTree(tree);\n\t\tthis.buildActivePath();\n\t\tthis.applyFilter();\n\n\t\t// Start with initialSelectedId if provided, otherwise current leaf\n\t\tconst targetId = initialSelectedId ?? currentLeafId;\n\t\tthis.selectedIndex = this.findNearestVisibleIndex(targetId);\n\t\tthis.lastSelectedId = this.filteredNodes[this.selectedIndex]?.node.entry.id ?? null;\n\t}\n\n\t/**\n\t * Find the index of the nearest visible entry, walking up the parent chain if needed.\n\t * Returns the index in filteredNodes, or the last index as fallback.\n\t */\n\tprivate findNearestVisibleIndex(entryId: string | null): number {\n\t\tif (this.filteredNodes.length === 0) return 0;\n\n\t\t// Build a map for parent lookup\n\t\tconst entryMap = new Map<string, FlatNode>();\n\t\tfor (const flatNode of this.flatNodes) {\n\t\t\tentryMap.set(flatNode.node.entry.id, flatNode);\n\t\t}\n\n\t\t// Build a map of visible entry IDs to their indices in filteredNodes\n\t\tconst visibleIdToIndex = new Map<string, number>(this.filteredNodes.map((node, i) => [node.node.entry.id, i]));\n\n\t\t// Walk from entryId up to root, looking for a visible entry\n\t\tlet currentId = entryId;\n\t\twhile (currentId !== null) {\n\t\t\tconst index = visibleIdToIndex.get(currentId);\n\t\t\tif (index !== undefined) return index;\n\t\t\tconst node = entryMap.get(currentId);\n\t\t\tif (!node) break;\n\t\t\tcurrentId = node.node.entry.parentId ?? null;\n\t\t}\n\n\t\t// Fallback: last visible entry\n\t\treturn this.filteredNodes.length - 1;\n\t}\n\n\t/** Build the set of entry IDs on the path from root to current leaf */\n\tprivate buildActivePath(): void {\n\t\tthis.activePathIds.clear();\n\t\tif (!this.currentLeafId) return;\n\n\t\t// Build a map of id -> entry for parent lookup\n\t\tconst entryMap = new Map<string, FlatNode>();\n\t\tfor (const flatNode of this.flatNodes) {\n\t\t\tentryMap.set(flatNode.node.entry.id, flatNode);\n\t\t}\n\n\t\t// Walk from leaf to root\n\t\tlet currentId: string | null = this.currentLeafId;\n\t\twhile (currentId) {\n\t\t\tthis.activePathIds.add(currentId);\n\t\t\tconst node = entryMap.get(currentId);\n\t\t\tif (!node) break;\n\t\t\tcurrentId = node.node.entry.parentId ?? null;\n\t\t}\n\t}\n\n\tprivate flattenTree(roots: SessionTreeNode[]): FlatNode[] {\n\t\tconst result: FlatNode[] = [];\n\t\tthis.toolCallMap.clear();\n\n\t\t// Indentation rules:\n\t\t// - At indent 0: stay at 0 unless parent has >1 children (then +1)\n\t\t// - At indent 1: children always go to indent 2 (visual grouping of subtree)\n\t\t// - At indent 2+: stay flat for single-child chains, +1 only if parent branches\n\n\t\t// Stack items: [node, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild]\n\t\ttype StackItem = [SessionTreeNode, number, boolean, boolean, boolean, GutterInfo[], boolean];\n\t\tconst stack: StackItem[] = [];\n\n\t\t// Determine which subtrees contain the active leaf (to sort current branch first)\n\t\t// Use iterative post-order traversal to avoid stack overflow\n\t\tconst containsActive = new Map<SessionTreeNode, boolean>();\n\t\tconst leafId = this.currentLeafId;\n\t\t{\n\t\t\t// Build list in pre-order, then process in reverse for post-order effect\n\t\t\tconst allNodes: SessionTreeNode[] = [];\n\t\t\tconst preOrderStack: SessionTreeNode[] = [...roots];\n\t\t\twhile (preOrderStack.length > 0) {\n\t\t\t\tconst node = preOrderStack.pop()!;\n\t\t\t\tallNodes.push(node);\n\t\t\t\t// Push children in reverse so they're processed left-to-right\n\t\t\t\tfor (let i = node.children.length - 1; i >= 0; i--) {\n\t\t\t\t\tpreOrderStack.push(node.children[i]);\n\t\t\t\t}\n\t\t\t}\n\t\t\t// Process in reverse (post-order): children before parents\n\t\t\tfor (let i = allNodes.length - 1; i >= 0; i--) {\n\t\t\t\tconst node = allNodes[i];\n\t\t\t\tlet has = leafId !== null && node.entry.id === leafId;\n\t\t\t\tfor (const child of node.children) {\n\t\t\t\t\tif (containsActive.get(child)) {\n\t\t\t\t\t\thas = true;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tcontainsActive.set(node, has);\n\t\t\t}\n\t\t}\n\n\t\t// Add roots in reverse order, prioritizing the one containing the active leaf\n\t\t// If multiple roots, treat them as children of a virtual root that branches\n\t\tconst multipleRoots = roots.length > 1;\n\t\tconst orderedRoots = [...roots].sort((a, b) => Number(containsActive.get(b)) - Number(containsActive.get(a)));\n\t\tfor (let i = orderedRoots.length - 1; i >= 0; i--) {\n\t\t\tconst isLast = i === orderedRoots.length - 1;\n\t\t\tstack.push([orderedRoots[i], multipleRoots ? 1 : 0, multipleRoots, multipleRoots, isLast, [], multipleRoots]);\n\t\t}\n\n\t\twhile (stack.length > 0) {\n\t\t\tconst [node, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild] = stack.pop()!;\n\n\t\t\t// Extract tool calls from assistant messages for later lookup\n\t\t\tconst entry = node.entry;\n\t\t\tif (entry.type === \"message\" && entry.message.role === \"assistant\") {\n\t\t\t\tconst content = (entry.message as { content?: unknown }).content;\n\t\t\t\tif (Array.isArray(content)) {\n\t\t\t\t\tfor (const block of content) {\n\t\t\t\t\t\tif (typeof block === \"object\" && block !== null && \"type\" in block && block.type === \"toolCall\") {\n\t\t\t\t\t\t\tconst tc = block as { id: string; name: string; arguments: Record<string, unknown> };\n\t\t\t\t\t\t\tthis.toolCallMap.set(tc.id, { name: tc.name, arguments: tc.arguments });\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tresult.push({ node, indent, showConnector, isLast, gutters, isVirtualRootChild });\n\n\t\t\tconst children = node.children;\n\t\t\tconst multipleChildren = children.length > 1;\n\n\t\t\t// Order children so the branch containing the active leaf comes first\n\t\t\tconst orderedChildren = (() => {\n\t\t\t\tconst prioritized: SessionTreeNode[] = [];\n\t\t\t\tconst rest: SessionTreeNode[] = [];\n\t\t\t\tfor (const child of children) {\n\t\t\t\t\tif (containsActive.get(child)) {\n\t\t\t\t\t\tprioritized.push(child);\n\t\t\t\t\t} else {\n\t\t\t\t\t\trest.push(child);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\treturn [...prioritized, ...rest];\n\t\t\t})();\n\n\t\t\t// Calculate child indent\n\t\t\tlet childIndent: number;\n\t\t\tif (multipleChildren) {\n\t\t\t\t// Parent branches: children get +1\n\t\t\t\tchildIndent = indent + 1;\n\t\t\t} else if (justBranched && indent > 0) {\n\t\t\t\t// First generation after a branch: +1 for visual grouping\n\t\t\t\tchildIndent = indent + 1;\n\t\t\t} else {\n\t\t\t\t// Single-child chain: stay flat\n\t\t\t\tchildIndent = indent;\n\t\t\t}\n\n\t\t\t// Build gutters for children\n\t\t\t// If this node showed a connector, add a gutter entry for descendants\n\t\t\t// Only add gutter if connector is actually displayed (not suppressed for virtual root children)\n\t\t\tconst connectorDisplayed = showConnector && !isVirtualRootChild;\n\t\t\t// When connector is displayed, add a gutter entry at the connector's position\n\t\t\t// Connector is at position (displayIndent - 1), so gutter should be there too\n\t\t\tconst currentDisplayIndent = this.multipleRoots ? Math.max(0, indent - 1) : indent;\n\t\t\tconst connectorPosition = Math.max(0, currentDisplayIndent - 1);\n\t\t\tconst childGutters: GutterInfo[] = connectorDisplayed\n\t\t\t\t? [...gutters, { position: connectorPosition, show: !isLast }]\n\t\t\t\t: gutters;\n\n\t\t\t// Add children in reverse order\n\t\t\tfor (let i = orderedChildren.length - 1; i >= 0; i--) {\n\t\t\t\tconst childIsLast = i === orderedChildren.length - 1;\n\t\t\t\tstack.push([\n\t\t\t\t\torderedChildren[i],\n\t\t\t\t\tchildIndent,\n\t\t\t\t\tmultipleChildren,\n\t\t\t\t\tmultipleChildren,\n\t\t\t\t\tchildIsLast,\n\t\t\t\t\tchildGutters,\n\t\t\t\t\tfalse,\n\t\t\t\t]);\n\t\t\t}\n\t\t}\n\n\t\treturn result;\n\t}\n\n\tprivate applyFilter(): void {\n\t\t// Update lastSelectedId only when we have a valid selection (non-empty list)\n\t\t// This preserves the selection when switching through empty filter results\n\t\tif (this.filteredNodes.length > 0) {\n\t\t\tthis.lastSelectedId = this.filteredNodes[this.selectedIndex]?.node.entry.id ?? this.lastSelectedId;\n\t\t}\n\n\t\tconst searchTokens = this.searchQuery.toLowerCase().split(/\\s+/).filter(Boolean);\n\n\t\tthis.filteredNodes = this.flatNodes.filter((flatNode) => {\n\t\t\tconst entry = flatNode.node.entry;\n\t\t\tconst isCurrentLeaf = entry.id === this.currentLeafId;\n\n\t\t\t// Skip assistant messages with only tool calls (no text) unless error/aborted\n\t\t\t// Always show current leaf so active position is visible\n\t\t\tif (entry.type === \"message\" && entry.message.role === \"assistant\" && !isCurrentLeaf) {\n\t\t\t\tconst msg = entry.message as { stopReason?: string; content?: unknown };\n\t\t\t\tconst hasText = this.hasTextContent(msg.content);\n\t\t\t\tconst isErrorOrAborted = msg.stopReason && msg.stopReason !== \"stop\" && msg.stopReason !== \"toolUse\";\n\t\t\t\t// Only hide if no text AND not an error/aborted message\n\t\t\t\tif (!hasText && !isErrorOrAborted) {\n\t\t\t\t\treturn false;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Apply filter mode\n\t\t\tlet passesFilter = true;\n\t\t\t// Entry types hidden in default view (settings/bookkeeping)\n\t\t\tconst isSettingsEntry =\n\t\t\t\tentry.type === \"label\" ||\n\t\t\t\tentry.type === \"custom\" ||\n\t\t\t\tentry.type === \"model_change\" ||\n\t\t\t\tentry.type === \"thinking_level_change\";\n\n\t\t\tswitch (this.filterMode) {\n\t\t\t\tcase \"user-only\":\n\t\t\t\t\t// Just user messages\n\t\t\t\t\tpassesFilter = entry.type === \"message\" && entry.message.role === \"user\";\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"no-tools\":\n\t\t\t\t\t// Default minus tool results\n\t\t\t\t\tpassesFilter = !isSettingsEntry && !(entry.type === \"message\" && entry.message.role === \"toolResult\");\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"labeled-only\":\n\t\t\t\t\t// Just labeled entries\n\t\t\t\t\tpassesFilter = flatNode.node.label !== undefined;\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"all\":\n\t\t\t\t\t// Show everything\n\t\t\t\t\tpassesFilter = true;\n\t\t\t\t\tbreak;\n\t\t\t\tdefault:\n\t\t\t\t\t// Default mode: hide settings/bookkeeping entries\n\t\t\t\t\tpassesFilter = !isSettingsEntry;\n\t\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tif (!passesFilter) return false;\n\n\t\t\t// Apply search filter\n\t\t\tif (searchTokens.length > 0) {\n\t\t\t\tconst nodeText = this.getSearchableText(flatNode.node).toLowerCase();\n\t\t\t\treturn searchTokens.every((token) => nodeText.includes(token));\n\t\t\t}\n\n\t\t\treturn true;\n\t\t});\n\n\t\t// Recalculate visual structure (indent, connectors, gutters) based on visible tree\n\t\tthis.recalculateVisualStructure();\n\n\t\t// Try to preserve cursor on the same node, or find nearest visible ancestor\n\t\tif (this.lastSelectedId) {\n\t\t\tthis.selectedIndex = this.findNearestVisibleIndex(this.lastSelectedId);\n\t\t} else if (this.selectedIndex >= this.filteredNodes.length) {\n\t\t\t// Clamp index if out of bounds\n\t\t\tthis.selectedIndex = Math.max(0, this.filteredNodes.length - 1);\n\t\t}\n\n\t\t// Update lastSelectedId to the actual selection (may have changed due to parent walk)\n\t\tif (this.filteredNodes.length > 0) {\n\t\t\tthis.lastSelectedId = this.filteredNodes[this.selectedIndex]?.node.entry.id ?? this.lastSelectedId;\n\t\t}\n\t}\n\n\t/**\n\t * Recompute indentation/connectors for the filtered view\n\t *\n\t * Filtering can hide intermediate entries; descendants attach to the nearest visible ancestor.\n\t * Keep indentation semantics aligned with flattenTree() so single-child chains don't drift right.\n\t */\n\tprivate recalculateVisualStructure(): void {\n\t\tif (this.filteredNodes.length === 0) return;\n\n\t\tconst visibleIds = new Set(this.filteredNodes.map((n) => n.node.entry.id));\n\n\t\t// Build entry map for efficient parent lookup (using full tree)\n\t\tconst entryMap = new Map<string, FlatNode>();\n\t\tfor (const flatNode of this.flatNodes) {\n\t\t\tentryMap.set(flatNode.node.entry.id, flatNode);\n\t\t}\n\n\t\t// Find nearest visible ancestor for a node\n\t\tconst findVisibleAncestor = (nodeId: string): string | null => {\n\t\t\tlet currentId = entryMap.get(nodeId)?.node.entry.parentId ?? null;\n\t\t\twhile (currentId !== null) {\n\t\t\t\tif (visibleIds.has(currentId)) {\n\t\t\t\t\treturn currentId;\n\t\t\t\t}\n\t\t\t\tcurrentId = entryMap.get(currentId)?.node.entry.parentId ?? null;\n\t\t\t}\n\t\t\treturn null;\n\t\t};\n\n\t\t// Build visible tree structure:\n\t\t// - visibleParent: nodeId → nearest visible ancestor (or null for roots)\n\t\t// - visibleChildren: parentId → list of visible children (in filteredNodes order)\n\t\tconst visibleParent = new Map<string, string | null>();\n\t\tconst visibleChildren = new Map<string | null, string[]>();\n\t\tvisibleChildren.set(null, []); // root-level nodes\n\n\t\tfor (const flatNode of this.filteredNodes) {\n\t\t\tconst nodeId = flatNode.node.entry.id;\n\t\t\tconst ancestorId = findVisibleAncestor(nodeId);\n\t\t\tvisibleParent.set(nodeId, ancestorId);\n\n\t\t\tif (!visibleChildren.has(ancestorId)) {\n\t\t\t\tvisibleChildren.set(ancestorId, []);\n\t\t\t}\n\t\t\tvisibleChildren.get(ancestorId)!.push(nodeId);\n\t\t}\n\n\t\t// Update multipleRoots based on visible roots\n\t\tconst visibleRootIds = visibleChildren.get(null)!;\n\t\tthis.multipleRoots = visibleRootIds.length > 1;\n\n\t\t// Build a map for quick lookup: nodeId → FlatNode\n\t\tconst filteredNodeMap = new Map<string, FlatNode>();\n\t\tfor (const flatNode of this.filteredNodes) {\n\t\t\tfilteredNodeMap.set(flatNode.node.entry.id, flatNode);\n\t\t}\n\n\t\t// DFS over the visible tree using flattenTree() indentation semantics\n\t\t// Stack items: [nodeId, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild]\n\t\ttype StackItem = [string, number, boolean, boolean, boolean, GutterInfo[], boolean];\n\t\tconst stack: StackItem[] = [];\n\n\t\t// Add visible roots in reverse order (to process in forward order via stack)\n\t\tfor (let i = visibleRootIds.length - 1; i >= 0; i--) {\n\t\t\tconst isLast = i === visibleRootIds.length - 1;\n\t\t\tstack.push([\n\t\t\t\tvisibleRootIds[i],\n\t\t\t\tthis.multipleRoots ? 1 : 0,\n\t\t\t\tthis.multipleRoots,\n\t\t\t\tthis.multipleRoots,\n\t\t\t\tisLast,\n\t\t\t\t[],\n\t\t\t\tthis.multipleRoots,\n\t\t\t]);\n\t\t}\n\n\t\twhile (stack.length > 0) {\n\t\t\tconst [nodeId, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild] = stack.pop()!;\n\n\t\t\tconst flatNode = filteredNodeMap.get(nodeId);\n\t\t\tif (!flatNode) continue;\n\n\t\t\t// Update this node's visual properties\n\t\t\tflatNode.indent = indent;\n\t\t\tflatNode.showConnector = showConnector;\n\t\t\tflatNode.isLast = isLast;\n\t\t\tflatNode.gutters = gutters;\n\t\t\tflatNode.isVirtualRootChild = isVirtualRootChild;\n\n\t\t\t// Get visible children of this node\n\t\t\tconst children = visibleChildren.get(nodeId) || [];\n\t\t\tconst multipleChildren = children.length > 1;\n\n\t\t\t// Child indent follows flattenTree(): branch points (and first generation after a branch) shift +1\n\t\t\tlet childIndent: number;\n\t\t\tif (multipleChildren) {\n\t\t\t\tchildIndent = indent + 1;\n\t\t\t} else if (justBranched && indent > 0) {\n\t\t\t\tchildIndent = indent + 1;\n\t\t\t} else {\n\t\t\t\tchildIndent = indent;\n\t\t\t}\n\n\t\t\t// Child gutters follow flattenTree() connector/gutter rules\n\t\t\tconst connectorDisplayed = showConnector && !isVirtualRootChild;\n\t\t\tconst currentDisplayIndent = this.multipleRoots ? Math.max(0, indent - 1) : indent;\n\t\t\tconst connectorPosition = Math.max(0, currentDisplayIndent - 1);\n\t\t\tconst childGutters: GutterInfo[] = connectorDisplayed\n\t\t\t\t? [...gutters, { position: connectorPosition, show: !isLast }]\n\t\t\t\t: gutters;\n\n\t\t\t// Add children in reverse order (to process in forward order via stack)\n\t\t\tfor (let i = children.length - 1; i >= 0; i--) {\n\t\t\t\tconst childIsLast = i === children.length - 1;\n\t\t\t\tstack.push([\n\t\t\t\t\tchildren[i],\n\t\t\t\t\tchildIndent,\n\t\t\t\t\tmultipleChildren,\n\t\t\t\t\tmultipleChildren,\n\t\t\t\t\tchildIsLast,\n\t\t\t\t\tchildGutters,\n\t\t\t\t\tfalse,\n\t\t\t\t]);\n\t\t\t}\n\t\t}\n\t}\n\n\t/** Get searchable text content from a node */\n\tprivate getSearchableText(node: SessionTreeNode): string {\n\t\tconst entry = node.entry;\n\t\tconst parts: string[] = [];\n\n\t\tif (node.label) {\n\t\t\tparts.push(node.label);\n\t\t}\n\n\t\tswitch (entry.type) {\n\t\t\tcase \"message\": {\n\t\t\t\tconst msg = entry.message;\n\t\t\t\tparts.push(msg.role);\n\t\t\t\tif (\"content\" in msg && msg.content) {\n\t\t\t\t\tparts.push(this.extractContent(msg.content));\n\t\t\t\t}\n\t\t\t\tif (msg.role === \"bashExecution\") {\n\t\t\t\t\tconst bashMsg = msg as { command?: string };\n\t\t\t\t\tif (bashMsg.command) parts.push(bashMsg.command);\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"custom_message\": {\n\t\t\t\tparts.push(entry.customType);\n\t\t\t\tif (typeof entry.content === \"string\") {\n\t\t\t\t\tparts.push(entry.content);\n\t\t\t\t} else {\n\t\t\t\t\tparts.push(this.extractContent(entry.content));\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"compaction\":\n\t\t\t\tparts.push(\"compaction\");\n\t\t\t\tbreak;\n\t\t\tcase \"branch_summary\":\n\t\t\t\tparts.push(\"branch summary\", entry.summary);\n\t\t\t\tbreak;\n\t\t\tcase \"model_change\":\n\t\t\t\tparts.push(\"model\", entry.modelId);\n\t\t\t\tbreak;\n\t\t\tcase \"thinking_level_change\":\n\t\t\t\tparts.push(\"thinking\", entry.thinkingLevel);\n\t\t\t\tbreak;\n\t\t\tcase \"custom\":\n\t\t\t\tparts.push(\"custom\", entry.customType);\n\t\t\t\tbreak;\n\t\t\tcase \"label\":\n\t\t\t\tparts.push(\"label\", entry.label ?? \"\");\n\t\t\t\tbreak;\n\t\t}\n\n\t\treturn parts.join(\" \");\n\t}\n\n\tinvalidate(): void {}\n\n\tgetSearchQuery(): string {\n\t\treturn this.searchQuery;\n\t}\n\n\tgetSelectedNode(): SessionTreeNode | undefined {\n\t\treturn this.filteredNodes[this.selectedIndex]?.node;\n\t}\n\n\tupdateNodeLabel(entryId: string, label: string | undefined): void {\n\t\tfor (const flatNode of this.flatNodes) {\n\t\t\tif (flatNode.node.entry.id === entryId) {\n\t\t\t\tflatNode.node.label = label;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate getFilterLabel(): string {\n\t\tswitch (this.filterMode) {\n\t\t\tcase \"no-tools\":\n\t\t\t\treturn \" [no-tools]\";\n\t\t\tcase \"user-only\":\n\t\t\t\treturn \" [user]\";\n\t\t\tcase \"labeled-only\":\n\t\t\t\treturn \" [labeled]\";\n\t\t\tcase \"all\":\n\t\t\t\treturn \" [all]\";\n\t\t\tdefault:\n\t\t\t\treturn \"\";\n\t\t}\n\t}\n\n\trender(width: number): string[] {\n\t\tconst lines: string[] = [];\n\n\t\tif (this.filteredNodes.length === 0) {\n\t\t\tlines.push(truncateToWidth(theme.fg(\"muted\", \" No entries found\"), width));\n\t\t\tlines.push(truncateToWidth(theme.fg(\"muted\", ` (0/0)${this.getFilterLabel()}`), width));\n\t\t\treturn lines;\n\t\t}\n\n\t\tconst startIndex = Math.max(\n\t\t\t0,\n\t\t\tMath.min(\n\t\t\t\tthis.selectedIndex - Math.floor(this.maxVisibleLines / 2),\n\t\t\t\tthis.filteredNodes.length - this.maxVisibleLines,\n\t\t\t),\n\t\t);\n\t\tconst endIndex = Math.min(startIndex + this.maxVisibleLines, this.filteredNodes.length);\n\n\t\tfor (let i = startIndex; i < endIndex; i++) {\n\t\t\tconst flatNode = this.filteredNodes[i];\n\t\t\tconst entry = flatNode.node.entry;\n\t\t\tconst isSelected = i === this.selectedIndex;\n\n\t\t\t// Build line: cursor + prefix + path marker + label + content\n\t\t\tconst cursor = isSelected ? theme.fg(\"accent\", \"› \") : \" \";\n\n\t\t\t// If multiple roots, shift display (roots at 0, not 1)\n\t\t\tconst displayIndent = this.multipleRoots ? Math.max(0, flatNode.indent - 1) : flatNode.indent;\n\n\t\t\t// Build prefix with gutters at their correct positions\n\t\t\t// Each gutter has a position (displayIndent where its connector was shown)\n\t\t\tconst connector =\n\t\t\t\tflatNode.showConnector && !flatNode.isVirtualRootChild ? (flatNode.isLast ? \"└─ \" : \"├─ \") : \"\";\n\t\t\tconst connectorPosition = connector ? displayIndent - 1 : -1;\n\n\t\t\t// Build prefix char by char, placing gutters and connector at their positions\n\t\t\tconst totalChars = displayIndent * 3;\n\t\t\tconst prefixChars: string[] = [];\n\t\t\tfor (let i = 0; i < totalChars; i++) {\n\t\t\t\tconst level = Math.floor(i / 3);\n\t\t\t\tconst posInLevel = i % 3;\n\n\t\t\t\t// Check if there's a gutter at this level\n\t\t\t\tconst gutter = flatNode.gutters.find((g) => g.position === level);\n\t\t\t\tif (gutter) {\n\t\t\t\t\tif (posInLevel === 0) {\n\t\t\t\t\t\tprefixChars.push(gutter.show ? \"│\" : \" \");\n\t\t\t\t\t} else {\n\t\t\t\t\t\tprefixChars.push(\" \");\n\t\t\t\t\t}\n\t\t\t\t} else if (connector && level === connectorPosition) {\n\t\t\t\t\t// Connector at this level\n\t\t\t\t\tif (posInLevel === 0) {\n\t\t\t\t\t\tprefixChars.push(flatNode.isLast ? \"└\" : \"├\");\n\t\t\t\t\t} else if (posInLevel === 1) {\n\t\t\t\t\t\tprefixChars.push(\"─\");\n\t\t\t\t\t} else {\n\t\t\t\t\t\tprefixChars.push(\" \");\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tprefixChars.push(\" \");\n\t\t\t\t}\n\t\t\t}\n\t\t\tconst prefix = prefixChars.join(\"\");\n\n\t\t\t// Active path marker - shown right before the entry text\n\t\t\tconst isOnActivePath = this.activePathIds.has(entry.id);\n\t\t\tconst pathMarker = isOnActivePath ? theme.fg(\"accent\", \"• \") : \"\";\n\n\t\t\tconst label = flatNode.node.label ? theme.fg(\"warning\", `[${flatNode.node.label}] `) : \"\";\n\t\t\tconst content = this.getEntryDisplayText(flatNode.node, isSelected);\n\n\t\t\tlet line = cursor + theme.fg(\"dim\", prefix) + pathMarker + label + content;\n\t\t\tif (isSelected) {\n\t\t\t\tline = theme.bg(\"selectedBg\", line);\n\t\t\t}\n\t\t\tlines.push(truncateToWidth(line, width));\n\t\t}\n\n\t\tlines.push(\n\t\t\ttruncateToWidth(\n\t\t\t\ttheme.fg(\"muted\", ` (${this.selectedIndex + 1}/${this.filteredNodes.length})${this.getFilterLabel()}`),\n\t\t\t\twidth,\n\t\t\t),\n\t\t);\n\n\t\treturn lines;\n\t}\n\n\tprivate getEntryDisplayText(node: SessionTreeNode, isSelected: boolean): string {\n\t\tconst entry = node.entry;\n\t\tlet result: string;\n\n\t\tconst normalize = (s: string) => s.replace(/[\\n\\t]/g, \" \").trim();\n\n\t\tswitch (entry.type) {\n\t\t\tcase \"message\": {\n\t\t\t\tconst msg = entry.message;\n\t\t\t\tconst role = msg.role;\n\t\t\t\tif (role === \"user\") {\n\t\t\t\t\tconst msgWithContent = msg as { content?: unknown };\n\t\t\t\t\tconst content = normalize(this.extractContent(msgWithContent.content));\n\t\t\t\t\tresult = theme.fg(\"accent\", \"user: \") + content;\n\t\t\t\t} else if (role === \"assistant\") {\n\t\t\t\t\tconst msgWithContent = msg as { content?: unknown; stopReason?: string; errorMessage?: string };\n\t\t\t\t\tconst textContent = normalize(this.extractContent(msgWithContent.content));\n\t\t\t\t\tif (textContent) {\n\t\t\t\t\t\tresult = theme.fg(\"success\", \"assistant: \") + textContent;\n\t\t\t\t\t} else if (msgWithContent.stopReason === \"aborted\") {\n\t\t\t\t\t\tresult = theme.fg(\"success\", \"assistant: \") + theme.fg(\"muted\", \"(aborted)\");\n\t\t\t\t\t} else if (msgWithContent.errorMessage) {\n\t\t\t\t\t\tconst errMsg = normalize(msgWithContent.errorMessage).slice(0, 80);\n\t\t\t\t\t\tresult = theme.fg(\"success\", \"assistant: \") + theme.fg(\"error\", errMsg);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tresult = theme.fg(\"success\", \"assistant: \") + theme.fg(\"muted\", \"(no content)\");\n\t\t\t\t\t}\n\t\t\t\t} else if (role === \"toolResult\") {\n\t\t\t\t\tconst toolMsg = msg as { toolCallId?: string; toolName?: string };\n\t\t\t\t\tconst toolCall = toolMsg.toolCallId ? this.toolCallMap.get(toolMsg.toolCallId) : undefined;\n\t\t\t\t\tif (toolCall) {\n\t\t\t\t\t\tresult = theme.fg(\"muted\", this.formatToolCall(toolCall.name, toolCall.arguments));\n\t\t\t\t\t} else {\n\t\t\t\t\t\tresult = theme.fg(\"muted\", `[${toolMsg.toolName ?? \"tool\"}]`);\n\t\t\t\t\t}\n\t\t\t\t} else if (role === \"bashExecution\") {\n\t\t\t\t\tconst bashMsg = msg as { command?: string };\n\t\t\t\t\tresult = theme.fg(\"dim\", `[bash]: ${normalize(bashMsg.command ?? \"\")}`);\n\t\t\t\t} else {\n\t\t\t\t\tresult = theme.fg(\"dim\", `[${role}]`);\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"custom_message\": {\n\t\t\t\tconst content =\n\t\t\t\t\ttypeof entry.content === \"string\"\n\t\t\t\t\t\t? entry.content\n\t\t\t\t\t\t: entry.content\n\t\t\t\t\t\t\t\t.filter((c): c is { type: \"text\"; text: string } => c.type === \"text\")\n\t\t\t\t\t\t\t\t.map((c) => c.text)\n\t\t\t\t\t\t\t\t.join(\"\");\n\t\t\t\tresult = theme.fg(\"customMessageLabel\", `[${entry.customType}]: `) + normalize(content);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"compaction\": {\n\t\t\t\tconst tokens = Math.round(entry.tokensBefore / 1000);\n\t\t\t\tresult = theme.fg(\"borderAccent\", `[compaction: ${tokens}k tokens]`);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"branch_summary\":\n\t\t\t\tresult = theme.fg(\"warning\", `[branch summary]: `) + normalize(entry.summary);\n\t\t\t\tbreak;\n\t\t\tcase \"model_change\":\n\t\t\t\tresult = theme.fg(\"dim\", `[model: ${entry.modelId}]`);\n\t\t\t\tbreak;\n\t\t\tcase \"thinking_level_change\":\n\t\t\t\tresult = theme.fg(\"dim\", `[thinking: ${entry.thinkingLevel}]`);\n\t\t\t\tbreak;\n\t\t\tcase \"custom\":\n\t\t\t\tresult = theme.fg(\"dim\", `[custom: ${entry.customType}]`);\n\t\t\t\tbreak;\n\t\t\tcase \"label\":\n\t\t\t\tresult = theme.fg(\"dim\", `[label: ${entry.label ?? \"(cleared)\"}]`);\n\t\t\t\tbreak;\n\t\t\tdefault:\n\t\t\t\tresult = \"\";\n\t\t}\n\n\t\treturn isSelected ? theme.bold(result) : result;\n\t}\n\n\tprivate extractContent(content: unknown): string {\n\t\tconst maxLen = 200;\n\t\tif (typeof content === \"string\") return content.slice(0, maxLen);\n\t\tif (Array.isArray(content)) {\n\t\t\tlet result = \"\";\n\t\t\tfor (const c of content) {\n\t\t\t\tif (typeof c === \"object\" && c !== null && \"type\" in c && c.type === \"text\") {\n\t\t\t\t\tresult += (c as { text: string }).text;\n\t\t\t\t\tif (result.length >= maxLen) return result.slice(0, maxLen);\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn result;\n\t\t}\n\t\treturn \"\";\n\t}\n\n\tprivate hasTextContent(content: unknown): boolean {\n\t\tif (typeof content === \"string\") return content.trim().length > 0;\n\t\tif (Array.isArray(content)) {\n\t\t\tfor (const c of content) {\n\t\t\t\tif (typeof c === \"object\" && c !== null && \"type\" in c && c.type === \"text\") {\n\t\t\t\t\tconst text = (c as { text?: string }).text;\n\t\t\t\t\tif (text && text.trim().length > 0) return true;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn false;\n\t}\n\n\tprivate formatToolCall(name: string, args: Record<string, unknown>): string {\n\t\tconst shortenPath = (p: string): string => {\n\t\t\tconst home = process.env.HOME || process.env.USERPROFILE || \"\";\n\t\t\tif (home && p.startsWith(home)) return `~${p.slice(home.length)}`;\n\t\t\treturn p;\n\t\t};\n\n\t\tswitch (name) {\n\t\t\tcase \"read\": {\n\t\t\t\tconst path = shortenPath(String(args.path || args.file_path || \"\"));\n\t\t\t\tconst offset = args.offset as number | undefined;\n\t\t\t\tconst limit = args.limit as number | undefined;\n\t\t\t\tlet display = path;\n\t\t\t\tif (offset !== undefined || limit !== undefined) {\n\t\t\t\t\tconst start = offset ?? 1;\n\t\t\t\t\tconst end = limit !== undefined ? start + limit - 1 : \"\";\n\t\t\t\t\tdisplay += `:${start}${end ? `-${end}` : \"\"}`;\n\t\t\t\t}\n\t\t\t\treturn `[read: ${display}]`;\n\t\t\t}\n\t\t\tcase \"write\": {\n\t\t\t\tconst path = shortenPath(String(args.path || args.file_path || \"\"));\n\t\t\t\treturn `[write: ${path}]`;\n\t\t\t}\n\t\t\tcase \"edit\": {\n\t\t\t\tconst path = shortenPath(String(args.path || args.file_path || \"\"));\n\t\t\t\treturn `[edit: ${path}]`;\n\t\t\t}\n\t\t\tcase \"bash\": {\n\t\t\t\tconst rawCmd = String(args.command || \"\");\n\t\t\t\tconst cmd = rawCmd\n\t\t\t\t\t.replace(/[\\n\\t]/g, \" \")\n\t\t\t\t\t.trim()\n\t\t\t\t\t.slice(0, 50);\n\t\t\t\treturn `[bash: ${cmd}${rawCmd.length > 50 ? \"...\" : \"\"}]`;\n\t\t\t}\n\t\t\tcase \"grep\": {\n\t\t\t\tconst pattern = String(args.pattern || \"\");\n\t\t\t\tconst path = shortenPath(String(args.path || \".\"));\n\t\t\t\treturn `[grep: /${pattern}/ in ${path}]`;\n\t\t\t}\n\t\t\tcase \"find\": {\n\t\t\t\tconst pattern = String(args.pattern || \"\");\n\t\t\t\tconst path = shortenPath(String(args.path || \".\"));\n\t\t\t\treturn `[find: ${pattern} in ${path}]`;\n\t\t\t}\n\t\t\tcase \"ls\": {\n\t\t\t\tconst path = shortenPath(String(args.path || \".\"));\n\t\t\t\treturn `[ls: ${path}]`;\n\t\t\t}\n\t\t\tdefault: {\n\t\t\t\t// Custom tool - show name and truncated JSON args\n\t\t\t\tconst argsStr = JSON.stringify(args).slice(0, 40);\n\t\t\t\treturn `[${name}: ${argsStr}${JSON.stringify(args).length > 40 ? \"...\" : \"\"}]`;\n\t\t\t}\n\t\t}\n\t}\n\n\thandleInput(keyData: string): void {\n\t\tconst kb = getEditorKeybindings();\n\t\tif (kb.matches(keyData, \"selectUp\")) {\n\t\t\tthis.selectedIndex = this.selectedIndex === 0 ? this.filteredNodes.length - 1 : this.selectedIndex - 1;\n\t\t} else if (kb.matches(keyData, \"selectDown\")) {\n\t\t\tthis.selectedIndex = this.selectedIndex === this.filteredNodes.length - 1 ? 0 : this.selectedIndex + 1;\n\t\t} else if (kb.matches(keyData, \"cursorLeft\")) {\n\t\t\t// Page up\n\t\t\tthis.selectedIndex = Math.max(0, this.selectedIndex - this.maxVisibleLines);\n\t\t} else if (kb.matches(keyData, \"cursorRight\")) {\n\t\t\t// Page down\n\t\t\tthis.selectedIndex = Math.min(this.filteredNodes.length - 1, this.selectedIndex + this.maxVisibleLines);\n\t\t} else if (kb.matches(keyData, \"selectConfirm\")) {\n\t\t\tconst selected = this.filteredNodes[this.selectedIndex];\n\t\t\tif (selected && this.onSelect) {\n\t\t\t\tthis.onSelect(selected.node.entry.id);\n\t\t\t}\n\t\t} else if (kb.matches(keyData, \"selectCancel\")) {\n\t\t\tif (this.searchQuery) {\n\t\t\t\tthis.searchQuery = \"\";\n\t\t\t\tthis.applyFilter();\n\t\t\t} else {\n\t\t\t\tthis.onCancel?.();\n\t\t\t}\n\t\t} else if (matchesKey(keyData, \"ctrl+d\")) {\n\t\t\t// Direct filter: default\n\t\t\tthis.filterMode = \"default\";\n\t\t\tthis.applyFilter();\n\t\t} else if (matchesKey(keyData, \"ctrl+t\")) {\n\t\t\t// Toggle filter: no-tools ↔ default\n\t\t\tthis.filterMode = this.filterMode === \"no-tools\" ? \"default\" : \"no-tools\";\n\t\t\tthis.applyFilter();\n\t\t} else if (matchesKey(keyData, \"ctrl+u\")) {\n\t\t\t// Toggle filter: user-only ↔ default\n\t\t\tthis.filterMode = this.filterMode === \"user-only\" ? \"default\" : \"user-only\";\n\t\t\tthis.applyFilter();\n\t\t} else if (matchesKey(keyData, \"ctrl+l\")) {\n\t\t\t// Toggle filter: labeled-only ↔ default\n\t\t\tthis.filterMode = this.filterMode === \"labeled-only\" ? \"default\" : \"labeled-only\";\n\t\t\tthis.applyFilter();\n\t\t} else if (matchesKey(keyData, \"ctrl+a\")) {\n\t\t\t// Toggle filter: all ↔ default\n\t\t\tthis.filterMode = this.filterMode === \"all\" ? \"default\" : \"all\";\n\t\t\tthis.applyFilter();\n\t\t} else if (matchesKey(keyData, \"shift+ctrl+o\")) {\n\t\t\t// Cycle filter backwards\n\t\t\tconst modes: FilterMode[] = [\"default\", \"no-tools\", \"user-only\", \"labeled-only\", \"all\"];\n\t\t\tconst currentIndex = modes.indexOf(this.filterMode);\n\t\t\tthis.filterMode = modes[(currentIndex - 1 + modes.length) % modes.length];\n\t\t\tthis.applyFilter();\n\t\t} else if (matchesKey(keyData, \"ctrl+o\")) {\n\t\t\t// Cycle filter forwards: default → no-tools → user-only → labeled-only → all → default\n\t\t\tconst modes: FilterMode[] = [\"default\", \"no-tools\", \"user-only\", \"labeled-only\", \"all\"];\n\t\t\tconst currentIndex = modes.indexOf(this.filterMode);\n\t\t\tthis.filterMode = modes[(currentIndex + 1) % modes.length];\n\t\t\tthis.applyFilter();\n\t\t} else if (kb.matches(keyData, \"deleteCharBackward\")) {\n\t\t\tif (this.searchQuery.length > 0) {\n\t\t\t\tthis.searchQuery = this.searchQuery.slice(0, -1);\n\t\t\t\tthis.applyFilter();\n\t\t\t}\n\t\t} else if (matchesKey(keyData, \"shift+l\")) {\n\t\t\tconst selected = this.filteredNodes[this.selectedIndex];\n\t\t\tif (selected && this.onLabelEdit) {\n\t\t\t\tthis.onLabelEdit(selected.node.entry.id, selected.node.label);\n\t\t\t}\n\t\t} else {\n\t\t\tconst hasControlChars = [...keyData].some((ch) => {\n\t\t\t\tconst code = ch.charCodeAt(0);\n\t\t\t\treturn code < 32 || code === 0x7f || (code >= 0x80 && code <= 0x9f);\n\t\t\t});\n\t\t\tif (!hasControlChars && keyData.length > 0) {\n\t\t\t\tthis.searchQuery += keyData;\n\t\t\t\tthis.applyFilter();\n\t\t\t}\n\t\t}\n\t}\n}\n\n/** Component that displays the current search query */\nclass SearchLine implements Component {\n\tconstructor(private treeList: TreeList) {}\n\n\tinvalidate(): void {}\n\n\trender(width: number): string[] {\n\t\tconst query = this.treeList.getSearchQuery();\n\t\tif (query) {\n\t\t\treturn [truncateToWidth(` ${theme.fg(\"muted\", \"Type to search:\")} ${theme.fg(\"accent\", query)}`, width)];\n\t\t}\n\t\treturn [truncateToWidth(` ${theme.fg(\"muted\", \"Type to search:\")}`, width)];\n\t}\n\n\thandleInput(_keyData: string): void {}\n}\n\n/** Label input component shown when editing a label */\nclass LabelInput implements Component, Focusable {\n\tprivate input: Input;\n\tprivate entryId: string;\n\tpublic onSubmit?: (entryId: string, label: string | undefined) => void;\n\tpublic onCancel?: () => void;\n\n\t// Focusable implementation - propagate to input for IME cursor positioning\n\tprivate _focused = false;\n\tget focused(): boolean {\n\t\treturn this._focused;\n\t}\n\tset focused(value: boolean) {\n\t\tthis._focused = value;\n\t\tthis.input.focused = value;\n\t}\n\n\tconstructor(entryId: string, currentLabel: string | undefined) {\n\t\tthis.entryId = entryId;\n\t\tthis.input = new Input();\n\t\tif (currentLabel) {\n\t\t\tthis.input.setValue(currentLabel);\n\t\t}\n\t}\n\n\tinvalidate(): void {}\n\n\trender(width: number): string[] {\n\t\tconst lines: string[] = [];\n\t\tconst indent = \" \";\n\t\tconst availableWidth = width - indent.length;\n\t\tlines.push(truncateToWidth(`${indent}${theme.fg(\"muted\", \"Label (empty to remove):\")}`, width));\n\t\tlines.push(...this.input.render(availableWidth).map((line) => truncateToWidth(`${indent}${line}`, width)));\n\t\tlines.push(\n\t\t\ttruncateToWidth(`${indent}${keyHint(\"selectConfirm\", \"save\")} ${keyHint(\"selectCancel\", \"cancel\")}`, width),\n\t\t);\n\t\treturn lines;\n\t}\n\n\thandleInput(keyData: string): void {\n\t\tconst kb = getEditorKeybindings();\n\t\tif (kb.matches(keyData, \"selectConfirm\")) {\n\t\t\tconst value = this.input.getValue().trim();\n\t\t\tthis.onSubmit?.(this.entryId, value || undefined);\n\t\t} else if (kb.matches(keyData, \"selectCancel\")) {\n\t\t\tthis.onCancel?.();\n\t\t} else {\n\t\t\tthis.input.handleInput(keyData);\n\t\t}\n\t}\n}\n\n/**\n * Component that renders a session tree selector for navigation\n */\nexport class TreeSelectorComponent extends Container implements Focusable {\n\tprivate treeList: TreeList;\n\tprivate labelInput: LabelInput | null = null;\n\tprivate labelInputContainer: Container;\n\tprivate treeContainer: Container;\n\tprivate onLabelChangeCallback?: (entryId: string, label: string | undefined) => void;\n\n\t// Focusable implementation - propagate to labelInput when active for IME cursor positioning\n\tprivate _focused = false;\n\tget focused(): boolean {\n\t\treturn this._focused;\n\t}\n\tset focused(value: boolean) {\n\t\tthis._focused = value;\n\t\t// Propagate to labelInput when it's active\n\t\tif (this.labelInput) {\n\t\t\tthis.labelInput.focused = value;\n\t\t}\n\t}\n\n\tconstructor(\n\t\ttree: SessionTreeNode[],\n\t\tcurrentLeafId: string | null,\n\t\tterminalHeight: number,\n\t\tonSelect: (entryId: string) => void,\n\t\tonCancel: () => void,\n\t\tonLabelChange?: (entryId: string, label: string | undefined) => void,\n\t\tinitialSelectedId?: string,\n\t\tinitialFilterMode?: FilterMode,\n\t) {\n\t\tsuper();\n\n\t\tthis.onLabelChangeCallback = onLabelChange;\n\t\tconst maxVisibleLines = Math.max(5, Math.floor(terminalHeight / 2));\n\n\t\tthis.treeList = new TreeList(tree, currentLeafId, maxVisibleLines, initialSelectedId, initialFilterMode);\n\t\tthis.treeList.onSelect = onSelect;\n\t\tthis.treeList.onCancel = onCancel;\n\t\tthis.treeList.onLabelEdit = (entryId, currentLabel) => this.showLabelInput(entryId, currentLabel);\n\n\t\tthis.treeContainer = new Container();\n\t\tthis.treeContainer.addChild(this.treeList);\n\n\t\tthis.labelInputContainer = new Container();\n\n\t\tthis.addChild(new Spacer(1));\n\t\tthis.addChild(new DynamicBorder());\n\t\tthis.addChild(new Text(theme.bold(\" Session Tree\"), 1, 0));\n\t\tthis.addChild(\n\t\t\tnew TruncatedText(\n\t\t\t\ttheme.fg(\"muted\", \" ↑/↓: move. ←/→: page. Shift+L: label. \") +\n\t\t\t\t\ttheme.fg(\"muted\", \"^D/^T/^U/^L/^A: filters (^O/⇧^O cycle)\"),\n\t\t\t\t0,\n\t\t\t\t0,\n\t\t\t),\n\t\t);\n\t\tthis.addChild(new SearchLine(this.treeList));\n\t\tthis.addChild(new DynamicBorder());\n\t\tthis.addChild(new Spacer(1));\n\t\tthis.addChild(this.treeContainer);\n\t\tthis.addChild(this.labelInputContainer);\n\t\tthis.addChild(new Spacer(1));\n\t\tthis.addChild(new DynamicBorder());\n\n\t\tif (tree.length === 0) {\n\t\t\tsetTimeout(() => onCancel(), 100);\n\t\t}\n\t}\n\n\tprivate showLabelInput(entryId: string, currentLabel: string | undefined): void {\n\t\tthis.labelInput = new LabelInput(entryId, currentLabel);\n\t\tthis.labelInput.onSubmit = (id, label) => {\n\t\t\tthis.treeList.updateNodeLabel(id, label);\n\t\t\tthis.onLabelChangeCallback?.(id, label);\n\t\t\tthis.hideLabelInput();\n\t\t};\n\t\tthis.labelInput.onCancel = () => this.hideLabelInput();\n\n\t\t// Propagate current focused state to the new labelInput\n\t\tthis.labelInput.focused = this._focused;\n\n\t\tthis.treeContainer.clear();\n\t\tthis.labelInputContainer.clear();\n\t\tthis.labelInputContainer.addChild(this.labelInput);\n\t}\n\n\tprivate hideLabelInput(): void {\n\t\tthis.labelInput = null;\n\t\tthis.labelInputContainer.clear();\n\t\tthis.treeContainer.clear();\n\t\tthis.treeContainer.addChild(this.treeList);\n\t}\n\n\thandleInput(keyData: string): void {\n\t\tif (this.labelInput) {\n\t\t\tthis.labelInput.handleInput(keyData);\n\t\t} else {\n\t\t\tthis.treeList.handleInput(keyData);\n\t\t}\n\t}\n\n\tgetTreeList(): TreeList {\n\t\treturn this.treeList;\n\t}\n}\n"]}
1
+ {"version":3,"file":"tree-selector.d.ts","sourceRoot":"","sources":["../../../../src/modes/interactive/components/tree-selector.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n\ttype Component,\n\tContainer,\n\ttype Focusable,\n\tgetEditorKeybindings,\n\tInput,\n\tmatchesKey,\n\tSpacer,\n\tText,\n\tTruncatedText,\n\ttruncateToWidth,\n} from \"@mariozechner/pi-tui\";\nimport type { SessionTreeNode } from \"../../../core/session-manager.js\";\nimport { theme } from \"../theme/theme.js\";\nimport { DynamicBorder } from \"./dynamic-border.js\";\nimport { keyHint } from \"./keybinding-hints.js\";\n\n/** Gutter info: position (displayIndent where connector was) and whether to show │ */\ninterface GutterInfo {\n\tposition: number; // displayIndent level where the connector was shown\n\tshow: boolean; // true = show │, false = show spaces\n}\n\n/** Flattened tree node for navigation */\ninterface FlatNode {\n\tnode: SessionTreeNode;\n\t/** Indentation level (each level = 3 chars) */\n\tindent: number;\n\t/** Whether to show connector (├─ or └─) - true if parent has multiple children */\n\tshowConnector: boolean;\n\t/** If showConnector, true = last sibling (└─), false = not last (├─) */\n\tisLast: boolean;\n\t/** Gutter info for each ancestor branch point */\n\tgutters: GutterInfo[];\n\t/** True if this node is a root under a virtual branching root (multiple roots) */\n\tisVirtualRootChild: boolean;\n}\n\n/** Filter mode for tree display */\nexport type FilterMode = \"default\" | \"no-tools\" | \"user-only\" | \"labeled-only\" | \"all\";\n\n/**\n * Tree list component with selection and ASCII art visualization\n */\n/** Tool call info for lookup */\ninterface ToolCallInfo {\n\tname: string;\n\targuments: Record<string, unknown>;\n}\n\nclass TreeList implements Component {\n\tprivate flatNodes: FlatNode[] = [];\n\tprivate filteredNodes: FlatNode[] = [];\n\tprivate selectedIndex = 0;\n\tprivate currentLeafId: string | null;\n\tprivate maxVisibleLines: number;\n\tprivate filterMode: FilterMode = \"default\";\n\tprivate searchQuery = \"\";\n\tprivate toolCallMap: Map<string, ToolCallInfo> = new Map();\n\tprivate multipleRoots = false;\n\tprivate activePathIds: Set<string> = new Set();\n\tprivate visibleParentMap: Map<string, string | null> = new Map();\n\tprivate visibleChildrenMap: Map<string | null, string[]> = new Map();\n\tprivate lastSelectedId: string | null = null;\n\tprivate foldedNodes: Set<string> = new Set();\n\n\tpublic onSelect?: (entryId: string) => void;\n\tpublic onCancel?: () => void;\n\tpublic onLabelEdit?: (entryId: string, currentLabel: string | undefined) => void;\n\n\tconstructor(\n\t\ttree: SessionTreeNode[],\n\t\tcurrentLeafId: string | null,\n\t\tmaxVisibleLines: number,\n\t\tinitialSelectedId?: string,\n\t\tinitialFilterMode?: FilterMode,\n\t) {\n\t\tthis.currentLeafId = currentLeafId;\n\t\tthis.maxVisibleLines = maxVisibleLines;\n\t\tthis.filterMode = initialFilterMode ?? \"default\";\n\t\tthis.multipleRoots = tree.length > 1;\n\t\tthis.flatNodes = this.flattenTree(tree);\n\t\tthis.buildActivePath();\n\t\tthis.applyFilter();\n\n\t\t// Start with initialSelectedId if provided, otherwise current leaf\n\t\tconst targetId = initialSelectedId ?? currentLeafId;\n\t\tthis.selectedIndex = this.findNearestVisibleIndex(targetId);\n\t\tthis.lastSelectedId = this.filteredNodes[this.selectedIndex]?.node.entry.id ?? null;\n\t}\n\n\t/**\n\t * Find the index of the nearest visible entry, walking up the parent chain if needed.\n\t * Returns the index in filteredNodes, or the last index as fallback.\n\t */\n\tprivate findNearestVisibleIndex(entryId: string | null): number {\n\t\tif (this.filteredNodes.length === 0) return 0;\n\n\t\t// Build a map for parent lookup\n\t\tconst entryMap = new Map<string, FlatNode>();\n\t\tfor (const flatNode of this.flatNodes) {\n\t\t\tentryMap.set(flatNode.node.entry.id, flatNode);\n\t\t}\n\n\t\t// Build a map of visible entry IDs to their indices in filteredNodes\n\t\tconst visibleIdToIndex = new Map<string, number>(this.filteredNodes.map((node, i) => [node.node.entry.id, i]));\n\n\t\t// Walk from entryId up to root, looking for a visible entry\n\t\tlet currentId = entryId;\n\t\twhile (currentId !== null) {\n\t\t\tconst index = visibleIdToIndex.get(currentId);\n\t\t\tif (index !== undefined) return index;\n\t\t\tconst node = entryMap.get(currentId);\n\t\t\tif (!node) break;\n\t\t\tcurrentId = node.node.entry.parentId ?? null;\n\t\t}\n\n\t\t// Fallback: last visible entry\n\t\treturn this.filteredNodes.length - 1;\n\t}\n\n\t/** Build the set of entry IDs on the path from root to current leaf */\n\tprivate buildActivePath(): void {\n\t\tthis.activePathIds.clear();\n\t\tif (!this.currentLeafId) return;\n\n\t\t// Build a map of id -> entry for parent lookup\n\t\tconst entryMap = new Map<string, FlatNode>();\n\t\tfor (const flatNode of this.flatNodes) {\n\t\t\tentryMap.set(flatNode.node.entry.id, flatNode);\n\t\t}\n\n\t\t// Walk from leaf to root\n\t\tlet currentId: string | null = this.currentLeafId;\n\t\twhile (currentId) {\n\t\t\tthis.activePathIds.add(currentId);\n\t\t\tconst node = entryMap.get(currentId);\n\t\t\tif (!node) break;\n\t\t\tcurrentId = node.node.entry.parentId ?? null;\n\t\t}\n\t}\n\n\tprivate flattenTree(roots: SessionTreeNode[]): FlatNode[] {\n\t\tconst result: FlatNode[] = [];\n\t\tthis.toolCallMap.clear();\n\n\t\t// Indentation rules:\n\t\t// - At indent 0: stay at 0 unless parent has >1 children (then +1)\n\t\t// - At indent 1: children always go to indent 2 (visual grouping of subtree)\n\t\t// - At indent 2+: stay flat for single-child chains, +1 only if parent branches\n\n\t\t// Stack items: [node, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild]\n\t\ttype StackItem = [SessionTreeNode, number, boolean, boolean, boolean, GutterInfo[], boolean];\n\t\tconst stack: StackItem[] = [];\n\n\t\t// Determine which subtrees contain the active leaf (to sort current branch first)\n\t\t// Use iterative post-order traversal to avoid stack overflow\n\t\tconst containsActive = new Map<SessionTreeNode, boolean>();\n\t\tconst leafId = this.currentLeafId;\n\t\t{\n\t\t\t// Build list in pre-order, then process in reverse for post-order effect\n\t\t\tconst allNodes: SessionTreeNode[] = [];\n\t\t\tconst preOrderStack: SessionTreeNode[] = [...roots];\n\t\t\twhile (preOrderStack.length > 0) {\n\t\t\t\tconst node = preOrderStack.pop()!;\n\t\t\t\tallNodes.push(node);\n\t\t\t\t// Push children in reverse so they're processed left-to-right\n\t\t\t\tfor (let i = node.children.length - 1; i >= 0; i--) {\n\t\t\t\t\tpreOrderStack.push(node.children[i]);\n\t\t\t\t}\n\t\t\t}\n\t\t\t// Process in reverse (post-order): children before parents\n\t\t\tfor (let i = allNodes.length - 1; i >= 0; i--) {\n\t\t\t\tconst node = allNodes[i];\n\t\t\t\tlet has = leafId !== null && node.entry.id === leafId;\n\t\t\t\tfor (const child of node.children) {\n\t\t\t\t\tif (containsActive.get(child)) {\n\t\t\t\t\t\thas = true;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tcontainsActive.set(node, has);\n\t\t\t}\n\t\t}\n\n\t\t// Add roots in reverse order, prioritizing the one containing the active leaf\n\t\t// If multiple roots, treat them as children of a virtual root that branches\n\t\tconst multipleRoots = roots.length > 1;\n\t\tconst orderedRoots = [...roots].sort((a, b) => Number(containsActive.get(b)) - Number(containsActive.get(a)));\n\t\tfor (let i = orderedRoots.length - 1; i >= 0; i--) {\n\t\t\tconst isLast = i === orderedRoots.length - 1;\n\t\t\tstack.push([orderedRoots[i], multipleRoots ? 1 : 0, multipleRoots, multipleRoots, isLast, [], multipleRoots]);\n\t\t}\n\n\t\twhile (stack.length > 0) {\n\t\t\tconst [node, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild] = stack.pop()!;\n\n\t\t\t// Extract tool calls from assistant messages for later lookup\n\t\t\tconst entry = node.entry;\n\t\t\tif (entry.type === \"message\" && entry.message.role === \"assistant\") {\n\t\t\t\tconst content = (entry.message as { content?: unknown }).content;\n\t\t\t\tif (Array.isArray(content)) {\n\t\t\t\t\tfor (const block of content) {\n\t\t\t\t\t\tif (typeof block === \"object\" && block !== null && \"type\" in block && block.type === \"toolCall\") {\n\t\t\t\t\t\t\tconst tc = block as { id: string; name: string; arguments: Record<string, unknown> };\n\t\t\t\t\t\t\tthis.toolCallMap.set(tc.id, { name: tc.name, arguments: tc.arguments });\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tresult.push({ node, indent, showConnector, isLast, gutters, isVirtualRootChild });\n\n\t\t\tconst children = node.children;\n\t\t\tconst multipleChildren = children.length > 1;\n\n\t\t\t// Order children so the branch containing the active leaf comes first\n\t\t\tconst orderedChildren = (() => {\n\t\t\t\tconst prioritized: SessionTreeNode[] = [];\n\t\t\t\tconst rest: SessionTreeNode[] = [];\n\t\t\t\tfor (const child of children) {\n\t\t\t\t\tif (containsActive.get(child)) {\n\t\t\t\t\t\tprioritized.push(child);\n\t\t\t\t\t} else {\n\t\t\t\t\t\trest.push(child);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\treturn [...prioritized, ...rest];\n\t\t\t})();\n\n\t\t\t// Calculate child indent\n\t\t\tlet childIndent: number;\n\t\t\tif (multipleChildren) {\n\t\t\t\t// Parent branches: children get +1\n\t\t\t\tchildIndent = indent + 1;\n\t\t\t} else if (justBranched && indent > 0) {\n\t\t\t\t// First generation after a branch: +1 for visual grouping\n\t\t\t\tchildIndent = indent + 1;\n\t\t\t} else {\n\t\t\t\t// Single-child chain: stay flat\n\t\t\t\tchildIndent = indent;\n\t\t\t}\n\n\t\t\t// Build gutters for children\n\t\t\t// If this node showed a connector, add a gutter entry for descendants\n\t\t\t// Only add gutter if connector is actually displayed (not suppressed for virtual root children)\n\t\t\tconst connectorDisplayed = showConnector && !isVirtualRootChild;\n\t\t\t// When connector is displayed, add a gutter entry at the connector's position\n\t\t\t// Connector is at position (displayIndent - 1), so gutter should be there too\n\t\t\tconst currentDisplayIndent = this.multipleRoots ? Math.max(0, indent - 1) : indent;\n\t\t\tconst connectorPosition = Math.max(0, currentDisplayIndent - 1);\n\t\t\tconst childGutters: GutterInfo[] = connectorDisplayed\n\t\t\t\t? [...gutters, { position: connectorPosition, show: !isLast }]\n\t\t\t\t: gutters;\n\n\t\t\t// Add children in reverse order\n\t\t\tfor (let i = orderedChildren.length - 1; i >= 0; i--) {\n\t\t\t\tconst childIsLast = i === orderedChildren.length - 1;\n\t\t\t\tstack.push([\n\t\t\t\t\torderedChildren[i],\n\t\t\t\t\tchildIndent,\n\t\t\t\t\tmultipleChildren,\n\t\t\t\t\tmultipleChildren,\n\t\t\t\t\tchildIsLast,\n\t\t\t\t\tchildGutters,\n\t\t\t\t\tfalse,\n\t\t\t\t]);\n\t\t\t}\n\t\t}\n\n\t\treturn result;\n\t}\n\n\tprivate applyFilter(): void {\n\t\t// Update lastSelectedId only when we have a valid selection (non-empty list)\n\t\t// This preserves the selection when switching through empty filter results\n\t\tif (this.filteredNodes.length > 0) {\n\t\t\tthis.lastSelectedId = this.filteredNodes[this.selectedIndex]?.node.entry.id ?? this.lastSelectedId;\n\t\t}\n\n\t\tconst searchTokens = this.searchQuery.toLowerCase().split(/\\s+/).filter(Boolean);\n\n\t\tthis.filteredNodes = this.flatNodes.filter((flatNode) => {\n\t\t\tconst entry = flatNode.node.entry;\n\t\t\tconst isCurrentLeaf = entry.id === this.currentLeafId;\n\n\t\t\t// Skip assistant messages with only tool calls (no text) unless error/aborted\n\t\t\t// Always show current leaf so active position is visible\n\t\t\tif (entry.type === \"message\" && entry.message.role === \"assistant\" && !isCurrentLeaf) {\n\t\t\t\tconst msg = entry.message as { stopReason?: string; content?: unknown };\n\t\t\t\tconst hasText = this.hasTextContent(msg.content);\n\t\t\t\tconst isErrorOrAborted = msg.stopReason && msg.stopReason !== \"stop\" && msg.stopReason !== \"toolUse\";\n\t\t\t\t// Only hide if no text AND not an error/aborted message\n\t\t\t\tif (!hasText && !isErrorOrAborted) {\n\t\t\t\t\treturn false;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Apply filter mode\n\t\t\tlet passesFilter = true;\n\t\t\t// Entry types hidden in default view (settings/bookkeeping)\n\t\t\tconst isSettingsEntry =\n\t\t\t\tentry.type === \"label\" ||\n\t\t\t\tentry.type === \"custom\" ||\n\t\t\t\tentry.type === \"model_change\" ||\n\t\t\t\tentry.type === \"thinking_level_change\";\n\n\t\t\tswitch (this.filterMode) {\n\t\t\t\tcase \"user-only\":\n\t\t\t\t\t// Just user messages\n\t\t\t\t\tpassesFilter = entry.type === \"message\" && entry.message.role === \"user\";\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"no-tools\":\n\t\t\t\t\t// Default minus tool results\n\t\t\t\t\tpassesFilter = !isSettingsEntry && !(entry.type === \"message\" && entry.message.role === \"toolResult\");\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"labeled-only\":\n\t\t\t\t\t// Just labeled entries\n\t\t\t\t\tpassesFilter = flatNode.node.label !== undefined;\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"all\":\n\t\t\t\t\t// Show everything\n\t\t\t\t\tpassesFilter = true;\n\t\t\t\t\tbreak;\n\t\t\t\tdefault:\n\t\t\t\t\t// Default mode: hide settings/bookkeeping entries\n\t\t\t\t\tpassesFilter = !isSettingsEntry;\n\t\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tif (!passesFilter) return false;\n\n\t\t\t// Apply search filter\n\t\t\tif (searchTokens.length > 0) {\n\t\t\t\tconst nodeText = this.getSearchableText(flatNode.node).toLowerCase();\n\t\t\t\treturn searchTokens.every((token) => nodeText.includes(token));\n\t\t\t}\n\n\t\t\treturn true;\n\t\t});\n\n\t\t// Filter out descendants of folded nodes.\n\t\tif (this.foldedNodes.size > 0) {\n\t\t\tconst skipSet = new Set<string>();\n\t\t\tfor (const flatNode of this.flatNodes) {\n\t\t\t\tconst { id, parentId } = flatNode.node.entry;\n\t\t\t\tif (parentId != null && (this.foldedNodes.has(parentId) || skipSet.has(parentId))) {\n\t\t\t\t\tskipSet.add(id);\n\t\t\t\t}\n\t\t\t}\n\t\t\tthis.filteredNodes = this.filteredNodes.filter((flatNode) => !skipSet.has(flatNode.node.entry.id));\n\t\t}\n\n\t\t// Recalculate visual structure (indent, connectors, gutters) based on visible tree\n\t\tthis.recalculateVisualStructure();\n\n\t\t// Try to preserve cursor on the same node, or find nearest visible ancestor\n\t\tif (this.lastSelectedId) {\n\t\t\tthis.selectedIndex = this.findNearestVisibleIndex(this.lastSelectedId);\n\t\t} else if (this.selectedIndex >= this.filteredNodes.length) {\n\t\t\t// Clamp index if out of bounds\n\t\t\tthis.selectedIndex = Math.max(0, this.filteredNodes.length - 1);\n\t\t}\n\n\t\t// Update lastSelectedId to the actual selection (may have changed due to parent walk)\n\t\tif (this.filteredNodes.length > 0) {\n\t\t\tthis.lastSelectedId = this.filteredNodes[this.selectedIndex]?.node.entry.id ?? this.lastSelectedId;\n\t\t}\n\t}\n\n\t/**\n\t * Recompute indentation/connectors for the filtered view\n\t *\n\t * Filtering can hide intermediate entries; descendants attach to the nearest visible ancestor.\n\t * Keep indentation semantics aligned with flattenTree() so single-child chains don't drift right.\n\t */\n\tprivate recalculateVisualStructure(): void {\n\t\tif (this.filteredNodes.length === 0) return;\n\n\t\tconst visibleIds = new Set(this.filteredNodes.map((n) => n.node.entry.id));\n\n\t\t// Build entry map for efficient parent lookup (using full tree)\n\t\tconst entryMap = new Map<string, FlatNode>();\n\t\tfor (const flatNode of this.flatNodes) {\n\t\t\tentryMap.set(flatNode.node.entry.id, flatNode);\n\t\t}\n\n\t\t// Find nearest visible ancestor for a node\n\t\tconst findVisibleAncestor = (nodeId: string): string | null => {\n\t\t\tlet currentId = entryMap.get(nodeId)?.node.entry.parentId ?? null;\n\t\t\twhile (currentId !== null) {\n\t\t\t\tif (visibleIds.has(currentId)) {\n\t\t\t\t\treturn currentId;\n\t\t\t\t}\n\t\t\t\tcurrentId = entryMap.get(currentId)?.node.entry.parentId ?? null;\n\t\t\t}\n\t\t\treturn null;\n\t\t};\n\n\t\t// Build visible tree structure:\n\t\t// - visibleParent: nodeId → nearest visible ancestor (or null for roots)\n\t\t// - visibleChildren: parentId → list of visible children (in filteredNodes order)\n\t\tconst visibleParent = new Map<string, string | null>();\n\t\tconst visibleChildren = new Map<string | null, string[]>();\n\t\tvisibleChildren.set(null, []); // root-level nodes\n\n\t\tfor (const flatNode of this.filteredNodes) {\n\t\t\tconst nodeId = flatNode.node.entry.id;\n\t\t\tconst ancestorId = findVisibleAncestor(nodeId);\n\t\t\tvisibleParent.set(nodeId, ancestorId);\n\n\t\t\tif (!visibleChildren.has(ancestorId)) {\n\t\t\t\tvisibleChildren.set(ancestorId, []);\n\t\t\t}\n\t\t\tvisibleChildren.get(ancestorId)!.push(nodeId);\n\t\t}\n\n\t\t// Update multipleRoots based on visible roots\n\t\tconst visibleRootIds = visibleChildren.get(null)!;\n\t\tthis.multipleRoots = visibleRootIds.length > 1;\n\n\t\t// Build a map for quick lookup: nodeId → FlatNode\n\t\tconst filteredNodeMap = new Map<string, FlatNode>();\n\t\tfor (const flatNode of this.filteredNodes) {\n\t\t\tfilteredNodeMap.set(flatNode.node.entry.id, flatNode);\n\t\t}\n\n\t\t// DFS over the visible tree using flattenTree() indentation semantics\n\t\t// Stack items: [nodeId, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild]\n\t\ttype StackItem = [string, number, boolean, boolean, boolean, GutterInfo[], boolean];\n\t\tconst stack: StackItem[] = [];\n\n\t\t// Add visible roots in reverse order (to process in forward order via stack)\n\t\tfor (let i = visibleRootIds.length - 1; i >= 0; i--) {\n\t\t\tconst isLast = i === visibleRootIds.length - 1;\n\t\t\tstack.push([\n\t\t\t\tvisibleRootIds[i],\n\t\t\t\tthis.multipleRoots ? 1 : 0,\n\t\t\t\tthis.multipleRoots,\n\t\t\t\tthis.multipleRoots,\n\t\t\t\tisLast,\n\t\t\t\t[],\n\t\t\t\tthis.multipleRoots,\n\t\t\t]);\n\t\t}\n\n\t\twhile (stack.length > 0) {\n\t\t\tconst [nodeId, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild] = stack.pop()!;\n\n\t\t\tconst flatNode = filteredNodeMap.get(nodeId);\n\t\t\tif (!flatNode) continue;\n\n\t\t\t// Update this node's visual properties\n\t\t\tflatNode.indent = indent;\n\t\t\tflatNode.showConnector = showConnector;\n\t\t\tflatNode.isLast = isLast;\n\t\t\tflatNode.gutters = gutters;\n\t\t\tflatNode.isVirtualRootChild = isVirtualRootChild;\n\n\t\t\t// Get visible children of this node\n\t\t\tconst children = visibleChildren.get(nodeId) || [];\n\t\t\tconst multipleChildren = children.length > 1;\n\n\t\t\t// Child indent follows flattenTree(): branch points (and first generation after a branch) shift +1\n\t\t\tlet childIndent: number;\n\t\t\tif (multipleChildren) {\n\t\t\t\tchildIndent = indent + 1;\n\t\t\t} else if (justBranched && indent > 0) {\n\t\t\t\tchildIndent = indent + 1;\n\t\t\t} else {\n\t\t\t\tchildIndent = indent;\n\t\t\t}\n\n\t\t\t// Child gutters follow flattenTree() connector/gutter rules\n\t\t\tconst connectorDisplayed = showConnector && !isVirtualRootChild;\n\t\t\tconst currentDisplayIndent = this.multipleRoots ? Math.max(0, indent - 1) : indent;\n\t\t\tconst connectorPosition = Math.max(0, currentDisplayIndent - 1);\n\t\t\tconst childGutters: GutterInfo[] = connectorDisplayed\n\t\t\t\t? [...gutters, { position: connectorPosition, show: !isLast }]\n\t\t\t\t: gutters;\n\n\t\t\t// Add children in reverse order (to process in forward order via stack)\n\t\t\tfor (let i = children.length - 1; i >= 0; i--) {\n\t\t\t\tconst childIsLast = i === children.length - 1;\n\t\t\t\tstack.push([\n\t\t\t\t\tchildren[i],\n\t\t\t\t\tchildIndent,\n\t\t\t\t\tmultipleChildren,\n\t\t\t\t\tmultipleChildren,\n\t\t\t\t\tchildIsLast,\n\t\t\t\t\tchildGutters,\n\t\t\t\t\tfalse,\n\t\t\t\t]);\n\t\t\t}\n\t\t}\n\n\t\t// Store visible tree maps for ancestor/descendant lookups in navigation\n\t\tthis.visibleParentMap = visibleParent;\n\t\tthis.visibleChildrenMap = visibleChildren;\n\t}\n\n\t/** Get searchable text content from a node */\n\tprivate getSearchableText(node: SessionTreeNode): string {\n\t\tconst entry = node.entry;\n\t\tconst parts: string[] = [];\n\n\t\tif (node.label) {\n\t\t\tparts.push(node.label);\n\t\t}\n\n\t\tswitch (entry.type) {\n\t\t\tcase \"message\": {\n\t\t\t\tconst msg = entry.message;\n\t\t\t\tparts.push(msg.role);\n\t\t\t\tif (\"content\" in msg && msg.content) {\n\t\t\t\t\tparts.push(this.extractContent(msg.content));\n\t\t\t\t}\n\t\t\t\tif (msg.role === \"bashExecution\") {\n\t\t\t\t\tconst bashMsg = msg as { command?: string };\n\t\t\t\t\tif (bashMsg.command) parts.push(bashMsg.command);\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"custom_message\": {\n\t\t\t\tparts.push(entry.customType);\n\t\t\t\tif (typeof entry.content === \"string\") {\n\t\t\t\t\tparts.push(entry.content);\n\t\t\t\t} else {\n\t\t\t\t\tparts.push(this.extractContent(entry.content));\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"compaction\":\n\t\t\t\tparts.push(\"compaction\");\n\t\t\t\tbreak;\n\t\t\tcase \"branch_summary\":\n\t\t\t\tparts.push(\"branch summary\", entry.summary);\n\t\t\t\tbreak;\n\t\t\tcase \"model_change\":\n\t\t\t\tparts.push(\"model\", entry.modelId);\n\t\t\t\tbreak;\n\t\t\tcase \"thinking_level_change\":\n\t\t\t\tparts.push(\"thinking\", entry.thinkingLevel);\n\t\t\t\tbreak;\n\t\t\tcase \"custom\":\n\t\t\t\tparts.push(\"custom\", entry.customType);\n\t\t\t\tbreak;\n\t\t\tcase \"label\":\n\t\t\t\tparts.push(\"label\", entry.label ?? \"\");\n\t\t\t\tbreak;\n\t\t}\n\n\t\treturn parts.join(\" \");\n\t}\n\n\tinvalidate(): void {}\n\n\tgetSearchQuery(): string {\n\t\treturn this.searchQuery;\n\t}\n\n\tgetSelectedNode(): SessionTreeNode | undefined {\n\t\treturn this.filteredNodes[this.selectedIndex]?.node;\n\t}\n\n\tupdateNodeLabel(entryId: string, label: string | undefined): void {\n\t\tfor (const flatNode of this.flatNodes) {\n\t\t\tif (flatNode.node.entry.id === entryId) {\n\t\t\t\tflatNode.node.label = label;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate getFilterLabel(): string {\n\t\tswitch (this.filterMode) {\n\t\t\tcase \"no-tools\":\n\t\t\t\treturn \" [no-tools]\";\n\t\t\tcase \"user-only\":\n\t\t\t\treturn \" [user]\";\n\t\t\tcase \"labeled-only\":\n\t\t\t\treturn \" [labeled]\";\n\t\t\tcase \"all\":\n\t\t\t\treturn \" [all]\";\n\t\t\tdefault:\n\t\t\t\treturn \"\";\n\t\t}\n\t}\n\n\trender(width: number): string[] {\n\t\tconst lines: string[] = [];\n\n\t\tif (this.filteredNodes.length === 0) {\n\t\t\tlines.push(truncateToWidth(theme.fg(\"muted\", \" No entries found\"), width));\n\t\t\tlines.push(truncateToWidth(theme.fg(\"muted\", ` (0/0)${this.getFilterLabel()}`), width));\n\t\t\treturn lines;\n\t\t}\n\n\t\tconst startIndex = Math.max(\n\t\t\t0,\n\t\t\tMath.min(\n\t\t\t\tthis.selectedIndex - Math.floor(this.maxVisibleLines / 2),\n\t\t\t\tthis.filteredNodes.length - this.maxVisibleLines,\n\t\t\t),\n\t\t);\n\t\tconst endIndex = Math.min(startIndex + this.maxVisibleLines, this.filteredNodes.length);\n\n\t\tfor (let i = startIndex; i < endIndex; i++) {\n\t\t\tconst flatNode = this.filteredNodes[i];\n\t\t\tconst entry = flatNode.node.entry;\n\t\t\tconst isSelected = i === this.selectedIndex;\n\n\t\t\t// Build line: cursor + prefix + path marker + label + content\n\t\t\tconst cursor = isSelected ? theme.fg(\"accent\", \"› \") : \" \";\n\n\t\t\t// If multiple roots, shift display (roots at 0, not 1)\n\t\t\tconst displayIndent = this.multipleRoots ? Math.max(0, flatNode.indent - 1) : flatNode.indent;\n\n\t\t\t// Build prefix with gutters at their correct positions\n\t\t\t// Each gutter has a position (displayIndent where its connector was shown)\n\t\t\tconst connector =\n\t\t\t\tflatNode.showConnector && !flatNode.isVirtualRootChild ? (flatNode.isLast ? \"└─ \" : \"├─ \") : \"\";\n\t\t\tconst connectorPosition = connector ? displayIndent - 1 : -1;\n\n\t\t\t// Build prefix char by char, placing gutters and connector at their positions\n\t\t\tconst totalChars = displayIndent * 3;\n\t\t\tconst prefixChars: string[] = [];\n\t\t\tconst isFolded = this.foldedNodes.has(entry.id);\n\t\t\tfor (let i = 0; i < totalChars; i++) {\n\t\t\t\tconst level = Math.floor(i / 3);\n\t\t\t\tconst posInLevel = i % 3;\n\n\t\t\t\t// Check if there's a gutter at this level\n\t\t\t\tconst gutter = flatNode.gutters.find((g) => g.position === level);\n\t\t\t\tif (gutter) {\n\t\t\t\t\tif (posInLevel === 0) {\n\t\t\t\t\t\tprefixChars.push(gutter.show ? \"│\" : \" \");\n\t\t\t\t\t} else {\n\t\t\t\t\t\tprefixChars.push(\" \");\n\t\t\t\t\t}\n\t\t\t\t} else if (connector && level === connectorPosition) {\n\t\t\t\t\t// Connector at this level, with fold indicator\n\t\t\t\t\tif (posInLevel === 0) {\n\t\t\t\t\t\tprefixChars.push(flatNode.isLast ? \"└\" : \"├\");\n\t\t\t\t\t} else if (posInLevel === 1) {\n\t\t\t\t\t\tconst foldable = this.isFoldable(entry.id);\n\t\t\t\t\t\tprefixChars.push(isFolded ? \"⊞\" : foldable ? \"⊟\" : \"─\");\n\t\t\t\t\t} else {\n\t\t\t\t\t\tprefixChars.push(\" \");\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tprefixChars.push(\" \");\n\t\t\t\t}\n\t\t\t}\n\t\t\tconst prefix = prefixChars.join(\"\");\n\n\t\t\t// Fold marker for nodes without connectors (roots)\n\t\t\tconst showsFoldInConnector = flatNode.showConnector && !flatNode.isVirtualRootChild;\n\t\t\tconst foldMarker = isFolded && !showsFoldInConnector ? theme.fg(\"accent\", \"⊞ \") : \"\";\n\n\t\t\t// Active path marker - shown right before the entry text\n\t\t\tconst isOnActivePath = this.activePathIds.has(entry.id);\n\t\t\tconst pathMarker = isOnActivePath ? theme.fg(\"accent\", \"• \") : \"\";\n\n\t\t\tconst label = flatNode.node.label ? theme.fg(\"warning\", `[${flatNode.node.label}] `) : \"\";\n\t\t\tconst content = this.getEntryDisplayText(flatNode.node, isSelected);\n\n\t\t\tlet line = cursor + theme.fg(\"dim\", prefix) + foldMarker + pathMarker + label + content;\n\t\t\tif (isSelected) {\n\t\t\t\tline = theme.bg(\"selectedBg\", line);\n\t\t\t}\n\t\t\tlines.push(truncateToWidth(line, width));\n\t\t}\n\n\t\tlines.push(\n\t\t\ttruncateToWidth(\n\t\t\t\ttheme.fg(\"muted\", ` (${this.selectedIndex + 1}/${this.filteredNodes.length})${this.getFilterLabel()}`),\n\t\t\t\twidth,\n\t\t\t),\n\t\t);\n\n\t\treturn lines;\n\t}\n\n\tprivate getEntryDisplayText(node: SessionTreeNode, isSelected: boolean): string {\n\t\tconst entry = node.entry;\n\t\tlet result: string;\n\n\t\tconst normalize = (s: string) => s.replace(/[\\n\\t]/g, \" \").trim();\n\n\t\tswitch (entry.type) {\n\t\t\tcase \"message\": {\n\t\t\t\tconst msg = entry.message;\n\t\t\t\tconst role = msg.role;\n\t\t\t\tif (role === \"user\") {\n\t\t\t\t\tconst msgWithContent = msg as { content?: unknown };\n\t\t\t\t\tconst content = normalize(this.extractContent(msgWithContent.content));\n\t\t\t\t\tresult = theme.fg(\"accent\", \"user: \") + content;\n\t\t\t\t} else if (role === \"assistant\") {\n\t\t\t\t\tconst msgWithContent = msg as { content?: unknown; stopReason?: string; errorMessage?: string };\n\t\t\t\t\tconst textContent = normalize(this.extractContent(msgWithContent.content));\n\t\t\t\t\tif (textContent) {\n\t\t\t\t\t\tresult = theme.fg(\"success\", \"assistant: \") + textContent;\n\t\t\t\t\t} else if (msgWithContent.stopReason === \"aborted\") {\n\t\t\t\t\t\tresult = theme.fg(\"success\", \"assistant: \") + theme.fg(\"muted\", \"(aborted)\");\n\t\t\t\t\t} else if (msgWithContent.errorMessage) {\n\t\t\t\t\t\tconst errMsg = normalize(msgWithContent.errorMessage).slice(0, 80);\n\t\t\t\t\t\tresult = theme.fg(\"success\", \"assistant: \") + theme.fg(\"error\", errMsg);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tresult = theme.fg(\"success\", \"assistant: \") + theme.fg(\"muted\", \"(no content)\");\n\t\t\t\t\t}\n\t\t\t\t} else if (role === \"toolResult\") {\n\t\t\t\t\tconst toolMsg = msg as { toolCallId?: string; toolName?: string };\n\t\t\t\t\tconst toolCall = toolMsg.toolCallId ? this.toolCallMap.get(toolMsg.toolCallId) : undefined;\n\t\t\t\t\tif (toolCall) {\n\t\t\t\t\t\tresult = theme.fg(\"muted\", this.formatToolCall(toolCall.name, toolCall.arguments));\n\t\t\t\t\t} else {\n\t\t\t\t\t\tresult = theme.fg(\"muted\", `[${toolMsg.toolName ?? \"tool\"}]`);\n\t\t\t\t\t}\n\t\t\t\t} else if (role === \"bashExecution\") {\n\t\t\t\t\tconst bashMsg = msg as { command?: string };\n\t\t\t\t\tresult = theme.fg(\"dim\", `[bash]: ${normalize(bashMsg.command ?? \"\")}`);\n\t\t\t\t} else {\n\t\t\t\t\tresult = theme.fg(\"dim\", `[${role}]`);\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"custom_message\": {\n\t\t\t\tconst content =\n\t\t\t\t\ttypeof entry.content === \"string\"\n\t\t\t\t\t\t? entry.content\n\t\t\t\t\t\t: entry.content\n\t\t\t\t\t\t\t\t.filter((c): c is { type: \"text\"; text: string } => c.type === \"text\")\n\t\t\t\t\t\t\t\t.map((c) => c.text)\n\t\t\t\t\t\t\t\t.join(\"\");\n\t\t\t\tresult = theme.fg(\"customMessageLabel\", `[${entry.customType}]: `) + normalize(content);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"compaction\": {\n\t\t\t\tconst tokens = Math.round(entry.tokensBefore / 1000);\n\t\t\t\tresult = theme.fg(\"borderAccent\", `[compaction: ${tokens}k tokens]`);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"branch_summary\":\n\t\t\t\tresult = theme.fg(\"warning\", `[branch summary]: `) + normalize(entry.summary);\n\t\t\t\tbreak;\n\t\t\tcase \"model_change\":\n\t\t\t\tresult = theme.fg(\"dim\", `[model: ${entry.modelId}]`);\n\t\t\t\tbreak;\n\t\t\tcase \"thinking_level_change\":\n\t\t\t\tresult = theme.fg(\"dim\", `[thinking: ${entry.thinkingLevel}]`);\n\t\t\t\tbreak;\n\t\t\tcase \"custom\":\n\t\t\t\tresult = theme.fg(\"dim\", `[custom: ${entry.customType}]`);\n\t\t\t\tbreak;\n\t\t\tcase \"label\":\n\t\t\t\tresult = theme.fg(\"dim\", `[label: ${entry.label ?? \"(cleared)\"}]`);\n\t\t\t\tbreak;\n\t\t\tdefault:\n\t\t\t\tresult = \"\";\n\t\t}\n\n\t\treturn isSelected ? theme.bold(result) : result;\n\t}\n\n\tprivate extractContent(content: unknown): string {\n\t\tconst maxLen = 200;\n\t\tif (typeof content === \"string\") return content.slice(0, maxLen);\n\t\tif (Array.isArray(content)) {\n\t\t\tlet result = \"\";\n\t\t\tfor (const c of content) {\n\t\t\t\tif (typeof c === \"object\" && c !== null && \"type\" in c && c.type === \"text\") {\n\t\t\t\t\tresult += (c as { text: string }).text;\n\t\t\t\t\tif (result.length >= maxLen) return result.slice(0, maxLen);\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn result;\n\t\t}\n\t\treturn \"\";\n\t}\n\n\tprivate hasTextContent(content: unknown): boolean {\n\t\tif (typeof content === \"string\") return content.trim().length > 0;\n\t\tif (Array.isArray(content)) {\n\t\t\tfor (const c of content) {\n\t\t\t\tif (typeof c === \"object\" && c !== null && \"type\" in c && c.type === \"text\") {\n\t\t\t\t\tconst text = (c as { text?: string }).text;\n\t\t\t\t\tif (text && text.trim().length > 0) return true;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn false;\n\t}\n\n\tprivate formatToolCall(name: string, args: Record<string, unknown>): string {\n\t\tconst shortenPath = (p: string): string => {\n\t\t\tconst home = process.env.HOME || process.env.USERPROFILE || \"\";\n\t\t\tif (home && p.startsWith(home)) return `~${p.slice(home.length)}`;\n\t\t\treturn p;\n\t\t};\n\n\t\tswitch (name) {\n\t\t\tcase \"read\": {\n\t\t\t\tconst path = shortenPath(String(args.path || args.file_path || \"\"));\n\t\t\t\tconst offset = args.offset as number | undefined;\n\t\t\t\tconst limit = args.limit as number | undefined;\n\t\t\t\tlet display = path;\n\t\t\t\tif (offset !== undefined || limit !== undefined) {\n\t\t\t\t\tconst start = offset ?? 1;\n\t\t\t\t\tconst end = limit !== undefined ? start + limit - 1 : \"\";\n\t\t\t\t\tdisplay += `:${start}${end ? `-${end}` : \"\"}`;\n\t\t\t\t}\n\t\t\t\treturn `[read: ${display}]`;\n\t\t\t}\n\t\t\tcase \"write\": {\n\t\t\t\tconst path = shortenPath(String(args.path || args.file_path || \"\"));\n\t\t\t\treturn `[write: ${path}]`;\n\t\t\t}\n\t\t\tcase \"edit\": {\n\t\t\t\tconst path = shortenPath(String(args.path || args.file_path || \"\"));\n\t\t\t\treturn `[edit: ${path}]`;\n\t\t\t}\n\t\t\tcase \"bash\": {\n\t\t\t\tconst rawCmd = String(args.command || \"\");\n\t\t\t\tconst cmd = rawCmd\n\t\t\t\t\t.replace(/[\\n\\t]/g, \" \")\n\t\t\t\t\t.trim()\n\t\t\t\t\t.slice(0, 50);\n\t\t\t\treturn `[bash: ${cmd}${rawCmd.length > 50 ? \"...\" : \"\"}]`;\n\t\t\t}\n\t\t\tcase \"grep\": {\n\t\t\t\tconst pattern = String(args.pattern || \"\");\n\t\t\t\tconst path = shortenPath(String(args.path || \".\"));\n\t\t\t\treturn `[grep: /${pattern}/ in ${path}]`;\n\t\t\t}\n\t\t\tcase \"find\": {\n\t\t\t\tconst pattern = String(args.pattern || \"\");\n\t\t\t\tconst path = shortenPath(String(args.path || \".\"));\n\t\t\t\treturn `[find: ${pattern} in ${path}]`;\n\t\t\t}\n\t\t\tcase \"ls\": {\n\t\t\t\tconst path = shortenPath(String(args.path || \".\"));\n\t\t\t\treturn `[ls: ${path}]`;\n\t\t\t}\n\t\t\tdefault: {\n\t\t\t\t// Custom tool - show name and truncated JSON args\n\t\t\t\tconst argsStr = JSON.stringify(args).slice(0, 40);\n\t\t\t\treturn `[${name}: ${argsStr}${JSON.stringify(args).length > 40 ? \"...\" : \"\"}]`;\n\t\t\t}\n\t\t}\n\t}\n\n\thandleInput(keyData: string): void {\n\t\tconst kb = getEditorKeybindings();\n\t\tif (kb.matches(keyData, \"selectUp\")) {\n\t\t\tthis.selectedIndex = this.selectedIndex === 0 ? this.filteredNodes.length - 1 : this.selectedIndex - 1;\n\t\t} else if (kb.matches(keyData, \"selectDown\")) {\n\t\t\tthis.selectedIndex = this.selectedIndex === this.filteredNodes.length - 1 ? 0 : this.selectedIndex + 1;\n\t\t} else if (kb.matches(keyData, \"treeFoldOrUp\")) {\n\t\t\tconst currentId = this.filteredNodes[this.selectedIndex]?.node.entry.id;\n\t\t\tif (currentId && this.isFoldable(currentId) && !this.foldedNodes.has(currentId)) {\n\t\t\t\tthis.foldedNodes.add(currentId);\n\t\t\t\tthis.applyFilter();\n\t\t\t} else {\n\t\t\t\tthis.selectedIndex = this.findBranchSegmentStart(\"up\");\n\t\t\t}\n\t\t} else if (kb.matches(keyData, \"treeUnfoldOrDown\")) {\n\t\t\tconst currentId = this.filteredNodes[this.selectedIndex]?.node.entry.id;\n\t\t\tif (currentId && this.foldedNodes.has(currentId)) {\n\t\t\t\tthis.foldedNodes.delete(currentId);\n\t\t\t\tthis.applyFilter();\n\t\t\t} else {\n\t\t\t\tthis.selectedIndex = this.findBranchSegmentStart(\"down\");\n\t\t\t}\n\t\t} else if (kb.matches(keyData, \"cursorLeft\") || kb.matches(keyData, \"selectPageUp\")) {\n\t\t\t// Page up\n\t\t\tthis.selectedIndex = Math.max(0, this.selectedIndex - this.maxVisibleLines);\n\t\t} else if (kb.matches(keyData, \"cursorRight\") || kb.matches(keyData, \"selectPageDown\")) {\n\t\t\t// Page down\n\t\t\tthis.selectedIndex = Math.min(this.filteredNodes.length - 1, this.selectedIndex + this.maxVisibleLines);\n\t\t} else if (kb.matches(keyData, \"selectConfirm\")) {\n\t\t\tconst selected = this.filteredNodes[this.selectedIndex];\n\t\t\tif (selected && this.onSelect) {\n\t\t\t\tthis.onSelect(selected.node.entry.id);\n\t\t\t}\n\t\t} else if (kb.matches(keyData, \"selectCancel\")) {\n\t\t\tif (this.searchQuery) {\n\t\t\t\tthis.searchQuery = \"\";\n\t\t\t\tthis.foldedNodes.clear();\n\t\t\t\tthis.applyFilter();\n\t\t\t} else {\n\t\t\t\tthis.onCancel?.();\n\t\t\t}\n\t\t} else if (matchesKey(keyData, \"ctrl+d\")) {\n\t\t\t// Direct filter: default\n\t\t\tthis.filterMode = \"default\";\n\t\t\tthis.foldedNodes.clear();\n\t\t\tthis.applyFilter();\n\t\t} else if (matchesKey(keyData, \"ctrl+t\")) {\n\t\t\t// Toggle filter: no-tools ↔ default\n\t\t\tthis.filterMode = this.filterMode === \"no-tools\" ? \"default\" : \"no-tools\";\n\t\t\tthis.foldedNodes.clear();\n\t\t\tthis.applyFilter();\n\t\t} else if (matchesKey(keyData, \"ctrl+u\")) {\n\t\t\t// Toggle filter: user-only ↔ default\n\t\t\tthis.filterMode = this.filterMode === \"user-only\" ? \"default\" : \"user-only\";\n\t\t\tthis.foldedNodes.clear();\n\t\t\tthis.applyFilter();\n\t\t} else if (matchesKey(keyData, \"ctrl+l\")) {\n\t\t\t// Toggle filter: labeled-only ↔ default\n\t\t\tthis.filterMode = this.filterMode === \"labeled-only\" ? \"default\" : \"labeled-only\";\n\t\t\tthis.foldedNodes.clear();\n\t\t\tthis.applyFilter();\n\t\t} else if (matchesKey(keyData, \"ctrl+a\")) {\n\t\t\t// Toggle filter: all ↔ default\n\t\t\tthis.filterMode = this.filterMode === \"all\" ? \"default\" : \"all\";\n\t\t\tthis.foldedNodes.clear();\n\t\t\tthis.applyFilter();\n\t\t} else if (matchesKey(keyData, \"shift+ctrl+o\")) {\n\t\t\t// Cycle filter backwards\n\t\t\tconst modes: FilterMode[] = [\"default\", \"no-tools\", \"user-only\", \"labeled-only\", \"all\"];\n\t\t\tconst currentIndex = modes.indexOf(this.filterMode);\n\t\t\tthis.filterMode = modes[(currentIndex - 1 + modes.length) % modes.length];\n\t\t\tthis.foldedNodes.clear();\n\t\t\tthis.applyFilter();\n\t\t} else if (matchesKey(keyData, \"ctrl+o\")) {\n\t\t\t// Cycle filter forwards: default → no-tools → user-only → labeled-only → all → default\n\t\t\tconst modes: FilterMode[] = [\"default\", \"no-tools\", \"user-only\", \"labeled-only\", \"all\"];\n\t\t\tconst currentIndex = modes.indexOf(this.filterMode);\n\t\t\tthis.filterMode = modes[(currentIndex + 1) % modes.length];\n\t\t\tthis.foldedNodes.clear();\n\t\t\tthis.applyFilter();\n\t\t} else if (kb.matches(keyData, \"deleteCharBackward\")) {\n\t\t\tif (this.searchQuery.length > 0) {\n\t\t\t\tthis.searchQuery = this.searchQuery.slice(0, -1);\n\t\t\t\tthis.foldedNodes.clear();\n\t\t\t\tthis.applyFilter();\n\t\t\t}\n\t\t} else if (matchesKey(keyData, \"shift+l\")) {\n\t\t\tconst selected = this.filteredNodes[this.selectedIndex];\n\t\t\tif (selected && this.onLabelEdit) {\n\t\t\t\tthis.onLabelEdit(selected.node.entry.id, selected.node.label);\n\t\t\t}\n\t\t} else {\n\t\t\tconst hasControlChars = [...keyData].some((ch) => {\n\t\t\t\tconst code = ch.charCodeAt(0);\n\t\t\t\treturn code < 32 || code === 0x7f || (code >= 0x80 && code <= 0x9f);\n\t\t\t});\n\t\t\tif (!hasControlChars && keyData.length > 0) {\n\t\t\t\tthis.searchQuery += keyData;\n\t\t\t\tthis.foldedNodes.clear();\n\t\t\t\tthis.applyFilter();\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * Whether a node can be folded. A node is foldable if it has visible children\n\t * and is either a root (no visible parent) or a segment start (visible parent\n\t * has multiple visible children).\n\t */\n\tprivate isFoldable(entryId: string): boolean {\n\t\tconst children = this.visibleChildrenMap.get(entryId);\n\t\tif (!children || children.length === 0) return false;\n\t\tconst parentId = this.visibleParentMap.get(entryId);\n\t\tif (parentId === null || parentId === undefined) return true;\n\t\tconst siblings = this.visibleChildrenMap.get(parentId);\n\t\treturn siblings !== undefined && siblings.length > 1;\n\t}\n\n\t/**\n\t * Find the index of the next branch segment start in the given direction.\n\t * A segment start is the first child of a branch point.\n\t *\n\t * \"up\" walks the visible parent chain; \"down\" walks visible children\n\t * (always following the first child).\n\t */\n\tprivate findBranchSegmentStart(direction: \"up\" | \"down\"): number {\n\t\tconst selectedId = this.filteredNodes[this.selectedIndex]?.node.entry.id;\n\t\tif (!selectedId) return this.selectedIndex;\n\n\t\tconst indexByEntryId = new Map(this.filteredNodes.map((node, i) => [node.node.entry.id, i]));\n\t\tlet currentId: string = selectedId;\n\t\tif (direction === \"down\") {\n\t\t\twhile (true) {\n\t\t\t\tconst children: string[] = this.visibleChildrenMap.get(currentId) ?? [];\n\t\t\t\tif (children.length === 0) return indexByEntryId.get(currentId)!;\n\t\t\t\tif (children.length > 1) return indexByEntryId.get(children[0])!;\n\t\t\t\tcurrentId = children[0];\n\t\t\t}\n\t\t}\n\n\t\t// direction === \"up\"\n\t\twhile (true) {\n\t\t\tconst parentId: string | null = this.visibleParentMap.get(currentId) ?? null;\n\t\t\tif (parentId === null) return indexByEntryId.get(currentId)!;\n\t\t\tconst children = this.visibleChildrenMap.get(parentId) ?? [];\n\t\t\tif (children.length > 1) {\n\t\t\t\tconst segmentStart = indexByEntryId.get(currentId)!;\n\t\t\t\tif (segmentStart < this.selectedIndex) {\n\t\t\t\t\treturn segmentStart;\n\t\t\t\t}\n\t\t\t}\n\t\t\tcurrentId = parentId;\n\t\t}\n\t}\n}\n\n/** Component that displays the current search query */\nclass SearchLine implements Component {\n\tconstructor(private treeList: TreeList) {}\n\n\tinvalidate(): void {}\n\n\trender(width: number): string[] {\n\t\tconst query = this.treeList.getSearchQuery();\n\t\tif (query) {\n\t\t\treturn [truncateToWidth(` ${theme.fg(\"muted\", \"Type to search:\")} ${theme.fg(\"accent\", query)}`, width)];\n\t\t}\n\t\treturn [truncateToWidth(` ${theme.fg(\"muted\", \"Type to search:\")}`, width)];\n\t}\n\n\thandleInput(_keyData: string): void {}\n}\n\n/** Label input component shown when editing a label */\nclass LabelInput implements Component, Focusable {\n\tprivate input: Input;\n\tprivate entryId: string;\n\tpublic onSubmit?: (entryId: string, label: string | undefined) => void;\n\tpublic onCancel?: () => void;\n\n\t// Focusable implementation - propagate to input for IME cursor positioning\n\tprivate _focused = false;\n\tget focused(): boolean {\n\t\treturn this._focused;\n\t}\n\tset focused(value: boolean) {\n\t\tthis._focused = value;\n\t\tthis.input.focused = value;\n\t}\n\n\tconstructor(entryId: string, currentLabel: string | undefined) {\n\t\tthis.entryId = entryId;\n\t\tthis.input = new Input();\n\t\tif (currentLabel) {\n\t\t\tthis.input.setValue(currentLabel);\n\t\t}\n\t}\n\n\tinvalidate(): void {}\n\n\trender(width: number): string[] {\n\t\tconst lines: string[] = [];\n\t\tconst indent = \" \";\n\t\tconst availableWidth = width - indent.length;\n\t\tlines.push(truncateToWidth(`${indent}${theme.fg(\"muted\", \"Label (empty to remove):\")}`, width));\n\t\tlines.push(...this.input.render(availableWidth).map((line) => truncateToWidth(`${indent}${line}`, width)));\n\t\tlines.push(\n\t\t\ttruncateToWidth(`${indent}${keyHint(\"selectConfirm\", \"save\")} ${keyHint(\"selectCancel\", \"cancel\")}`, width),\n\t\t);\n\t\treturn lines;\n\t}\n\n\thandleInput(keyData: string): void {\n\t\tconst kb = getEditorKeybindings();\n\t\tif (kb.matches(keyData, \"selectConfirm\")) {\n\t\t\tconst value = this.input.getValue().trim();\n\t\t\tthis.onSubmit?.(this.entryId, value || undefined);\n\t\t} else if (kb.matches(keyData, \"selectCancel\")) {\n\t\t\tthis.onCancel?.();\n\t\t} else {\n\t\t\tthis.input.handleInput(keyData);\n\t\t}\n\t}\n}\n\n/**\n * Component that renders a session tree selector for navigation\n */\nexport class TreeSelectorComponent extends Container implements Focusable {\n\tprivate treeList: TreeList;\n\tprivate labelInput: LabelInput | null = null;\n\tprivate labelInputContainer: Container;\n\tprivate treeContainer: Container;\n\tprivate onLabelChangeCallback?: (entryId: string, label: string | undefined) => void;\n\n\t// Focusable implementation - propagate to labelInput when active for IME cursor positioning\n\tprivate _focused = false;\n\tget focused(): boolean {\n\t\treturn this._focused;\n\t}\n\tset focused(value: boolean) {\n\t\tthis._focused = value;\n\t\t// Propagate to labelInput when it's active\n\t\tif (this.labelInput) {\n\t\t\tthis.labelInput.focused = value;\n\t\t}\n\t}\n\n\tconstructor(\n\t\ttree: SessionTreeNode[],\n\t\tcurrentLeafId: string | null,\n\t\tterminalHeight: number,\n\t\tonSelect: (entryId: string) => void,\n\t\tonCancel: () => void,\n\t\tonLabelChange?: (entryId: string, label: string | undefined) => void,\n\t\tinitialSelectedId?: string,\n\t\tinitialFilterMode?: FilterMode,\n\t) {\n\t\tsuper();\n\n\t\tthis.onLabelChangeCallback = onLabelChange;\n\t\tconst maxVisibleLines = Math.max(5, Math.floor(terminalHeight / 2));\n\n\t\tthis.treeList = new TreeList(tree, currentLeafId, maxVisibleLines, initialSelectedId, initialFilterMode);\n\t\tthis.treeList.onSelect = onSelect;\n\t\tthis.treeList.onCancel = onCancel;\n\t\tthis.treeList.onLabelEdit = (entryId, currentLabel) => this.showLabelInput(entryId, currentLabel);\n\n\t\tthis.treeContainer = new Container();\n\t\tthis.treeContainer.addChild(this.treeList);\n\n\t\tthis.labelInputContainer = new Container();\n\n\t\tthis.addChild(new Spacer(1));\n\t\tthis.addChild(new DynamicBorder());\n\t\tthis.addChild(new Text(theme.bold(\" Session Tree\"), 1, 0));\n\t\tthis.addChild(\n\t\t\tnew TruncatedText(\n\t\t\t\ttheme.fg(\"muted\", \" ↑/↓: move. ←/→: page. ^←/^→ or Alt+←/Alt+→: fold/branch. Shift+L: label. \") +\n\t\t\t\t\ttheme.fg(\"muted\", \"^D/^T/^U/^L/^A: filters (^O/⇧^O cycle)\"),\n\t\t\t\t0,\n\t\t\t\t0,\n\t\t\t),\n\t\t);\n\t\tthis.addChild(new SearchLine(this.treeList));\n\t\tthis.addChild(new DynamicBorder());\n\t\tthis.addChild(new Spacer(1));\n\t\tthis.addChild(this.treeContainer);\n\t\tthis.addChild(this.labelInputContainer);\n\t\tthis.addChild(new Spacer(1));\n\t\tthis.addChild(new DynamicBorder());\n\n\t\tif (tree.length === 0) {\n\t\t\tsetTimeout(() => onCancel(), 100);\n\t\t}\n\t}\n\n\tprivate showLabelInput(entryId: string, currentLabel: string | undefined): void {\n\t\tthis.labelInput = new LabelInput(entryId, currentLabel);\n\t\tthis.labelInput.onSubmit = (id, label) => {\n\t\t\tthis.treeList.updateNodeLabel(id, label);\n\t\t\tthis.onLabelChangeCallback?.(id, label);\n\t\t\tthis.hideLabelInput();\n\t\t};\n\t\tthis.labelInput.onCancel = () => this.hideLabelInput();\n\n\t\t// Propagate current focused state to the new labelInput\n\t\tthis.labelInput.focused = this._focused;\n\n\t\tthis.treeContainer.clear();\n\t\tthis.labelInputContainer.clear();\n\t\tthis.labelInputContainer.addChild(this.labelInput);\n\t}\n\n\tprivate hideLabelInput(): void {\n\t\tthis.labelInput = null;\n\t\tthis.labelInputContainer.clear();\n\t\tthis.treeContainer.clear();\n\t\tthis.treeContainer.addChild(this.treeList);\n\t}\n\n\thandleInput(keyData: string): void {\n\t\tif (this.labelInput) {\n\t\t\tthis.labelInput.handleInput(keyData);\n\t\t} else {\n\t\t\tthis.treeList.handleInput(keyData);\n\t\t}\n\t}\n\n\tgetTreeList(): TreeList {\n\t\treturn this.treeList;\n\t}\n}\n"]}
@@ -13,7 +13,10 @@ class TreeList {
13
13
  toolCallMap = new Map();
14
14
  multipleRoots = false;
15
15
  activePathIds = new Set();
16
+ visibleParentMap = new Map();
17
+ visibleChildrenMap = new Map();
16
18
  lastSelectedId = null;
19
+ foldedNodes = new Set();
17
20
  onSelect;
18
21
  onCancel;
19
22
  onLabelEdit;
@@ -250,6 +253,17 @@ class TreeList {
250
253
  }
251
254
  return true;
252
255
  });
256
+ // Filter out descendants of folded nodes.
257
+ if (this.foldedNodes.size > 0) {
258
+ const skipSet = new Set();
259
+ for (const flatNode of this.flatNodes) {
260
+ const { id, parentId } = flatNode.node.entry;
261
+ if (parentId != null && (this.foldedNodes.has(parentId) || skipSet.has(parentId))) {
262
+ skipSet.add(id);
263
+ }
264
+ }
265
+ this.filteredNodes = this.filteredNodes.filter((flatNode) => !skipSet.has(flatNode.node.entry.id));
266
+ }
253
267
  // Recalculate visual structure (indent, connectors, gutters) based on visible tree
254
268
  this.recalculateVisualStructure();
255
269
  // Try to preserve cursor on the same node, or find nearest visible ancestor
@@ -374,6 +388,9 @@ class TreeList {
374
388
  ]);
375
389
  }
376
390
  }
391
+ // Store visible tree maps for ancestor/descendant lookups in navigation
392
+ this.visibleParentMap = visibleParent;
393
+ this.visibleChildrenMap = visibleChildren;
377
394
  }
378
395
  /** Get searchable text content from a node */
379
396
  getSearchableText(node) {
@@ -480,6 +497,7 @@ class TreeList {
480
497
  // Build prefix char by char, placing gutters and connector at their positions
481
498
  const totalChars = displayIndent * 3;
482
499
  const prefixChars = [];
500
+ const isFolded = this.foldedNodes.has(entry.id);
483
501
  for (let i = 0; i < totalChars; i++) {
484
502
  const level = Math.floor(i / 3);
485
503
  const posInLevel = i % 3;
@@ -494,12 +512,13 @@ class TreeList {
494
512
  }
495
513
  }
496
514
  else if (connector && level === connectorPosition) {
497
- // Connector at this level
515
+ // Connector at this level, with fold indicator
498
516
  if (posInLevel === 0) {
499
517
  prefixChars.push(flatNode.isLast ? "└" : "├");
500
518
  }
501
519
  else if (posInLevel === 1) {
502
- prefixChars.push("─");
520
+ const foldable = this.isFoldable(entry.id);
521
+ prefixChars.push(isFolded ? "⊞" : foldable ? "⊟" : "─");
503
522
  }
504
523
  else {
505
524
  prefixChars.push(" ");
@@ -510,12 +529,15 @@ class TreeList {
510
529
  }
511
530
  }
512
531
  const prefix = prefixChars.join("");
532
+ // Fold marker for nodes without connectors (roots)
533
+ const showsFoldInConnector = flatNode.showConnector && !flatNode.isVirtualRootChild;
534
+ const foldMarker = isFolded && !showsFoldInConnector ? theme.fg("accent", "⊞ ") : "";
513
535
  // Active path marker - shown right before the entry text
514
536
  const isOnActivePath = this.activePathIds.has(entry.id);
515
537
  const pathMarker = isOnActivePath ? theme.fg("accent", "• ") : "";
516
538
  const label = flatNode.node.label ? theme.fg("warning", `[${flatNode.node.label}] `) : "";
517
539
  const content = this.getEntryDisplayText(flatNode.node, isSelected);
518
- let line = cursor + theme.fg("dim", prefix) + pathMarker + label + content;
540
+ let line = cursor + theme.fg("dim", prefix) + foldMarker + pathMarker + label + content;
519
541
  if (isSelected) {
520
542
  line = theme.bg("selectedBg", line);
521
543
  }
@@ -704,11 +726,31 @@ class TreeList {
704
726
  else if (kb.matches(keyData, "selectDown")) {
705
727
  this.selectedIndex = this.selectedIndex === this.filteredNodes.length - 1 ? 0 : this.selectedIndex + 1;
706
728
  }
707
- else if (kb.matches(keyData, "cursorLeft")) {
729
+ else if (kb.matches(keyData, "treeFoldOrUp")) {
730
+ const currentId = this.filteredNodes[this.selectedIndex]?.node.entry.id;
731
+ if (currentId && this.isFoldable(currentId) && !this.foldedNodes.has(currentId)) {
732
+ this.foldedNodes.add(currentId);
733
+ this.applyFilter();
734
+ }
735
+ else {
736
+ this.selectedIndex = this.findBranchSegmentStart("up");
737
+ }
738
+ }
739
+ else if (kb.matches(keyData, "treeUnfoldOrDown")) {
740
+ const currentId = this.filteredNodes[this.selectedIndex]?.node.entry.id;
741
+ if (currentId && this.foldedNodes.has(currentId)) {
742
+ this.foldedNodes.delete(currentId);
743
+ this.applyFilter();
744
+ }
745
+ else {
746
+ this.selectedIndex = this.findBranchSegmentStart("down");
747
+ }
748
+ }
749
+ else if (kb.matches(keyData, "cursorLeft") || kb.matches(keyData, "selectPageUp")) {
708
750
  // Page up
709
751
  this.selectedIndex = Math.max(0, this.selectedIndex - this.maxVisibleLines);
710
752
  }
711
- else if (kb.matches(keyData, "cursorRight")) {
753
+ else if (kb.matches(keyData, "cursorRight") || kb.matches(keyData, "selectPageDown")) {
712
754
  // Page down
713
755
  this.selectedIndex = Math.min(this.filteredNodes.length - 1, this.selectedIndex + this.maxVisibleLines);
714
756
  }
@@ -721,6 +763,7 @@ class TreeList {
721
763
  else if (kb.matches(keyData, "selectCancel")) {
722
764
  if (this.searchQuery) {
723
765
  this.searchQuery = "";
766
+ this.foldedNodes.clear();
724
767
  this.applyFilter();
725
768
  }
726
769
  else {
@@ -730,26 +773,31 @@ class TreeList {
730
773
  else if (matchesKey(keyData, "ctrl+d")) {
731
774
  // Direct filter: default
732
775
  this.filterMode = "default";
776
+ this.foldedNodes.clear();
733
777
  this.applyFilter();
734
778
  }
735
779
  else if (matchesKey(keyData, "ctrl+t")) {
736
780
  // Toggle filter: no-tools ↔ default
737
781
  this.filterMode = this.filterMode === "no-tools" ? "default" : "no-tools";
782
+ this.foldedNodes.clear();
738
783
  this.applyFilter();
739
784
  }
740
785
  else if (matchesKey(keyData, "ctrl+u")) {
741
786
  // Toggle filter: user-only ↔ default
742
787
  this.filterMode = this.filterMode === "user-only" ? "default" : "user-only";
788
+ this.foldedNodes.clear();
743
789
  this.applyFilter();
744
790
  }
745
791
  else if (matchesKey(keyData, "ctrl+l")) {
746
792
  // Toggle filter: labeled-only ↔ default
747
793
  this.filterMode = this.filterMode === "labeled-only" ? "default" : "labeled-only";
794
+ this.foldedNodes.clear();
748
795
  this.applyFilter();
749
796
  }
750
797
  else if (matchesKey(keyData, "ctrl+a")) {
751
798
  // Toggle filter: all ↔ default
752
799
  this.filterMode = this.filterMode === "all" ? "default" : "all";
800
+ this.foldedNodes.clear();
753
801
  this.applyFilter();
754
802
  }
755
803
  else if (matchesKey(keyData, "shift+ctrl+o")) {
@@ -757,6 +805,7 @@ class TreeList {
757
805
  const modes = ["default", "no-tools", "user-only", "labeled-only", "all"];
758
806
  const currentIndex = modes.indexOf(this.filterMode);
759
807
  this.filterMode = modes[(currentIndex - 1 + modes.length) % modes.length];
808
+ this.foldedNodes.clear();
760
809
  this.applyFilter();
761
810
  }
762
811
  else if (matchesKey(keyData, "ctrl+o")) {
@@ -764,11 +813,13 @@ class TreeList {
764
813
  const modes = ["default", "no-tools", "user-only", "labeled-only", "all"];
765
814
  const currentIndex = modes.indexOf(this.filterMode);
766
815
  this.filterMode = modes[(currentIndex + 1) % modes.length];
816
+ this.foldedNodes.clear();
767
817
  this.applyFilter();
768
818
  }
769
819
  else if (kb.matches(keyData, "deleteCharBackward")) {
770
820
  if (this.searchQuery.length > 0) {
771
821
  this.searchQuery = this.searchQuery.slice(0, -1);
822
+ this.foldedNodes.clear();
772
823
  this.applyFilter();
773
824
  }
774
825
  }
@@ -785,10 +836,64 @@ class TreeList {
785
836
  });
786
837
  if (!hasControlChars && keyData.length > 0) {
787
838
  this.searchQuery += keyData;
839
+ this.foldedNodes.clear();
788
840
  this.applyFilter();
789
841
  }
790
842
  }
791
843
  }
844
+ /**
845
+ * Whether a node can be folded. A node is foldable if it has visible children
846
+ * and is either a root (no visible parent) or a segment start (visible parent
847
+ * has multiple visible children).
848
+ */
849
+ isFoldable(entryId) {
850
+ const children = this.visibleChildrenMap.get(entryId);
851
+ if (!children || children.length === 0)
852
+ return false;
853
+ const parentId = this.visibleParentMap.get(entryId);
854
+ if (parentId === null || parentId === undefined)
855
+ return true;
856
+ const siblings = this.visibleChildrenMap.get(parentId);
857
+ return siblings !== undefined && siblings.length > 1;
858
+ }
859
+ /**
860
+ * Find the index of the next branch segment start in the given direction.
861
+ * A segment start is the first child of a branch point.
862
+ *
863
+ * "up" walks the visible parent chain; "down" walks visible children
864
+ * (always following the first child).
865
+ */
866
+ findBranchSegmentStart(direction) {
867
+ const selectedId = this.filteredNodes[this.selectedIndex]?.node.entry.id;
868
+ if (!selectedId)
869
+ return this.selectedIndex;
870
+ const indexByEntryId = new Map(this.filteredNodes.map((node, i) => [node.node.entry.id, i]));
871
+ let currentId = selectedId;
872
+ if (direction === "down") {
873
+ while (true) {
874
+ const children = this.visibleChildrenMap.get(currentId) ?? [];
875
+ if (children.length === 0)
876
+ return indexByEntryId.get(currentId);
877
+ if (children.length > 1)
878
+ return indexByEntryId.get(children[0]);
879
+ currentId = children[0];
880
+ }
881
+ }
882
+ // direction === "up"
883
+ while (true) {
884
+ const parentId = this.visibleParentMap.get(currentId) ?? null;
885
+ if (parentId === null)
886
+ return indexByEntryId.get(currentId);
887
+ const children = this.visibleChildrenMap.get(parentId) ?? [];
888
+ if (children.length > 1) {
889
+ const segmentStart = indexByEntryId.get(currentId);
890
+ if (segmentStart < this.selectedIndex) {
891
+ return segmentStart;
892
+ }
893
+ }
894
+ currentId = parentId;
895
+ }
896
+ }
792
897
  }
793
898
  /** Component that displays the current search query */
794
899
  class SearchLine {
@@ -887,7 +992,7 @@ export class TreeSelectorComponent extends Container {
887
992
  this.addChild(new Spacer(1));
888
993
  this.addChild(new DynamicBorder());
889
994
  this.addChild(new Text(theme.bold(" Session Tree"), 1, 0));
890
- this.addChild(new TruncatedText(theme.fg("muted", " ↑/↓: move. ←/→: page. Shift+L: label. ") +
995
+ this.addChild(new TruncatedText(theme.fg("muted", " ↑/↓: move. ←/→: page. ^←/^→ or Alt+←/Alt+→: fold/branch. Shift+L: label. ") +
891
996
  theme.fg("muted", "^D/^T/^U/^L/^A: filters (^O/⇧^O cycle)"), 0, 0));
892
997
  this.addChild(new SearchLine(this.treeList));
893
998
  this.addChild(new DynamicBorder());