diff --git a/packages/cli/src/mini/footer.prompt.tsx b/packages/cli/src/mini/footer.prompt.tsx index df1aa9312a..89786a14d6 100644 --- a/packages/cli/src/mini/footer.prompt.tsx +++ b/packages/cli/src/mini/footer.prompt.tsx @@ -23,7 +23,7 @@ import { movePromptHistory, pushPromptHistory, } from "./prompt.shared" -import { OPENCODE_BASE_MODE, useBindings } from "@opencode-ai/tui/keymap" +import { Keymap } from "@opencode-ai/tui/context/keymap" import { realignEditorPromptParts, resolveEditorSlashValue } from "./prompt.editor" import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu" import type { RunFooterTheme } from "./theme" @@ -993,93 +993,83 @@ export function createPromptState(input: PromptInput): PromptState { return true } - useBindings(() => ({ - mode: OPENCODE_BASE_MODE, + Keymap.createLayer(() => ({ enabled: baseBindingsEnabled(), commands: [ { - name: "prompt.clear", + id: "prompt.clear", title: "Clear prompt or exit", - category: "Prompt", + group: "Prompt", run() { if (requestExit()) return return false }, }, ], - bindings: input.tuiConfig.keybinds.get("prompt.clear"), })) - useBindings(() => ({ - mode: OPENCODE_BASE_MODE, + Keymap.createLayer(() => ({ enabled: input.prompt(), commands: [ { - name: "session.interrupt", + id: "session.interrupt", title: "Interrupt session", - category: "Session", + group: "Session", run() { if (input.onInterrupt()) return return false }, }, ], - bindings: input.tuiConfig.keybinds.get("session.interrupt"), })) - useBindings(() => ({ - mode: OPENCODE_BASE_MODE, + Keymap.createLayer(() => ({ enabled: input.prompt() && !visible(), commands: [ { - name: "prompt.editor", + id: "prompt.editor", title: "Open editor", - category: "Prompt", + group: "Prompt", run() { void openEditor() }, }, ], - bindings: input.tuiConfig.keybinds.get("prompt.editor"), })) - useBindings(() => ({ + Keymap.createLayer(() => ({ priority: 1, - mode: OPENCODE_BASE_MODE, enabled: input.prompt() && !visible(), commands: [ { - name: "prompt.history.previous", + id: "prompt.history.previous", title: "Previous prompt history", - category: "Prompt", - run(ctx: { event: KeyEvent }) { - return historyCommand(-1, ctx.event) + group: "Prompt", + run(_input: string | undefined, event?: KeyEvent) { + if (!event) return false + return historyCommand(-1, event) }, }, { - name: "prompt.history.next", + id: "prompt.history.next", title: "Next prompt history", - category: "Prompt", - run(ctx: { event: KeyEvent }) { - return historyCommand(1, ctx.event) + group: "Prompt", + run(_input: string | undefined, event?: KeyEvent) { + if (!event) return false + return historyCommand(1, event) }, }, ], - bindings: [ - ...input.tuiConfig.keybinds.get("prompt.history.previous"), - ...input.tuiConfig.keybinds.get("prompt.history.next"), - ], })) - useBindings(() => ({ - mode: OPENCODE_BASE_MODE, + Keymap.createLayer(() => ({ enabled: input.prompt() && !visible(), - bindings: [ + commands: [ { - key: "!", - desc: "Shell mode", + bind: "!", + title: "Shell mode", group: "Prompt", - cmd() { + run() { if (shell()) return false if (!area || area.isDestroyed) return false if (area.cursorOffset !== 0) return false @@ -1089,21 +1079,20 @@ export function createPromptState(input: PromptInput): PromptState { ], })) - useBindings(() => ({ - mode: OPENCODE_BASE_MODE, + Keymap.createLayer(() => ({ enabled: input.prompt() && shell() && !visible(), - bindings: [ + commands: [ { - key: "escape", - desc: "Exit shell mode", + bind: "escape", + title: "Exit shell mode", group: "Prompt", - cmd: () => setShellMode(false), + run: () => setShellMode(false), }, { - key: "backspace", - desc: "Exit shell mode", + bind: "backspace", + title: "Exit shell mode", group: "Prompt", - cmd() { + run() { if (!area || area.isDestroyed) return false if (area.cursorOffset !== 0) return false setShellMode(false) @@ -1112,32 +1101,31 @@ export function createPromptState(input: PromptInput): PromptState { ], })) - useBindings(() => ({ - mode: OPENCODE_BASE_MODE, + Keymap.createLayer(() => ({ enabled: input.prompt() && visible(), commands: [ { - name: "prompt.autocomplete.prev", + id: "prompt.autocomplete.prev", title: "Previous autocomplete item", - category: "Autocomplete", + group: "Autocomplete", run: () => menu.move(-1), }, { - name: "prompt.autocomplete.next", + id: "prompt.autocomplete.next", title: "Next autocomplete item", - category: "Autocomplete", + group: "Autocomplete", run: () => menu.move(1), }, { - name: "prompt.autocomplete.hide", + id: "prompt.autocomplete.hide", title: "Hide autocomplete", - category: "Autocomplete", + group: "Autocomplete", run: cancelAutocomplete, }, { - name: "prompt.autocomplete.select", + id: "prompt.autocomplete.select", title: "Select autocomplete item", - category: "Autocomplete", + group: "Autocomplete", run() { if (mode() === "slash" && options().length === 0) { hide() @@ -1147,9 +1135,9 @@ export function createPromptState(input: PromptInput): PromptState { }, }, { - name: "prompt.autocomplete.complete", + id: "prompt.autocomplete.complete", title: "Complete autocomplete item", - category: "Autocomplete", + group: "Autocomplete", run() { if (mode() === "slash" && options().length === 0) { hide() @@ -1164,13 +1152,6 @@ export function createPromptState(input: PromptInput): PromptState { }, }, ], - bindings: [ - "prompt.autocomplete.prev", - "prompt.autocomplete.next", - "prompt.autocomplete.hide", - "prompt.autocomplete.select", - "prompt.autocomplete.complete", - ].flatMap((command) => input.tuiConfig.keybinds.get(command)), })) const onKeyDown = (event: KeyEvent) => { diff --git a/packages/cli/src/mini/footer.ts b/packages/cli/src/mini/footer.ts index fd758caf29..f55aa1cea2 100644 --- a/packages/cli/src/mini/footer.ts +++ b/packages/cli/src/mini/footer.ts @@ -24,12 +24,11 @@ // Ctrl-c clears a live prompt draft first; otherwise interrupt and exit use a // two-press pattern where the first press shows a hint and the second press // within 5 seconds actually fires the action. -import { CliRenderEvents, type CliRenderer, type KeyEvent, type Renderable, type TreeSitterClient } from "@opentui/core" -import type { Keymap } from "@opentui/keymap" +import { CliRenderEvents, type CliRenderer, type TreeSitterClient } from "@opentui/core" import { render } from "@opentui/solid" import { createComponent, createSignal, type Accessor, type Setter } from "solid-js" import { createStore, reconcile } from "solid-js/store" -import { OpencodeKeymapProvider } from "@opencode-ai/tui/keymap" +import { Keymap } from "@opencode-ai/tui/context/keymap" import { RUN_COMMAND_PANEL_ROWS, RUN_SUBAGENT_PANEL_ROWS } from "./footer.command" import { SUBAGENT_INSPECTOR_ROWS } from "./footer.subagent" import { PROMPT_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt" @@ -82,7 +81,6 @@ type RunFooterOptions = { first: boolean history?: RunPrompt[] theme: RunTheme - keymap: Keymap tuiConfig: RunTuiConfig diffStyle: RunDiffStyle onPermissionReply: (input: PermissionReply) => void | Promise @@ -305,8 +303,8 @@ export class RunFooter implements FooterApi { const footer = this void render( () => - createComponent(OpencodeKeymapProvider, { - keymap: options.keymap, + createComponent(Keymap.Provider, { + config: options.tuiConfig, get children() { return createComponent(RunFooterView, { directory: options.directory, diff --git a/packages/cli/src/mini/footer.view.tsx b/packages/cli/src/mini/footer.view.tsx index a7824dddcf..c60e0a40d1 100644 --- a/packages/cli/src/mini/footer.view.tsx +++ b/packages/cli/src/mini/footer.view.tsx @@ -27,14 +27,8 @@ import { RunPromptBody, createPromptState } from "./footer.prompt" import { RunPermissionBody } from "./footer.permission" import { RunQuestionBody } from "./footer.question" import { footerWidthPolicy } from "./footer.width" -import { - OPENCODE_BASE_MODE, - formatKeyBindings, - formatKeySequence, - useBindings, - useKeymapSelector, - type OpenTuiKeymap, -} from "@opencode-ai/tui/keymap" +import { Keymap } from "@opencode-ai/tui/context/keymap" + import type { FooterPromptRoute, FooterQueuedPrompt, @@ -177,75 +171,15 @@ export function RunFooterView(props: RunFooterViewProps) { const current = route() return current.type === "subagent" ? subagent().details[current.sessionID] : undefined }) - const command = useKeymapSelector( - (keymap: OpenTuiKeymap) => - formatKeySequence( - keymap - .getCommandBindings({ visibility: "registered", commands: ["command.palette.show"] }) - .get("command.palette.show")?.[0]?.sequence, - props.tuiConfig, - ) ?? "", - ) - const subagentShortcut = useKeymapSelector( - (keymap: OpenTuiKeymap) => - formatKeySequence( - keymap - .getCommandBindings({ visibility: "registered", commands: ["session.child.first"] }) - .get("session.child.first")?.[0]?.sequence, - props.tuiConfig, - ) ?? "", - ) - const queuedShortcut = useKeymapSelector( - (keymap: OpenTuiKeymap) => - formatKeySequence( - keymap - .getCommandBindings({ visibility: "registered", commands: ["session.queued_prompts"] }) - .get("session.queued_prompts")?.[0]?.sequence, - props.tuiConfig, - ) ?? "", - ) - const backgroundShortcut = useKeymapSelector( - (keymap: OpenTuiKeymap) => - formatKeySequence( - keymap - .getCommandBindings({ visibility: "registered", commands: ["session.background"] }) - .get("session.background")?.[0]?.sequence, - props.tuiConfig, - ) ?? "", - ) - const subagentInterruptShortcut = useKeymapSelector( - (keymap: OpenTuiKeymap) => - formatKeySequence( - keymap - .getCommandBindings({ visibility: "registered", commands: ["subagent.interrupt"] }) - .get("subagent.interrupt")?.[0]?.sequence, - props.tuiConfig, - ) ?? "", - ) - const interrupt = useKeymapSelector( - (keymap: OpenTuiKeymap) => - formatKeySequence( - keymap - .getCommandBindings({ visibility: "registered", commands: ["session.interrupt"] }) - .get("session.interrupt")?.[0]?.sequence, - props.tuiConfig, - ) ?? "", - ) - const variantCycle = useKeymapSelector( - (keymap: OpenTuiKeymap) => - formatKeyBindings( - keymap.getCommandBindings({ visibility: "registered", commands: ["variant.cycle"] }).get("variant.cycle"), - props.tuiConfig, - ) ?? "", - ) - const clearShortcut = useKeymapSelector( - (keymap: OpenTuiKeymap) => - formatKeySequence( - keymap.getCommandBindings({ visibility: "registered", commands: ["prompt.clear"] }).get("prompt.clear")?.[0] - ?.sequence, - props.tuiConfig, - ) ?? "", - ) + const shortcuts = Keymap.useShortcuts() + const command = () => shortcuts.get("command.palette.show") ?? "" + const subagentShortcut = () => shortcuts.get("session.child.first") ?? "" + const queuedShortcut = () => shortcuts.get("session.queued_prompts") ?? "" + const backgroundShortcut = () => shortcuts.get("session.background") ?? "" + const subagentInterruptShortcut = () => shortcuts.get("subagent.interrupt") ?? "" + const interrupt = () => shortcuts.get("session.interrupt") ?? "" + const variantCycle = () => shortcuts.all("variant.cycle") ?? "" + const clearShortcut = () => shortcuts.get("prompt.clear") ?? "" const busy = createMemo(() => props.state().phase === "running") const armed = createMemo(() => props.state().interrupt > 0) const exiting = createMemo(() => props.state().exit > 0) @@ -504,74 +438,62 @@ export function RunFooterView(props: RunFooterViewProps) { props.onRequestExit?.(undefined) }) - useBindings(() => ({ - mode: OPENCODE_BASE_MODE, + Keymap.createLayer(() => ({ enabled: active().type === "prompt" && route().type === "composer" && !composer.visible(), commands: [ { - name: "command.palette.show", + id: "command.palette.show", title: "Open command palette", - category: "Prompt", + group: "Prompt", run: openCommand, }, { - name: "variant.cycle", + id: "variant.cycle", title: "Cycle model variant", - category: "Model", + group: "Model", run: props.onCycle, }, ], - bindings: [ - ...props.tuiConfig.keybinds.get("command.palette.show"), - ...props.tuiConfig.keybinds.get("variant.cycle"), - ], })) - useBindings(() => ({ - mode: OPENCODE_BASE_MODE, + Keymap.createLayer(() => ({ enabled: active().type === "prompt" && route().type === "composer" && foregroundSubagents() && !!props.onBackground, priority: 1, commands: [ { - name: "session.background", + id: "session.background", title: "Background subagents", - category: "Session", + group: "Session", run: () => props.onBackground?.(), }, ], - bindings: props.tuiConfig.keybinds.get("session.background"), })) - useBindings(() => ({ - mode: OPENCODE_BASE_MODE, + Keymap.createLayer(() => ({ enabled: active().type === "prompt" && route().type === "composer" && tabs().length > 0, commands: [ { - name: "session.child.first", + id: "session.child.first", title: "View subagents", - category: "Session", + group: "Session", run: openSubagentMenu, }, ], - bindings: props.tuiConfig.keybinds.get("session.child.first"), })) - useBindings(() => ({ - mode: OPENCODE_BASE_MODE, + Keymap.createLayer(() => ({ enabled: active().type === "prompt" && route().type === "composer" && queuedPrompts().length > 0, commands: [ { - name: "session.queued_prompts", + id: "session.queued_prompts", title: "Manage queued prompts", - category: "Session", + group: "Session", run: openQueuedMenu, }, ], - bindings: props.tuiConfig.keybinds.get("session.queued_prompts"), })) - useBindings(() => ({ - mode: OPENCODE_BASE_MODE, + Keymap.createLayer(() => ({ enabled: active().type === "prompt" && route().type === "subagent" && @@ -580,9 +502,10 @@ export function RunFooterView(props: RunFooterViewProps) { priority: 1, commands: [ { - name: "subagent.interrupt", + id: "subagent.interrupt", title: "Interrupt subagent", - category: "Session", + group: "Session", + bind: "ctrl+d", run: () => { const current = selectedTab() if (current?.status !== "running") { @@ -593,7 +516,6 @@ export function RunFooterView(props: RunFooterViewProps) { }, }, ], - bindings: [{ key: "ctrl+d", desc: "Interrupt subagent", group: "Subagents", cmd: "subagent.interrupt" }], })) createEffect(() => { diff --git a/packages/cli/src/mini/runtime.lifecycle.ts b/packages/cli/src/mini/runtime.lifecycle.ts index e25c29f02e..937acb307b 100644 --- a/packages/cli/src/mini/runtime.lifecycle.ts +++ b/packages/cli/src/mini/runtime.lifecycle.ts @@ -10,9 +10,7 @@ // back to the usual two-press exit sequence through RunFooter.requestExit(). import path from "path" import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core" -import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" import { Global } from "@opencode-ai/core/global" -import { registerOpencodeKeymap } from "@opencode-ai/tui/keymap" import { isDefaultTitle } from "@opencode-ai/tui/util/session" import { Locale } from "@opencode-ai/tui/util/locale" import { resolveInteractiveStdin } from "./runtime.stdin" @@ -167,8 +165,6 @@ function queueSplash( export async function createRuntimeLifecycle(input: LifecycleInput): Promise { const source = resolveInteractiveStdin() const footerTask = import("./footer") - let unregisterKeymap: (() => void) | undefined - try { const renderer = await createCliRenderer({ stdin: source.stdin, @@ -187,8 +183,6 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise {}) footer.destroy() - unregisterKeymap?.() shutdown(renderer) if (!wroteExit) { process.stdout.write("\n") @@ -391,7 +383,6 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise { { keybinds: { editor_open: "none", session_queued_prompts: "none" } }, { terminalSuspend: true }, ) - let offKeymap: (() => void) | undefined - function Harness() { - const renderer = useRenderer() - const keymap = createDefaultOpenTuiKeymap(renderer) - offKeymap = registerOpencodeKeymap(keymap, renderer, config) - - return createComponent(OpencodeKeymapProvider, { - keymap, - get children() { - return ( - []} - agents={() => []} - references={() => []} - commands={() => []} - providers={() => undefined} - currentModel={() => undefined} - variants={() => []} - currentVariant={() => undefined} - state={state} - view={view} - subagent={subagents} - theme={() => RUN_THEME_FALLBACK} - tuiConfig={config} - agent="opencode" - onSubmit={() => true} - onPermissionReply={() => {}} - onQuestionReply={() => {}} - onQuestionReject={() => {}} - onCycle={() => {}} - onInterrupt={() => false} - onEditorOpen={async () => undefined} - onInputClear={() => {}} - onExit={() => {}} - onModelSelect={() => {}} - onVariantSelect={() => {}} - onRows={() => {}} - onLayout={() => {}} - onStatus={() => {}} - onQueuedRemove={async () => true} - /> - ) - }, - }) + return ( + + []} + agents={() => []} + references={() => []} + commands={() => []} + providers={() => undefined} + currentModel={() => undefined} + variants={() => []} + currentVariant={() => undefined} + state={state} + view={view} + subagent={subagents} + theme={() => RUN_THEME_FALLBACK} + tuiConfig={config} + agent="opencode" + onSubmit={() => true} + onPermissionReply={() => {}} + onQuestionReply={() => {}} + onQuestionReject={() => {}} + onCycle={() => {}} + onInterrupt={() => false} + onEditorOpen={async () => undefined} + onInputClear={() => {}} + onExit={() => {}} + onModelSelect={() => {}} + onVariantSelect={() => {}} + onRows={() => {}} + onLayout={() => {}} + onStatus={() => {}} + onQueuedRemove={async () => true} + /> + + ) } const app = await testRender(() => , { width: 100, height: 8, kittyKeyboard: true }) @@ -100,7 +90,6 @@ test("down opens subagents from an empty prompt", async () => { } finally { app.renderer.currentFocusedRenderable?.blur() app.renderer.currentFocusedEditor?.blur() - offKeymap?.() app.renderer.destroy() } }) diff --git a/packages/opencode/test/cli/run/footer.view.test.tsx b/packages/opencode/test/cli/run/footer.view.test.tsx index 25b1a32c88..dc82a74f54 100644 --- a/packages/opencode/test/cli/run/footer.view.test.tsx +++ b/packages/opencode/test/cli/run/footer.view.test.tsx @@ -1,11 +1,10 @@ /** @jsxImportSource @opentui/solid */ import { expect, test } from "bun:test" import { BoxRenderable, RGBA, type RootRenderable } from "@opentui/core" -import { testRender, useRenderer } from "@opentui/solid" +import { testRender } from "@opentui/solid" import { createSignal } from "solid-js" -import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" import type { QuestionRequest } from "@opencode-ai/sdk/v2" -import { OpencodeKeymapProvider, registerOpencodeKeymap } from "@opencode-ai/tui/keymap" +import { Keymap } from "@opencode-ai/tui/context/keymap" import { RUN_COMMAND_PANEL_ROWS, RUN_SUBAGENT_PANEL_ROWS, @@ -174,15 +173,9 @@ async function renderFooter( ) const state = footerState(input.state) const config = input.tuiConfig ?? tuiConfig - let offKeymap: (() => void) | undefined - function Harness() { - const renderer = useRenderer() - const keymap = createDefaultOpenTuiKeymap(renderer) - offKeymap = registerOpencodeKeymap(keymap, renderer, config) - return ( - + []} @@ -215,7 +208,7 @@ async function renderFooter( onStatus={() => {}} onQueuedRemove={async () => true} /> - + ) } @@ -233,8 +226,6 @@ async function renderFooter( cleanup() { app.renderer.currentFocusedRenderable?.blur() app.renderer.currentFocusedEditor?.blur() - offKeymap?.() - offKeymap = undefined app.renderer.destroy() }, } @@ -1003,14 +994,9 @@ test("direct footer shows editable prompts and additional queued work while runn permissions: [], questions: [], }) - let offKeymap: (() => void) | undefined function Harness() { - const renderer = useRenderer() - const keymap = createDefaultOpenTuiKeymap(renderer) - offKeymap = registerOpencodeKeymap(keymap, renderer, tuiConfig) - return ( - + []} @@ -1049,7 +1035,7 @@ test("direct footer shows editable prompts and additional queued work while runn onStatus={() => {}} onQueuedRemove={async () => true} /> - + ) } @@ -1085,7 +1071,7 @@ test("direct footer shows editable prompts and additional queued work while runn expect(frame).toContain("3 queued") expect(frame).toContain("ctrl+b background") expect(frame).toContain("ctrl+x q 3 queued") - expect(frame).toContain("ctrl+x down subagents") + expect(frame).toContain("↓ subagents") expect(frame).toContain("ctrl+p cmd") expect(frame).toContain("a-model-name-long-enough-to-force-responsive-truncation") expect(frame).toContain("subagents · ctrl+p cmd") @@ -1099,7 +1085,6 @@ test("direct footer shows editable prompts and additional queued work while runn } finally { app.renderer.currentFocusedRenderable?.blur() app.renderer.currentFocusedEditor?.blur() - offKeymap?.() app.renderer.destroy() } }) @@ -1151,7 +1136,7 @@ test("direct footer hides the subagent hint when only completed subagents remain expect(frame).toContain("GPT-5") expect(frame).toContain("xhigh · ctrl+p cmd") - expect(frame).not.toContain("ctrl+x down subagents") + expect(frame).not.toContain("↓ subagents") } finally { app.cleanup() } @@ -1269,15 +1254,9 @@ test.skip("direct custom answer submits through keymap return binding", async () ], } satisfies QuestionRequest const questions: unknown[] = [] - let off: (() => void) | undefined - function Harness() { - const renderer = useRenderer() - const keymap = createDefaultOpenTuiKeymap(renderer) - off = registerOpencodeKeymap(keymap, renderer, tuiConfig) - return ( - + {}} /> - + ) } @@ -1311,7 +1290,6 @@ test.skip("direct custom answer submits through keymap return binding", async () } finally { app.renderer.currentFocusedRenderable?.blur() app.renderer.currentFocusedEditor?.blur() - off?.() app.renderer.destroy() } }) @@ -1319,15 +1297,9 @@ test.skip("direct custom answer submits through keymap return binding", async () test("direct permission rejection submits through keymap return binding", async () => { let text = "" const submits: string[] = [] - let off: (() => void) | undefined - function Harness() { - const renderer = useRenderer() - const keymap = createDefaultOpenTuiKeymap(renderer) - off = registerOpencodeKeymap(keymap, renderer, tuiConfig) - return ( - + {}} /> - + ) } @@ -1364,7 +1336,6 @@ test("direct permission rejection submits through keymap return binding", async } finally { app.renderer.currentFocusedRenderable?.blur() app.renderer.currentFocusedEditor?.blur() - off?.() app.renderer.destroy() } }) diff --git a/packages/plugin/src/v2/tui/context.ts b/packages/plugin/src/v2/tui/context.ts index c95636b44a..8d7c99003a 100644 --- a/packages/plugin/src/v2/tui/context.ts +++ b/packages/plugin/src/v2/tui/context.ts @@ -19,7 +19,7 @@ import type { ShellInfo, SkillInfo, } from "@opencode-ai/client" -import type { Renderable } from "@opentui/core" +import type { KeyEvent, Renderable } from "@opentui/core" import type { JSX } from "@opentui/solid" interface LocationCollection { @@ -139,8 +139,8 @@ export interface KeymapCommand { } /** Promotes the command in discovery UI. */ readonly suggested?: boolean | (() => boolean) - /** Executes the command. Return false to let keymap dispatch continue. */ - readonly run: (input?: string) => void | false | Promise + /** Executes the command. Keyboard dispatch includes its event; programmatic dispatch does not. Return false to continue. */ + readonly run: (input?: string, event?: KeyEvent) => void | false | Promise } export interface KeymapLayer { diff --git a/packages/tui/package.json b/packages/tui/package.json index 2840fa56e3..c88246e64b 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -30,7 +30,7 @@ "./editor-zed": "./src/editor-zed.ts", "./runtime": "./src/runtime.tsx", "./terminal-win32": "./src/terminal-win32.ts", - "./keymap": "./src/keymap.tsx", + "./context/keymap": "./src/context/keymap.tsx", "./prompt/content": "./src/prompt/content.ts", "./prompt/display": "./src/prompt/display.ts", "./plugin/runtime": "./src/plugin/runtime.tsx", diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index d15f0d8cf3..a0b2e3fc0b 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -80,8 +80,7 @@ import { Config, ConfigProvider, useConfig } from "./config" import { createPluginRuntime, PluginRuntimeProvider, usePluginRuntime } from "./plugin/runtime" import { PluginProvider, PluginRoute, PluginSlot, usePlugin, type PackageResolver } from "./plugin/context" import { CommandPaletteDialog } from "./component/command-palette" -import { COMMAND_PALETTE_COMMAND, OPENCODE_BASE_MODE, useBindings, useOpencodeKeymap } from "./keymap" -import { Keymap } from "./context/keymap" +import { COMMAND_PALETTE_COMMAND, Keymap, type KeymapCommand } from "./context/keymap" import { DialogVariant } from "./component/dialog-variant" import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-win32" @@ -416,7 +415,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) { const renderer = useRenderer() const dialog = useDialog() const local = useLocal() - const keymap = useOpencodeKeymap() + const keymap = Keymap.use() const event = useEvent() const client = useClient() const toast = useToast() @@ -589,7 +588,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) { name: COMMAND_PALETTE_COMMAND, title: "Show command palette", category: "System", - hidden: true, + palette: undefined, run: () => { dialog.replace(() => ) }, @@ -621,7 +620,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) { name: `session.quick_switch.${i + 1}`, title: `Switch to session in quick slot ${i + 1}`, category: "Session", - hidden: true, + palette: undefined, run: () => { local.session.quickSwitch(i + 1) }, @@ -641,7 +640,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) { name: "model.cycle_recent", title: "Model cycle", category: "Agent", - hidden: true, + palette: undefined, run: () => { local.model.cycle(1) }, @@ -650,7 +649,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) { name: "model.cycle_recent_reverse", title: "Model cycle reverse", category: "Agent", - hidden: true, + palette: undefined, run: () => { local.model.cycle(-1) }, @@ -659,7 +658,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) { name: "model.cycle_favorite", title: "Favorite cycle", category: "Agent", - hidden: true, + palette: undefined, run: () => { local.model.cycleFavorite(1) }, @@ -668,7 +667,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) { name: "model.cycle_favorite_reverse", title: "Favorite cycle reverse", category: "Agent", - hidden: true, + palette: undefined, run: () => { local.model.cycleFavorite(-1) }, @@ -695,7 +694,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) { name: "agent.cycle", title: "Agent cycle", category: "Agent", - hidden: true, + palette: undefined, run: () => { local.agent.move(1) }, @@ -712,7 +711,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) { name: "variant.list", title: "Switch model variant", category: "Agent", - hidden: local.model.variant.list().length === 0, + palette: local.model.variant.list().length === 0 ? undefined : (true as const), slash: { name: "variants" }, run: () => { if (local.model.variant.list().length === 0) { @@ -729,7 +728,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) { name: "agent.cycle.reverse", title: "Agent cycle reverse", category: "Agent", - hidden: true, + palette: undefined, run: () => { local.agent.move(-1) }, @@ -818,7 +817,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) { { name: "theme.switch_mode", title: mode() === "dark" ? "Switch to light mode" : "Switch to dark mode", - hidden: true, + palette: undefined, run: () => { setMode(mode() === "dark" ? "light" : "dark") dialog.clear() @@ -828,7 +827,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) { { name: "theme.mode.lock", title: locked() ? "Unlock theme mode" : "Lock theme mode", - hidden: true, + palette: undefined, run: () => { if (locked()) unlock() else lock() @@ -883,7 +882,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) { name: "terminal.suspend", title: "Suspend terminal", category: "System", - hidden: true, + palette: undefined, enabled: process.platform !== "win32", run: () => { renderer.suspend() @@ -895,7 +894,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) { name: "terminal.title.toggle", title: terminalTitleEnabled() ? "Disable terminal title" : "Enable terminal title", category: "System", - hidden: true, + palette: undefined, run: () => { const next = !terminalTitleEnabled() if (!next) renderer.setTerminalTitle("") @@ -911,7 +910,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) { name: "app.toggle.animations", title: (config.data.animations ?? true) ? "Disable animations" : "Enable animations", category: "System", - hidden: true, + palette: undefined, run: () => { void config .update((draft) => { @@ -925,7 +924,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) { name: "app.toggle.file_context", title: (config.data.prompt?.editor ?? true) ? "Disable file context" : "Enable file context", category: "System", - hidden: true, + palette: undefined, run: () => { void config .update((draft) => { @@ -939,7 +938,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) { name: "app.toggle.diffwrap", title: (config.data.diffs?.wrap ?? "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping", category: "System", - hidden: true, + palette: undefined, run: () => { void config .update((draft) => { @@ -956,7 +955,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) { name: "app.toggle.paste_summary", title: pasteSummaryEnabled() ? "Disable paste summary" : "Enable paste summary", category: "System", - hidden: true, + palette: undefined, run: () => { void config .update((draft) => { @@ -976,38 +975,44 @@ function App(props: { pair?: DialogPairCredentials; started: number }) { dialog.clear() }, }, - ].map((command) => ({ - namespace: "palette", - ...command, - })), + ].map( + ({ name, category, ...command }) => + ({ + id: name, + group: category, + bind: false, + palette: true as const, + ...command, + }) satisfies KeymapCommand, + ), ) - useBindings(() => ({ + Keymap.createLayer(() => ({ + mode: "global", commands: appCommands(), })) - useBindings(() => ({ - mode: OPENCODE_BASE_MODE, - bindings: appBindingCommands.flatMap((command) => config.data.keybinds.get(command)), + Keymap.createLayer(() => ({ + bindings: appBindingCommands, })) - useBindings(() => ({ - bindings: appGlobalBindingCommands.flatMap((command) => config.data.keybinds.get(command)), + Keymap.createLayer(() => ({ + mode: "global", + bindings: appGlobalBindingCommands, })) - useBindings(() => ({ - mode: OPENCODE_BASE_MODE, + Keymap.createLayer(() => ({ enabled: () => { const current = promptRef.current if (!current?.focused) return true return current.current.text === "" }, - bindings: config.data.keybinds.get("app.exit"), + bindings: ["app.exit"], })) event.on("tui.command.execute", (evt, { workspace }) => { if (workspace !== (location.current?.workspaceID ?? data.location.default().workspaceID)) return - keymap.dispatchCommand(evt.data.command) + keymap.dispatch(evt.data.command) }) event.on("tui.toast.show", (evt, { workspace }) => { diff --git a/packages/tui/src/component/command-palette.tsx b/packages/tui/src/component/command-palette.tsx index e7c55b5a48..d9db1a1cd3 100644 --- a/packages/tui/src/component/command-palette.tsx +++ b/packages/tui/src/component/command-palette.tsx @@ -1,8 +1,7 @@ import { createMemo } from "solid-js" import { DialogSelect, type DialogSelectRef } from "../ui/dialog-select" import { type DialogContext } from "../ui/dialog" -import { COMMAND_PALETTE_COMMAND } from "../keymap" -import { Keymap, type KeymapCommand } from "../context/keymap" +import { COMMAND_PALETTE_COMMAND, Keymap, type KeymapCommand } from "../context/keymap" function isSuggestedPaletteCommand(command: KeymapCommand) { const suggested = command.suggested diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index 545f8099d6..0d1dd9f340 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -18,7 +18,6 @@ import { useTerminalDimensions } from "@opentui/solid" import { Locale } from "../../util/locale" import type { PromptInfo, PromptPartRef } from "../../prompt/history" import { useFrecency } from "../../prompt/frecency" -import { useBindings } from "../../keymap" import { Keymap } from "../../context/keymap" import { displayCharAt, mentionTriggerIndex } from "../../prompt/display" import type { FileSystemEntry } from "@opencode-ai/client" @@ -578,48 +577,49 @@ export function Autocomplete(props: { setStore("selected", 0) } - useBindings(() => ({ + Keymap.createLayer(() => ({ + mode: "autocomplete", target: props.input, enabled: () => Boolean(store.visible), commands: [ { - name: "prompt.autocomplete.prev", + id: "prompt.autocomplete.prev", title: "Previous autocomplete item", - category: "Autocomplete", + group: "Autocomplete", run() { setStore("input", "keyboard") move(-1) }, }, { - name: "prompt.autocomplete.next", + id: "prompt.autocomplete.next", title: "Next autocomplete item", - category: "Autocomplete", + group: "Autocomplete", run() { setStore("input", "keyboard") move(1) }, }, { - name: "prompt.autocomplete.hide", + id: "prompt.autocomplete.hide", title: "Hide autocomplete", - category: "Autocomplete", + group: "Autocomplete", run() { hide() }, }, { - name: "prompt.autocomplete.select", + id: "prompt.autocomplete.select", title: "Select autocomplete item", - category: "Autocomplete", + group: "Autocomplete", run() { select() }, }, { - name: "prompt.autocomplete.complete", + id: "prompt.autocomplete.complete", title: "Complete autocomplete item", - category: "Autocomplete", + group: "Autocomplete", run() { const selected = options()[store.selected] if (selected?.isDirectory) { @@ -631,13 +631,6 @@ export function Autocomplete(props: { }, }, ], - bindings: [ - "prompt.autocomplete.prev", - "prompt.autocomplete.next", - "prompt.autocomplete.hide", - "prompt.autocomplete.select", - "prompt.autocomplete.complete", - ].flatMap((command) => config.keybinds.get(command)), })) function show(mode: "@" | "/") { diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 3688ef3ef7..d578f70e54 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -6,9 +6,7 @@ import { PasteEvent, decodePasteBytes, type KeyEvent, - type Renderable, } from "@opentui/core" -import type { CommandContext } from "@opentui/keymap" import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js" import { registerOpencodeSpinner } from "../register-spinner" import path from "path" @@ -46,7 +44,6 @@ import { useToast } from "../../ui/toast" import { createFadeIn } from "../../util/signal" import { DialogSkill } from "../dialog-skill" import { useArgs } from "../../context/args" -import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useLeaderActive, useOpencodeKeymap } from "../../keymap" import { useConfig } from "../../config" import { usePromptMove } from "./move" import { readLocalAttachment } from "./local-attachment" @@ -155,7 +152,7 @@ export function Prompt(props: PromptProps) { let anchor: BoxRenderable const [inputTarget, setInputTarget] = createSignal() - const leader = useLeaderActive() + const leader = Keymap.useLeaderActive() const local = useLocal() const args = useArgs() const paths = useTuiPaths() @@ -183,10 +180,10 @@ export function Prompt(props: PromptProps) { ) const history = usePromptHistory() const stash = usePromptStash() - const keymap = useOpencodeKeymap() - const agentShortcut = useCommandShortcut("agent.cycle") - const paletteShortcut = useCommandShortcut("command.palette.show") - const liveWorkShortcut = useCommandShortcut("session.child.first") + const keymap = Keymap.use() + const agentShortcut = Keymap.useShortcut("agent.cycle") + const paletteShortcut = Keymap.useShortcut("command.palette.show") + const liveWorkShortcut = Keymap.useShortcut("session.child.first") const renderer = useRenderer() const exit = useExit() const dimensions = useTerminalDimensions() @@ -387,7 +384,7 @@ export function Prompt(props: PromptProps) { title: "Clear prompt", name: "prompt.clear", category: "Prompt", - hidden: true, + palette: undefined, run: () => { clearPrompt() dialog.clear() @@ -397,8 +394,10 @@ export function Prompt(props: PromptProps) { title: "Submit prompt", name: "prompt.submit", category: "Prompt", - hidden: true, - run: async () => { + palette: undefined, + run: async (_input: string | undefined, event?: KeyEvent) => { + event?.preventDefault() + event?.stopPropagation() if (!input.focused) return const handled = await submit() if (!handled) return @@ -420,10 +419,10 @@ export function Prompt(props: PromptProps) { title: "Paste", name: "prompt.paste", category: "Prompt", - hidden: true, - run: async (ctx: CommandContext) => { - ctx.event.preventDefault() - ctx.event.stopPropagation() + palette: undefined, + run: async (_input: string | undefined, event?: KeyEvent) => { + event?.preventDefault() + event?.stopPropagation() const content = await clipboard.read?.() if (content?.mime.startsWith("image/")) { await pasteAttachment({ @@ -441,7 +440,7 @@ export function Prompt(props: PromptProps) { title: "Interrupt session", name: "session.interrupt", category: "Session", - hidden: true, + palette: undefined, enabled: status() === "running", run: () => { if (auto()?.visible) return @@ -472,7 +471,7 @@ export function Prompt(props: PromptProps) { title: "Background blocking tools", name: "session.background", category: "Session", - hidden: true, + palette: undefined, enabled: status() === "running", run: () => { if (auto()?.visible) return @@ -564,18 +563,24 @@ export function Prompt(props: PromptProps) { move.open() }, }, - ].map((entry) => ({ - namespace: "palette", - ...entry, - })), + ].map( + ({ name, category, ...command }) => + ({ + id: name, + group: category, + bind: false, + palette: true as const, + ...command, + }) satisfies KeymapCommand, + ), ) - useBindings(() => ({ + Keymap.createLayer(() => ({ + mode: "global", commands: promptCommands(), })) - useBindings(() => ({ - mode: OPENCODE_BASE_MODE, + Keymap.createLayer(() => ({ bindings: [ "prompt.submit", "prompt.editor", @@ -587,7 +592,7 @@ export function Prompt(props: PromptProps) { "session.interrupt", "session.background", "session.move", - ].flatMap((command) => config.keybinds.get(command)), + ], })) const ref: PromptRef = { @@ -803,33 +808,40 @@ export function Prompt(props: PromptProps) { )) }, }, - ].map((entry) => ({ - namespace: "palette", - ...entry, - })), + ].map( + ({ name, category, ...command }) => + ({ + id: name, + group: category, + bind: false, + palette: true as const, + ...command, + }) satisfies KeymapCommand, + ), ) - useBindings(() => ({ + Keymap.createLayer(() => ({ + mode: "global", commands: stashCommands(), })) - useBindings(() => { + Keymap.createLayer(() => { return { target: inputTarget, enabled: inputTarget() !== undefined && !props.disabled, - bindings: config.keybinds.get("prompt.paste"), + bindings: ["prompt.paste"], } }) - useBindings(() => { + Keymap.createLayer(() => { return { target: inputTarget, enabled: inputTarget() !== undefined && !props.disabled && store.prompt.text !== "", - bindings: config.keybinds.get("prompt.clear"), + bindings: ["prompt.clear"], } }) - useBindings(() => { + Keymap.createLayer(() => { return { target: inputTarget, enabled: (() => { @@ -842,12 +854,12 @@ export function Prompt(props: PromptProps) { input?.visualCursor.offset === 0 ) })(), - bindings: [ + commands: [ { - key: "!", - desc: "Shell mode", + bind: "!", + title: "Shell mode", group: "Prompt", - cmd: () => { + run: () => { setStore("placeholder", randomIndex(shell().length)) setStore("mode", "shell") }, @@ -856,26 +868,28 @@ export function Prompt(props: PromptProps) { } }) - useBindings(() => { + Keymap.createLayer(() => { return { target: inputTarget, enabled: inputTarget() !== undefined && store.mode === "shell", - bindings: [{ key: "escape", desc: "Exit shell mode", group: "Prompt", cmd: () => setStore("mode", "normal") }], + commands: [{ bind: "escape", title: "Exit shell mode", group: "Prompt", run: () => setStore("mode", "normal") }], } }) - useBindings(() => { + Keymap.createLayer(() => { return { target: inputTarget, enabled: (() => { cursorVersion() return inputTarget() !== undefined && store.mode === "shell" && input?.visualCursor.offset === 0 })(), - bindings: [{ key: "backspace", desc: "Exit shell mode", group: "Prompt", cmd: () => setStore("mode", "normal") }], + commands: [ + { bind: "backspace", title: "Exit shell mode", group: "Prompt", run: () => setStore("mode", "normal") }, + ], } }) - useBindings(() => { + Keymap.createLayer(() => { return { priority: 1, target: inputTarget, @@ -885,9 +899,9 @@ export function Prompt(props: PromptProps) { })(), commands: [ { - name: "prompt.history.previous", + id: "prompt.history.previous", title: "Previous prompt history", - category: "Prompt", + group: "Prompt", run() { if (input.cursorOffset !== 0) { if (input.scrollY + input.visualCursor.visualRow === 0) { @@ -908,11 +922,10 @@ export function Prompt(props: PromptProps) { }, }, ], - bindings: config.keybinds.get("prompt.history.previous"), } }) - useBindings(() => { + Keymap.createLayer(() => { return { priority: 1, target: inputTarget, @@ -922,9 +935,9 @@ export function Prompt(props: PromptProps) { })(), commands: [ { - name: "prompt.history.next", + id: "prompt.history.next", title: "Next prompt history", - category: "Prompt", + group: "Prompt", run() { if (input.cursorOffset !== input.plainText.length) { if ( @@ -948,7 +961,6 @@ export function Prompt(props: PromptProps) { }, }, ], - bindings: config.keybinds.get("prompt.history.next"), } }) @@ -1429,7 +1441,7 @@ export function Prompt(props: PromptProps) { // Windows Terminal <1.25 can surface image-only clipboard as an // empty bracketed paste. Windows Terminal 1.25+ does not. if (!pastedContent) { - keymap.dispatchCommand("prompt.paste") + keymap.dispatch("prompt.paste") return } @@ -1589,10 +1601,7 @@ export function Prompt(props: PromptProps) { - } - > + }> {(location) => ( {location()} diff --git a/packages/tui/src/context/keymap.tsx b/packages/tui/src/context/keymap.tsx index 5e902c664e..e8da0ca909 100644 --- a/packages/tui/src/context/keymap.tsx +++ b/packages/tui/src/context/keymap.tsx @@ -1,6 +1,6 @@ import type { KeymapCommand, KeymapLayer } from "@opencode-ai/plugin/v2/tui/context" -import { InputRenderable, TextareaRenderable } from "@opentui/core" -import { stringifyKeyStroke } from "@opentui/keymap" +import { InputRenderable, TextareaRenderable, type KeyEvent, type Renderable } from "@opentui/core" +import { stringifyKeyStroke, type Binding, type CommandContext } from "@opentui/keymap" import { registerBackspacePopsPendingSequence, registerBaseLayoutFallback, @@ -32,17 +32,27 @@ const MODE = { key: "opencode.mode", base: "base" } as const type OpenTuiKeymap = Parameters[0]["keymap"] type Mode = ReturnType +type KeymapConfig = { + readonly keybinds: { + get(command: string): readonly Binding[] + } + readonly leader?: { readonly timeout: number } + readonly leader_timeout?: number +} + +export const COMMAND_PALETTE_COMMAND = "command.palette.show" const Context = createContext<{ readonly keymap: OpenTuiKeymap + readonly config: KeymapConfig readonly mode: Mode readonly dispatch: (id: string, input?: string) => void readonly input: (id: string) => string | undefined }>() -function Provider(props: ParentProps) { +function Provider(props: ParentProps<{ config?: KeymapConfig }>) { const renderer = useRenderer() - const config = useConfig() + const config: KeymapConfig = props.config ?? useConfig().data const keymap = createDefaultOpenTuiKeymap(renderer) const mode = createMode(keymap) let invocation: { readonly id: string; readonly input?: string } | undefined @@ -111,16 +121,16 @@ function Provider(props: ParentProps) { "input.delete.word.backward", "input.select.all", "input.submit", - ].flatMap((command) => config.data.keybinds.get(command)), + ].flatMap((command) => config.keybinds.get(command)), }), ] - const leader = config.data.keybinds.get("leader")?.[0]?.key + const leader = config.keybinds.get("leader")?.[0]?.key if (leader) { dispose.push( registerTimedLeader(keymap, { trigger: leader, name: "leader", - timeoutMs: config.data.leader.timeout, + timeoutMs: config.leader?.timeout ?? ("leader_timeout" in config ? config.leader_timeout : undefined) ?? 2000, }), ) } @@ -131,7 +141,13 @@ function Provider(props: ParentProps) { return ( (invocation?.id === id ? invocation.input : undefined) }} + value={{ + keymap, + config, + mode, + dispatch, + input: (id) => (invocation?.id === id ? invocation.input : undefined), + }} > {props.children} @@ -151,6 +167,8 @@ export interface Keymap { /** Pushes a mode until the returned cleanup is called. */ push(mode: string): () => void } + /** Registers a low-level keymap interceptor. */ + intercept: OpenTuiKeymap["intercept"] } function use(): Keymap { @@ -160,12 +178,12 @@ function use(): Keymap { value.dispatch(id, input) }, mode: value.mode, + intercept: value.keymap.intercept.bind(value.keymap), } } function createLayer(input: () => KeymapLayer) { const value = useValue() - const config = useConfig() useBindings(() => { const layer = input() const { commands, bindings, mode, ...options } = layer @@ -199,7 +217,7 @@ function createLayer(input: () => KeymapLayer) { ...definition, name: id, opencode: command, - run: () => run(value.input(id)), + run: (context: CommandContext) => run(value.input(id), context.event), ...(description === undefined ? {} : { desc: description }), ...(group === undefined ? {} : { category: group }), ...(palette === undefined ? {} : { namespace: "palette" }), @@ -220,20 +238,19 @@ function createLayer(input: () => KeymapLayer) { })), ...grouped.named.flatMap((command) => { if (command.bind === false) return [] - const configured = config.data.keybinds.get(command.id) + const configured = value.config.keybinds.get(command.id) if (configured.length) return configured if (typeof command.bind !== "string") return [] return [{ key: command.bind, cmd: command.id }] }), - ...(bindings ?? []).flatMap((id) => config.data.keybinds.get(id)), + ...(bindings ?? []).flatMap((id) => value.config.keybinds.get(id)), ], } }) } function useShortcuts() { - useValue() - const config = useConfig() + const value = useValue() const shortcuts = useKeymapSelector((keymap) => { const commands = keymap.getCommands({ visibility: "registered" }).map((command) => command.name) const bindings = keymap.getCommandBindings({ visibility: "registered", commands }) @@ -241,8 +258,8 @@ function useShortcuts() { commands.map((id) => [ id, { - first: formatKeySequence(bindings.get(id)?.[0]?.sequence, formatOptions(config.data)), - all: formatCommandBindings(bindings.get(id) ?? [], formatOptions(config.data)), + first: formatKeySequence(bindings.get(id)?.[0]?.sequence, formatOptions(value.config)), + all: formatCommandBindings(bindings.get(id) ?? [], formatOptions(value.config)), }, ]), ) @@ -257,6 +274,16 @@ function useShortcuts() { } } +function useShortcut(id: string) { + const shortcuts = useShortcuts() + return () => shortcuts.get(id) +} + +function useLeaderActive() { + const pending = usePendingSequence() + return () => pending()[0]?.tokenName === "leader" +} + function useCommands(): Accessor { const value = useValue() return useKeymapSelector((keymap) => @@ -312,6 +339,8 @@ export const Keymap = { use, createLayer, useShortcuts, + useShortcut, + useLeaderActive, useCommands, usePendingSequence, useActiveKeys, @@ -355,7 +384,7 @@ function createMode(keymap: OpenTuiKeymap) { } } -function formatOptions(config: ReturnType["data"]) { +function formatOptions(config: KeymapConfig) { const leader = config.keybinds.get("leader")?.[0]?.key return { tokenDisplay: { diff --git a/packages/tui/src/feature-plugins/system/plugins.tsx b/packages/tui/src/feature-plugins/system/plugins.tsx index 147248f05d..02c916e6ff 100644 --- a/packages/tui/src/feature-plugins/system/plugins.tsx +++ b/packages/tui/src/feature-plugins/system/plugins.tsx @@ -4,7 +4,7 @@ import { useTerminalDimensions } from "@opentui/solid" import { fileURLToPath } from "url" import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select" import { Show, createEffect, createMemo, createSignal } from "solid-js" -import { useBindings } from "../../keymap" +import { Keymap } from "../../context/keymap" const id = "internal:plugin-manager" @@ -39,9 +39,19 @@ function Install(props: { api: TuiPluginApi }) { const [global, setGlobal] = createSignal(false) const [busy, setBusy] = createSignal(false) - useBindings(() => ({ + Keymap.createLayer(() => ({ + mode: "modal", enabled: !busy(), - bindings: [{ key: "tab", desc: "Toggle install scope", group: "Plugins", cmd: () => setGlobal((value) => !value) }], + commands: [ + { + bind: "tab", + title: "Toggle install scope", + group: "Plugins", + run: () => { + setGlobal((value) => !value) + }, + }, + ], })) return ( diff --git a/packages/tui/src/feature-plugins/system/which-key.tsx b/packages/tui/src/feature-plugins/system/which-key.tsx index 67293e7a30..732b3a96c3 100644 --- a/packages/tui/src/feature-plugins/system/which-key.tsx +++ b/packages/tui/src/feature-plugins/system/which-key.tsx @@ -2,7 +2,7 @@ import { RGBA, TextAttributes, type KeyEvent, type Renderable } from "@opentui/core" import { useTerminalDimensions } from "@opentui/solid" import { createEffect, createMemo, createSignal, For, Show } from "solid-js" -import { useBindings, useKeymapSelector } from "../../keymap" +import { Keymap } from "../../context/keymap" import type { ActiveKey } from "@opentui/keymap" import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" import type { BuiltinTuiPlugin } from "../builtins" @@ -153,12 +153,9 @@ function grouped(entries: Entry[]): Group[] { .toSorted((a, b) => a.label.localeCompare(b.label)) } -function commandShortcut(api: TuiPluginApi, name: string) { - return useKeymapSelector((keymap) => - api.keys.formatSequence( - keymap.getCommandBindings({ visibility: "registered", commands: [name] }).get(name)?.[0]?.sequence, - ), - ) +function commandShortcut(_api: TuiPluginApi, name: string) { + const shortcuts = Keymap.useShortcuts() + return () => shortcuts.get(name) ?? "" } function layout(value: unknown): Layout { @@ -189,8 +186,8 @@ function WhichKeyPanel(props: { const dimensions = useTerminalDimensions() const [offset, setOffset] = createSignal(0) const [activeGroup, setActiveGroup] = createSignal() - const pending = useKeymapSelector((keymap) => keymap.getPendingSequence()) - const active = useKeymapSelector((keymap) => keymap.getActiveKeys({ includeMetadata: true })) + const pending = Keymap.usePendingSequence() + const active = Keymap.useActiveKeys() const pendingActive = createMemo(() => pending().length > 0 && active().length > 0) const pendingAutoVisible = createMemo(() => props.mode() === "overlay" && props.pendingPreview() && pendingActive()) const visible = createMemo(() => props.pinned() || pendingAutoVisible()) @@ -281,86 +278,92 @@ function WhichKeyPanel(props: { setOffset(0) } - useBindings(() => ({ + Keymap.createLayer(() => ({ priority: 1000, enabled: visible(), commands: [ { - name: command.groupPrevious, + id: command.groupPrevious, + bind: false, title: "Previous key binding group", - desc: "Show the previous which-key group", - category: "System", + description: "Show the previous which-key group", + group: "System", run() { moveGroup(-1) }, }, { - name: command.groupNext, + id: command.groupNext, + bind: false, title: "Next key binding group", - desc: "Show the next which-key group", - category: "System", + description: "Show the next which-key group", + group: "System", run() { moveGroup(1) }, }, { - name: command.scrollUp, + id: command.scrollUp, + bind: false, title: "Scroll key bindings up", - desc: "Scroll the which-key panel up", - category: "System", + description: "Scroll the which-key panel up", + group: "System", run() { scroll(-columns()) }, }, { - name: command.scrollDown, + id: command.scrollDown, + bind: false, title: "Scroll key bindings down", - desc: "Scroll the which-key panel down", - category: "System", + description: "Scroll the which-key panel down", + group: "System", run() { scroll(columns()) }, }, { - name: command.pageUp, + id: command.pageUp, + bind: false, title: "Page key bindings up", - desc: "Page the which-key panel up", - category: "System", + description: "Page the which-key panel up", + group: "System", run() { scroll(-pageSize()) }, }, { - name: command.pageDown, + id: command.pageDown, + bind: false, title: "Page key bindings down", - desc: "Page the which-key panel down", - category: "System", + description: "Page the which-key panel down", + group: "System", run() { scroll(pageSize()) }, }, { - name: command.home, + id: command.home, + bind: false, title: "First key binding", - desc: "Jump to the first which-key binding", - category: "System", + description: "Jump to the first which-key binding", + group: "System", run() { setOffset(0) }, }, { - name: command.end, + id: command.end, + bind: false, title: "Last key binding", - desc: "Jump to the last which-key binding", - category: "System", + description: "Jump to the last which-key binding", + group: "System", run() { setOffset(maxOffset()) }, }, ], - bindings: (pendingMode() ? scrollCommands : panelCommands).flatMap((command) => - props.api.tuiConfig.keybinds.get(command), - ), + bindings: pendingMode() ? scrollCommands : panelCommands, })) createEffect(() => { diff --git a/packages/tui/src/keymap.tsx b/packages/tui/src/keymap.tsx deleted file mode 100644 index ebf78ffc24..0000000000 --- a/packages/tui/src/keymap.tsx +++ /dev/null @@ -1,262 +0,0 @@ -import { InputRenderable, TextareaRenderable, type CliRenderer, type KeyEvent, type Renderable } from "@opentui/core" -import { - registerBackspacePopsPendingSequence, - registerBaseLayoutFallback, - registerCommaBindings, - registerEscapeClearsPendingSequence, - registerManagedTextareaLayer, - registerTimedLeader, -} from "@opentui/keymap/addons/opentui" -import { stringifyKeyStroke, type Binding } from "@opentui/keymap" -import { - formatCommandBindings as formatCommandBindingsExtra, - formatKeySequence as formatKeySequenceExtra, -} from "@opentui/keymap/extras" -import { KeymapProvider, useKeymap, useKeymapSelector, useBindings } from "@opentui/keymap/solid" -import type { Accessor } from "solid-js" -import { useConfig } from "./config" -import { TuiKeybind } from "./config/keybind" -import type { KeymapCommand } from "@opencode-ai/plugin/v2/tui/context" - -declare module "@opentui/keymap" { - interface Command { - opencode?: KeymapCommand - slash?: { - name: string - aliases?: string[] - arguments?: true - } - } -} - -export const LEADER_TOKEN = "leader" -export const OPENCODE_BASE_MODE = "base" -export const COMMAND_PALETTE_COMMAND = "command.palette.show" - -const OPENCODE_MODE_KEY = "opencode.mode" - -export { useBindings, useKeymapSelector } - -export const OpencodeKeymapProvider = KeymapProvider -export const useOpencodeKeymap = useKeymap - -export type OpenTuiKeymap = ReturnType -type OpencodeModeStack = ReturnType -type BindingLookup = { - get(command: string): readonly Binding[] -} -type FormatConfig = { keybinds: BindingLookup } -type ResolvedKeymapConfig = FormatConfig & ({ leader: { timeout: number } } | { leader_timeout: number }) - -const modeStacks = new WeakMap() - -export function createOpencodeModeStack(keymap: OpenTuiKeymap) { - keymap.setData(OPENCODE_MODE_KEY, OPENCODE_BASE_MODE) - - const offFields = keymap.registerLayerFields({ - mode(value, ctx) { - ctx.require(OPENCODE_MODE_KEY, value) - }, - }) - - const stack: { id: symbol; mode: string }[] = [] - let disposed = false - - const update = () => { - keymap.setData(OPENCODE_MODE_KEY, stack.at(-1)?.mode ?? OPENCODE_BASE_MODE) - } - - const stackApi = { - current() { - return stack.at(-1)?.mode ?? OPENCODE_BASE_MODE - }, - push(mode: string) { - if (disposed) return () => {} - const id = Symbol(mode) - let active = true - stack.push({ id, mode }) - update() - - return () => { - if (!active) return - active = false - const index = stack.findIndex((item) => item.id === id) - if (index !== -1) stack.splice(index, 1) - update() - } - }, - dispose() { - if (disposed) return - disposed = true - stack.length = 0 - offFields() - keymap.setData(OPENCODE_MODE_KEY, undefined) - modeStacks.delete(keymap) - }, - } - - modeStacks.set(keymap, stackApi) - return stackApi -} - -export function useOpencodeModeStack() { - return getOpencodeModeStack(useOpencodeKeymap()) -} - -export function getOpencodeModeStack(keymap: OpenTuiKeymap) { - const value = modeStacks.get(keymap) - if (!value) throw new Error("Opencode mode stack is not registered for this keymap") - return value -} - -const KEY_ALIASES = { - enter: "return", - esc: "escape", - pgdown: "pagedown", - pgup: "pageup", -} as const - -function expandKeyAliases(input: string) { - const result = Object.entries(KEY_ALIASES).reduce( - (acc, [alias, key]) => acc.replace(new RegExp(`(^|[+,\\s>])${alias}(?=$|[+,\\s<])`, "gi"), `$1${key}`), - input, - ) - if (result === input) return - return result -} - -function registerKeyAliases(keymap: OpenTuiKeymap) { - return keymap.appendBindingExpander((ctx) => { - const key = expandKeyAliases(ctx.input) - if (!key) return - return [{ key, displays: ctx.displays }] - }) -} - -const inputCommands = [ - "input.move.left", - "input.move.right", - "input.move.up", - "input.move.down", - "input.select.left", - "input.select.right", - "input.select.up", - "input.select.down", - "input.line.home", - "input.line.end", - "input.select.line.home", - "input.select.line.end", - "input.visual.line.home", - "input.visual.line.end", - "input.select.visual.line.home", - "input.select.visual.line.end", - "input.buffer.home", - "input.buffer.end", - "input.select.buffer.home", - "input.select.buffer.end", - "input.delete.line", - "input.delete.to.line.end", - "input.delete.to.line.start", - "input.backspace", - "input.delete", - "input.newline", - "input.undo", - "input.redo", - "input.word.forward", - "input.word.backward", - "input.select.word.forward", - "input.select.word.backward", - "input.delete.word.forward", - "input.delete.word.backward", - "input.select.all", - "input.submit", -] as const - -function hasManagedTextareaFocus(renderer: CliRenderer) { - const editor = renderer.currentFocusedEditor - return editor instanceof TextareaRenderable && !(editor instanceof InputRenderable) -} - -function leaderDisplay(config: FormatConfig) { - const key = config.keybinds.get(LEADER_TOKEN)?.[0]?.key - if (!key) return TuiKeybind.LeaderDefault - return typeof key === "string" ? key : stringifyKeyStroke(key) -} - -function leaderKey(config: FormatConfig) { - return config.keybinds.get(LEADER_TOKEN)?.[0]?.key -} - -function formatOptions(config: FormatConfig) { - return { - tokenDisplay: { - [LEADER_TOKEN]: leaderDisplay(config), - }, - keyNameAliases: { - up: "↑", - down: "↓", - left: "←", - right: "→", - pageup: "pgup", - pagedown: "pgdn", - delete: "del", - }, - modifierAliases: { - meta: "alt", - }, - } as const -} - -export function formatKeySequence(parts: Parameters[0], config: FormatConfig) { - return formatKeySequenceExtra(parts, formatOptions(config)) -} - -export function formatKeyBindings(bindings: Parameters[0], config: FormatConfig) { - return formatCommandBindingsExtra(bindings, formatOptions(config)) -} - -export function registerOpencodeKeymap(keymap: OpenTuiKeymap, renderer: CliRenderer, config: ResolvedKeymapConfig) { - const modeStack = createOpencodeModeStack(keymap) - const offCommaBindings = registerCommaBindings(keymap) - const offAliasExpander = registerKeyAliases(keymap) - const offBaseLayout = registerBaseLayoutFallback(keymap) - const leader = leaderKey(config) - const offLeader = leader - ? registerTimedLeader(keymap, { - trigger: leader, - name: LEADER_TOKEN, - timeoutMs: "leader" in config ? config.leader.timeout : config.leader_timeout, - }) - : () => {} - const offEscape = registerEscapeClearsPendingSequence(keymap) - const offBackspace = registerBackspacePopsPendingSequence(keymap) - const offInputBindings = registerManagedTextareaLayer(keymap, renderer, { - enabled: () => hasManagedTextareaFocus(renderer), - bindings: inputCommands.flatMap((command) => config.keybinds.get(command)), - }) - - return () => { - offInputBindings() - offBackspace() - offEscape() - offLeader() - offAliasExpander() - offBaseLayout() - offCommaBindings() - modeStack.dispose() - } -} - -export function useLeaderActive(): Accessor { - return useKeymapSelector((keymap: OpenTuiKeymap) => keymap.getPendingSequence()[0]?.tokenName === LEADER_TOKEN) -} - -export function useCommandShortcut(command: string): Accessor { - const config = useConfig().data - return useKeymapSelector((keymap: OpenTuiKeymap) => - formatKeySequence( - keymap.getCommandBindings({ visibility: "registered", commands: [command] }).get(command)?.[0]?.sequence, - config, - ), - ) -} diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 11370dbd53..413459dbaa 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -68,7 +68,7 @@ import { nextThinkingMode, reasoningSummary, type ThinkingMode } from "../../con import { getScrollAcceleration } from "../../util/scroll" import { collapseToolOutput } from "../../util/collapse-tool-output" import { usePluginRuntime } from "../../plugin/runtime" -import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut } from "../../keymap" +import { Keymap, type KeymapCommand } from "../../context/keymap" import { usePathFormatter } from "../../context/path-format" import { useLocation } from "../../context/location" import { createSessionRows, messageBoundaryIDs, resolvePart, type PartRef, type SessionRow } from "./rows" @@ -317,10 +317,10 @@ export function Session() { const globalCommands = [ { - name: "session.page.up", + id: "session.page.up", title: "Page up", - category: "Session", - hidden: true, + group: "Session", + palette: undefined, run: () => { clearMessageNavigation() scroll.scrollBy(-scroll.height / 2) @@ -328,10 +328,10 @@ export function Session() { }, }, { - name: "session.page.down", + id: "session.page.down", title: "Page down", - category: "Session", - hidden: true, + group: "Session", + palette: undefined, run: () => { clearMessageNavigation() scroll.scrollBy(scroll.height / 2) @@ -339,10 +339,10 @@ export function Session() { }, }, { - name: "session.line.up", + id: "session.line.up", title: "Line up", - category: "Session", - hidden: true, + group: "Session", + palette: undefined, run: () => { clearMessageNavigation() scroll.scrollBy(-1) @@ -350,10 +350,10 @@ export function Session() { }, }, { - name: "session.line.down", + id: "session.line.down", title: "Line down", - category: "Session", - hidden: true, + group: "Session", + palette: undefined, run: () => { clearMessageNavigation() scroll.scrollBy(1) @@ -361,10 +361,10 @@ export function Session() { }, }, { - name: "session.half.page.up", + id: "session.half.page.up", title: "Half page up", - category: "Session", - hidden: true, + group: "Session", + palette: undefined, run: () => { clearMessageNavigation() scroll.scrollBy(-scroll.height / 4) @@ -372,10 +372,10 @@ export function Session() { }, }, { - name: "session.half.page.down", + id: "session.half.page.down", title: "Half page down", - category: "Session", - hidden: true, + group: "Session", + palette: undefined, run: () => { clearMessageNavigation() scroll.scrollBy(scroll.height / 4) @@ -386,10 +386,10 @@ export function Session() { const baseAndUnfocusedCommands = [ { - name: "session.first", + id: "session.first", title: "First message", - category: "Session", - hidden: true, + group: "Session", + palette: undefined, run: () => { clearMessageNavigation() scroll.scrollTo(0) @@ -397,10 +397,10 @@ export function Session() { }, }, { - name: "session.last", + id: "session.last", title: "Last message", - category: "Session", - hidden: true, + group: "Session", + palette: undefined, run: () => { clearMessageNavigation() scroll.scrollTo(scroll.scrollHeight) @@ -412,30 +412,30 @@ export function Session() { const baseCommands = createMemo(() => [ { title: "Share session", - name: "session.share", + id: "session.share", suggested: route.type === "session", - category: "Session", + group: "Session", slash: { name: "share" }, run: () => unavailable("Sharing"), }, { title: "Rename session", - name: "session.rename", - category: "Session", + id: "session.rename", + group: "Session", slash: { name: "rename" }, run: () => DialogSessionRename.show(dialog, route.sessionID, session()?.title), }, { title: "Jump to message", - name: "session.timeline", - category: "Session", + id: "session.timeline", + group: "Session", slash: { name: "timeline" }, run: () => unavailable("The message timeline"), }, { title: "Fork session", - name: "session.fork", - category: "Session", + id: "session.fork", + group: "Session", slash: { name: "fork" }, run: () => { dialog.replace(() => ( @@ -451,8 +451,8 @@ export function Session() { }, { title: "Compact session", - name: "session.compact", - category: "Session", + id: "session.compact", + group: "Session", slash: { name: "compact", aliases: ["summarize"], @@ -464,16 +464,16 @@ export function Session() { }, { title: "Unshare session", - name: "session.unshare", - category: "Session", + id: "session.unshare", + group: "Session", enabled: false, slash: { name: "unshare" }, run: () => unavailable("Unsharing"), }, { title: "Undo previous message", - name: "session.undo", - category: "Session", + id: "session.undo", + group: "Session", slash: { name: "undo" }, run: () => { const boundary = session()?.revert?.messageID @@ -508,8 +508,8 @@ export function Session() { }, { title: "Redo", - name: "session.redo", - category: "Session", + id: "session.redo", + group: "Session", enabled: !!session()?.revert?.messageID, slash: { name: "redo" }, run: () => { @@ -525,8 +525,8 @@ export function Session() { }, { title: sidebarVisible() ? "Hide sidebar" : "Show sidebar", - name: "session.sidebar.toggle", - category: "Session", + id: "session.sidebar.toggle", + group: "Session", run: () => { batch(() => { const isVisible = sidebarVisible() @@ -546,9 +546,9 @@ export function Session() { if (next === "hide") return "Collapse thinking" return "Expand thinking" })(), - name: "session.toggle.thinking", - category: "Session", - hidden: true, + id: "session.toggle.thinking", + group: "Session", + palette: undefined, slash: { name: "thinking", aliases: ["toggle-thinking"], @@ -564,9 +564,9 @@ export function Session() { }, { title: "Toggle session scrollbar", - name: "session.toggle.scrollbar", - category: "Session", - hidden: true, + id: "session.toggle.scrollbar", + group: "Session", + palette: undefined, run: () => { void configState .update((draft) => { @@ -578,9 +578,9 @@ export function Session() { }, { title: groupExploration() ? "Show tool calls individually" : "Group related tool calls", - name: "session.toggle.exploration_grouping", - category: "Session", - hidden: true, + id: "session.toggle.exploration_grouping", + group: "Session", + palette: undefined, run: () => { void configState .update((draft) => { @@ -592,9 +592,9 @@ export function Session() { }, { title: "Jump to last user message", - name: "session.messages_last_user", - category: "Session", - hidden: true, + id: "session.messages_last_user", + group: "Session", + palette: undefined, run: () => { const messages = data.session.message.list(route.sessionID) if (!messages || !messages.length) return @@ -612,36 +612,36 @@ export function Session() { }, { title: "Next message", - name: "session.message.next", - category: "Session", - hidden: true, + id: "session.message.next", + group: "Session", + palette: undefined, run: () => scrollToMessage("next", dialog), }, { title: "Previous message", - name: "session.message.previous", - category: "Session", - hidden: true, + id: "session.message.previous", + group: "Session", + palette: undefined, run: () => scrollToMessage("prev", dialog), }, { title: "Next user message", - name: "session.message.user.next", - category: "Session", - hidden: true, + id: "session.message.user.next", + group: "Session", + palette: undefined, run: () => scrollToMessage("next", dialog, true), }, { title: "Previous user message", - name: "session.message.user.previous", - category: "Session", - hidden: true, + id: "session.message.user.previous", + group: "Session", + palette: undefined, run: () => scrollToMessage("prev", dialog, true), }, { title: "Copy last assistant message", - name: "messages.copy", - category: "Session", + id: "messages.copy", + group: "Session", run: () => { const revertID = session()?.revert?.messageID const lastAssistantMessage = messages().findLast( @@ -682,8 +682,8 @@ export function Session() { }, { title: "Copy session transcript", - name: "session.copy", - category: "Session", + id: "session.copy", + group: "Session", slash: { name: "copy", }, @@ -702,8 +702,8 @@ export function Session() { }, { title: "Export session transcript", - name: "session.export", - category: "Session", + id: "session.export", + group: "Session", slash: { name: "export", }, @@ -772,9 +772,9 @@ export function Session() { }, { title: "Background blocking tools", - name: "session.background", - category: "Session", - hidden: true, + id: "session.background", + group: "Session", + palette: undefined, run: () => { void client.api.session.background({ sessionID: route.sessionID }) dialog.clear() @@ -782,8 +782,8 @@ export function Session() { }, { title: "Toggle subagent picker", - name: "session.child.first", - category: "Session", + id: "session.child.first", + group: "Session", run: () => { if (composer.open || session()?.parentID) setComposer("open", false) else setComposer("open", true) @@ -792,9 +792,9 @@ export function Session() { }, { title: "Go to parent session", - name: "session.parent", - category: "Session", - hidden: true, + id: "session.parent", + group: "Session", + palette: undefined, enabled: !!session()?.parentID, run: () => { const parentID = session()?.parentID @@ -809,41 +809,46 @@ export function Session() { }, { title: "Next subagent", - name: "session.child.next", - category: "Session", - hidden: true, + id: "session.child.next", + group: "Session", + palette: undefined, enabled: !!session()?.parentID, run: () => unavailable("Subagent navigation"), }, { title: "Previous subagent", - name: "session.child.previous", - category: "Session", - hidden: true, + id: "session.child.previous", + group: "Session", + palette: undefined, enabled: !!session()?.parentID, run: () => unavailable("Subagent navigation"), }, ]) - useBindings(() => ({ - commands: [...globalCommands, ...baseAndUnfocusedCommands, ...baseCommands()].map((command) => ({ - namespace: "palette", - ...command, - })), + const commands = createMemo(() => + [...globalCommands, ...baseAndUnfocusedCommands, ...baseCommands()].map( + (command) => + ({ + bind: false, + palette: true as const, + ...command, + }) satisfies KeymapCommand, + ), + ) + + Keymap.createLayer(() => ({ + mode: "global", + commands: commands(), + bindings: globalCommands.map((command) => command.id), })) - useBindings(() => ({ - bindings: globalCommands.flatMap((command) => config.keybinds.get(command.name)), - })) - - useBindings(() => ({ + Keymap.createLayer(() => ({ enabled: () => renderer.currentFocusedEditor === null, - bindings: baseAndUnfocusedCommands.flatMap((command) => config.keybinds.get(command.name)), + bindings: baseAndUnfocusedCommands.map((command) => command.id), })) - useBindings(() => ({ - mode: OPENCODE_BASE_MODE, - bindings: [...baseAndUnfocusedCommands, ...baseCommands()].flatMap((command) => config.keybinds.get(command.name)), + Keymap.createLayer(() => ({ + bindings: [...baseAndUnfocusedCommands, ...baseCommands()].map((command) => command.id), })) // snap to bottom when session changes @@ -1040,7 +1045,7 @@ function SessionRowView(props: { function BackgroundToolHint(props: { messages: SessionMessageInfo[] }) { const { themeV2 } = useTheme() - const shortcut = useCommandShortcut("session.background") + const shortcut = Keymap.useShortcut("session.background") const visible = createMemo(() => { const current = props.messages.findLast( (message): message is SessionMessageAssistant => message.type === "assistant" && !message.time.completed, @@ -1508,7 +1513,7 @@ function RevertMessage(props: { const toast = useToast() const renderer = useRenderer() const [hover, setHover] = createSignal(false) - const redoKey = useCommandShortcut("session.redo") + const redoKey = Keymap.useShortcut("session.redo") return ( setHover(true)} diff --git a/packages/tui/src/ui/dialog-select.tsx b/packages/tui/src/ui/dialog-select.tsx index fc5261de93..b972b651fe 100644 --- a/packages/tui/src/ui/dialog-select.tsx +++ b/packages/tui/src/ui/dialog-select.tsx @@ -11,7 +11,6 @@ import { useDialog, type DialogContext } from "./dialog" import { Locale } from "../util/locale" import { getScrollAcceleration } from "../util/scroll" import { useConfig } from "../config" -import { formatKeyBindings, useKeymapSelector } from "../keymap" export interface DialogSelectProps { title: string @@ -126,18 +125,13 @@ export function DialogSelect(props: DialogSelectProps) { const actions = createMemo(() => props.actions ?? []) const shownActions = createMemo(() => actions().filter((item) => !item.hidden)) - const actionBindings = useKeymapSelector((keymap) => - keymap.getCommandBindings({ - visibility: "registered", - commands: shownActions().map((item) => item.command), - }), - ) + const shortcuts = Keymap.useShortcuts() const actionLabels = createMemo(() => { const labels = new Map() for (const action of shownActions()) { - const label = formatKeyBindings(actionBindings().get(action.command), config) + const label = shortcuts.all(action.command) if (label) labels.set(action.command, label) } diff --git a/packages/tui/test/keymap.test.tsx b/packages/tui/test/keymap.test.tsx index 94bb802503..3885914ebc 100644 --- a/packages/tui/test/keymap.test.tsx +++ b/packages/tui/test/keymap.test.tsx @@ -1,106 +1,71 @@ /** @jsxImportSource @opentui/solid */ -import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" -import { createBindingLookup } from "@opentui/keymap/extras" -import { type TextareaRenderable } from "@opentui/core" -import { testRender, useRenderer } from "@opentui/solid" +import { testRender } from "@opentui/solid" import { expect, test } from "bun:test" -import { onCleanup, onMount } from "solid-js" -import { TuiKeybind } from "../src/config/keybind" -import { - formatKeySequence, - getOpencodeModeStack, - OPENCODE_BASE_MODE, - OpencodeKeymapProvider, - registerOpencodeKeymap, -} from "../src/keymap" - -function createResolvedKeymapConfig(input: TuiKeybind.KeybindOverrides = {}) { - const keybinds = TuiKeybind.parse(input) - return { - keybinds: createBindingLookup(TuiKeybind.toBindingConfig(keybinds), { - commandMap: TuiKeybind.CommandMap, - bindingDefaults: TuiKeybind.bindingDefaults(), - }), - leader_timeout: 2000, - } -} +import { ConfigProvider } from "../src/config" +import { Keymap } from "../src/context/keymap" +import { createTuiResolvedConfig } from "./fixture/tui-runtime" test("legacy page key aliases compile as page keys", async () => { - const sequences: Record = {} + let read = () => ({ up: "", down: "" }) function Harness() { - const renderer = useRenderer() - const keymap = createDefaultOpenTuiKeymap(renderer) - const config = createResolvedKeymapConfig({ - messages_page_up: "pgup", - messages_page_down: "pgdown", + const shortcuts = Keymap.useShortcuts() + Keymap.createLayer(() => ({ + commands: [ + { id: "session.page.up", run() {} }, + { id: "session.page.down", run() {} }, + ], + })) + read = () => ({ + up: shortcuts.get("session.page.up") ?? "", + down: shortcuts.get("session.page.down") ?? "", }) - const offKeymap = registerOpencodeKeymap(keymap, renderer, config) - const offLayer = keymap.registerLayer({ - bindings: ["session.page.up", "session.page.down"].flatMap((command) => config.keybinds.get(command)), - }) - const bindings = keymap.getCommandBindings({ - visibility: "registered", - commands: ["session.page.up", "session.page.down"], - }) - sequences.up = - bindings.get("session.page.up")?.map((binding) => binding.sequence.map((part) => part.stroke.name)) ?? [] - sequences.down = - bindings.get("session.page.down")?.map((binding) => binding.sequence.map((part) => part.stroke.name)) ?? [] - onCleanup(() => { - offLayer() - offKeymap() - }) - - return ( - - - - ) + return } - const app = await testRender(() => ) + const app = await testRender(() => ( + + + + + + )) try { - expect(sequences).toEqual({ - up: [["pageup"]], - down: [["pagedown"]], - }) + expect(read()).toEqual({ up: "pgup", down: "pgdn" }) } finally { app.renderer.destroy() } }) test("formats navigation keys as arrows", async () => { - const shortcuts: Record = {} + let read = () => ({}) as Record + const commands = ["session.parent", "session.child.first", "session.child.previous", "session.child.next"] function Harness() { - const renderer = useRenderer() - const keymap = createDefaultOpenTuiKeymap(renderer) - const config = createResolvedKeymapConfig() - const offKeymap = registerOpencodeKeymap(keymap, renderer, config) - const commands = ["session.parent", "session.child.first", "session.child.previous", "session.child.next"] - const offLayer = keymap.registerLayer({ - bindings: commands.flatMap((command) => config.keybinds.get(command)), - }) - const bindings = keymap.getCommandBindings({ visibility: "registered", commands }) - commands.forEach((command) => { - shortcuts[command] = formatKeySequence(bindings.get(command)?.[0]?.sequence, config) - }) - onCleanup(() => { - offLayer() - offKeymap() - }) - - return ( - - - - ) + const shortcuts = Keymap.useShortcuts() + Keymap.createLayer(() => ({ + commands: commands.map((id) => ({ id, run() {} })), + })) + read = () => Object.fromEntries(commands.map((id) => [id, shortcuts.get(id) ?? ""])) + return } - const app = await testRender(() => ) + const app = await testRender(() => ( + + + + + + )) try { - expect(shortcuts).toEqual({ + expect(read()).toEqual({ "session.parent": "↑", "session.child.first": "↓", "session.child.previous": "←", @@ -111,133 +76,41 @@ test("formats navigation keys as arrows", async () => { } }) -test("dispatches message navigation while the composer is focused", async () => { - for (const kittyKeyboard of [false, true]) { - const counts = { - "session.first": 0, - "session.message.previous": 0, - "session.message.next": 0, - "session.messages_last_user": 0, - } - - function Harness() { - const renderer = useRenderer() - const keymap = createDefaultOpenTuiKeymap(renderer) - const config = createResolvedKeymapConfig() - const offKeymap = registerOpencodeKeymap(keymap, renderer, config) - const commands = Object.keys(counts) as (keyof typeof counts)[] - const offLayer = keymap.registerLayer({ - commands: commands.map((name) => ({ - name, - run() { - counts[name]++ - }, - })), - bindings: commands.flatMap((command) => config.keybinds.get(command)), - }) - let textarea: TextareaRenderable - onMount(() => textarea.focus()) - onCleanup(() => { - offLayer() - offKeymap() - }) - - return ( - -