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:
@@ -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))
|
||||
|
||||
@@ -211,7 +211,7 @@ pi --session <path> # Use specific session file or ID
|
||||
|
||||
<p align="center"><img src="docs/images/tree-view.png" alt="Tree View" width="600"></p>
|
||||
|
||||
- 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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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<EditorKeybindingsConfig> = {
|
||||
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",
|
||||
|
||||
Reference in New Issue
Block a user