import { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import { type Component, Container, extractPrintableText, Input, matchesKey, Spacer, Text, TruncatedText, truncateToWidth, } from "@oh-my-pi/pi-tui"; import type { TreeFilterMode } from "../../config/settings-schema"; import { theme } from "../../modes/theme/theme"; import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers"; import type { SessionTreeNode } from "../../session/session-manager"; import { shortenPath } from "../../tools/render-utils"; import { DynamicBorder } from "./dynamic-border"; /** Gutter info: position (displayIndent where connector was) and whether to show │ */ interface GutterInfo { position: number; // displayIndent level where the connector was shown show: boolean; // true = show │, false = show spaces } /** Flattened tree node for navigation */ interface FlatNode { node: SessionTreeNode; /** Indentation level (each level = 3 chars) */ indent: number; /** Whether to show connector (├─ or └─) - true if parent has multiple children */ showConnector: boolean; /** If showConnector, true = last sibling (└─), false = not last (├─) */ isLast: boolean; /** Gutter info for each ancestor branch point */ gutters: GutterInfo[]; /** True if this node is a root under a virtual branching root (multiple roots) */ isVirtualRootChild: boolean; } /** Filter mode for tree display */ type FilterMode = TreeFilterMode; /** * Tree list component with selection and ASCII art visualization */ /** Tool call info for lookup */ interface ToolCallInfo { name: string; arguments: Record; } class TreeList implements Component { #flatNodes: FlatNode[] = []; #filteredNodes: FlatNode[] = []; #selectedIndex = 0; #filterMode: FilterMode; #searchQuery = ""; #toolCallMap: Map = new Map(); #multipleRoots = false; #activePathIds: Set = new Set(); #lastSelectedId: string | null = null; onSelect?: (entryId: string) => void; onCancel?: () => void; onLabelEdit?: (entryId: string, currentLabel: string | undefined) => void; constructor( tree: SessionTreeNode[], private readonly currentLeafId: string | null, private readonly maxVisibleLines: number, initialFilterMode: FilterMode = "default", initialSelectedId?: string, ) { this.#filterMode = initialFilterMode; this.#multipleRoots = tree.length > 1; this.#flatNodes = this.#flattenTree(tree); this.#buildActivePath(); this.#applyFilter(); // Start with initialSelectedId if provided, otherwise current leaf const targetId = initialSelectedId ?? currentLeafId; this.#selectedIndex = this.#findNearestVisibleIndex(targetId); this.#lastSelectedId = this.#filteredNodes[this.#selectedIndex]?.node.entry.id ?? null; } /** Build the set of entry IDs on the path from root to current leaf */ #buildActivePath(): void { this.#activePathIds.clear(); if (!this.currentLeafId) return; // Build a map of id -> entry for parent lookup const entryMap = new Map(); for (const flatNode of this.#flatNodes) { entryMap.set(flatNode.node.entry.id, flatNode); } // Walk from leaf to root let currentId: string | null = this.currentLeafId; while (currentId) { this.#activePathIds.add(currentId); const node = entryMap.get(currentId); if (!node) break; currentId = node.node.entry.parentId ?? null; } } /** * Find the index of the nearest visible entry, walking up the parent chain if needed. * Returns the index in filteredNodes, or the last index as fallback. */ #findNearestVisibleIndex(entryId: string | null): number { if (this.#filteredNodes.length === 0) return 0; // Build a map for parent lookup const entryMap = new Map(); for (const flatNode of this.#flatNodes) { entryMap.set(flatNode.node.entry.id, flatNode); } // Build a map of visible entry IDs to their indices in filteredNodes const visibleIdToIndex = new Map(this.#filteredNodes.map((node, i) => [node.node.entry.id, i])); // Walk from entryId up to root, looking for a visible entry let currentId = entryId; while (currentId !== null) { const index = visibleIdToIndex.get(currentId); if (index !== undefined) return index; const node = entryMap.get(currentId); if (!node) break; currentId = node.node.entry.parentId ?? null; } // Fallback: last visible entry return this.#filteredNodes.length - 1; } #flattenTree(roots: SessionTreeNode[]): FlatNode[] { const result: FlatNode[] = []; this.#toolCallMap.clear(); // Indentation rules: // - At indent 0: stay at 0 unless parent has >1 children (then +1) // - At indent 1: children always go to indent 2 (visual grouping of subtree) // - At indent 2+: stay flat for single-child chains, +1 only if parent branches // Stack items: [node, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild] type StackItem = [SessionTreeNode, number, boolean, boolean, boolean, GutterInfo[], boolean]; const stack: StackItem[] = []; // Determine which subtrees contain the active leaf (to sort current branch first) // Use iterative post-order traversal to avoid stack overflow const containsActive = new Map(); const leafId = this.currentLeafId; { // Build list in pre-order, then process in reverse for post-order effect const allNodes: SessionTreeNode[] = []; const preOrderStack: SessionTreeNode[] = [...roots]; while (preOrderStack.length > 0) { const node = preOrderStack.pop()!; allNodes.push(node); // Push children in reverse so they're processed left-to-right for (let i = node.children.length - 1; i >= 0; i--) { preOrderStack.push(node.children[i]); } } // Process in reverse (post-order): children before parents for (let i = allNodes.length - 1; i >= 0; i--) { const node = allNodes[i]; let has = leafId !== null && node.entry.id === leafId; for (const child of node.children) { if (containsActive.get(child)) { has = true; } } containsActive.set(node, has); } } // Add roots in reverse order, prioritizing the one containing the active leaf // If multiple roots, treat them as children of a virtual root that branches const multipleRoots = roots.length > 1; const orderedRoots = [...roots].sort((a, b) => Number(containsActive.get(b)) - Number(containsActive.get(a))); for (let i = orderedRoots.length - 1; i >= 0; i--) { const isLast = i === orderedRoots.length - 1; stack.push([orderedRoots[i], multipleRoots ? 1 : 0, multipleRoots, multipleRoots, isLast, [], multipleRoots]); } while (stack.length > 0) { const [node, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild] = stack.pop()!; // Extract tool calls from assistant messages for later lookup const entry = node.entry; if (entry.type === "message" && entry.message.role === "assistant") { const content = (entry.message as { content?: unknown }).content; if (Array.isArray(content)) { for (const block of content) { if (typeof block === "object" && block !== null && "type" in block && block.type === "toolCall") { const tc = block as { id: string; name: string; arguments: Record }; this.#toolCallMap.set(tc.id, { name: tc.name, arguments: tc.arguments }); } } } } result.push({ node, indent, showConnector, isLast, gutters, isVirtualRootChild }); const children = node.children; const multipleChildren = children.length > 1; // Order children so the branch containing the active leaf comes first const orderedChildren = (() => { const prioritized: SessionTreeNode[] = []; const rest: SessionTreeNode[] = []; for (const child of children) { if (containsActive.get(child)) { prioritized.push(child); } else { rest.push(child); } } return [...prioritized, ...rest]; })(); // Calculate child indent let childIndent: number; if (multipleChildren) { // Parent branches: children get +1 childIndent = indent + 1; } else if (justBranched && indent > 0) { // First generation after a branch: +1 for visual grouping childIndent = indent + 1; } else { // Single-child chain: stay flat childIndent = indent; } // Build gutters for children // If this node showed a connector, add a gutter entry for descendants // Only add gutter if connector is actually displayed (not suppressed for virtual root children) const connectorDisplayed = showConnector && !isVirtualRootChild; // When connector is displayed, add a gutter entry at the connector's position // Connector is at position (displayIndent - 1), so gutter should be there too const currentDisplayIndent = this.#multipleRoots ? Math.max(0, indent - 1) : indent; const connectorPosition = Math.max(0, currentDisplayIndent - 1); const childGutters: GutterInfo[] = connectorDisplayed ? [...gutters, { position: connectorPosition, show: !isLast }] : gutters; // Add children in reverse order for (let i = orderedChildren.length - 1; i >= 0; i--) { const childIsLast = i === orderedChildren.length - 1; stack.push([ orderedChildren[i], childIndent, multipleChildren, multipleChildren, childIsLast, childGutters, false, ]); } } return result; } #applyFilter(): void { // Update lastSelectedId only when we have a valid selection (non-empty list) // This preserves the selection when switching through empty filter results if (this.#filteredNodes.length > 0) { this.#lastSelectedId = this.#filteredNodes[this.#selectedIndex]?.node.entry.id ?? this.#lastSelectedId; } const searchTokens = this.#searchQuery.toLowerCase().split(/\s+/).filter(Boolean); this.#filteredNodes = this.#flatNodes.filter(flatNode => { const entry = flatNode.node.entry; const isCurrentLeaf = entry.id === this.currentLeafId; // Skip assistant messages with only tool calls (no text) unless error/aborted // Always show current leaf so active position is visible if (entry.type === "message" && entry.message.role === "assistant" && !isCurrentLeaf) { const msg = entry.message as { stopReason?: string; content?: unknown }; const hasText = this.#hasTextContent(msg.content); const isErrorOrAborted = msg.stopReason && msg.stopReason !== "stop" && msg.stopReason !== "toolUse"; // Only hide if no text AND not an error/aborted message if (!hasText && !isErrorOrAborted) { return false; } } // Apply filter mode let passesFilter = true; // Entry types hidden in default view (settings/bookkeeping) const isSettingsEntry = entry.type === "label" || entry.type === "custom" || entry.type === "model_change" || entry.type === "thinking_level_change"; switch (this.#filterMode) { case "user-only": // Just user messages passesFilter = entry.type === "message" && entry.message.role === "user"; break; case "no-tools": // Default minus tool results passesFilter = !isSettingsEntry && !(entry.type === "message" && entry.message.role === "toolResult"); break; case "labeled-only": // Just labeled entries passesFilter = flatNode.node.label !== undefined; break; case "all": // Show everything passesFilter = true; break; default: // Default mode: hide settings/bookkeeping entries passesFilter = !isSettingsEntry; break; } if (!passesFilter) return false; // Apply search filter if (searchTokens.length > 0) { const nodeText = this.#getSearchableText(flatNode.node).toLowerCase(); return searchTokens.every(token => nodeText.includes(token)); } return true; }); // Try to preserve cursor on the same node, or find nearest visible ancestor if (this.#lastSelectedId) { this.#selectedIndex = this.#findNearestVisibleIndex(this.#lastSelectedId); } else if (this.#selectedIndex >= this.#filteredNodes.length) { // Clamp index if out of bounds this.#selectedIndex = Math.max(0, this.#filteredNodes.length - 1); } // Update lastSelectedId to the actual selection (may have changed due to parent walk) if (this.#filteredNodes.length > 0) { this.#lastSelectedId = this.#filteredNodes[this.#selectedIndex]?.node.entry.id ?? this.#lastSelectedId; } } /** Get searchable text content from a node */ #getSearchableText(node: SessionTreeNode): string { const entry = node.entry; const parts: string[] = []; if (node.label) { parts.push(node.label); } switch (entry.type) { case "message": { const msg = entry.message; parts.push(msg.role); if ("content" in msg && msg.content) { parts.push(this.#extractContent(msg.content)); } if (msg.role === "bashExecution") { const bashMsg = msg as { command?: string }; if (bashMsg.command) parts.push(bashMsg.command); } break; } case "custom_message": { parts.push(entry.customType); if (typeof entry.content === "string") { parts.push(entry.content); } else { parts.push(this.#extractContent(entry.content)); } break; } case "compaction": parts.push("compaction"); break; case "branch_summary": parts.push("branch summary", entry.summary); break; case "model_change": parts.push("model", entry.model); break; case "thinking_level_change": parts.push("thinking", entry.thinkingLevel ?? ThinkingLevel.Off); break; case "custom": parts.push("custom", entry.customType); break; case "label": parts.push("label", entry.label ?? ""); break; } return parts.join(" "); } invalidate(): void {} getSearchQuery(): string { return this.#searchQuery; } getSelectedNode(): SessionTreeNode | undefined { return this.#filteredNodes[this.#selectedIndex]?.node; } updateNodeLabel(entryId: string, label: string | undefined): void { for (const flatNode of this.#flatNodes) { if (flatNode.node.entry.id === entryId) { flatNode.node.label = label; break; } } } #getFilterLabel(): string { switch (this.#filterMode) { case "no-tools": return " [no-tools]"; case "user-only": return " [user]"; case "labeled-only": return " [labeled]"; case "all": return " [all]"; default: return ""; } } render(width: number): string[] { const lines: string[] = []; if (this.#filteredNodes.length === 0) { lines.push(truncateToWidth(theme.fg("muted", " No entries found"), width)); lines.push(truncateToWidth(theme.fg("muted", ` (0/0)${this.#getFilterLabel()}`), width)); return lines; } const startIndex = Math.max( 0, Math.min( this.#selectedIndex - Math.floor(this.maxVisibleLines / 2), this.#filteredNodes.length - this.maxVisibleLines, ), ); const endIndex = Math.min(startIndex + this.maxVisibleLines, this.#filteredNodes.length); // Cap the per-row gutter prefix so a content budget is always preserved. // Each indent level renders as 3 cells; deep branching would otherwise eat the // entire viewport (issue #1144). Reserve at least MIN_CONTENT_COLS for entry // text — or half the viewport, whichever is larger — and compress older gutter // levels off-screen behind a leading ellipsis when the row would exceed budget. const MIN_CONTENT_COLS = 24; const OVERHEAD_COLS = 4; // cursor (2) + a touch of breathing room const contentReserve = Math.max(MIN_CONTENT_COLS, Math.floor(width / 2)); const maxIndentLevels = Math.max(1, Math.floor((width - contentReserve - OVERHEAD_COLS) / 3)); for (let i = startIndex; i < endIndex; i++) { const flatNode = this.#filteredNodes[i]; const entry = flatNode.node.entry; const isSelected = i === this.#selectedIndex; // Build line: cursor + prefix + path marker + label + content const cursor = isSelected ? theme.fg("accent", "› ") : " "; // If multiple roots, shift display (roots at 0, not 1) const displayIndent = this.#multipleRoots ? Math.max(0, flatNode.indent - 1) : flatNode.indent; // Build prefix with gutters at their correct positions, clamped to // `maxIndentLevels` cells so the content always fits. When clamped, the // leftmost cells represent the deepest visible ancestors and a `…` marker // indicates older branch context has been compressed. const hasConnector = flatNode.showConnector && !flatNode.isVirtualRootChild; const connectorSymbol = hasConnector ? (flatNode.isLast ? theme.tree.last : theme.tree.branch) : ""; const connectorChars = hasConnector ? Array.from(connectorSymbol) : []; const renderedIndent = Math.min(displayIndent, maxIndentLevels); const scrollOffset = displayIndent - renderedIndent; const connectorPositionDisplay = hasConnector ? renderedIndent - 1 : -1; // Build prefix char by char, placing gutters and connector at their positions const totalChars = renderedIndent * 3; const prefixChars: string[] = []; for (let i = 0; i < totalChars; i++) { const level = Math.floor(i / 3); const originalLevel = level + scrollOffset; const posInLevel = i % 3; // Check if there's a gutter at this level (translated to original tree depth) const gutter = flatNode.gutters.find(g => g.position === originalLevel); if (gutter) { if (posInLevel === 0) { prefixChars.push(gutter.show ? theme.tree.vertical : " "); } else { prefixChars.push(" "); } } else if (hasConnector && level === connectorPositionDisplay) { // Connector at this level if (posInLevel === 0) { prefixChars.push(connectorChars[0] ?? " "); } else if (posInLevel === 1) { prefixChars.push(connectorChars[1] ?? theme.tree.horizontal); } else { prefixChars.push(connectorChars[2] ?? " "); } } else { prefixChars.push(" "); } } // Mark the leftmost cell when ancestors were compressed off-screen. if (scrollOffset > 0 && prefixChars.length > 0) { prefixChars[0] = "…"; } const prefix = prefixChars.join(""); // Active path marker - shown right before the entry text const isOnActivePath = this.#activePathIds.has(entry.id); const pathMarker = isOnActivePath ? theme.fg("accent", `${theme.md.bullet} `) : ""; const label = flatNode.node.label ? theme.fg("warning", `[${flatNode.node.label}] `) : ""; const content = this.#getEntryDisplayText(flatNode.node, isSelected); let line = cursor + theme.fg("dim", prefix) + pathMarker + label + content; if (isSelected) { line = theme.bg("selectedBg", line); } lines.push(truncateToWidth(line, width)); } lines.push( truncateToWidth( theme.fg("muted", ` (${this.#selectedIndex + 1}/${this.#filteredNodes.length})${this.#getFilterLabel()}`), width, ), ); return lines; } #getEntryDisplayText(node: SessionTreeNode, isSelected: boolean): string { const entry = node.entry; let result: string; const normalize = (s: string) => s.replace(/[\n\t]/g, " ").trim(); switch (entry.type) { case "message": { const msg = entry.message; const role = msg.role; if (role === "user") { const msgWithContent = msg as { content?: unknown }; const content = normalize(this.#extractContent(msgWithContent.content)); result = theme.fg("accent", "user: ") + content; } else if (role === "developer") { const msgWithContent = msg as { content?: unknown }; const content = normalize(this.#extractContent(msgWithContent.content)); result = theme.fg("dim", "developer: ") + theme.fg("muted", content); } else if (role === "assistant") { const msgWithContent = msg as { content?: unknown; stopReason?: string; errorMessage?: string }; const textContent = normalize(this.#extractContent(msgWithContent.content)); if (textContent) { result = theme.fg("success", "assistant: ") + textContent; } else if (msgWithContent.stopReason === "aborted") { result = theme.fg("success", "assistant: ") + theme.fg("muted", "(aborted)"); } else if (msgWithContent.errorMessage) { const errMsg = normalize(msgWithContent.errorMessage).slice(0, 80); result = theme.fg("success", "assistant: ") + theme.fg("error", errMsg); } else { result = theme.fg("success", "assistant: ") + theme.fg("muted", "(no content)"); } } else if (role === "toolResult") { const toolMsg = msg as { toolCallId?: string; toolName?: string }; const toolCall = toolMsg.toolCallId ? this.#toolCallMap.get(toolMsg.toolCallId) : undefined; if (toolCall) { result = theme.fg("muted", this.#formatToolCall(toolCall.name, toolCall.arguments)); } else { result = theme.fg("muted", `[${toolMsg.toolName ?? "tool"}]`); } } else if (role === "bashExecution") { const bashMsg = msg as { command?: string }; result = theme.fg("dim", `[bash]: ${normalize(bashMsg.command ?? "")}`); } else { result = theme.fg("dim", `[${role}]`); } break; } case "custom_message": { const content = typeof entry.content === "string" ? entry.content : entry.content .filter((c): c is { type: "text"; text: string } => c.type === "text") .map(c => c.text) .join(""); result = theme.fg("customMessageLabel", `[${entry.customType}]: `) + normalize(content); break; } case "compaction": { const tokens = Math.round(entry.tokensBefore / 1000); result = theme.fg("borderAccent", `[compaction: ${tokens}k tokens]`); break; } case "branch_summary": result = theme.fg("warning", `[branch summary]: `) + normalize(entry.summary); break; case "model_change": result = theme.fg("dim", `[model: ${entry.model}]`); break; case "thinking_level_change": result = theme.fg("dim", `[thinking: ${entry.thinkingLevel ?? ThinkingLevel.Off}]`); break; case "custom": result = theme.fg("dim", `[custom: ${entry.customType}]`); break; case "label": result = theme.fg("dim", `[label: ${entry.label ?? "(cleared)"}]`); break; default: result = ""; } return isSelected ? theme.bold(result) : result; } #extractContent(content: unknown): string { const maxLen = 200; if (typeof content === "string") return content.slice(0, maxLen); if (Array.isArray(content)) { let result = ""; for (const c of content) { if (typeof c === "object" && c !== null && "type" in c && c.type === "text") { result += (c as { text: string }).text; if (result.length >= maxLen) return result.slice(0, maxLen); } } return result; } return ""; } #hasTextContent(content: unknown): boolean { if (typeof content === "string") return content.trim().length > 0; if (Array.isArray(content)) { for (const c of content) { if (typeof c === "object" && c !== null && "type" in c && c.type === "text") { const text = (c as { text?: string }).text; if (text && text.trim().length > 0) return true; } } } return false; } #formatToolCall(name: string, args: Record): string { switch (name) { case "read": { const path = shortenPath(String(args.path || args.file_path || "")); const offset = args.offset as number | undefined; const limit = args.limit as number | undefined; let display = path; if (offset !== undefined || limit !== undefined) { const start = offset ?? 1; const end = limit !== undefined ? start + limit - 1 : ""; display += `:${start}${end ? `-${end}` : ""}`; } return `[read: ${display}]`; } case "write": { const path = shortenPath(String(args.path || args.file_path || "")); return `[write: ${path}]`; } case "edit": { const path = shortenPath(String(args.path || args.file_path || "")); return `[edit: ${path}]`; } case "bash": { const rawCmd = String(args.command || ""); const cmd = rawCmd .replace(/[\n\t]/g, " ") .trim() .slice(0, 50); return `[bash: ${cmd}${rawCmd.length > 50 ? "..." : ""}]`; } case "search": { const pattern = String(args.pattern || ""); const paths = Array.isArray(args.paths) ? args.paths.join(", ") : String(args.path || "."); return `[search: /${pattern}/ in ${shortenPath(paths)}]`; } case "find": { const paths = Array.isArray(args.paths) ? args.paths.join(", ") : String(args.pattern || "."); return `[find: ${shortenPath(paths)}]`; } case "ls": { const path = shortenPath(String(args.path || ".")); return `[ls: ${path}]`; } default: { // Custom tool - show name and truncated JSON args const argsStr = JSON.stringify(args).slice(0, 40); return `[${name}: ${argsStr}${JSON.stringify(args).length > 40 ? "..." : ""}]`; } } } handleInput(keyData: string): void { if (matchesKey(keyData, "up")) { this.#selectedIndex = this.#selectedIndex === 0 ? this.#filteredNodes.length - 1 : this.#selectedIndex - 1; } else if (matchesKey(keyData, "down")) { this.#selectedIndex = this.#selectedIndex === this.#filteredNodes.length - 1 ? 0 : this.#selectedIndex + 1; } else if (matchesKey(keyData, "left")) { // Page up this.#selectedIndex = Math.max(0, this.#selectedIndex - this.maxVisibleLines); } else if (matchesKey(keyData, "right")) { // Page down this.#selectedIndex = Math.min(this.#filteredNodes.length - 1, this.#selectedIndex + this.maxVisibleLines); } else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") { const selected = this.#filteredNodes[this.#selectedIndex]; if (selected && this.onSelect) { this.onSelect(selected.node.entry.id); } } else if (matchesAppInterrupt(keyData)) { if (this.#searchQuery) { this.#searchQuery = ""; this.#applyFilter(); } else { this.onCancel?.(); } } else if (matchesKey(keyData, "ctrl+c")) { this.onCancel?.(); } else if (matchesKey(keyData, "shift+ctrl+o") || matchesKey(keyData, "ctrl+shift+o")) { // Cycle filter backwards const modes: FilterMode[] = ["default", "no-tools", "user-only", "labeled-only", "all"]; const currentIndex = modes.indexOf(this.#filterMode); this.#filterMode = modes[(currentIndex - 1 + modes.length) % modes.length]; this.#applyFilter(); } else if (matchesKey(keyData, "ctrl+o")) { // Cycle filter forwards: default → no-tools → user-only → labeled-only → all → default const modes: FilterMode[] = ["default", "no-tools", "user-only", "labeled-only", "all"]; const currentIndex = modes.indexOf(this.#filterMode); this.#filterMode = modes[(currentIndex + 1) % modes.length]; this.#applyFilter(); } else if (matchesKey(keyData, "alt+d")) { this.#filterMode = "default"; this.#applyFilter(); } else if (matchesKey(keyData, "alt+t")) { this.#filterMode = "no-tools"; this.#applyFilter(); } else if (matchesKey(keyData, "alt+u")) { this.#filterMode = "user-only"; this.#applyFilter(); } else if (matchesKey(keyData, "alt+l")) { this.#filterMode = "labeled-only"; this.#applyFilter(); } else if (matchesKey(keyData, "alt+a")) { this.#filterMode = "all"; this.#applyFilter(); } else if (matchesKey(keyData, "backspace")) { if (this.#searchQuery.length > 0) { this.#searchQuery = this.#searchQuery.slice(0, -1); this.#applyFilter(); } } else if (matchesKey(keyData, "shift+l") && !this.#searchQuery) { const selected = this.#filteredNodes[this.#selectedIndex]; if (selected && this.onLabelEdit) { this.onLabelEdit(selected.node.entry.id, selected.node.label); } } else { const printableText = extractPrintableText(keyData); if (printableText) { this.#searchQuery += printableText; this.#applyFilter(); } } } } /** Component that displays the current search query */ class SearchLine implements Component { constructor(private treeList: TreeList) {} invalidate(): void {} render(width: number): string[] { const query = this.treeList.getSearchQuery(); if (query) { return [truncateToWidth(` ${theme.fg("muted", "Search:")} ${theme.fg("accent", query)}`, width)]; } return [truncateToWidth(` ${theme.fg("muted", "Search:")}`, width)]; } handleInput(_keyData: string): void {} } /** Label input component shown when editing a label */ class LabelInput implements Component { #input: Input; onSubmit?: (entryId: string, label: string | undefined) => void; onCancel?: () => void; constructor( private readonly entryId: string, currentLabel: string | undefined, ) { this.#input = new Input(); if (currentLabel) { this.#input.setValue(currentLabel); } } invalidate(): void {} render(width: number): string[] { const lines: string[] = []; const indent = " "; const availableWidth = width - indent.length; lines.push(truncateToWidth(`${indent}${theme.fg("muted", "Label (empty to remove):")}`, width)); lines.push(...this.#input.render(availableWidth).map(line => truncateToWidth(`${indent}${line}`, width))); lines.push(truncateToWidth(`${indent}${theme.fg("dim", "enter: save esc: cancel")}`, width)); return lines; } handleInput(keyData: string): void { if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") { const value = this.#input.getValue().trim(); this.onSubmit?.(this.entryId, value || undefined); } else if (matchesAppInterrupt(keyData)) { this.onCancel?.(); } else { this.#input.handleInput(keyData); } } } /** * Component that renders a session tree selector for navigation */ export class TreeSelectorComponent extends Container { #treeList: TreeList; #labelInput: LabelInput | null = null; #labelInputContainer: Container; #treeContainer: Container; constructor( tree: SessionTreeNode[], currentLeafId: string | null, terminalHeight: number, onSelect: (entryId: string) => void, onCancel: () => void, private readonly onLabelChangeCallback?: (entryId: string, label: string | undefined) => void, initialFilterMode: FilterMode = "default", ) { super(); const maxVisibleLines = Math.max(5, Math.floor(terminalHeight / 2)); this.#treeList = new TreeList(tree, currentLeafId, maxVisibleLines, initialFilterMode); this.#treeList.onSelect = onSelect; this.#treeList.onCancel = onCancel; this.#treeList.onLabelEdit = (entryId, currentLabel) => this.#showLabelInput(entryId, currentLabel); this.#treeContainer = new Container(); this.#treeContainer.addChild(this.#treeList); this.#labelInputContainer = new Container(); this.addChild(new Spacer(1)); this.addChild(new DynamicBorder()); this.addChild(new Text(theme.bold(" Session Tree"), 1, 0)); this.addChild( new TruncatedText( theme.fg( "muted", "Up/Down: move. Left/Right: page. Shift+L: label. Ctrl+O/Shift+Ctrl+O: filter. Alt+D/T/U/L/A: filter. Type to search", ), 0, 0, ), ); this.addChild(new SearchLine(this.#treeList)); this.addChild(new DynamicBorder()); this.addChild(new Spacer(1)); this.addChild(this.#treeContainer); this.addChild(this.#labelInputContainer); this.addChild(new Spacer(1)); this.addChild(new DynamicBorder()); if (tree.length === 0) { setTimeout(() => onCancel(), 100); } } #showLabelInput(entryId: string, currentLabel: string | undefined): void { this.#labelInput = new LabelInput(entryId, currentLabel); this.#labelInput.onSubmit = (id, label) => { this.#treeList.updateNodeLabel(id, label); this.onLabelChangeCallback?.(id, label); this.#hideLabelInput(); }; this.#labelInput.onCancel = () => this.#hideLabelInput(); this.#treeContainer.clear(); this.#labelInputContainer.clear(); this.#labelInputContainer.addChild(this.#labelInput); } #hideLabelInput(): void { this.#labelInput = null; this.#labelInputContainer.clear(); this.#treeContainer.clear(); this.#treeContainer.addChild(this.#treeList); } handleInput(keyData: string): void { if (this.#labelInput) { this.#labelInput.handleInput(keyData); } else { this.#treeList.handleInput(keyData); } } getTreeList(): TreeList { return this.#treeList; } }