diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index b61ee80446..39182dac0e 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -79,8 +79,7 @@ import { import type { EventSource } from "./context/sdk" import { DialogVariant } from "./component/dialog-variant" -const appBindingCommands = [ - "command.palette.show", +const appGlobalBindingCommands = [ "session.list", "session.new", "session.quick_switch.1", @@ -92,6 +91,10 @@ const appBindingCommands = [ "session.quick_switch.7", "session.quick_switch.8", "session.quick_switch.9", +] as const + +const appBindingCommands = [ + "command.palette.show", "model.list", "model.cycle_recent", "model.cycle_recent_reverse", @@ -929,6 +932,10 @@ function App(props: { onSnapshot?: () => Promise }) { bindings: tuiConfig.keybinds.gather("app", appBindingCommands), })) + useBindings(() => ({ + bindings: tuiConfig.keybinds.gather("app.global", appGlobalBindingCommands), + })) + useBindings(() => ({ mode: OPENCODE_BASE_MODE, enabled: () => { diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 1461da0aee..6d9fe3688d 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -134,12 +134,6 @@ const sessionBindingCommands = [ "session.toggle.actions", "session.toggle.scrollbar", "session.toggle.generic_tool_output", - "session.page.up", - "session.page.down", - "session.line.up", - "session.line.down", - "session.half.page.up", - "session.half.page.down", "session.first", "session.last", "session.messages_last_user", @@ -154,6 +148,17 @@ const sessionBindingCommands = [ "session.child.previous", ] as const +const sessionGlobalBindingCommands = [ + "session.page.up", + "session.page.down", + "session.line.up", + "session.line.down", + "session.half.page.up", + "session.half.page.down", +] as const + +const sessionGlobalUnfocusedBindingCommands = ["session.first", "session.last"] as const + const context = createContext<{ width: number sessionID: string @@ -1067,6 +1072,15 @@ export function Session() { commands: sessionCommands(), })) + useBindings(() => ({ + bindings: tuiConfig.keybinds.gather("session.global", sessionGlobalBindingCommands), + })) + + useBindings(() => ({ + enabled: () => renderer.currentFocusedEditor === null, + bindings: tuiConfig.keybinds.gather("session.global.unfocused", sessionGlobalUnfocusedBindingCommands), + })) + useBindings(() => ({ mode: OPENCODE_BASE_MODE, bindings: tuiConfig.keybinds.gather("session", sessionBindingCommands), diff --git a/packages/opencode/test/cli/tui/keymap.test.tsx b/packages/opencode/test/cli/tui/keymap.test.tsx index 82cd72d6c8..d1ebefb4c1 100644 --- a/packages/opencode/test/cli/tui/keymap.test.tsx +++ b/packages/opencode/test/cli/tui/keymap.test.tsx @@ -4,7 +4,12 @@ import { testRender, useRenderer } from "@opentui/solid" import { expect, test } from "bun:test" import { onCleanup } from "solid-js" import { createTuiResolvedConfig } from "../../fixture/tui-runtime" -import { OpencodeKeymapProvider, registerOpencodeKeymap } from "@/cli/cmd/tui/keymap" +import { + getOpencodeModeStack, + OPENCODE_BASE_MODE, + OpencodeKeymapProvider, + registerOpencodeKeymap, +} from "@/cli/cmd/tui/keymap" test("legacy page key aliases compile as page keys", async () => { const sequences: Record = {} @@ -52,3 +57,80 @@ test("legacy page key aliases compile as page keys", async () => { app.renderer.destroy() } }) + +test("mode-less bindings stay active when opencode mode changes", async () => { + const counts: Record> = {} + + function Harness() { + const renderer = useRenderer() + const keymap = createDefaultOpenTuiKeymap(renderer) + const config = createTuiResolvedConfig() + const offKeymap = registerOpencodeKeymap(keymap, renderer, config) + const offGlobal = keymap.registerLayer({ + commands: [ + { name: "session.list", run() {} }, + { name: "session.new", run() {} }, + { name: "session.page.up", run() {} }, + { name: "session.first", run() {} }, + ], + bindings: config.keybinds.gather("test.global", [ + "session.list", + "session.new", + "session.page.up", + "session.first", + ]), + }) + const offBase = keymap.registerLayer({ + mode: OPENCODE_BASE_MODE, + commands: [{ name: "model.list", run() {} }], + bindings: config.keybinds.gather("test.base", ["model.list"]), + }) + const activeCounts = () => + Object.fromEntries( + Array.from( + keymap.getCommandBindings({ + visibility: "active", + commands: ["session.list", "session.new", "session.page.up", "session.first", "model.list"], + }), + ([command, bindings]) => [command, bindings.length], + ), + ) + + counts.base = activeCounts() + const popQuestion = getOpencodeModeStack(keymap).push("question") + counts.question = activeCounts() + popQuestion() + const popAutocomplete = getOpencodeModeStack(keymap).push("autocomplete") + counts.autocomplete = activeCounts() + popAutocomplete() + + onCleanup(() => { + offBase() + offGlobal() + offKeymap() + }) + + return ( + + + + ) + } + + const app = await testRender(() => ) + try { + expect(counts).toEqual({ + base: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 2, "model.list": 1 }, + question: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 2, "model.list": 0 }, + autocomplete: { + "session.list": 1, + "session.new": 1, + "session.page.up": 2, + "session.first": 2, + "model.list": 0, + }, + }) + } finally { + app.renderer.destroy() + } +})