* 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

@@ -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");
});
});
});