Pr 1724 (#1939)
* 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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user