diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 0c239482..5beb2b1b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] +### Added +- Added `/tree` branch folding and segment-jump navigation with `Ctrl+←`/`Ctrl+→` and `Alt+←`/`Alt+→`, while keeping `←`/`→` and `Page Up`/`Page Down` for paging ([#1724](https://github.com/badlogic/pi-mono/pull/1724) by [@Perlence](https://github.com/Perlence)) + ### Fixed - Fixed custom tool collapsed/expanded rendering in HTML exports. Custom tools that define different collapsed vs expanded displays now render correctly in exported HTML, with expandable sections when both states differ and direct display when only expanded exists ([#1934](https://github.com/badlogic/pi-mono/pull/1934) by [@aliou](https://github.com/aliou)) - Fixed tmux startup guidance and keyboard setup warnings for modified key handling, including Ghostty `shift+enter=text:\n` remap guidance and tmux `extended-keys-format` detection ([#1872](https://github.com/badlogic/pi-mono/issues/1872)) diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 525091e7..3fd951c4 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -211,7 +211,7 @@ pi --session # Use specific session file or ID

Tree View

-- Search by typing, page with ←/→ +- Search by typing, fold/unfold and jump between branches with Ctrl+←/Ctrl+→ or Alt+←/Alt+→, page with ←/→ - Filter modes (Ctrl+O): default → no-tools → user-only → labeled-only → all - Press `l` to label entries as bookmarks diff --git a/packages/coding-agent/docs/keybindings.md b/packages/coding-agent/docs/keybindings.md index 67e74c71..3b8ec6db 100644 --- a/packages/coding-agent/docs/keybindings.md +++ b/packages/coding-agent/docs/keybindings.md @@ -119,6 +119,13 @@ Modifier combinations: `ctrl+shift+x`, `alt+ctrl+x`, `ctrl+shift+alt+x`, etc. | `selectConfirm` | `enter` | Confirm selection | | `selectCancel` | `escape`, `ctrl+c` | Cancel selection | +### Tree Navigation + +| Action | Default | Description | +|--------|---------|-------------| +| `treeFoldOrUp` | `ctrl+left`, `alt+left` | Fold current branch segment, or jump to the previous segment start | +| `treeUnfoldOrDown` | `ctrl+right`, `alt+right` | Unfold current branch segment, or jump to the next segment start or branch end | + ### Session Picker | Action | Default | Description | diff --git a/packages/coding-agent/docs/tree.md b/packages/coding-agent/docs/tree.md index 23a0b12c..0beb62dd 100644 --- a/packages/coding-agent/docs/tree.md +++ b/packages/coding-agent/docs/tree.md @@ -33,16 +33,25 @@ Sessions are stored as trees where each entry has an `id` and `parentId`. The "l | Key | Action | |-----|--------| | ↑/↓ | Navigate (depth-first order) | +| ←/→ | Page up/down | +| Ctrl+←/Ctrl+→ or Alt+←/Alt+→ | Fold/unfold and jump between branch segments | | Enter | Select node | | Escape/Ctrl+C | Cancel | | Ctrl+U | Toggle: user messages only | | Ctrl+O | Toggle: show all (including custom/label entries) | +`Ctrl+←` or `Alt+←` folds the current node if it is foldable. Foldable nodes are roots and branch segment starts that have visible children. If the current node is not foldable, or is already folded, the selection jumps up to the previous visible branch segment start. + +`Ctrl+→` or `Alt+→` unfolds the current node if it is folded. Otherwise, the selection jumps down to the next visible branch segment start, or to the branch end when there is no further branch point. + ### Display - Height: half terminal height - Current leaf marked with `← active` - Labels shown inline: `[label-name]` +- Foldable branch starts show `⊟` in the connector. Folded branches show `⊞` +- Active path marker `•` appears after the fold indicator when applicable +- Search and filter changes reset all folds - Default filter hides `label` and `custom` entries (shown in Ctrl+O mode) - Children sorted by timestamp (oldest first) diff --git a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts index 74be0f44..e6d53af5 100644 --- a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts @@ -59,7 +59,10 @@ class TreeList implements Component { private toolCallMap: Map = new Map(); private multipleRoots = false; private activePathIds: Set = new Set(); + private visibleParentMap: Map = new Map(); + private visibleChildrenMap: Map = new Map(); private lastSelectedId: string | null = null; + private foldedNodes: Set = new Set(); public onSelect?: (entryId: string) => void; public onCancel?: () => void; @@ -335,6 +338,18 @@ class TreeList implements Component { return true; }); + // Filter out descendants of folded nodes. + if (this.foldedNodes.size > 0) { + const skipSet = new Set(); + for (const flatNode of this.flatNodes) { + const { id, parentId } = flatNode.node.entry; + if (parentId != null && (this.foldedNodes.has(parentId) || skipSet.has(parentId))) { + skipSet.add(id); + } + } + this.filteredNodes = this.filteredNodes.filter((flatNode) => !skipSet.has(flatNode.node.entry.id)); + } + // Recalculate visual structure (indent, connectors, gutters) based on visible tree this.recalculateVisualStructure(); @@ -477,6 +492,10 @@ class TreeList implements Component { ]); } } + + // Store visible tree maps for ancestor/descendant lookups in navigation + this.visibleParentMap = visibleParent; + this.visibleChildrenMap = visibleChildren; } /** Get searchable text content from a node */ @@ -605,6 +624,7 @@ class TreeList implements Component { // Build prefix char by char, placing gutters and connector at their positions const totalChars = displayIndent * 3; const prefixChars: string[] = []; + const isFolded = this.foldedNodes.has(entry.id); for (let i = 0; i < totalChars; i++) { const level = Math.floor(i / 3); const posInLevel = i % 3; @@ -618,11 +638,12 @@ class TreeList implements Component { prefixChars.push(" "); } } else if (connector && level === connectorPosition) { - // Connector at this level + // Connector at this level, with fold indicator if (posInLevel === 0) { prefixChars.push(flatNode.isLast ? "└" : "├"); } else if (posInLevel === 1) { - prefixChars.push("─"); + const foldable = this.isFoldable(entry.id); + prefixChars.push(isFolded ? "⊞" : foldable ? "⊟" : "─"); } else { prefixChars.push(" "); } @@ -632,6 +653,10 @@ class TreeList implements Component { } const prefix = prefixChars.join(""); + // Fold marker for nodes without connectors (roots) + const showsFoldInConnector = flatNode.showConnector && !flatNode.isVirtualRootChild; + const foldMarker = isFolded && !showsFoldInConnector ? theme.fg("accent", "⊞ ") : ""; + // Active path marker - shown right before the entry text const isOnActivePath = this.activePathIds.has(entry.id); const pathMarker = isOnActivePath ? theme.fg("accent", "• ") : ""; @@ -639,7 +664,7 @@ class TreeList implements Component { 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; + let line = cursor + theme.fg("dim", prefix) + foldMarker + pathMarker + label + content; if (isSelected) { line = theme.bg("selectedBg", line); } @@ -830,10 +855,26 @@ class TreeList implements Component { this.selectedIndex = this.selectedIndex === 0 ? this.filteredNodes.length - 1 : this.selectedIndex - 1; } else if (kb.matches(keyData, "selectDown")) { this.selectedIndex = this.selectedIndex === this.filteredNodes.length - 1 ? 0 : this.selectedIndex + 1; - } else if (kb.matches(keyData, "cursorLeft")) { + } else if (kb.matches(keyData, "treeFoldOrUp")) { + const currentId = this.filteredNodes[this.selectedIndex]?.node.entry.id; + if (currentId && this.isFoldable(currentId) && !this.foldedNodes.has(currentId)) { + this.foldedNodes.add(currentId); + this.applyFilter(); + } else { + this.selectedIndex = this.findBranchSegmentStart("up"); + } + } else if (kb.matches(keyData, "treeUnfoldOrDown")) { + const currentId = this.filteredNodes[this.selectedIndex]?.node.entry.id; + if (currentId && this.foldedNodes.has(currentId)) { + this.foldedNodes.delete(currentId); + this.applyFilter(); + } else { + this.selectedIndex = this.findBranchSegmentStart("down"); + } + } else if (kb.matches(keyData, "cursorLeft") || kb.matches(keyData, "selectPageUp")) { // Page up this.selectedIndex = Math.max(0, this.selectedIndex - this.maxVisibleLines); - } else if (kb.matches(keyData, "cursorRight")) { + } else if (kb.matches(keyData, "cursorRight") || kb.matches(keyData, "selectPageDown")) { // Page down this.selectedIndex = Math.min(this.filteredNodes.length - 1, this.selectedIndex + this.maxVisibleLines); } else if (kb.matches(keyData, "selectConfirm")) { @@ -844,6 +885,7 @@ class TreeList implements Component { } else if (kb.matches(keyData, "selectCancel")) { if (this.searchQuery) { this.searchQuery = ""; + this.foldedNodes.clear(); this.applyFilter(); } else { this.onCancel?.(); @@ -851,38 +893,46 @@ class TreeList implements Component { } else if (matchesKey(keyData, "ctrl+d")) { // Direct filter: default this.filterMode = "default"; + this.foldedNodes.clear(); this.applyFilter(); } else if (matchesKey(keyData, "ctrl+t")) { // Toggle filter: no-tools ↔ default this.filterMode = this.filterMode === "no-tools" ? "default" : "no-tools"; + this.foldedNodes.clear(); this.applyFilter(); } else if (matchesKey(keyData, "ctrl+u")) { // Toggle filter: user-only ↔ default this.filterMode = this.filterMode === "user-only" ? "default" : "user-only"; + this.foldedNodes.clear(); this.applyFilter(); } else if (matchesKey(keyData, "ctrl+l")) { // Toggle filter: labeled-only ↔ default this.filterMode = this.filterMode === "labeled-only" ? "default" : "labeled-only"; + this.foldedNodes.clear(); this.applyFilter(); } else if (matchesKey(keyData, "ctrl+a")) { // Toggle filter: all ↔ default this.filterMode = this.filterMode === "all" ? "default" : "all"; + this.foldedNodes.clear(); this.applyFilter(); } else if (matchesKey(keyData, "shift+ctrl+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.foldedNodes.clear(); 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.foldedNodes.clear(); this.applyFilter(); } else if (kb.matches(keyData, "deleteCharBackward")) { if (this.searchQuery.length > 0) { this.searchQuery = this.searchQuery.slice(0, -1); + this.foldedNodes.clear(); this.applyFilter(); } } else if (matchesKey(keyData, "shift+l")) { @@ -897,10 +947,62 @@ class TreeList implements Component { }); if (!hasControlChars && keyData.length > 0) { this.searchQuery += keyData; + this.foldedNodes.clear(); this.applyFilter(); } } } + + /** + * Whether a node can be folded. A node is foldable if it has visible children + * and is either a root (no visible parent) or a segment start (visible parent + * has multiple visible children). + */ + private isFoldable(entryId: string): boolean { + const children = this.visibleChildrenMap.get(entryId); + if (!children || children.length === 0) return false; + const parentId = this.visibleParentMap.get(entryId); + if (parentId === null || parentId === undefined) return true; + const siblings = this.visibleChildrenMap.get(parentId); + return siblings !== undefined && siblings.length > 1; + } + + /** + * Find the index of the next branch segment start in the given direction. + * A segment start is the first child of a branch point. + * + * "up" walks the visible parent chain; "down" walks visible children + * (always following the first child). + */ + private findBranchSegmentStart(direction: "up" | "down"): number { + const selectedId = this.filteredNodes[this.selectedIndex]?.node.entry.id; + if (!selectedId) return this.selectedIndex; + + const indexByEntryId = new Map(this.filteredNodes.map((node, i) => [node.node.entry.id, i])); + let currentId: string = selectedId; + if (direction === "down") { + while (true) { + const children: string[] = this.visibleChildrenMap.get(currentId) ?? []; + if (children.length === 0) return indexByEntryId.get(currentId)!; + if (children.length > 1) return indexByEntryId.get(children[0])!; + currentId = children[0]; + } + } + + // direction === "up" + while (true) { + const parentId: string | null = this.visibleParentMap.get(currentId) ?? null; + if (parentId === null) return indexByEntryId.get(currentId)!; + const children = this.visibleChildrenMap.get(parentId) ?? []; + if (children.length > 1) { + const segmentStart = indexByEntryId.get(currentId)!; + if (segmentStart < this.selectedIndex) { + return segmentStart; + } + } + currentId = parentId; + } + } } /** Component that displays the current search query */ @@ -1025,7 +1127,7 @@ export class TreeSelectorComponent extends Container implements Focusable { this.addChild(new Text(theme.bold(" Session Tree"), 1, 0)); this.addChild( new TruncatedText( - theme.fg("muted", " ↑/↓: move. ←/→: page. Shift+L: label. ") + + theme.fg("muted", " ↑/↓: move. ←/→: page. ^←/^→ or Alt+←/Alt+→: fold/branch. Shift+L: label. ") + theme.fg("muted", "^D/^T/^U/^L/^A: filters (^O/⇧^O cycle)"), 0, 0, diff --git a/packages/coding-agent/test/tree-selector.test.ts b/packages/coding-agent/test/tree-selector.test.ts index 4e22afd1..01b64a50 100644 --- a/packages/coding-agent/test/tree-selector.test.ts +++ b/packages/coding-agent/test/tree-selector.test.ts @@ -50,6 +50,33 @@ function assistantMessage(id: string, parentId: string | null, text: string): Se }; } +// Helper to create a tool-call-only assistant message (filtered out in default mode) +function toolCallOnlyAssistant(id: string, parentId: string | null): SessionMessageEntry { + return { + type: "message", + id, + parentId, + timestamp: new Date().toISOString(), + message: { + role: "assistant", + content: [{ type: "toolCall", id: `tc-${id}`, name: "read", arguments: { path: "test.ts" } }], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "toolUse", + timestamp: Date.now(), + }, + }; +} + // Helper to create a model_change entry function modelChange(id: string, parentId: string | null): ModelChangeEntry { return { @@ -280,4 +307,312 @@ describe("TreeSelectorComponent", () => { expect(list.getSelectedNode()?.entry.id).toBe("asst-1"); }); }); + + describe("branch navigation and folding with ctrl+arrow keys", () => { + // Key escape sequences + const UP = "\x1b[A"; + const DOWN = "\x1b[B"; + const CTRL_LEFT = "\x1b[1;5D"; + const CTRL_RIGHT = "\x1b[1;5C"; + const ALT_LEFT = "\x1b[1;3D"; + const ALT_RIGHT = "\x1b[1;3C"; + + // Tree structure: + // + // user-1 + // asst-1 + // user-2 + // asst-2 ← branch point (has 2 children) + // ├─ user-3a ← branch A (active: leaf is asst-4a) + // │ asst-3a + // │ user-4a + // │ asst-4a + // └─ user-3b ← branch B + // asst-3b + // user-4b + // + // Foldable nodes: user-1 (root), user-3a (segment start), user-3b (segment start) + + function buildBranchingTree() { + const entries: SessionEntry[] = [ + userMessage("user-1", null, "first message"), + assistantMessage("asst-1", "user-1", "response 1"), + userMessage("user-2", "asst-1", "second message"), + assistantMessage("asst-2", "user-2", "response 2"), + // Branch A (active) + userMessage("user-3a", "asst-2", "branch A start"), + assistantMessage("asst-3a", "user-3a", "branch A response"), + userMessage("user-4a", "asst-3a", "branch A deep"), + assistantMessage("asst-4a", "user-4a", "branch A leaf"), + // Branch B + userMessage("user-3b", "asst-2", "branch B start"), + assistantMessage("asst-3b", "user-3b", "branch B response"), + userMessage("user-4b", "asst-3b", "branch B deep"), + ]; + return buildTree(entries); + } + + test("ctrl+right unfolds a folded node, then does segment jump when unfolded", () => { + const tree = buildBranchingTree(); + const selector = new TreeSelectorComponent( + tree, + "asst-4a", + 24, + () => {}, + () => {}, + ); + const list = selector.getTreeList(); + + selector.handleInput(CTRL_LEFT); // asst-4a → user-3a + expect(list.getSelectedNode()?.entry.id).toBe("user-3a"); + + selector.handleInput(CTRL_LEFT); // fold user-3a + expect(list.getSelectedNode()?.entry.id).toBe("user-3a"); + + selector.handleInput(DOWN); // user-3a → user-3b (children hidden) + expect(list.getSelectedNode()?.entry.id).toBe("user-3b"); + + selector.handleInput(UP); // user-3b → user-3a + expect(list.getSelectedNode()?.entry.id).toBe("user-3a"); + + selector.handleInput(CTRL_RIGHT); // unfold user-3a + expect(list.getSelectedNode()?.entry.id).toBe("user-3a"); + + selector.handleInput(DOWN); // user-3a → asst-3a (children restored) + expect(list.getSelectedNode()?.entry.id).toBe("asst-3a"); + + selector.handleInput(CTRL_LEFT); // asst-3a → user-3a + expect(list.getSelectedNode()?.entry.id).toBe("user-3a"); + + selector.handleInput(CTRL_RIGHT); // user-3a → asst-4a (segment jump to leaf) + expect(list.getSelectedNode()?.entry.id).toBe("asst-4a"); + }); + + test("alt+left/right are aliases for fold and unfold navigation", () => { + const tree = buildBranchingTree(); + const selector = new TreeSelectorComponent( + tree, + "asst-4a", + 24, + () => {}, + () => {}, + ); + const list = selector.getTreeList(); + + selector.handleInput(ALT_LEFT); // asst-4a → user-3a + expect(list.getSelectedNode()?.entry.id).toBe("user-3a"); + + selector.handleInput(ALT_LEFT); // fold user-3a + expect(list.getSelectedNode()?.entry.id).toBe("user-3a"); + + selector.handleInput(ALT_RIGHT); // unfold user-3a + expect(list.getSelectedNode()?.entry.id).toBe("user-3a"); + + selector.handleInput(ALT_RIGHT); // user-3a → asst-4a + expect(list.getSelectedNode()?.entry.id).toBe("asst-4a"); + }); + + test("folding root hides entire subtree, nested fold preserved on unfold", () => { + const tree = buildBranchingTree(); + const selector = new TreeSelectorComponent( + tree, + "asst-4a", + 24, + () => {}, + () => {}, + ); + const list = selector.getTreeList(); + + selector.handleInput(CTRL_LEFT); // asst-4a → user-3a + expect(list.getSelectedNode()?.entry.id).toBe("user-3a"); + + selector.handleInput(CTRL_LEFT); // fold user-3a + expect(list.getSelectedNode()?.entry.id).toBe("user-3a"); + + selector.handleInput(CTRL_LEFT); // user-3a (folded) → user-1 + expect(list.getSelectedNode()?.entry.id).toBe("user-1"); + + selector.handleInput(CTRL_LEFT); // fold user-1 + expect(list.getSelectedNode()?.entry.id).toBe("user-1"); + + selector.handleInput(DOWN); // wrap (only visible node) + expect(list.getSelectedNode()?.entry.id).toBe("user-1"); + + selector.handleInput(CTRL_RIGHT); // unfold user-1 + expect(list.getSelectedNode()?.entry.id).toBe("user-1"); + + selector.handleInput(CTRL_RIGHT); // user-1 → user-3a (segment jump, user-3a still folded) + expect(list.getSelectedNode()?.entry.id).toBe("user-3a"); + + selector.handleInput(DOWN); // user-3a → user-3b (user-3a still folded) + expect(list.getSelectedNode()?.entry.id).toBe("user-3b"); + }); + + test("fold and navigate on non-active branch", () => { + const tree = buildBranchingTree(); + const selector = new TreeSelectorComponent( + tree, + "asst-4a", + 24, + () => {}, + () => {}, + ); + const list = selector.getTreeList(); + + // Navigate down to user-3b (branch B) + let found = false; + for (let i = 0; i < 20; i++) { + selector.handleInput(DOWN); + if (list.getSelectedNode()?.entry.id === "user-3b") { + found = true; + break; + } + } + expect(found).toBe(true); + + selector.handleInput(CTRL_RIGHT); // user-3b → user-4b (segment jump to leaf) + expect(list.getSelectedNode()?.entry.id).toBe("user-4b"); + + selector.handleInput(CTRL_LEFT); // user-4b → user-3b + expect(list.getSelectedNode()?.entry.id).toBe("user-3b"); + + selector.handleInput(CTRL_LEFT); // fold user-3b + expect(list.getSelectedNode()?.entry.id).toBe("user-3b"); + + selector.handleInput(CTRL_LEFT); // user-3b (folded) → user-1 + expect(list.getSelectedNode()?.entry.id).toBe("user-1"); + }); + + test("fold and navigate with multiple roots", () => { + const entries: SessionEntry[] = [ + userMessage("user-1", null, "first root"), + assistantMessage("asst-1", "user-1", "response 1"), + userMessage("user-2", null, "second root"), + assistantMessage("asst-2", "user-2", "response 2"), + ]; + const tree = buildTree(entries); + const selector = new TreeSelectorComponent( + tree, + "asst-1", + 24, + () => {}, + () => {}, + ); + const list = selector.getTreeList(); + + expect(list.getSelectedNode()?.entry.id).toBe("asst-1"); + + selector.handleInput(CTRL_LEFT); // asst-1 → user-1 + expect(list.getSelectedNode()?.entry.id).toBe("user-1"); + + selector.handleInput(CTRL_LEFT); // fold user-1 + expect(list.getSelectedNode()?.entry.id).toBe("user-1"); + + selector.handleInput(DOWN); // user-1 → user-2 (children hidden) + expect(list.getSelectedNode()?.entry.id).toBe("user-2"); + + selector.handleInput(CTRL_RIGHT); // user-2 → asst-2 (segment jump to leaf) + expect(list.getSelectedNode()?.entry.id).toBe("asst-2"); + + selector.handleInput(CTRL_LEFT); // asst-2 → user-2 + expect(list.getSelectedNode()?.entry.id).toBe("user-2"); + + selector.handleInput(CTRL_LEFT); // fold user-2 + expect(list.getSelectedNode()?.entry.id).toBe("user-2"); + + selector.handleInput(CTRL_LEFT); // user-2 (folded, root) → stays on user-2 + expect(list.getSelectedNode()?.entry.id).toBe("user-2"); + }); + + test("folding root hides descendants even when intermediate nodes are filtered out", () => { + // user-1 → toolCallOnly-1 (filtered out) → user-2 → asst-2 + const entries: SessionEntry[] = [ + userMessage("user-1", null, "hello"), + toolCallOnlyAssistant("tool-asst-1", "user-1"), + userMessage("user-2", "tool-asst-1", "follow up"), + assistantMessage("asst-2", "user-2", "response"), + ]; + const tree = buildTree(entries); + const selector = new TreeSelectorComponent( + tree, + "asst-2", + 24, + () => {}, + () => {}, + ); + const list = selector.getTreeList(); + + selector.handleInput(CTRL_LEFT); // asst-2 → user-1 + expect(list.getSelectedNode()?.entry.id).toBe("user-1"); + + selector.handleInput(CTRL_LEFT); // fold user-1 + expect(list.getSelectedNode()?.entry.id).toBe("user-1"); + + selector.handleInput(DOWN); // wrap (only visible node) + expect(list.getSelectedNode()?.entry.id).toBe("user-1"); + }); + + test("search resets fold state", () => { + const tree = buildBranchingTree(); + const selector = new TreeSelectorComponent( + tree, + "asst-4a", + 24, + () => {}, + () => {}, + ); + const list = selector.getTreeList(); + + selector.handleInput(CTRL_LEFT); // asst-4a → user-3a + selector.handleInput(CTRL_LEFT); // fold user-3a + + selector.handleInput(DOWN); // user-3a → user-3b (children hidden) + expect(list.getSelectedNode()?.entry.id).toBe("user-3b"); + + selector.handleInput("b"); // search resets folds + selector.handleInput("\x1b"); // clear search + + // Navigate to user-3a to verify fold was reset + let currentId = ""; + for (let i = 0; i < 20; i++) { + selector.handleInput(DOWN); + currentId = list.getSelectedNode()?.entry.id ?? ""; + if (currentId === "user-3a") break; + } + expect(currentId).toBe("user-3a"); + + selector.handleInput(DOWN); // user-3a → asst-3a (not user-3b) + expect(list.getSelectedNode()?.entry.id).toBe("asst-3a"); + }); + + test("filter mode change resets fold state", () => { + const tree = buildBranchingTree(); + const selector = new TreeSelectorComponent( + tree, + "asst-4a", + 24, + () => {}, + () => {}, + ); + const list = selector.getTreeList(); + + selector.handleInput(CTRL_LEFT); // asst-4a → user-3a + selector.handleInput(CTRL_LEFT); // fold user-3a + + selector.handleInput("\x15"); // ctrl+u: user-only filter resets folds + selector.handleInput("\x04"); // ctrl+d: back to default + + // Navigate to user-3a to verify fold was reset + let currentId = ""; + for (let i = 0; i < 20; i++) { + selector.handleInput(DOWN); + currentId = list.getSelectedNode()?.entry.id ?? ""; + if (currentId === "user-3a") break; + } + expect(currentId).toBe("user-3a"); + + selector.handleInput(DOWN); // user-3a → asst-3a (not user-3b) + expect(list.getSelectedNode()?.entry.id).toBe("asst-3a"); + }); + }); }); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 1a18d844..5f47ce59 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added `treeFoldOrUp` and `treeUnfoldOrDown` editor actions with default bindings for `Ctrl+←`/`Ctrl+→` and `Alt+←`/`Alt+→` ([#1724](https://github.com/badlogic/pi-mono/pull/1724) by [@Perlence](https://github.com/Perlence)) + ### Fixed - Fixed autocomplete selection ignoring typed text: highlight now follows the first prefix match as the user types, and exact matches are always selected on Enter ([#1931](https://github.com/badlogic/pi-mono/pull/1931) by [@aliou](https://github.com/aliou)) diff --git a/packages/tui/src/keybindings.ts b/packages/tui/src/keybindings.ts index e6db8e57..cafc9e23 100644 --- a/packages/tui/src/keybindings.ts +++ b/packages/tui/src/keybindings.ts @@ -44,6 +44,9 @@ export type EditorAction = | "undo" // Tool output | "expandTools" + // Tree navigation + | "treeFoldOrUp" + | "treeUnfoldOrDown" // Session | "toggleSessionPath" | "toggleSessionSort" @@ -105,6 +108,9 @@ export const DEFAULT_EDITOR_KEYBINDINGS: Required = { undo: "ctrl+-", // Tool output expandTools: "ctrl+o", + // Tree navigation + treeFoldOrUp: ["ctrl+left", "alt+left"], + treeUnfoldOrDown: ["ctrl+right", "alt+right"], // Session toggleSessionPath: "ctrl+p", toggleSessionSort: "ctrl+s",