From c40b4787560a970c1a46c74b13fa5ae32e1b4b35 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 16 Mar 2026 20:49:08 -0400 Subject: [PATCH] fix(tui): keep tab behavior in shell mode --- .../cli/cmd/tui/component/prompt/index.tsx | 5 ++++ .../src/cli/cmd/tui/component/prompt/key.ts | 16 ++++++++++++ .../opencode/test/cli/tui/prompt-key.test.ts | 26 +++++++++++++++++++ 3 files changed, 47 insertions(+) create mode 100644 packages/opencode/src/cli/cmd/tui/component/prompt/key.ts create mode 100644 packages/opencode/test/cli/tui/prompt-key.test.ts diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index c85426cc24..9f4ed0dc13 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -33,6 +33,7 @@ import { DialogAlert } from "../../ui/dialog-alert" import { useToast } from "../../ui/toast" import { useKV } from "../../context/kv" import { useTextareaKeybindings } from "../textarea-keybindings" +import { shellPassthrough } from "./key" import { DialogSkill } from "../dialog-skill" export type PromptProps = { @@ -894,6 +895,10 @@ export function Prompt(props: PromptProps) { return } if (store.mode === "shell") { + if (shellPassthrough(keybind, e, store.mode)) { + e.stopPropagation() + return + } if ((e.name === "backspace" && input.visualCursor.offset === 0) || e.name === "escape") { setStore("mode", "normal") e.preventDefault() diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/key.ts b/packages/opencode/src/cli/cmd/tui/component/prompt/key.ts new file mode 100644 index 0000000000..05ea0e72b1 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/key.ts @@ -0,0 +1,16 @@ +import type { KeybindKey } from "@/cli/cmd/tui/context/keybind" + +type Mode = "normal" | "shell" + +type Key = { + readonly name?: string + readonly shift?: boolean +} + +export function shellPassthrough( + keybind: { readonly match: (key: KeybindKey, evt: E) => boolean | undefined }, + evt: E, + mode: Mode, +) { + return mode === "shell" && (keybind.match("agent_cycle", evt) || keybind.match("agent_cycle_reverse", evt)) +} diff --git a/packages/opencode/test/cli/tui/prompt-key.test.ts b/packages/opencode/test/cli/tui/prompt-key.test.ts new file mode 100644 index 0000000000..1cb6aee28c --- /dev/null +++ b/packages/opencode/test/cli/tui/prompt-key.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test" +import { shellPassthrough } from "../../../src/cli/cmd/tui/component/prompt/key" + +const key = (name: string, extra: { readonly shift?: boolean } = {}) => ({ + name, + shift: false, + ...extra, +}) + +describe("shellPassthrough", () => { + test("allows tab agent-cycle bindings to pass through in shell mode", () => { + const match = (target: string, evt: { readonly name?: string }) => target === "agent_cycle" && evt.name === "tab" + expect(shellPassthrough({ match }, key("tab"), "shell")).toBe(true) + }) + + test("allows reverse agent-cycle bindings to pass through in shell mode", () => { + const match = (target: string, evt: { readonly name?: string; readonly shift?: boolean }) => + target === "agent_cycle_reverse" && evt.name === "tab" && evt.shift + expect(shellPassthrough({ match }, key("tab", { shift: true }), "shell")).toBe(true) + }) + + test("does not bypass agent-cycle outside shell mode", () => { + const match = (target: string, evt: { readonly name?: string }) => target === "agent_cycle" && evt.name === "tab" + expect(shellPassthrough({ match }, key("tab"), "normal")).toBe(false) + }) +})