From a0fc27e4246c2f3a8dd1876eb0de0aa2fcdec0c3 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sat, 9 May 2026 01:29:13 +0200 Subject: [PATCH] flatten to keybind compatible config (#26421) --- .opencode/plugins/tui-smoke.tsx | 208 ++++---- .opencode/tui.json | 20 +- bun.lock | 30 +- package.json | 6 +- packages/opencode/specs/tui-plugins.md | 13 +- packages/opencode/specs/v2/keymappings.md | 36 -- .../opencode/src/cli/cmd/run/runtime.boot.ts | 30 +- packages/opencode/src/cli/cmd/tui/app.tsx | 41 +- .../src/cli/cmd/tui/component/dialog-mcp.tsx | 2 +- .../cli/cmd/tui/component/dialog-model.tsx | 3 - .../cmd/tui/component/dialog-session-list.tsx | 6 +- .../cli/cmd/tui/component/dialog-stash.tsx | 4 +- .../cmd/tui/component/prompt/autocomplete.tsx | 11 +- .../cli/cmd/tui/component/prompt/index.tsx | 11 +- .../src/cli/cmd/tui/config/keybind.ts | 384 ++++++++++++++ .../cmd/tui/config/legacy-keymap-transform.ts | 187 ------- .../src/cli/cmd/tui/config/tui-schema.ts | 343 +------------ .../opencode/src/cli/cmd/tui/config/tui.ts | 42 +- .../cli/cmd/tui/feature-plugins/home/tips.tsx | 2 +- .../tui/feature-plugins/system/plugins.tsx | 7 +- .../tui/feature-plugins/system/which-key.tsx | 33 +- packages/opencode/src/cli/cmd/tui/keymap.tsx | 61 ++- .../src/cli/cmd/tui/routes/session/index.tsx | 41 +- .../cli/cmd/tui/routes/session/permission.tsx | 12 +- .../cli/cmd/tui/routes/session/question.tsx | 14 +- .../src/cli/cmd/tui/ui/dialog-select.tsx | 18 +- packages/opencode/src/config/keybinds.ts | 143 ------ .../test/cli/run/runtime.boot.test.ts | 49 +- .../test/cli/tui/plugin-loader.test.ts | 39 +- .../test/cli/tui/plugin-toggle.test.ts | 20 +- packages/opencode/test/config/tui.test.ts | 200 +++----- packages/opencode/test/fixture/tui-plugin.ts | 8 +- packages/opencode/test/fixture/tui-runtime.ts | 44 +- packages/plugin/package.json | 6 +- packages/plugin/src/tui.ts | 41 +- packages/web/src/content/docs/config.mdx | 16 +- packages/web/src/content/docs/keybinds.mdx | 467 ++++++++---------- packages/web/src/content/docs/tui.mdx | 16 +- 38 files changed, 1096 insertions(+), 1518 deletions(-) delete mode 100644 packages/opencode/specs/v2/keymappings.md create mode 100644 packages/opencode/src/cli/cmd/tui/config/keybind.ts delete mode 100644 packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts delete mode 100644 packages/opencode/src/config/keybinds.ts diff --git a/.opencode/plugins/tui-smoke.tsx b/.opencode/plugins/tui-smoke.tsx index fc890537ec..2d3095a57c 100644 --- a/.opencode/plugins/tui-smoke.tsx +++ b/.opencode/plugins/tui-smoke.tsx @@ -2,87 +2,62 @@ import { useTerminalDimensions, type JSX } from "@opentui/solid" import { useBindings, useKeymapSelector } from "@opentui/keymap/solid" import { RGBA, VignetteEffect, type KeyEvent, type Renderable } from "@opentui/core" -import { resolveBindingSections, type BindingSectionsConfig, type BindingValue } from "@opentui/keymap/extras" -import type { Binding } from "@opentui/keymap" +import { createBindingLookup, type BindingConfig } from "@opentui/keymap/extras" import type { TuiPlugin, TuiPluginApi, TuiPluginMeta, TuiPluginModule, TuiSlotPlugin } from "@opencode-ai/plugin/tui" const tabs = ["overview", "counter", "help"] const command = { - modal: "plugin.smoke.modal", - screen: "plugin.smoke.screen", - alert: "plugin.smoke.alert", - confirm: "plugin.smoke.confirm", - prompt: "plugin.smoke.prompt", - select: "plugin.smoke.select", - host: "plugin.smoke.host", - home: "plugin.smoke.home", - toast: "plugin.smoke.toast", - dialog_close: "plugin.smoke.dialog.close", - local_push: "plugin.smoke.local.push", - local_pop: "plugin.smoke.local.pop", - screen_home: "plugin.smoke.screen.home", - screen_left: "plugin.smoke.screen.left", - screen_right: "plugin.smoke.screen.right", - screen_up: "plugin.smoke.screen.up", - screen_down: "plugin.smoke.screen.down", - screen_modal: "plugin.smoke.screen.modal", - screen_local: "plugin.smoke.screen.local", - screen_host: "plugin.smoke.screen.host", - screen_alert: "plugin.smoke.screen.alert", - screen_confirm: "plugin.smoke.screen.confirm", - screen_prompt: "plugin.smoke.screen.prompt", - screen_select: "plugin.smoke.screen.select", - modal_accept: "plugin.smoke.modal.accept", - modal_close: "plugin.smoke.modal.close", -} as const - -const sectionNames = ["global", "dialog", "local", "screen", "modal"] as const -type SectionName = (typeof sectionNames)[number] -type SectionConfig = Record> -type ResolvedSections = Record[]> -type SmokeKeymap = { - sections?: Partial> + modal: "smoke_modal", + screen: "smoke_screen", + alert: "smoke_alert", + confirm: "smoke_confirm", + prompt: "smoke_prompt", + select: "smoke_select", + host: "smoke_host", + home: "smoke_home", + toast: "smoke_toast", + dialog_close: "smoke_dialog_close", + local_push: "smoke_local_push", + local_pop: "smoke_local_pop", + screen_home: "smoke_screen_home", + screen_left: "smoke_screen_left", + screen_right: "smoke_screen_right", + screen_up: "smoke_screen_up", + screen_down: "smoke_screen_down", + screen_modal: "smoke_screen_modal", + screen_local: "smoke_screen_local", + screen_host: "smoke_screen_host", + screen_alert: "smoke_screen_alert", + screen_confirm: "smoke_screen_confirm", + screen_prompt: "smoke_screen_prompt", + screen_select: "smoke_screen_select", + modal_accept: "smoke_modal_accept", + modal_close: "smoke_modal_close", } -type SmokeOptions = { - enabled?: boolean - label?: unknown - route?: unknown - vignette?: unknown - keymap?: SmokeKeymap -} +type SmokeBindings = BindingConfig const defaultKeymap = { - global: { - [command.modal]: "ctrl+shift+m", - [command.screen]: "ctrl+shift+o", - }, - dialog: { - [command.dialog_close]: "escape", - }, - local: { - [command.local_push]: "enter,return", - [command.local_pop]: "escape,q,backspace", - }, - screen: { - [command.screen_home]: "escape,ctrl+h", - [command.screen_left]: "left,h", - [command.screen_right]: "right,l", - [command.screen_up]: "up,k", - [command.screen_down]: "down,j", - [command.screen_modal]: "ctrl+shift+m", - [command.screen_local]: "x", - [command.screen_host]: "z", - [command.screen_alert]: "a", - [command.screen_confirm]: "c", - [command.screen_prompt]: "p", - [command.screen_select]: "s", - }, - modal: { - [command.modal_accept]: "enter,return", - [command.modal_close]: "escape", - }, -} satisfies Record + [command.modal]: "ctrl+shift+m", + [command.screen]: "ctrl+shift+o", + [command.dialog_close]: "escape", + [command.local_push]: "enter,return", + [command.local_pop]: "escape,q,backspace", + [command.screen_home]: "escape,ctrl+h", + [command.screen_left]: "left,h", + [command.screen_right]: "right,l", + [command.screen_up]: "up,k", + [command.screen_down]: "down,j", + [command.screen_modal]: "ctrl+shift+m", + [command.screen_local]: "x", + [command.screen_host]: "z", + [command.screen_alert]: "a", + [command.screen_confirm]: "c", + [command.screen_prompt]: "p", + [command.screen_select]: "s", + [command.modal_accept]: "enter,return", + [command.modal_close]: "escape", +} const pick = (value: unknown, fallback: string) => { if (typeof value !== "string") return fallback @@ -95,11 +70,14 @@ const num = (value: unknown, fallback: number) => { return value } +const record = (value: unknown): value is Record => + !!value && typeof value === "object" && !Array.isArray(value) + type Cfg = { label: string route: string vignette: number - keymap: SmokeKeymap | undefined + keybinds: SmokeBindings | undefined } type Route = { @@ -116,12 +94,12 @@ type State = { local: number } -const cfg = (options: SmokeOptions | undefined) => { +const cfg = (options: Record | undefined) => { return { label: pick(options?.label, "smoke"), route: pick(options?.route, "workspace-smoke"), vignette: Math.max(0, num(options?.vignette, 0.35)), - keymap: options?.keymap, + keybinds: record(options?.keybinds) ? (options.keybinds as SmokeBindings) : undefined, } } @@ -132,21 +110,8 @@ const names = (input: Cfg) => { } } -function createKeys(input: SmokeKeymap | undefined): { sections: ResolvedSections } { - const sections = resolveBindingSections( - { - global: { ...defaultKeymap.global, ...input?.sections?.global }, - dialog: { ...defaultKeymap.dialog, ...input?.sections?.dialog }, - local: { ...defaultKeymap.local, ...input?.sections?.local }, - screen: { ...defaultKeymap.screen, ...input?.sections?.screen }, - modal: { ...defaultKeymap.modal, ...input?.sections?.modal }, - } satisfies BindingSectionsConfig, - { sections: sectionNames }, - ).sections - - return { - sections, - } +function createKeys(input: SmokeBindings | undefined) { + return createBindingLookup({ ...defaultKeymap, ...input }) } type Keys = ReturnType @@ -376,7 +341,7 @@ const Screen = (props: { }, }, ], - bindings: props.keys.sections.dialog, + bindings: props.keys.gather("smoke.dialog", [command.dialog_close]), })) useBindings(() => ({ @@ -395,7 +360,7 @@ const Screen = (props: { }, }, ], - bindings: props.keys.sections.local, + bindings: props.keys.gather("smoke.local", [command.local_push, command.local_pop]), })) useBindings(() => ({ @@ -478,7 +443,20 @@ const Screen = (props: { }, }, ], - bindings: props.keys.sections.screen, + bindings: props.keys.gather("smoke.screen", [ + command.screen_home, + command.screen_left, + command.screen_right, + command.screen_up, + command.screen_down, + command.screen_modal, + command.screen_local, + command.screen_host, + command.screen_alert, + command.screen_confirm, + command.screen_prompt, + command.screen_select, + ]), })) const shortcuts = useKeymapSelector((keymap) => { const bindings = keymap.getCommandBindings({ @@ -687,7 +665,7 @@ const Modal = (props: { }, }, ], - bindings: props.keys.sections.modal, + bindings: props.keys.gather("smoke.modal", [command.modal_accept, command.modal_close]), })) const shortcuts = useKeymapSelector((keymap) => { const bindings = keymap.getCommandBindings({ @@ -766,25 +744,8 @@ const home = (api: TuiPluginApi, input: Cfg) => ({ }, home_prompt(ctx, value) { const skin = look(ctx.theme.current) - type Prompt = (props: { - workspaceID?: string - visible?: boolean - disabled?: boolean - onSubmit?: () => void - hint?: JSX.Element - right?: JSX.Element - showPlaceholder?: boolean - placeholders?: { - normal?: string[] - shell?: string[] - } - }) => JSX.Element - type Slot = ( - props: { name: string; mode?: unknown; children?: JSX.Element } & Record, - ) => JSX.Element | null - const ui = api.ui as TuiPluginApi["ui"] & { Prompt: Prompt; Slot: Slot } - const Prompt = ui.Prompt - const Slot = ui.Slot + const Prompt = api.ui.Prompt + const Slot = api.ui.Slot const normal = [ `[SMOKE] route check for ${input.label}`, "[SMOKE] confirm home_prompt slot override", @@ -1003,20 +964,29 @@ const reg = (api: TuiPluginApi, input: Cfg, keys: Keys) => { }, }, ], - bindings: keys.sections.global, + bindings: keys.gather("smoke.global", [ + command.modal, + command.screen, + command.alert, + command.confirm, + command.prompt, + command.select, + command.host, + command.home, + command.toast, + ]), }) } const tui: TuiPlugin = async (api, options, meta) => { - const input = options as SmokeOptions | undefined - if (input?.enabled === false) return + if (options?.enabled === false) return await api.theme.install("./smoke-theme.json") api.theme.set("smoke-theme") - const value = cfg(input) + const value = cfg(options) const route = names(value) - const keys = createKeys(value.keymap) + const keys = createKeys(value.keybinds) const fx = new VignetteEffect(value.vignette) const post = fx.apply.bind(fx) api.renderer.addPostProcessFn(post) diff --git a/.opencode/tui.json b/.opencode/tui.json index e795209d9c..b92e58dac2 100644 --- a/.opencode/tui.json +++ b/.opencode/tui.json @@ -6,20 +6,12 @@ { "enabled": false, "label": "workspace", - "keymap": { - "sections": { - "global": { - "plugin.smoke.modal": "ctrl+alt+m", - "plugin.smoke.screen": "ctrl+alt+o" - }, - "screen": { - "plugin.smoke.screen.home": "escape,ctrl+shift+h", - "plugin.smoke.screen.modal": "ctrl+alt+m" - }, - "dialog": { - "plugin.smoke.dialog.close": "escape,q" - } - } + "keybinds": { + "smoke_modal": "ctrl+alt+m", + "smoke_screen": "ctrl+alt+o", + "smoke_screen_home": "escape,ctrl+shift+h", + "smoke_screen_modal": "ctrl+alt+m", + "smoke_dialog_close": "escape,q" } } ] diff --git a/bun.lock b/bun.lock index 90be71910a..fa3924bf39 100644 --- a/bun.lock +++ b/bun.lock @@ -519,9 +519,9 @@ "typescript": "catalog:", }, "peerDependencies": { - "@opentui/core": ">=0.2.5", - "@opentui/keymap": ">=0.2.5", - "@opentui/solid": ">=0.2.5", + "@opentui/core": ">=0.2.6", + "@opentui/keymap": ">=0.2.6", + "@opentui/solid": ">=0.2.6", }, "optionalPeers": [ "@opentui/core", @@ -700,9 +700,9 @@ "@npmcli/arborist": "9.4.0", "@octokit/rest": "22.0.0", "@openauthjs/openauth": "0.0.0-20250322224806", - "@opentui/core": "0.2.5", - "@opentui/keymap": "0.2.5", - "@opentui/solid": "0.2.5", + "@opentui/core": "0.2.6", + "@opentui/keymap": "0.2.6", + "@opentui/solid": "0.2.6", "@pierre/diffs": "1.1.0-beta.18", "@playwright/test": "1.59.1", "@sentry/solid": "10.36.0", @@ -1631,23 +1631,23 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], - "@opentui/core": ["@opentui/core@0.2.5", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.5", "@opentui/core-darwin-x64": "0.2.5", "@opentui/core-linux-arm64": "0.2.5", "@opentui/core-linux-x64": "0.2.5", "@opentui/core-win32-arm64": "0.2.5", "@opentui/core-win32-x64": "0.2.5" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-A5DNOW39S60LtOcBdWYx7fuIGsPcClzbdKz9WuLp+wgy0Bt/jPw5XX6dk3k4dCX4jmhA1nX7x7680+GXLHPL6Q=="], + "@opentui/core": ["@opentui/core@0.2.6", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.6", "@opentui/core-darwin-x64": "0.2.6", "@opentui/core-linux-arm64": "0.2.6", "@opentui/core-linux-x64": "0.2.6", "@opentui/core-win32-arm64": "0.2.6", "@opentui/core-win32-x64": "0.2.6" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-dBpMaWVM7wtW2/2TlGPrkPjg6gOL3MVU/5XXk+U1LDJB8L4q4NeYWVdzfAVNcEvgmuuCy/cVqdY2D4ei+e7MMg=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Jdl8TN7oxV8NTaKZsUAt0B/A4hIYiyUKwXNSe4w1OchNMlgjwF1fx/7RhgHXSvWh1Fcqi1IH5FfhsmO89Aed1A=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-hR5nsxNj+059utzenTCF0kealUlibON6fLuebFUCGM/5kJnqa+shIh0XbUDFm0+F47vqVUgZufBdUuieQZIbvQ=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-78sKg0ZvwFHzZZGJCeaSNIVi2dadDxQymHAmrK698zEgnQr4eLVVB+MxNpxJx55/z9Y+YqbfSZaobC6w6Q3y5A=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-pJ/bH4WC/mbBaakM1YdH6TVo67jhy0KPd61bCz97w0I/PJGr8fmNKvhmMt/AwyFgOQi3FYZiEKLMpGdvUcSsrQ=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-BtqbOjP64hKQaVd0ApHunt0MjkEEKTvxpaBwk7OhwVCoYakQBDZTZXUQ9zuPXvaHc9IF286z1PnJGLu0t11BAw=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Pnd3kOxig8ii+/IqYheOPEgferylsQA0L6tKBnHQ9jRlCJOcu0Rv65Jepueh212vevdV9DzPURJnhejG06J6g=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.5", "", { "os": "linux", "cpu": "x64" }, "sha512-c3sEXtmOd1E5R4wfWh/MejplxgApYKqzyJ0AVMTU8pU1MHRAMwD8UFDMSVQhl7rYMTuBYPWok3IoCK2u8a2A4A=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-458Mx9tBzEPzfft8cSt5ZaIpEepoxBXBOL6AUVmDTKWaZ3uouraPcEKraGAyvOTDQp2XDI3R8c/2GdaR77FaUQ=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WlgpYkgmuvMPc2mYGJSwN7c+VGAxiZvMKwZEbS+w9PMj7sJhvY+zFrOJNFpvjbAFw8vS3Kz39km4Nj7GF8JH6w=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-BDUrdrT1RCcVnQoHJmUut4y811jDBAEtc6GJFB4Gs265Be8SrTjVCus6p2fSQ7j9sZQ1OcjO+5+4NkheSZICDQ=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.5", "", { "os": "win32", "cpu": "x64" }, "sha512-4X7BHJ7Wztzj7p0E+SsN0d4goUVU7Dy2VnhnD4n65ODgVbW59iqasAvbnPLbX3ghjgKiwQ+2SD+ImCIHE6uCAA=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-SUYAzRJ9TSoD2Qt8kn6FJz6dbTrFEPVig5mScB4zFGgGQO/Bbod2/Q31vLS/IQrX+FDb67WaErD+kuMCnMPPLA=="], - "@opentui/keymap": ["@opentui/keymap@0.2.5", "", { "dependencies": { "@opentui/core": "0.2.5" }, "peerDependencies": { "@opentui/react": "0.2.5", "@opentui/solid": "0.2.5", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-/B6Gy9LLRRKhvyDV1rFX0p7BUN8NQOcXwTV8E0xb7ym1yREvVmij+hCRkXXddMme2HW9NmV0+RRHo4kJzJxkNQ=="], + "@opentui/keymap": ["@opentui/keymap@0.2.6", "", { "dependencies": { "@opentui/core": "0.2.6" }, "peerDependencies": { "@opentui/react": "0.2.6", "@opentui/solid": "0.2.6", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-+6OYuedrFCKVo4ryGFNwws++2VOmPcXU3PwpY0mP47gYQY2nvQ+etWIs2Y7r5eMIqUfxVCldkKsrzcEcA4tb/A=="], - "@opentui/solid": ["@opentui/solid@0.2.5", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.5", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-M8MxDYJzjtF8TvxB6Q7656GOSS+QIg89jD0jf/asfF4qeip5TQhNZ3ba+R1v2fVuIkQCyRJzTtOtMZiglzGKPQ=="], + "@opentui/solid": ["@opentui/solid@0.2.6", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.6", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-2y225WlOGi/fCaajkxBmLyVW8Cr+OmhowHdvrYcz5w2kBD15sKbJLIYu1G9DxceirT1uIyasGy2TGzRRcVkTDg=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], diff --git a/package.json b/package.json index f2258ab698..27a3597553 100644 --- a/package.json +++ b/package.json @@ -35,9 +35,9 @@ "@types/cross-spawn": "6.0.6", "@octokit/rest": "22.0.0", "@hono/zod-validator": "0.4.2", - "@opentui/core": "0.2.5", - "@opentui/keymap": "0.2.5", - "@opentui/solid": "0.2.5", + "@opentui/core": "0.2.6", + "@opentui/keymap": "0.2.6", + "@opentui/solid": "0.2.6", "ulid": "3.0.1", "@kobalte/core": "0.13.11", "@types/luxon": "3.7.1", diff --git a/packages/opencode/specs/tui-plugins.md b/packages/opencode/specs/tui-plugins.md index 73927dbf83..c1a9b271c1 100644 --- a/packages/opencode/specs/tui-plugins.md +++ b/packages/opencode/specs/tui-plugins.md @@ -20,6 +20,12 @@ Example: { "$schema": "https://opencode.ai/tui.json", "theme": "smoke-theme", + "leader_timeout": 2000, + "keybinds": { + "leader": "ctrl+x", + "command_list": "ctrl+p", + "session_new": "n" + }, "plugin": ["@acme/opencode-plugin@1.2.3", ["./plugins/demo.tsx", { "label": "demo" }]], "plugin_enabled": { "acme.demo": false @@ -39,6 +45,9 @@ Example: - Internal plugins can declare `enabled: false` to be registered but inactive by default; `plugin_enabled` and runtime KV can still enable them by id. - `plugin_enabled` is merged across config layers. - Runtime enable/disable state is also stored in KV under `plugin_enabled`; that KV state overrides config on startup. +- `leader_timeout` is a top-level TUI setting. +- `keybinds` is a flat object keyed by command id; values are key binding values (`false`, `"none"`, a key string/object, a binding object, or an array of key strings/objects/binding objects). +- `keybinds.leader` sets the key used by `` shortcuts. ## Author package shape @@ -228,14 +237,14 @@ Top-level API groups exposed to `tui(api, options, meta)`: - To surface a command in the host command palette, set `namespace: "palette"` and provide metadata such as `title`, `category`, `desc`, `suggested`, `hidden`, `enabled`, `slashName`, and `slashAliases` on the command. - Use `api.keymap.dispatchCommand(name)` for user-style execution semantics and `api.keymap.runCommand(name)` only for forced programmatic execution. - Disposers returned by `api.keymap` registrations and `acquireResource(...)` are automatically cleaned up when the plugin deactivates. You do not need to add those disposers to `api.lifecycle.onDispose(...)` yourself. -- Built-in which-key shortcuts are resolved from `keymap.sections.which_key`, not plugin options. +- Built-in which-key shortcuts are resolved from flat `keybinds` command ids such as `which_key_toggle`, not plugin options. ### Keys - `api.keys` exposes host-formatted shortcut display helpers for plugin UI. - `formatSequence(parts)` formats parsed key sequence parts using the host's display policy. - `formatBindings(bindings)` formats binding lists and returns `undefined` when there is nothing to show. -- For generic config-to-bindings helpers, import `resolveBindingSections` from `@opencode-ai/plugin/tui`. +- For generic config-to-bindings helpers, import `createBindingLookup` from `@opencode-ai/plugin/tui`. ### Routes diff --git a/packages/opencode/specs/v2/keymappings.md b/packages/opencode/specs/v2/keymappings.md deleted file mode 100644 index 30a298eee4..0000000000 --- a/packages/opencode/specs/v2/keymappings.md +++ /dev/null @@ -1,36 +0,0 @@ -# Keybindings vs. Keymappings - -Make it `keymappings`, closer to neovim. Can be layered like `abc`. Commands don't define their binding, but have an id that a key can be mapped to like - -```ts -{ key: "ctrl+w", cmd: string | function, description } -``` - -_Why_ -Currently its keybindings that have an `id` like `message_redo` and then a command can use that or define it's own binding. While some keybindings are just used with `.match` in arbitrary key handlers and there is no info what the key is used for, except the binding id maybe. It also is unknown in which context/scope what binding is active, so a plugin like `which-key` is nearly impossible to get right. - -## OpenTUI Keymap Migration - -The v2 TUI uses `@opentui/keymap` as the key/cmd engine. The remaining legacy compatibility is config-only and exists to migrate users from `keybinds` to `keymap`: - -- `packages/opencode/src/config/keybinds.ts`: old `keybinds` schema, defaults, and legacy key names. -- `packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts`: transforms parsed legacy `keybinds` into OpenTUI `keymap` sections. -- `packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts`: migrates legacy TUI keys from `opencode.json` into `tui.json`, including `theme`, `keybinds`, and nested `tui`. -- `packages/opencode/src/cli/cmd/tui/config/tui-schema.ts`: still accepts deprecated `keybinds` via `KeybindOverride` and marks it as deprecated. This file also contains the new `keymap` config schema. -- `packages/opencode/src/cli/cmd/tui/config/tui.ts`: parses legacy `keybinds`, applies the Windows `terminal_suspend`/`input_undo` adjustment, and uses `LegacyKeymapTransform.create(...)` as the fallback when no `keymap` section is configured. -- `packages/plugin/src/tui.ts`: plugin-facing `tuiConfig` still includes `keybinds` through `PluginConfig`; this should be removed when the public plugin API no longer exposes legacy config. - -The transform must stay while users are migrating. It lets users upgrade without first rewriting their existing `keybinds` config. If `keymap` is configured, `keybinds` are ignored for keymap resolution. If `keymap` is missing, `legacy-keymap-transform.ts` turns legacy `keybinds` into the resolved `keymap` consumed by OpenTUI. - -## Removing Legacy Later - -When switching fully to the new config style, remove legacy support with these exact changes: - -- Delete `packages/opencode/src/config/keybinds.ts`. -- Delete `packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts`. -- Delete `packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts`. -- In `packages/opencode/src/cli/cmd/tui/config/tui-schema.ts`, remove the `ConfigKeybinds` import, remove `KeybindOverride`, and delete the deprecated `keybinds` field from `TuiInfo`. -- In `packages/opencode/src/cli/cmd/tui/config/tui.ts`, remove `migrateTuiConfig(...)`, remove `ConfigKeybinds`, remove the Windows legacy keybind adjustment, remove `LegacyKeymapTransform.create(...)`, and require/default `keymap` through the new config path instead. -- In `packages/opencode/src/cli/cmd/tui/config/tui.ts`, remove `keybinds` from `Resolved`; resolved TUI config should expose `keymap` only. -- In `packages/plugin/src/tui.ts`, remove `keybinds` from plugin-facing `TuiConfigView`. -- Remove or rewrite tests that write or assert `keybinds`, especially in `packages/opencode/test/config/tui.test.ts`, `packages/opencode/test/fixture/tui-runtime.ts`, and TUI plugin loader tests. diff --git a/packages/opencode/src/cli/cmd/run/runtime.boot.ts b/packages/opencode/src/cli/cmd/run/runtime.boot.ts index 9d4aa3658c..3ff9801c6a 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.boot.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.boot.ts @@ -6,7 +6,9 @@ // history ring. All are async because they read config or hit the SDK, but // none block each other. import { Context, Effect, Layer } from "effect" +import { stringifyKeyStroke } from "@opentui/keymap" import { TuiConfig } from "@/cli/cmd/tui/config/tui" +import { TuiKeybind } from "@/cli/cmd/tui/config/keybind" import { makeRuntime } from "@/effect/run-service" import { reusePendingTask } from "./runtime.shared" import { resolveSession, sessionHistory } from "./session.shared" @@ -14,7 +16,7 @@ import type { FooterKeybinds, RunDiffStyle, RunInput, RunPrompt, RunProvider } f import { pickVariant } from "./variant.shared" const DEFAULT_KEYBINDS: FooterKeybinds = { - leader: "ctrl+x", + leader: TuiKeybind.LeaderDefault, leaderTimeout: 2000, commandList: [{ key: "ctrl+p" }], variantCycle: [{ key: "ctrl+t" }], @@ -78,22 +80,28 @@ function emptySessionInfo(): SessionInfo { } } +function leaderKey(config: Config) { + const key = config.keybinds.get("leader")?.[0]?.key + if (!key) return TuiKeybind.LeaderDefault + return typeof key === "string" ? key : stringifyKeyStroke(key) +} + function footerKeybinds(config: Config | undefined): FooterKeybinds { if (!config) { return DEFAULT_KEYBINDS } return { - leader: config.keymap.leader, - leaderTimeout: config.keymap.leader_timeout, - commandList: config.keymap.get("global", "command.palette.show") ?? [], - variantCycle: config.keymap.get("global", "variant.cycle") ?? [], - interrupt: config.keymap.get("prompt", "session.interrupt") ?? [], - historyPrevious: config.keymap.get("prompt", "prompt.history.previous") ?? [], - historyNext: config.keymap.get("prompt", "prompt.history.next") ?? [], - inputClear: config.keymap.get("prompt", "prompt.clear") ?? [], - inputSubmit: config.keymap.get("input", "input.submit") ?? [], - inputNewline: config.keymap.get("input", "input.newline") ?? [], + leader: leaderKey(config), + leaderTimeout: config.leader_timeout, + commandList: config.keybinds.get("command.palette.show"), + variantCycle: config.keybinds.get("variant.cycle"), + interrupt: config.keybinds.get("session.interrupt"), + historyPrevious: config.keybinds.get("prompt.history.previous"), + historyNext: config.keybinds.get("prompt.history.next"), + inputClear: config.keybinds.get("prompt.clear"), + inputSubmit: config.keybinds.get("input.submit"), + inputNewline: config.keybinds.get("input.newline"), } } diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 275a494578..c7a2cd560f 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -70,6 +70,42 @@ import { OpencodeKeymapProvider, registerOpencodeKeymap, useBindings, useOpencod import type { EventSource } from "./context/sdk" import { DialogVariant } from "./component/dialog-variant" +const appBindingCommands = [ + "command.palette.show", + "session.list", + "session.new", + "model.list", + "model.cycle_recent", + "model.cycle_recent_reverse", + "model.cycle_favorite", + "model.cycle_favorite_reverse", + "agent.list", + "mcp.list", + "agent.cycle", + "agent.cycle.reverse", + "variant.cycle", + "variant.list", + "provider.connect", + "console.org.switch", + "opencode.status", + "theme.switch", + "theme.switch_mode", + "theme.mode.lock", + "help.show", + "docs.open", + "app.exit", + "app.debug", + "app.console", + "app.heap_snapshot", + "terminal.suspend", + "terminal.title.toggle", + "app.toggle.animations", + "app.toggle.file_context", + "app.toggle.diffwrap", + "app.toggle.paste_summary", + "app.toggle.session_directory_filter", +] as const + function rendererConfig(_config: TuiConfig.Resolved): CliRendererConfig { const mouseEnabled = !Flag.OPENCODE_DISABLE_MOUSE && (_config.mouse ?? true) @@ -215,9 +251,6 @@ export function tui(input: { function App(props: { onSnapshot?: () => Promise }) { const tuiConfig = useTuiConfig() - const { - keymap: { sections }, - } = tuiConfig const route = useRoute() const dimensions = useTerminalDimensions() const renderer = useRenderer() @@ -749,7 +782,7 @@ function App(props: { onSnapshot?: () => Promise }) { useBindings(() => ({ enabled: command.matcher, - bindings: sections.global, + bindings: tuiConfig.keybinds.gather("app", appBindingCommands), })) event.on(TuiEvent.CommandExecute.type, (evt) => { diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx index faa26dc3a6..c577d49329 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx @@ -46,7 +46,7 @@ export function DialogMcp() { const actions = createMemo(() => [ { - command: "dialog.action.toggle", + command: "dialog.mcp.toggle", title: "toggle", onTrigger: async (option: DialogSelectOption) => { // Prevent toggling while an operation is already in progress diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx index 068c6a1e03..09c2d64b00 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx @@ -8,13 +8,11 @@ import { createDialogProviderOptions, DialogProvider } from "./dialog-provider" import { DialogVariant } from "./dialog-variant" import * as fuzzysort from "fuzzysort" import { useConnected } from "./use-connected" -import { useTuiConfig } from "../context/tui-config" export function DialogModel(props: { providerID?: string }) { const local = useLocal() const sync = useSync() const dialog = useDialog() - const tuiConfig = useTuiConfig() const [query, setQuery] = createSignal("") const connected = useConnected() @@ -167,7 +165,6 @@ export function DialogModel(props: { providerID?: string }) { }, }, ]} - bindings={tuiConfig.keymap.sections.model} onFilter={setQuery} flat={true} skipFilter={true} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 195221b88f..31c8eb555d 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -28,7 +28,7 @@ export function DialogSessionList() { const toast = useToast() const [toDelete, setToDelete] = createSignal() const [search, setSearch] = createDebouncedSignal("", 150) - const deleteHint = useCommandShortcut("dialog.action.delete") + const deleteHint = useCommandShortcut("session.delete") const [searchResults, { refetch }] = createResource( () => ({ query: search(), filter: sync.session.query() }), @@ -190,7 +190,7 @@ export function DialogSessionList() { }} actions={[ { - command: "dialog.action.delete", + command: "session.delete", title: "delete", onTrigger: async (option) => { if (toDelete() === option.value) { @@ -238,7 +238,7 @@ export function DialogSessionList() { }, }, { - command: "dialog.action.rename", + command: "session.rename", title: "rename", onTrigger: async (option) => { dialog.replace(() => ) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-stash.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-stash.tsx index 62843c2527..2dfe2dee9c 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-stash.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-stash.tsx @@ -32,7 +32,7 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) { const { theme } = useTheme() const [toDelete, setToDelete] = createSignal() - const deleteHint = useCommandShortcut("dialog.action.delete") + const deleteHint = useCommandShortcut("stash.delete") const options = createMemo(() => { const entries = stash.list() @@ -70,7 +70,7 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) { }} actions={[ { - command: "dialog.action.delete", + command: "stash.delete", title: "delete", onTrigger: (option) => { if (toDelete() === option.value) { diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 57c890f5a2..7f390f0eb6 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -87,9 +87,6 @@ export function Autocomplete(props: { const dimensions = useTerminalDimensions() const frecency = useFrecency() const tuiConfig = useTuiConfig() - const { - keymap: { sections }, - } = tuiConfig const [store, setStore] = createStore({ index: 0, selected: 0, @@ -575,7 +572,13 @@ export function Autocomplete(props: { }, }, ], - bindings: sections.autocomplete, + bindings: tuiConfig.keybinds.gather("prompt.autocomplete", [ + "prompt.autocomplete.prev", + "prompt.autocomplete.next", + "prompt.autocomplete.hide", + "prompt.autocomplete.select", + "prompt.autocomplete.complete", + ]), })) function show(mode: "@" | "/") { 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 c6bcb89924..d3bfdfbac3 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -147,7 +147,6 @@ export function Prompt(props: PromptProps) { const project = useProject() const sync = useSync() const tuiConfig = useTuiConfig() - const keymapConfig = tuiConfig.keymap const dialog = useDialog() const toast = useToast() const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" }) @@ -630,7 +629,7 @@ export function Prompt(props: PromptProps) { useBindings(() => ({ enabled: command.matcher, - bindings: keymapConfig.pick("prompt", [ + bindings: tuiConfig.keybinds.gather("prompt.palette", [ "prompt.submit", "prompt.editor", "prompt.editor_context.clear", @@ -865,7 +864,7 @@ export function Prompt(props: PromptProps) { return { target: inputTarget, enabled: inputTarget() !== undefined && !props.disabled, - bindings: keymapConfig.pick("prompt", ["prompt.paste"]), + bindings: tuiConfig.keybinds.get("prompt.paste"), } }) @@ -873,7 +872,7 @@ export function Prompt(props: PromptProps) { return { target: inputTarget, enabled: inputTarget() !== undefined && !props.disabled && store.prompt.input !== "", - bindings: keymapConfig.pick("prompt", ["prompt.clear"]), + bindings: tuiConfig.keybinds.get("prompt.clear"), } }) @@ -957,7 +956,7 @@ export function Prompt(props: PromptProps) { }, }, ], - bindings: keymapConfig.pick("prompt", ["prompt.history.previous"]), + bindings: tuiConfig.keybinds.get("prompt.history.previous"), } }) @@ -995,7 +994,7 @@ export function Prompt(props: PromptProps) { }, }, ], - bindings: keymapConfig.pick("prompt", ["prompt.history.next"]), + bindings: tuiConfig.keybinds.get("prompt.history.next"), } }) diff --git a/packages/opencode/src/cli/cmd/tui/config/keybind.ts b/packages/opencode/src/cli/cmd/tui/config/keybind.ts new file mode 100644 index 0000000000..46a48e18e9 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/config/keybind.ts @@ -0,0 +1,384 @@ +export * as TuiKeybind from "./keybind" + +import type { KeyEvent, Renderable } from "@opentui/core" +import type { Binding } from "@opentui/keymap" +import type { BindingCommandMap, BindingConfig, BindingDefaults, BindingValue } from "@opentui/keymap/extras" +import z from "zod" + +const KeyStroke = z + .object({ + name: z.string(), + ctrl: z.boolean().optional(), + shift: z.boolean().optional(), + meta: z.boolean().optional(), + super: z.boolean().optional(), + hyper: z.boolean().optional(), + }) + .strict() + +const BindingObject = z + .object({ + key: z.union([z.string(), KeyStroke]), + event: z.enum(["press", "release"]).optional(), + preventDefault: z.boolean().optional(), + fallthrough: z.boolean().optional(), + }) + .passthrough() + +const BindingItem = z.union([z.string(), KeyStroke, BindingObject]) +export const BindingValueSchema = z.union([z.literal(false), z.literal("none"), BindingItem, z.array(BindingItem)]) + +type Definition = { + default: z.input + description: string +} + +const inputUndoDefault = process.platform === "win32" ? "ctrl+z,ctrl+-,super+z" : "ctrl+-,super+z" +export const LeaderDefault = "ctrl+x" + +const keybind = (value: Definition["default"], description: string): Definition => ({ default: value, description }) + +const Definitions = { + leader: keybind(LeaderDefault, "Leader key for keybind combinations"), + + app_exit: keybind("ctrl+c,ctrl+d,q", "Exit the application"), + app_debug: keybind("none", "Toggle debug panel"), + app_console: keybind("none", "Toggle console"), + app_heap_snapshot: keybind("none", "Write heap snapshot"), + app_toggle_animations: keybind("none", "Toggle animations"), + app_toggle_file_context: keybind("none", "Toggle file context"), + app_toggle_diffwrap: keybind("none", "Toggle diff wrapping"), + app_toggle_paste_summary: keybind("none", "Toggle paste summary"), + app_toggle_session_directory_filter: keybind("none", "Toggle session directory filtering"), + command_list: keybind("ctrl+p", "List available commands"), + help_show: keybind("none", "Open help dialog"), + docs_open: keybind("none", "Open documentation"), + + editor_open: keybind("e", "Open external editor"), + theme_list: keybind("t", "List available themes"), + theme_switch_mode: keybind("none", "Switch between light and dark theme mode"), + theme_mode_lock: keybind("none", "Lock or unlock theme mode"), + sidebar_toggle: keybind("b", "Toggle sidebar"), + scrollbar_toggle: keybind("none", "Toggle session scrollbar"), + status_view: keybind("s", "View status"), + + session_export: keybind("x", "Export session to editor"), + session_copy: keybind("none", "Copy session transcript"), + session_new: keybind("n", "Create a new session"), + session_list: keybind("l", "List all sessions"), + session_timeline: keybind("g", "Show session timeline"), + session_fork: keybind("none", "Fork session from message"), + session_rename: keybind("ctrl+r", "Rename session"), + session_delete: keybind("ctrl+d", "Delete session"), + session_share: keybind("none", "Share current session"), + session_unshare: keybind("none", "Unshare current session"), + session_interrupt: keybind("escape", "Interrupt current session"), + session_compact: keybind("c", "Compact the session"), + session_toggle_timestamps: keybind("none", "Toggle message timestamps"), + session_toggle_generic_tool_output: keybind("none", "Toggle generic tool output"), + session_child_first: keybind("down", "Go to first child session"), + session_child_cycle: keybind("right", "Go to next child session"), + session_child_cycle_reverse: keybind("left", "Go to previous child session"), + session_parent: keybind("up", "Go to parent session"), + + stash_delete: keybind("ctrl+d", "Delete stash entry"), + model_provider_list: keybind("ctrl+a", "Open provider list from model dialog"), + model_favorite_toggle: keybind("ctrl+f", "Toggle model favorite status"), + model_list: keybind("m", "List available models"), + model_cycle_recent: keybind("f2", "Next recently used model"), + model_cycle_recent_reverse: keybind("shift+f2", "Previous recently used model"), + model_cycle_favorite: keybind("none", "Next favorite model"), + model_cycle_favorite_reverse: keybind("none", "Previous favorite model"), + mcp_list: keybind("none", "List MCP servers"), + provider_connect: keybind("none", "Connect provider"), + console_org_switch: keybind("none", "Switch console organization"), + agent_list: keybind("a", "List agents"), + agent_cycle: keybind("tab", "Next agent"), + agent_cycle_reverse: keybind("shift+tab", "Previous agent"), + variant_cycle: keybind("ctrl+t", "Cycle model variants"), + variant_list: keybind("none", "List model variants"), + + messages_page_up: keybind("pageup,ctrl+alt+b", "Scroll messages up by one page"), + messages_page_down: keybind("pagedown,ctrl+alt+f", "Scroll messages down by one page"), + messages_line_up: keybind("ctrl+alt+y", "Scroll messages up by one line"), + messages_line_down: keybind("ctrl+alt+e", "Scroll messages down by one line"), + messages_half_page_up: keybind("ctrl+alt+u", "Scroll messages up by half page"), + messages_half_page_down: keybind("ctrl+alt+d", "Scroll messages down by half page"), + messages_first: keybind("ctrl+g,home", "Navigate to first message"), + messages_last: keybind("ctrl+alt+g,end", "Navigate to last message"), + messages_next: keybind("none", "Navigate to next message"), + messages_previous: keybind("none", "Navigate to previous message"), + messages_last_user: keybind("none", "Navigate to last user message"), + messages_copy: keybind("y", "Copy message"), + messages_undo: keybind("u", "Undo message"), + messages_redo: keybind("r", "Redo message"), + messages_toggle_conceal: keybind("h", "Toggle code block concealment in messages"), + tool_details: keybind("none", "Toggle tool details visibility"), + display_thinking: keybind("none", "Toggle thinking blocks visibility"), + + prompt_submit: keybind("none", "Submit prompt"), + prompt_editor_context_clear: keybind("none", "Clear editor context"), + prompt_skills: keybind("none", "Open skill selector"), + prompt_stash: keybind("none", "Stash prompt"), + prompt_stash_pop: keybind("none", "Pop stashed prompt"), + prompt_stash_list: keybind("none", "List stashed prompts"), + workspace_set: keybind("none", "Set workspace"), + + input_clear: keybind("ctrl+c", "Clear input field"), + input_paste: keybind({ key: "ctrl+v", preventDefault: false }, "Paste from clipboard"), + input_submit: keybind("return", "Submit input"), + input_newline: keybind("shift+return,ctrl+return,alt+return,ctrl+j", "Insert newline in input"), + input_move_left: keybind("left,ctrl+b", "Move cursor left in input"), + input_move_right: keybind("right,ctrl+f", "Move cursor right in input"), + input_move_up: keybind("up", "Move cursor up in input"), + input_move_down: keybind("down", "Move cursor down in input"), + input_select_left: keybind("shift+left", "Select left in input"), + input_select_right: keybind("shift+right", "Select right in input"), + input_select_up: keybind("shift+up", "Select up in input"), + input_select_down: keybind("shift+down", "Select down in input"), + input_line_home: keybind("ctrl+a", "Move to start of line in input"), + input_line_end: keybind("ctrl+e", "Move to end of line in input"), + input_select_line_home: keybind("ctrl+shift+a", "Select to start of line in input"), + input_select_line_end: keybind("ctrl+shift+e", "Select to end of line in input"), + input_visual_line_home: keybind("alt+a", "Move to start of visual line in input"), + input_visual_line_end: keybind("alt+e", "Move to end of visual line in input"), + input_select_visual_line_home: keybind("alt+shift+a", "Select to start of visual line in input"), + input_select_visual_line_end: keybind("alt+shift+e", "Select to end of visual line in input"), + input_buffer_home: keybind("home", "Move to start of buffer in input"), + input_buffer_end: keybind("end", "Move to end of buffer in input"), + input_select_buffer_home: keybind("shift+home", "Select to start of buffer in input"), + input_select_buffer_end: keybind("shift+end", "Select to end of buffer in input"), + input_delete_line: keybind("ctrl+shift+d", "Delete line in input"), + input_delete_to_line_end: keybind("ctrl+k", "Delete to end of line in input"), + input_delete_to_line_start: keybind("ctrl+u", "Delete to start of line in input"), + input_backspace: keybind("backspace,shift+backspace", "Backspace in input"), + input_delete: keybind("ctrl+d,delete,shift+delete", "Delete character in input"), + input_undo: keybind(inputUndoDefault, "Undo in input"), + input_redo: keybind("ctrl+.,super+shift+z", "Redo in input"), + input_word_forward: keybind("alt+f,alt+right,ctrl+right", "Move word forward in input"), + input_word_backward: keybind("alt+b,alt+left,ctrl+left", "Move word backward in input"), + input_select_word_forward: keybind("alt+shift+f,alt+shift+right", "Select word forward in input"), + input_select_word_backward: keybind("alt+shift+b,alt+shift+left", "Select word backward in input"), + input_delete_word_forward: keybind("alt+d,alt+delete,ctrl+delete", "Delete word forward in input"), + input_delete_word_backward: keybind("ctrl+w,ctrl+backspace,alt+backspace", "Delete word backward in input"), + input_select_all: keybind("super+a", "Select all in input"), + history_previous: keybind("up", "Previous history item"), + history_next: keybind("down", "Next history item"), + + "dialog.select.prev": keybind("up,ctrl+p", "Move to previous dialog item"), + "dialog.select.next": keybind("down,ctrl+n", "Move to next dialog item"), + "dialog.select.page_up": keybind("pageup", "Move up one page in dialog"), + "dialog.select.page_down": keybind("pagedown", "Move down one page in dialog"), + "dialog.select.home": keybind("home", "Move to first dialog item"), + "dialog.select.end": keybind("end", "Move to last dialog item"), + "dialog.select.submit": keybind("return", "Submit selected dialog item"), + "dialog.mcp.toggle": keybind("space", "Toggle MCP in MCP dialog"), + "prompt.autocomplete.prev": keybind("up,ctrl+p", "Move to previous autocomplete item"), + "prompt.autocomplete.next": keybind("down,ctrl+n", "Move to next autocomplete item"), + "prompt.autocomplete.hide": keybind("escape", "Hide autocomplete"), + "prompt.autocomplete.select": keybind("return", "Select autocomplete item"), + "prompt.autocomplete.complete": keybind("tab", "Complete autocomplete item"), + "permission.prompt.fullscreen": keybind("ctrl+f", "Toggle permission prompt fullscreen"), + "plugins.toggle": keybind("space", "Toggle plugin"), + "dialog.plugins.install": keybind("shift+i", "Install plugin from plugin dialog"), + + terminal_suspend: keybind("ctrl+z", "Suspend terminal"), + terminal_title_toggle: keybind("none", "Toggle terminal title"), + tips_toggle: keybind("h", "Toggle tips on home screen"), + plugin_manager: keybind("none", "Open plugin manager dialog"), + plugin_install: keybind("none", "Install plugin"), + + which_key_toggle: keybind("ctrl+alt+k", "Toggle which-key panel"), + which_key_layout_toggle: keybind("ctrl+alt+shift+k", "Switch which-key layout"), + which_key_pending_toggle: keybind("ctrl+alt+shift+p", "Toggle which-key pending preview"), + which_key_group_previous: keybind("ctrl+alt+left,ctrl+alt+[", "Previous which-key group"), + which_key_group_next: keybind("ctrl+alt+right,ctrl+alt+]", "Next which-key group"), + which_key_scroll_up: keybind("ctrl+alt+up,ctrl+alt+p", "Scroll which-key up"), + which_key_scroll_down: keybind("ctrl+alt+down,ctrl+alt+n", "Scroll which-key down"), + which_key_page_up: keybind("ctrl+alt+pageup", "Page which-key up"), + which_key_page_down: keybind("ctrl+alt+pagedown", "Page which-key down"), + which_key_home: keybind("ctrl+alt+home", "Jump to first which-key binding"), + which_key_end: keybind("ctrl+alt+end", "Jump to last which-key binding"), +} satisfies Record + +type KeybindName = keyof typeof Definitions & string + +const KeybindShape = Object.fromEntries( + Object.entries(Definitions).map(([name, item]) => [ + name, + BindingValueSchema.optional().default(item.default).describe(item.description), + ]), +) as Record>> + +const KeybindOverrideShape = Object.fromEntries( + Object.entries(Definitions).map(([name, item]) => [name, BindingValueSchema.optional().describe(item.description)]), +) as Record> + +export const Keybinds = z.strictObject(KeybindShape).describe("TUI keybinding configuration") +export const KeybindOverrides = z.strictObject(KeybindOverrideShape).describe("TUI keybinding overrides") +export const Descriptions = Object.fromEntries( + Object.entries(Definitions).map(([name, item]) => [name, item.description]), +) as Record +export const CommandMap = { + app_exit: "app.exit", + app_debug: "app.debug", + app_console: "app.console", + app_heap_snapshot: "app.heap_snapshot", + app_toggle_animations: "app.toggle.animations", + app_toggle_file_context: "app.toggle.file_context", + app_toggle_diffwrap: "app.toggle.diffwrap", + app_toggle_paste_summary: "app.toggle.paste_summary", + app_toggle_session_directory_filter: "app.toggle.session_directory_filter", + command_list: "command.palette.show", + help_show: "help.show", + docs_open: "docs.open", + editor_open: "prompt.editor", + theme_list: "theme.switch", + theme_switch_mode: "theme.switch_mode", + theme_mode_lock: "theme.mode.lock", + sidebar_toggle: "session.sidebar.toggle", + scrollbar_toggle: "session.toggle.scrollbar", + status_view: "opencode.status", + session_export: "session.export", + session_copy: "session.copy", + session_new: "session.new", + session_list: "session.list", + session_timeline: "session.timeline", + session_fork: "session.fork", + session_rename: "session.rename", + session_delete: "session.delete", + session_share: "session.share", + session_unshare: "session.unshare", + session_interrupt: "session.interrupt", + session_compact: "session.compact", + session_toggle_timestamps: "session.toggle.timestamps", + session_toggle_generic_tool_output: "session.toggle.generic_tool_output", + session_child_first: "session.child.first", + session_child_cycle: "session.child.next", + session_child_cycle_reverse: "session.child.previous", + session_parent: "session.parent", + stash_delete: "stash.delete", + model_provider_list: "model.dialog.provider", + model_favorite_toggle: "model.dialog.favorite", + model_list: "model.list", + model_cycle_recent: "model.cycle_recent", + model_cycle_recent_reverse: "model.cycle_recent_reverse", + model_cycle_favorite: "model.cycle_favorite", + model_cycle_favorite_reverse: "model.cycle_favorite_reverse", + mcp_list: "mcp.list", + provider_connect: "provider.connect", + console_org_switch: "console.org.switch", + agent_list: "agent.list", + agent_cycle: "agent.cycle", + agent_cycle_reverse: "agent.cycle.reverse", + variant_cycle: "variant.cycle", + variant_list: "variant.list", + messages_page_up: "session.page.up", + messages_page_down: "session.page.down", + messages_line_up: "session.line.up", + messages_line_down: "session.line.down", + messages_half_page_up: "session.half.page.up", + messages_half_page_down: "session.half.page.down", + messages_first: "session.first", + messages_last: "session.last", + messages_next: "session.message.next", + messages_previous: "session.message.previous", + messages_last_user: "session.messages_last_user", + messages_copy: "messages.copy", + messages_undo: "session.undo", + messages_redo: "session.redo", + messages_toggle_conceal: "session.toggle.conceal", + tool_details: "session.toggle.actions", + display_thinking: "session.toggle.thinking", + prompt_submit: "prompt.submit", + prompt_editor_context_clear: "prompt.editor_context.clear", + prompt_skills: "prompt.skills", + prompt_stash: "prompt.stash", + prompt_stash_pop: "prompt.stash.pop", + prompt_stash_list: "prompt.stash.list", + workspace_set: "workspace.set", + input_clear: "prompt.clear", + input_paste: "prompt.paste", + input_submit: "input.submit", + input_newline: "input.newline", + input_move_left: "input.move.left", + input_move_right: "input.move.right", + input_move_up: "input.move.up", + input_move_down: "input.move.down", + input_select_left: "input.select.left", + input_select_right: "input.select.right", + input_select_up: "input.select.up", + input_select_down: "input.select.down", + input_line_home: "input.line.home", + input_line_end: "input.line.end", + input_select_line_home: "input.select.line.home", + input_select_line_end: "input.select.line.end", + input_visual_line_home: "input.visual.line.home", + input_visual_line_end: "input.visual.line.end", + input_select_visual_line_home: "input.select.visual.line.home", + input_select_visual_line_end: "input.select.visual.line.end", + input_buffer_home: "input.buffer.home", + input_buffer_end: "input.buffer.end", + input_select_buffer_home: "input.select.buffer.home", + input_select_buffer_end: "input.select.buffer.end", + input_delete_line: "input.delete.line", + input_delete_to_line_end: "input.delete.to.line.end", + input_delete_to_line_start: "input.delete.to.line.start", + input_backspace: "input.backspace", + input_delete: "input.delete", + input_undo: "input.undo", + input_redo: "input.redo", + input_word_forward: "input.word.forward", + input_word_backward: "input.word.backward", + input_select_word_forward: "input.select.word.forward", + input_select_word_backward: "input.select.word.backward", + input_delete_word_forward: "input.delete.word.forward", + input_delete_word_backward: "input.delete.word.backward", + input_select_all: "input.select.all", + history_previous: "prompt.history.previous", + history_next: "prompt.history.next", + terminal_suspend: "terminal.suspend", + terminal_title_toggle: "terminal.title.toggle", + tips_toggle: "tips.toggle", + plugin_manager: "plugins.list", + plugin_install: "plugins.install", + which_key_toggle: "which-key.toggle", + which_key_layout_toggle: "which-key.layout.toggle", + which_key_pending_toggle: "which-key.pending.toggle", + which_key_group_previous: "which-key.group.previous", + which_key_group_next: "which-key.group.next", + which_key_scroll_up: "which-key.scroll.up", + which_key_scroll_down: "which-key.scroll.down", + which_key_page_up: "which-key.page.up", + which_key_page_down: "which-key.page.down", + which_key_home: "which-key.home", + which_key_end: "which-key.end", +} satisfies BindingCommandMap +const CommandDescriptions = Object.fromEntries( + Object.entries(Definitions).map(([name, item]) => [ + CommandMap[name as keyof typeof CommandMap] ?? name, + item.description, + ]), +) as Record + +export type Keybinds = z.output +export type KeybindOverrides = z.output +export type BindingLookupView = { + readonly bindings: readonly Binding[] + get(command: string): readonly Binding[] + has(command: string): boolean + gather(name: string, commands: readonly string[]): readonly Binding[] + pick(name: string, commands: readonly string[]): Binding[] + omit(name: string, commands: readonly string[]): Binding[] +} + +export function toBindingConfig(keybinds: Keybinds): BindingConfig { + return Object.fromEntries(Object.entries(keybinds)) as BindingConfig +} + +export function bindingDefaults(): BindingDefaults { + return ({ command, binding }) => { + if (binding.desc !== undefined) return + return { desc: CommandDescriptions[command] } + } +} diff --git a/packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts b/packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts deleted file mode 100644 index 4b266a4ecc..0000000000 --- a/packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts +++ /dev/null @@ -1,187 +0,0 @@ -import type { KeyEvent, Renderable } from "@opentui/core" -import type { Binding } from "@opentui/keymap" -import type { BindingValue } from "@opentui/keymap/extras" -import { ConfigKeybinds } from "@/config/keybinds" -import { type KeymapConfigInput, type KeymapSection } from "./tui-schema" - -type LegacyKeybinds = Partial -type SectionsConfig = Record>> - -const inputCommands = { - input_submit: "input.submit", - input_newline: "input.newline", - input_move_left: "input.move.left", - input_move_right: "input.move.right", - input_move_up: "input.move.up", - input_move_down: "input.move.down", - input_select_left: "input.select.left", - input_select_right: "input.select.right", - input_select_up: "input.select.up", - input_select_down: "input.select.down", - input_line_home: "input.line.home", - input_line_end: "input.line.end", - input_select_line_home: "input.select.line.home", - input_select_line_end: "input.select.line.end", - input_visual_line_home: "input.visual.line.home", - input_visual_line_end: "input.visual.line.end", - input_select_visual_line_home: "input.select.visual.line.home", - input_select_visual_line_end: "input.select.visual.line.end", - input_buffer_home: "input.buffer.home", - input_buffer_end: "input.buffer.end", - input_select_buffer_home: "input.select.buffer.home", - input_select_buffer_end: "input.select.buffer.end", - input_delete_line: "input.delete.line", - input_delete_to_line_end: "input.delete.to.line.end", - input_delete_to_line_start: "input.delete.to.line.start", - input_backspace: "input.backspace", - input_delete: "input.delete", - input_undo: "input.undo", - input_redo: "input.redo", - input_word_forward: "input.word.forward", - input_word_backward: "input.word.backward", - input_select_word_forward: "input.select.word.forward", - input_select_word_backward: "input.select.word.backward", - input_delete_word_forward: "input.delete.word.forward", - input_delete_word_backward: "input.delete.word.backward", - input_select_all: "input.select.all", -} as const satisfies Partial> - -function add( - config: SectionsConfig, - section: KeymapSection, - command: string, - binding: BindingValue | undefined, -) { - if (binding === undefined) return - config[section] ??= {} - config[section][command] = binding -} - -function bindingWith(key: string | undefined, input: Omit, "key" | "cmd">) { - if (!key) return undefined - if (key === "none") return "none" - return { ...input, key } -} - -function combineBindings(...keys: (string | undefined)[]) { - const result = Array.from( - new Set( - keys.flatMap((key) => { - if (!key || key === "none") return [] - return key - .split(",") - .map((part) => part.trim()) - .filter((part) => part && part !== "none") - }), - ), - ) - if (result.length) return result.join(",") - if (keys.some((key) => key === "none")) return "none" - return undefined -} - -export function create(keybinds: LegacyKeybinds): KeymapConfigInput { - const config: SectionsConfig = {} - - add(config, "global", "command.palette.show", keybinds.command_list) - add(config, "global", "session.list", keybinds.session_list) - add(config, "global", "session.new", keybinds.session_new) - add(config, "global", "model.list", keybinds.model_list) - add(config, "global", "model.cycle_recent", keybinds.model_cycle_recent) - add(config, "global", "model.cycle_recent_reverse", keybinds.model_cycle_recent_reverse) - add(config, "global", "model.cycle_favorite", keybinds.model_cycle_favorite) - add(config, "global", "model.cycle_favorite_reverse", keybinds.model_cycle_favorite_reverse) - add(config, "global", "agent.list", keybinds.agent_list) - add(config, "global", "agent.cycle", keybinds.agent_cycle) - add(config, "global", "agent.cycle.reverse", keybinds.agent_cycle_reverse) - add(config, "global", "variant.cycle", keybinds.variant_cycle) - add(config, "global", "variant.list", keybinds.variant_list) - add(config, "prompt", "prompt.editor", keybinds.editor_open) - add(config, "global", "opencode.status", keybinds.status_view) - add(config, "global", "theme.switch", keybinds.theme_list) - add(config, "global", "app.exit", keybinds.app_exit) - add(config, "global", "terminal.suspend", keybinds.terminal_suspend) - add(config, "global", "terminal.title.toggle", keybinds.terminal_title_toggle) - - add(config, "session", "session.share", keybinds.session_share) - add(config, "session", "session.rename", keybinds.session_rename) - add(config, "session", "session.timeline", keybinds.session_timeline) - add(config, "session", "session.fork", keybinds.session_fork) - add(config, "session", "session.compact", keybinds.session_compact) - add(config, "session", "session.unshare", keybinds.session_unshare) - add(config, "session", "session.undo", keybinds.messages_undo) - add(config, "session", "session.redo", keybinds.messages_redo) - add(config, "session", "session.sidebar.toggle", keybinds.sidebar_toggle) - add(config, "session", "session.toggle.conceal", keybinds.messages_toggle_conceal) - add(config, "session", "session.toggle.thinking", keybinds.display_thinking) - add(config, "session", "session.toggle.actions", keybinds.tool_details) - add(config, "session", "session.toggle.scrollbar", keybinds.scrollbar_toggle) - add(config, "session", "session.page.up", keybinds.messages_page_up) - add(config, "session", "session.page.down", keybinds.messages_page_down) - add(config, "session", "session.line.up", keybinds.messages_line_up) - add(config, "session", "session.line.down", keybinds.messages_line_down) - add(config, "session", "session.half.page.up", keybinds.messages_half_page_up) - add(config, "session", "session.half.page.down", keybinds.messages_half_page_down) - add(config, "session", "session.first", keybinds.messages_first) - add(config, "session", "session.last", keybinds.messages_last) - add(config, "session", "session.messages_last_user", keybinds.messages_last_user) - add(config, "session", "session.message.next", keybinds.messages_next) - add(config, "session", "session.message.previous", keybinds.messages_previous) - add(config, "session", "messages.copy", keybinds.messages_copy) - add(config, "session", "session.export", keybinds.session_export) - add(config, "session", "session.child.first", keybinds.session_child_first) - add(config, "session", "session.parent", keybinds.session_parent) - add(config, "session", "session.child.next", keybinds.session_child_cycle) - add(config, "session", "session.child.previous", keybinds.session_child_cycle_reverse) - - add(config, "prompt", "session.interrupt", keybinds.session_interrupt) - add(config, "prompt", "prompt.clear", keybinds.input_clear) - add(config, "prompt", "prompt.paste", bindingWith(keybinds.input_paste, { preventDefault: false })) - add(config, "prompt", "prompt.history.previous", keybinds.history_previous) - add(config, "prompt", "prompt.history.next", keybinds.history_next) - - add(config, "autocomplete", "prompt.autocomplete.prev", keybinds["prompt.autocomplete.prev"]) - add(config, "autocomplete", "prompt.autocomplete.next", keybinds["prompt.autocomplete.next"]) - add(config, "autocomplete", "prompt.autocomplete.hide", keybinds["prompt.autocomplete.hide"]) - add(config, "autocomplete", "prompt.autocomplete.select", keybinds["prompt.autocomplete.select"]) - add(config, "autocomplete", "prompt.autocomplete.complete", keybinds["prompt.autocomplete.complete"]) - - for (const [legacy, command] of Object.entries(inputCommands) as [keyof typeof inputCommands, string][]) { - add(config, "input", command, keybinds[legacy]) - } - - add(config, "dialog_select", "dialog.select.prev", keybinds["dialog.select.prev"]) - add(config, "dialog_select", "dialog.select.next", keybinds["dialog.select.next"]) - add(config, "dialog_select", "dialog.select.page_up", keybinds["dialog.select.page_up"]) - add(config, "dialog_select", "dialog.select.page_down", keybinds["dialog.select.page_down"]) - add(config, "dialog_select", "dialog.select.home", keybinds["dialog.select.home"]) - add(config, "dialog_select", "dialog.select.end", keybinds["dialog.select.end"]) - add(config, "dialog_select", "dialog.select.submit", keybinds["dialog.select.submit"]) - add(config, "dialog_actions", "dialog.action.delete", combineBindings(keybinds.stash_delete, keybinds.session_delete)) - add(config, "dialog_actions", "dialog.action.rename", keybinds.session_rename) - add( - config, - "dialog_actions", - "dialog.action.toggle", - combineBindings(keybinds["dialog.mcp.toggle"], keybinds["plugins.toggle"]), - ) - add(config, "model", "model.dialog.provider", keybinds.model_provider_list) - add(config, "model", "model.dialog.favorite", keybinds.model_favorite_toggle) - - add(config, "permission", "permission.reject.cancel", keybinds.app_exit) - add(config, "permission", "permission.prompt.escape", keybinds.app_exit) - add(config, "permission", "permission.prompt.fullscreen", keybinds["permission.prompt.fullscreen"]) - add(config, "question", "question.reject", keybinds.app_exit) - add(config, "question", "question.edit.clear", keybinds.input_clear) - - add(config, "plugins", "plugins.list", keybinds.plugin_manager) - add(config, "plugins", "plugin.dialog.install", keybinds["dialog.plugins.install"]) - add(config, "home_tips", "tips.toggle", keybinds.tips_toggle) - - return { - ...(keybinds.leader && keybinds.leader !== "none" && { leader: keybinds.leader }), - sections: config, - } -} - -export * as LegacyKeymapTransform from "./legacy-keymap-transform" diff --git a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts index 8e142dc101..d08836e1dd 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts @@ -1,339 +1,12 @@ import z from "zod" -import type { KeyEvent, Renderable } from "@opentui/core" -import type { Binding } from "@opentui/keymap" -import type { ResolvedBindingSections } from "@opentui/keymap/extras" import { ConfigPlugin } from "@/config/plugin" -import { ConfigKeybinds } from "@/config/keybinds" +import { TuiKeybind } from "./keybind" -const KeybindOverride = z - .object( - Object.fromEntries(Object.keys(ConfigKeybinds.Keybinds.shape).map((key) => [key, z.string().optional()])) as Record< - string, - z.ZodOptional - >, - ) - .strict() - -const KeyStroke = z - .object({ - name: z.string(), - ctrl: z.boolean().optional(), - shift: z.boolean().optional(), - meta: z.boolean().optional(), - super: z.boolean().optional(), - hyper: z.boolean().optional(), - }) - .strict() - -const KeymapBindingObject = z - .object({ - key: z.union([z.string(), KeyStroke]), - event: z.enum(["press", "release"]).optional(), - preventDefault: z.boolean().optional(), - fallthrough: z.boolean().optional(), - }) - .passthrough() - -const KeymapBindingItem = z.union([z.string(), KeyStroke, KeymapBindingObject]) -const KeymapBindingValue = z.union([z.literal(false), z.literal("none"), KeymapBindingItem, z.array(KeymapBindingItem)]) - -const keymapBinding = (value: z.input | (() => z.input)) => - KeymapBindingValue.prefault(value) -const keymapSection = (shape: Shape) => { - const schema = z.object(shape).strict() - return schema.prefault({} as z.input) -} -const keymapSectionInput = (shape: Shape) => - z - .object( - Object.fromEntries(Object.keys(shape).map((key) => [key, KeymapBindingValue.optional()])) as { - [Key in keyof Shape]: z.ZodOptional - }, - ) - .strict() - -const GlobalKeymapSection = { - "command.palette.show": keymapBinding("ctrl+p"), - "session.list": keymapBinding("l"), - "session.new": keymapBinding("n"), - "model.list": keymapBinding("m"), - "model.cycle_recent": keymapBinding("f2"), - "model.cycle_recent_reverse": keymapBinding("shift+f2"), - "model.cycle_favorite": keymapBinding("none"), - "model.cycle_favorite_reverse": keymapBinding("none"), - "agent.list": keymapBinding("a"), - "mcp.list": keymapBinding("none"), - "agent.cycle": keymapBinding("tab"), - "agent.cycle.reverse": keymapBinding("shift+tab"), - "variant.cycle": keymapBinding("ctrl+t"), - "variant.list": keymapBinding("none"), - "provider.connect": keymapBinding("none"), - "console.org.switch": keymapBinding("none"), - "opencode.status": keymapBinding("s"), - "theme.switch": keymapBinding("t"), - "theme.switch_mode": keymapBinding("none"), - "theme.mode.lock": keymapBinding("none"), - "help.show": keymapBinding("none"), - "docs.open": keymapBinding("none"), - "app.exit": keymapBinding("ctrl+c,ctrl+d,q"), - "app.debug": keymapBinding("none"), - "app.console": keymapBinding("none"), - "app.heap_snapshot": keymapBinding("none"), - "app.toggle.animations": keymapBinding("none"), - "app.toggle.file_context": keymapBinding("none"), - "app.toggle.diffwrap": keymapBinding("none"), - "app.toggle.paste_summary": keymapBinding("none"), - "app.toggle.session_directory_filter": keymapBinding("none"), - "terminal.suspend": keymapBinding(() => (process.platform === "win32" ? "none" : "ctrl+z")), - "terminal.title.toggle": keymapBinding("none"), -} - -const WhichKeyKeymapSection = { - "tui-which-key.toggle": keymapBinding("ctrl+alt+k"), - "tui-which-key.layout.toggle": keymapBinding("ctrl+alt+shift+k"), - "tui-which-key.pending.toggle": keymapBinding("ctrl+alt+shift+p"), - "tui-which-key.group.previous": keymapBinding("ctrl+alt+left,ctrl+alt+["), - "tui-which-key.group.next": keymapBinding("ctrl+alt+right,ctrl+alt+]"), - "tui-which-key.scroll.up": keymapBinding("ctrl+alt+up,ctrl+alt+p"), - "tui-which-key.scroll.down": keymapBinding("ctrl+alt+down,ctrl+alt+n"), - "tui-which-key.page.up": keymapBinding("ctrl+alt+pageup"), - "tui-which-key.page.down": keymapBinding("ctrl+alt+pagedown"), - "tui-which-key.home": keymapBinding("ctrl+alt+home"), - "tui-which-key.end": keymapBinding("ctrl+alt+end"), -} - -const SessionKeymapSection = { - "session.share": keymapBinding("none"), - "session.rename": keymapBinding("ctrl+r"), - "session.timeline": keymapBinding("g"), - "session.fork": keymapBinding("none"), - "session.compact": keymapBinding("c"), - "session.unshare": keymapBinding("none"), - "session.undo": keymapBinding("u"), - "session.redo": keymapBinding("r"), - "session.sidebar.toggle": keymapBinding("b"), - "session.toggle.conceal": keymapBinding("h"), - "session.toggle.timestamps": keymapBinding("none"), - "session.toggle.thinking": keymapBinding("none"), - "session.toggle.actions": keymapBinding("none"), - "session.toggle.scrollbar": keymapBinding("none"), - "session.toggle.generic_tool_output": keymapBinding("none"), - "session.page.up": keymapBinding("pageup,ctrl+alt+b"), - "session.page.down": keymapBinding("pagedown,ctrl+alt+f"), - "session.line.up": keymapBinding("ctrl+alt+y"), - "session.line.down": keymapBinding("ctrl+alt+e"), - "session.half.page.up": keymapBinding("ctrl+alt+u"), - "session.half.page.down": keymapBinding("ctrl+alt+d"), - "session.first": keymapBinding("ctrl+g,home"), - "session.last": keymapBinding("ctrl+alt+g,end"), - "session.messages_last_user": keymapBinding("none"), - "session.message.next": keymapBinding("none"), - "session.message.previous": keymapBinding("none"), - "messages.copy": keymapBinding("y"), - "session.copy": keymapBinding("none"), - "session.export": keymapBinding("x"), - "session.child.first": keymapBinding("down"), - "session.parent": keymapBinding("up"), - "session.child.next": keymapBinding("right"), - "session.child.previous": keymapBinding("left"), -} - -const PromptKeymapSection = { - "prompt.submit": keymapBinding("none"), - "prompt.editor": keymapBinding("e"), - "prompt.editor_context.clear": keymapBinding("none"), - "prompt.skills": keymapBinding("none"), - "prompt.stash": keymapBinding("none"), - "prompt.stash.pop": keymapBinding("none"), - "prompt.stash.list": keymapBinding("none"), - "workspace.set": keymapBinding("none"), - "session.interrupt": keymapBinding("escape"), - "prompt.clear": keymapBinding("ctrl+c"), - "prompt.paste": keymapBinding({ key: "ctrl+v", preventDefault: false }), - "prompt.history.previous": keymapBinding("up"), - "prompt.history.next": keymapBinding("down"), -} - -const AutocompleteKeymapSection = { - "prompt.autocomplete.prev": keymapBinding("up,ctrl+p"), - "prompt.autocomplete.next": keymapBinding("down,ctrl+n"), - "prompt.autocomplete.hide": keymapBinding("escape"), - "prompt.autocomplete.select": keymapBinding("return"), - "prompt.autocomplete.complete": keymapBinding("tab"), -} - -const InputKeymapSection = { - "input.submit": keymapBinding("return"), - "input.newline": keymapBinding("shift+return,ctrl+return,alt+return,ctrl+j"), - "input.move.left": keymapBinding("left,ctrl+b"), - "input.move.right": keymapBinding("right,ctrl+f"), - "input.move.up": keymapBinding("up"), - "input.move.down": keymapBinding("down"), - "input.select.left": keymapBinding("shift+left"), - "input.select.right": keymapBinding("shift+right"), - "input.select.up": keymapBinding("shift+up"), - "input.select.down": keymapBinding("shift+down"), - "input.line.home": keymapBinding("ctrl+a"), - "input.line.end": keymapBinding("ctrl+e"), - "input.select.line.home": keymapBinding("ctrl+shift+a"), - "input.select.line.end": keymapBinding("ctrl+shift+e"), - "input.visual.line.home": keymapBinding("alt+a"), - "input.visual.line.end": keymapBinding("alt+e"), - "input.select.visual.line.home": keymapBinding("alt+shift+a"), - "input.select.visual.line.end": keymapBinding("alt+shift+e"), - "input.buffer.home": keymapBinding("home"), - "input.buffer.end": keymapBinding("end"), - "input.select.buffer.home": keymapBinding("shift+home"), - "input.select.buffer.end": keymapBinding("shift+end"), - "input.delete.line": keymapBinding("ctrl+shift+d"), - "input.delete.to.line.end": keymapBinding("ctrl+k"), - "input.delete.to.line.start": keymapBinding("ctrl+u"), - "input.backspace": keymapBinding("backspace,shift+backspace"), - "input.delete": keymapBinding("ctrl+d,delete,shift+delete"), - "input.undo": keymapBinding(() => (process.platform === "win32" ? "ctrl+z,ctrl+-,super+z" : "ctrl+-,super+z")), - "input.redo": keymapBinding("ctrl+.,super+shift+z"), - "input.word.forward": keymapBinding("alt+f,alt+right,ctrl+right"), - "input.word.backward": keymapBinding("alt+b,alt+left,ctrl+left"), - "input.select.word.forward": keymapBinding("alt+shift+f,alt+shift+right"), - "input.select.word.backward": keymapBinding("alt+shift+b,alt+shift+left"), - "input.delete.word.forward": keymapBinding("alt+d,alt+delete,ctrl+delete"), - "input.delete.word.backward": keymapBinding("ctrl+w,ctrl+backspace,alt+backspace"), - "input.select.all": keymapBinding("super+a"), -} - -const DialogSelectKeymapSection = { - "dialog.select.prev": keymapBinding("up,ctrl+p"), - "dialog.select.next": keymapBinding("down,ctrl+n"), - "dialog.select.page_up": keymapBinding("pageup"), - "dialog.select.page_down": keymapBinding("pagedown"), - "dialog.select.home": keymapBinding("home"), - "dialog.select.end": keymapBinding("end"), - "dialog.select.submit": keymapBinding("return"), -} - -const DialogActionsKeymapSection = { - "dialog.action.toggle": keymapBinding("space"), - "dialog.action.delete": keymapBinding("ctrl+d"), - "dialog.action.rename": keymapBinding("ctrl+r"), -} - -const ModelKeymapSection = { - "model.dialog.provider": keymapBinding("ctrl+a"), - "model.dialog.favorite": keymapBinding("ctrl+f"), -} - -const PermissionKeymapSection = { - "permission.reject.cancel": keymapBinding("ctrl+c,ctrl+d,q"), - "permission.prompt.escape": keymapBinding("ctrl+c,ctrl+d,q"), - "permission.prompt.fullscreen": keymapBinding("ctrl+f"), -} - -const QuestionKeymapSection = { - "question.reject": keymapBinding("ctrl+c,ctrl+d,q"), - "question.edit.clear": keymapBinding("ctrl+c"), -} - -const PluginsKeymapSection = { - "plugins.list": keymapBinding("none"), - "plugins.install": keymapBinding("none"), - "plugin.dialog.install": keymapBinding("shift+i"), -} - -const HomeTipsKeymapSection = { - "tips.toggle": keymapBinding("h"), -} - -const KeymapSectionsShape = { - global: keymapSection(GlobalKeymapSection), - which_key: keymapSection(WhichKeyKeymapSection), - session: keymapSection(SessionKeymapSection), - prompt: keymapSection(PromptKeymapSection), - autocomplete: keymapSection(AutocompleteKeymapSection), - input: keymapSection(InputKeymapSection), - dialog_select: keymapSection(DialogSelectKeymapSection), - dialog_actions: keymapSection(DialogActionsKeymapSection), - model: keymapSection(ModelKeymapSection), - permission: keymapSection(PermissionKeymapSection), - question: keymapSection(QuestionKeymapSection), - plugins: keymapSection(PluginsKeymapSection), - home_tips: keymapSection(HomeTipsKeymapSection), -} - -const KeymapSectionsInputShape = { - global: keymapSectionInput(GlobalKeymapSection).optional(), - which_key: keymapSectionInput(WhichKeyKeymapSection).optional(), - session: keymapSectionInput(SessionKeymapSection).optional(), - prompt: keymapSectionInput(PromptKeymapSection).optional(), - autocomplete: keymapSectionInput(AutocompleteKeymapSection).optional(), - input: keymapSectionInput(InputKeymapSection).optional(), - dialog_select: keymapSectionInput(DialogSelectKeymapSection).optional(), - dialog_actions: keymapSectionInput(DialogActionsKeymapSection).optional(), - model: keymapSectionInput(ModelKeymapSection).optional(), - permission: keymapSectionInput(PermissionKeymapSection).optional(), - question: keymapSectionInput(QuestionKeymapSection).optional(), - plugins: keymapSectionInput(PluginsKeymapSection).optional(), - home_tips: keymapSectionInput(HomeTipsKeymapSection).optional(), -} - -export const KeymapSections = z.object(KeymapSectionsShape).strict().prefault({}) -export type KeymapSections = z.output -export type KeymapSection = keyof KeymapSections -export const KeymapSectionNames = Object.keys(KeymapSectionsShape) as KeymapSection[] export const KeymapLeaderTimeoutDefault = 2000 -export type KeymapInfo = { - leader: string - leader_timeout: number -} & ResolvedBindingSections - -export const KeymapSectionGroups = { - global: "Global", - which_key: "System", - session: "Session", - prompt: "Prompt", - autocomplete: "Autocomplete", - input: "Text Editing", - dialog_select: "Dialog", - dialog_actions: "Dialog", - model: "Model", - permission: "Permission", - question: "Question", - plugins: "Plugins", - home_tips: "Home", -} satisfies Record - -export function keymapBindingDefaults(input: { section: string; binding: Readonly> }) { - if (input.binding.group !== undefined) return - if (!Object.hasOwn(KeymapSectionGroups, input.section)) return - return { group: KeymapSectionGroups[input.section as KeymapSection] } -} - -export const KeymapConfig = z - .object({ - leader: z.string().prefault("ctrl+x"), - leader_timeout: z - .number() - .int() - .positive() - .prefault(KeymapLeaderTimeoutDefault) - .describe("Leader key timeout in milliseconds"), - sections: KeymapSections, - }) - .strict() - .describe("TUI keymap configuration") -export type KeymapConfig = z.output - -const KeymapSectionsInput = z.object(KeymapSectionsInputShape).strict().optional() -export const KeymapConfigInput = z - .object({ - leader: z.string().optional(), - leader_timeout: z.number().int().positive().optional().describe("Leader key timeout in milliseconds"), - sections: KeymapSectionsInput, - }) - .strict() - .describe("TUI keymap configuration") -export type KeymapConfigInput = z.output +const KeymapLeaderTimeout = z.number().int().positive().describe("Leader key timeout in milliseconds") export const TuiOptions = z.object({ + leader_timeout: KeymapLeaderTimeout.optional(), scroll_speed: z.number().min(0.001).optional().describe("TUI scroll speed"), scroll_acceleration: z .object({ @@ -352,17 +25,11 @@ export const TuiInfo = z .object({ $schema: z.string().optional(), theme: z.string().optional(), - keybinds: KeybindOverride.optional().meta({ - deprecated: true, - description: "Use keymap instead. This will be removed in opencode v2.0.", - }), - keymap: KeymapConfigInput.optional(), + keybinds: TuiKeybind.KeybindOverrides.optional(), plugin: ConfigPlugin.Spec.zod.array().optional(), plugin_enabled: z.record(z.string(), z.boolean()).optional(), }) .extend(TuiOptions.shape) .strict() -export const TuiJsonSchemaInfo = TuiInfo.extend({ - keymap: KeymapConfig.optional(), -}).strict() +export const TuiJsonSchemaInfo = TuiInfo diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts index 429d7e5c1c..14d9918160 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts @@ -1,29 +1,26 @@ export * as TuiConfig from "./tui" import type z from "zod" -import type { KeyEvent, Renderable } from "@opentui/core" -import { resolveBindingSections, type BindingSectionsConfig } from "@opentui/keymap/extras" +import { createBindingLookup } from "@opentui/keymap/extras" import { mergeDeep, unique } from "remeda" import { Context, Effect, Fiber, Layer } from "effect" import { ConfigParse } from "@/config/parse" import * as ConfigPaths from "@/config/paths" import { migrateTuiConfig } from "./tui-migrate" -import { KeymapConfig, TuiInfo, TuiJsonSchemaInfo } from "./tui-schema" +import { KeymapLeaderTimeoutDefault, TuiInfo, TuiJsonSchemaInfo } from "./tui-schema" import { Flag } from "@opencode-ai/core/flag/flag" import { isRecord } from "@/util/record" import { Global } from "@opencode-ai/core/global" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { CurrentWorkingDirectory } from "./cwd" import { ConfigPlugin } from "@/config/plugin" -import { ConfigKeybinds } from "@/config/keybinds" +import { TuiKeybind } from "./keybind" import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version" import { makeRuntime } from "@opencode-ai/core/effect/runtime" import { Filesystem } from "@/util/filesystem" import * as Log from "@opencode-ai/core/util/log" import { ConfigVariable } from "@/config/variable" import { Npm } from "@opencode-ai/core/npm" -import { LegacyKeymapTransform } from "./legacy-keymap-transform" -import { KeymapSectionNames, keymapBindingDefaults, type KeymapInfo, type KeymapSection } from "./tui-schema" const log = Log.create({ service: "tui.config" }) @@ -36,9 +33,9 @@ type Acc = { plugin_origins: ConfigPlugin.Origin[] } -export type Resolved = Omit & { - keybinds: ConfigKeybinds.Keybinds - keymap: KeymapInfo +export type Resolved = Omit & { + keybinds: TuiKeybind.BindingLookupView + leader_timeout: number // Internal resolved plugin list used by runtime loading. plugin_origins?: ConfigPlugin.Origin[] } @@ -186,31 +183,18 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: keybinds.terminal_suspend = "none" keybinds.input_undo ??= unique([ "ctrl+z", - ...ConfigKeybinds.Keybinds.shape.input_undo.parse(undefined).split(","), + ...String(TuiKeybind.Keybinds.shape.input_undo.parse(undefined)).split(","), ]).join(",") } - const parsedKeybinds = ConfigKeybinds.Keybinds.parse(keybinds) - const keymapInput = acc.result.keymap ?? LegacyKeymapTransform.create(acc.result.keybinds ?? {}) - const keymapConfig = KeymapConfig.parse(keymapInput) - const keymap = { - leader: !keymapConfig.leader || keymapConfig.leader === "none" ? "ctrl+x" : keymapConfig.leader, - leader_timeout: keymapConfig.leader_timeout, - ...resolveBindingSections, KeymapSection>( - keymapConfig.sections, - { - sections: KeymapSectionNames, - bindingDefaults: keymapBindingDefaults, - }, - ), - } + const parsedKeybinds = TuiKeybind.Keybinds.parse(keybinds) const result: Resolved = { ...acc.result, - keybinds: parsedKeybinds, + keybinds: createBindingLookup(TuiKeybind.toBindingConfig(parsedKeybinds), { + commandMap: TuiKeybind.CommandMap, + bindingDefaults: TuiKeybind.bindingDefaults(), + }), + leader_timeout: acc.result.leader_timeout ?? KeymapLeaderTimeoutDefault, plugin_origins: acc.plugin_origins.length ? acc.plugin_origins : undefined, - // `keybinds` is deprecated and will be removed in opencode v2.0. Keep it - // only as the legacy fallback; once `keymap` is configured, ignore - // `keybinds` for keymap resolution. - keymap, } return { diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips.tsx index beb92578fa..69071b1f7c 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips.tsx @@ -20,7 +20,7 @@ function View(props: { api: TuiPluginApi; hidden: boolean; show: boolean; connec }, }, ], - bindings: props.api.tuiConfig.keymap.sections.home_tips, + bindings: props.api.tuiConfig.keybinds.get("tips.toggle"), })) return ( diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/plugins.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/plugins.tsx index 34666ff88c..2cf03c4a8f 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/plugins.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/plugins.tsx @@ -207,7 +207,7 @@ function View(props: { api: TuiPluginApi }) { actions={[ { title: "toggle", - command: "dialog.action.toggle", + command: "plugins.toggle", disabled: lock(), onTrigger: (item) => { setCur(item.value) @@ -216,14 +216,13 @@ function View(props: { api: TuiPluginApi }) { }, { title: "install", - command: "plugin.dialog.install", + command: "dialog.plugins.install", disabled: lock(), onTrigger: () => { showInstall(props.api) }, }, ]} - bindings={props.api.tuiConfig.keymap.pick("plugins", ["plugin.dialog.install"])} onSelect={(item) => { setCur(item.value) flip(item.value) @@ -258,7 +257,7 @@ const tui: TuiPlugin = async (api) => { }, }, ], - bindings: api.tuiConfig.keymap.omit("plugins", ["plugin.dialog.install"]), + bindings: api.tuiConfig.keybinds.gather("plugins.palette", ["plugins.list", "plugins.install"]), }) } diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/which-key.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/which-key.tsx index 2735939a00..3fcd244a2b 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/which-key.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/which-key.tsx @@ -8,17 +8,17 @@ import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" import type { InternalTuiPlugin } from "../../plugin/internal" const command = { - toggle: "tui-which-key.toggle", - toggleLayout: "tui-which-key.layout.toggle", - togglePending: "tui-which-key.pending.toggle", - groupPrevious: "tui-which-key.group.previous", - groupNext: "tui-which-key.group.next", - scrollUp: "tui-which-key.scroll.up", - scrollDown: "tui-which-key.scroll.down", - pageUp: "tui-which-key.page.up", - pageDown: "tui-which-key.page.down", - home: "tui-which-key.home", - end: "tui-which-key.end", + toggle: "which-key.toggle", + toggleLayout: "which-key.layout.toggle", + togglePending: "which-key.pending.toggle", + groupPrevious: "which-key.group.previous", + groupNext: "which-key.group.next", + scrollUp: "which-key.scroll.up", + scrollDown: "which-key.scroll.down", + pageUp: "which-key.page.up", + pageDown: "which-key.page.down", + home: "which-key.home", + end: "which-key.end", } as const const LAYER_PRIORITY = 900 @@ -112,8 +112,7 @@ function skin(api: TuiPluginApi): Skin { } function activeKeyLabel(active: ActiveKey) { - const group = text(active.bindingAttrs?.group) - if (active.continues) return group ?? text(active.tokenName) ?? UNKNOWN + if (active.continues) return text(active.tokenName) ?? text(active.display) ?? UNKNOWN return ( text(active.commandAttrs?.title) ?? text(active.bindingAttrs?.desc) ?? text(active.commandAttrs?.desc) ?? UNKNOWN ) @@ -361,7 +360,9 @@ function WhichKeyPanel(props: { }, }, ], - bindings: props.api.tuiConfig.keymap.pick("which_key", pendingMode() ? scrollCommands : panelCommands), + bindings: pendingMode() + ? props.api.tuiConfig.keybinds.gather("which-key.scroll", scrollCommands) + : props.api.tuiConfig.keybinds.gather("which-key.panel", panelCommands), })) createEffect(() => { @@ -571,7 +572,7 @@ const tui: TuiPlugin = async (api) => { }, }, ], - bindings: api.tuiConfig.keymap.pick("which_key", toggleCommands), + bindings: api.tuiConfig.keybinds.gather("which-key.toggle", toggleCommands), }) api.slots.register({ @@ -599,7 +600,7 @@ const tui: TuiPlugin = async (api) => { } const plugin: InternalTuiPlugin = { - id: "tui-which-key", + id: "which-key", enabled: false, tui, } diff --git a/packages/opencode/src/cli/cmd/tui/keymap.tsx b/packages/opencode/src/cli/cmd/tui/keymap.tsx index 0d65057d79..379fa5afdf 100644 --- a/packages/opencode/src/cli/cmd/tui/keymap.tsx +++ b/packages/opencode/src/cli/cmd/tui/keymap.tsx @@ -1,5 +1,6 @@ import { type CliRenderer } from "@opentui/core" import * as addons from "@opentui/keymap/addons/opentui" +import { stringifyKeyStroke } from "@opentui/keymap" import { formatCommandBindings as formatCommandBindingsExtra, formatKeySequence as formatKeySequenceExtra, @@ -14,6 +15,7 @@ import { import type { Accessor } from "solid-js" import type { TuiConfig } from "./config/tui" import { useTuiConfig } from "./context/tui-config" +import { TuiKeybind } from "./config/keybind" export const LEADER_TOKEN = "leader" @@ -24,10 +26,55 @@ export { reactiveMatcherFromSignal, useBindings, useKeymapSelector } export type OpenTuiKeymap = ReturnType +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 leaderDisplay(config: TuiConfig.Resolved) { + const key = config.keybinds.get(LEADER_TOKEN)?.[0]?.key + if (!key) return TuiKeybind.LeaderDefault + return typeof key === "string" ? key : stringifyKeyStroke(key) +} + function formatOptions(config: TuiConfig.Resolved) { return { tokenDisplay: { - [LEADER_TOKEN]: config.keymap.leader, + [LEADER_TOKEN]: leaderDisplay(config), }, keyNameAliases: { pageup: "pgup", @@ -55,19 +102,23 @@ export function registerOpencodeKeymap(keymap: OpenTuiKeymap, renderer: CliRende const offCommaBindings = addons.registerCommaBindings(keymap) const offBaseLayout = addons.registerBaseLayoutFallback(keymap) const offLeader = addons.registerTimedLeader(keymap, { - trigger: config.keymap.leader, + trigger: config.keybinds.get(LEADER_TOKEN), name: LEADER_TOKEN, - timeoutMs: config.keymap.leader_timeout, + timeoutMs: config.leader_timeout, }) const offEscape = addons.registerEscapeClearsPendingSequence(keymap) const offBackspace = addons.registerBackspacePopsPendingSequence(keymap) - const offInputBindings = addons.registerManagedTextareaLayer(keymap, renderer, { + const offInputCommands = addons.registerEditBufferCommands(keymap, renderer) + const offInputSuspension = addons.registerTextareaMappingSuspension(keymap, renderer) + const offInputBindings = keymap.registerLayer({ enabled: () => renderer.currentFocusedEditor !== null, - bindings: config.keymap.sections.input, + bindings: config.keybinds.gather("input", inputCommands), }) return () => { offInputBindings() + offInputSuspension() + offInputCommands() offBackspace() offEscape() offLeader() 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 40f3e4fbca..508ba49416 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -117,6 +117,42 @@ function goUpsellKeys(action: SessionRetry.Retryable["action"]) { } } +const sessionBindingCommands = [ + "session.share", + "session.rename", + "session.timeline", + "session.fork", + "session.compact", + "session.unshare", + "session.undo", + "session.redo", + "session.sidebar.toggle", + "session.toggle.conceal", + "session.toggle.timestamps", + "session.toggle.thinking", + "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", + "session.message.next", + "session.message.previous", + "messages.copy", + "session.copy", + "session.export", + "session.child.first", + "session.parent", + "session.child.next", + "session.child.previous", +] as const + const context = createContext<{ width: number sessionID: string @@ -144,9 +180,6 @@ export function Session() { const event = useEvent() const project = useProject() const tuiConfig = useTuiConfig() - const { - keymap: { sections }, - } = tuiConfig const kv = useKV() const { theme } = useTheme() const promptRef = usePromptRef() @@ -1015,7 +1048,7 @@ export function Session() { useBindings(() => ({ enabled: command.matcher, - bindings: sections.session, + bindings: tuiConfig.keybinds.gather("session", sessionBindingCommands), })) const revertInfo = createMemo(() => session()?.revert) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index 036b56dbd5..0ccc3d7262 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -463,7 +463,6 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: ( let input: TextareaRenderable const { theme } = useTheme() const tuiConfig = useTuiConfig() - const keymapConfig = tuiConfig.keymap const dimensions = useTerminalDimensions() const narrow = createMemo(() => dimensions().width < 80) const dialog = useDialog() @@ -471,7 +470,7 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: ( enabled: dialog.stack.length === 0, commands: [ { - name: "permission.reject.cancel", + name: "app.exit", title: "Cancel permission rejection", category: "Permission", run() { @@ -481,7 +480,7 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: ( ], bindings: [ { key: "escape", desc: "Cancel permission rejection", group: "Permission", cmd: () => props.onCancel() }, - ...keymapConfig.pick("permission", ["permission.reject.cancel"]), + ...tuiConfig.keybinds.get("app.exit"), { key: "return", desc: "Confirm permission rejection", @@ -553,7 +552,6 @@ function Prompt>(props: { }) { const { theme } = useTheme() const tuiConfig = useTuiConfig() - const keymapConfig = tuiConfig.keymap const dimensions = useTerminalDimensions() const keys = Object.keys(props.options) as (keyof T)[] const [store, setStore] = createStore({ @@ -568,7 +566,7 @@ function Prompt>(props: { enabled: dialog.stack.length === 0, commands: [ { - name: "permission.prompt.escape", + name: "app.exit", title: "Reject permission", category: "Permission", run() { @@ -643,8 +641,8 @@ function Prompt>(props: { }, ] : []), - ...(props.escapeKey ? keymapConfig.pick("permission", ["permission.prompt.escape"]) : []), - ...(props.fullscreen ? keymapConfig.pick("permission", ["permission.prompt.fullscreen"]) : []), + ...(props.escapeKey ? tuiConfig.keybinds.get("app.exit") : []), + ...(props.fullscreen ? tuiConfig.keybinds.get("permission.prompt.fullscreen") : []), ], })) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx index e37b51e0a4..e690f6f327 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx @@ -13,10 +13,6 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { const sdk = useSDK() const { theme } = useTheme() const tuiConfig = useTuiConfig() - const { - keymap: { sections }, - } = tuiConfig - const keymapConfig = tuiConfig.keymap const questions = createMemo(() => props.request.questions) const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true) @@ -128,7 +124,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { enabled: store.editing && !confirm(), commands: [ { - name: "question.edit.clear", + name: "prompt.clear", title: "Clear answer edit", category: "Question", run() { @@ -150,7 +146,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { setStore("editing", false) }, }, - ...keymapConfig.pick("question", ["question.edit.clear"]), + ...tuiConfig.keybinds.get("prompt.clear"), { key: "return", desc: "Submit answer edit", @@ -208,7 +204,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { enabled: dialog.stack.length === 0 && !store.editing, commands: [ { - name: "question.reject", + name: "app.exit", title: "Reject question", category: "Question", run() { @@ -243,7 +239,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { ? [ { key: "return", desc: "Submit answer", group: "Question", cmd: () => submit() }, { key: "escape", desc: "Reject question", group: "Question", cmd: () => reject() }, - ...sections.question, + ...tuiConfig.keybinds.get("app.exit"), ] : [ ...Array.from({ length: max }, (_, index) => ({ @@ -271,7 +267,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { { key: "j", desc: "Next answer", group: "Question", cmd: () => moveTo((store.selected + 1) % total) }, { key: "return", desc: "Select answer", group: "Question", cmd: () => selectOption() }, { key: "escape", desc: "Reject question", group: "Question", cmd: () => reject() }, - ...sections.question, + ...tuiConfig.keybinds.get("app.exit"), ]), ], } diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index afa9d50571..a791aebc30 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -65,9 +65,6 @@ export function DialogSelect(props: DialogSelectProps) { const dialog = useDialog() const { theme } = useTheme() const tuiConfig = useTuiConfig() - const { - keymap: { sections }, - } = tuiConfig const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig)) const [store, setStore] = createStore({ @@ -308,11 +305,16 @@ export function DialogSelect(props: DialogSelectProps) { })), ], bindings: [ - ...sections.dialog_select, - ...tuiConfig.keymap.pick( - "dialog_actions", - enabledActions.map((item) => item.command), - ), + ...tuiConfig.keybinds.gather("dialog.select", [ + "dialog.select.prev", + "dialog.select.next", + "dialog.select.page_up", + "dialog.select.page_down", + "dialog.select.home", + "dialog.select.end", + "dialog.select.submit", + ]), + ...enabledActions.flatMap((item) => tuiConfig.keybinds.get(item.command)), ...(props.bindings ?? []).filter((binding) => { if (typeof binding.cmd !== "string") return true return enabledActions.some((item) => item.command === binding.cmd) diff --git a/packages/opencode/src/config/keybinds.ts b/packages/opencode/src/config/keybinds.ts deleted file mode 100644 index d9a397f516..0000000000 --- a/packages/opencode/src/config/keybinds.ts +++ /dev/null @@ -1,143 +0,0 @@ -export * as ConfigKeybinds from "./keybinds" - -import { Effect, Schema } from "effect" -import type z from "zod" -import { zod } from "@/util/effect-zod" - -// Every keybind field has the same shape: an optional string with a default -// binding and a human description. `keybind()` keeps the declaration list -// below dense and readable. -const keybind = (value: string, description: string) => - Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(value))).annotate({ description }) - -// Windows prepends ctrl+z to the undo binding because `terminal_suspend` -// cannot consume ctrl+z on native Windows terminals (no POSIX suspend). -const inputUndoDefault = process.platform === "win32" ? "ctrl+z,ctrl+-,super+z" : "ctrl+-,super+z" - -const KeybindsSchema = Schema.Struct({ - leader: keybind("ctrl+x", "Leader key for keybind combinations"), - app_exit: keybind("ctrl+c,ctrl+d,q", "Exit the application"), - editor_open: keybind("e", "Open external editor"), - theme_list: keybind("t", "List available themes"), - sidebar_toggle: keybind("b", "Toggle sidebar"), - scrollbar_toggle: keybind("none", "Toggle session scrollbar"), - status_view: keybind("s", "View status"), - session_export: keybind("x", "Export session to editor"), - session_new: keybind("n", "Create a new session"), - session_list: keybind("l", "List all sessions"), - session_timeline: keybind("g", "Show session timeline"), - session_fork: keybind("none", "Fork session from message"), - session_rename: keybind("ctrl+r", "Rename session"), - session_delete: keybind("ctrl+d", "Delete session"), - stash_delete: keybind("ctrl+d", "Delete stash entry"), - model_provider_list: keybind("ctrl+a", "Open provider list from model dialog"), - model_favorite_toggle: keybind("ctrl+f", "Toggle model favorite status"), - session_share: keybind("none", "Share current session"), - session_unshare: keybind("none", "Unshare current session"), - session_interrupt: keybind("escape", "Interrupt current session"), - session_compact: keybind("c", "Compact the session"), - messages_page_up: keybind("pageup,ctrl+alt+b", "Scroll messages up by one page"), - messages_page_down: keybind("pagedown,ctrl+alt+f", "Scroll messages down by one page"), - messages_line_up: keybind("ctrl+alt+y", "Scroll messages up by one line"), - messages_line_down: keybind("ctrl+alt+e", "Scroll messages down by one line"), - messages_half_page_up: keybind("ctrl+alt+u", "Scroll messages up by half page"), - messages_half_page_down: keybind("ctrl+alt+d", "Scroll messages down by half page"), - messages_first: keybind("ctrl+g,home", "Navigate to first message"), - messages_last: keybind("ctrl+alt+g,end", "Navigate to last message"), - messages_next: keybind("none", "Navigate to next message"), - messages_previous: keybind("none", "Navigate to previous message"), - messages_last_user: keybind("none", "Navigate to last user message"), - messages_copy: keybind("y", "Copy message"), - messages_undo: keybind("u", "Undo message"), - messages_redo: keybind("r", "Redo message"), - messages_toggle_conceal: keybind("h", "Toggle code block concealment in messages"), - tool_details: keybind("none", "Toggle tool details visibility"), - model_list: keybind("m", "List available models"), - model_cycle_recent: keybind("f2", "Next recently used model"), - model_cycle_recent_reverse: keybind("shift+f2", "Previous recently used model"), - model_cycle_favorite: keybind("none", "Next favorite model"), - model_cycle_favorite_reverse: keybind("none", "Previous favorite model"), - command_list: keybind("ctrl+p", "List available commands"), - "dialog.select.prev": keybind("up,ctrl+p", "Move to previous dialog item"), - "dialog.select.next": keybind("down,ctrl+n", "Move to next dialog item"), - "dialog.select.page_up": keybind("pageup", "Move up one page in dialog"), - "dialog.select.page_down": keybind("pagedown", "Move down one page in dialog"), - "dialog.select.home": keybind("home", "Move to first dialog item"), - "dialog.select.end": keybind("end", "Move to last dialog item"), - "dialog.select.submit": keybind("return", "Submit selected dialog item"), - "dialog.mcp.toggle": keybind("space", "Toggle MCP in MCP dialog"), - "prompt.autocomplete.prev": keybind("up,ctrl+p", "Move to previous autocomplete item"), - "prompt.autocomplete.next": keybind("down,ctrl+n", "Move to next autocomplete item"), - "prompt.autocomplete.hide": keybind("escape", "Hide autocomplete"), - "prompt.autocomplete.select": keybind("return", "Select autocomplete item"), - "prompt.autocomplete.complete": keybind("tab", "Complete autocomplete item"), - "permission.prompt.fullscreen": keybind("ctrl+f", "Toggle permission prompt fullscreen"), - "plugins.toggle": keybind("space", "Toggle plugin"), - "dialog.plugins.install": keybind("shift+i", "Install plugin from plugin dialog"), - agent_list: keybind("a", "List agents"), - agent_cycle: keybind("tab", "Next agent"), - agent_cycle_reverse: keybind("shift+tab", "Previous agent"), - variant_cycle: keybind("ctrl+t", "Cycle model variants"), - variant_list: keybind("none", "List model variants"), - input_clear: keybind("ctrl+c", "Clear input field"), - input_paste: keybind("ctrl+v", "Paste from clipboard"), - input_submit: keybind("return", "Submit input"), - input_newline: keybind("shift+return,ctrl+return,alt+return,ctrl+j", "Insert newline in input"), - input_move_left: keybind("left,ctrl+b", "Move cursor left in input"), - input_move_right: keybind("right,ctrl+f", "Move cursor right in input"), - input_move_up: keybind("up", "Move cursor up in input"), - input_move_down: keybind("down", "Move cursor down in input"), - input_select_left: keybind("shift+left", "Select left in input"), - input_select_right: keybind("shift+right", "Select right in input"), - input_select_up: keybind("shift+up", "Select up in input"), - input_select_down: keybind("shift+down", "Select down in input"), - input_line_home: keybind("ctrl+a", "Move to start of line in input"), - input_line_end: keybind("ctrl+e", "Move to end of line in input"), - input_select_line_home: keybind("ctrl+shift+a", "Select to start of line in input"), - input_select_line_end: keybind("ctrl+shift+e", "Select to end of line in input"), - input_visual_line_home: keybind("alt+a", "Move to start of visual line in input"), - input_visual_line_end: keybind("alt+e", "Move to end of visual line in input"), - input_select_visual_line_home: keybind("alt+shift+a", "Select to start of visual line in input"), - input_select_visual_line_end: keybind("alt+shift+e", "Select to end of visual line in input"), - input_buffer_home: keybind("home", "Move to start of buffer in input"), - input_buffer_end: keybind("end", "Move to end of buffer in input"), - input_select_buffer_home: keybind("shift+home", "Select to start of buffer in input"), - input_select_buffer_end: keybind("shift+end", "Select to end of buffer in input"), - input_delete_line: keybind("ctrl+shift+d", "Delete line in input"), - input_delete_to_line_end: keybind("ctrl+k", "Delete to end of line in input"), - input_delete_to_line_start: keybind("ctrl+u", "Delete to start of line in input"), - input_backspace: keybind("backspace,shift+backspace", "Backspace in input"), - input_delete: keybind("ctrl+d,delete,shift+delete", "Delete character in input"), - input_undo: keybind(inputUndoDefault, "Undo in input"), - input_redo: keybind("ctrl+.,super+shift+z", "Redo in input"), - input_word_forward: keybind("alt+f,alt+right,ctrl+right", "Move word forward in input"), - input_word_backward: keybind("alt+b,alt+left,ctrl+left", "Move word backward in input"), - input_select_word_forward: keybind("alt+shift+f,alt+shift+right", "Select word forward in input"), - input_select_word_backward: keybind("alt+shift+b,alt+shift+left", "Select word backward in input"), - input_delete_word_forward: keybind("alt+d,alt+delete,ctrl+delete", "Delete word forward in input"), - input_delete_word_backward: keybind("ctrl+w,ctrl+backspace,alt+backspace", "Delete word backward in input"), - input_select_all: keybind("super+a", "Select all in input"), - history_previous: keybind("up", "Previous history item"), - history_next: keybind("down", "Next history item"), - session_child_first: keybind("down", "Go to first child session"), - session_child_cycle: keybind("right", "Go to next child session"), - session_child_cycle_reverse: keybind("left", "Go to previous child session"), - session_parent: keybind("up", "Go to parent session"), - // `terminal_suspend` was formerly `.default("ctrl+z").transform((v) => win32 ? "none" : v)`, - // but `tui.ts` already forces the binding to "none" on win32 before calling - // `Keybinds.parse(...)`, so the schema-level transform was redundant. - terminal_suspend: keybind("ctrl+z", "Suspend terminal"), - terminal_title_toggle: keybind("none", "Toggle terminal title"), - tips_toggle: keybind("h", "Toggle tips on home screen"), - plugin_manager: keybind("none", "Open plugin manager dialog"), - display_thinking: keybind("none", "Toggle thinking blocks visibility"), -}).annotate({ identifier: "KeybindsConfig" }) - -export type Keybinds = Schema.Schema.Type - -// Consumers access `Keybinds.shape` and `Keybinds.shape.X.parse(undefined)`, -// which requires the runtime type to be a ZodObject, not just ZodType. Every -// field is `string().optional().default(...)` at runtime, so widen to that. -export const Keybinds = zod(KeybindsSchema) as unknown as z.ZodObject< - Record>> -> diff --git a/packages/opencode/test/cli/run/runtime.boot.test.ts b/packages/opencode/test/cli/run/runtime.boot.test.ts index c0d66bf75d..e2569b0ac6 100644 --- a/packages/opencode/test/cli/run/runtime.boot.test.ts +++ b/packages/opencode/test/cli/run/runtime.boot.test.ts @@ -1,12 +1,11 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" import type { KeyEvent, Renderable } from "@opentui/core" import type { Binding } from "@opentui/keymap" -import { resolveBindingSections, type BindingSectionsConfig } from "@opentui/keymap/extras" +import { createBindingLookup } from "@opentui/keymap/extras" import { OpencodeClient, type Provider } from "@opencode-ai/sdk/v2" import { TuiConfig, type Resolved } from "@/cli/cmd/tui/config/tui" import { formatBindings } from "@/cli/cmd/run/keymap.shared" -import { KeymapSectionNames, keymapBindingDefaults, type KeymapSection } from "@/cli/cmd/tui/config/tui-schema" -import { ConfigKeybinds } from "@/config/keybinds" +import { TuiKeybind } from "@/cli/cmd/tui/config/keybind" import { resolveDiffStyle, resolveFooterKeybinds, resolveModelInfo } from "@/cli/cmd/run/runtime.boot" type RunBinding = Binding @@ -82,34 +81,24 @@ function config(input?: { }> }): Resolved { const bind = input?.bindings - const sections = { - global: Object.fromEntries([ - ...(bind?.commandList ? [["command.palette.show", bind.commandList] as const] : []), - ...(bind?.variantCycle ? [["variant.cycle", bind.variantCycle] as const] : []), - ]), - prompt: Object.fromEntries([ - ...(bind?.interrupt ? [["session.interrupt", bind.interrupt] as const] : []), - ...(bind?.historyPrevious ? [["prompt.history.previous", bind.historyPrevious] as const] : []), - ...(bind?.historyNext ? [["prompt.history.next", bind.historyNext] as const] : []), - ...(bind?.inputClear ? [["prompt.clear", bind.inputClear] as const] : []), - ]), - input: Object.fromEntries([ - ...(bind?.inputSubmit ? [["input.submit", bind.inputSubmit] as const] : []), - ...(bind?.inputNewline ? [["input.newline", bind.inputNewline] as const] : []), - ]), - } satisfies BindingSectionsConfig - + const keybinds = TuiKeybind.Keybinds.parse({ + ...(input?.leader && { leader: input.leader }), + ...(bind?.commandList && { command_list: bind.commandList }), + ...(bind?.variantCycle && { variant_cycle: bind.variantCycle }), + ...(bind?.interrupt && { session_interrupt: bind.interrupt }), + ...(bind?.historyPrevious && { history_previous: bind.historyPrevious }), + ...(bind?.historyNext && { history_next: bind.historyNext }), + ...(bind?.inputClear && { input_clear: bind.inputClear }), + ...(bind?.inputSubmit && { input_submit: bind.inputSubmit }), + ...(bind?.inputNewline && { input_newline: bind.inputNewline }), + }) return { diff_style: input?.diff_style, - keybinds: ConfigKeybinds.Keybinds.parse({}), - keymap: { - leader: input?.leader ?? "ctrl+x", - leader_timeout: input?.leaderTimeout ?? 2000, - ...resolveBindingSections(sections, { - sections: KeymapSectionNames, - bindingDefaults: keymapBindingDefaults, - }), - }, + keybinds: createBindingLookup(TuiKeybind.toBindingConfig(keybinds), { + commandMap: TuiKeybind.CommandMap, + bindingDefaults: TuiKeybind.bindingDefaults(), + }), + leader_timeout: input?.leaderTimeout ?? 2000, } } @@ -118,7 +107,7 @@ describe("run runtime boot", () => { mock.restore() }) - test("reads footer keybinds from resolved keymap config", async () => { + test("reads footer keybinds from resolved keybind config", async () => { spyOn(TuiConfig, "get").mockResolvedValue( config({ leader: "ctrl+g", diff --git a/packages/opencode/test/cli/tui/plugin-loader.test.ts b/packages/opencode/test/cli/tui/plugin-loader.test.ts index 1702101233..d62bc19bfe 100644 --- a/packages/opencode/test/cli/tui/plugin-loader.test.ts +++ b/packages/opencode/test/cli/tui/plugin-loader.test.ts @@ -81,7 +81,7 @@ async function load(): Promise { await Bun.write( localPluginPath, - `import { resolveBindingSections } from "@opentui/keymap/extras" + `import { createBindingLookup } from "@opentui/keymap/extras" import { useBindings } from "@opentui/keymap/solid" export const ignored = async (_input, options) => { @@ -97,20 +97,18 @@ export default { const cfg_diff = api.tuiConfig.diff_style const cfg_speed = api.tuiConfig.scroll_speed const cfg_accel = api.tuiConfig.scroll_acceleration?.enabled - const cfg_submit = api.tuiConfig.keybinds?.input_submit const has_keys = typeof api.keys.formatBindings === "function" - const keymap = resolveBindingSections(options.keymap?.sections ?? { - main: { - "plugin.loader.local": "ctrl+shift+m", - "plugin.loader.close": "escape", - }, - }, { sections: ["main"] }).sections - const key_modal = keymap.main.find((item) => item.cmd === "plugin.loader.local")?.key - const key_close = keymap.main.find((item) => item.cmd === "plugin.loader.close")?.key + const keybinds = createBindingLookup(options.keybinds ?? { + "plugin.loader.local": "ctrl+shift+m", + "plugin.loader.close": "escape", + }) + const bindings = keybinds.gather("plugin.loader", ["plugin.loader.local", "plugin.loader.close"]) + const key_modal = bindings.find((item) => item.cmd === "plugin.loader.local")?.key + const key_close = bindings.find((item) => item.cmd === "plugin.loader.close")?.key const key_unknown = "ctrl+k" const off = api.keymap.registerLayer({ commands: [{ name: "plugin.loader.local", run() {} }, { name: "plugin.loader.close", run() {} }], - bindings: keymap.main, + bindings, }) off() const kv_before = api.kv.get(options.kv_key, "missing") @@ -153,7 +151,7 @@ export default { key_unknown, has_keys, has_keymap: typeof api.keymap.registerLayer === "function", - has_resolve_binding_sections: typeof resolveBindingSections === "function", + has_create_binding_lookup: typeof createBindingLookup === "function", has_keymap_solid: typeof useBindings === "function", kv_before, kv_after, @@ -176,7 +174,6 @@ export default { cfg_diff, cfg_speed, cfg_accel, - cfg_submit, }), ) }, @@ -356,13 +353,9 @@ export default { theme_name: tmp.extra.localThemeName, kv_key: "plugin_state_key", session_id: "ses_test", - keymap: { - sections: { - main: { - "plugin.loader.local": "ctrl+alt+m", - "plugin.loader.close": "q", - }, - }, + keybinds: { + "plugin.loader.local": "ctrl+alt+m", + "plugin.loader.close": "q", }, } const invalidOpts = { @@ -408,9 +401,6 @@ export default { diff_style: "stacked", scroll_speed: 1.5, scroll_acceleration: { enabled: true }, - keybinds: { - input_submit: "ctrl+enter", - }, }, state: { session: { @@ -670,7 +660,7 @@ describe("tui.plugin.loader", () => { expect(data.local.key_unknown).toBe("ctrl+k") expect(data.local.has_keys).toBe(true) expect(data.local.has_keymap).toBe(true) - expect(data.local.has_resolve_binding_sections).toBe(true) + expect(data.local.has_create_binding_lookup).toBe(true) expect(data.local.has_keymap_solid).toBe(true) expect(data.local.kv_before).toBe("missing") expect(data.local.kv_after).toBe("stored") @@ -693,7 +683,6 @@ describe("tui.plugin.loader", () => { expect(data.local.cfg_diff).toBe("stacked") expect(data.local.cfg_speed).toBe(1.5) expect(data.local.cfg_accel).toBe(true) - expect(data.local.cfg_submit).toBe("ctrl+enter") }) test("installs themes in the correct scope and remains resilient", () => { diff --git a/packages/opencode/test/cli/tui/plugin-toggle.test.ts b/packages/opencode/test/cli/tui/plugin-toggle.test.ts index 0f3f663c02..a3ee744bff 100644 --- a/packages/opencode/test/cli/tui/plugin-toggle.test.ts +++ b/packages/opencode/test/cli/tui/plugin-toggle.test.ts @@ -171,26 +171,26 @@ test("loads disabled-by-default internal plugin inactive and activates on demand enabled: true, active: true, }) - expect(TuiPluginRuntime.list().find((item) => item.id === "tui-which-key")).toEqual({ - id: "tui-which-key", + expect(TuiPluginRuntime.list().find((item) => item.id === "which-key")).toEqual({ + id: "which-key", source: "internal", - spec: "tui-which-key", - target: "tui-which-key", + spec: "which-key", + target: "which-key", enabled: false, active: false, }) - await expect(TuiPluginRuntime.activatePlugin("tui-which-key")).resolves.toBe(true) - expect(TuiPluginRuntime.list().find((item) => item.id === "tui-which-key")).toEqual({ - id: "tui-which-key", + await expect(TuiPluginRuntime.activatePlugin("which-key")).resolves.toBe(true) + expect(TuiPluginRuntime.list().find((item) => item.id === "which-key")).toEqual({ + id: "which-key", source: "internal", - spec: "tui-which-key", - target: "tui-which-key", + spec: "which-key", + target: "which-key", enabled: true, active: true, }) expect(api.kv.get("plugin_enabled", {})).toEqual({ - "tui-which-key": true, + "which-key": true, }) } finally { await TuiPluginRuntime.dispose() diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index 4ad942b251..db04568573 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -163,7 +163,7 @@ test("migrates tui-specific keys from opencode.json when tui.json does not exist const config = await getTuiConfig(tmp.path) expect(config.theme).toBe("migrated-theme") expect(config.scroll_speed).toBe(5) - expect(config.keybinds?.app_exit).toBe("ctrl+q") + expect(config.keybinds.get("app.exit")?.[0]?.key).toBe("ctrl+q") const text = await Filesystem.readText(path.join(tmp.path, "tui.json")) expect(JSON.parse(text)).toMatchObject({ theme: "migrated-theme", @@ -398,83 +398,64 @@ test("merges keybind overrides across precedence layers", async () => { }, }) const config = await getTuiConfig(tmp.path) - expect(config.keybinds?.app_exit).toBe("ctrl+q") - expect(config.keybinds?.theme_list).toBe("ctrl+k") + expect(config.keybinds.get("app.exit")?.[0]?.key).toBe("ctrl+q") + expect(config.keybinds.get("theme.switch")?.[0]?.key).toBe("ctrl+k") }) -test("resolves semantic keymap sections", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "tui.json"), - JSON.stringify({ - keybinds: { command_list: "ctrl+z" }, - keymap: { - sections: { - global: { "command.palette.show": "alt+p" }, - which_key: { "tui-which-key.toggle": "alt+k" }, - prompt: { "prompt.editor": "ctrl+e" }, - autocomplete: { "prompt.autocomplete.next": "ctrl+j" }, - dialog_actions: { "dialog.action.toggle": "ctrl+t" }, - model: { "model.dialog.favorite": "ctrl+f" }, - plugins: { "plugin.dialog.install": "shift+i" }, - }, - }, - }), - ) - }, - }) - - const config = await getTuiConfig(tmp.path) - expect(config.keymap.sections.global.find((binding) => binding.cmd === "command.palette.show")?.key).toBe("alt+p") - expect(config.keymap.sections.global.find((binding) => binding.cmd === "session.new")?.key).toBe("n") - expect(config.keymap.sections.which_key.find((binding) => binding.cmd === "tui-which-key.toggle")?.key).toBe("alt+k") - expect(config.keymap.sections.which_key.find((binding) => binding.cmd === "tui-which-key.layout.toggle")?.key).toBe( - "ctrl+alt+shift+k", - ) - expect(config.keymap.sections.which_key.find((binding) => binding.cmd === "tui-which-key.pending.toggle")?.key).toBe( - "ctrl+alt+shift+p", - ) - expect(config.keymap.sections.which_key.find((binding) => binding.cmd === "tui-which-key.group.next")?.key).toBe( - "ctrl+alt+right,ctrl+alt+]", - ) - expect( - ( - config.keymap.sections.which_key.find((binding) => binding.cmd === "tui-which-key.toggle") as - | { group?: unknown } - | undefined - )?.group, - ).toBe("System") - expect(config.keymap.sections.prompt.find((binding) => binding.cmd === "prompt.editor")?.key).toBe("ctrl+e") - expect(config.keymap.sections.autocomplete.find((binding) => binding.cmd === "prompt.autocomplete.next")?.key).toBe( - "ctrl+j", - ) - expect(config.keymap.sections.dialog_actions.find((binding) => binding.cmd === "dialog.action.toggle")?.key).toBe( - "ctrl+t", - ) - expect(config.keymap.sections.model.find((binding) => binding.cmd === "model.dialog.favorite")?.key).toBe("ctrl+f") - expect(config.keymap.sections.plugins.find((binding) => binding.cmd === "plugin.dialog.install")?.key).toBe("shift+i") - expect(config.keymap.pick("plugins", ["plugin.dialog.install"]).map((binding) => binding.cmd)).toEqual([ - "plugin.dialog.install", - ]) - expect((config.keymap.pick("plugins", ["plugin.dialog.install"])[0] as { group?: unknown } | undefined)?.group).toBe( - "Plugins", - ) - expect(config.keymap.omit("plugins", ["plugin.dialog.install"]).map((binding) => binding.cmd)).toEqual([]) -}) - -test("legacy keybinds transform into semantic keymap sections", async () => { +test("resolves keybind lookup from canonical keybinds", async () => { await using tmp = await tmpdir({ init: async (dir) => { await Bun.write( path.join(dir, "tui.json"), JSON.stringify({ keybinds: { + leader: { key: { name: "g", ctrl: true } }, command_list: "alt+p", + which_key_toggle: "alt+k", editor_open: "ctrl+e", "prompt.autocomplete.next": "ctrl+j", "dialog.mcp.toggle": "ctrl+t", + model_favorite_toggle: "ctrl+f", "dialog.plugins.install": "shift+i", + }, + leader_timeout: 1234, + }), + ) + }, + }) + + const config = await getTuiConfig(tmp.path) + expect(config.keybinds.get("leader")?.[0]?.key).toEqual({ name: "g", ctrl: true }) + expect(config.leader_timeout).toBe(1234) + expect(config.keybinds.get("command.palette.show")?.[0]?.key).toBe("alt+p") + expect(config.keybinds.get("session.new")?.[0]?.key).toBe("n") + expect(config.keybinds.get("which-key.toggle")?.[0]?.key).toBe("alt+k") + expect(config.keybinds.get("which-key.layout.toggle")?.[0]?.key).toBe("ctrl+alt+shift+k") + expect(config.keybinds.get("which-key.pending.toggle")?.[0]?.key).toBe("ctrl+alt+shift+p") + expect(config.keybinds.get("which-key.group.next")?.[0]?.key).toBe("ctrl+alt+right,ctrl+alt+]") + expect((config.keybinds.get("which-key.toggle")?.[0] as { desc?: unknown } | undefined)?.desc).toBe( + "Toggle which-key panel", + ) + expect(config.keybinds.get("prompt.editor")?.[0]?.key).toBe("ctrl+e") + expect(config.keybinds.get("prompt.autocomplete.next")?.[0]?.key).toBe("ctrl+j") + expect(config.keybinds.get("dialog.mcp.toggle")?.[0]?.key).toBe("ctrl+t") + expect(config.keybinds.get("model.dialog.favorite")?.[0]?.key).toBe("ctrl+f") + expect(config.keybinds.get("dialog.plugins.install")?.[0]?.key).toBe("shift+i") + expect(config.keybinds.gather("plugins.dialog", ["dialog.plugins.install"]).map((binding) => binding.cmd)).toEqual([ + "dialog.plugins.install", + ]) +}) + +test("keybinds accept OpenTUI binding specs", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "tui.json"), + JSON.stringify({ + keybinds: { + command_list: [{ key: "alt+p", preventDefault: false }], + editor_open: { key: { name: "e", ctrl: true }, group: "Explicit" }, + "prompt.autocomplete.next": false, plugin_manager: "ctrl+shift+p", }, }), @@ -483,52 +464,23 @@ test("legacy keybinds transform into semantic keymap sections", async () => { }) const config = await getTuiConfig(tmp.path) - expect(Object.keys(config.keymap.sections)).toEqual([ - "global", - "which_key", - "session", - "prompt", - "autocomplete", - "input", - "dialog_select", - "dialog_actions", - "model", - "permission", - "question", - "plugins", - "home_tips", - ]) - expect(config.keymap.sections.global.find((binding) => binding.cmd === "command.palette.show")?.key).toBe("alt+p") - expect(config.keymap.sections.which_key.find((binding) => binding.cmd === "tui-which-key.toggle")?.key).toBe( - "ctrl+alt+k", - ) - expect(config.keymap.sections.prompt.find((binding) => binding.cmd === "prompt.editor")?.key).toBe("ctrl+e") - expect(config.keymap.sections.autocomplete.find((binding) => binding.cmd === "prompt.autocomplete.next")?.key).toBe( - "ctrl+j", - ) - expect(config.keymap.sections.dialog_actions.find((binding) => binding.cmd === "dialog.action.toggle")?.key).toBe( - "ctrl+t", - ) - expect(config.keymap.sections.model.find((binding) => binding.cmd === "model.dialog.provider")?.key).toBe("ctrl+a") - expect(config.keymap.sections.model.find((binding) => binding.cmd === "model.dialog.favorite")?.key).toBe("ctrl+f") - expect(config.keymap.sections.plugins.find((binding) => binding.cmd === "plugin.dialog.install")?.key).toBe("shift+i") - expect(config.keymap.sections.plugins.find((binding) => binding.cmd === "plugins.list")?.key).toBe("ctrl+shift+p") - expect(config.keymap.pick("plugins", ["plugin.dialog.install"]).map((binding) => binding.cmd)).toEqual([ - "plugin.dialog.install", - ]) - expect((config.keymap.omit("plugins", ["plugin.dialog.install"])[0] as { group?: unknown } | undefined)?.group).toBe( - "Plugins", - ) - expect(config.keymap.omit("plugins", ["plugin.dialog.install"]).map((binding) => binding.cmd)).toEqual([ - "plugins.list", + expect(config.keybinds.get("command.palette.show")).toEqual([ + { key: "alt+p", cmd: "command.palette.show", preventDefault: false, desc: "List available commands" }, ]) + expect(config.keybinds.get("prompt.editor")?.[0]).toMatchObject({ + key: { name: "e", ctrl: true }, + cmd: "prompt.editor", + group: "Explicit", + }) + expect(config.keybinds.get("prompt.autocomplete.next")).toEqual([]) + expect(config.keybinds.get("plugins.list")?.[0]?.key).toBe("ctrl+shift+p") }) wintest("defaults Ctrl+Z to input undo on Windows", async () => { await using tmp = await tmpdir() const config = await getTuiConfig(tmp.path) - expect(config.keybinds?.terminal_suspend).toBe("none") - expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z") + expect(config.keybinds.get("terminal.suspend")).toEqual([]) + expect(config.keybinds.get("input.undo")?.[0]?.key).toBe("ctrl+z,ctrl+-,super+z") }) wintest("keeps explicit input undo overrides on Windows", async () => { @@ -538,8 +490,8 @@ wintest("keeps explicit input undo overrides on Windows", async () => { }, }) const config = await getTuiConfig(tmp.path) - expect(config.keybinds?.terminal_suspend).toBe("none") - expect(config.keybinds?.input_undo).toBe("ctrl+y") + expect(config.keybinds.get("terminal.suspend")).toEqual([]) + expect(config.keybinds.get("input.undo")?.[0]?.key).toBe("ctrl+y") }) wintest("ignores terminal suspend bindings on Windows", async () => { @@ -550,33 +502,29 @@ wintest("ignores terminal suspend bindings on Windows", async () => { }) const config = await getTuiConfig(tmp.path) - expect(config.keybinds?.terminal_suspend).toBe("none") - expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z") + expect(config.keybinds.get("terminal.suspend")).toEqual([]) + expect(config.keybinds.get("input.undo")?.[0]?.key).toBe("ctrl+z,ctrl+-,super+z") }) -test("applies Windows keymap defaults", async () => { +test("applies Windows keybind defaults", async () => { await withPlatform("win32", async () => { await using tmp = await tmpdir() const config = await getTuiConfig(tmp.path) - expect(config.keymap.sections.global.find((binding) => binding.cmd === "terminal.suspend")).toBeUndefined() - expect(config.keymap.sections.input.find((binding) => binding.cmd === "input.undo")?.key).toBe( - "ctrl+z,ctrl+-,super+z", - ) + expect(config.keybinds.get("terminal.suspend")).toEqual([]) + expect(config.keybinds.get("input.undo")?.[0]?.key).toBe("ctrl+z,ctrl+-,super+z") }) }) -test("keeps explicit configured keymap terminal suspend binding on Windows", async () => { +test("ignores explicit keybind terminal suspend binding on Windows", async () => { await withPlatform("win32", async () => { await using tmp = await tmpdir({ init: async (dir) => { await Bun.write( path.join(dir, "tui.json"), JSON.stringify({ - keymap: { - sections: { - global: { "terminal.suspend": "alt+z" }, - }, + keybinds: { + terminal_suspend: "alt+z", }, }), ) @@ -584,21 +532,19 @@ test("keeps explicit configured keymap terminal suspend binding on Windows", asy }) const config = await getTuiConfig(tmp.path) - expect(config.keymap.sections.global.find((binding) => binding.cmd === "terminal.suspend")?.key).toBe("alt+z") + expect(config.keybinds.get("terminal.suspend")).toEqual([]) }) }) -test("keeps explicit configured keymap input undo on Windows", async () => { +test("keeps explicit configured keybind input undo on Windows", async () => { await withPlatform("win32", async () => { await using tmp = await tmpdir({ init: async (dir) => { await Bun.write( path.join(dir, "tui.json"), JSON.stringify({ - keymap: { - sections: { - input: { "input.undo": "ctrl+y" }, - }, + keybinds: { + input_undo: "ctrl+y", }, }), ) @@ -606,7 +552,7 @@ test("keeps explicit configured keymap input undo on Windows", async () => { }) const config = await getTuiConfig(tmp.path) - expect(config.keymap.sections.input.find((binding) => binding.cmd === "input.undo")?.key).toBe("ctrl+y") + expect(config.keybinds.get("input.undo")?.[0]?.key).toBe("ctrl+y") }) }) @@ -655,7 +601,7 @@ test("applies env and file substitutions in tui.json", async () => { }) const config = await getTuiConfig(tmp.path) expect(config.theme).toBe("env-theme") - expect(config.keybinds?.app_exit).toBe("ctrl+q") + expect(config.keybinds.get("app.exit")?.[0]?.key).toBe("ctrl+q") } finally { if (original === undefined) delete process.env.TUI_THEME_TEST else process.env.TUI_THEME_TEST = original diff --git a/packages/opencode/test/fixture/tui-plugin.ts b/packages/opencode/test/fixture/tui-plugin.ts index a4a5aaad60..62a3ae6e6b 100644 --- a/packages/opencode/test/fixture/tui-plugin.ts +++ b/packages/opencode/test/fixture/tui-plugin.ts @@ -1,9 +1,7 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { RGBA, type CliRenderer } from "@opentui/core" import type { HostPluginApi } from "../../src/cli/cmd/tui/plugin/slots" -import { LegacyKeymapTransform } from "../../src/cli/cmd/tui/config/legacy-keymap-transform" -import { ConfigKeybinds } from "../../src/config/keybinds" -import { createTuiResolvedKeymap } from "./tui-runtime" +import { createTuiResolvedConfig } from "./tui-runtime" type Count = { event_add: number @@ -112,11 +110,9 @@ type Opts = { } function tuiConfig(input?: Partial): HostPluginApi["tuiConfig"] { - const keybinds = ConfigKeybinds.Keybinds.parse(input?.keybinds ?? {}) return { + ...createTuiResolvedConfig(), ...input, - keybinds, - keymap: input?.keymap ?? createTuiResolvedKeymap(LegacyKeymapTransform.create(input?.keybinds ?? {})), } } diff --git a/packages/opencode/test/fixture/tui-runtime.ts b/packages/opencode/test/fixture/tui-runtime.ts index d1e4c744b0..64537b6c50 100644 --- a/packages/opencode/test/fixture/tui-runtime.ts +++ b/packages/opencode/test/fixture/tui-runtime.ts @@ -1,45 +1,29 @@ import { spyOn } from "bun:test" import path from "path" -import type { KeyEvent, Renderable } from "@opentui/core" -import { resolveBindingSections, type BindingSectionsConfig } from "@opentui/keymap/extras" +import { createBindingLookup } from "@opentui/keymap/extras" import { TuiConfig } from "../../src/cli/cmd/tui/config/tui" -import { LegacyKeymapTransform } from "../../src/cli/cmd/tui/config/legacy-keymap-transform" -import { ConfigKeybinds } from "../../src/config/keybinds" -import { - KeymapConfig, - KeymapSectionNames, - keymapBindingDefaults, - type KeymapConfigInput, - type KeymapSection, -} from "../../src/cli/cmd/tui/config/tui-schema" +import { TuiKeybind } from "../../src/cli/cmd/tui/config/keybind" type PluginSpec = string | [string, Record] -type ResolvedInput = Omit & { - keybinds?: TuiConfig.Resolved["keybinds"] - keymap?: TuiConfig.Resolved["keymap"] +type ResolvedInput = Omit & { + keybinds?: Partial + leader_timeout?: number } -export function createTuiResolvedKeymap(input: KeymapConfigInput): TuiConfig.Resolved["keymap"] { - const config = KeymapConfig.parse(input) - return { - leader: !config.leader || config.leader === "none" ? "ctrl+x" : config.leader, - leader_timeout: config.leader_timeout, - ...resolveBindingSections, KeymapSection>( - config.sections, - { - sections: KeymapSectionNames, - bindingDefaults: keymapBindingDefaults, - }, - ), - } +export function createTuiResolvedKeybinds(input: Partial = {}): TuiConfig.Resolved["keybinds"] { + const keybinds = TuiKeybind.Keybinds.parse(input) + return createBindingLookup(TuiKeybind.toBindingConfig(keybinds), { + commandMap: TuiKeybind.CommandMap, + bindingDefaults: TuiKeybind.bindingDefaults(), + }) } export function createTuiResolvedConfig(input: ResolvedInput = {}): TuiConfig.Resolved { - const keybinds = input.keybinds ?? ConfigKeybinds.Keybinds.parse({}) + const keybinds = TuiKeybind.Keybinds.parse(input.keybinds ?? {}) return { ...input, - keybinds, - keymap: input.keymap ?? createTuiResolvedKeymap(LegacyKeymapTransform.create(input.keybinds ?? {})), + keybinds: createTuiResolvedKeybinds(keybinds), + leader_timeout: input.leader_timeout ?? 2000, } } diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 0c7a694834..91afa3c883 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -22,9 +22,9 @@ "zod": "catalog:" }, "peerDependencies": { - "@opentui/core": ">=0.2.5", - "@opentui/keymap": ">=0.2.5", - "@opentui/solid": ">=0.2.5" + "@opentui/core": ">=0.2.6", + "@opentui/keymap": ">=0.2.6", + "@opentui/solid": ">=0.2.6" }, "peerDependenciesMeta": { "@opentui/core": { diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index b42bfdaf1f..13bc17f66b 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -18,8 +18,9 @@ import type { import type { CliRenderer, KeyEvent, RGBA, Renderable, SlotMode } from "@opentui/core" import type { Binding, Keymap } from "@opentui/keymap" import { - resolveBindingSections as resolveKeymapBindingSections, - type BindingSectionsConfig, + createBindingLookup as createKeymapBindingLookup, + type BindingConfig, + type CreateBindingLookupOptions, type KeySequenceFormatPart, type SequenceBindingLike, } from "@opentui/keymap/extras" @@ -31,22 +32,21 @@ export { stringifyKeySequence, stringifyKeyStroke } from "@opentui/keymap" export type { Binding, KeyLike, KeySequencePart, KeyStringifyInput, StringifyOptions } from "@opentui/keymap" export { formatCommandBindings, formatKeySequence } from "@opentui/keymap/extras" export type { - BindingSectionsConfig, + BindingConfig, + BindingLookup, BindingValue, + CreateBindingLookupOptions, FormatCommandBindingsOptions, FormatKeySequenceOptions, KeySequenceFormatPart, SequenceBindingLike, } from "@opentui/keymap/extras" -export function resolveBindingSections
( - config: BindingSectionsConfig | undefined, - options: { sections: readonly Section[] }, +export function createBindingLookup( + config: BindingConfig | undefined, + options?: CreateBindingLookupOptions, ) { - return resolveKeymapBindingSections, Section>( - config ?? {}, - options, - ) + return createKeymapBindingLookup(config ?? {}, options) } export type TuiRouteCurrent = @@ -286,17 +286,20 @@ export type TuiState = { mcp: () => ReadonlyArray } -type TuiConfigView = Pick & +type TuiBindingLookupView = { + readonly bindings: ReadonlyArray> + get: (command: string) => ReadonlyArray> + has: (command: string) => boolean + gather: (name: string, commands: readonly string[]) => ReadonlyArray> + pick: (name: string, commands: readonly string[]) => Binding[] + omit: (name: string, commands: readonly string[]) => Binding[] +} + +type TuiConfigView = Pick & NonNullable & { + leader_timeout: number plugin_enabled?: Record - keymap: { - leader: string - leader_timeout: number - sections: Record>> - get: (section: string, cmd: string) => ReadonlyArray> | undefined - pick: (section: string, commands: readonly string[]) => Binding[] - omit: (section: string, commands: readonly string[]) => Binding[] - } + keybinds: TuiBindingLookupView } export type TuiApp = { diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index 39c9974c56..ec96069c70 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -525,26 +525,20 @@ You can also define commands using markdown files in `~/.config/opencode/command --- -### Keymap +### Keybinds -Customize TUI keyboard shortcuts in `tui.json` with `keymap`. +Customize TUI keyboard shortcuts in `tui.json` with `keybinds`. ```json title="tui.json" { "$schema": "https://opencode.ai/tui.json", - "keymap": { - "sections": { - "global": { - "command.palette.show": "ctrl+p" - } - } + "keybinds": { + "command_list": "ctrl+p" } } ``` -`keymap` is merged with built-in defaults, so you only need to configure the shortcuts you want to change. - -The older `keybinds` field is deprecated and only applies when `keymap` is not present. +`keybinds` is merged with built-in defaults, so you only need to configure the shortcuts you want to change. [Learn more here](/docs/keybinds). diff --git a/packages/web/src/content/docs/keybinds.mdx b/packages/web/src/content/docs/keybinds.mdx index 599945428e..f083bb40b0 100644 --- a/packages/web/src/content/docs/keybinds.mdx +++ b/packages/web/src/content/docs/keybinds.mdx @@ -1,100 +1,218 @@ --- title: Keybinds -description: Customize your keyboard shortcuts. +description: Customize your keybinds. --- -OpenCode customizes TUI keyboard shortcuts with `keymap` in `tui.json`. - -The older `keybinds` field is still accepted as a migration fallback, but it is deprecated and will be removed in OpenCode v2.0. If `keymap` is present, OpenCode ignores `keybinds` for shortcut resolution. - -`keymap` is merged with built-in defaults, so you only need to configure the shortcuts you want to change. - ---- - -## Leader key - -OpenCode uses a `leader` key for many shortcuts. This avoids conflicts in your terminal. - -By default, `ctrl+x` is the leader key and leader shortcuts require you to first press the leader key and then the shortcut. For example, to start a new session you first press `ctrl+x` and then press `n`. - -You do not need to use a leader key, but we recommend doing so. - ---- - -## Minimal example +OpenCode has a list of keybinds that you can customize through `tui.json`. ```json title="tui.json" { "$schema": "https://opencode.ai/tui.json", - "keymap": { + "leader_timeout": 2000, + "keybinds": { "leader": "ctrl+x", - "leader_timeout": 2000, - "sections": { - "global": { - "command.palette.show": "ctrl+p", - "session.new": "n", - "session.list": "l" - }, - "session": { - "session.compact": "c", - "session.undo": "u", - "session.redo": "r" - }, - "input": { - "input.submit": "return", - "input.newline": ["shift+return", "ctrl+return", "alt+return", "ctrl+j"] - } - } + "app_exit": "ctrl+c,ctrl+d,q", + "app_debug": "none", + "app_console": "none", + "app_heap_snapshot": "none", + "app_toggle_animations": "none", + "app_toggle_file_context": "none", + "app_toggle_diffwrap": "none", + "app_toggle_paste_summary": "none", + "app_toggle_session_directory_filter": "none", + "command_list": "ctrl+p", + "help_show": "none", + "docs_open": "none", + + "editor_open": "e", + "theme_list": "t", + "theme_switch_mode": "none", + "theme_mode_lock": "none", + "sidebar_toggle": "b", + "scrollbar_toggle": "none", + "status_view": "s", + + "session_export": "x", + "session_copy": "none", + "session_new": "n", + "session_list": "l", + "session_timeline": "g", + "session_fork": "none", + "session_rename": "ctrl+r", + "session_delete": "ctrl+d", + "session_share": "none", + "session_unshare": "none", + "session_interrupt": "escape", + "session_compact": "c", + "session_toggle_timestamps": "none", + "session_toggle_generic_tool_output": "none", + "session_child_first": "down", + "session_child_cycle": "right", + "session_child_cycle_reverse": "left", + "session_parent": "up", + + "stash_delete": "ctrl+d", + "model_provider_list": "ctrl+a", + "model_favorite_toggle": "ctrl+f", + "model_list": "m", + "model_cycle_recent": "f2", + "model_cycle_recent_reverse": "shift+f2", + "model_cycle_favorite": "none", + "model_cycle_favorite_reverse": "none", + "mcp_list": "none", + "provider_connect": "none", + "console_org_switch": "none", + "agent_list": "a", + "agent_cycle": "tab", + "agent_cycle_reverse": "shift+tab", + "variant_cycle": "ctrl+t", + "variant_list": "none", + + "messages_page_up": "pageup,ctrl+alt+b", + "messages_page_down": "pagedown,ctrl+alt+f", + "messages_line_up": "ctrl+alt+y", + "messages_line_down": "ctrl+alt+e", + "messages_half_page_up": "ctrl+alt+u", + "messages_half_page_down": "ctrl+alt+d", + "messages_first": "ctrl+g,home", + "messages_last": "ctrl+alt+g,end", + "messages_next": "none", + "messages_previous": "none", + "messages_last_user": "none", + "messages_copy": "y", + "messages_undo": "u", + "messages_redo": "r", + "messages_toggle_conceal": "h", + "tool_details": "none", + "display_thinking": "none", + + "prompt_submit": "none", + "prompt_editor_context_clear": "none", + "prompt_skills": "none", + "prompt_stash": "none", + "prompt_stash_pop": "none", + "prompt_stash_list": "none", + "workspace_set": "none", + + "input_clear": "ctrl+c", + "input_paste": { + "key": "ctrl+v", + "preventDefault": false + }, + "input_submit": "return", + "input_newline": "shift+return,ctrl+return,alt+return,ctrl+j", + "input_move_left": "left,ctrl+b", + "input_move_right": "right,ctrl+f", + "input_move_up": "up", + "input_move_down": "down", + "input_select_left": "shift+left", + "input_select_right": "shift+right", + "input_select_up": "shift+up", + "input_select_down": "shift+down", + "input_line_home": "ctrl+a", + "input_line_end": "ctrl+e", + "input_select_line_home": "ctrl+shift+a", + "input_select_line_end": "ctrl+shift+e", + "input_visual_line_home": "alt+a", + "input_visual_line_end": "alt+e", + "input_select_visual_line_home": "alt+shift+a", + "input_select_visual_line_end": "alt+shift+e", + "input_buffer_home": "home", + "input_buffer_end": "end", + "input_select_buffer_home": "shift+home", + "input_select_buffer_end": "shift+end", + "input_delete_line": "ctrl+shift+d", + "input_delete_to_line_end": "ctrl+k", + "input_delete_to_line_start": "ctrl+u", + "input_backspace": "backspace,shift+backspace", + "input_delete": "ctrl+d,delete,shift+delete", + "input_undo": "ctrl+-,super+z", + "input_redo": "ctrl+.,super+shift+z", + "input_word_forward": "alt+f,alt+right,ctrl+right", + "input_word_backward": "alt+b,alt+left,ctrl+left", + "input_select_word_forward": "alt+shift+f,alt+shift+right", + "input_select_word_backward": "alt+shift+b,alt+shift+left", + "input_delete_word_forward": "alt+d,alt+delete,ctrl+delete", + "input_delete_word_backward": "ctrl+w,ctrl+backspace,alt+backspace", + "input_select_all": "super+a", + "history_previous": "up", + "history_next": "down", + + "dialog.select.prev": "up,ctrl+p", + "dialog.select.next": "down,ctrl+n", + "dialog.select.page_up": "pageup", + "dialog.select.page_down": "pagedown", + "dialog.select.home": "home", + "dialog.select.end": "end", + "dialog.select.submit": "return", + "dialog.mcp.toggle": "space", + "prompt.autocomplete.prev": "up,ctrl+p", + "prompt.autocomplete.next": "down,ctrl+n", + "prompt.autocomplete.hide": "escape", + "prompt.autocomplete.select": "return", + "prompt.autocomplete.complete": "tab", + "permission.prompt.fullscreen": "ctrl+f", + "plugins.toggle": "space", + "dialog.plugins.install": "shift+i", + + "terminal_suspend": "ctrl+z", + "terminal_title_toggle": "none", + "tips_toggle": "h", + "plugin_manager": "none", + "plugin_install": "none", + + "which_key_toggle": "ctrl+alt+k", + "which_key_layout_toggle": "ctrl+alt+shift+k", + "which_key_pending_toggle": "ctrl+alt+shift+p", + "which_key_group_previous": "ctrl+alt+left,ctrl+alt+[", + "which_key_group_next": "ctrl+alt+right,ctrl+alt+]", + "which_key_scroll_up": "ctrl+alt+up,ctrl+alt+p", + "which_key_scroll_down": "ctrl+alt+down,ctrl+alt+n", + "which_key_page_up": "ctrl+alt+pageup", + "which_key_page_down": "ctrl+alt+pagedown", + "which_key_home": "ctrl+alt+home", + "which_key_end": "ctrl+alt+end" } } ``` ---- +:::note +On Windows, the defaults for `input_undo` and `terminal_suspend` are different: -## Keymap structure - -`keymap.sections` is grouped by semantic area. Each section contains command names and the key sequence that triggers them. - -| Field | Description | -| ---------------- | --------------------------------------------------------------------------------------------------- | -| `leader` | The key used by `` sequences. Defaults to `ctrl+x`. | -| `leader_timeout` | How long OpenCode waits for the next key after the leader key, in milliseconds. Defaults to `2000`. | -| `sections` | A map of TUI areas to command bindings. | +- `input_undo` defaults to `ctrl+z,ctrl+-,super+z` when it is not explicitly configured. The `ctrl+z` binding is added because Windows terminals do not support POSIX suspend. +- `terminal_suspend` is forced to `none` because native Windows terminals do not support POSIX suspend. + ::: --- -## Binding values +## Leader Key -A string can contain one shortcut or multiple comma-separated shortcuts. You can also use an array for multiple shortcuts, or `"none"`/`false` to disable a command. +OpenCode uses a `leader` key for many keybinds. This avoids conflicts in your terminal. -```json title="tui.json" -{ - "$schema": "https://opencode.ai/tui.json", - "keymap": { - "sections": { - "session": { - "session.compact": "none", - "session.export": "x,ctrl+shift+x", - "session.copy": ["y", "ctrl+shift+c"] - } - } - } -} -``` +By default, `ctrl+x` is the leader key and many actions require you to first press the leader key and then the shortcut. For example, to start a new session you first press `ctrl+x` and then press `n`. + +You don't need to use a leader key for your keybinds but we recommend doing so. + +Some navigation keybinds intentionally do not use the leader key by default. For subagent sessions, the defaults are `session_child_first` = `down`, `session_child_cycle` = `right`, `session_child_cycle_reverse` = `left`, and `session_parent` = `up`. + +`leader_timeout` controls how long OpenCode waits for the next key after the leader key. It defaults to `2000` milliseconds. + +--- + +## Binding Values + +A string can contain one shortcut or multiple comma-separated shortcuts. You can also use an array for multiple shortcuts. For advanced cases, use an object with `key`, `event`, `preventDefault`, or `fallthrough`. ```json title="tui.json" { "$schema": "https://opencode.ai/tui.json", - "keymap": { - "sections": { - "prompt": { - "prompt.paste": { - "key": "ctrl+v", - "preventDefault": false - } - } + "keybinds": { + "messages_copy": ["y", "ctrl+shift+c"], + "input_paste": { + "key": "ctrl+v", + "preventDefault": false } } } @@ -102,219 +220,22 @@ For advanced cases, use an object with `key`, `event`, `preventDefault`, or `fal --- -## Complete keymap reference +## Disable Keybind -This example lists the built-in sections, command names, and default fallback bindings. Commands set to `"none"` are available to bind but disabled by default. - -```json title="tui.json" -{ - "$schema": "https://opencode.ai/tui.json", - "keymap": { - "leader": "ctrl+x", - "leader_timeout": 2000, - "sections": { - "global": { - "command.palette.show": "ctrl+p", - "session.list": "l", - "session.new": "n", - "model.list": "m", - "model.cycle_recent": "f2", - "model.cycle_recent_reverse": "shift+f2", - "model.cycle_favorite": "none", - "model.cycle_favorite_reverse": "none", - "agent.list": "a", - "mcp.list": "none", - "agent.cycle": "tab", - "agent.cycle.reverse": "shift+tab", - "variant.cycle": "ctrl+t", - "variant.list": "none", - "provider.connect": "none", - "console.org.switch": "none", - "opencode.status": "s", - "theme.switch": "t", - "theme.switch_mode": "none", - "theme.mode.lock": "none", - "help.show": "none", - "docs.open": "none", - "app.exit": "ctrl+c,ctrl+d,q", - "app.debug": "none", - "app.console": "none", - "app.heap_snapshot": "none", - "app.toggle.animations": "none", - "app.toggle.file_context": "none", - "app.toggle.diffwrap": "none", - "app.toggle.paste_summary": "none", - "app.toggle.session_directory_filter": "none", - "terminal.suspend": "ctrl+z", - "terminal.title.toggle": "none" - }, - "session": { - "session.share": "none", - "session.rename": "ctrl+r", - "session.timeline": "g", - "session.fork": "none", - "session.compact": "c", - "session.unshare": "none", - "session.undo": "u", - "session.redo": "r", - "session.sidebar.toggle": "b", - "session.toggle.conceal": "h", - "session.toggle.timestamps": "none", - "session.toggle.thinking": "none", - "session.toggle.actions": "none", - "session.toggle.scrollbar": "none", - "session.toggle.generic_tool_output": "none", - "session.page.up": "pageup,ctrl+alt+b", - "session.page.down": "pagedown,ctrl+alt+f", - "session.line.up": "ctrl+alt+y", - "session.line.down": "ctrl+alt+e", - "session.half.page.up": "ctrl+alt+u", - "session.half.page.down": "ctrl+alt+d", - "session.first": "ctrl+g,home", - "session.last": "ctrl+alt+g,end", - "session.messages_last_user": "none", - "session.message.next": "none", - "session.message.previous": "none", - "messages.copy": "y", - "session.copy": "none", - "session.export": "x", - "session.child.first": "down", - "session.parent": "up", - "session.child.next": "right", - "session.child.previous": "left" - }, - "prompt": { - "prompt.submit": "none", - "prompt.editor": "e", - "prompt.editor_context.clear": "none", - "prompt.skills": "none", - "prompt.stash": "none", - "prompt.stash.pop": "none", - "prompt.stash.list": "none", - "workspace.set": "none", - "session.interrupt": "escape", - "prompt.clear": "ctrl+c", - "prompt.paste": { - "key": "ctrl+v", - "preventDefault": false - }, - "prompt.history.previous": "up", - "prompt.history.next": "down" - }, - "autocomplete": { - "prompt.autocomplete.prev": "up,ctrl+p", - "prompt.autocomplete.next": "down,ctrl+n", - "prompt.autocomplete.hide": "escape", - "prompt.autocomplete.select": "return", - "prompt.autocomplete.complete": "tab" - }, - "input": { - "input.submit": "return", - "input.newline": "shift+return,ctrl+return,alt+return,ctrl+j", - "input.move.left": "left,ctrl+b", - "input.move.right": "right,ctrl+f", - "input.move.up": "up", - "input.move.down": "down", - "input.select.left": "shift+left", - "input.select.right": "shift+right", - "input.select.up": "shift+up", - "input.select.down": "shift+down", - "input.line.home": "ctrl+a", - "input.line.end": "ctrl+e", - "input.select.line.home": "ctrl+shift+a", - "input.select.line.end": "ctrl+shift+e", - "input.visual.line.home": "alt+a", - "input.visual.line.end": "alt+e", - "input.select.visual.line.home": "alt+shift+a", - "input.select.visual.line.end": "alt+shift+e", - "input.buffer.home": "home", - "input.buffer.end": "end", - "input.select.buffer.home": "shift+home", - "input.select.buffer.end": "shift+end", - "input.delete.line": "ctrl+shift+d", - "input.delete.to.line.end": "ctrl+k", - "input.delete.to.line.start": "ctrl+u", - "input.backspace": "backspace,shift+backspace", - "input.delete": "ctrl+d,delete,shift+delete", - "input.undo": "ctrl+-,super+z", - "input.redo": "ctrl+.,super+shift+z", - "input.word.forward": "alt+f,alt+right,ctrl+right", - "input.word.backward": "alt+b,alt+left,ctrl+left", - "input.select.word.forward": "alt+shift+f,alt+shift+right", - "input.select.word.backward": "alt+shift+b,alt+shift+left", - "input.delete.word.forward": "alt+d,alt+delete,ctrl+delete", - "input.delete.word.backward": "ctrl+w,ctrl+backspace,alt+backspace", - "input.select.all": "super+a" - }, - "dialog_select": { - "dialog.select.prev": "up,ctrl+p", - "dialog.select.next": "down,ctrl+n", - "dialog.select.page_up": "pageup", - "dialog.select.page_down": "pagedown", - "dialog.select.home": "home", - "dialog.select.end": "end", - "dialog.select.submit": "return" - }, - "dialog_actions": { - "dialog.action.toggle": "space", - "dialog.action.delete": "ctrl+d", - "dialog.action.rename": "ctrl+r" - }, - "model": { - "model.dialog.provider": "ctrl+a", - "model.dialog.favorite": "ctrl+f" - }, - "permission": { - "permission.reject.cancel": "ctrl+c,ctrl+d,q", - "permission.prompt.escape": "ctrl+c,ctrl+d,q", - "permission.prompt.fullscreen": "ctrl+f" - }, - "question": { - "question.reject": "ctrl+c,ctrl+d,q", - "question.edit.clear": "ctrl+c" - }, - "plugins": { - "plugins.list": "none", - "plugins.install": "none", - "plugin.dialog.install": "shift+i" - }, - "home_tips": { - "tips.toggle": "h" - } - } - } -} -``` - ---- - -## Legacy keybinds - -`keybinds` is deprecated. It is kept so existing configs continue to work while users migrate to `keymap`. - -Only use `keybinds` when `keymap` is not present. If both fields are set, `keymap` wins and `keybinds` are ignored for shortcut resolution. +You can disable a keybind by adding the key to `tui.json` with a value of `"none"` or `false`. ```json title="tui.json" { "$schema": "https://opencode.ai/tui.json", "keybinds": { - "command_list": "ctrl+p", - "session_new": "n", - "session_compact": "c" + "session_compact": "none" } } ``` -:::note -On native Windows, the defaults for undo and terminal suspend are different for both `keymap` and legacy `keybinds`: - -- `input.undo` defaults to `ctrl+z,ctrl+-,super+z` when it is not explicitly configured (the `ctrl+z` binding is added because Windows terminals do not support POSIX suspend). -- `terminal.suspend` is disabled because native Windows terminals do not support POSIX suspend. - ::: - --- -## Desktop prompt shortcuts +## Desktop Prompt Shortcuts The OpenCode desktop app prompt input supports common Readline/Emacs-style shortcuts for editing text. These are built-in and currently not configurable via `opencode.json`. diff --git a/packages/web/src/content/docs/tui.mdx b/packages/web/src/content/docs/tui.mdx index 99e9aa752b..72d9658d16 100644 --- a/packages/web/src/content/docs/tui.mdx +++ b/packages/web/src/content/docs/tui.mdx @@ -353,14 +353,10 @@ You can customize TUI behavior through `tui.json` (or `tui.jsonc`). { "$schema": "https://opencode.ai/tui.json", "theme": "opencode", - "keymap": { + "leader_timeout": 2000, + "keybinds": { "leader": "ctrl+x", - "leader_timeout": 2000, - "sections": { - "global": { - "command.palette.show": "ctrl+p" - } - } + "command_list": "ctrl+p" }, "scroll_speed": 3, "scroll_acceleration": { @@ -373,13 +369,13 @@ You can customize TUI behavior through `tui.json` (or `tui.jsonc`). This is separate from `opencode.json`, which configures server/runtime behavior. -`keymap` is merged with built-in defaults, so you only need to configure the shortcuts you want to change. +`keybinds` is merged with built-in defaults, so you only need to configure the shortcuts you want to change. ### Options - `theme` - Sets your UI theme. [Learn more](/docs/themes). -- `keymap` - Customizes keyboard shortcuts. [Learn more](/docs/keybinds). -- `keybinds` - Deprecated legacy shortcut config. This only applies when `keymap` is not present. +- `keybinds` - Customizes keyboard shortcuts. [Learn more](/docs/keybinds). +- `leader_timeout` - Controls how long OpenCode waits after the leader key. Defaults to `2000`. - `scroll_acceleration.enabled` - Enable macOS-style scroll acceleration for smooth, natural scrolling. When enabled, scroll speed increases with rapid scrolling gestures and stays precise for slower movements. **This setting takes precedence over `scroll_speed` and overrides it when enabled.** - `scroll_speed` - Controls how fast the TUI scrolls when using scroll commands (minimum: `0.001`, supports decimal values). Defaults to `3`. **Note: This is ignored if `scroll_acceleration.enabled` is set to `true`.** - `diff_style` - Controls diff rendering. `"auto"` adapts to terminal width, `"stacked"` always shows a single-column layout.