diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5baa76cb4..7be1ec348 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- Added turn-aware `/tree` navigation: Alt+Up/Alt+Down traverses previous/next user or assistant turns while skipping tool and bookkeeping entries, Home/End jumps to the first/latest turn, and PageUp/PageDown moves by a visible page. +- Added turn-aware `/tree` navigation: Alt+Up/Alt+Down traverses previous/next user or assistant turns while skipping tool and bookkeeping entries, Home/End jumps to the first/last visible item, and PageUp/PageDown moves by a visible page. ## [17.2.1] - 2026-07-30 diff --git a/packages/coding-agent/src/modes/components/tree-selector.ts b/packages/coding-agent/src/modes/components/tree-selector.ts index d6f24a4ac..8577f6375 100644 --- a/packages/coding-agent/src/modes/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/components/tree-selector.ts @@ -801,33 +801,19 @@ class TreeList implements Component { } } - #jumpToTurnBoundary(boundary: "first" | "last"): void { - const start = boundary === "first" ? 0 : this.#filteredNodes.length - 1; - const step = boundary === "first" ? 1 : -1; - for (let index = start; index >= 0 && index < this.#filteredNodes.length; index += step) { - const entry = this.#filteredNodes[index]?.node.entry; - if (entry?.type === "message" && (entry.message.role === "user" || entry.message.role === "assistant")) { - this.#selectedIndex = index; - return; - } - } - } - handleInput(keyData: string): void { - if (matchesKey(keyData, "alt+up")) { - this.#moveToAdjacentTurn(-1); - } else if (matchesKey(keyData, "alt+down")) { - this.#moveToAdjacentTurn(1); - } else if (matchesSelectUp(keyData)) { + if (matchesSelectUp(keyData)) { this.#selectedIndex = this.#selectedIndex === 0 ? this.#filteredNodes.length - 1 : this.#selectedIndex - 1; } else if (matchesSelectDown(keyData)) { this.#selectedIndex = this.#selectedIndex === this.#filteredNodes.length - 1 ? 0 : this.#selectedIndex + 1; + } else if (matchesKey(keyData, "alt+up")) { + this.#moveToAdjacentTurn(-1); + } else if (matchesKey(keyData, "alt+down")) { + this.#moveToAdjacentTurn(1); } else if (matchesKey(keyData, "home")) { - // Jump to the earliest actual conversation turn, skipping tool and bookkeeping entries. - this.#jumpToTurnBoundary("first"); + this.#selectedIndex = 0; } else if (matchesKey(keyData, "end")) { - // Jump to the latest actual conversation turn, skipping tool and bookkeeping entries. - this.#jumpToTurnBoundary("last"); + this.#selectedIndex = Math.max(0, this.#filteredNodes.length - 1); } else if (matchesSelectPageUp(keyData) || matchesKey(keyData, "left")) { this.#selectedIndex = Math.max(0, this.#selectedIndex - this.maxVisibleLines); } else if (matchesSelectPageDown(keyData) || matchesKey(keyData, "right")) { @@ -994,7 +980,7 @@ export class TreeSelectorComponent extends Container { new TruncatedText( theme.fg( "muted", - "Enter: switch. Alt+↑/↓: previous/next turn. PgUp/PgDn (←/→): page. Home/End: first/latest turn. Shift+Enter: summarize & switch. Shift+L: label. Ctrl+O: filter. Alt+D/T/U/L/A: filter. Type to search", + "Enter: switch. Alt+↑/↓: previous/next turn. PgUp/PgDn (←/→): page. Home/End: first/last item. Shift+Enter: summarize & switch. Shift+L: label. Ctrl+O: filter. Alt+D/T/U/L/A: filter. Type to search", ), 0, 0, diff --git a/packages/coding-agent/test/keybindings-selector-navigation.test.ts b/packages/coding-agent/test/keybindings-selector-navigation.test.ts index 9e38d8f09..f07171552 100644 --- a/packages/coding-agent/test/keybindings-selector-navigation.test.ts +++ b/packages/coding-agent/test/keybindings-selector-navigation.test.ts @@ -146,7 +146,7 @@ describe("selector navigation keybindings", () => { expect(selected).toEqual(["child"]); }); - it("traverses actual turns while skipping tool results in the session tree", () => { + it("traverses actual turns and jumps to first/last visible tree items", () => { const firstUser = createMessageNode("first-user", null, "First question"); const assistant = createAgentMessageNode("assistant", "first-user", { role: "assistant", @@ -209,7 +209,74 @@ describe("selector navigation keybindings", () => { selector.handleInput("\x1b[H"); selector.handleInput("\n"); - expect(selected).toEqual(["second-user", "assistant", "first-user", "assistant", "second-user", "first-user"]); + expect(selected).toEqual([ + "second-user", + "assistant", + "first-user", + "assistant", + "trailing-tool-result", + "first-user", + ]); + }); + + it("honors configured row bindings before Alt+Up and Alt+Down turn traversal", () => { + setKeybindings( + KeybindingsManager.inMemory({ + "tui.select.up": "alt+up", + "tui.select.down": "alt+down", + }), + ); + const firstUser = createMessageNode("first-user", null, "First question"); + const toolResult = createAgentMessageNode("tool-result", "first-user", { + role: "toolResult", + toolCallId: "call-1", + toolName: "read", + content: [{ type: "text", text: "file contents" }], + isError: false, + timestamp: 2, + }); + const secondUser = createMessageNode("second-user", "tool-result", "Second question"); + firstUser.children.push(toolResult); + toolResult.children.push(secondUser); + + const selected: string[] = []; + const selector = new TreeSelectorComponent( + [firstUser], + "first-user", + 40, + id => selected.push(id), + () => {}, + ); + + selector.handleInput("\x1b[1;3B"); + selector.handleInput("\n"); + selector.handleInput("\x1b[1;3A"); + selector.handleInput("\n"); + + expect(selected).toEqual(["tool-result", "first-user"]); + }); + + it("uses rendered tree order for Home and End across branches", () => { + const root = createMessageNode("root", null, "Root"); + const activeBranch = createMessageNode("active-branch", "root", "Active branch"); + const inactiveBranch = createMessageNode("inactive-branch", "root", "Inactive branch"); + root.children.push(activeBranch, inactiveBranch); + + const selected: string[] = []; + const selector = new TreeSelectorComponent( + [root], + "active-branch", + 40, + id => selected.push(id), + () => {}, + ); + + selector.handleInput("\x1b[F"); + selector.handleInput("\n"); + selector.handleInput("\x1b[H"); + selector.handleInput("\n"); + + expect(selected).toEqual(["inactive-branch", "root"]); }); it("uses PageUp and PageDown to move by a visible page in the session tree", () => {