From a54a2f79cbfd9b3a3e7b136cea83533064ca9d0a Mon Sep 17 00:00:00 2001 From: roboomp Date: Mon, 3 Aug 2026 09:33:07 +0000 Subject: [PATCH] fix(tui): preserved b before /btw completion Reserve the plain b shortcut only after /btw has a completed answer or a branch is already pending. Running, empty, aborted, and failed panels now leave the key for the composer, while completed-but-refused branches still consume it with an explanation. Fixes #7474 --- .../src/modes/controllers/btw-controller.ts | 13 +++++++++++++ .../src/modes/controllers/input-controller.ts | 2 +- .../coding-agent/src/modes/interactive-mode.ts | 5 +++++ packages/coding-agent/src/modes/types.ts | 1 + .../test/input-controller-keybindings.test.ts | 16 ++++++++++------ .../modes/controllers/btw-controller.test.ts | 7 +++++++ 6 files changed, 37 insertions(+), 7 deletions(-) diff --git a/packages/coding-agent/src/modes/controllers/btw-controller.ts b/packages/coding-agent/src/modes/controllers/btw-controller.ts index 1f3a2b315..1a45a614d 100644 --- a/packages/coding-agent/src/modes/controllers/btw-controller.ts +++ b/packages/coding-agent/src/modes/controllers/btw-controller.ts @@ -55,6 +55,19 @@ export class BtwController { return this.#branchUnavailableReason() === undefined; } + /** Whether plain `b` is currently reserved for a completed or pending branch action. */ + handlesBranchKey(): boolean { + if (this.#branchInFlight) return true; + if (this.#activeRequest?.component.isBranchable() !== true) return false; + return ( + this.#lastQuestion !== undefined && + this.#lastReplyText !== undefined && + this.#lastAssistantMessage !== undefined && + this.#lastLeafId !== undefined && + this.#lastSessionId !== undefined + ); + } + #branchUnavailableReason(): string | undefined { if (this.#branchInFlight) return "a branch is already in progress"; if (this.#activeRequest?.component.isBranchable() !== true) return "the answer is not ready"; diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index dc265a984..711a2d8b8 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -250,7 +250,7 @@ export class InputController { this.#btwBranchListenerInstalled = true; this.ctx.ui.addInputListener(data => { if (!matchesKey(data, "b")) return undefined; - if (!this.ctx.hasActiveBtw()) return undefined; + if (!this.ctx.handlesBtwBranchKey()) return undefined; if (this.ctx.ui.getFocused() !== this.ctx.editor) return undefined; if (this.ctx.editor.getText().trim()) return undefined; void this.ctx.handleBtwBranchKey(); diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index c4f809a49..87dd71003 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -4844,6 +4844,11 @@ export class InteractiveMode implements InteractiveModeContext { return this.#btwController.canBranch(); } + /** Reserves plain `b` only after /btw has a completed branch action to handle. */ + handlesBtwBranchKey(): boolean { + return this.#btwController.handlesBranchKey(); + } + handleBtwBranchKey(): Promise { return this.#btwController.handleBranch(); } diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index f0167e098..d62fd9fda 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -418,6 +418,7 @@ export interface InteractiveModeContext { handleBtwEscape(): boolean; handleBtwBranchKey(): Promise; canBranchBtw(): boolean; + handlesBtwBranchKey(): boolean; canCopyBtw(): boolean; handleBtwCopyKey(): Promise; handleBtwBranch( diff --git a/packages/coding-agent/test/input-controller-keybindings.test.ts b/packages/coding-agent/test/input-controller-keybindings.test.ts index bb54f68fc..6e0b387c0 100644 --- a/packages/coding-agent/test/input-controller-keybindings.test.ts +++ b/packages/coding-agent/test/input-controller-keybindings.test.ts @@ -110,6 +110,7 @@ async function createContext() { const canBranchBtw = vi.fn(() => false); const canCopyBtw = vi.fn(() => false); const hasActiveBtw = vi.fn(() => false); + const handlesBtwBranchKey = vi.fn(() => false); const editor: FakeEditor = { setText(text: string) { editorText = text; @@ -212,6 +213,7 @@ async function createContext() { showModelSelector, updateEditorBorderColor: vi.fn(), hasActiveBtw, + handlesBtwBranchKey, handleBtwBranchKey, canBranchBtw, canCopyBtw, @@ -244,6 +246,7 @@ async function createContext() { addInputListener, canBranchBtw, hasActiveBtw, + handlesBtwBranchKey, handleBtwCopyKey, canCopyBtw, showError, @@ -394,7 +397,7 @@ describe("InputController keybinding setup", () => { it("routes b to branch a branchable /btw panel", async () => { const { InputController, ctx, spies } = await createContext(); - spies.hasActiveBtw.mockReturnValue(true); + spies.handlesBtwBranchKey.mockReturnValue(true); const controller = new InputController(ctx); controller.setupKeyHandlers(); @@ -408,7 +411,7 @@ describe("InputController keybinding setup", () => { it("lets b fall through while the editor has draft text", async () => { const { InputController, ctx, editor, spies } = await createContext(); - spies.hasActiveBtw.mockReturnValue(true); + spies.handlesBtwBranchKey.mockReturnValue(true); editor.setText("build a branch"); const controller = new InputController(ctx); @@ -421,9 +424,9 @@ describe("InputController keybinding setup", () => { expect(spies.handleBtwBranchKey).not.toHaveBeenCalled(); }); - it("consumes b while an active /btw branch is unavailable", async () => { + it("consumes b while a completed /btw branch is unavailable", async () => { const { InputController, ctx, spies } = await createContext(); - spies.hasActiveBtw.mockReturnValue(true); + spies.handlesBtwBranchKey.mockReturnValue(true); const controller = new InputController(ctx); controller.setupKeyHandlers(); @@ -435,8 +438,9 @@ describe("InputController keybinding setup", () => { expect(spies.handleBtwBranchKey).toHaveBeenCalledTimes(1); }); - it("lets b fall through when no /btw panel is active", async () => { + it("lets b reach the composer before an active /btw answer is branchable", async () => { const { InputController, ctx, spies } = await createContext(); + spies.hasActiveBtw.mockReturnValue(true); const controller = new InputController(ctx); controller.setupKeyHandlers(); @@ -450,7 +454,7 @@ describe("InputController keybinding setup", () => { it("lets b fall through while another input is focused", async () => { const { InputController, ctx, setFocused, spies } = await createContext(); - spies.hasActiveBtw.mockReturnValue(true); + spies.handlesBtwBranchKey.mockReturnValue(true); setFocused({ pasteText: vi.fn() }); const controller = new InputController(ctx); diff --git a/packages/coding-agent/test/modes/controllers/btw-controller.test.ts b/packages/coding-agent/test/modes/controllers/btw-controller.test.ts index 88f7be15d..e0d55017f 100644 --- a/packages/coding-agent/test/modes/controllers/btw-controller.test.ts +++ b/packages/coding-agent/test/modes/controllers/btw-controller.test.ts @@ -249,6 +249,7 @@ describe("BtwController", () => { await controller.start("Question?"); expect(controller.canBranch()).toBe(false); + expect(controller.handlesBranchKey()).toBe(false); }); it("does not allow branch when the completed answer has no originating leaf", async () => { @@ -264,6 +265,7 @@ describe("BtwController", () => { await drainBtwRequest(); expect(controller.canBranch()).toBe(false); + expect(controller.handlesBranchKey()).toBe(true); }); it("allows branch after a complete non-empty reply", async () => { @@ -276,6 +278,7 @@ describe("BtwController", () => { await drainBtwRequest(); expect(controller.canBranch()).toBe(true); + expect(controller.handlesBranchKey()).toBe(true); }); it("refuses branch when the loaded session changed but the leaf id still matches", async () => { @@ -295,6 +298,7 @@ describe("BtwController", () => { ctx.setTestSessionId("session-2"); expect(controller.canBranch()).toBe(false); + expect(controller.handlesBranchKey()).toBe(true); expect(await controller.handleBranch()).toBe(false); expect(ctx.handleBtwBranch).not.toHaveBeenCalled(); expect(ctx.showStatus).toHaveBeenCalledWith("/btw branch unavailable: the session changed since /btw started", { @@ -315,6 +319,7 @@ describe("BtwController", () => { await drainBtwRequest(); expect(controller.canBranch()).toBe(false); + expect(controller.handlesBranchKey()).toBe(true); const panel = btwContainer.children[0]; expect(Bun.stripANSI(panel?.render(120).join("\n") ?? "")).not.toContain("b branch to chat"); expect(await controller.handleBranch()).toBe(false); @@ -335,6 +340,7 @@ describe("BtwController", () => { await drainBtwRequest(); expect(controller.canBranch()).toBe(false); + expect(controller.handlesBranchKey()).toBe(false); }); it("does not allow branch after aborted or errored requests", async () => { @@ -393,6 +399,7 @@ describe("BtwController", () => { await drainBtwRequest(); const branchPromise = controller.handleBranch(); await Promise.resolve(); + expect(controller.handlesBranchKey()).toBe(true); const panel = btwContainer.children[0]; expect(Bun.stripANSI(panel?.render(120).join("\n") ?? "")).toContain("Branching to chat");