* feat(coding-agent): add fold/unfold to tree branch navigation

Remap left/right arrow keys in the session tree to jump between branch
segment starts (first child of each branching point). Left on a foldable
node (root or segment start) folds it, hiding its descendants. Right on
a folded node unfolds it. Nested fold state is preserved. Search and
filter changes reset all folds.

Folded branches show ⊞ in the connector (├⊞/└⊞) or as a prefix for
roots. Active path marker • appears after the fold indicator.

Add Page Up/Down support for paging through the tree list, consistent
with session selector and config selector.

* feat(coding-agent): improve tree branch folding closes #1724

---------

Co-authored-by: Sviatoslav Abakumov <dust.harvesting@gmail.com>
This commit is contained in:
Mario Zechner
2026-03-08 00:15:47 +01:00
committed by GitHub
parent 41e02598f5
commit d48843ea55
8 changed files with 473 additions and 7 deletions

View File

@@ -59,7 +59,10 @@ class TreeList implements Component {
private toolCallMap: Map<string, ToolCallInfo> = new Map();
private multipleRoots = false;
private activePathIds: Set<string> = new Set();
private visibleParentMap: Map<string, string | null> = new Map();
private visibleChildrenMap: Map<string | null, string[]> = new Map();
private lastSelectedId: string | null = null;
private foldedNodes: Set<string> = 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<string>();
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,