From a70d226a14767d4536680b3a301638b867eb6d5a Mon Sep 17 00:00:00 2001 From: Sebastian Herrlinger Date: Wed, 6 May 2026 16:57:14 +0200 Subject: [PATCH 1/7] squash for rebase --- .opencode/plugins/tui-smoke.tsx | 660 +++++++------ .opencode/tui.json | 19 +- bun.lock | 31 +- package.json | 6 +- packages/opencode/package.json | 2 +- packages/opencode/specs/tui-plugins.md | 61 +- packages/opencode/specs/v2/keymappings.md | 26 + packages/opencode/src/cli/cmd/tui/app.tsx | 891 +++++++++--------- .../cli/cmd/tui/component/dialog-command.tsx | 172 ---- .../cmd/tui/component/dialog-go-upsell.tsx | 37 +- .../src/cli/cmd/tui/component/dialog-mcp.tsx | 12 +- .../cli/cmd/tui/component/dialog-model.tsx | 13 +- .../cli/cmd/tui/component/dialog-provider.tsx | 23 +- .../dialog-session-delete-failed.tsx | 24 +- .../cmd/tui/component/dialog-session-list.tsx | 18 +- .../cli/cmd/tui/component/dialog-stash.tsx | 16 +- .../dialog-workspace-unavailable.tsx | 28 +- .../cmd/tui/component/prompt/autocomplete.tsx | 118 ++- .../cli/cmd/tui/component/prompt/index.tsx | 640 +++++++------ .../cli/cmd/tui/component/prompt/traits.ts | 9 +- .../cmd/tui/component/textarea-keybindings.ts | 73 -- .../cmd/tui/config/legacy-keymap-transform.ts | 164 ++++ .../src/cli/cmd/tui/config/tui-schema.ts | 77 +- .../opencode/src/cli/cmd/tui/config/tui.ts | 98 +- .../cli/cmd/tui/context/command-palette.tsx | 163 ++++ .../src/cli/cmd/tui/context/keybind.tsx | 105 --- .../cli/cmd/tui/context/plugin-keybinds.ts | 41 - .../src/cli/cmd/tui/context/tui-config.tsx | 2 +- .../cli/cmd/tui/feature-plugins/home/tips.tsx | 37 +- .../tui/feature-plugins/system/plugins.tsx | 66 +- .../tui/feature-plugins/system/session-v2.tsx | 50 +- packages/opencode/src/cli/cmd/tui/keymap.tsx | 91 ++ .../opencode/src/cli/cmd/tui/plugin/api.tsx | 37 +- .../src/cli/cmd/tui/plugin/runtime.ts | 85 +- .../src/cli/cmd/tui/routes/session/index.tsx | 159 ++-- .../cli/cmd/tui/routes/session/permission.tsx | 146 +-- .../cli/cmd/tui/routes/session/question.tsx | 235 +++-- .../tui/routes/session/subagent-footer.tsx | 22 +- .../src/cli/cmd/tui/ui/dialog-alert.tsx | 21 +- .../src/cli/cmd/tui/ui/dialog-confirm.tsx | 41 +- .../cli/cmd/tui/ui/dialog-export-options.tsx | 70 +- .../src/cli/cmd/tui/ui/dialog-help.tsx | 20 +- .../src/cli/cmd/tui/ui/dialog-prompt.tsx | 16 - .../src/cli/cmd/tui/ui/dialog-select.tsx | 160 +++- .../opencode/src/cli/cmd/tui/ui/dialog.tsx | 55 +- .../opencode/src/cli/cmd/tui/util/scroll.ts | 4 +- .../src/cli/cmd/tui/util/selection.ts | 42 +- packages/opencode/src/config/keybinds.ts | 18 +- packages/opencode/src/util/keybind.ts | 103 -- .../test/cli/tui/keybind-plugin.test.ts | 90 -- .../opencode/test/cli/tui/plugin-add.test.ts | 11 +- .../test/cli/tui/plugin-install.test.ts | 6 +- .../cli/tui/plugin-loader-entrypoint.test.ts | 33 +- .../test/cli/tui/plugin-loader-pure.test.ts | 5 +- .../test/cli/tui/plugin-loader.test.ts | 308 +++++- .../test/cli/tui/plugin-toggle.test.ts | 9 +- packages/opencode/test/fixture/tui-plugin.ts | 66 +- packages/opencode/test/fixture/tui-runtime.ts | 19 +- packages/opencode/test/keybind.test.ts | 421 --------- packages/plugin/package.json | 9 +- packages/plugin/src/tui.ts | 84 +- .../script => script}/upgrade-opentui.ts | 31 +- 62 files changed, 3207 insertions(+), 2892 deletions(-) delete mode 100644 packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx delete mode 100644 packages/opencode/src/cli/cmd/tui/component/textarea-keybindings.ts create mode 100644 packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts create mode 100644 packages/opencode/src/cli/cmd/tui/context/command-palette.tsx delete mode 100644 packages/opencode/src/cli/cmd/tui/context/keybind.tsx delete mode 100644 packages/opencode/src/cli/cmd/tui/context/plugin-keybinds.ts create mode 100644 packages/opencode/src/cli/cmd/tui/keymap.tsx delete mode 100644 packages/opencode/src/util/keybind.ts delete mode 100644 packages/opencode/test/cli/tui/keybind-plugin.test.ts delete mode 100644 packages/opencode/test/keybind.test.ts rename {packages/opencode/script => script}/upgrade-opentui.ts (63%) diff --git a/.opencode/plugins/tui-smoke.tsx b/.opencode/plugins/tui-smoke.tsx index 63f9f331e04d..fc890537ec60 100644 --- a/.opencode/plugins/tui-smoke.tsx +++ b/.opencode/plugins/tui-smoke.tsx @@ -1,37 +1,89 @@ /** @jsxImportSource @opentui/solid */ -import { useKeyboard, useTerminalDimensions, type JSX } from "@opentui/solid" -import { RGBA, VignetteEffect } from "@opentui/core" -import type { - TuiKeybindSet, - TuiPlugin, - TuiPluginApi, - TuiPluginMeta, - TuiPluginModule, - TuiSlotPlugin, -} from "@opencode-ai/plugin/tui" +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 type { TuiPlugin, TuiPluginApi, TuiPluginMeta, TuiPluginModule, TuiSlotPlugin } from "@opencode-ai/plugin/tui" const tabs = ["overview", "counter", "help"] -const bind = { - modal: "ctrl+shift+m", - screen: "ctrl+shift+o", - home: "escape,ctrl+h", - left: "left,h", - right: "right,l", - up: "up,k", - down: "down,j", - alert: "a", - confirm: "c", - prompt: "p", - select: "s", - modal_accept: "enter,return", - modal_close: "escape", - dialog_close: "escape", - local: "x", - local_push: "enter,return", - local_close: "q,backspace", - host: "z", +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> } +type SmokeOptions = { + enabled?: boolean + label?: unknown + route?: unknown + vignette?: unknown + keymap?: SmokeKeymap +} + +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 + const pick = (value: unknown, fallback: string) => { if (typeof value !== "string") return fallback if (!value.trim()) return fallback @@ -43,16 +95,11 @@ const num = (value: unknown, fallback: number) => { return value } -const rec = (value: unknown) => { - if (!value || typeof value !== "object" || Array.isArray(value)) return - return Object.fromEntries(Object.entries(value)) -} - type Cfg = { label: string route: string vignette: number - keybinds: Record | undefined + keymap: SmokeKeymap | undefined } type Route = { @@ -69,12 +116,12 @@ type State = { local: number } -const cfg = (options: Record | undefined) => { +const cfg = (options: SmokeOptions | undefined) => { return { label: pick(options?.label, "smoke"), route: pick(options?.route, "workspace-smoke"), vignette: Math.max(0, num(options?.vignette, 0.35)), - keybinds: rec(options?.keybinds), + keymap: options?.keymap, } } @@ -85,7 +132,25 @@ const names = (input: Cfg) => { } } -type Keys = TuiKeybindSet +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, + } +} + +type Keys = ReturnType + const ui = { panel: "#1d1d1d", border: "#4a4a4a", @@ -292,125 +357,161 @@ const Screen = (props: { } const pop = (base?: State) => { const next = base ?? current(props.api, props.route) - const local = Math.max(0, next.local - 1) - set(local, next) + set(Math.max(0, next.local - 1), next) } const show = () => { setTimeout(() => { open() }, 0) } - useKeyboard((evt) => { - if (props.api.route.current.name !== props.route.screen) return - const next = current(props.api, props.route) - if (props.api.ui.dialog.open) { - if (props.keys.match("dialog_close", evt)) { - evt.preventDefault() - evt.stopPropagation() - props.api.ui.dialog.clear() - return - } - return - } - - if (next.local > 0) { - if (evt.name === "escape" || props.keys.match("local_close", evt)) { - evt.preventDefault() - evt.stopPropagation() - pop(next) - return - } - - if (props.keys.match("local_push", evt)) { - evt.preventDefault() - evt.stopPropagation() - push(next) - return - } - return - } - - if (props.keys.match("home", evt)) { - evt.preventDefault() - evt.stopPropagation() - props.api.route.navigate("home") - return - } - - if (props.keys.match("left", evt)) { - evt.preventDefault() - evt.stopPropagation() - props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab - 1 + tabs.length) % tabs.length }) - return - } - - if (props.keys.match("right", evt)) { - evt.preventDefault() - evt.stopPropagation() - props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab + 1) % tabs.length }) - return - } - - if (props.keys.match("up", evt)) { - evt.preventDefault() - evt.stopPropagation() - props.api.route.navigate(props.route.screen, { ...next, count: next.count + 1 }) - return - } - - if (props.keys.match("down", evt)) { - evt.preventDefault() - evt.stopPropagation() - props.api.route.navigate(props.route.screen, { ...next, count: next.count - 1 }) - return - } - - if (props.keys.match("modal", evt)) { - evt.preventDefault() - evt.stopPropagation() - props.api.route.navigate(props.route.modal, next) - return - } - - if (props.keys.match("local", evt)) { - evt.preventDefault() - evt.stopPropagation() - open() - return - } - - if (props.keys.match("host", evt)) { - evt.preventDefault() - evt.stopPropagation() - host(props.api, props.input, skin) - return - } - - if (props.keys.match("alert", evt)) { - evt.preventDefault() - evt.stopPropagation() - warn(props.api, props.route, next) - return - } - - if (props.keys.match("confirm", evt)) { - evt.preventDefault() - evt.stopPropagation() - check(props.api, props.route, next) - return - } - - if (props.keys.match("prompt", evt)) { - evt.preventDefault() - evt.stopPropagation() - entry(props.api, props.route, next) - return - } - - if (props.keys.match("select", evt)) { - evt.preventDefault() - evt.stopPropagation() - picker(props.api, props.route, next) + const screenActive = () => props.api.route.current.name === props.route.screen + + useBindings(() => ({ + enabled: () => screenActive() && props.api.ui.dialog.open, + commands: [ + { + name: command.dialog_close, + run() { + props.api.ui.dialog.clear() + }, + }, + ], + bindings: props.keys.sections.dialog, + })) + + useBindings(() => ({ + enabled: () => screenActive() && !props.api.ui.dialog.open && current(props.api, props.route).local > 0, + commands: [ + { + name: command.local_push, + run() { + push(current(props.api, props.route)) + }, + }, + { + name: command.local_pop, + run() { + pop(current(props.api, props.route)) + }, + }, + ], + bindings: props.keys.sections.local, + })) + + useBindings(() => ({ + enabled: () => screenActive() && !props.api.ui.dialog.open && current(props.api, props.route).local === 0, + commands: [ + { + name: command.screen_home, + run() { + props.api.route.navigate("home") + }, + }, + { + name: command.screen_left, + run() { + const next = current(props.api, props.route) + props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab - 1 + tabs.length) % tabs.length }) + }, + }, + { + name: command.screen_right, + run() { + const next = current(props.api, props.route) + props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab + 1) % tabs.length }) + }, + }, + { + name: command.screen_up, + run() { + const next = current(props.api, props.route) + props.api.route.navigate(props.route.screen, { ...next, count: next.count + 1 }) + }, + }, + { + name: command.screen_down, + run() { + const next = current(props.api, props.route) + props.api.route.navigate(props.route.screen, { ...next, count: next.count - 1 }) + }, + }, + { + name: command.screen_modal, + run() { + props.api.route.navigate(props.route.modal, current(props.api, props.route)) + }, + }, + { + name: command.screen_local, + run() { + open() + }, + }, + { + name: command.screen_host, + run() { + host(props.api, props.input, skin) + }, + }, + { + name: command.screen_alert, + run() { + warn(props.api, props.route, current(props.api, props.route)) + }, + }, + { + name: command.screen_confirm, + run() { + check(props.api, props.route, current(props.api, props.route)) + }, + }, + { + name: command.screen_prompt, + run() { + entry(props.api, props.route, current(props.api, props.route)) + }, + }, + { + name: command.screen_select, + run() { + picker(props.api, props.route, current(props.api, props.route)) + }, + }, + ], + bindings: props.keys.sections.screen, + })) + const shortcuts = useKeymapSelector((keymap) => { + const bindings = keymap.getCommandBindings({ + visibility: "registered", + commands: [ + command.screen_home, + command.screen_up, + command.screen_down, + command.screen_modal, + command.screen_alert, + command.screen_confirm, + command.screen_prompt, + command.screen_select, + command.screen_local, + command.screen_host, + command.local_push, + command.local_pop, + ], + }) + + return { + screen_home: props.api.keys.formatBindings(bindings.get(command.screen_home)) ?? "", + screen_up: props.api.keys.formatBindings(bindings.get(command.screen_up)) ?? "", + screen_down: props.api.keys.formatBindings(bindings.get(command.screen_down)) ?? "", + screen_modal: props.api.keys.formatBindings(bindings.get(command.screen_modal)) ?? "", + screen_alert: props.api.keys.formatBindings(bindings.get(command.screen_alert)) ?? "", + screen_confirm: props.api.keys.formatBindings(bindings.get(command.screen_confirm)) ?? "", + screen_prompt: props.api.keys.formatBindings(bindings.get(command.screen_prompt)) ?? "", + screen_select: props.api.keys.formatBindings(bindings.get(command.screen_select)) ?? "", + screen_local: props.api.keys.formatBindings(bindings.get(command.screen_local)) ?? "", + screen_host: props.api.keys.formatBindings(bindings.get(command.screen_host)) ?? "", + local_push: props.api.keys.formatBindings(bindings.get(command.local_push)) ?? "", + local_pop: props.api.keys.formatBindings(bindings.get(command.local_pop)) ?? "", } }) @@ -430,7 +531,7 @@ const Screen = (props: { {props.input.label} screen plugin route - {props.keys.print("home")} home + {shortcuts().screen_home} home @@ -477,7 +578,7 @@ const Screen = (props: { Counter: {value.count} - {props.keys.print("up")} / {props.keys.print("down")} change value + {shortcuts().screen_up} / {shortcuts().screen_down} change value ) : null} @@ -485,17 +586,16 @@ const Screen = (props: { {value.tab === 2 ? ( - {props.keys.print("modal")} modal | {props.keys.print("alert")} alert | {props.keys.print("confirm")}{" "} - confirm | {props.keys.print("prompt")} prompt | {props.keys.print("select")} select + {shortcuts().screen_modal} modal | {shortcuts().screen_alert} alert | {shortcuts().screen_confirm}{" "} + confirm | {shortcuts().screen_prompt} prompt | {shortcuts().screen_select} select - {props.keys.print("local")} local stack | {props.keys.print("host")} host stack + {shortcuts().screen_local} local stack | {shortcuts().screen_host} host stack - local open: {props.keys.print("local_push")} push nested · esc or {props.keys.print("local_close")}{" "} - close + local open: {shortcuts().local_push} push nested · {shortcuts().local_pop} close - {props.keys.print("home")} returns home + {shortcuts().screen_home} returns home ) : null} @@ -548,7 +648,7 @@ const Screen = (props: { Plugin-owned stack depth: {value.local} - {props.keys.print("local_push")} push nested · {props.keys.print("local_close")} pop/close + {shortcuts().local_push} push nested · {shortcuts().local_pop} pop/close @@ -571,20 +671,35 @@ const Modal = (props: { const value = parse(props.params) const skin = tone(props.api) - useKeyboard((evt) => { - if (props.api.route.current.name !== props.route.modal) return - - if (props.keys.match("modal_accept", evt)) { - evt.preventDefault() - evt.stopPropagation() - props.api.route.navigate(props.route.screen, { ...value, source: "modal" }) - return - } - - if (props.keys.match("modal_close", evt)) { - evt.preventDefault() - evt.stopPropagation() - props.api.route.navigate("home") + useBindings(() => ({ + enabled: () => props.api.route.current.name === props.route.modal, + commands: [ + { + name: command.modal_accept, + run() { + props.api.route.navigate(props.route.screen, { ...parse(props.params), source: "modal" }) + }, + }, + { + name: command.modal_close, + run() { + props.api.route.navigate("home") + }, + }, + ], + bindings: props.keys.sections.modal, + })) + const shortcuts = useKeymapSelector((keymap) => { + const bindings = keymap.getCommandBindings({ + visibility: "registered", + commands: [command.modal, command.screen, command.modal_accept, command.modal_close], + }) + + return { + modal: props.api.keys.formatBindings(bindings.get(command.modal)) ?? "", + screen: props.api.keys.formatBindings(bindings.get(command.screen)) ?? "", + modal_accept: props.api.keys.formatBindings(bindings.get(command.modal_accept)) ?? "", + modal_close: props.api.keys.formatBindings(bindings.get(command.modal_close)) ?? "", } }) @@ -595,10 +710,10 @@ const Modal = (props: { {props.input.label} modal - {props.keys.print("modal")} modal command - {props.keys.print("screen")} screen command + {shortcuts().modal} modal command + {shortcuts().screen} screen command - {props.keys.print("modal_accept")} opens screen · {props.keys.print("modal_close")} closes + {shortcuts().modal_accept} opens screen · {shortcuts().modal_close} closes [ const reg = (api: TuiPluginApi, input: Cfg, keys: Keys) => { const route = names(input) - api.command.register(() => [ - { - title: `${input.label} modal`, - value: "plugin.smoke.modal", - keybind: keys.get("modal"), - category: "Plugin", - slash: { - name: "smoke", - }, - onSelect: () => { - api.route.navigate(route.modal, { source: "command" }) - }, - }, - { - title: `${input.label} screen`, - value: "plugin.smoke.screen", - keybind: keys.get("screen"), - category: "Plugin", - slash: { - name: "smoke-screen", + api.keymap.registerLayer({ + commands: [ + { + name: command.modal, + title: `${input.label} modal`, + category: "Plugin", + namespace: "palette", + slashName: "smoke", + run() { + api.route.navigate(route.modal, { source: "command" }) + }, }, - onSelect: () => { - api.route.navigate(route.screen, { source: "command", tab: 0, count: 0 }) + { + name: command.screen, + title: `${input.label} screen`, + category: "Plugin", + namespace: "palette", + slashName: "smoke-screen", + run() { + api.route.navigate(route.screen, { source: "command", tab: 0, count: 0 }) + }, }, - }, - { - title: `${input.label} alert dialog`, - value: "plugin.smoke.alert", - category: "Plugin", - slash: { - name: "smoke-alert", - }, - onSelect: () => { - warn(api, route, current(api, route)) - }, - }, - { - title: `${input.label} confirm dialog`, - value: "plugin.smoke.confirm", - category: "Plugin", - slash: { - name: "smoke-confirm", + { + name: command.alert, + title: `${input.label} alert dialog`, + category: "Plugin", + namespace: "palette", + slashName: "smoke-alert", + run() { + warn(api, route, current(api, route)) + }, }, - onSelect: () => { - check(api, route, current(api, route)) + { + name: command.confirm, + title: `${input.label} confirm dialog`, + category: "Plugin", + namespace: "palette", + slashName: "smoke-confirm", + run() { + check(api, route, current(api, route)) + }, }, - }, - { - title: `${input.label} prompt dialog`, - value: "plugin.smoke.prompt", - category: "Plugin", - slash: { - name: "smoke-prompt", + { + name: command.prompt, + title: `${input.label} prompt dialog`, + category: "Plugin", + namespace: "palette", + slashName: "smoke-prompt", + run() { + entry(api, route, current(api, route)) + }, }, - onSelect: () => { - entry(api, route, current(api, route)) + { + name: command.select, + title: `${input.label} select dialog`, + category: "Plugin", + namespace: "palette", + slashName: "smoke-select", + run() { + picker(api, route, current(api, route)) + }, }, - }, - { - title: `${input.label} select dialog`, - value: "plugin.smoke.select", - category: "Plugin", - slash: { - name: "smoke-select", + { + name: command.host, + title: `${input.label} host overlay`, + category: "Plugin", + namespace: "palette", + slashName: "smoke-host", + run() { + host(api, input, tone(api)) + }, }, - onSelect: () => { - picker(api, route, current(api, route)) + { + name: command.home, + title: `${input.label} go home`, + category: "Plugin", + namespace: "palette", + enabled: () => api.route.current.name !== "home", + run() { + api.route.navigate("home") + }, }, - }, - { - title: `${input.label} host overlay`, - value: "plugin.smoke.host", - category: "Plugin", - slash: { - name: "smoke-host", + { + name: command.toast, + title: `${input.label} toast`, + category: "Plugin", + namespace: "palette", + run() { + api.ui.toast({ + variant: "info", + title: "Smoke", + message: "Plugin toast works", + duration: 2000, + }) + }, }, - onSelect: () => { - host(api, input, tone(api)) - }, - }, - { - title: `${input.label} go home`, - value: "plugin.smoke.home", - category: "Plugin", - enabled: api.route.current.name !== "home", - onSelect: () => { - api.route.navigate("home") - }, - }, - { - title: `${input.label} toast`, - value: "plugin.smoke.toast", - category: "Plugin", - onSelect: () => { - api.ui.toast({ - variant: "info", - title: "Smoke", - message: "Plugin toast works", - duration: 2000, - }) - }, - }, - ]) + ], + bindings: keys.sections.global, + }) } const tui: TuiPlugin = async (api, options, meta) => { - if (options?.enabled === false) return + const input = options as SmokeOptions | undefined + if (input?.enabled === false) return await api.theme.install("./smoke-theme.json") api.theme.set("smoke-theme") - const value = cfg(options ?? undefined) + const value = cfg(input) const route = names(value) - const keys = api.keybind.create(bind, value.keybinds) + const keys = createKeys(value.keymap) 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 1eee01b30220..e795209d9c65 100644 --- a/.opencode/tui.json +++ b/.opencode/tui.json @@ -6,11 +6,20 @@ { "enabled": false, "label": "workspace", - "keybinds": { - "modal": "ctrl+alt+m", - "screen": "ctrl+alt+o", - "home": "escape,ctrl+shift+h", - "dialog_close": "escape,q" + "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" + } + } } } ] diff --git a/bun.lock b/bun.lock index 77ad4d982fea..521c3649fef7 100644 --- a/bun.lock +++ b/bun.lock @@ -380,6 +380,7 @@ "@opentelemetry/sdk-trace-base": "2.6.1", "@opentelemetry/sdk-trace-node": "2.6.1", "@opentui/core": "catalog:", + "@opentui/keymap": "catalog:", "@opentui/solid": "catalog:", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", @@ -478,6 +479,7 @@ }, "devDependencies": { "@opentui/core": "catalog:", + "@opentui/keymap": "catalog:", "@opentui/solid": "catalog:", "@tsconfig/node22": "catalog:", "@types/node": "catalog:", @@ -485,11 +487,13 @@ "typescript": "catalog:", }, "peerDependencies": { - "@opentui/core": ">=0.2.2", - "@opentui/solid": ">=0.2.2", + "@opentui/core": ">=0.2.3", + "@opentui/keymap": ">=0.2.3", + "@opentui/solid": ">=0.2.3", }, "optionalPeers": [ "@opentui/core", + "@opentui/keymap", "@opentui/solid", ], }, @@ -664,8 +668,9 @@ "@npmcli/arborist": "9.4.0", "@octokit/rest": "22.0.0", "@openauthjs/openauth": "0.0.0-20250322224806", - "@opentui/core": "0.2.2", - "@opentui/solid": "0.2.2", + "@opentui/core": "0.2.3", + "@opentui/keymap": "0.2.3", + "@opentui/solid": "0.2.3", "@pierre/diffs": "1.1.0-beta.18", "@playwright/test": "1.59.1", "@sentry/solid": "10.36.0", @@ -1590,21 +1595,23 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], - "@opentui/core": ["@opentui/core@0.2.2", "", { "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.2", "@opentui/core-darwin-x64": "0.2.2", "@opentui/core-linux-arm64": "0.2.2", "@opentui/core-linux-x64": "0.2.2", "@opentui/core-win32-arm64": "0.2.2", "@opentui/core-win32-x64": "0.2.2" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-wxg1CD58SVrowu+WgbhZNi3UP/wWxPio2Kj2IeTjomoIE+6EXLxR8eCCxHYVuQUd9E4fknrKkY5HmiSsp6oPow=="], + "@opentui/core": ["@opentui/core@0.2.3", "", { "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.3", "@opentui/core-darwin-x64": "0.2.3", "@opentui/core-linux-arm64": "0.2.3", "@opentui/core-linux-x64": "0.2.3", "@opentui/core-win32-arm64": "0.2.3", "@opentui/core-win32-x64": "0.2.3" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-46YK/zF8NdpbaGzvo8zo+w8d6VFZTJpvVU+607SBgWE6yQDyDiyk0fk3TaJ6KwP9Fq0J1sALv0o2Q+AvXuXVcw=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tY5n3ZRQx+b0kyhQJJLsyJMeZ+0w4FV37YZc/Qqv3qvOqE9kZPw/7adR77FYwWDm/7fax94mLMrR8Y5bKUkDmw=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-51E6eWJ/XMbq9grSwdy93kR29cC9sm4vt3mF/aQBQA0Usu7TwrRQNs7Pspttm6fdjVF9gRlBJ0ICdLe0gmjLQg=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-W/R7OnqY30FXcTG0tiP2JkQFmgtYbIte5afQ5PC12TliRoee1RqG3iCG6kY1jxW+3Vg6jge88uiSjUEDpeV2gA=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-VZZlY388H9TxVlSvR6u/Pw5ZHW7nFVTAgGHxWGDKmaxXqqQnxGaXHm/hY+PpTxhEEx36QkStqv3nC5E1nVQMIA=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-1pzTYFEZauYuw6AGycw2TYGtAlZVGjuUtSdxH1fP51kBPS3oVWduUY2j7GKREz3SU5NulvO2Wc6HWsm3feMqwQ=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-vJgZaP2nkIx7mFTSIZA1ddfrtDQ2FDmr1BccRcf6WjyjTGwoAQyw5J6EO4BcTrcIoWeXTKJjtNd1pv8qs7eROQ=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-ucVwUtUYeOYGVFPBLbPoxzbrPdhD0PDyKNQ2X4n1AJ9jlQX4gqBZRcXMEF8hiXDjFxsikZwef7De0ciCcWvAMg=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-7yaOvaaOPfgsVDIZjPiudd7BdVZ+6qWI1qYHvKI4HyJaOh77a2Zrqi+rYPWdhd8Cw3mS2/l5UslBu1slZEw0yA=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-MPhYdJNdxmC5Bqsq6sis/+VkjRgkEjm+bQ1Tl++NSKLuiTU32Re0ImcZlgHbe+LZtZoGMZHVSgZlkGd3oYXO2g=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-KWWQrqZSmaMRNfGucmPHH3pAy5ddJahopbGXGGKrhFZhFGnPNVg5KWL2noNtbNpcakwjOchgf8BcULuMGmD8Dw=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-19BroLfn2h0RDYfJS5o96Fc8kYCDhRBcseIXtHIkoKIsKMxx62KiDLo/byVye6rp+yQRRB7Xkd2uWqsbdiWo9w=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.3", "", { "os": "win32", "cpu": "x64" }, "sha512-eOqo1OI4ZT/rqZ3u2e96SRm+dolUAodBxa+LyPIx3wM7AItMBM4WzXUgdnaWMQWTHGLoYZCqIXitqVtDoqUMYA=="], - "@opentui/solid": ["@opentui/solid@0.2.2", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.2", "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-ZBVfCoVAhcUGQWPAWOTdzuVldMaRkuPpCu4U1VZCqmIw9DtbCuiVr0WnDocDxKhJLbTu8bl3qEWtVCf6lTSi3w=="], + "@opentui/keymap": ["@opentui/keymap@0.2.3", "", { "dependencies": { "@opentui/core": "0.2.3" }, "peerDependencies": { "@opentui/react": "0.2.3", "@opentui/solid": "0.2.3", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-09lF09VBdJCz/OuPs2VNfhh+U7KoGH64R0K7E1O+VpNGWFdl37QbgelJTEEvvBT8IC4OXm0owRTry59WBxIJIg=="], + + "@opentui/solid": ["@opentui/solid@0.2.3", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.3", "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-Qah/I26uPH3uJYMziPj8EI3yuUe1lDLoj3QRe2OBptIeLbUsCF9SDiN7DZtfMvdUCozezPYEiQUA4ppzwoUrEg=="], "@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 9d9207c5ea3e..49d87ef3c385 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "dev:storybook": "bun --cwd packages/storybook storybook", "lint": "oxlint", "typecheck": "bun turbo typecheck", + "upgrade-opentui": "bun run script/upgrade-opentui.ts", "postinstall": "bun run --cwd packages/opencode fix-node-pty", "prepare": "husky", "random": "echo 'Random script'", @@ -34,8 +35,9 @@ "@types/cross-spawn": "6.0.6", "@octokit/rest": "22.0.0", "@hono/zod-validator": "0.4.2", - "@opentui/core": "0.2.2", - "@opentui/solid": "0.2.2", + "@opentui/core": "0.2.3", + "@opentui/keymap": "0.2.3", + "@opentui/solid": "0.2.3", "ulid": "3.0.1", "@kobalte/core": "0.13.11", "@types/luxon": "3.7.1", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index db42557616fe..a5db07351440 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -11,7 +11,6 @@ "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", "build": "bun run script/build.ts", "fix-node-pty": "bun run script/fix-node-pty.ts", - "upgrade-opentui": "bun run script/upgrade-opentui.ts", "dev": "bun run --conditions=browser ./src/index.ts", "dev:temporary": "bun run --conditions=browser ./src/temporary.ts", "db": "bun drizzle-kit" @@ -125,6 +124,7 @@ "@opentelemetry/sdk-trace-base": "2.6.1", "@opentelemetry/sdk-trace-node": "2.6.1", "@opentui/core": "catalog:", + "@opentui/keymap": "catalog:", "@opentui/solid": "catalog:", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", diff --git a/packages/opencode/specs/tui-plugins.md b/packages/opencode/specs/tui-plugins.md index 943125b79c61..1a337a60c836 100644 --- a/packages/opencode/specs/tui-plugins.md +++ b/packages/opencode/specs/tui-plugins.md @@ -53,13 +53,21 @@ Minimal module shape: import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui" const tui: TuiPlugin = async (api, options, meta) => { - api.command.register(() => [ - { - title: "Demo", - value: "demo.open", - onSelect: () => api.route.navigate("demo"), - }, - ]) + api.keymap.registerLayer({ + commands: [ + { + name: "demo.open", + title: "Demo", + category: "Plugin", + namespace: "palette", + slashName: "demo", + run() { + api.route.navigate("demo") + }, + }, + ], + bindings: [{ key: "ctrl+shift+m", cmd: "demo.open", desc: "Open demo" }], + }) api.route.register([ { @@ -194,10 +202,10 @@ That is what makes local config-scoped plugins able to import `@opencode-ai/plug Top-level API groups exposed to `tui(api, options, meta)`: - `api.app.version` -- `api.command.register(cb)` / `api.command.trigger(value)` / `api.command.show()` +- `api.keys.formatSequence(parts)`, `formatBindings(bindings)` +- `api.keymap` - `api.route.register(routes)` / `api.route.navigate(name, params?)` / `api.route.current` - `api.ui.Dialog`, `DialogAlert`, `DialogConfirm`, `DialogPrompt`, `DialogSelect`, `Slot`, `Prompt`, `ui.toast`, `ui.dialog` -- `api.keybind.match`, `print`, `create` - `api.tuiConfig` - `api.kv.get`, `set`, `ready` - `api.state` @@ -209,23 +217,23 @@ Top-level API groups exposed to `tui(api, options, meta)`: - `api.plugins.list()`, `activate(id)`, `deactivate(id)`, `add(spec)`, `install(spec, options?)` - `api.lifecycle.signal`, `api.lifecycle.onDispose(fn)` -### Commands +### Keymap -`api.command.register` returns an unregister function. Command rows support: +- `api.keymap` exposes the raw `Keymap` instance from the host. +- The host already installs the default OpenTUI bundle (`default keys`, metadata fields, and enabled fields) plus OpenCode's comma bindings, leader token, base layout fallback, pending-sequence helpers, and managed textarea layer. +- Register commands with `api.keymap.registerLayer({ commands: [...] })`. +- Register key bindings with `bindings: [{ key, cmd, desc }]` in the same layer or a separate layer. +- Use `api.keymap.acquireResource(...)` for shared plugin addon setup that should ref-count against the host keymap. +- 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. -- `title`, `value` -- `description`, `category` -- `keybind` -- `suggested`, `hidden`, `enabled` -- `slash: { name, aliases? }` -- `onSelect` +### Keys -Command behavior: - -- Registrations are reactive. -- Later registrations win for duplicate `value` and for keybind handling. -- Hidden commands are removed from the command dialog and slash list, but still respond to keybinds and `command.trigger(value)` if `enabled !== false`. -- `api.command.show()` opens the host command dialog directly. +- `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`. ### Routes @@ -252,13 +260,6 @@ Command behavior: - `setSize("medium" | "large" | "xlarge")` - readonly `size`, `depth`, `open` -### Keybinds - -- `api.keybind.match(key, evt)` and `print(key)` use the host keybind parser/printer. -- `api.keybind.create(defaults, overrides?)` builds a plugin-local keybind set. -- Only missing, blank, or non-string overrides are ignored. Key syntax is not validated. -- Returned keybind set exposes `all`, `get(name)`, `match(name, evt)`, `print(name)`. - ### KV, state, client, events - `api.kv` is the shared app KV store backed by `state/kv.json`. It is not plugin-namespaced. diff --git a/packages/opencode/specs/v2/keymappings.md b/packages/opencode/specs/v2/keymappings.md index 5b23db795493..30a298eee457 100644 --- a/packages/opencode/specs/v2/keymappings.md +++ b/packages/opencode/specs/v2/keymappings.md @@ -8,3 +8,29 @@ Make it `keymappings`, closer to neovim. Can be layered like `abc`. Comm _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/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index ea742f699708..d1cbf7c0a019 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -1,4 +1,5 @@ -import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" +import { render, TimeToFirstDraw, useRenderer, useTerminalDimensions } from "@opentui/solid" +import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" import * as Clipboard from "@tui/util/clipboard" import * as Selection from "@tui/util/selection" import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core" @@ -11,6 +12,7 @@ import { ErrorBoundary, createSignal, onMount, + onCleanup, batch, Show, on, @@ -36,11 +38,9 @@ import { DialogMcp } from "@tui/component/dialog-mcp" import { DialogStatus } from "@tui/component/dialog-status" import { DialogThemeList } from "@tui/component/dialog-theme-list" import { DialogHelp } from "./ui/dialog-help" -import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command" import { DialogAgent } from "@tui/component/dialog-agent" import { DialogSessionList } from "@tui/component/dialog-session-list" import { DialogConsoleOrg } from "@tui/component/dialog-console-org" -import { KeybindProvider, useKeybind } from "@tui/context/keybind" import { ThemeProvider, useTheme } from "@tui/context/theme" import { Home } from "@tui/routes/home" import { Session } from "@tui/routes/session" @@ -60,15 +60,17 @@ import open from "open" import { PromptRefProvider, usePromptRef } from "./context/prompt" import { TuiConfigProvider, useTuiConfig } from "./context/tui-config" import { TuiConfig } from "@/cli/cmd/tui/config/tui" -import { createTuiApi } from "@/cli/cmd/tui/plugin/api" import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime" +import { createTuiApi } from "@/cli/cmd/tui/plugin/api" import type { RouteMap } from "@/cli/cmd/tui/plugin/api" import { FormatError, FormatUnknownError } from "@/cli/error" +import { CommandPaletteProvider, useCommandPalette } from "./context/command-palette" +import { OpencodeKeymapProvider, registerOpencodeKeymap, useBindings, useOpencodeKeymap } from "./keymap" import type { EventSource } from "./context/sdk" import { DialogVariant } from "./component/dialog-variant" -function rendererConfig(_config: TuiConfig.Info): CliRendererConfig { +function rendererConfig(_config: TuiConfig.Resolved): CliRendererConfig { const mouseEnabled = !Flag.OPENCODE_DISABLE_MOUSE && (_config.mouse ?? true) return { @@ -111,7 +113,7 @@ function errorMessage(error: unknown) { export function tui(input: { url: string args: Args - config: TuiConfig.Info + config: TuiConfig.Resolved onSnapshot?: () => Promise directory?: string fetch?: typeof fetch @@ -130,6 +132,7 @@ export function tui(input: { } const onBeforeExit = async () => { + offKeymap() await TuiPluginRuntime.dispose() } @@ -138,6 +141,9 @@ export function tui(input: { void renderer.getPalette({ size: 16 }).catch(() => undefined) const mode = (await renderer.waitForThemeMode(1000)) ?? "dark" + const keymap = createDefaultOpenTuiKeymap(renderer) + const offKeymap = registerOpencodeKeymap(keymap, renderer, input.config) + await render(() => { return ( )} > - - - - - - - - - - - - - + + + + + + + + + + + + + - + @@ -185,22 +191,22 @@ export function tui(input: { - + - - - - - - - - - - - - - + + + + + + + + + + + + + ) }, renderer) @@ -209,14 +215,17 @@ 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() const dialog = useDialog() const local = useLocal() const kv = useKV() - const command = useCommandDialog() - const keybind = useKeybind() + const command = useCommandPalette() + const keymap = useOpencodeKeymap() const event = useEvent() const sdk = useSDK() const toast = useToast() @@ -233,10 +242,9 @@ function App(props: { onSnapshot?: () => Promise }) { } const api = createTuiApi({ - command, tuiConfig, dialog, - keybind, + keymap, kv, route, routes, @@ -260,40 +268,16 @@ function App(props: { onSnapshot?: () => Promise }) { setReady(true) }) - useKeyboard((evt) => { - if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return - const sel = renderer.getSelection() - if (!sel) return - - // Windows Terminal-like behavior: - // - Ctrl+C copies and dismisses selection - // - Esc dismisses selection - // - Most other key input dismisses selection and is passed through - if (evt.ctrl && evt.name === "c") { - if (!Selection.copy(renderer, toast)) { - renderer.clearSelection() - return - } - - evt.preventDefault() - evt.stopPropagation() - return - } - - if (evt.name === "escape") { - renderer.clearSelection() - evt.preventDefault() - evt.stopPropagation() - return - } - - const focus = renderer.currentFocusedRenderable - if (focus?.hasSelection() && sel.selectedRenderables.includes(focus)) { - return - } - - renderer.clearSelection() - }) + // Let selection copy/dismiss win ahead of normal bindings when the feature flag is on. + const offSelectionKeys = keymap.intercept( + "key", + ({ event }) => { + if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return + Selection.handleSelectionKey(renderer, toast, event) + }, + { priority: 1 }, + ) + onCleanup(offSelectionKeys) // Wire up console copy-to-clipboard via opentui's onCopySelection callback renderer.console.onCopySelection = async (text: string) => { @@ -410,379 +394,374 @@ function App(props: { onSnapshot?: () => Promise }) { ) const connected = useConnected() - command.register(() => [ - { - title: "Switch session", - value: "session.list", - keybind: "session_list", - category: "Session", - suggested: sync.data.session.length > 0, - slash: { - name: "sessions", - aliases: ["resume", "continue"], - }, - onSelect: () => { - dialog.replace(() => ) - }, - }, - { - title: "New session", - suggested: route.data.type === "session", - value: "session.new", - keybind: "session_new", - category: "Session", - slash: { - name: "new", - aliases: ["clear"], - }, - onSelect: () => { - route.navigate({ - type: "home", - }) - dialog.clear() - }, - }, - { - title: "Switch model", - value: "model.list", - keybind: "model_list", - suggested: true, - category: "Agent", - slash: { - name: "models", - }, - onSelect: () => { - dialog.replace(() => ) - }, - }, - { - title: "Model cycle", - value: "model.cycle_recent", - keybind: "model_cycle_recent", - category: "Agent", - hidden: true, - onSelect: () => { - local.model.cycle(1) - }, - }, - { - title: "Model cycle reverse", - value: "model.cycle_recent_reverse", - keybind: "model_cycle_recent_reverse", - category: "Agent", - hidden: true, - onSelect: () => { - local.model.cycle(-1) - }, - }, - { - title: "Favorite cycle", - value: "model.cycle_favorite", - keybind: "model_cycle_favorite", - category: "Agent", - hidden: true, - onSelect: () => { - local.model.cycleFavorite(1) - }, - }, - { - title: "Favorite cycle reverse", - value: "model.cycle_favorite_reverse", - keybind: "model_cycle_favorite_reverse", - category: "Agent", - hidden: true, - onSelect: () => { - local.model.cycleFavorite(-1) - }, - }, - { - title: "Switch agent", - value: "agent.list", - keybind: "agent_list", - category: "Agent", - slash: { - name: "agents", - }, - onSelect: () => { - dialog.replace(() => ) - }, - }, - { - title: "Toggle MCPs", - value: "mcp.list", - category: "Agent", - slash: { - name: "mcps", - }, - onSelect: () => { - dialog.replace(() => ) - }, - }, - { - title: "Agent cycle", - value: "agent.cycle", - keybind: "agent_cycle", - category: "Agent", - hidden: true, - onSelect: () => { - local.agent.move(1) - }, - }, - { - title: "Variant cycle", - value: "variant.cycle", - keybind: "variant_cycle", - category: "Agent", - onSelect: () => { - local.model.variant.cycle() - }, - }, - { - title: "Switch model variant", - value: "variant.list", - keybind: "variant_list", - category: "Agent", - hidden: local.model.variant.list().length === 0, - slash: { - name: "variants", - }, - onSelect: () => { - dialog.replace(() => ) - }, - }, - { - title: "Agent cycle reverse", - value: "agent.cycle.reverse", - keybind: "agent_cycle_reverse", - category: "Agent", - hidden: true, - onSelect: () => { - local.agent.move(-1) - }, - }, - { - title: "Connect provider", - value: "provider.connect", - suggested: !connected(), - slash: { - name: "connect", - }, - onSelect: () => { - dialog.replace(() => ) - }, - category: "Provider", - }, - ...(sync.data.console_state.switchableOrgCount > 1 - ? [ - { - title: "Switch org", - value: "console.org.switch", - suggested: Boolean(sync.data.console_state.activeOrgName), - slash: { - name: "org", - aliases: ["orgs", "switch-org"], - }, - onSelect: () => { - dialog.replace(() => ) + const appCommands = createMemo(() => + [ + { + name: "command.palette.show", + title: "Show command palette", + hidden: true, + run: () => { + command.show() + }, + }, + { + name: "session.list", + title: "Switch session", + category: "Session", + suggested: sync.data.session.length > 0, + slashName: "sessions", + slashAliases: ["resume", "continue"], + run: () => { + dialog.replace(() => ) + }, + }, + { + name: "session.new", + title: "New session", + suggested: route.data.type === "session", + category: "Session", + slashName: "new", + slashAliases: ["clear"], + run: () => { + route.navigate({ + type: "home", + }) + dialog.clear() + }, + }, + { + name: "model.list", + title: "Switch model", + suggested: true, + category: "Agent", + slashName: "models", + run: () => { + dialog.replace(() => ) + }, + }, + { + name: "model.cycle_recent", + title: "Model cycle", + category: "Agent", + hidden: true, + run: () => { + local.model.cycle(1) + }, + }, + { + name: "model.cycle_recent_reverse", + title: "Model cycle reverse", + category: "Agent", + hidden: true, + run: () => { + local.model.cycle(-1) + }, + }, + { + name: "model.cycle_favorite", + title: "Favorite cycle", + category: "Agent", + hidden: true, + run: () => { + local.model.cycleFavorite(1) + }, + }, + { + name: "model.cycle_favorite_reverse", + title: "Favorite cycle reverse", + category: "Agent", + hidden: true, + run: () => { + local.model.cycleFavorite(-1) + }, + }, + { + name: "agent.list", + title: "Switch agent", + category: "Agent", + slashName: "agents", + run: () => { + dialog.replace(() => ) + }, + }, + { + name: "mcp.list", + title: "Toggle MCPs", + category: "Agent", + slashName: "mcps", + run: () => { + dialog.replace(() => ) + }, + }, + { + name: "agent.cycle", + title: "Agent cycle", + category: "Agent", + hidden: true, + run: () => { + local.agent.move(1) + }, + }, + { + name: "variant.cycle", + title: "Variant cycle", + category: "Agent", + run: () => { + local.model.variant.cycle() + }, + }, + { + name: "variant.list", + title: "Switch model variant", + category: "Agent", + hidden: local.model.variant.list().length === 0, + slashName: "variants", + run: () => { + dialog.replace(() => ) + }, + }, + { + name: "agent.cycle.reverse", + title: "Agent cycle reverse", + category: "Agent", + hidden: true, + run: () => { + local.agent.move(-1) + }, + }, + { + name: "provider.connect", + title: "Connect provider", + suggested: !connected(), + slashName: "connect", + run: () => { + dialog.replace(() => ) + }, + category: "Provider", + }, + { + name: "prompt.editor.shortcut", + title: "Open editor shortcut", + category: "Session", + hidden: true, + run: () => { + command.run("prompt.editor") + }, + }, + ...(sync.data.console_state.switchableOrgCount > 1 + ? [ + { + name: "console.org.switch", + title: "Switch org", + suggested: Boolean(sync.data.console_state.activeOrgName), + slashName: "org", + slashAliases: ["orgs", "switch-org"], + run: () => { + dialog.replace(() => ) + }, + category: "Provider", }, - category: "Provider", - }, - ] - : []), - { - title: "View status", - keybind: "status_view", - value: "opencode.status", - slash: { - name: "status", - }, - onSelect: () => { - dialog.replace(() => ) - }, - category: "System", - }, - { - title: "Switch theme", - value: "theme.switch", - keybind: "theme_list", - slash: { - name: "themes", - }, - onSelect: () => { - dialog.replace(() => ) - }, - category: "System", - }, - { - title: mode() === "dark" ? "Switch to light mode" : "Switch to dark mode", - value: "theme.switch_mode", - onSelect: (dialog) => { - setMode(mode() === "dark" ? "light" : "dark") - dialog.clear() - }, - category: "System", - }, - { - title: locked() ? "Unlock theme mode" : "Lock theme mode", - value: "theme.mode.lock", - onSelect: (dialog) => { - if (locked()) unlock() - else lock() - dialog.clear() - }, - category: "System", - }, - { - title: "Help", - value: "help.show", - slash: { - name: "help", - }, - onSelect: () => { - dialog.replace(() => ) - }, - category: "System", - }, - { - title: "Open docs", - value: "docs.open", - onSelect: () => { - open("https://opencode.ai/docs").catch(() => {}) - dialog.clear() - }, - category: "System", - }, - { - title: "Exit the app", - value: "app.exit", - slash: { - name: "exit", - aliases: ["quit", "q"], - }, - onSelect: () => exit(), - category: "System", - }, - { - title: "Toggle debug panel", - category: "System", - value: "app.debug", - onSelect: (dialog) => { - renderer.toggleDebugOverlay() - dialog.clear() - }, - }, - { - title: "Toggle console", - category: "System", - value: "app.console", - onSelect: (dialog) => { - renderer.console.toggle() - dialog.clear() - }, - }, - { - title: "Write heap snapshot", - category: "System", - value: "app.heap_snapshot", - onSelect: async (dialog) => { - const files = await props.onSnapshot?.() - toast.show({ - variant: "info", - message: `Heap snapshot written to ${files?.join(", ")}`, - duration: 5000, - }) - dialog.clear() - }, - }, - { - title: "Suspend terminal", - value: "terminal.suspend", - keybind: "terminal_suspend", - category: "System", - hidden: true, - enabled: tuiConfig.keybinds?.terminal_suspend !== "none", - onSelect: () => { - process.once("SIGCONT", () => { - renderer.resume() - }) + ] + : []), + { + name: "opencode.status", + title: "View status", + slashName: "status", + run: () => { + dialog.replace(() => ) + }, + category: "System", + }, + { + name: "theme.switch", + title: "Switch theme", + slashName: "themes", + run: () => { + dialog.replace(() => ) + }, + category: "System", + }, + { + name: "theme.switch_mode", + title: mode() === "dark" ? "Switch to light mode" : "Switch to dark mode", + run: () => { + setMode(mode() === "dark" ? "light" : "dark") + dialog.clear() + }, + category: "System", + }, + { + name: "theme.mode.lock", + title: locked() ? "Unlock theme mode" : "Lock theme mode", + run: () => { + if (locked()) unlock() + else lock() + dialog.clear() + }, + category: "System", + }, + { + name: "help.show", + title: "Help", + slashName: "help", + run: () => { + dialog.replace(() => ) + }, + category: "System", + }, + { + name: "docs.open", + title: "Open docs", + run: () => { + open("https://opencode.ai/docs").catch(() => {}) + dialog.clear() + }, + category: "System", + }, + { + name: "app.exit", + title: "Exit the app", + slashName: "exit", + slashAliases: ["quit", "q"], + enabled: () => { + const current = promptRef.current + if (!current?.focused) return true + return current.current.input === "" + }, + run: () => exit(), + category: "System", + }, + { + name: "app.debug", + title: "Toggle debug panel", + category: "System", + run: () => { + renderer.toggleDebugOverlay() + dialog.clear() + }, + }, + { + name: "app.console", + title: "Toggle console", + category: "System", + run: () => { + renderer.console.toggle() + dialog.clear() + }, + }, + { + name: "app.heap_snapshot", + title: "Write heap snapshot", + category: "System", + run: async () => { + const files = await props.onSnapshot?.() + toast.show({ + variant: "info", + message: `Heap snapshot written to ${files?.join(", ")}`, + duration: 5000, + }) + dialog.clear() + }, + }, + { + name: "terminal.suspend", + title: "Suspend terminal", + category: "System", + hidden: true, + enabled: sections.app.some((binding) => binding.cmd === "terminal.suspend"), + run: () => { + process.once("SIGCONT", () => { + renderer.resume() + }) - renderer.suspend() - // pid=0 means send the signal to all processes in the process group - process.kill(0, "SIGTSTP") - }, - }, - { - title: terminalTitleEnabled() ? "Disable terminal title" : "Enable terminal title", - value: "terminal.title.toggle", - keybind: "terminal_title_toggle", - category: "System", - onSelect: (dialog) => { - setTerminalTitleEnabled((prev) => { - const next = !prev - kv.set("terminal_title_enabled", next) - if (!next) renderer.setTerminalTitle("") - return next - }) - dialog.clear() - }, - }, - { - title: kv.get("animations_enabled", true) ? "Disable animations" : "Enable animations", - value: "app.toggle.animations", - category: "System", - onSelect: (dialog) => { - kv.set("animations_enabled", !kv.get("animations_enabled", true)) - dialog.clear() - }, - }, - { - title: kv.get("file_context_enabled", true) ? "Disable file context" : "Enable file context", - value: "app.toggle.file_context", - category: "System", - onSelect: (dialog) => { - kv.set("file_context_enabled", !kv.get("file_context_enabled", true)) - dialog.clear() - }, - }, - { - title: pasteSummaryEnabled() ? "Disable paste summary" : "Enable paste summary", - value: "app.toggle.paste_summary", - category: "System", - onSelect: (dialog) => { - setPasteSummaryEnabled((prev) => { - const next = !prev - kv.set("paste_summary_enabled", next) - return next - }) - dialog.clear() - }, - }, - { - title: kv.get("session_directory_filter_enabled", true) - ? "Disable session directory filtering" - : "Enable session directory filtering", - value: "app.toggle.session_directory_filter", - category: "System", - onSelect: async (dialog) => { - kv.set("session_directory_filter_enabled", !kv.get("session_directory_filter_enabled", true)) - await sync.session.refresh() - dialog.clear() - }, - }, - { - title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping", - value: "app.toggle.diffwrap", - category: "System", - onSelect: (dialog) => { - const current = kv.get("diff_wrap_mode", "word") - kv.set("diff_wrap_mode", current === "word" ? "none" : "word") - dialog.clear() - }, - }, - ]) + renderer.suspend() + process.kill(0, "SIGTSTP") + }, + }, + { + name: "terminal.title.toggle", + title: terminalTitleEnabled() ? "Disable terminal title" : "Enable terminal title", + category: "System", + run: () => { + setTerminalTitleEnabled((prev) => { + const next = !prev + kv.set("terminal_title_enabled", next) + if (!next) renderer.setTerminalTitle("") + return next + }) + dialog.clear() + }, + }, + { + name: "app.toggle.animations", + title: kv.get("animations_enabled", true) ? "Disable animations" : "Enable animations", + category: "System", + run: () => { + kv.set("animations_enabled", !kv.get("animations_enabled", true)) + dialog.clear() + }, + }, + { + name: "app.toggle.file_context", + title: kv.get("file_context_enabled", true) ? "Disable file context" : "Enable file context", + category: "System", + run: () => { + kv.set("file_context_enabled", !kv.get("file_context_enabled", true)) + dialog.clear() + }, + }, + { + name: "app.toggle.diffwrap", + title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping", + category: "System", + run: () => { + const current = kv.get("diff_wrap_mode", "word") + kv.set("diff_wrap_mode", current === "word" ? "none" : "word") + dialog.clear() + }, + }, + { + name: "app.toggle.paste_summary", + title: pasteSummaryEnabled() ? "Disable paste summary" : "Enable paste summary", + category: "System", + run: () => { + setPasteSummaryEnabled((prev) => { + const next = !prev + kv.set("paste_summary_enabled", next) + return next + }) + dialog.clear() + }, + }, + { + name: "app.toggle.session_directory_filter", + title: kv.get("session_directory_filter_enabled", true) + ? "Disable session directory filtering" + : "Enable session directory filtering", + category: "System", + run: async () => { + kv.set("session_directory_filter_enabled", !kv.get("session_directory_filter_enabled", true)) + await sync.session.refresh() + dialog.clear() + }, + }, + ].map((command) => ({ + namespace: "palette", + ...command, + })), + ) + + useBindings(() => ({ + commands: appCommands(), + })) + + useBindings(() => ({ + enabled: command.matcher, + bindings: sections.app, + })) event.on(TuiEvent.CommandExecute.type, (evt) => { - command.trigger(evt.properties.command) + command.run(evt.properties.command) }) event.on(TuiEvent.ToastShow.type, (evt) => { diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx deleted file mode 100644 index 49bf42c63e85..000000000000 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx +++ /dev/null @@ -1,172 +0,0 @@ -import { useDialog } from "@tui/ui/dialog" -import { DialogSelect, type DialogSelectOption, type DialogSelectRef } from "@tui/ui/dialog-select" -import { - createContext, - createMemo, - createSignal, - getOwner, - onCleanup, - runWithOwner, - useContext, - type Accessor, - type ParentProps, -} from "solid-js" -import { useKeyboard } from "@opentui/solid" -import { useKeybind } from "@tui/context/keybind" - -type Context = ReturnType -const ctx = createContext() - -export type Slash = { - name: string - aliases?: string[] -} - -export type CommandOption = DialogSelectOption & { - keybind?: string - suggested?: boolean - slash?: Slash - hidden?: boolean - enabled?: boolean -} - -function init() { - const root = getOwner() - const [registrations, setRegistrations] = createSignal[]>([]) - const [suspendCount, setSuspendCount] = createSignal(0) - const dialog = useDialog() - const keybind = useKeybind() - - const entries = createMemo(() => { - const all = registrations().flatMap((x) => x()) - return all.map((x) => ({ - ...x, - footer: x.keybind ? keybind.print(x.keybind) : undefined, - })) - }) - - const isEnabled = (option: CommandOption) => option.enabled !== false - const isVisible = (option: CommandOption) => isEnabled(option) && !option.hidden - - const visibleOptions = createMemo(() => entries().filter((option) => isVisible(option))) - const suggestedOptions = createMemo(() => - visibleOptions() - .filter((option) => option.suggested) - .map((option) => ({ - ...option, - value: `suggested:${option.value}`, - category: "Suggested", - })), - ) - const suspended = () => suspendCount() > 0 - - useKeyboard((evt) => { - if (suspended()) return - if (dialog.stack.length > 0) return - if (evt.defaultPrevented) return - for (const option of entries()) { - if (!isEnabled(option)) continue - if (option.keybind && keybind.match(option.keybind, evt)) { - evt.preventDefault() - option.onSelect?.(dialog) - return - } - } - }) - - const result = { - trigger(name: string) { - for (const option of entries()) { - if (option.value === name) { - if (!isEnabled(option)) return - option.onSelect?.(dialog) - return - } - } - }, - slashes() { - return visibleOptions().flatMap((option) => { - const slash = option.slash - if (!slash) return [] - return { - display: "/" + slash.name, - description: option.description ?? option.title, - aliases: slash.aliases?.map((alias) => "/" + alias), - onSelect: () => result.trigger(option.value), - } - }) - }, - keybinds(enabled: boolean) { - setSuspendCount((count) => count + (enabled ? -1 : 1)) - }, - suspended, - show() { - dialog.replace(() => ) - }, - register(cb: () => CommandOption[]) { - const owner = getOwner() ?? root - if (!owner) return () => {} - - let list: Accessor | undefined - - // TUI plugins now register commands via an async store that runs outside an active reactive scope. - // runWithOwner attaches createMemo/onCleanup to this owner so plugin registrations stay reactive and dispose correctly. - runWithOwner(owner, () => { - list = createMemo(cb) - const ref = list - if (!ref) return - setRegistrations((arr) => [ref, ...arr]) - onCleanup(() => { - setRegistrations((arr) => arr.filter((x) => x !== ref)) - }) - }) - - if (!list) return () => {} - let done = false - return () => { - if (done) return - done = true - const ref = list - if (!ref) return - setRegistrations((arr) => arr.filter((x) => x !== ref)) - } - }, - } - return result -} - -export function useCommandDialog() { - const value = useContext(ctx) - if (!value) { - throw new Error("useCommandDialog must be used within a CommandProvider") - } - return value -} - -export function CommandProvider(props: ParentProps) { - const value = init() - const dialog = useDialog() - const keybind = useKeybind() - - useKeyboard((evt) => { - if (value.suspended()) return - if (dialog.stack.length > 0) return - if (evt.defaultPrevented) return - if (keybind.match("command_list", evt)) { - evt.preventDefault() - value.show() - return - } - }) - - return {props.children} -} - -function DialogCommand(props: { options: CommandOption[]; suggestedOptions: CommandOption[] }) { - let ref: DialogSelectRef - const list = () => { - if (ref?.filter) return props.options - return [...props.suggestedOptions, ...props.options] - } - return (ref = r)} title="Commands" options={list()} /> -} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-go-upsell.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-go-upsell.tsx index b512f9021c37..3a1fd97b2cc4 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-go-upsell.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-go-upsell.tsx @@ -1,5 +1,4 @@ import { BoxRenderable, RGBA, TextAttributes } from "@opentui/core" -import { useKeyboard } from "@opentui/solid" import open from "open" import { createSignal, onCleanup, onMount } from "solid-js" import { selectedForeground, useTheme } from "@tui/context/theme" @@ -7,6 +6,7 @@ import { useDialog, type DialogContext } from "@tui/ui/dialog" import { Link } from "@tui/ui/link" import { GoLogo } from "./logo" import { BgPulse, type BgPulseMask } from "./bg-pulse" +import { useBindings } from "../keymap" const GO_URL = "https://opencode.ai/go" const PAD_X = 3 @@ -71,18 +71,29 @@ export function DialogGoUpsell(props: DialogGoUpsellProps) { for (const b of [content, logoBox, headingBox, descBox, buttonsBox]) b?.off("resize", sync) }) - useKeyboard((evt) => { - if (evt.name === "left" || evt.name === "right" || evt.name === "tab") { - setSelected((s) => (s === "subscribe" ? "dismiss" : "subscribe")) - return - } - if (evt.name === "return") { - evt.preventDefault() - evt.stopPropagation() - if (selected() === "subscribe") subscribe(props, dialog) - else dismiss(props, dialog) - } - }) + useBindings(() => ({ + bindings: [ + { + key: "left", + cmd: () => setSelected((value) => (value === "subscribe" ? "dismiss" : "subscribe")), + }, + { + key: "right", + cmd: () => setSelected((value) => (value === "subscribe" ? "dismiss" : "subscribe")), + }, + { + key: "tab", + cmd: () => setSelected((value) => (value === "subscribe" ? "dismiss" : "subscribe")), + }, + { + key: "return", + cmd: () => { + if (selected() === "subscribe") subscribe(props, dialog) + else dismiss(props, dialog) + }, + }, + ], + })) return ( (content = item)}> 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 173c5ff60cd6..694fd25f5c2a 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx @@ -4,9 +4,9 @@ import { useSync } from "@tui/context/sync" import { map, pipe, entries, sortBy } from "remeda" import { DialogSelect, type DialogSelectRef, type DialogSelectOption } from "@tui/ui/dialog-select" import { useTheme } from "../context/theme" -import { Keybind } from "@/util/keybind" import { TextAttributes } from "@opentui/core" import { useSDK } from "@tui/context/sdk" +import { useTuiConfig } from "../context/tui-config" function Status(props: { enabled: boolean; loading: boolean }) { const { theme } = useTheme() @@ -23,6 +23,9 @@ export function DialogMcp() { const local = useLocal() const sync = useSync() const sdk = useSDK() + const { + keymap: { sections }, + } = useTuiConfig() const [, setRef] = createSignal>() const [loading, setLoading] = createSignal(null) @@ -45,9 +48,9 @@ export function DialogMcp() { ) }) - const keybinds = createMemo(() => [ + const actions = createMemo(() => [ { - keybind: Keybind.parse("space")[0], + command: "dialog.mcp.toggle", title: "toggle", onTrigger: async (option: DialogSelectOption) => { // Prevent toggling while an operation is already in progress @@ -77,7 +80,8 @@ export function DialogMcp() { ref={setRef} title="MCPs" options={options()} - keybind={keybinds()} + actions={actions()} + bindings={sections.dialog_mcp} onSelect={(_option) => { // Don't close on select, only on escape }} 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 06723f3c2bd3..fa00ed549b21 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx @@ -6,15 +6,17 @@ import { DialogSelect } from "@tui/ui/dialog-select" import { useDialog } from "@tui/ui/dialog" import { createDialogProviderOptions, DialogProvider } from "./dialog-provider" import { DialogVariant } from "./dialog-variant" -import { useKeybind } from "../context/keybind" 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 keybind = useKeybind() + const { + keymap: { sections }, + } = useTuiConfig() const [query, setQuery] = createSignal("") const connected = useConnected() @@ -150,16 +152,16 @@ export function DialogModel(props: { providerID?: string }) { return ( [number]["value"]> options={options()} - keybind={[ + actions={[ { - keybind: keybind.all.model_provider_list?.[0], + command: "dialog.model.provider.list", title: connected() ? "Connect provider" : "View all providers", onTrigger() { dialog.replace(() => ) }, }, { - keybind: keybind.all.model_favorite_toggle?.[0], + command: "dialog.model.favorite.toggle", title: "Favorite", disabled: !connected(), onTrigger: (option) => { @@ -167,6 +169,7 @@ export function DialogModel(props: { providerID?: string }) { }, }, ]} + bindings={sections.dialog_model} onFilter={setQuery} flat={true} skipFilter={true} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx index d6cbda413317..a218f084d0e8 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -10,11 +10,11 @@ import { useTheme } from "../context/theme" import { TextAttributes } from "@opentui/core" import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2" import { DialogModel } from "./dialog-model" -import { useKeyboard } from "@opentui/solid" import * as Clipboard from "@tui/util/clipboard" import { useToast } from "../ui/toast" import { isConsoleManagedProvider } from "@tui/util/provider-origin" import { useConnected } from "./use-connected" +import { useBindings } from "../keymap" const PROVIDER_PRIORITY: Record = { opencode: 0, @@ -163,14 +163,19 @@ function AutoMethod(props: AutoMethodProps) { const sync = useSync() const toast = useToast() - useKeyboard((evt) => { - if (evt.name === "c" && !evt.ctrl && !evt.meta) { - const code = props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.authorization.url - Clipboard.copy(code) - .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) - .catch(toast.error) - } - }) + useBindings(() => ({ + bindings: [ + { + key: "c", + cmd: () => { + const code = props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.authorization.url + Clipboard.copy(code) + .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) + .catch(toast.error) + }, + }, + ], + })) onMount(async () => { const result = await sdk.client.provider.oauth.callback({ diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-delete-failed.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-delete-failed.tsx index 3d3059d9534c..cdd50019e447 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-delete-failed.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-delete-failed.tsx @@ -3,7 +3,7 @@ import { useTheme } from "../context/theme" import { useDialog } from "../ui/dialog" import { createStore } from "solid-js/store" import { For } from "solid-js" -import { useKeyboard } from "@opentui/solid" +import { useBindings } from "../keymap" export function DialogSessionDeleteFailed(props: { session: string @@ -40,19 +40,15 @@ export function DialogSessionDeleteFailed(props: { if (!props.onDone) dialog.clear() } - useKeyboard((evt) => { - if (evt.name === "return") { - evt.preventDefault() - evt.stopPropagation() - void confirm() - } - if (evt.name === "left" || evt.name === "up") { - setStore("active", "delete") - } - if (evt.name === "right" || evt.name === "down") { - setStore("active", "restore") - } - }) + useBindings(() => ({ + bindings: [ + { key: "return", cmd: () => void confirm() }, + { key: "left", cmd: () => setStore("active", "delete") }, + { key: "up", cmd: () => setStore("active", "delete") }, + { key: "right", cmd: () => setStore("active", "restore") }, + { key: "down", cmd: () => setStore("active", "restore") }, + ], + })) return ( 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 09d952ef8192..92c9411b1c57 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 @@ -5,7 +5,6 @@ import { useSync } from "@tui/context/sync" import { createMemo, createResource, createSignal, onMount, type JSX } from "solid-js" import { Locale } from "@/util/locale" import { useProject } from "@tui/context/project" -import { useKeybind } from "../context/keybind" import { useTheme } from "../context/theme" import { useSDK } from "../context/sdk" import { Flag } from "@opencode-ai/core/flag/flag" @@ -17,18 +16,24 @@ import { Spinner } from "./spinner" import { errorMessage } from "@/util/error" import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed" import { WorkspaceLabel } from "./workspace-label" +import { useTuiConfig } from "../context/tui-config" +import { useCommandShortcut } from "../keymap" export function DialogSessionList() { const dialog = useDialog() const route = useRoute() const sync = useSync() const project = useProject() - const keybind = useKeybind() const { theme } = useTheme() const sdk = useSDK() const toast = useToast() + const tuiConfig = useTuiConfig() + const { + keymap: { sections }, + } = tuiConfig const [toDelete, setToDelete] = createSignal() const [search, setSearch] = createDebouncedSignal("", 150) + const deleteHint = useCommandShortcut("dialog.session.delete") const [searchResults, { refetch }] = createResource( () => ({ query: search(), filter: sync.session.query() }), @@ -154,7 +159,7 @@ export function DialogSessionList() { const status = sync.data.session_status?.[x.id] const isWorking = status?.type === "busy" return { - title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : x.title, + title: isDeleting ? `Press ${deleteHint()} again to confirm` : x.title, bg: isDeleting ? theme.error : undefined, value: x.id, category, @@ -185,9 +190,9 @@ export function DialogSessionList() { }) dialog.clear() }} - keybind={[ + actions={[ { - keybind: keybind.all.session_delete?.[0], + command: "dialog.session.delete", title: "delete", onTrigger: async (option) => { if (toDelete() === option.value) { @@ -235,13 +240,14 @@ export function DialogSessionList() { }, }, { - keybind: keybind.all.session_rename?.[0], + command: "dialog.session.rename", title: "rename", onTrigger: async (option) => { dialog.replace(() => ) }, }, ]} + bindings={sections.dialog_session_list} /> ) } 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 e8664f6289bc..b70ec33d988b 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-stash.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-stash.tsx @@ -3,8 +3,9 @@ import { DialogSelect } from "@tui/ui/dialog-select" import { createMemo, createSignal } from "solid-js" import { Locale } from "@/util/locale" import { useTheme } from "../context/theme" -import { useKeybind } from "../context/keybind" import { usePromptStash, type StashEntry } from "./prompt/stash" +import { useTuiConfig } from "../context/tui-config" +import { useCommandShortcut } from "../keymap" function getRelativeTime(timestamp: number): string { const now = Date.now() @@ -30,9 +31,13 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) { const dialog = useDialog() const stash = usePromptStash() const { theme } = useTheme() - const keybind = useKeybind() + const tuiConfig = useTuiConfig() + const { + keymap: { sections }, + } = tuiConfig const [toDelete, setToDelete] = createSignal() + const deleteHint = useCommandShortcut("dialog.stash.delete") const options = createMemo(() => { const entries = stash.list() @@ -42,7 +47,7 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) { const isDeleting = toDelete() === index const lineCount = (entry.input.match(/\n/g)?.length ?? 0) + 1 return { - title: isDeleting ? `Press ${keybind.print("stash_delete")} again to confirm` : getStashPreview(entry.input), + title: isDeleting ? `Press ${deleteHint()} again to confirm` : getStashPreview(entry.input), bg: isDeleting ? theme.error : undefined, value: index, description: getRelativeTime(entry.timestamp), @@ -68,9 +73,9 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) { } dialog.clear() }} - keybind={[ + actions={[ { - keybind: keybind.all.stash_delete?.[0], + command: "dialog.stash.delete", title: "delete", onTrigger: (option) => { if (toDelete() === option.value) { @@ -82,6 +87,7 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) { }, }, ]} + bindings={sections.dialog_stash} /> ) } diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-unavailable.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-unavailable.tsx index 7a217985348b..0da7394bc43e 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-unavailable.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-unavailable.tsx @@ -1,9 +1,9 @@ import { TextAttributes } from "@opentui/core" -import { useKeyboard } from "@opentui/solid" import { createStore } from "solid-js/store" import { For } from "solid-js" import { useTheme } from "../context/theme" import { useDialog } from "../ui/dialog" +import { useBindings } from "../keymap" export function DialogWorkspaceUnavailable(props: { onRestore?: () => boolean | void | Promise }) { const dialog = useDialog() @@ -23,25 +23,13 @@ export function DialogWorkspaceUnavailable(props: { onRestore?: () => boolean | if (result === false) return } - useKeyboard((evt) => { - if (evt.name === "return") { - evt.preventDefault() - evt.stopPropagation() - void confirm() - return - } - if (evt.name === "left") { - evt.preventDefault() - evt.stopPropagation() - setStore("active", "cancel") - return - } - if (evt.name === "right") { - evt.preventDefault() - evt.stopPropagation() - setStore("active", "restore") - } - }) + useBindings(() => ({ + bindings: [ + { key: "return", cmd: () => void confirm() }, + { key: "left", cmd: () => setStore("active", "cancel") }, + { key: "right", cmd: () => setStore("active", "restore") }, + ], + })) return ( 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 47bb162cb4bc..579487930666 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -1,4 +1,4 @@ -import type { BoxRenderable, TextareaRenderable, KeyEvent, ScrollBoxRenderable } from "@opentui/core" +import type { BoxRenderable, TextareaRenderable, ScrollBoxRenderable } from "@opentui/core" import { pathToFileURL } from "bun" import fuzzysort from "fuzzysort" import path from "path" @@ -12,11 +12,12 @@ import { getScrollAcceleration } from "../../util/scroll" import { useTuiConfig } from "../../context/tui-config" import { useTheme, selectedForeground } from "@tui/context/theme" import { SplitBorder } from "@tui/component/border" -import { useCommandDialog } from "@tui/component/dialog-command" +import { useCommandPalette } from "../../context/command-palette" import { useTerminalDimensions } from "@opentui/solid" import { Locale } from "@/util/locale" import type { PromptInfo } from "./history" import { useFrecency } from "./frecency" +import { useBindings } from "../../keymap" function removeLineRange(input: string) { const hashIndex = input.lastIndexOf("#") @@ -52,7 +53,6 @@ function extractLineRange(input: string) { export type AutocompleteRef = { onInput: (value: string) => void - onKeyDown: (e: KeyEvent) => void visible: false | "@" | "/" } @@ -82,12 +82,14 @@ export function Autocomplete(props: { const editor = useEditorContext() const sdk = useSDK() const sync = useSync() - const command = useCommandDialog() + const command = useCommandPalette() const { theme } = useTheme() const dimensions = useTerminalDimensions() const frecency = useFrecency() const tuiConfig = useTuiConfig() - + const { + keymap: { sections }, + } = tuiConfig const [store, setStore] = createStore({ index: 0, selected: 0, @@ -282,7 +284,7 @@ export function Autocomplete(props: { const { filename, part } = createFilePart(item, lineRange) const index = store.visible === "@" ? store.index : props.input().cursorOffset - command.keybinds(true) + command.suspend(false) setStore("visible", false) setStore("index", index) insertPart(filename, part) @@ -520,8 +522,54 @@ export function Autocomplete(props: { setStore("selected", 0) } + useBindings(() => ({ + target: props.input, + enabled: () => Boolean(store.visible), + commands: [ + { + name: "prompt.autocomplete.prev", + run() { + setStore("input", "keyboard") + move(-1) + }, + }, + { + name: "prompt.autocomplete.next", + run() { + setStore("input", "keyboard") + move(1) + }, + }, + { + name: "prompt.autocomplete.hide", + run() { + hide() + }, + }, + { + name: "prompt.autocomplete.select", + run() { + select() + }, + }, + { + name: "prompt.autocomplete.complete", + run() { + const selected = options()[store.selected] + if (selected?.isDirectory) { + expandDirectory() + return + } + + select() + }, + }, + ], + bindings: sections.prompt_autocomplete, + })) + function show(mode: "@" | "/") { - command.keybinds(false) + command.suspend(true) setStore({ visible: mode, index: props.input().cursorOffset, @@ -538,7 +586,7 @@ export function Autocomplete(props: { draft.input = props.input().plainText }) } - command.keybinds(true) + command.suspend(false) setStore("visible", false) } @@ -593,60 +641,6 @@ export function Autocomplete(props: { setStore("index", idx) } }, - onKeyDown(e: KeyEvent) { - if (store.visible) { - const name = e.name?.toLowerCase() - const ctrlOnly = e.ctrl && !e.meta && !e.shift - const isNavUp = name === "up" || (ctrlOnly && name === "p") - const isNavDown = name === "down" || (ctrlOnly && name === "n") - - if (isNavUp) { - setStore("input", "keyboard") - move(-1) - e.preventDefault() - return - } - if (isNavDown) { - setStore("input", "keyboard") - move(1) - e.preventDefault() - return - } - if (name === "escape") { - hide() - e.preventDefault() - return - } - if (name === "return") { - select() - e.preventDefault() - return - } - if (name === "tab") { - const selected = options()[store.selected] - if (selected?.isDirectory) { - expandDirectory() - } else { - select() - } - e.preventDefault() - return - } - } - if (!store.visible) { - if (e.name === "@") { - const cursorOffset = props.input().cursorOffset - const charBeforeCursor = - cursorOffset === 0 ? undefined : props.input().getTextRange(cursorOffset - 1, cursorOffset) - const canTrigger = charBeforeCursor === undefined || charBeforeCursor === "" || /\s/.test(charBeforeCursor) - if (canTrigger) show("@") - } - - if (e.name === "/") { - if (props.input().cursorOffset === 0) show("/") - } - } - }, }) }) 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 41e32539eef5..12be7a586b0c 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -1,4 +1,14 @@ -import { BoxRenderable, RGBA, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes } from "@opentui/core" +import { + BoxRenderable, + RGBA, + TextareaRenderable, + MouseEvent, + PasteEvent, + decodePasteBytes, + type KeyEvent, + type Renderable, +} from "@opentui/core" +import type { CommandContext } from "@opentui/keymap" import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js" import "opentui-spinner/solid" import path from "path" @@ -16,14 +26,12 @@ import { useEvent } from "@tui/context/event" import { editorSelectionKey, useEditorContext, type EditorSelection } from "@tui/context/editor" import { MessageID, PartID } from "@/session/schema" import { createStore, produce, unwrap } from "solid-js/store" -import { useKeybind } from "@tui/context/keybind" import { usePromptHistory, type PromptInfo } from "./history" import { computePromptTraits } from "./traits" import { assign } from "./part" import { usePromptStash } from "./stash" import { DialogStash } from "../dialog-stash" import { type AutocompleteRef, Autocomplete } from "./autocomplete" -import { useCommandDialog } from "../dialog-command" import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import * as Editor from "@tui/util/editor" import { useExit } from "../../context/exit" @@ -40,13 +48,20 @@ import { DialogAlert } from "../../ui/dialog-alert" import { useToast } from "../../ui/toast" import { useKV } from "../../context/kv" import { createFadeIn } from "../../util/signal" -import { useTextareaKeybindings } from "../textarea-keybindings" import { DialogSkill } from "../dialog-skill" import { openWorkspaceSelect, warpWorkspaceSession, type WorkspaceSelection } from "../dialog-workspace-create" import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable" import { useArgs } from "@tui/context/args" import { Flag } from "@opencode-ai/core/flag/flag" -import { WorkspaceLabel, type WorkspaceStatus } from "../workspace-label" +import { type WorkspaceStatus } from "../workspace-label" +import { useCommandPalette } from "../../context/command-palette" +import { + useBindings, + useCommandShortcut, + useLeaderActive, + useOpencodeKeymap, +} from "../../keymap" +import { useTuiConfig } from "../../context/tui-config" export type PromptProps = { sessionID?: string @@ -119,9 +134,9 @@ let stashed: { prompt: PromptInfo; cursor: number } | undefined export function Prompt(props: PromptProps) { let input: TextareaRenderable let anchor: BoxRenderable - let autocomplete: AutocompleteRef + const [inputTarget, setInputTarget] = createSignal() - const keybind = useKeybind() + const leader = useLeaderActive() const local = useLocal() const args = useArgs() const sdk = useSDK() @@ -129,12 +144,19 @@ export function Prompt(props: PromptProps) { const route = useRoute() const project = useProject() const sync = useSync() + const tuiConfig = useTuiConfig() + const { + keymap: { sections }, + } = tuiConfig const dialog = useDialog() const toast = useToast() const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" }) const history = usePromptHistory() const stash = usePromptStash() - const command = useCommandDialog() + const command = useCommandPalette() + const keymap = useOpencodeKeymap() + const agentShortcut = useCommandShortcut("agent.cycle") + const paletteShortcut = useCommandShortcut("command.palette.show") const renderer = useRenderer() const dimensions = useTerminalDimensions() const { theme, syntax } = useTheme() @@ -179,6 +201,7 @@ export function Prompt(props: PromptProps) { const [workspaceCreating, setWorkspaceCreating] = createSignal(false) const [workspaceCreatingDots, setWorkspaceCreatingDots] = createSignal(3) const [warpNotice, setWarpNotice] = createSignal() + const [cursorVersion, setCursorVersion] = createSignal(0) const currentProviderLabel = createMemo(() => local.model.parsed().provider) const hasRightContent = createMemo(() => Boolean(props.right)) const defaultWorkspaceID = createMemo(() => props.workspaceID ?? project.workspace.current()) @@ -277,9 +300,6 @@ export function Prompt(props: PromptProps) { setDismissedEditorSelectionKey(editorSelectionKey(editorContext())) editor.clearSelection() } - - const textareaKeybindings = useTextareaKeybindings() - const fileStyleId = syntax().getStyleId("extmark.file")! const agentStyleId = syntax().getStyleId("extmark.agent")! const pasteStyleId = syntax().getStyleId("extmark.paste")! @@ -381,26 +401,30 @@ export function Prompt(props: PromptProps) { } }) - command.register(() => { - return [ + const promptCommands = createMemo(() => + [ { title: "Clear prompt", - value: "prompt.clear", + name: "prompt.clear", category: "Prompt", hidden: true, - onSelect: (dialog) => { - input.extmarks.clear() + run: () => { input.clear() + input.extmarks.clear() + setStore("prompt", { + input: "", + parts: [], + }) + setStore("extmarkToPartIndex", new Map()) dialog.clear() }, }, { title: "Submit prompt", - value: "prompt.submit", - keybind: "input_submit", + name: "prompt.submit", category: "Prompt", hidden: true, - onSelect: async (dialog) => { + run: async () => { if (!input.focused) return const handled = await submit() if (!handled) return @@ -410,21 +434,22 @@ export function Prompt(props: PromptProps) { }, { title: "Remove editor context", - value: "prompt.editor_context.clear", + name: "prompt.editor_context.clear", category: "Prompt", enabled: Boolean(editorContext()), - onSelect: (dialog) => { + run: () => { dismissEditorContext() dialog.clear() }, }, { title: "Paste", - value: "prompt.paste", - keybind: "input_paste", + name: "prompt.paste", category: "Prompt", hidden: true, - onSelect: async () => { + run: async (ctx: CommandContext) => { + ctx.event.preventDefault() + ctx.event.stopPropagation() const content = await Clipboard.read() if (content?.mime.startsWith("image/")) { await pasteAttachment({ @@ -432,18 +457,21 @@ export function Prompt(props: PromptProps) { mime: content.mime, content: content.data, }) + return + } + if (content?.mime === "text/plain") { + await pasteInputText(content.data) } }, }, { title: "Interrupt session", - value: "session.interrupt", - keybind: "session_interrupt", + name: "session.interrupt", category: "Session", hidden: true, enabled: status().type !== "idle", - onSelect: (dialog) => { - if (autocomplete.visible) return + run: () => { + if (auto()?.visible) return if (!input.focused) return // TODO: this should be its own command if (store.mode === "shell") { @@ -470,12 +498,9 @@ export function Prompt(props: PromptProps) { { title: "Open editor", category: "Session", - keybind: "editor_open", - value: "prompt.editor", - slash: { - name: "editor", - }, - onSelect: async (dialog) => { + name: "prompt.editor", + slashName: "editor", + run: async () => { dialog.clear() // replace summarized text parts with the actual text @@ -556,12 +581,10 @@ export function Prompt(props: PromptProps) { }, { title: "Skills", - value: "prompt.skills", + name: "prompt.skills", category: "Prompt", - slash: { - name: "skills", - }, - onSelect: () => { + slashName: "skills", + run: () => { dialog.replace(() => ( { @@ -578,14 +601,12 @@ export function Prompt(props: PromptProps) { }, { title: "Warp", - description: "Change the workspace for the session", - value: "workspace.set", + desc: "Change the workspace for the session", + name: "workspace.set", category: "Session", enabled: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES, - slash: { - name: "warp", - }, - onSelect: (dialog) => { + slashName: "warp", + run: () => { void openWorkspaceSelect({ dialog, sdk, @@ -597,8 +618,20 @@ export function Prompt(props: PromptProps) { }) }, }, - ] - }) + ].map((entry) => ({ + namespace: "palette", + ...entry, + })), + ) + + useBindings(() => ({ + commands: promptCommands(), + })) + + useBindings(() => ({ + enabled: command.matcher, + bindings: sections.prompt, + })) const ref: PromptRef = { get focused() { @@ -649,6 +682,7 @@ export function Prompt(props: PromptProps) { if (store.prompt.input) { stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset } } + setInputTarget(undefined) props.ref?.(undefined) }) @@ -666,11 +700,14 @@ export function Prompt(props: PromptProps) { createEffect(() => { if (!input || input.isDestroyed) return - input.traits = computePromptTraits({ - mode: store.mode, - disabled: !!props.disabled, - autocompleteVisible: !!auto()?.visible, - }) + input.traits = { + ...input.traits, + ...computePromptTraits({ + mode: store.mode, + disabled: !!props.disabled, + autocompleteVisible: !!auto()?.visible, + }), + } }) function restoreExtmarksFromParts(parts: PromptInfo["parts"]) { @@ -751,60 +788,195 @@ export function Prompt(props: PromptProps) { ) } - command.register(() => [ - { - title: "Stash prompt", - value: "prompt.stash", - category: "Prompt", - enabled: !!store.prompt.input, - onSelect: (dialog) => { - if (!store.prompt.input) return - stash.push({ - input: store.prompt.input, - parts: store.prompt.parts, - }) - input.extmarks.clear() - input.clear() - setStore("prompt", { input: "", parts: [] }) - setStore("extmarkToPartIndex", new Map()) - dialog.clear() + const stashCommands = createMemo(() => + [ + { + title: "Stash prompt", + name: "prompt.stash", + category: "Prompt", + enabled: !!store.prompt.input, + run: () => { + if (!store.prompt.input) return + stash.push({ + input: store.prompt.input, + parts: store.prompt.parts, + }) + input.extmarks.clear() + input.clear() + setStore("prompt", { input: "", parts: [] }) + setStore("extmarkToPartIndex", new Map()) + dialog.clear() + }, }, - }, - { - title: "Stash pop", - value: "prompt.stash.pop", - category: "Prompt", - enabled: stash.list().length > 0, - onSelect: (dialog) => { - const entry = stash.pop() - if (entry) { - input.setText(entry.input) - setStore("prompt", { input: entry.input, parts: entry.parts }) - restoreExtmarksFromParts(entry.parts) - input.gotoBufferEnd() - } - dialog.clear() + { + title: "Stash pop", + name: "prompt.stash.pop", + category: "Prompt", + enabled: stash.list().length > 0, + run: () => { + const entry = stash.pop() + if (entry) { + input.setText(entry.input) + setStore("prompt", { input: entry.input, parts: entry.parts }) + restoreExtmarksFromParts(entry.parts) + input.gotoBufferEnd() + } + dialog.clear() + }, }, - }, - { - title: "Stash list", - value: "prompt.stash.list", - category: "Prompt", - enabled: stash.list().length > 0, - onSelect: (dialog) => { - dialog.replace(() => ( - { - input.setText(entry.input) - setStore("prompt", { input: entry.input, parts: entry.parts }) - restoreExtmarksFromParts(entry.parts) - input.gotoBufferEnd() - }} - /> - )) + { + title: "Stash list", + name: "prompt.stash.list", + category: "Prompt", + enabled: stash.list().length > 0, + run: () => { + dialog.replace(() => ( + { + input.setText(entry.input) + setStore("prompt", { input: entry.input, parts: entry.parts }) + restoreExtmarksFromParts(entry.parts) + input.gotoBufferEnd() + }} + /> + )) + }, }, - }, - ]) + ].map((entry) => ({ + namespace: "palette", + ...entry, + })), + ) + + useBindings(() => ({ + commands: stashCommands(), + })) + + useBindings(() => { + return { + target: inputTarget, + enabled: inputTarget() !== undefined && !props.disabled, + bindings: sections.prompt_paste, + } + }) + + useBindings(() => { + return { + target: inputTarget, + enabled: inputTarget() !== undefined && !props.disabled && store.prompt.input !== "", + bindings: sections.prompt_clear, + } + }) + + useBindings(() => { + return { + target: inputTarget, + enabled: (() => { + cursorVersion() + return inputTarget() !== undefined && !props.disabled && store.mode === "normal" && !auto()?.visible && input?.visualCursor.offset === 0 + })(), + bindings: [ + { + key: "!", + cmd: () => { + setStore("placeholder", randomIndex(shell().length)) + setStore("mode", "shell") + }, + }, + ], + } + }) + + useBindings(() => { + return { + target: inputTarget, + enabled: inputTarget() !== undefined && store.mode === "shell", + bindings: [{ key: "escape", cmd: () => setStore("mode", "normal") }], + } + }) + + useBindings(() => { + return { + target: inputTarget, + enabled: (() => { + cursorVersion() + return inputTarget() !== undefined && store.mode === "shell" && input?.visualCursor.offset === 0 + })(), + bindings: [{ key: "backspace", cmd: () => setStore("mode", "normal") }], + } + }) + + useBindings(() => { + return { + target: inputTarget, + enabled: (() => { + cursorVersion() + return ( + inputTarget() !== undefined && + !props.disabled && + !auto()?.visible && + input !== undefined && + (input.cursorOffset === 0 || input.visualCursor.visualRow === 0) + ) + })(), + commands: [ + { + name: "prompt.history.previous", + run() { + if (input.cursorOffset !== 0) { + input.cursorOffset = 0 + return + } + + const item = history.move(-1, input.plainText) + if (!item) return + input.setText(item.input) + setStore("prompt", item) + setStore("mode", item.mode ?? "normal") + restoreExtmarksFromParts(item.parts) + input.cursorOffset = 0 + }, + }, + ], + bindings: sections.prompt_history_previous, + } + }) + + useBindings(() => { + return { + target: inputTarget, + enabled: (() => { + cursorVersion() + return ( + inputTarget() !== undefined && + !props.disabled && + !auto()?.visible && + input !== undefined && + (input.cursorOffset === input.plainText.length || input.visualCursor.visualRow === input.height - 1) + ) + })(), + commands: [ + { + name: "prompt.history.next", + run() { + if (input.cursorOffset !== input.plainText.length) { + input.cursorOffset = input.plainText.length + return + } + + const item = history.move(1, input.plainText) + if (!item) return + input.setText(item.input) + setStore("prompt", item) + setStore("mode", item.mode ?? "normal") + restoreExtmarksFromParts(item.parts) + input.cursorOffset = input.plainText.length + }, + }, + ], + bindings: sections.prompt_history_next, + } + }) async function submit() { setWarpNotice(undefined) @@ -818,7 +990,7 @@ export function Prompt(props: PromptProps) { } if (props.disabled) return false if (workspaceCreating()) return false - if (autocomplete?.visible) return false + if (auto()?.visible) return false if (!store.prompt.input) return false const agent = local.agent.current() if (!agent) return false @@ -1058,6 +1230,66 @@ export function Prompt(props: PromptProps) { ) } + async function pasteInputText(text: string) { + const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n") + const pastedContent = normalizedText.trim() + const filepath = iife(() => { + const raw = pastedContent.replace(/^['"]+|['"]+$/g, "") + if (raw.startsWith("file://")) { + try { + return fileURLToPath(raw) + } catch {} + } + if (process.platform === "win32") return raw + return raw.replace(/\\(.)/g, "$1") + }) + const isUrl = /^(https?):\/\//.test(filepath) + if (!isUrl) { + try { + const mime = await Filesystem.mimeType(filepath) + const filename = path.basename(filepath) + if (mime === "image/svg+xml") { + const content = await Filesystem.readText(filepath).catch(() => {}) + if (content) { + pasteText(content, `[SVG: ${filename ?? "image"}]`) + return + } + } + if (mime.startsWith("image/") || mime === "application/pdf") { + const content = await Filesystem.readArrayBuffer(filepath) + .then((buffer) => Buffer.from(buffer).toString("base64")) + .catch(() => {}) + if (content) { + await pasteAttachment({ + filename, + filepath, + mime, + content, + }) + return + } + } + } catch {} + } + + const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1 + if ( + (lineCount >= 3 || pastedContent.length > 150) && + kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary) + ) { + pasteText(pastedContent, `[Pasted ~${lineCount} lines]`) + return + } + + input.insertText(normalizedText) + + setTimeout(() => { + if (!input || input.isDestroyed) return + input.getLayoutNode().markDirty() + renderer.requestRender() + }, 0) + } + async function pasteAttachment(file: { filename?: string; filepath?: string; content: string; mime: string }) { const currentOffset = input.visualCursor.offset const extmarkStart = currentOffset @@ -1107,7 +1339,7 @@ export function Prompt(props: PromptProps) { } const highlight = createMemo(() => { - if (keybind.leader) return theme.border + if (leader()) return theme.border if (store.mode === "shell") return theme.primary const agent = local.agent.current() if (!agent) return theme.border @@ -1196,30 +1428,7 @@ export function Prompt(props: PromptProps) { return ( <> - { - autocomplete = r - setAuto(() => r) - }} - anchor={() => anchor} - input={() => input} - setPrompt={(cb) => { - setStore("prompt", produce(cb)) - }} - setExtmark={(partIndex, extmarkId) => { - setStore("extmarkToPartIndex", (map: Map) => { - const newMap = new Map(map) - newMap.set(extmarkId, partIndex) - return newMap - }) - }} - value={store.prompt.input} - fileStyleId={fileStyleId} - agentStyleId={agentStyleId} - promptPartTypeId={() => promptPartTypeId} - /> - (anchor = r)} visible={props.visible !== false}> + (anchor = r)} visible={props.visible !== false}> { const value = input.plainText setStore("prompt", "input", value) - autocomplete.onInput(value) + auto()?.onInput(value) syncExtmarksWithPromptParts() + setCursorVersion((value) => value + 1) }} - keyBindings={textareaKeybindings()} - onKeyDown={async (e) => { + onCursorChange={() => setCursorVersion((value) => value + 1)} + onKeyDown={(e: { preventDefault(): void }) => { if (props.disabled) { e.preventDefault() return } - // Check clipboard for images before terminal-handled paste runs. - // This helps terminals that forward Ctrl+V to the app; Windows - // Terminal 1.25+ usually handles Ctrl+V before this path. - if (keybind.match("input_paste", e)) { - const content = await Clipboard.read() - if (content?.mime.startsWith("image/")) { - e.preventDefault() - await pasteAttachment({ - filename: "clipboard", - mime: content.mime, - content: content.data, - }) - return - } - // If no image, let the default paste behavior continue - } - if (keybind.match("input_clear", e) && store.prompt.input !== "") { - input.clear() - input.extmarks.clear() - setStore("prompt", { - input: "", - parts: [], - }) - setStore("extmarkToPartIndex", new Map()) - return - } - if (keybind.match("app_exit", e)) { - if (store.prompt.input === "") { - await exit() - // Don't preventDefault - let textarea potentially handle the event - e.preventDefault() - return - } - } - if (e.name === "!" && input.visualCursor.offset === 0) { - setStore("placeholder", randomIndex(shell().length)) - setStore("mode", "shell") - e.preventDefault() - return - } - if (store.mode === "shell") { - if ((e.name === "backspace" && input.visualCursor.offset === 0) || e.name === "escape") { - setStore("mode", "normal") - e.preventDefault() - return - } - } - if (store.mode === "normal") autocomplete.onKeyDown(e) - if (!autocomplete.visible) { - if ( - (keybind.match("history_previous", e) && input.cursorOffset === 0) || - (keybind.match("history_next", e) && input.cursorOffset === input.plainText.length) - ) { - const direction = keybind.match("history_previous", e) ? -1 : 1 - const item = history.move(direction, input.plainText) - - if (item) { - input.setText(item.input) - setStore("prompt", item) - setStore("mode", item.mode ?? "normal") - restoreExtmarksFromParts(item.parts) - e.preventDefault() - if (direction === -1) input.cursorOffset = 0 - if (direction === 1) input.cursorOffset = input.plainText.length - } - return - } - - if (keybind.match("history_previous", e) && input.visualCursor.visualRow === 0) input.cursorOffset = 0 - if (keybind.match("history_next", e) && input.visualCursor.visualRow === input.height - 1) - input.cursorOffset = input.plainText.length - } }} onSubmit={() => { // IME: double-defer so the last composed character (e.g. Korean @@ -1348,7 +1486,7 @@ export function Prompt(props: PromptProps) { // Windows Terminal <1.25 can surface image-only clipboard as an // empty bracketed paste. Windows Terminal 1.25+ does not. if (!pastedContent) { - command.trigger("prompt.paste") + keymap.dispatchCommand("prompt.paste") return } @@ -1356,67 +1494,11 @@ export function Prompt(props: PromptProps) { // default paste unless we suppress it first and handle insertion ourselves. event.preventDefault() - const filepath = iife(() => { - const raw = pastedContent.replace(/^['"]+|['"]+$/g, "") - if (raw.startsWith("file://")) { - try { - return fileURLToPath(raw) - } catch {} - } - if (process.platform === "win32") return raw - return raw.replace(/\\(.)/g, "$1") - }) - const isUrl = /^(https?):\/\//.test(filepath) - if (!isUrl) { - try { - const mime = await Filesystem.mimeType(filepath) - const filename = path.basename(filepath) - // Handle SVG as raw text content, not as base64 image - if (mime === "image/svg+xml") { - const content = await Filesystem.readText(filepath).catch(() => {}) - if (content) { - pasteText(content, `[SVG: ${filename ?? "image"}]`) - return - } - } - if (mime.startsWith("image/") || mime === "application/pdf") { - const content = await Filesystem.readArrayBuffer(filepath) - .then((buffer) => Buffer.from(buffer).toString("base64")) - .catch(() => {}) - if (content) { - await pasteAttachment({ - filename, - filepath, - mime, - content, - }) - return - } - } - } catch {} - } - - const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1 - if ( - (lineCount >= 3 || pastedContent.length > 150) && - kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary) - ) { - pasteText(pastedContent, `[Pasted ~${lineCount} lines]`) - return - } - - input.insertText(normalizedText) - - // Force layout update and render for the pasted content - setTimeout(() => { - // setTimeout is a workaround and needs to be addressed properly - if (!input || input.isDestroyed) return - input.getLayoutNode().markDirty() - renderer.requestRender() - }, 0) + await pasteInputText(normalizedText) }} ref={(r: TextareaRenderable) => { input = r + setInputTarget(r) if (promptPartTypeId === 0) { promptPartTypeId = input.extmarks.registerType("prompt-part") } @@ -1445,7 +1527,7 @@ export function Prompt(props: PromptProps) { · {local.model.parsed().model} @@ -1636,12 +1718,12 @@ export function Prompt(props: PromptProps) { - {keybind.print("agent_cycle")} agents + {agentShortcut()} agents - {keybind.print("command_list")} commands + {paletteShortcut()} commands @@ -1654,6 +1736,28 @@ export function Prompt(props: PromptProps) { + { + setAuto(() => r) + }} + anchor={() => anchor} + input={() => input} + setPrompt={(cb) => { + setStore("prompt", produce(cb)) + }} + setExtmark={(partIndex, extmarkId) => { + setStore("extmarkToPartIndex", (map: Map) => { + const newMap = new Map(map) + newMap.set(extmarkId, partIndex) + return newMap + }) + }} + value={store.prompt.input} + fileStyleId={fileStyleId} + agentStyleId={agentStyleId} + promptPartTypeId={() => promptPartTypeId} + /> ) } diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/traits.ts b/packages/opencode/src/cli/cmd/tui/component/prompt/traits.ts index e47a1aeba5fe..a70139656286 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/traits.ts +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/traits.ts @@ -8,6 +8,11 @@ export interface PromptTraitsInput { autocompleteVisible: boolean } +export type PromptTraits = EditorTraits & { + owner: "opencode" + role: "prompt" +} + /** * Compute the textarea editor traits for the prompt. * @@ -16,7 +21,7 @@ export interface PromptTraitsInput { * editing mode — only `disabled` should suspend the textarea, otherwise * users can type in shell mode but cannot delete or move the cursor. */ -export function computePromptTraits(input: PromptTraitsInput): EditorTraits { +export function computePromptTraits(input: PromptTraitsInput): PromptTraits { const capture = input.mode === "normal" ? input.autocompleteVisible @@ -27,5 +32,7 @@ export function computePromptTraits(input: PromptTraitsInput): EditorTraits { capture, suspend: input.disabled, status: input.mode === "shell" ? "SHELL" : undefined, + owner: "opencode", + role: "prompt", } } diff --git a/packages/opencode/src/cli/cmd/tui/component/textarea-keybindings.ts b/packages/opencode/src/cli/cmd/tui/component/textarea-keybindings.ts deleted file mode 100644 index 36ab03de545c..000000000000 --- a/packages/opencode/src/cli/cmd/tui/component/textarea-keybindings.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { createMemo } from "solid-js" -import type { KeyBinding } from "@opentui/core" -import { useKeybind } from "../context/keybind" -import { Keybind } from "@/util/keybind" - -const TEXTAREA_ACTIONS = [ - "submit", - "newline", - "move-left", - "move-right", - "move-up", - "move-down", - "select-left", - "select-right", - "select-up", - "select-down", - "line-home", - "line-end", - "select-line-home", - "select-line-end", - "visual-line-home", - "visual-line-end", - "select-visual-line-home", - "select-visual-line-end", - "buffer-home", - "buffer-end", - "select-buffer-home", - "select-buffer-end", - "delete-line", - "delete-to-line-end", - "delete-to-line-start", - "backspace", - "delete", - "undo", - "redo", - "word-forward", - "word-backward", - "select-word-forward", - "select-word-backward", - "delete-word-forward", - "delete-word-backward", -] as const - -function mapTextareaKeybindings( - keybinds: Record, - action: (typeof TEXTAREA_ACTIONS)[number], -): KeyBinding[] { - const configKey = `input_${action.replace(/-/g, "_")}` - const bindings = keybinds[configKey] - if (!bindings) return [] - return bindings.map((binding) => ({ - name: binding.name, - ctrl: binding.ctrl || undefined, - meta: binding.meta || undefined, - shift: binding.shift || undefined, - super: binding.super || undefined, - action, - })) -} - -export function useTextareaKeybindings() { - const keybind = useKeybind() - - return createMemo(() => { - const keybinds = keybind.all - - return [ - { name: "return", action: "submit" }, - { name: "return", meta: true, action: "newline" }, - ...TEXTAREA_ACTIONS.flatMap((action) => mapTextareaKeybindings(keybinds, action)), - ] satisfies KeyBinding[] - }) -} 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 new file mode 100644 index 000000000000..3be8382a9bac --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts @@ -0,0 +1,164 @@ +import type { KeyEvent, Renderable } from "@opentui/core" +import type { Binding } from "@opentui/keymap" +import { resolveBindingSections, type BindingSectionsConfig, type BindingValue } from "@opentui/keymap/extras" +import { ConfigKeybinds } from "@/config/keybinds" +import { KeymapLeaderTimeoutDefault, KeymapSectionNames, type KeymapInfo, type KeymapSection } from "./tui-schema" + +type LegacyKeybinds = ConfigKeybinds.Keybinds +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) { + config[section] ??= {} + config[section][command] = binding ?? "none" +} + +function bindingWith(key: string | undefined, input: Omit, "key" | "cmd">) { + if (!key || key === "none") return "none" + return { ...input, key } +} + +export function create(keybinds: LegacyKeybinds): KeymapInfo { + const config: SectionsConfig = {} + + add(config, "app", "command.palette.show", keybinds.command_list) + add(config, "app", "session.list", keybinds.session_list) + add(config, "app", "session.new", keybinds.session_new) + add(config, "app", "model.list", keybinds.model_list) + add(config, "app", "model.cycle_recent", keybinds.model_cycle_recent) + add(config, "app", "model.cycle_recent_reverse", keybinds.model_cycle_recent_reverse) + add(config, "app", "model.cycle_favorite", keybinds.model_cycle_favorite) + add(config, "app", "model.cycle_favorite_reverse", keybinds.model_cycle_favorite_reverse) + add(config, "app", "agent.list", keybinds.agent_list) + add(config, "app", "agent.cycle", keybinds.agent_cycle) + add(config, "app", "agent.cycle.reverse", keybinds.agent_cycle_reverse) + add(config, "app", "variant.cycle", keybinds.variant_cycle) + add(config, "app", "variant.list", keybinds.variant_list) + add(config, "app", "prompt.editor.shortcut", keybinds.editor_open) + add(config, "app", "opencode.status", keybinds.status_view) + add(config, "app", "theme.switch", keybinds.theme_list) + add(config, "app", "app.exit", keybinds.app_exit) + add(config, "app", "terminal.suspend", keybinds.terminal_suspend) + add(config, "app", "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_clear", "prompt.clear", keybinds.input_clear) + add(config, "prompt_paste", "prompt.paste", bindingWith(keybinds.input_paste, { preventDefault: false })) + add(config, "prompt_history_previous", "prompt.history.previous", keybinds.history_previous) + add(config, "prompt_history_next", "prompt.history.next", keybinds.history_next) + + add(config, "prompt_autocomplete", "prompt.autocomplete.prev", keybinds["prompt.autocomplete.prev"]) + add(config, "prompt_autocomplete", "prompt.autocomplete.next", keybinds["prompt.autocomplete.next"]) + add(config, "prompt_autocomplete", "prompt.autocomplete.hide", keybinds["prompt.autocomplete.hide"]) + add(config, "prompt_autocomplete", "prompt.autocomplete.select", keybinds["prompt.autocomplete.select"]) + add(config, "prompt_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_stash", "dialog.stash.delete", keybinds.stash_delete) + add(config, "dialog_session_list", "dialog.session.delete", keybinds.session_delete) + add(config, "dialog_session_list", "dialog.session.rename", keybinds.session_rename) + add(config, "dialog_model", "dialog.model.provider.list", keybinds.model_provider_list) + add(config, "dialog_model", "dialog.model.favorite.toggle", keybinds.model_favorite_toggle) + add(config, "dialog_mcp", "dialog.mcp.toggle", keybinds["dialog.mcp.toggle"]) + + add(config, "permission_reject", "permission.reject.cancel", keybinds.app_exit) + add(config, "permission_prompt_escape", "permission.prompt.escape", keybinds.app_exit) + add(config, "permission_prompt_fullscreen", "permission.prompt.fullscreen", keybinds["permission.prompt.fullscreen"]) + add(config, "question", "question.reject", keybinds.app_exit) + add(config, "question_edit", "question.edit.clear", keybinds.input_clear) + + add(config, "plugins", "plugins.list", keybinds.plugin_manager) + add(config, "dialog_plugins", "plugins.toggle", keybinds["plugins.toggle"]) + add(config, "dialog_plugins", "dialog.plugins.install", keybinds["dialog.plugins.install"]) + add(config, "home_tips", "tips.toggle", keybinds.tips_toggle) + + return { + leader: !keybinds.leader || keybinds.leader === "none" ? "ctrl+x" : keybinds.leader, + leader_timeout: KeymapLeaderTimeoutDefault, + sections: resolveBindingSections(config, { + sections: KeymapSectionNames, + }).sections, + } +} + +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 ed79e8e52418..e10a28411a12 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts @@ -1,4 +1,7 @@ import z from "zod" +import type { KeyEvent, Renderable } from "@opentui/core" +import type { Binding } from "@opentui/keymap" +import type { BindingSectionsConfig, BindingValue } from "@opentui/keymap/extras" import { ConfigPlugin } from "@/config/plugin" import { ConfigKeybinds } from "@/config/keybinds" @@ -11,6 +14,74 @@ const KeybindOverride = z ) .strict() +export const KeymapSectionNames = [ + "app", + "session", + "prompt", + "prompt_clear", + "prompt_paste", + "prompt_history_previous", + "prompt_history_next", + "prompt_autocomplete", + "input", + "dialog_select", + "dialog_stash", + "dialog_session_list", + "dialog_model", + "dialog_mcp", + "permission_reject", + "permission_prompt_escape", + "permission_prompt_fullscreen", + "question", + "question_edit", + "plugins", + "dialog_plugins", + "home_tips", +] as const + +export type KeymapSection = (typeof KeymapSectionNames)[number] +export type KeymapSections = Record[]> +export const KeymapLeaderTimeoutDefault = 2000 +export type KeymapInfo = { + leader: string + leader_timeout: number + sections: KeymapSections +} + +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 KeymapSectionsConfig = z.record(z.string(), z.record(z.string(), KeymapBindingValue)) + +export const KeymapConfig = z + .object({ + leader: z.string().optional(), + leader_timeout: z.number().int().positive().optional().describe("Leader key timeout in milliseconds"), + sections: KeymapSectionsConfig.optional(), + }) + .strict() + .describe("TUI keymap configuration") +export type KeymapConfig = z.output + export const TuiOptions = z.object({ scroll_speed: z.number().min(0.001).optional().describe("TUI scroll speed"), scroll_acceleration: z @@ -30,7 +101,11 @@ export const TuiInfo = z .object({ $schema: z.string().optional(), theme: z.string().optional(), - keybinds: KeybindOverride.optional(), + keybinds: KeybindOverride.optional().meta({ + deprecated: true, + description: "Use keymap instead. This will be removed in opencode v2.0.", + }), + keymap: KeymapConfig.optional(), plugin: ConfigPlugin.Spec.zod.array().optional(), plugin_enabled: z.record(z.string(), z.boolean()).optional(), }) diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts index 890f73622853..78d31b0aff4a 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts @@ -1,6 +1,8 @@ export * as TuiConfig from "./tui" -import z from "zod" +import type z from "zod" +import type { KeyEvent, Renderable } from "@opentui/core" +import { resolveBindingSections, type BindingSectionsConfig } from "@opentui/keymap/extras" import { mergeDeep, unique } from "remeda" import { Context, Effect, Fiber, Layer } from "effect" import { ConfigParse } from "@/config/parse" @@ -20,27 +22,54 @@ 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 { KeymapLeaderTimeoutDefault, KeymapSectionNames, type KeymapInfo, type KeymapSection } from "./tui-schema" +import type { Binding } from "@opentui/keymap" const log = Log.create({ service: "tui.config" }) export const Info = TuiInfo +export type Info = z.output type Acc = { result: Info + plugin_origins: ConfigPlugin.Origin[] } -type State = { - config: Info - deps: Array> -} - -export type Info = z.output & { +const KeymapSectionGroups = { + app: "Global", + session: "Session", + prompt: "Prompt", + prompt_clear: "Prompt", + prompt_paste: "Prompt", + prompt_history_previous: "Prompt", + prompt_history_next: "Prompt", + prompt_autocomplete: "Autocomplete", + input: "Text Editing", + dialog_select: "Dialog", + dialog_stash: "Stash", + dialog_session_list: "Session", + dialog_model: "Agent", + dialog_mcp: "Agent", + permission_reject: "Permission", + permission_prompt_escape: "Permission", + permission_prompt_fullscreen: "Permission", + question: "Question", + question_edit: "Question", + plugins: "Plugins", + dialog_plugins: "Plugins", + home_tips: "Home", +} satisfies Record + +export type Resolved = Omit & { + keybinds: ConfigKeybinds.Keybinds + keymap: KeymapInfo // Internal resolved plugin list used by runtime loading. plugin_origins?: ConfigPlugin.Origin[] } export interface Interface { - readonly get: () => Effect.Effect + readonly get: () => Effect.Effect readonly waitForDependencies: () => Effect.Effect } @@ -68,6 +97,21 @@ function normalize(raw: Record) { } } +function withDefaultGroups(sections: KeymapInfo["sections"]): KeymapInfo["sections"] { + return Object.fromEntries( + KeymapSectionNames.map((section) => [ + section, + sections[section].map((binding) => { + if ((binding as Binding & { group?: unknown }).group !== undefined) return binding + return { + ...binding, + group: KeymapSectionGroups[section], + } + }), + ]), + ) as KeymapInfo["sections"] +} + const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: string }) { const afs = yield* AppFileSystem.Service @@ -128,11 +172,11 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: const scope = pluginScope(file, ctx) const plugins = ConfigPlugin.deduplicatePluginOrigins([ - ...(acc.result.plugin_origins ?? []), + ...acc.plugin_origins, ...data.plugin.map((spec) => ({ spec, scope, source: file })), ]) acc.result.plugin = plugins.map((item) => item.spec) - acc.result.plugin_origins = plugins + acc.plugin_origins = plugins }) // Every config dir we may read from: global config dir, any `.opencode` @@ -144,6 +188,7 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: const acc: Acc = { result: {}, + plugin_origins: [], } // 1. Global tui config (lowest precedence). @@ -184,11 +229,38 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: ...ConfigKeybinds.Keybinds.shape.input_undo.parse(undefined).split(","), ]).join(",") } - acc.result.keybinds = ConfigKeybinds.Keybinds.parse(keybinds) + const parsedKeybinds = ConfigKeybinds.Keybinds.parse(keybinds) + const configuredKeymap = acc.result.keymap + const keymap = configuredKeymap + ? { + leader: !configuredKeymap.leader || configuredKeymap.leader === "none" ? "ctrl+x" : configuredKeymap.leader, + leader_timeout: configuredKeymap.leader_timeout ?? KeymapLeaderTimeoutDefault, + sections: resolveBindingSections< + Renderable, + KeyEvent, + BindingSectionsConfig, + KeymapSection + >(configuredKeymap.sections ?? {}, { + sections: KeymapSectionNames, + }).sections, + } + : LegacyKeymapTransform.create(parsedKeybinds) + const result: Resolved = { + ...acc.result, + keybinds: parsedKeybinds, + 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: { + ...keymap, + sections: withDefaultGroups(keymap.sections), + }, + } return { - config: acc.result, - dirs: acc.result.plugin?.length ? dirs : [], + config: result, + dirs: result.plugin?.length ? dirs : [], } }) diff --git a/packages/opencode/src/cli/cmd/tui/context/command-palette.tsx b/packages/opencode/src/cli/cmd/tui/context/command-palette.tsx new file mode 100644 index 000000000000..07cca99074c9 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/context/command-palette.tsx @@ -0,0 +1,163 @@ +import { createContext, createMemo, createSignal, useContext, type Accessor, type ParentProps } from "solid-js" +import { DialogSelect, type DialogSelectRef } from "@tui/ui/dialog-select" +import { useDialog, type DialogContext } from "@tui/ui/dialog" +import { + formatKeyBindings, + reactiveMatcherFromSignal, + type OpenTuiKeymap, + useKeymapSelector, + useOpencodeKeymap, +} from "../keymap" +import { useTuiConfig } from "./tui-config" + +type SlashEntry = { + display: string + description?: string + aliases?: string[] + onSelect: () => void +} + +type CommandPaletteContext = { + run(command: string): void + show(): void + slashes: Accessor + suspend(enabled: boolean): void + readonly suspended: boolean + matcher: ReturnType +} + +const COMMAND_PALETTE_DIALOG = "command.palette.show" +const ctx = createContext() +type PaletteCommandEntry = ReturnType[number] + +function isVisiblePaletteCommand(entry: PaletteCommandEntry) { + return entry.command.hidden !== true && entry.command.name !== COMMAND_PALETTE_DIALOG +} + +function isSuggestedPaletteCommand(entry: PaletteCommandEntry) { + const suggested = entry.command.suggested + if (typeof suggested === "boolean") return suggested + if (typeof suggested === "function") return suggested() === true + return false +} + +export function CommandPaletteProvider(props: ParentProps) { + const dialog = useDialog() + const keymap = useOpencodeKeymap() + const [suspendCount, setSuspendCount] = createSignal(0) + const entries = useKeymapSelector((keymap: OpenTuiKeymap) => + keymap + .getCommandEntries({ + visibility: "reachable", + namespace: "palette", + }) + .filter(isVisiblePaletteCommand), + ) + + const run = (command: string) => { + keymap.dispatchCommand(command) + } + + const slashes = createMemo(() => + entries().flatMap((entry) => { + const slashName = entry.command.slashName + if (typeof slashName !== "string" || !slashName) return [] + const slashAliases = entry.command.slashAliases + return { + display: `/${slashName}`, + description: + typeof entry.command.desc === "string" + ? entry.command.desc + : typeof entry.command.title === "string" + ? entry.command.title + : undefined, + aliases: Array.isArray(slashAliases) + ? slashAliases.filter((alias): alias is string => typeof alias === "string").map((alias) => `/${alias}`) + : undefined, + onSelect: () => run(entry.command.name), + } + }), + ) + + const value: CommandPaletteContext = { + run, + show() { + dialog.replace(() => ) + }, + slashes, + suspend(enabled: boolean) { + setSuspendCount((count) => Math.max(0, count + (enabled ? 1 : -1))) + }, + get suspended() { + return suspendCount() > 0 || dialog.stack.length > 0 + }, + matcher: reactiveMatcherFromSignal(() => suspendCount() === 0 && dialog.stack.length === 0), + } + + return {props.children} +} + +export function useCommandPalette() { + const value = useContext(ctx) + if (!value) throw new Error("CommandPalette context must be used within a CommandPaletteProvider") + return value +} + +function CommandPaletteDialog(props: { run(command: string): void }) { + const config = useTuiConfig() + const entries = useKeymapSelector((keymap: OpenTuiKeymap) => { + const query = { + namespace: "palette", + } + const reachable = keymap + .getCommandEntries({ + ...query, + visibility: "reachable", + }) + .filter(isVisiblePaletteCommand) + const registeredBindings = keymap.getCommandBindings({ + visibility: "registered", + commands: reachable.map((entry) => entry.command.name), + }) + + return reachable.map((entry) => ({ + ...entry, + bindings: registeredBindings.get(entry.command.name) ?? entry.bindings, + })) + }) + const options = createMemo(() => + entries().map((entry) => ({ + title: typeof entry.command.title === "string" ? entry.command.title : entry.command.name, + description: typeof entry.command.desc === "string" ? entry.command.desc : undefined, + category: typeof entry.command.category === "string" ? entry.command.category : undefined, + footer: formatKeyBindings(entry.bindings, config), + value: entry.command.name, + suggested: isSuggestedPaletteCommand(entry), + onSelect: (dialog: DialogContext) => { + dialog.clear() + props.run(entry.command.name) + }, + })), + ) + + let ref: DialogSelectRef + const list = () => { + if (ref?.filter) return options() + return [ + ...options() + .filter((option) => option.suggested) + .map((option) => ({ + ...option, + value: `suggested:${option.value}`, + category: "Suggested", + })), + ...options(), + ] + } + + return (ref = value)} title="Commands" options={list()} /> +} + +export function useCommandSlashes(): Accessor { + return useCommandPalette().slashes +} diff --git a/packages/opencode/src/cli/cmd/tui/context/keybind.tsx b/packages/opencode/src/cli/cmd/tui/context/keybind.tsx deleted file mode 100644 index 2c1ab245a50c..000000000000 --- a/packages/opencode/src/cli/cmd/tui/context/keybind.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { createMemo } from "solid-js" -import { Keybind } from "@/util/keybind" -import { pipe, mapValues } from "remeda" -import type { TuiConfig } from "@/cli/cmd/tui/config/tui" -import type { ParsedKey, Renderable } from "@opentui/core" -import { createStore } from "solid-js/store" -import { useKeyboard, useRenderer } from "@opentui/solid" -import { createSimpleContext } from "./helper" -import { useTuiConfig } from "./tui-config" - -export type KeybindKey = keyof NonNullable & string - -export const { use: useKeybind, provider: KeybindProvider } = createSimpleContext({ - name: "Keybind", - init: () => { - const config = useTuiConfig() - const keybinds = createMemo>(() => { - return pipe( - (config.keybinds ?? {}) as Record, - mapValues((value) => Keybind.parse(value)), - ) - }) - const [store, setStore] = createStore({ - leader: false, - }) - const renderer = useRenderer() - - let focus: Renderable | null - let timeout: NodeJS.Timeout - function leader(active: boolean) { - if (active) { - setStore("leader", true) - focus = renderer.currentFocusedRenderable - focus?.blur() - if (timeout) clearTimeout(timeout) - timeout = setTimeout(() => { - if (!store.leader) return - leader(false) - if (!focus || focus.isDestroyed) return - focus.focus() - }, 2000) - return - } - - if (!active) { - if (focus && !renderer.currentFocusedRenderable) { - focus.focus() - } - setStore("leader", false) - } - } - - useKeyboard(async (evt) => { - if (!store.leader && result.match("leader", evt)) { - leader(true) - return - } - - if (store.leader && evt.name) { - setImmediate(() => { - if (focus && renderer.currentFocusedRenderable === focus) { - focus.focus() - } - leader(false) - }) - } - }) - - const result = { - get all() { - return keybinds() - }, - get leader() { - return store.leader - }, - parse(evt: ParsedKey): Keybind.Info { - // Handle special case for Ctrl+Underscore (represented as \x1F) - if (evt.name === "\x1F") { - return Keybind.fromParsedKey({ ...evt, name: "_", ctrl: true }, store.leader) - } - return Keybind.fromParsedKey(evt, store.leader) - }, - match(key: string, evt: ParsedKey) { - const list = keybinds()[key] ?? Keybind.parse(key) - if (!list.length) return false - const parsed: Keybind.Info = result.parse(evt) - for (const item of list) { - if (Keybind.match(item, parsed)) { - return true - } - } - return false - }, - print(key: string) { - const first = keybinds()[key]?.at(0) ?? Keybind.parse(key).at(0) - if (!first) return "" - const text = Keybind.toString(first) - const lead = keybinds().leader?.[0] - if (!lead) return text - return text.replace("", Keybind.toString(lead)) - }, - } - return result - }, -}) diff --git a/packages/opencode/src/cli/cmd/tui/context/plugin-keybinds.ts b/packages/opencode/src/cli/cmd/tui/context/plugin-keybinds.ts deleted file mode 100644 index a84e10128c39..000000000000 --- a/packages/opencode/src/cli/cmd/tui/context/plugin-keybinds.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { ParsedKey } from "@opentui/core" - -export type PluginKeybindMap = Record - -type Base = { - match: (key: string, evt: ParsedKey) => boolean - print: (key: string) => string -} - -export type PluginKeybind = { - readonly all: PluginKeybindMap - get: (name: string) => string - match: (name: string, evt: ParsedKey) => boolean - print: (name: string) => string -} - -const txt = (value: unknown) => { - if (typeof value !== "string") return - if (!value.trim()) return - return value -} - -export function createPluginKeybind( - base: Base, - defaults: PluginKeybindMap, - overrides?: Record, -): PluginKeybind { - const all = Object.freeze( - Object.fromEntries(Object.entries(defaults).map(([name, value]) => [name, txt(overrides?.[name]) ?? value])), - ) - const get = (name: string) => all[name] ?? name - - return { - get all() { - return all - }, - get, - match: (name, evt) => base.match(get(name), evt), - print: (name) => base.print(get(name)), - } -} diff --git a/packages/opencode/src/cli/cmd/tui/context/tui-config.tsx b/packages/opencode/src/cli/cmd/tui/context/tui-config.tsx index 05fdd025c7ac..9691ae595969 100644 --- a/packages/opencode/src/cli/cmd/tui/context/tui-config.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/tui-config.tsx @@ -3,7 +3,7 @@ import { createSimpleContext } from "./helper" export const { use: useTuiConfig, provider: TuiConfigProvider } = createSimpleContext({ name: "TuiConfig", - init: (props: { config: TuiConfig.Info }) => { + init: (props: { config: TuiConfig.Resolved }) => { return props.config }, }) 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 26c03ee347bb..a9542fc127a5 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 @@ -1,10 +1,27 @@ -import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui" +import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui" import { createMemo, Show } from "solid-js" import { Tips } from "./tips-view" +import { useBindings } from "../../keymap" const id = "internal:home-tips" -function View(props: { show: boolean; connected: boolean }) { +function View(props: { api: TuiPluginApi; hidden: boolean; show: boolean; connected: boolean }) { + useBindings(() => ({ + commands: [ + { + name: "tips.toggle", + title: props.hidden ? "Show tips" : "Hide tips", + category: "System", + namespace: "palette", + run() { + props.api.kv.set("tips_hidden", !props.api.kv.get("tips_hidden", false)) + props.api.ui.dialog.clear() + }, + }, + ], + bindings: props.api.tuiConfig.keymap.sections.home_tips, + })) + return ( @@ -15,20 +32,6 @@ function View(props: { show: boolean; connected: boolean }) { } const tui: TuiPlugin = async (api) => { - api.command.register(() => [ - { - title: api.kv.get("tips_hidden", false) ? "Show tips" : "Hide tips", - value: "tips.toggle", - keybind: "tips_toggle", - category: "System", - hidden: api.route.current.name !== "home", - onSelect() { - api.kv.set("tips_hidden", !api.kv.get("tips_hidden", false)) - api.ui.dialog.clear() - }, - }, - ]) - api.slots.register({ order: 100, slots: { @@ -41,7 +44,7 @@ const tui: TuiPlugin = async (api) => { ), ) const show = createMemo(() => (!first() || !connected()) && !hidden()) - return + return )} @@ -154,6 +148,7 @@ function showInstall(api: TuiPluginApi) { function View(props: { api: TuiPluginApi }) { const size = useTerminalDimensions() + const sections = props.api.tuiConfig.keymap.sections const [list, setList] = createSignal(props.api.plugins.list()) const [cur, setCur] = createSignal() const [lock, setLock] = createSignal(false) @@ -209,10 +204,10 @@ function View(props: { api: TuiPluginApi }) { options={rows()} current={cur()} onMove={(item) => setCur(item.value)} - keybind={[ + actions={[ { title: "toggle", - keybind: key, + command: "plugins.toggle", disabled: lock(), onTrigger: (item) => { setCur(item.value) @@ -221,13 +216,14 @@ function View(props: { api: TuiPluginApi }) { }, { title: "install", - keybind: add, + command: "dialog.plugins.install", disabled: lock(), onTrigger: () => { showInstall(props.api) }, }, ]} + bindings={sections.dialog_plugins} onSelect={(item) => { setCur(item.value) flip(item.value) @@ -241,25 +237,29 @@ function show(api: TuiPluginApi) { } const tui: TuiPlugin = async (api) => { - api.command.register(() => [ - { - title: "Plugins", - value: "plugins.list", - keybind: "plugin_manager", - category: "System", - onSelect() { - show(api) + api.keymap.registerLayer({ + commands: [ + { + name: "plugins.list", + title: "Plugins", + category: "System", + namespace: "palette", + run() { + show(api) + }, }, - }, - { - title: "Install plugin", - value: "plugins.install", - category: "System", - onSelect() { - showInstall(api) + { + name: "plugins.install", + title: "Install plugin", + category: "System", + namespace: "palette", + run() { + showInstall(api) + }, }, - }, - ]) + ], + bindings: api.tuiConfig.keymap.sections.plugins, + }) } const plugin: TuiPluginModule & { id: string } = { diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx index 2e5cea9804e3..0d899a8bae67 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx @@ -4,8 +4,9 @@ import { SplitBorder } from "@tui/component/border" import { Spinner } from "@tui/component/spinner" import { useTheme } from "@tui/context/theme" import { useLocal } from "@tui/context/local" -import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" +import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import { TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/core" +import { useBindings } from "../../keymap" import { Locale } from "@/util/locale" import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import path from "path" @@ -53,12 +54,16 @@ function View(props: { api: TuiPluginApi; sessionID: string }) { void sync.session.message.sync(props.sessionID) }) - useKeyboard((event) => { - if (event.name !== "escape") return - event.preventDefault() - event.stopPropagation() - props.api.route.navigate("session", { sessionID: props.sessionID }) - }) + useBindings(() => ({ + bindings: [ + { + key: "escape", + cmd() { + props.api.route.navigate("session", { sessionID: props.sessionID }) + }, + }, + ], + })) return ( @@ -1113,21 +1118,24 @@ const tui: TuiPlugin = async (api) => { }, ]) - api.command.register(() => [ - { - title: "View v2 session messages", - value: route, - category: "Debug", - suggested: api.route.current.name === "session", - enabled: api.route.current.name === "session", - onSelect() { - const sessionID = currentSessionID(api) - if (!sessionID) return - api.route.navigate(route, { sessionID }) - api.ui.dialog.clear() + api.keymap.registerLayer({ + commands: [ + { + name: route, + title: "View v2 session messages", + category: "Debug", + namespace: "palette", + suggested: () => api.route.current.name === "session", + enabled: () => api.route.current.name === "session", + run() { + const sessionID = currentSessionID(api) + if (!sessionID) return + api.route.navigate(route, { sessionID }) + api.ui.dialog.clear() + }, }, - }, - ]) + ], + }) } const plugin: TuiPluginModule & { id: string } = { diff --git a/packages/opencode/src/cli/cmd/tui/keymap.tsx b/packages/opencode/src/cli/cmd/tui/keymap.tsx new file mode 100644 index 000000000000..0d65057d79fe --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/keymap.tsx @@ -0,0 +1,91 @@ +import { type CliRenderer } from "@opentui/core" +import * as addons from "@opentui/keymap/addons/opentui" +import { + formatCommandBindings as formatCommandBindingsExtra, + formatKeySequence as formatKeySequenceExtra, +} from "@opentui/keymap/extras" +import { + KeymapProvider, + reactiveMatcherFromSignal, + useBindings, + useKeymap, + useKeymapSelector, +} from "@opentui/keymap/solid" +import type { Accessor } from "solid-js" +import type { TuiConfig } from "./config/tui" +import { useTuiConfig } from "./context/tui-config" + +export const LEADER_TOKEN = "leader" + +export const OpencodeKeymapProvider = KeymapProvider +export const useOpencodeKeymap = useKeymap + +export { reactiveMatcherFromSignal, useBindings, useKeymapSelector } + +export type OpenTuiKeymap = ReturnType + +function formatOptions(config: TuiConfig.Resolved) { + return { + tokenDisplay: { + [LEADER_TOKEN]: config.keymap.leader, + }, + keyNameAliases: { + pageup: "pgup", + pagedown: "pgdn", + delete: "del", + }, + modifierAliases: { + meta: "alt", + }, + } as const +} + +export function formatKeySequence(parts: Parameters[0], config: TuiConfig.Resolved) { + return formatKeySequenceExtra(parts, formatOptions(config)) +} + +export function formatKeyBindings( + bindings: Parameters[0], + config: TuiConfig.Resolved, +) { + return formatCommandBindingsExtra(bindings, formatOptions(config)) +} + +export function registerOpencodeKeymap(keymap: OpenTuiKeymap, renderer: CliRenderer, config: TuiConfig.Resolved) { + const offCommaBindings = addons.registerCommaBindings(keymap) + const offBaseLayout = addons.registerBaseLayoutFallback(keymap) + const offLeader = addons.registerTimedLeader(keymap, { + trigger: config.keymap.leader, + name: LEADER_TOKEN, + timeoutMs: config.keymap.leader_timeout, + }) + const offEscape = addons.registerEscapeClearsPendingSequence(keymap) + const offBackspace = addons.registerBackspacePopsPendingSequence(keymap) + const offInputBindings = addons.registerManagedTextareaLayer(keymap, renderer, { + enabled: () => renderer.currentFocusedEditor !== null, + bindings: config.keymap.sections.input, + }) + + return () => { + offInputBindings() + offBackspace() + offEscape() + offLeader() + offBaseLayout() + offCommaBindings() + } +} + +export function useCommandShortcut(command: string): Accessor { + const config = useTuiConfig() + return useKeymapSelector((keymap) => + formatKeySequence( + keymap.getCommandBindings({ visibility: "registered", commands: [command] }).get(command)?.[0]?.sequence, + config, + ), + ) +} + +export function useLeaderActive(): Accessor { + return useKeymapSelector((keymap: OpenTuiKeymap) => keymap.getPendingSequence()[0]?.tokenName === LEADER_TOKEN) +} diff --git a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx index 25ea3ac9edb4..7b7ce0bbb533 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx +++ b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx @@ -1,15 +1,12 @@ -import type { ParsedKey } from "@opentui/core" import type { TuiDialogSelectOption, TuiPluginApi, TuiRouteDefinition, TuiSlotProps } from "@opencode-ai/plugin/tui" -import type { useCommandDialog } from "@tui/component/dialog-command" import type { useEvent } from "@tui/context/event" -import type { useKeybind } from "@tui/context/keybind" import type { useRoute } from "@tui/context/route" import type { useSDK } from "@tui/context/sdk" import type { useSync } from "@tui/context/sync" import type { useTheme } from "@tui/context/theme" import { Dialog as DialogUI, type useDialog } from "@tui/ui/dialog" import type { TuiConfig } from "@/cli/cmd/tui/config/tui" -import { createPluginKeybind } from "../context/plugin-keybinds" +import type { useOpencodeKeymap } from "../keymap" import type { useKV } from "../context/kv" import { DialogAlert } from "../ui/dialog-alert" import { DialogConfirm } from "../ui/dialog-confirm" @@ -19,6 +16,7 @@ import { Prompt } from "../component/prompt" import { Slot as HostSlot } from "./slots" import type { useToast } from "../ui/toast" import { InstallationVersion } from "@opencode-ai/core/installation/version" +import * as Keymap from "../keymap" type RouteEntry = { key: symbol @@ -28,10 +26,9 @@ type RouteEntry = { export type RouteMap = Map type Input = { - command: ReturnType - tuiConfig: TuiConfig.Info + tuiConfig: TuiConfig.Resolved dialog: ReturnType - keybind: ReturnType + keymap: ReturnType kv: ReturnType route: ReturnType routes: RouteMap @@ -201,20 +198,17 @@ export function createTuiApi(input: Input): TuiPluginApi { return () => {} }, } - return { app: appApi(), - command: { - register(cb) { - return input.command.register(() => cb()) - }, - trigger(value) { - input.command.trigger(value) + keys: { + formatSequence(parts) { + return Keymap.formatKeySequence(parts, input.tuiConfig) }, - show() { - input.command.show() + formatBindings(bindings) { + return Keymap.formatKeyBindings(bindings, input.tuiConfig) }, }, + keymap: input.keymap, route: { register(list) { return routeRegister(input.routes, list, input.bump) @@ -306,17 +300,6 @@ export function createTuiApi(input: Input): TuiPluginApi { }, }, }, - keybind: { - match(key, evt: ParsedKey) { - return input.keybind.match(key, evt) - }, - print(key) { - return input.keybind.print(key) - }, - create(defaults, overrides) { - return createPluginKeybind(input.keybind, defaults, overrides) - }, - }, get tuiConfig() { return input.tuiConfig }, diff --git a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts index 73193d142e1d..a43f62deecff 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts @@ -1,4 +1,5 @@ -import "@opentui/solid/runtime-plugin-support" +import { runtimeModules as keymapRuntimeModules } from "@opentui/keymap/runtime-modules" +import { ensureRuntimePluginSupport } from "@opentui/solid/runtime-plugin-support/configure" import { type TuiDispose, type TuiPlugin, @@ -39,6 +40,8 @@ import { setupSlots, Slot as View } from "./slots" import type { HostPluginApi, HostSlots } from "./slots" import { ConfigPlugin } from "@/config/plugin" +ensureRuntimePluginSupport({ additional: keymapRuntimeModules }) + type PluginLoad = { options: ConfigPlugin.Options | undefined spec: string @@ -70,6 +73,36 @@ type PluginEntry = { scope?: PluginScope } +const ScopedKeymapMethods = new Set([ + "acquireResource", + "registerLayer", + "registerLayerFields", + "prependLayerBindingsTransformer", + "appendLayerBindingsTransformer", + "prependBindingTransformer", + "appendBindingTransformer", + "prependBindingParser", + "appendBindingParser", + "registerToken", + "registerSequencePattern", + "prependBindingExpander", + "appendBindingExpander", + "registerBindingFields", + "registerCommandFields", + "prependCommandTransformer", + "appendCommandTransformer", + "prependCommandResolver", + "appendCommandResolver", + "prependLayerAnalyzer", + "appendLayerAnalyzer", + "intercept", + "on", + "prependEventMatchResolver", + "appendEventMatchResolver", + "prependDisambiguationResolver", + "appendDisambiguationResolver", +]) + type RuntimeState = { directory: string api: Api @@ -104,6 +137,25 @@ function warn(message: string, data: Record) { console.warn(`[tui.plugin] ${message}`, data) } +function createScopedKeymap(keymap: TuiPluginApi["keymap"], scope: PluginScope): TuiPluginApi["keymap"] { + const cache = new Map() + return new Proxy(keymap, { + get(target, prop) { + const value = Reflect.get(target, prop, target) + if (typeof value !== "function") return value + if (cache.has(prop)) return cache.get(prop) + const fn = ScopedKeymapMethods.has(prop) + ? (...args: unknown[]) => { + const dispose = (value as (...args: unknown[]) => unknown).apply(target, args) + return scope.track(typeof dispose === "function" ? (dispose as () => void) : undefined) + } + : (...args: unknown[]) => (value as (...args: unknown[]) => unknown).apply(target, args) + cache.set(prop, fn) + return fn + }, + }) +} + type CleanupResult = { type: "ok" } | { type: "error"; error: unknown } | { type: "timeout" } function runCleanup(fn: () => unknown, ms: number): Promise { @@ -327,14 +379,16 @@ function createPluginScope(load: PluginLoad, id: string) { const track = (fn: (() => void) | undefined) => { if (!fn) return () => {} - const off = onDispose(fn) let drop = false - return () => { + let off = () => {} + const wrapped = () => { if (drop) return drop = true off() fn() } + off = onDispose(wrapped) + return wrapped } const lifecycle: TuiPluginApi["lifecycle"] = { @@ -395,7 +449,7 @@ function readPluginEnabledMap(value: unknown) { ) } -function pluginEnabledState(state: RuntimeState, config: TuiConfig.Info) { +function pluginEnabledState(state: RuntimeState, config: TuiConfig.Resolved) { return { ...readPluginEnabledMap(config.plugin_enabled), ...readPluginEnabledMap(state.api.kv.get(KV_KEY, {})), @@ -484,17 +538,6 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop const api = runtime.api const host = runtime.slots const load = plugin.load - const command: TuiPluginApi["command"] = { - register(cb) { - return scope.track(api.command.register(cb)) - }, - trigger(value) { - api.command.trigger(value) - }, - show() { - api.command.show() - }, - } const route: TuiPluginApi["route"] = { register(list) { @@ -518,6 +561,8 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop }, } + const keymap = createScopedKeymap(api.keymap, scope) + let count = 0 const slots: TuiPluginApi["slots"] = { @@ -531,10 +576,10 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop return { app: api.app, - command, + keys: api.keys, + keymap, route, ui: api.ui, - keybind: api.keybind, tuiConfig: api.tuiConfig, kv: api.kv, state: api.state, @@ -580,7 +625,7 @@ function addPluginEntry(state: RuntimeState, plugin: PluginEntry) { return true } -function applyInitialPluginEnabledState(state: RuntimeState, config: TuiConfig.Info) { +function applyInitialPluginEnabledState(state: RuntimeState, config: TuiConfig.Resolved) { const map = pluginEnabledState(state, config) for (const plugin of state.plugins) { const enabled = map[plugin.id] @@ -923,7 +968,7 @@ let loaded: Promise | undefined let runtime: RuntimeState | undefined export const Slot = View -export async function init(input: { api: HostPluginApi; config: TuiConfig.Info }) { +export async function init(input: { api: HostPluginApi; config: TuiConfig.Resolved }) { const cwd = process.cwd() if (loaded) { if (dir !== cwd) { @@ -972,7 +1017,7 @@ export async function dispose() { } } -async function load(input: { api: Api; config: TuiConfig.Info }) { +async function load(input: { api: Api; config: TuiConfig.Resolved }) { const { api, config } = input const cwd = process.cwd() const slots = setupSlots(api) 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 d43edd2dd5d7..81df91805989 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -49,12 +49,10 @@ import type { WebSearchTool } from "@/tool/websearch" import type { TaskTool } from "@/tool/task" import type { QuestionTool } from "@/tool/question" import type { SkillTool } from "@/tool/skill" -import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" +import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import { useSDK } from "@tui/context/sdk" import { useEditorContext } from "@tui/context/editor" -import { useCommandDialog } from "@tui/component/dialog-command" import type { DialogContext } from "@tui/ui/dialog" -import { useKeybind } from "@tui/context/keybind" import { useDialog } from "../../ui/dialog" import { TodoItem } from "../../component/todo-item" import { DialogMessage } from "./dialog-message" @@ -90,6 +88,8 @@ import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime" import { DialogGoUpsell } from "../../component/dialog-go-upsell" import { SessionRetry } from "@/session/retry" import { getRevertDiffFiles } from "../../util/revert-diff" +import { useCommandPalette } from "../../context/command-palette" +import { useBindings, useCommandShortcut } from "../../keymap" addDefaultParsers(parsers.parsers) @@ -124,6 +124,9 @@ 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() @@ -250,7 +253,7 @@ export function Session() { seeded = true r.set(route.prompt) } - const keybind = useKeybind() + const command = useCommandPalette() const dialog = useDialog() const renderer = useRenderer() @@ -271,7 +274,6 @@ export function Session() { }) }) - // Allow exit when in child session (prompt is hidden) const exit = useExit() createEffect(() => { @@ -293,13 +295,6 @@ export function Session() { ) }) - useKeyboard((evt) => { - if (!session()?.parentID) return - if (keybind.match("app_exit", evt)) { - void exit() - } - }) - // Helper: Find next visible message boundary in direction const findNextVisibleMessage = (direction: "next" | "prev"): string | null => { const children = scroll.getChildren() @@ -382,26 +377,24 @@ export function Session() { } } - function childSessionHandler(func: (dialog: DialogContext) => void) { - return (dialog: DialogContext) => { + function childSessionHandler(func: () => void) { + return () => { if (!session()?.parentID || dialog.stack.length > 0) return - func(dialog) + func() } } - const command = useCommandDialog() - command.register(() => [ + const sessionCommandList = createMemo(() => [ { title: session()?.share?.url ? "Copy share link" : "Share session", value: "session.share", suggested: route.type === "session", - keybind: "session_share", category: "Session", enabled: sync.data.config.share !== "disabled", slash: { name: "share", }, - onSelect: async (dialog) => { + run: async () => { const copy = (url: string) => Clipboard.copy(url) .then(() => toast.show({ message: "Share URL copied to clipboard!", variant: "success" })) @@ -434,24 +427,22 @@ export function Session() { { title: "Rename session", value: "session.rename", - keybind: "session_rename", category: "Session", slash: { name: "rename", }, - onSelect: (dialog) => { + run: () => { dialog.replace(() => ) }, }, { title: "Jump to message", value: "session.timeline", - keybind: "session_timeline", category: "Session", slash: { name: "timeline", }, - onSelect: (dialog) => { + run: () => { dialog.replace(() => ( { @@ -469,12 +460,11 @@ export function Session() { { title: "Fork session", value: "session.fork", - keybind: "session_fork", category: "Session", slash: { name: "fork", }, - onSelect: (dialog) => { + run: () => { dialog.replace(() => ( { @@ -492,13 +482,12 @@ export function Session() { { title: "Compact session", value: "session.compact", - keybind: "session_compact", category: "Session", slash: { name: "compact", aliases: ["summarize"], }, - onSelect: (dialog) => { + run: () => { const selectedModel = local.model.current() if (!selectedModel) { toast.show({ @@ -519,13 +508,12 @@ export function Session() { { title: "Unshare session", value: "session.unshare", - keybind: "session_unshare", category: "Session", enabled: !!session()?.share?.url, slash: { name: "unshare", }, - onSelect: async (dialog) => { + run: async () => { await sdk.client.session .unshare({ sessionID: route.sessionID, @@ -543,12 +531,11 @@ export function Session() { { title: "Undo previous message", value: "session.undo", - keybind: "messages_undo", category: "Session", slash: { name: "undo", }, - onSelect: async (dialog) => { + run: async () => { const status = sync.data.session_status?.[route.sessionID] if (status?.type !== "idle") await sdk.client.session.abort({ sessionID: route.sessionID }).catch(() => {}) const revert = session()?.revert?.messageID @@ -581,13 +568,12 @@ export function Session() { { title: "Redo", value: "session.redo", - keybind: "messages_redo", category: "Session", enabled: !!session()?.revert?.messageID, slash: { name: "redo", }, - onSelect: (dialog) => { + run: () => { dialog.clear() const messageID = session()?.revert?.messageID if (!messageID) return @@ -608,9 +594,8 @@ export function Session() { { title: sidebarVisible() ? "Hide sidebar" : "Show sidebar", value: "session.sidebar.toggle", - keybind: "sidebar_toggle", category: "Session", - onSelect: (dialog) => { + run: () => { batch(() => { const isVisible = sidebarVisible() setSidebar(() => (isVisible ? "hide" : "auto")) @@ -622,9 +607,8 @@ export function Session() { { title: conceal() ? "Disable code concealment" : "Enable code concealment", value: "session.toggle.conceal", - keybind: "messages_toggle_conceal", category: "Session", - onSelect: (dialog) => { + run: () => { setConceal((prev) => !prev) dialog.clear() }, @@ -637,7 +621,7 @@ export function Session() { name: "timestamps", aliases: ["toggle-timestamps"], }, - onSelect: (dialog) => { + run: () => { setTimestamps((prev) => (prev === "show" ? "hide" : "show")) dialog.clear() }, @@ -645,13 +629,12 @@ export function Session() { { title: showThinking() ? "Hide thinking" : "Show thinking", value: "session.toggle.thinking", - keybind: "display_thinking", category: "Session", slash: { name: "thinking", aliases: ["toggle-thinking"], }, - onSelect: (dialog) => { + run: () => { setShowThinking((prev) => !prev) dialog.clear() }, @@ -659,9 +642,8 @@ export function Session() { { title: showDetails() ? "Hide tool details" : "Show tool details", value: "session.toggle.actions", - keybind: "tool_details", category: "Session", - onSelect: (dialog) => { + run: () => { setShowDetails((prev) => !prev) dialog.clear() }, @@ -669,9 +651,8 @@ export function Session() { { title: "Toggle session scrollbar", value: "session.toggle.scrollbar", - keybind: "scrollbar_toggle", category: "Session", - onSelect: (dialog) => { + run: () => { setShowScrollbar((prev) => !prev) dialog.clear() }, @@ -680,7 +661,7 @@ export function Session() { title: showGenericToolOutput() ? "Hide generic tool output" : "Show generic tool output", value: "session.toggle.generic_tool_output", category: "Session", - onSelect: (dialog) => { + run: () => { setShowGenericToolOutput((prev) => !prev) dialog.clear() }, @@ -688,10 +669,9 @@ export function Session() { { title: "Page up", value: "session.page.up", - keybind: "messages_page_up", category: "Session", hidden: true, - onSelect: (dialog) => { + run: () => { scroll.scrollBy(-scroll.height / 2) dialog.clear() }, @@ -699,10 +679,9 @@ export function Session() { { title: "Page down", value: "session.page.down", - keybind: "messages_page_down", category: "Session", hidden: true, - onSelect: (dialog) => { + run: () => { scroll.scrollBy(scroll.height / 2) dialog.clear() }, @@ -710,10 +689,9 @@ export function Session() { { title: "Line up", value: "session.line.up", - keybind: "messages_line_up", category: "Session", - disabled: true, - onSelect: (dialog) => { + enabled: false, + run: () => { scroll.scrollBy(-1) dialog.clear() }, @@ -721,10 +699,9 @@ export function Session() { { title: "Line down", value: "session.line.down", - keybind: "messages_line_down", category: "Session", - disabled: true, - onSelect: (dialog) => { + enabled: false, + run: () => { scroll.scrollBy(1) dialog.clear() }, @@ -732,10 +709,9 @@ export function Session() { { title: "Half page up", value: "session.half.page.up", - keybind: "messages_half_page_up", category: "Session", hidden: true, - onSelect: (dialog) => { + run: () => { scroll.scrollBy(-scroll.height / 4) dialog.clear() }, @@ -743,10 +719,9 @@ export function Session() { { title: "Half page down", value: "session.half.page.down", - keybind: "messages_half_page_down", category: "Session", hidden: true, - onSelect: (dialog) => { + run: () => { scroll.scrollBy(scroll.height / 4) dialog.clear() }, @@ -754,10 +729,9 @@ export function Session() { { title: "First message", value: "session.first", - keybind: "messages_first", category: "Session", hidden: true, - onSelect: (dialog) => { + run: () => { scroll.scrollTo(0) dialog.clear() }, @@ -765,10 +739,9 @@ export function Session() { { title: "Last message", value: "session.last", - keybind: "messages_last", category: "Session", hidden: true, - onSelect: (dialog) => { + run: () => { scroll.scrollTo(scroll.scrollHeight) dialog.clear() }, @@ -776,10 +749,9 @@ export function Session() { { title: "Jump to last user message", value: "session.messages_last_user", - keybind: "messages_last_user", category: "Session", hidden: true, - onSelect: () => { + run: () => { const messages = sync.data.message[route.sessionID] if (!messages || !messages.length) return @@ -808,25 +780,22 @@ export function Session() { { title: "Next message", value: "session.message.next", - keybind: "messages_next", category: "Session", hidden: true, - onSelect: (dialog) => scrollToMessage("next", dialog), + run: () => scrollToMessage("next", dialog), }, { title: "Previous message", value: "session.message.previous", - keybind: "messages_previous", category: "Session", hidden: true, - onSelect: (dialog) => scrollToMessage("prev", dialog), + run: () => scrollToMessage("prev", dialog), }, { title: "Copy last assistant message", value: "messages.copy", - keybind: "messages_copy", category: "Session", - onSelect: (dialog) => { + run: () => { const revertID = session()?.revert?.messageID const lastAssistantMessage = messages().findLast( (msg) => msg.role === "assistant" && (!revertID || msg.id < revertID), @@ -871,7 +840,7 @@ export function Session() { slash: { name: "copy", }, - onSelect: async (dialog) => { + run: async () => { try { const sessionData = session() if (!sessionData) return @@ -897,12 +866,11 @@ export function Session() { { title: "Export session transcript", value: "session.export", - keybind: "session_export", category: "Session", slash: { name: "export", }, - onSelect: async (dialog) => { + run: async () => { try { const sessionData = session() if (!sessionData) return @@ -959,10 +927,9 @@ export function Session() { { title: "Go to child session", value: "session.child.first", - keybind: "session_child_first", category: "Session", hidden: true, - onSelect: (dialog) => { + run: () => { moveFirstChild() dialog.clear() }, @@ -970,11 +937,10 @@ export function Session() { { title: "Go to parent session", value: "session.parent", - keybind: "session_parent", category: "Session", hidden: true, enabled: !!session()?.parentID, - onSelect: childSessionHandler((dialog) => { + run: childSessionHandler(() => { const parentID = session()?.parentID if (parentID) { navigate({ @@ -988,11 +954,10 @@ export function Session() { { title: "Next child session", value: "session.child.next", - keybind: "session_child_cycle", category: "Session", hidden: true, enabled: !!session()?.parentID, - onSelect: childSessionHandler((dialog) => { + run: childSessionHandler(() => { moveChild(1) dialog.clear() }), @@ -1000,17 +965,36 @@ export function Session() { { title: "Previous child session", value: "session.child.previous", - keybind: "session_child_cycle_reverse", category: "Session", hidden: true, enabled: !!session()?.parentID, - onSelect: childSessionHandler((dialog) => { + run: childSessionHandler(() => { moveChild(-1) dialog.clear() }), }, ]) + const sessionCommands = createMemo(() => + sessionCommandList().map((command) => ({ + namespace: "palette", + name: command.value, + desc: "description" in command ? command.description : undefined, + slashName: "slash" in command ? command.slash?.name : undefined, + slashAliases: "slash" in command ? command.slash?.aliases : undefined, + ...command, + })), + ) + + useBindings(() => ({ + commands: sessionCommands(), + })) + + useBindings(() => ({ + enabled: command.matcher, + bindings: sections.session, + })) + const revertInfo = createMemo(() => session()?.revert) const revertMessageID = createMemo(() => revertInfo()?.messageID) @@ -1082,7 +1066,8 @@ export function Session() { {(function () { - const command = useCommandDialog() + const command = useCommandPalette() + const redoShortcut = useCommandShortcut("session.redo") const [hover, setHover] = createSignal(false) const dialog = useDialog() @@ -1093,7 +1078,7 @@ export function Session() { "Are you sure you want to restore the reverted messages?", ) if (confirmed) { - command.trigger("session.redo") + command.run("session.redo") } } @@ -1116,7 +1101,7 @@ export function Session() { > {revert()!.reverted.length} message reverted - {keybind.print("messages_redo")} or /redo to + {redoShortcut()} or /redo to restore @@ -1370,7 +1355,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las return props.message.time.completed - user.time.created }) - const keybind = useKeybind() + const childShortcut = useCommandShortcut("session.child.first") return ( <> @@ -1392,7 +1377,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las x.type === "tool" && x.tool === "task")}> - {keybind.print("session_child_first")} + {childShortcut()} view subagents 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 e7e4c7cea303..aab21e3f3f4f 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -1,24 +1,22 @@ import { createStore } from "solid-js/store" -import { createMemo, For, Match, Show, Switch } from "solid-js" -import { Portal, useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" +import { createMemo, createSignal, For, Match, Show, Switch } from "solid-js" +import { Portal, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import type { TextareaRenderable } from "@opentui/core" -import { useKeybind } from "../../context/keybind" import { useTheme, selectedForeground } from "../../context/theme" import type { PermissionRequest } from "@opencode-ai/sdk/v2" import { useSDK } from "../../context/sdk" import { SplitBorder } from "../../component/border" import { useSync } from "../../context/sync" -import { useTextareaKeybindings } from "../../component/textarea-keybindings" import { useProject } from "../../context/project" import path from "path" import { LANGUAGE_EXTENSIONS } from "@/lsp/language" -import { Keybind } from "@/util/keybind" import { Locale } from "@/util/locale" import { Global } from "@opencode-ai/core/global" import { ShellID } from "@/tool/shell/id" import { useDialog } from "../../ui/dialog" import { getScrollAcceleration } from "../../util/scroll" import { useTuiConfig } from "../../context/tui-config" +import { useBindings, useCommandShortcut } from "../../keymap" type PermissionStage = "permission" | "always" | "reject" @@ -463,25 +461,29 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: () => void }) { let input: TextareaRenderable const { theme } = useTheme() - const keybind = useKeybind() - const textareaKeybindings = useTextareaKeybindings() + const tuiConfig = useTuiConfig() + const { + keymap: { sections }, + } = tuiConfig const dimensions = useTerminalDimensions() const narrow = createMemo(() => dimensions().width < 80) const dialog = useDialog() - - useKeyboard((evt) => { - if (dialog.stack.length > 0) return - - if (evt.name === "escape" || keybind.match("app_exit", evt)) { - evt.preventDefault() - props.onCancel() - return - } - if (evt.name === "return") { - evt.preventDefault() - props.onConfirm(input.plainText) - } - }) + useBindings(() => ({ + enabled: dialog.stack.length === 0, + commands: [ + { + name: "permission.reject.cancel", + run() { + props.onCancel() + }, + }, + ], + bindings: [ + { key: "escape", cmd: () => props.onCancel() }, + ...sections.permission_reject, + { key: "return", cmd: () => props.onConfirm(input.plainText) }, + ], + })) return ( void; onCancel: ( textColor={theme.text} focusedTextColor={theme.text} cursorColor={theme.primary} - keyBindings={textareaKeybindings()} /> @@ -545,50 +546,77 @@ function Prompt>(props: { onSelect: (option: keyof T) => void }) { const { theme } = useTheme() - const keybind = useKeybind() + const tuiConfig = useTuiConfig() + const { + keymap: { sections }, + } = tuiConfig const dimensions = useTerminalDimensions() const keys = Object.keys(props.options) as (keyof T)[] const [store, setStore] = createStore({ selected: keys[0], expanded: false, }) - const diffKey = Keybind.parse("ctrl+f")[0] const narrow = createMemo(() => dimensions().width < 80) const dialog = useDialog() - - useKeyboard((evt) => { - if (dialog.stack.length > 0) return - - if (evt.name === "left" || evt.name == "h") { - evt.preventDefault() - const idx = keys.indexOf(store.selected) - const next = keys[(idx - 1 + keys.length) % keys.length] - setStore("selected", next) - } - - if (evt.name === "right" || evt.name == "l") { - evt.preventDefault() - const idx = keys.indexOf(store.selected) - const next = keys[(idx + 1) % keys.length] - setStore("selected", next) - } - - if (evt.name === "return") { - evt.preventDefault() - props.onSelect(store.selected) - } - - if (props.escapeKey && (evt.name === "escape" || keybind.match("app_exit", evt))) { - evt.preventDefault() - props.onSelect(props.escapeKey) - } - - if (props.fullscreen && diffKey && Keybind.match(diffKey, keybind.parse(evt))) { - evt.preventDefault() - evt.stopPropagation() - setStore("expanded", (v) => !v) - } - }) + const fullscreenHint = useCommandShortcut("permission.prompt.fullscreen") + + useBindings(() => ({ + enabled: dialog.stack.length === 0, + commands: [ + { + name: "permission.prompt.escape", + run() { + if (!props.escapeKey) return + props.onSelect(props.escapeKey) + }, + }, + { + name: "permission.prompt.fullscreen", + run() { + if (!props.fullscreen) return + setStore("expanded", (v) => !v) + }, + }, + ], + bindings: [ + { + key: "left", + cmd: () => { + const idx = keys.indexOf(store.selected) + const next = keys[(idx - 1 + keys.length) % keys.length] + setStore("selected", next) + }, + }, + { + key: "h", + cmd: () => { + const idx = keys.indexOf(store.selected) + const next = keys[(idx - 1 + keys.length) % keys.length] + setStore("selected", next) + }, + }, + { + key: "right", + cmd: () => { + const idx = keys.indexOf(store.selected) + const next = keys[(idx + 1) % keys.length] + setStore("selected", next) + }, + }, + { + key: "l", + cmd: () => { + const idx = keys.indexOf(store.selected) + const next = keys[(idx + 1) % keys.length] + setStore("selected", next) + }, + }, + { key: "return", cmd: () => props.onSelect(store.selected) }, + ...(props.escapeKey ? [{ key: "escape", cmd: () => props.onSelect(props.escapeKey!) }] : []), + ...(props.escapeKey ? sections.permission_prompt_escape : []), + ...(props.fullscreen ? sections.permission_prompt_fullscreen : []), + ], + })) const hint = createMemo(() => (store.expanded ? "minimize" : "fullscreen")) useRenderer() @@ -661,7 +689,7 @@ function Prompt>(props: { - {"ctrl+f"} {hint()} + {fullscreenHint()} {hint()} 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 3ff95b4bb885..8c6f5243031f 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx @@ -1,20 +1,21 @@ import { createStore } from "solid-js/store" import { createMemo, createSignal, For, Show } from "solid-js" -import { useKeyboard } from "@opentui/solid" import type { TextareaRenderable } from "@opentui/core" -import { useKeybind } from "../../context/keybind" import { selectedForeground, tint, useTheme } from "../../context/theme" import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2" import { useSDK } from "../../context/sdk" import { SplitBorder } from "../../component/border" -import { useTextareaKeybindings } from "../../component/textarea-keybindings" import { useDialog } from "../../ui/dialog" +import { useTuiConfig } from "../../context/tui-config" +import { useBindings } from "../../keymap" export function QuestionPrompt(props: { request: QuestionRequest }) { const sdk = useSDK() const { theme } = useTheme() - const keybind = useKeybind() - const bindings = useTextareaKeybindings() + const tuiConfig = useTuiConfig() + const { + keymap: { sections }, + } = tuiConfig const questions = createMemo(() => props.request.questions) const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true) @@ -122,131 +123,124 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { const dialog = useDialog() - useKeyboard((evt) => { - // Skip processing if a dialog (e.g., command palette) is open - if (dialog.stack.length > 0) return - - // When editing custom answer textarea - if (store.editing && !confirm()) { - if (evt.name === "escape") { - evt.preventDefault() - setStore("editing", false) - return - } - if (keybind.match("input_clear", evt)) { - evt.preventDefault() - const text = textarea?.plainText ?? "" - if (!text) { + useBindings(() => ({ + enabled: store.editing && !confirm(), + commands: [ + { + name: "question.edit.clear", + run() { + const text = textarea?.plainText ?? "" + if (!text) { + setStore("editing", false) + return + } + textarea?.setText("") + }, + }, + ], + bindings: [ + { + key: "escape", + cmd: () => { setStore("editing", false) - return - } - textarea?.setText("") - return - } - if (evt.name === "return") { - evt.preventDefault() - const text = textarea?.plainText?.trim() ?? "" - const prev = store.custom[store.tab] + }, + }, + ...sections.question_edit, + { + key: "return", + cmd: () => { + const text = textarea?.plainText?.trim() ?? "" + const prev = store.custom[store.tab] + + if (!text) { + if (prev) { + const inputs = [...store.custom] + inputs[store.tab] = "" + setStore("custom", inputs) + + const answers = [...store.answers] + answers[store.tab] = (answers[store.tab] ?? []).filter((x) => x !== prev) + setStore("answers", answers) + } + setStore("editing", false) + return + } - if (!text) { - if (prev) { + if (multi()) { const inputs = [...store.custom] - inputs[store.tab] = "" + inputs[store.tab] = text setStore("custom", inputs) + const existing = store.answers[store.tab] ?? [] + const next = [...existing] + if (prev) { + const index = next.indexOf(prev) + if (index !== -1) next.splice(index, 1) + } + if (!next.includes(text)) next.push(text) const answers = [...store.answers] - answers[store.tab] = (answers[store.tab] ?? []).filter((x) => x !== prev) + answers[store.tab] = next setStore("answers", answers) + setStore("editing", false) + return } - setStore("editing", false) - return - } - - if (multi()) { - const inputs = [...store.custom] - inputs[store.tab] = text - setStore("custom", inputs) - const existing = store.answers[store.tab] ?? [] - const next = [...existing] - if (prev) { - const index = next.indexOf(prev) - if (index !== -1) next.splice(index, 1) - } - if (!next.includes(text)) next.push(text) - const answers = [...store.answers] - answers[store.tab] = next - setStore("answers", answers) + pick(text, true) setStore("editing", false) - return - } - - pick(text, true) - setStore("editing", false) - return - } - // Let textarea handle all other keys - return - } - - if (evt.name === "left" || evt.name === "h") { - evt.preventDefault() - selectTab((store.tab - 1 + tabs()) % tabs()) - } - - if (evt.name === "right" || evt.name === "l") { - evt.preventDefault() - selectTab((store.tab + 1) % tabs()) - } - - if (evt.name === "tab") { - evt.preventDefault() - const direction = evt.shift ? -1 : 1 - selectTab((store.tab + direction + tabs()) % tabs()) - } - - if (confirm()) { - if (evt.name === "return") { - evt.preventDefault() - submit() - } - if (evt.name === "escape" || keybind.match("app_exit", evt)) { - evt.preventDefault() - reject() - } - } else { - const opts = options() - const total = opts.length + (custom() ? 1 : 0) - const max = Math.min(total, 9) - const digit = Number(evt.name) - - if (!Number.isNaN(digit) && digit >= 1 && digit <= max) { - evt.preventDefault() - const index = digit - 1 - moveTo(index) - selectOption() - return - } - - if (evt.name === "up" || evt.name === "k") { - evt.preventDefault() - moveTo((store.selected - 1 + total) % total) - } - - if (evt.name === "down" || evt.name === "j") { - evt.preventDefault() - moveTo((store.selected + 1) % total) - } - - if (evt.name === "return") { - evt.preventDefault() - selectOption() - } - - if (evt.name === "escape" || keybind.match("app_exit", evt)) { - evt.preventDefault() - reject() - } + }, + }, + ], + })) + + useBindings(() => { + const opts = options() + const total = opts.length + (custom() ? 1 : 0) + const max = Math.min(total, 9) + + return { + enabled: dialog.stack.length === 0 && !store.editing, + commands: [ + { + name: "question.reject", + run() { + reject() + }, + }, + ], + bindings: [ + { key: "left", cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()) }, + { key: "h", cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()) }, + { key: "right", cmd: () => selectTab((store.tab + 1) % tabs()) }, + { key: "l", cmd: () => selectTab((store.tab + 1) % tabs()) }, + { + key: "tab", + cmd: ({ event }: { event: { shift: boolean } }) => { + selectTab((store.tab + (event.shift ? -1 : 1) + tabs()) % tabs()) + }, + }, + ...(confirm() + ? [ + { key: "return", cmd: () => submit() }, + { key: "escape", cmd: () => reject() }, + ...sections.question, + ] + : [ + ...Array.from({ length: max }, (_, index) => ({ + key: String(index + 1), + cmd: () => { + moveTo(index) + selectOption() + }, + })), + { key: "up", cmd: () => moveTo((store.selected - 1 + total) % total) }, + { key: "k", cmd: () => moveTo((store.selected - 1 + total) % total) }, + { key: "down", cmd: () => moveTo((store.selected + 1) % total) }, + { key: "j", cmd: () => moveTo((store.selected + 1) % total) }, + { key: "return", cmd: () => selectOption() }, + { key: "escape", cmd: () => reject() }, + ...sections.question, + ]), + ], } }) @@ -394,7 +388,6 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { textColor={theme.text} focusedTextColor={theme.text} cursorColor={theme.primary} - keyBindings={bindings()} /> diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/subagent-footer.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/subagent-footer.tsx index c857937d4acb..2a6813ffbedd 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/subagent-footer.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/subagent-footer.tsx @@ -4,10 +4,10 @@ import { useSync } from "@tui/context/sync" import { useTheme } from "@tui/context/theme" import { SplitBorder } from "@tui/component/border" import type { AssistantMessage } from "@opencode-ai/sdk/v2" -import { useCommandDialog } from "@tui/component/dialog-command" -import { useKeybind } from "../../context/keybind" import { Locale } from "@/util/locale" import { useTerminalDimensions } from "@opentui/solid" +import { useCommandPalette } from "../../context/command-palette" +import { useCommandShortcut } from "../../keymap" export function SubagentFooter() { const route = useRouteData("session") @@ -56,8 +56,10 @@ export function SubagentFooter() { }) const { theme } = useTheme() - const keybind = useKeybind() - const command = useCommandDialog() + const command = useCommandPalette() + const parentShortcut = useCommandShortcut("session.parent") + const previousShortcut = useCommandShortcut("session.child.previous") + const nextShortcut = useCommandShortcut("session.child.next") const [hover, setHover] = createSignal<"parent" | "prev" | "next" | null>(null) useTerminalDimensions() @@ -96,31 +98,31 @@ export function SubagentFooter() { setHover("parent")} onMouseOut={() => setHover(null)} - onMouseUp={() => command.trigger("session.parent")} + onMouseUp={() => command.run("session.parent")} backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel} > - Parent {keybind.print("session_parent")} + Parent {parentShortcut()} setHover("prev")} onMouseOut={() => setHover(null)} - onMouseUp={() => command.trigger("session.child.previous")} + onMouseUp={() => command.run("session.child.previous")} backgroundColor={hover() === "prev" ? theme.backgroundElement : theme.backgroundPanel} > - Prev {keybind.print("session_child_cycle_reverse")} + Prev {previousShortcut()} setHover("next")} onMouseOut={() => setHover(null)} - onMouseUp={() => command.trigger("session.child.next")} + onMouseUp={() => command.run("session.child.next")} backgroundColor={hover() === "next" ? theme.backgroundElement : theme.backgroundPanel} > - Next {keybind.print("session_child_cycle")} + Next {nextShortcut()} diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx index fb159115dc51..965c80f362d5 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx @@ -1,7 +1,7 @@ import { TextAttributes } from "@opentui/core" import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" -import { useKeyboard } from "@opentui/solid" +import { useBindings } from "../keymap" export type DialogAlertProps = { title: string @@ -13,14 +13,17 @@ export function DialogAlert(props: DialogAlertProps) { const dialog = useDialog() const { theme } = useTheme() - useKeyboard((evt) => { - if (evt.name === "return") { - evt.preventDefault() - evt.stopPropagation() - props.onConfirm?.() - dialog.clear() - } - }) + useBindings(() => ({ + bindings: [ + { + key: "return", + cmd: () => { + props.onConfirm?.() + dialog.clear() + }, + }, + ], + })) return ( diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx index 3870cf816cbb..0a1ce0b34493 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx @@ -3,8 +3,8 @@ import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" import { createStore } from "solid-js/store" import { For } from "solid-js" -import { useKeyboard } from "@opentui/solid" import { Locale } from "@/util/locale" +import { useBindings } from "../keymap" export type DialogConfirmProps = { title: string @@ -23,19 +23,30 @@ export function DialogConfirm(props: DialogConfirmProps) { active: "confirm" as "confirm" | "cancel", }) - useKeyboard((evt) => { - if (evt.name === "return") { - evt.preventDefault() - evt.stopPropagation() - if (store.active === "confirm") props.onConfirm?.() - if (store.active === "cancel") props.onCancel?.() - dialog.clear() - } - - if (evt.name === "left" || evt.name === "right") { - setStore("active", store.active === "confirm" ? "cancel" : "confirm") - } - }) + useBindings(() => ({ + bindings: [ + { + key: "return", + cmd: () => { + if (store.active === "confirm") props.onConfirm?.() + if (store.active === "cancel") props.onCancel?.() + dialog.clear() + }, + }, + { + key: "left", + cmd: () => { + setStore("active", store.active === "confirm" ? "cancel" : "confirm") + }, + }, + { + key: "right", + cmd: () => { + setStore("active", store.active === "confirm" ? "cancel" : "confirm") + }, + }, + ], + })) return ( @@ -56,7 +67,7 @@ export function DialogConfirm(props: DialogConfirmProps) { paddingLeft={1} paddingRight={1} backgroundColor={key === store.active ? theme.primary : undefined} - onMouseUp={(_evt) => { + onMouseUp={() => { if (key === "confirm") props.onConfirm?.() if (key === "cancel") props.onCancel?.() dialog.clear() diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx index b9362db46b28..35d9dec4b04e 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx @@ -3,7 +3,7 @@ import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" import { createStore } from "solid-js/store" import { onMount, Show } from "solid-js" -import { useKeyboard } from "@opentui/solid" +import { useBindings } from "../keymap" export type DialogExportOptionsProps = { defaultFilename: string @@ -33,39 +33,40 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { active: "filename" as "filename" | "thinking" | "toolDetails" | "assistantMetadata" | "openWithoutSaving", }) - useKeyboard((evt) => { - if (evt.name === "return") { - evt.preventDefault() - evt.stopPropagation() - props.onConfirm?.({ - filename: textarea.plainText, - thinking: store.thinking, - toolDetails: store.toolDetails, - assistantMetadata: store.assistantMetadata, - openWithoutSaving: store.openWithoutSaving, - }) - } - if (evt.name === "tab") { - const order: Array<"filename" | "thinking" | "toolDetails" | "assistantMetadata" | "openWithoutSaving"> = [ - "filename", - "thinking", - "toolDetails", - "assistantMetadata", - "openWithoutSaving", - ] - const currentIndex = order.indexOf(store.active) - const nextIndex = (currentIndex + 1) % order.length - setStore("active", order[nextIndex]) - evt.preventDefault() - } - if (evt.name === "space" || evt.name === " ") { - if (store.active === "thinking") setStore("thinking", !store.thinking) - if (store.active === "toolDetails") setStore("toolDetails", !store.toolDetails) - if (store.active === "assistantMetadata") setStore("assistantMetadata", !store.assistantMetadata) - if (store.active === "openWithoutSaving") setStore("openWithoutSaving", !store.openWithoutSaving) - evt.preventDefault() - } - }) + useBindings(() => ({ + bindings: [ + { + key: "tab", + cmd: () => { + const order: Array<"filename" | "thinking" | "toolDetails" | "assistantMetadata" | "openWithoutSaving"> = [ + "filename", + "thinking", + "toolDetails", + "assistantMetadata", + "openWithoutSaving", + ] + const currentIndex = order.indexOf(store.active) + const nextIndex = (currentIndex + 1) % order.length + setStore("active", order[nextIndex]) + }, + }, + ], + })) + + useBindings(() => ({ + enabled: store.active !== "filename", + bindings: [ + { + key: "space", + cmd: () => { + if (store.active === "thinking") setStore("thinking", !store.thinking) + if (store.active === "toolDetails") setStore("toolDetails", !store.toolDetails) + if (store.active === "assistantMetadata") setStore("assistantMetadata", !store.assistantMetadata) + if (store.active === "openWithoutSaving") setStore("openWithoutSaving", !store.openWithoutSaving) + }, + }, + ], + })) onMount(() => { dialog.setSize("medium") @@ -101,7 +102,6 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { }) }} height={3} - keyBindings={[{ name: "return", action: "submit" }]} ref={(val: TextareaRenderable) => { textarea = val val.traits = { status: "FILENAME" } diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx index 24b93b96a77d..b6a394d2def0 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx @@ -1,21 +1,19 @@ import { TextAttributes } from "@opentui/core" import { useTheme } from "@tui/context/theme" import { useDialog } from "./dialog" -import { useKeyboard } from "@opentui/solid" -import { useKeybind } from "@tui/context/keybind" +import { useBindings, useCommandShortcut } from "../keymap" export function DialogHelp() { const dialog = useDialog() const { theme } = useTheme() - const keybind = useKeybind() + const commandShortcut = useCommandShortcut("command.palette.show") - useKeyboard((evt) => { - if (evt.name === "return" || evt.name === "escape") { - evt.preventDefault() - evt.stopPropagation() - dialog.clear() - } - }) + useBindings(() => ({ + bindings: [ + { key: "return", cmd: () => dialog.clear() }, + { key: "escape", cmd: () => dialog.clear() }, + ], + })) return ( @@ -29,7 +27,7 @@ export function DialogHelp() { - Press {keybind.print("command_list")} to see all available actions and commands in any context. + Press {commandShortcut()} to see all available actions and commands in any context. diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx index 92d6d277d0eb..34ab9161f6a8 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx @@ -2,7 +2,6 @@ import { TextareaRenderable, TextAttributes } from "@opentui/core" import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" import { Show, createEffect, onMount, type JSX } from "solid-js" -import { useKeyboard } from "@opentui/solid" import { Spinner } from "../component/spinner" export type DialogPromptProps = { @@ -21,20 +20,6 @@ export function DialogPrompt(props: DialogPromptProps) { const { theme } = useTheme() let textarea: TextareaRenderable - useKeyboard((evt) => { - if (props.busy) { - if (evt.name === "escape") return - evt.preventDefault() - evt.stopPropagation() - return - } - if (evt.name === "return") { - evt.preventDefault() - evt.stopPropagation() - props.onConfirm?.(textarea.plainText) - } - }) - onMount(() => { dialog.setSize("medium") setTimeout(() => { @@ -79,7 +64,6 @@ export function DialogPrompt(props: DialogPromptProps) { props.onConfirm?.(textarea.plainText) }} height={3} - keyBindings={props.busy ? [] : [{ name: "return", action: "submit" }]} ref={(val: TextareaRenderable) => { textarea = val }} 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 ef7d4bd3bbd6..c0ead3585ae8 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -1,17 +1,24 @@ -import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core" +import { + InputRenderable, + RGBA, + ScrollBoxRenderable, + TextAttributes, + type KeyEvent, + type Renderable, +} from "@opentui/core" +import type { Binding } from "@opentui/keymap" import { useTheme, selectedForeground } from "@tui/context/theme" import { entries, filter, flatMap, groupBy, pipe } from "remeda" import { batch, createEffect, createMemo, For, Show, type JSX, on } from "solid-js" import { createStore } from "solid-js/store" -import { useKeyboard, useTerminalDimensions } from "@opentui/solid" +import { useTerminalDimensions } from "@opentui/solid" import * as fuzzysort from "fuzzysort" import { isDeepEqual } from "remeda" import { useDialog, type DialogContext } from "@tui/ui/dialog" -import { useKeybind } from "@tui/context/keybind" -import { Keybind } from "@/util/keybind" import { Locale } from "@/util/locale" import { getScrollAcceleration } from "../util/scroll" import { useTuiConfig } from "../context/tui-config" +import { formatKeyBindings, useBindings, useKeymapSelector } from "../keymap" export interface DialogSelectProps { title: string @@ -24,13 +31,14 @@ export interface DialogSelectProps { onSelect?: (option: DialogSelectOption) => void skipFilter?: boolean renderFilter?: boolean - keybind?: { - keybind?: Keybind.Info + actions?: { + command: string title: string side?: "left" | "right" disabled?: boolean onTrigger: (option: DialogSelectOption) => void }[] + bindings?: readonly Binding[] current?: T } @@ -57,6 +65,9 @@ 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({ @@ -81,6 +92,25 @@ export function DialogSelect(props: DialogSelectProps) { let input: InputRenderable + const actions = createMemo(() => props.actions ?? []) + const actionBindings = useKeymapSelector((keymap) => + keymap.getCommandBindings({ + visibility: "registered", + commands: actions().map((item) => item.command), + }), + ) + + const actionLabels = createMemo(() => { + const labels = new Map() + + for (const action of actions()) { + const label = formatKeyBindings(actionBindings().get(action.command), tuiConfig) + if (label) labels.set(action.command, label) + } + + return labels + }) + const filtered = createMemo(() => { if (props.skipFilter || props.renderFilter === false) return props.options.filter((x) => x.disabled !== true) const needle = store.filter.toLowerCase() @@ -171,7 +201,7 @@ export function DialogSelect(props: DialogSelectProps) { const option = selected() if (option) props.onMove?.(option) if (!scroll) return - const target = scroll.getChildren().find((child) => { + const target = scroll.getChildren().find((child: { id?: string }) => { return child.id === JSON.stringify(selected()?.value) }) if (!target) return @@ -192,36 +222,82 @@ export function DialogSelect(props: DialogSelectProps) { } } - const keybind = useKeybind() - useKeyboard((evt) => { + function submit() { setStore("input", "keyboard") + const option = selected() + if (!option) return + option.onSelect?.(dialog) + props.onSelect?.(option) + } - if (evt.name === "up" || (evt.ctrl && evt.name === "p")) move(-1) - if (evt.name === "down" || (evt.ctrl && evt.name === "n")) move(1) - if (evt.name === "pageup") move(-10) - if (evt.name === "pagedown") move(10) - if (evt.name === "home") moveTo(0) - if (evt.name === "end") moveTo(flat().length - 1) - - if (evt.name === "return") { - const option = selected() - if (option) { - evt.preventDefault() - evt.stopPropagation() - if (option.onSelect) option.onSelect(dialog) - props.onSelect?.(option) - } - } + useBindings(() => { + const enabledActions = actions().filter((item) => !item.disabled) - for (const item of props.keybind ?? []) { - if (item.disabled || !item.keybind) continue - if (Keybind.match(item.keybind, keybind.parse(evt))) { - const s = selected() - if (s) { - evt.preventDefault() - item.onTrigger(s) - } - } + return { + commands: [ + { + name: "dialog.select.prev", + run() { + setStore("input", "keyboard") + move(-1) + }, + }, + { + name: "dialog.select.next", + run() { + setStore("input", "keyboard") + move(1) + }, + }, + { + name: "dialog.select.page_up", + run() { + setStore("input", "keyboard") + move(-10) + }, + }, + { + name: "dialog.select.page_down", + run() { + setStore("input", "keyboard") + move(10) + }, + }, + { + name: "dialog.select.home", + run() { + setStore("input", "keyboard") + moveTo(0) + }, + }, + { + name: "dialog.select.end", + run() { + setStore("input", "keyboard") + moveTo(flat().length - 1) + }, + }, + { + name: "dialog.select.submit", + run: submit, + }, + ...enabledActions.map((item) => ({ + name: item.command, + run() { + setStore("input", "keyboard") + const option = selected() + if (!option) return + item.onTrigger(option) + }, + })), + ], + bindings: [ + ...sections.dialog_select, + ...(props.bindings ?? []).filter((binding) => { + if (typeof binding.cmd !== "string") return true + return enabledActions.some((item) => item.command === binding.cmd) + }), + ], } }) @@ -236,9 +312,13 @@ export function DialogSelect(props: DialogSelectProps) { } props.ref?.(ref) - const keybinds = createMemo(() => props.keybind?.filter((x) => !x.disabled && x.keybind) ?? []) - const left = createMemo(() => keybinds().filter((item) => item.side !== "right")) - const right = createMemo(() => keybinds().filter((item) => item.side === "right")) + const visibleActions = createMemo(() => + actions() + .map((item) => ({ ...item, label: actionLabels().get(item.command) ?? "" })) + .filter((item) => !item.disabled && item.label), + ) + const left = createMemo(() => visibleActions().filter((item) => item.side !== "right")) + const right = createMemo(() => visibleActions().filter((item) => item.side === "right")) return ( @@ -365,7 +445,7 @@ export function DialogSelect(props: DialogSelectProps) { - }> + }> (props: DialogSelectProps) { {item.title}{" "} - {Keybind.toString(item.keybind)} + {item.label} )} @@ -393,7 +473,7 @@ export function DialogSelect(props: DialogSelectProps) { {item.title}{" "} - {Keybind.toString(item.keybind)} + {item.label} )} diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx index a5da735f6556..0dff8b543360 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx @@ -1,4 +1,4 @@ -import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" +import { useRenderer, useTerminalDimensions } from "@opentui/solid" import { batch, createContext, Show, useContext, type JSX, type ParentProps } from "solid-js" import { useTheme } from "@tui/context/theme" import { MouseButton, Renderable, RGBA } from "@opentui/core" @@ -6,6 +6,7 @@ import { createStore } from "solid-js/store" import { useToast } from "./toast" import { Flag } from "@opencode-ai/core/flag/flag" import * as Selection from "@tui/util/selection" +import { useBindings } from "../keymap" export function Dialog( props: ParentProps<{ @@ -47,7 +48,7 @@ export function Dialog( backgroundColor={RGBA.fromInts(0, 0, 0, 150)} > { + onMouseUp={(e: { stopPropagation(): void }) => { dismiss = false e.stopPropagation() }} @@ -73,23 +74,6 @@ function init() { const renderer = useRenderer() - useKeyboard((evt) => { - if (store.stack.length === 0) return - if (evt.defaultPrevented) return - if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && renderer.getSelection()?.getSelectedText()) return - if (evt.name === "escape" || (evt.ctrl && evt.name === "c")) { - if (renderer.getSelection()) { - renderer.clearSelection() - } - const current = store.stack.at(-1)! - current.onClose?.() - setStore("stack", store.stack.slice(0, -1)) - evt.preventDefault() - evt.stopPropagation() - refocus() - } - }) - let focus: Renderable | null function refocus() { setTimeout(() => { @@ -108,6 +92,36 @@ function init() { }, 1) } + useBindings(() => ({ + enabled: store.stack.length > 0 && !renderer.getSelection()?.getSelectedText(), + bindings: [ + { + key: "escape", + cmd: () => { + if (renderer.getSelection()) { + renderer.clearSelection() + } + const current = store.stack.at(-1) + current?.onClose?.() + setStore("stack", store.stack.slice(0, -1)) + refocus() + }, + }, + { + key: "ctrl+c", + cmd: () => { + if (renderer.getSelection()) { + renderer.clearSelection() + } + const current = store.stack.at(-1) + current?.onClose?.() + setStore("stack", store.stack.slice(0, -1)) + refocus() + }, + }, + ], + })) + return { clear() { for (const item of store.stack) { @@ -155,13 +169,14 @@ export function DialogProvider(props: ParentProps) { const value = init() const renderer = useRenderer() const toast = useToast() + return ( {props.children} { + onMouseDown={(evt: { button: number; preventDefault(): void; stopPropagation(): void }) => { if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return if (evt.button !== MouseButton.RIGHT) return diff --git a/packages/opencode/src/cli/cmd/tui/util/scroll.ts b/packages/opencode/src/cli/cmd/tui/util/scroll.ts index 30d006963942..715a8480cf13 100644 --- a/packages/opencode/src/cli/cmd/tui/util/scroll.ts +++ b/packages/opencode/src/cli/cmd/tui/util/scroll.ts @@ -11,7 +11,9 @@ export class CustomSpeedScroll implements ScrollAcceleration { reset(): void {} } -export function getScrollAcceleration(tuiConfig?: TuiConfig.Info): ScrollAcceleration { +export function getScrollAcceleration( + tuiConfig?: Pick, +): ScrollAcceleration { if (tuiConfig?.scroll_acceleration?.enabled) { return new MacOSScrollAccel() } diff --git a/packages/opencode/src/cli/cmd/tui/util/selection.ts b/packages/opencode/src/cli/cmd/tui/util/selection.ts index 0e0c47874e4f..bb2f658cc2f2 100644 --- a/packages/opencode/src/cli/cmd/tui/util/selection.ts +++ b/packages/opencode/src/cli/cmd/tui/util/selection.ts @@ -5,9 +5,21 @@ type Toast = { error: (err: unknown) => void } +type FocusableSelectionTarget = { + hasSelection: () => boolean +} + type Renderer = { - getSelection: () => { getSelectedText: () => string } | null + getSelection: () => { getSelectedText: () => string; selectedRenderables: FocusableSelectionTarget[] } | null clearSelection: () => void + currentFocusedRenderable?: FocusableSelectionTarget | null +} + +type SelectionKeyEvent = { + ctrl?: boolean + name: string + preventDefault: () => void + stopPropagation: () => void } export function copy(renderer: Renderer, toast: Toast): boolean { @@ -22,4 +34,32 @@ export function copy(renderer: Renderer, toast: Toast): boolean { return true } +export function handleSelectionKey(renderer: Renderer, toast: Toast, event: SelectionKeyEvent) { + const selection = renderer.getSelection() + if (!selection) return + + if (event.ctrl && event.name === "c") { + if (!copy(renderer, toast)) { + renderer.clearSelection() + return + } + + event.preventDefault() + event.stopPropagation() + return + } + + if (event.name === "escape") { + renderer.clearSelection() + event.preventDefault() + event.stopPropagation() + return + } + + const focus = renderer.currentFocusedRenderable + if (focus?.hasSelection() && selection.selectedRenderables.includes(focus)) return + + renderer.clearSelection() +} + export * as Selection from "./selection" diff --git a/packages/opencode/src/config/keybinds.ts b/packages/opencode/src/config/keybinds.ts index a84fc0b37d58..d9a397f516ec 100644 --- a/packages/opencode/src/config/keybinds.ts +++ b/packages/opencode/src/config/keybinds.ts @@ -21,7 +21,6 @@ const KeybindsSchema = Schema.Struct({ theme_list: keybind("t", "List available themes"), sidebar_toggle: keybind("b", "Toggle sidebar"), scrollbar_toggle: keybind("none", "Toggle session scrollbar"), - username_toggle: keybind("none", "Toggle username visibility"), status_view: keybind("s", "View status"), session_export: keybind("x", "Export session to editor"), session_new: keybind("n", "Create a new session"), @@ -59,6 +58,22 @@ const KeybindsSchema = Schema.Struct({ 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"), @@ -101,6 +116,7 @@ const KeybindsSchema = Schema.Struct({ 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"), diff --git a/packages/opencode/src/util/keybind.ts b/packages/opencode/src/util/keybind.ts deleted file mode 100644 index e3c9b2bc02bf..000000000000 --- a/packages/opencode/src/util/keybind.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { isDeepEqual } from "remeda" -import type { ParsedKey } from "@opentui/core" - -/** - * Keybind info derived from OpenTUI's ParsedKey with our custom `leader` field. - * This ensures type compatibility and catches missing fields at compile time. - */ -export type Info = Pick & { - leader: boolean // our custom field -} - -export function match(a: Info | undefined, b: Info): boolean { - if (!a) return false - const normalizedA = { ...a, super: a.super ?? false } - const normalizedB = { ...b, super: b.super ?? false } - return isDeepEqual(normalizedA, normalizedB) -} - -/** - * Convert OpenTUI's ParsedKey to our Keybind.Info format. - * This helper ensures all required fields are present and avoids manual object creation. - */ -export function fromParsedKey(key: ParsedKey, leader = false): Info { - return { - name: key.name === " " ? "space" : key.name, - ctrl: key.ctrl, - meta: key.meta, - shift: key.shift, - super: key.super ?? false, - leader, - } -} - -export function toString(info: Info | undefined): string { - if (!info) return "" - const parts: string[] = [] - - if (info.ctrl) parts.push("ctrl") - if (info.meta) parts.push("alt") - if (info.super) parts.push("super") - if (info.shift) parts.push("shift") - if (info.name) { - if (info.name === "delete") parts.push("del") - else parts.push(info.name) - } - - let result = parts.join("+") - - if (info.leader) { - result = result ? ` ${result}` : `` - } - - return result -} - -export function parse(key: string): Info[] { - if (key === "none") return [] - - return key.split(",").map((combo) => { - // Handle syntax by replacing with leader+ - const normalized = combo.replace(//g, "leader+") - const parts = normalized.toLowerCase().split("+") - const info: Info = { - ctrl: false, - meta: false, - shift: false, - leader: false, - name: "", - } - - for (const part of parts) { - switch (part) { - case "ctrl": - info.ctrl = true - break - case "alt": - case "meta": - case "option": - info.meta = true - break - case "super": - info.super = true - break - case "shift": - info.shift = true - break - case "leader": - info.leader = true - break - case "esc": - info.name = "escape" - break - default: - info.name = part - break - } - } - - return info - }) -} - -export * as Keybind from "./keybind" diff --git a/packages/opencode/test/cli/tui/keybind-plugin.test.ts b/packages/opencode/test/cli/tui/keybind-plugin.test.ts deleted file mode 100644 index 7cd4c87a73f0..000000000000 --- a/packages/opencode/test/cli/tui/keybind-plugin.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { describe, expect, test } from "bun:test" -import type { ParsedKey } from "@opentui/core" -import { createPluginKeybind } from "../../../src/cli/cmd/tui/context/plugin-keybinds" - -describe("createPluginKeybind", () => { - const defaults = { - open: "ctrl+o", - close: "escape", - } - - test("uses defaults when overrides are missing", () => { - const api = { - match: () => false, - print: (key: string) => key, - } - const bind = createPluginKeybind(api, defaults) - - expect(bind.all).toEqual(defaults) - expect(bind.get("open")).toBe("ctrl+o") - expect(bind.get("close")).toBe("escape") - }) - - test("applies valid overrides", () => { - const api = { - match: () => false, - print: (key: string) => key, - } - const bind = createPluginKeybind(api, defaults, { - open: "ctrl+alt+o", - close: "q", - }) - - expect(bind.all).toEqual({ - open: "ctrl+alt+o", - close: "q", - }) - }) - - test("ignores invalid overrides", () => { - const api = { - match: () => false, - print: (key: string) => key, - } - const bind = createPluginKeybind(api, defaults, { - open: " ", - close: 1, - extra: "ctrl+x", - }) - - expect(bind.all).toEqual(defaults) - expect(bind.get("extra")).toBe("extra") - }) - - test("resolves names for match", () => { - const list: string[] = [] - const api = { - match: (key: string) => { - list.push(key) - return true - }, - print: (key: string) => key, - } - const bind = createPluginKeybind(api, defaults, { - open: "ctrl+shift+o", - }) - - bind.match("open", { name: "x" } as ParsedKey) - bind.match("ctrl+k", { name: "x" } as ParsedKey) - - expect(list).toEqual(["ctrl+shift+o", "ctrl+k"]) - }) - - test("resolves names for print", () => { - const list: string[] = [] - const api = { - match: () => false, - print: (key: string) => { - list.push(key) - return `print:${key}` - }, - } - const bind = createPluginKeybind(api, defaults, { - close: "q", - }) - - expect(bind.print("close")).toBe("print:q") - expect(bind.print("ctrl+p")).toBe("print:ctrl+p") - expect(list).toEqual(["q", "ctrl+p"]) - }) -}) diff --git a/packages/opencode/test/cli/tui/plugin-add.test.ts b/packages/opencode/test/cli/tui/plugin-add.test.ts index 972da0f50f54..c54dbaacaa2f 100644 --- a/packages/opencode/test/cli/tui/plugin-add.test.ts +++ b/packages/opencode/test/cli/tui/plugin-add.test.ts @@ -4,6 +4,7 @@ import path from "path" import { pathToFileURL } from "url" import { tmpdir } from "../../fixture/fixture" import { createTuiPluginApi } from "../../fixture/tui-plugin" +import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui" const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") @@ -31,10 +32,9 @@ test("adds tui plugin at runtime from spec", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin: [], - plugin_origins: undefined, - } + }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) @@ -74,10 +74,9 @@ test("retries runtime add for file plugins after dependency wait", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin: [], - plugin_origins: undefined, - } + }) const wait = spyOn(TuiConfig, "waitForDependencies").mockImplementation(async () => { await Bun.write( path.join(tmp.extra.mod, "index.ts"), diff --git a/packages/opencode/test/cli/tui/plugin-install.test.ts b/packages/opencode/test/cli/tui/plugin-install.test.ts index ca7e8fcd216d..50ca4dbad2da 100644 --- a/packages/opencode/test/cli/tui/plugin-install.test.ts +++ b/packages/opencode/test/cli/tui/plugin-install.test.ts @@ -4,6 +4,7 @@ import path from "path" import { pathToFileURL } from "url" import { tmpdir } from "../../fixture/fixture" import { createTuiPluginApi } from "../../fixture/tui-plugin" +import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui" const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") @@ -50,10 +51,9 @@ test("installs plugin without loading it", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin: [], - plugin_origins: undefined, - } + }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const api = createTuiPluginApi({ diff --git a/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts b/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts index 66858e2d0d98..35df997e8b9a 100644 --- a/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts +++ b/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts @@ -4,6 +4,7 @@ import path from "path" import { pathToFileURL } from "url" import { tmpdir } from "../../fixture/fixture" import { createTuiPluginApi } from "../../fixture/tui-plugin" +import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui" import { Npm } from "@opencode-ai/core/npm" @@ -44,7 +45,7 @@ test("loads npm tui plugin from package ./tui export", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]], plugin_origins: [ { @@ -53,7 +54,7 @@ test("loads npm tui plugin from package ./tui export", async () => { source: path.join(tmp.path, "tui.json"), }, ], - } + }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) @@ -105,7 +106,7 @@ test("does not use npm package exports dot for tui entry", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin: [tmp.extra.spec], plugin_origins: [ { @@ -114,7 +115,7 @@ test("does not use npm package exports dot for tui entry", async () => { source: path.join(tmp.path, "tui.json"), }, ], - } + }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) @@ -167,7 +168,7 @@ test("rejects npm tui export that resolves outside plugin directory", async () = }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin: [tmp.extra.spec], plugin_origins: [ { @@ -176,7 +177,7 @@ test("rejects npm tui export that resolves outside plugin directory", async () = source: path.join(tmp.path, "tui.json"), }, ], - } + }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) @@ -229,7 +230,7 @@ test("rejects npm tui plugin that exports server and tui together", async () => }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin: [tmp.extra.spec], plugin_origins: [ { @@ -238,7 +239,7 @@ test("rejects npm tui plugin that exports server and tui together", async () => source: path.join(tmp.path, "tui.json"), }, ], - } + }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) @@ -287,7 +288,7 @@ test("does not use npm package main for tui entry", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin: [tmp.extra.spec], plugin_origins: [ { @@ -296,7 +297,7 @@ test("does not use npm package main for tui entry", async () => { source: path.join(tmp.path, "tui.json"), }, ], - } + }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) @@ -352,7 +353,7 @@ test("does not use directory package main for tui entry", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin: [tmp.extra.spec], plugin_origins: [ { @@ -361,7 +362,7 @@ test("does not use directory package main for tui entry", async () => { source: path.join(tmp.path, "tui.json"), }, ], - } + }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) @@ -399,7 +400,7 @@ test("uses directory index fallback for tui when package.json is missing", async }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin: [tmp.extra.spec], plugin_origins: [ { @@ -408,7 +409,7 @@ test("uses directory index fallback for tui when package.json is missing", async source: path.join(tmp.path, "tui.json"), }, ], - } + }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) @@ -456,7 +457,7 @@ test("uses npm package name when tui plugin id is omitted", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]], plugin_origins: [ { @@ -465,7 +466,7 @@ test("uses npm package name when tui plugin id is omitted", async () => { source: path.join(tmp.path, "tui.json"), }, ], - } + }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) diff --git a/packages/opencode/test/cli/tui/plugin-loader-pure.test.ts b/packages/opencode/test/cli/tui/plugin-loader-pure.test.ts index ba7a4b3959e6..fb4a3bb57d00 100644 --- a/packages/opencode/test/cli/tui/plugin-loader-pure.test.ts +++ b/packages/opencode/test/cli/tui/plugin-loader-pure.test.ts @@ -4,6 +4,7 @@ import path from "path" import { pathToFileURL } from "url" import { tmpdir } from "../../fixture/fixture" import { createTuiPluginApi } from "../../fixture/tui-plugin" +import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui" const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") @@ -37,7 +38,7 @@ test("skips external tui plugins in pure mode", async () => { process.env.OPENCODE_PURE = "1" process.env.OPENCODE_PLUGIN_META_FILE = tmp.extra.meta - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]], plugin_origins: [ { @@ -46,7 +47,7 @@ test("skips external tui plugins in pure mode", async () => { source: path.join(tmp.path, "tui.json"), }, ], - } + }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) diff --git a/packages/opencode/test/cli/tui/plugin-loader.test.ts b/packages/opencode/test/cli/tui/plugin-loader.test.ts index 4266906a24a9..170210123371 100644 --- a/packages/opencode/test/cli/tui/plugin-loader.test.ts +++ b/packages/opencode/test/cli/tui/plugin-loader.test.ts @@ -2,8 +2,10 @@ import { beforeAll, describe, expect, spyOn, test } from "bun:test" import fs from "fs/promises" import path from "path" import { pathToFileURL } from "url" +import { createTestKeymap } from "@opentui/keymap/testing" import { tmpdir } from "../../fixture/fixture" import { createTuiPluginApi } from "../../fixture/tui-plugin" +import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { Global } from "@opencode-ai/core/global" import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui" import { Filesystem } from "@/util/filesystem" @@ -79,7 +81,10 @@ async function load(): Promise { await Bun.write( localPluginPath, - `export const ignored = async (_input, options) => { + `import { resolveBindingSections } from "@opentui/keymap/extras" +import { useBindings } from "@opentui/keymap/solid" + +export const ignored = async (_input, options) => { if (!options?.fn_marker) return await Bun.write(options.fn_marker, "called") } @@ -93,10 +98,21 @@ export default { const cfg_speed = api.tuiConfig.scroll_speed const cfg_accel = api.tuiConfig.scroll_acceleration?.enabled const cfg_submit = api.tuiConfig.keybinds?.input_submit - const key = api.keybind.create( - { modal: "ctrl+shift+m", screen: "ctrl+shift+o", close: "escape" }, - options.keybinds, - ) + 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 key_unknown = "ctrl+k" + const off = api.keymap.registerLayer({ + commands: [{ name: "plugin.loader.local", run() {} }, { name: "plugin.loader.close", run() {} }], + bindings: keymap.main, + }) + off() const kv_before = api.kv.get(options.kv_key, "missing") api.kv.set(options.kv_key, "stored") const kv_after = api.kv.get(options.kv_key, "missing") @@ -132,10 +148,13 @@ export default { set_installed, selected: api.theme.selected, same: first === second, - key_modal: key.get("modal"), - key_close: key.get("close"), - key_unknown: key.get("ctrl+k"), - key_print: key.print("modal"), + key_modal, + key_close, + key_unknown, + has_keys, + has_keymap: typeof api.keymap.registerLayer === "function", + has_resolve_binding_sections: typeof resolveBindingSections === "function", + has_keymap_solid: typeof useBindings === "function", kv_before, kv_after, kv_ready: api.kv.ready, @@ -337,7 +356,14 @@ export default { theme_name: tmp.extra.localThemeName, kv_key: "plugin_state_key", session_id: "ses_test", - keybinds: { modal: "ctrl+alt+m", close: "q" }, + keymap: { + sections: { + main: { + "plugin.loader.local": "ctrl+alt+m", + "plugin.loader.close": "q", + }, + }, + }, } const invalidOpts = { marker: tmp.extra.invalidMarker, @@ -356,7 +382,7 @@ export default { theme_name: tmp.extra.globalThemeName, } - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin: [ [tmp.extra.localSpec, localOpts], [tmp.extra.invalidSpec, invalidOpts], @@ -373,7 +399,7 @@ export default { source: path.join(Global.Path.config, "tui.json"), }, ], - } + }) await TuiPluginRuntime.init({ api: createTuiPluginApi({ @@ -386,9 +412,6 @@ export default { input_submit: "ctrl+enter", }, }, - keybind: { - print: (key) => `print:${key}`, - }, state: { session: { diff(sessionID) { @@ -507,7 +530,7 @@ test("continues loading when a plugin is missing config metadata", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin: [ [tmp.extra.badSpec, { marker: path.join(tmp.path, "bad.txt") }], [tmp.extra.goodSpec, { marker: tmp.extra.goodMarker }], @@ -525,7 +548,7 @@ test("continues loading when a plugin is missing config metadata", async () => { source: path.join(tmp.path, "tui.json"), }, ], - } + }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) @@ -606,13 +629,13 @@ export default { const b = path.join(tmp.path, "order-b.ts") const aSpec = pathToFileURL(a).href const bSpec = pathToFileURL(b).href - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin: [aSpec, bSpec], plugin_origins: [ { spec: aSpec, scope: "local", source: path.join(tmp.path, "tui.json") }, { spec: bSpec, scope: "local", source: path.join(tmp.path, "tui.json") }, ], - } + }) await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) const lines = (await fs.readFile(tmp.extra.marker, "utf8")).trim().split("\n") expect(lines).toEqual(["a-start", "a-end", "b"]) @@ -645,7 +668,10 @@ describe("tui.plugin.loader", () => { expect(data.local.key_modal).toBe("ctrl+alt+m") expect(data.local.key_close).toBe("q") expect(data.local.key_unknown).toBe("ctrl+k") - expect(data.local.key_print).toBe("print:ctrl+alt+m") + 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_keymap_solid).toBe(true) expect(data.local.kv_before).toBe("missing") expect(data.local.kv_after).toBe("stored") expect(data.local.kv_ready).toBe(true) @@ -703,6 +729,227 @@ describe("tui.plugin.loader", () => { }) }) +test("auto-disposes plugin keymap layers", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const file = path.join(dir, "keymap-cleanup-plugin.ts") + const spec = pathToFileURL(file).href + + await Bun.write( + file, + `export default { + id: "demo.keymap.cleanup", + tui: async (api) => { + api.keymap.registerLayer({ + commands: [{ name: "demo.keymap.cleanup", run() {} }], + bindings: [{ key: "ctrl+g", cmd: "demo.keymap.cleanup" }], + }) + }, +} +`, + ) + + return { spec } + }, + }) + + let command_add = 0 + let command_drop = 0 + const keymap = { + registerLayer(layer: { commands?: Array<{ name: string }> }) { + const tracked = layer.commands?.some((item) => item.name === "demo.keymap.cleanup") ?? false + if (tracked) command_add += 1 + return () => { + if (!tracked) return + command_drop += 1 + } + }, + } as NonNullable[0]>["keymap"] + const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() + const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) + + try { + await TuiPluginRuntime.init({ + api: createTuiPluginApi({ keymap }), + config: createTuiResolvedConfig({ + plugin: [tmp.extra.spec], + plugin_origins: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }], + }), + }) + + expect(command_add).toBe(1) + expect(command_drop).toBe(0) + } finally { + await TuiPluginRuntime.dispose() + expect(command_drop).toBe(1) + cwd.mockRestore() + wait.mockRestore() + } +}) + +test("plugin keymap proxy preserves real keymap receiver", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const file = path.join(dir, "keymap-receiver-plugin.ts") + const spec = pathToFileURL(file).href + const marker = path.join(dir, "keymap-receiver.txt") + + await Bun.write( + file, + `export default { + id: "demo.keymap.receiver", + tui: async (api) => { + api.keymap.setData("demo.receiver", "ok") + await Bun.write(${JSON.stringify(marker)}, String(api.keymap.getData("demo.receiver"))) + }, +} +`, + ) + + return { spec, marker } + }, + }) + + const harness = createTestKeymap({ defaultKeys: true }) + const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() + const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) + + try { + await TuiPluginRuntime.init({ + api: createTuiPluginApi({ + keymap: harness.keymap as unknown as NonNullable[0]>["keymap"], + }), + config: createTuiResolvedConfig({ + plugin: [tmp.extra.spec], + plugin_origins: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }], + }), + }) + + await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("ok") + expect(harness.keymap.getData("demo.receiver")).toBe("ok") + } finally { + await TuiPluginRuntime.dispose() + harness.cleanup() + cwd.mockRestore() + wait.mockRestore() + } +}) + +test("auto-disposes plugin keymap transformers", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const file = path.join(dir, "keymap-transformer-cleanup-plugin.ts") + const spec = pathToFileURL(file).href + + await Bun.write( + file, + `export default { + id: "demo.keymap.transformer.cleanup", + tui: async (api) => { + api.keymap.prependLayerBindingsTransformer((bindings) => bindings) + api.keymap.appendLayerBindingsTransformer((bindings) => bindings) + api.keymap.prependCommandTransformer(() => {}) + api.keymap.appendCommandTransformer(() => {}) + }, +} +`, + ) + + return { spec } + }, + }) + + let add = 0 + let drop = 0 + const track = () => { + add += 1 + return () => { + drop += 1 + } + } + const keymap = { + registerLayer: () => () => {}, + prependLayerBindingsTransformer: track, + appendLayerBindingsTransformer: track, + prependCommandTransformer: track, + appendCommandTransformer: track, + } as unknown as NonNullable[0]>["keymap"] + const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() + const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) + + try { + await TuiPluginRuntime.init({ + api: createTuiPluginApi({ keymap }), + config: createTuiResolvedConfig({ + plugin: [tmp.extra.spec], + plugin_origins: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }], + }), + }) + + expect(add).toBe(4) + expect(drop).toBe(0) + } finally { + await TuiPluginRuntime.dispose() + expect(drop).toBe(4) + cwd.mockRestore() + wait.mockRestore() + } +}) + +test("manual onDispose for plugin keymap layers stays idempotent", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const file = path.join(dir, "keymap-cleanup-manual-plugin.ts") + const spec = pathToFileURL(file).href + + await Bun.write( + file, + `export default { + id: "demo.keymap.cleanup.manual", + tui: async (api) => { + const off = api.keymap.registerLayer({ + commands: [{ name: "demo.keymap.cleanup.manual", run() {} }], + bindings: [{ key: "ctrl+h", cmd: "demo.keymap.cleanup.manual" }], + }) + api.lifecycle.onDispose(off) + }, +} +`, + ) + + return { spec } + }, + }) + + let command_drop = 0 + const keymap = { + registerLayer(layer: { commands?: Array<{ name: string }> }) { + const tracked = layer.commands?.some((item) => item.name === "demo.keymap.cleanup.manual") ?? false + return () => { + if (!tracked) return + command_drop += 1 + } + }, + } as NonNullable[0]>["keymap"] + const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() + const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) + + try { + await TuiPluginRuntime.init({ + api: createTuiPluginApi({ keymap }), + config: createTuiResolvedConfig({ + plugin: [tmp.extra.spec], + plugin_origins: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }], + }), + }) + } finally { + await TuiPluginRuntime.dispose() + expect(command_drop).toBe(1) + cwd.mockRestore() + wait.mockRestore() + } +}) + test("updates installed theme when plugin metadata changes", async () => { await using tmp = await tmpdir<{ spec: string @@ -766,16 +1013,17 @@ test("updates installed theme when plugin metadata changes", async () => { }, }) - const mkConfig = (): TuiConfig.Info => ({ - plugin: [[tmp.extra.spec, { theme_path: `./theme-update.json` }]], - plugin_origins: [ - { - spec: [tmp.extra.spec, { theme_path: `./theme-update.json` }], - scope: "local", - source: path.join(tmp.path, "tui.json"), - }, - ], - }) + const mkConfig = () => + createTuiResolvedConfig({ + plugin: [[tmp.extra.spec, { theme_path: `./theme-update.json` }]], + plugin_origins: [ + { + spec: [tmp.extra.spec, { theme_path: `./theme-update.json` }], + scope: "local", + source: path.join(tmp.path, "tui.json"), + }, + ], + }) try { await TuiPluginRuntime.init({ api: mkApi(), config: mkConfig() }) diff --git a/packages/opencode/test/cli/tui/plugin-toggle.test.ts b/packages/opencode/test/cli/tui/plugin-toggle.test.ts index 11fdf5ce4626..4dde1add4d20 100644 --- a/packages/opencode/test/cli/tui/plugin-toggle.test.ts +++ b/packages/opencode/test/cli/tui/plugin-toggle.test.ts @@ -4,6 +4,7 @@ import path from "path" import { pathToFileURL } from "url" import { tmpdir } from "../../fixture/fixture" import { createTuiPluginApi } from "../../fixture/tui-plugin" +import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui" const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") @@ -39,7 +40,7 @@ test("toggles plugin runtime state by exported id", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]], plugin_enabled: { "demo.toggle": false, @@ -51,7 +52,7 @@ test("toggles plugin runtime state by exported id", async () => { source: path.join(tmp.path, "tui.json"), }, ], - } + }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const api = createTuiPluginApi() @@ -116,7 +117,7 @@ test("kv plugin_enabled overrides tui config on startup", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]], plugin_enabled: { "demo.startup": false, @@ -128,7 +129,7 @@ test("kv plugin_enabled overrides tui config on startup", async () => { source: path.join(tmp.path, "tui.json"), }, ], - } + }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const api = createTuiPluginApi() diff --git a/packages/opencode/test/fixture/tui-plugin.ts b/packages/opencode/test/fixture/tui-plugin.ts index 26913222e894..0f7cfd43a9c0 100644 --- a/packages/opencode/test/fixture/tui-plugin.ts +++ b/packages/opencode/test/fixture/tui-plugin.ts @@ -1,6 +1,5 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { RGBA, type CliRenderer } from "@opentui/core" -import { createPluginKeybind } from "../../src/cli/cmd/tui/context/plugin-keybinds" import type { HostPluginApi } from "../../src/cli/cmd/tui/plugin/slots" type Count = { @@ -84,8 +83,8 @@ type Opts = { client?: HostPluginApi["client"] | (() => HostPluginApi["client"]) renderer?: HostPluginApi["renderer"] count?: Count - keybind?: Partial - tuiConfig?: HostPluginApi["tuiConfig"] + keymap?: HostPluginApi["keymap"] + tuiConfig?: Partial app?: Partial state?: { ready?: HostPluginApi["state"]["ready"] @@ -109,6 +108,17 @@ type Opts = { } } +function tuiConfig(input?: Partial): HostPluginApi["tuiConfig"] { + return { + ...input, + keymap: input?.keymap ?? { + leader: "ctrl+x", + leader_timeout: 2000, + sections: {}, + }, + } +} + export function createTuiPluginApi(opts: Opts = {}): HostPluginApi { const kv: Record = {} const count = opts.count @@ -128,10 +138,6 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi { let size: "medium" | "large" | "xlarge" = "medium" const has = opts.theme?.has ?? (() => false) let selected = opts.theme?.selected ?? "opencode" - const key = { - match: opts.keybind?.match ?? (() => false), - print: opts.keybind?.print ?? ((name: string) => name), - } const set = opts.theme?.set ?? ((name: string) => { @@ -145,6 +151,26 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi { return this }, } + const keymap = + opts.keymap ?? + ({ + acquireResource(_key: symbol, setup: () => () => void) { + const dispose = setup() + return () => { + dispose() + } + }, + registerLayer() { + if (count) count.command_add += 1 + return () => { + if (!count) return + count.command_drop += 1 + } + }, + runCommand() { + return { ok: true } as const + }, + } as unknown as HostPluginApi["keymap"]) function kvGet(name: string): unknown function kvGet(name: string, fallback: Value): Value @@ -160,6 +186,10 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi { return opts.app?.version ?? "0.0.0-test" }, }, + keys: { + formatSequence: () => "", + formatBindings: () => undefined, + }, get client() { return client() }, @@ -192,17 +222,7 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi { return () => {} }, }, - command: { - register: () => { - if (count) count.command_add += 1 - return () => { - if (!count) return - count.command_drop += 1 - } - }, - trigger: () => {}, - show: () => {}, - }, + keymap, route: { register: () => { if (count) count.route_add += 1 @@ -247,15 +267,7 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi { }, }, }, - keybind: { - ...key, - create: - opts.keybind?.create ?? - ((defaults, over) => { - return createPluginKeybind(key, defaults, over) - }), - }, - tuiConfig: opts.tuiConfig ?? {}, + tuiConfig: tuiConfig(opts.tuiConfig), kv: { get: kvGet, set(name, value) { diff --git a/packages/opencode/test/fixture/tui-runtime.ts b/packages/opencode/test/fixture/tui-runtime.ts index ba8099fcdd6b..e3912ecaa3c5 100644 --- a/packages/opencode/test/fixture/tui-runtime.ts +++ b/packages/opencode/test/fixture/tui-runtime.ts @@ -1,8 +1,23 @@ import { spyOn } from "bun:test" import path from "path" 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" type PluginSpec = string | [string, Record] +type ResolvedInput = Omit & { + keybinds?: TuiConfig.Resolved["keybinds"] + keymap?: TuiConfig.Resolved["keymap"] +} + +export function createTuiResolvedConfig(input: ResolvedInput = {}): TuiConfig.Resolved { + const keybinds = input.keybinds ?? ConfigKeybinds.Keybinds.parse({}) + return { + ...input, + keybinds, + keymap: input.keymap ?? LegacyKeymapTransform.create(keybinds), + } +} export function mockTuiRuntime(dir: string, plugin: PluginSpec[], opts?: { plugin_enabled?: Record }) { process.env.OPENCODE_PLUGIN_META_FILE = path.join(dir, "plugin-meta.json") @@ -14,11 +29,11 @@ export function mockTuiRuntime(dir: string, plugin: PluginSpec[], opts?: { plugi const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => dir) - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin, plugin_origins, ...(opts?.plugin_enabled && { plugin_enabled: opts.plugin_enabled }), - } + }) return { config, diff --git a/packages/opencode/test/keybind.test.ts b/packages/opencode/test/keybind.test.ts deleted file mode 100644 index 09df5199259a..000000000000 --- a/packages/opencode/test/keybind.test.ts +++ /dev/null @@ -1,421 +0,0 @@ -import { describe, test, expect } from "bun:test" -import { Keybind } from "@/util/keybind" - -describe("Keybind.toString", () => { - test("should convert simple key to string", () => { - const info: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "f" } - expect(Keybind.toString(info)).toBe("f") - }) - - test("should convert ctrl modifier to string", () => { - const info: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "x" } - expect(Keybind.toString(info)).toBe("ctrl+x") - }) - - test("should convert leader key to string", () => { - const info: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: true, name: "f" } - expect(Keybind.toString(info)).toBe(" f") - }) - - test("should convert multiple modifiers to string", () => { - const info: Keybind.Info = { ctrl: true, meta: true, shift: false, leader: false, name: "g" } - expect(Keybind.toString(info)).toBe("ctrl+alt+g") - }) - - test("should convert all modifiers to string", () => { - const info: Keybind.Info = { ctrl: true, meta: true, shift: true, leader: true, name: "h" } - expect(Keybind.toString(info)).toBe(" ctrl+alt+shift+h") - }) - - test("should convert shift modifier to string", () => { - const info: Keybind.Info = { - ctrl: false, - meta: false, - shift: true, - leader: false, - name: "return", - } - expect(Keybind.toString(info)).toBe("shift+return") - }) - - test("should convert function key to string", () => { - const info: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "f2" } - expect(Keybind.toString(info)).toBe("f2") - }) - - test("should convert special key to string", () => { - const info: Keybind.Info = { - ctrl: false, - meta: false, - shift: false, - leader: false, - name: "pgup", - } - expect(Keybind.toString(info)).toBe("pgup") - }) - - test("should handle empty name", () => { - const info: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "" } - expect(Keybind.toString(info)).toBe("ctrl") - }) - - test("should handle only modifiers", () => { - const info: Keybind.Info = { ctrl: true, meta: true, shift: true, leader: true, name: "" } - expect(Keybind.toString(info)).toBe(" ctrl+alt+shift") - }) - - test("should handle only leader with no other parts", () => { - const info: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: true, name: "" } - expect(Keybind.toString(info)).toBe("") - }) - - test("should convert super modifier to string", () => { - const info: Keybind.Info = { ctrl: false, meta: false, shift: false, super: true, leader: false, name: "z" } - expect(Keybind.toString(info)).toBe("super+z") - }) - - test("should convert super+shift modifier to string", () => { - const info: Keybind.Info = { ctrl: false, meta: false, shift: true, super: true, leader: false, name: "z" } - expect(Keybind.toString(info)).toBe("super+shift+z") - }) - - test("should handle super with ctrl modifier", () => { - const info: Keybind.Info = { ctrl: true, meta: false, shift: false, super: true, leader: false, name: "a" } - expect(Keybind.toString(info)).toBe("ctrl+super+a") - }) - - test("should handle super with all modifiers", () => { - const info: Keybind.Info = { ctrl: true, meta: true, shift: true, super: true, leader: false, name: "x" } - expect(Keybind.toString(info)).toBe("ctrl+alt+super+shift+x") - }) - - test("should handle undefined super field (omitted)", () => { - const info: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "c" } - expect(Keybind.toString(info)).toBe("ctrl+c") - }) -}) - -describe("Keybind.match", () => { - test("should match identical keybinds", () => { - const a: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "x" } - const b: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "x" } - expect(Keybind.match(a, b)).toBe(true) - }) - - test("should not match different key names", () => { - const a: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "x" } - const b: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "y" } - expect(Keybind.match(a, b)).toBe(false) - }) - - test("should not match different modifiers", () => { - const a: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "x" } - const b: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "x" } - expect(Keybind.match(a, b)).toBe(false) - }) - - test("should match leader keybinds", () => { - const a: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: true, name: "f" } - const b: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: true, name: "f" } - expect(Keybind.match(a, b)).toBe(true) - }) - - test("should not match leader vs non-leader", () => { - const a: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: true, name: "f" } - const b: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "f" } - expect(Keybind.match(a, b)).toBe(false) - }) - - test("should match complex keybinds", () => { - const a: Keybind.Info = { ctrl: true, meta: true, shift: false, leader: false, name: "g" } - const b: Keybind.Info = { ctrl: true, meta: true, shift: false, leader: false, name: "g" } - expect(Keybind.match(a, b)).toBe(true) - }) - - test("should not match with one modifier different", () => { - const a: Keybind.Info = { ctrl: true, meta: true, shift: false, leader: false, name: "g" } - const b: Keybind.Info = { ctrl: true, meta: true, shift: true, leader: false, name: "g" } - expect(Keybind.match(a, b)).toBe(false) - }) - - test("should match simple key without modifiers", () => { - const a: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "a" } - const b: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "a" } - expect(Keybind.match(a, b)).toBe(true) - }) - - test("should match super modifier keybinds", () => { - const a: Keybind.Info = { ctrl: false, meta: false, shift: false, super: true, leader: false, name: "z" } - const b: Keybind.Info = { ctrl: false, meta: false, shift: false, super: true, leader: false, name: "z" } - expect(Keybind.match(a, b)).toBe(true) - }) - - test("should not match super vs non-super", () => { - const a: Keybind.Info = { ctrl: false, meta: false, shift: false, super: true, leader: false, name: "z" } - const b: Keybind.Info = { ctrl: false, meta: false, shift: false, super: false, leader: false, name: "z" } - expect(Keybind.match(a, b)).toBe(false) - }) - - test("should match undefined super with false super", () => { - const a: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "c" } - const b: Keybind.Info = { ctrl: true, meta: false, shift: false, super: false, leader: false, name: "c" } - expect(Keybind.match(a, b)).toBe(true) - }) - - test("should match super+shift combination", () => { - const a: Keybind.Info = { ctrl: false, meta: false, shift: true, super: true, leader: false, name: "z" } - const b: Keybind.Info = { ctrl: false, meta: false, shift: true, super: true, leader: false, name: "z" } - expect(Keybind.match(a, b)).toBe(true) - }) - - test("should not match when only super differs", () => { - const a: Keybind.Info = { ctrl: true, meta: true, shift: true, super: true, leader: false, name: "a" } - const b: Keybind.Info = { ctrl: true, meta: true, shift: true, super: false, leader: false, name: "a" } - expect(Keybind.match(a, b)).toBe(false) - }) -}) - -describe("Keybind.parse", () => { - test("should parse simple key", () => { - const result = Keybind.parse("f") - expect(result).toEqual([ - { - ctrl: false, - meta: false, - shift: false, - leader: false, - name: "f", - }, - ]) - }) - - test("should parse leader key syntax", () => { - const result = Keybind.parse("f") - expect(result).toEqual([ - { - ctrl: false, - meta: false, - shift: false, - leader: true, - name: "f", - }, - ]) - }) - - test("should parse ctrl modifier", () => { - const result = Keybind.parse("ctrl+x") - expect(result).toEqual([ - { - ctrl: true, - meta: false, - shift: false, - leader: false, - name: "x", - }, - ]) - }) - - test("should parse multiple modifiers", () => { - const result = Keybind.parse("ctrl+alt+u") - expect(result).toEqual([ - { - ctrl: true, - meta: true, - shift: false, - leader: false, - name: "u", - }, - ]) - }) - - test("should parse shift modifier", () => { - const result = Keybind.parse("shift+f2") - expect(result).toEqual([ - { - ctrl: false, - meta: false, - shift: true, - leader: false, - name: "f2", - }, - ]) - }) - - test("should parse meta/alt modifier", () => { - const result = Keybind.parse("meta+g") - expect(result).toEqual([ - { - ctrl: false, - meta: true, - shift: false, - leader: false, - name: "g", - }, - ]) - }) - - test("should parse leader with modifier", () => { - const result = Keybind.parse("h") - expect(result).toEqual([ - { - ctrl: false, - meta: false, - shift: false, - leader: true, - name: "h", - }, - ]) - }) - - test("should parse multiple keybinds separated by comma", () => { - const result = Keybind.parse("ctrl+c,q") - expect(result).toEqual([ - { - ctrl: true, - meta: false, - shift: false, - leader: false, - name: "c", - }, - { - ctrl: false, - meta: false, - shift: false, - leader: true, - name: "q", - }, - ]) - }) - - test("should parse shift+return combination", () => { - const result = Keybind.parse("shift+return") - expect(result).toEqual([ - { - ctrl: false, - meta: false, - shift: true, - leader: false, - name: "return", - }, - ]) - }) - - test("should parse ctrl+j combination", () => { - const result = Keybind.parse("ctrl+j") - expect(result).toEqual([ - { - ctrl: true, - meta: false, - shift: false, - leader: false, - name: "j", - }, - ]) - }) - - test("should handle 'none' value", () => { - const result = Keybind.parse("none") - expect(result).toEqual([]) - }) - - test("should handle special keys", () => { - const result = Keybind.parse("pgup") - expect(result).toEqual([ - { - ctrl: false, - meta: false, - shift: false, - leader: false, - name: "pgup", - }, - ]) - }) - - test("should handle function keys", () => { - const result = Keybind.parse("f2") - expect(result).toEqual([ - { - ctrl: false, - meta: false, - shift: false, - leader: false, - name: "f2", - }, - ]) - }) - - test("should handle complex multi-modifier combination", () => { - const result = Keybind.parse("ctrl+alt+g") - expect(result).toEqual([ - { - ctrl: true, - meta: true, - shift: false, - leader: false, - name: "g", - }, - ]) - }) - - test("should be case insensitive", () => { - const result = Keybind.parse("CTRL+X") - expect(result).toEqual([ - { - ctrl: true, - meta: false, - shift: false, - leader: false, - name: "x", - }, - ]) - }) - - test("should parse super modifier", () => { - const result = Keybind.parse("super+z") - expect(result).toEqual([ - { - ctrl: false, - meta: false, - shift: false, - super: true, - leader: false, - name: "z", - }, - ]) - }) - - test("should parse super with shift modifier", () => { - const result = Keybind.parse("super+shift+z") - expect(result).toEqual([ - { - ctrl: false, - meta: false, - shift: true, - super: true, - leader: false, - name: "z", - }, - ]) - }) - - test("should parse multiple keybinds with super", () => { - const result = Keybind.parse("ctrl+-,super+z") - expect(result).toEqual([ - { - ctrl: true, - meta: false, - shift: false, - leader: false, - name: "-", - }, - { - ctrl: false, - meta: false, - shift: false, - super: true, - leader: false, - name: "z", - }, - ]) - }) -}) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 9bcf2a6f1fc4..51cf78d9cf49 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -22,19 +22,24 @@ "zod": "catalog:" }, "peerDependencies": { - "@opentui/core": ">=0.2.2", - "@opentui/solid": ">=0.2.2" + "@opentui/core": ">=0.2.3", + "@opentui/keymap": ">=0.2.3", + "@opentui/solid": ">=0.2.3" }, "peerDependenciesMeta": { "@opentui/core": { "optional": true }, + "@opentui/keymap": { + "optional": true + }, "@opentui/solid": { "optional": true } }, "devDependencies": { "@opentui/core": "catalog:", + "@opentui/keymap": "catalog:", "@opentui/solid": "catalog:", "@tsconfig/node22": "catalog:", "@types/node": "catalog:", diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index 1c57a71ab315..c840b6f5da2d 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -15,11 +15,39 @@ import type { TextPart, Config as SdkConfig, } from "@opencode-ai/sdk/v2" -import type { CliRenderer, ParsedKey, RGBA, SlotMode } from "@opentui/core" +import type { CliRenderer, KeyEvent, RGBA, Renderable, SlotMode } from "@opentui/core" +import type { Binding, Keymap } from "@opentui/keymap" +import { + resolveBindingSections as resolveKeymapBindingSections, + type BindingSectionsConfig, + type KeySequenceFormatPart, + type SequenceBindingLike, +} from "@opentui/keymap/extras" import type { JSX, SolidPlugin } from "@opentui/solid" import type { Config as PluginConfig, PluginOptions } from "./index.js" -export type { CliRenderer, SlotMode } from "@opentui/core" +export type { CliRenderer, KeyEvent, Renderable, SlotMode } from "@opentui/core" +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, + BindingValue, + FormatCommandBindingsOptions, + FormatKeySequenceOptions, + KeySequenceFormatPart, + SequenceBindingLike, +} from "@opentui/keymap/extras" + +export function resolveBindingSections
( + config: BindingSectionsConfig | undefined, + options: { sections: readonly Section[] }, +) { + return resolveKeymapBindingSections, Section>( + config ?? {}, + options, + ) +} export type TuiRouteCurrent = | { @@ -42,39 +70,12 @@ export type TuiRouteDefinition = { render: (input: { params?: Record }) => JSX.Element } -export type TuiCommand = { - title: string - value: string - description?: string - category?: string - keybind?: string - suggested?: boolean - hidden?: boolean - enabled?: boolean - slash?: { - name: string - aliases?: string[] - } - onSelect?: () => void -} - -export type TuiKeybind = { - name: string - ctrl: boolean - meta: boolean - shift: boolean - super?: boolean - leader: boolean +export type TuiKeys = { + formatSequence: (parts: readonly KeySequenceFormatPart[] | undefined) => string + formatBindings: (bindings: readonly SequenceBindingLike[] | undefined) => string | undefined } -export type TuiKeybindMap = Record - -export type TuiKeybindSet = { - readonly all: TuiKeybindMap - get: (name: string) => string - match: (name: string, evt: ParsedKey) => boolean - print: (name: string) => string -} +export type TuiKeymap = Keymap export type TuiDialogProps = { size?: "medium" | "large" | "xlarge" @@ -288,6 +289,11 @@ export type TuiState = { type TuiConfigView = Pick & NonNullable & { plugin_enabled?: Record + keymap: { + leader: string + leader_timeout: number + sections: Record>> + } } export type TuiApp = { @@ -448,11 +454,8 @@ export type TuiWorkspace = { export type TuiPluginApi = { app: TuiApp - command: { - register: (cb: () => TuiCommand[]) => () => void - trigger: (value: string) => void - show: () => void - } + keys: TuiKeys + keymap: TuiKeymap route: { register: (routes: TuiRouteDefinition[]) => () => void navigate: (name: string, params?: Record) => void @@ -469,11 +472,6 @@ export type TuiPluginApi = { toast: (input: TuiToast) => void dialog: TuiDialogStack } - keybind: { - match: (key: string, evt: ParsedKey) => boolean - print: (key: string) => string - create: (defaults: TuiKeybindMap, overrides?: Record) => TuiKeybindSet - } readonly tuiConfig: Frozen kv: TuiKV state: TuiState diff --git a/packages/opencode/script/upgrade-opentui.ts b/script/upgrade-opentui.ts similarity index 63% rename from packages/opencode/script/upgrade-opentui.ts rename to script/upgrade-opentui.ts index 615a407745be..3fc194e16766 100644 --- a/packages/opencode/script/upgrade-opentui.ts +++ b/script/upgrade-opentui.ts @@ -9,29 +9,30 @@ if (!raw) { } const ver = raw.replace(/^v/, "") -const root = path.resolve(import.meta.dir, "../../..") +const root = path.resolve(import.meta.dir, "..") const skip = new Set([".git", ".opencode", ".turbo", "dist", "node_modules"]) -const keys = ["@opentui/core", "@opentui/solid"] as const +const keys = ["@opentui/core", "@opentui/keymap", "@opentui/solid"] as const const files = (await Array.fromAsync(new Bun.Glob("**/package.json").scan({ cwd: root }))).filter( (file) => !file.split("/").some((part) => skip.has(part)), ) -const set = (cur: string) => { +const setVersion = (cur: string) => { + if (cur === "catalog:" || cur.startsWith("workspace:")) return cur if (cur.startsWith(">=")) return `>=${ver}` if (cur.startsWith("^")) return `^${ver}` if (cur.startsWith("~")) return `~${ver}` return ver } -const edit = (obj: unknown) => { +const editDeps = (obj: unknown) => { if (!obj || typeof obj !== "object") return false const map = obj as Record return keys .map((key) => { const cur = map[key] if (typeof cur !== "string") return false - const next = set(cur) + const next = setVersion(cur) if (next === cur) return false map[key] = next return true @@ -39,13 +40,31 @@ const edit = (obj: unknown) => { .some(Boolean) } +const editCatalog = (obj: unknown) => { + if (!obj || typeof obj !== "object") return false + const map = obj as Record + return keys + .map((key) => { + const cur = map[key] + if (typeof cur !== "string" || cur === ver) return false + map[key] = ver + return true + }) + .some(Boolean) +} + const out = ( await Promise.all( files.map(async (rel) => { const file = path.join(root, rel) const txt = await Bun.file(file).text() const json = JSON.parse(txt) - const hit = [json.dependencies, json.devDependencies, json.peerDependencies].map(edit).some(Boolean) + const hit = [ + editCatalog(json.workspaces?.catalog), + editDeps(json.dependencies), + editDeps(json.devDependencies), + editDeps(json.peerDependencies), + ].some(Boolean) if (!hit) return null await Bun.write(file, `${JSON.stringify(json, null, 2)}\n`) return rel From 0905c8d2f9142210bd0ca5ee5834443f254c6337 Mon Sep 17 00:00:00 2001 From: Sebastian Herrlinger Date: Wed, 6 May 2026 22:04:49 +0200 Subject: [PATCH 2/7] docs --- packages/web/src/content/docs/config.mdx | 16 +- packages/web/src/content/docs/keybinds.mdx | 408 +++++++++++++++------ packages/web/src/content/docs/tui.mdx | 17 +- 3 files changed, 327 insertions(+), 114 deletions(-) diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index 8568ffbb9e08..fd4eee60ff14 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -525,17 +525,27 @@ You can also define commands using markdown files in `~/.config/opencode/command --- -### Keybinds +### Keymap -Customize keybinds in `tui.json`. +Customize TUI keyboard shortcuts in `tui.json` with `keymap`. ```json title="tui.json" { "$schema": "https://opencode.ai/tui.json", - "keybinds": {} + "keymap": { + "sections": { + "app": { + "command.palette.show": "ctrl+p" + } + } + } } ``` +When `keymap` is set, include every shortcut you want to keep active. It does not merge with the deprecated `keybinds` fallback. + +The older `keybinds` field is deprecated and only applies when `keymap` is not present. + [Learn more here](/docs/keybinds). --- diff --git a/packages/web/src/content/docs/keybinds.mdx b/packages/web/src/content/docs/keybinds.mdx index 86970638c71e..4ecec785c42f 100644 --- a/packages/web/src/content/docs/keybinds.mdx +++ b/packages/web/src/content/docs/keybinds.mdx @@ -1,144 +1,338 @@ --- title: Keybinds -description: Customize your keybinds. +description: Customize your keyboard shortcuts. --- -OpenCode has a list of keybinds that you can customize through `tui.json`. +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. + +When you define `keymap`, include every shortcut you want to keep active. It does not merge with the legacy `keybinds` fallback. + +--- + +## 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 ```json title="tui.json" { "$schema": "https://opencode.ai/tui.json", - "keybinds": { + "keymap": { "leader": "ctrl+x", - "app_exit": "ctrl+c,ctrl+d,q", - "editor_open": "e", - "theme_list": "t", - "sidebar_toggle": "b", - "scrollbar_toggle": "none", - "username_toggle": "none", - "status_view": "s", - "tool_details": "none", - "session_export": "x", - "session_new": "n", - "session_list": "l", - "session_timeline": "g", - "session_fork": "none", - "session_rename": "ctrl+r", - "session_share": "none", - "session_unshare": "none", - "session_interrupt": "escape", - "session_compact": "c", - "session_child_first": "down", - "session_child_cycle": "right", - "session_child_cycle_reverse": "left", - "session_parent": "up", - "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_copy": "y", - "messages_undo": "u", - "messages_redo": "r", - "messages_last_user": "none", - "messages_toggle_conceal": "h", - "model_list": "m", - "model_cycle_recent": "f2", - "model_cycle_recent_reverse": "shift+f2", - "model_cycle_favorite": "none", - "model_cycle_favorite_reverse": "none", - "variant_cycle": "ctrl+t", - "variant_list": "none", - "command_list": "ctrl+p", - "agent_list": "a", - "agent_cycle": "tab", - "agent_cycle_reverse": "shift+tab", - "input_clear": "ctrl+c", - "input_paste": "ctrl+v", - "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", - "history_previous": "up", - "history_next": "down", - "terminal_suspend": "ctrl+z", - "terminal_title_toggle": "none", - "tips_toggle": "h", - "display_thinking": "none" + "leader_timeout": 2000, + "sections": { + "app": { + "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"] + } + } } } ``` -:::note -On Windows, the defaults for `input_undo` and `terminal_suspend` are different: +--- -- `input_undo` defaults to `ctrl+z,ctrl+-,super+z` (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. - ::: +## Keymap structure + +`keymap.sections` is grouped by TUI 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 section names to command bindings. | --- -## Leader key +## Binding values + +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. + +```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"] + } + } + } +} +``` + +For advanced cases, use an object with `key`, `event`, `preventDefault`, or `fallthrough`. -OpenCode uses a `leader` key for most keybinds. This avoids conflicts in your terminal. +```json title="tui.json" +{ + "$schema": "https://opencode.ai/tui.json", + "keymap": { + "sections": { + "prompt_paste": { + "prompt.paste": { + "key": "ctrl+v", + "preventDefault": false + } + } + } + } +} +``` -By default, `ctrl+x` is the leader key and most 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. +## Complete keymap reference -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`. +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": { + "app": { + "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", + "prompt.editor.shortcut": "e", + "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": "none", + "prompt.editor_context.clear": "none", + "prompt.stash": "none", + "prompt.stash.pop": "none", + "prompt.stash.list": "none", + "session.interrupt": "escape" + }, + "prompt_clear": { + "prompt.clear": "ctrl+c" + }, + "prompt_paste": { + "prompt.paste": { + "key": "ctrl+v", + "preventDefault": false + } + }, + "prompt_history_previous": { + "prompt.history.previous": "up" + }, + "prompt_history_next": { + "prompt.history.next": "down" + }, + "prompt_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_stash": { + "dialog.stash.delete": "ctrl+d" + }, + "dialog_session_list": { + "dialog.session.delete": "ctrl+d", + "dialog.session.rename": "ctrl+r" + }, + "dialog_model": { + "dialog.model.provider.list": "ctrl+a", + "dialog.model.favorite.toggle": "ctrl+f" + }, + "dialog_mcp": { + "dialog.mcp.toggle": "space" + }, + "permission_reject": { + "permission.reject.cancel": "ctrl+c,ctrl+d,q" + }, + "permission_prompt_escape": { + "permission.prompt.escape": "ctrl+c,ctrl+d,q" + }, + "permission_prompt_fullscreen": { + "permission.prompt.fullscreen": "ctrl+f" + }, + "question": { + "question.reject": "ctrl+c,ctrl+d,q" + }, + "question_edit": { + "question.edit.clear": "ctrl+c" + }, + "plugins": { + "plugins.list": "none", + "plugins.install": "none" + }, + "dialog_plugins": { + "plugins.toggle": "space", + "dialog.plugins.install": "shift+i" + }, + "home_tips": { + "tips.toggle": "h" + } + } + } +} +``` --- -## Disable keybind +## Legacy keybinds + +`keybinds` is deprecated. It is kept so existing configs continue to work while users migrate to `keymap`. -You can disable a keybind by adding the key to `tui.json` with a value of "none". +Only use `keybinds` when `keymap` is not present. If both fields are set, `keymap` wins and `keybinds` are ignored for shortcut resolution. ```json title="tui.json" { "$schema": "https://opencode.ai/tui.json", "keybinds": { - "session_compact": "none" + "command_list": "ctrl+p", + "session_new": "n", + "session_compact": "c" } } ``` +:::note +When using legacy `keybinds` on Windows, the defaults for `input_undo` and `terminal_suspend` are different: + +- `input_undo` defaults to `ctrl+z,ctrl+-,super+z` (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. +::: + --- ## Desktop prompt shortcuts diff --git a/packages/web/src/content/docs/tui.mdx b/packages/web/src/content/docs/tui.mdx index 73ecce93b578..828792fb6b8d 100644 --- a/packages/web/src/content/docs/tui.mdx +++ b/packages/web/src/content/docs/tui.mdx @@ -63,7 +63,7 @@ When using the OpenCode TUI, you can type `/` followed by a command name to quic /help ``` -Most commands also have keybind using `ctrl+x` as the leader key, where `ctrl+x` is the default leader key. [Learn more](/docs/keybinds). +Most commands also have keyboard shortcuts using `ctrl+x` as the default leader key. [Learn more](/docs/keybinds). Here are all available slash commands: @@ -353,8 +353,14 @@ You can customize TUI behavior through `tui.json` (or `tui.jsonc`). { "$schema": "https://opencode.ai/tui.json", "theme": "opencode", - "keybinds": { - "leader": "ctrl+x" + "keymap": { + "leader": "ctrl+x", + "leader_timeout": 2000, + "sections": { + "app": { + "command.palette.show": "ctrl+p" + } + } }, "scroll_speed": 3, "scroll_acceleration": { @@ -367,10 +373,13 @@ You can customize TUI behavior through `tui.json` (or `tui.jsonc`). This is separate from `opencode.json`, which configures server/runtime behavior. +When `keymap` is set, include every shortcut you want to keep active. It does not merge with the deprecated `keybinds` fallback. + ### Options - `theme` - Sets your UI theme. [Learn more](/docs/themes). -- `keybinds` - Customizes keyboard shortcuts. [Learn more](/docs/keybinds). +- `keymap` - Customizes keyboard shortcuts. [Learn more](/docs/keybinds). +- `keybinds` - Deprecated legacy shortcut config. This only applies when `keymap` is not present. - `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. From b237916f7b5d0490488cf1c7ace903edf3d299d6 Mon Sep 17 00:00:00 2001 From: Sebastian Herrlinger Date: Thu, 7 May 2026 02:04:42 +0200 Subject: [PATCH 3/7] refactor sections --- bun.lock | 30 ++--- package.json | 6 +- packages/opencode/src/cli/cmd/tui/app.tsx | 13 +-- .../src/cli/cmd/tui/component/dialog-mcp.tsx | 7 +- .../cli/cmd/tui/component/dialog-model.tsx | 10 +- .../cmd/tui/component/dialog-session-list.tsx | 12 +- .../cli/cmd/tui/component/dialog-stash.tsx | 10 +- .../cmd/tui/component/prompt/autocomplete.tsx | 2 +- .../cli/cmd/tui/component/prompt/index.tsx | 23 ++-- .../cmd/tui/config/legacy-keymap-transform.ts | 109 ++++++++++-------- .../src/cli/cmd/tui/config/tui-schema.ts | 44 ++++--- .../opencode/src/cli/cmd/tui/config/tui.ts | 59 ++-------- .../tui/feature-plugins/system/plugins.tsx | 9 +- .../cli/cmd/tui/routes/session/permission.tsx | 14 +-- .../cli/cmd/tui/routes/session/question.tsx | 3 +- .../src/cli/cmd/tui/ui/dialog-select.tsx | 4 + packages/opencode/test/config/tui.test.ts | 91 +++++++++++++++ packages/opencode/test/fixture/tui-plugin.ts | 10 +- packages/plugin/package.json | 6 +- packages/plugin/src/tui.ts | 3 + packages/web/src/content/docs/config.mdx | 2 +- packages/web/src/content/docs/keybinds.mdx | 71 ++++-------- packages/web/src/content/docs/tui.mdx | 2 +- 23 files changed, 291 insertions(+), 249 deletions(-) diff --git a/bun.lock b/bun.lock index 521c3649fef7..8c16faaee13d 100644 --- a/bun.lock +++ b/bun.lock @@ -487,9 +487,9 @@ "typescript": "catalog:", }, "peerDependencies": { - "@opentui/core": ">=0.2.3", - "@opentui/keymap": ">=0.2.3", - "@opentui/solid": ">=0.2.3", + "@opentui/core": ">=0.0.0-20260506-6bb5353a", + "@opentui/keymap": ">=0.0.0-20260506-6bb5353a", + "@opentui/solid": ">=0.0.0-20260506-6bb5353a", }, "optionalPeers": [ "@opentui/core", @@ -668,9 +668,9 @@ "@npmcli/arborist": "9.4.0", "@octokit/rest": "22.0.0", "@openauthjs/openauth": "0.0.0-20250322224806", - "@opentui/core": "0.2.3", - "@opentui/keymap": "0.2.3", - "@opentui/solid": "0.2.3", + "@opentui/core": "0.0.0-20260506-6bb5353a", + "@opentui/keymap": "0.0.0-20260506-6bb5353a", + "@opentui/solid": "0.0.0-20260506-6bb5353a", "@pierre/diffs": "1.1.0-beta.18", "@playwright/test": "1.59.1", "@sentry/solid": "10.36.0", @@ -1595,23 +1595,23 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], - "@opentui/core": ["@opentui/core@0.2.3", "", { "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.3", "@opentui/core-darwin-x64": "0.2.3", "@opentui/core-linux-arm64": "0.2.3", "@opentui/core-linux-x64": "0.2.3", "@opentui/core-win32-arm64": "0.2.3", "@opentui/core-win32-x64": "0.2.3" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-46YK/zF8NdpbaGzvo8zo+w8d6VFZTJpvVU+607SBgWE6yQDyDiyk0fk3TaJ6KwP9Fq0J1sALv0o2Q+AvXuXVcw=="], + "@opentui/core": ["@opentui/core@0.0.0-20260506-6bb5353a", "", { "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.0.0-20260506-6bb5353a", "@opentui/core-darwin-x64": "0.0.0-20260506-6bb5353a", "@opentui/core-linux-arm64": "0.0.0-20260506-6bb5353a", "@opentui/core-linux-x64": "0.0.0-20260506-6bb5353a", "@opentui/core-win32-arm64": "0.0.0-20260506-6bb5353a", "@opentui/core-win32-x64": "0.0.0-20260506-6bb5353a" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-4M1k841QkLmeNvBNGGd53YosXgMWULpuoxGedXPSggNGDaNOoUQ2waPlZxlBHGpzGUtbrcEzjSze9DzZNZb6DA=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-51E6eWJ/XMbq9grSwdy93kR29cC9sm4vt3mF/aQBQA0Usu7TwrRQNs7Pspttm6fdjVF9gRlBJ0ICdLe0gmjLQg=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.0.0-20260506-6bb5353a", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JhxBaEUE/5hpC8XRqQdPR95f/Y2mZhFxDZnQwU4FsH00++HQwQX/m6+DhJtbwEOi9M/o1T4qAUd0I1y2JRbxjg=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-VZZlY388H9TxVlSvR6u/Pw5ZHW7nFVTAgGHxWGDKmaxXqqQnxGaXHm/hY+PpTxhEEx36QkStqv3nC5E1nVQMIA=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.0.0-20260506-6bb5353a", "", { "os": "darwin", "cpu": "x64" }, "sha512-Xc1z9V7NKKrcwykwY6pp3t95YWuZ1T8AD+5CYmUBTMHiEgAxpOWSsO7G/wjvshD6Fb9BKbDVpjwAmm2Ru82hCw=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-vJgZaP2nkIx7mFTSIZA1ddfrtDQ2FDmr1BccRcf6WjyjTGwoAQyw5J6EO4BcTrcIoWeXTKJjtNd1pv8qs7eROQ=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.0.0-20260506-6bb5353a", "", { "os": "linux", "cpu": "arm64" }, "sha512-W5smYekMyg39LDLac2AJswBueeLcVtf/Ke2UttsGYlV50GXoWnrrNRB41sM6n0MCPsngUKiGFBTpZuKspF3BsQ=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-7yaOvaaOPfgsVDIZjPiudd7BdVZ+6qWI1qYHvKI4HyJaOh77a2Zrqi+rYPWdhd8Cw3mS2/l5UslBu1slZEw0yA=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.0.0-20260506-6bb5353a", "", { "os": "linux", "cpu": "x64" }, "sha512-GHsZAN/Z5O/p1PoYh0kp9haW09jTHn6trYqwP/WvifOJWk3y+0LBY6Gh7/gTVfXtO7JW/PhWZqVwVq1zae/19A=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-KWWQrqZSmaMRNfGucmPHH3pAy5ddJahopbGXGGKrhFZhFGnPNVg5KWL2noNtbNpcakwjOchgf8BcULuMGmD8Dw=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.0.0-20260506-6bb5353a", "", { "os": "win32", "cpu": "arm64" }, "sha512-crRbkPzGEpAUbiBLyoX8rzb4uZle4EJohrhNj6lYQf/a2Nfbanr+/HmjPyZFOeAX0CTJ7i2n9vndPTtpdHUeJw=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.3", "", { "os": "win32", "cpu": "x64" }, "sha512-eOqo1OI4ZT/rqZ3u2e96SRm+dolUAodBxa+LyPIx3wM7AItMBM4WzXUgdnaWMQWTHGLoYZCqIXitqVtDoqUMYA=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.0.0-20260506-6bb5353a", "", { "os": "win32", "cpu": "x64" }, "sha512-fqTu+AMOjJzSSDh0VPhtmzrjwFFF2zTuYLr0k/13TKise+zNlbHabDCeJ+KOKo44f9MeTcf3YQKqlvVyjSLWEA=="], - "@opentui/keymap": ["@opentui/keymap@0.2.3", "", { "dependencies": { "@opentui/core": "0.2.3" }, "peerDependencies": { "@opentui/react": "0.2.3", "@opentui/solid": "0.2.3", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-09lF09VBdJCz/OuPs2VNfhh+U7KoGH64R0K7E1O+VpNGWFdl37QbgelJTEEvvBT8IC4OXm0owRTry59WBxIJIg=="], + "@opentui/keymap": ["@opentui/keymap@0.0.0-20260506-6bb5353a", "", { "dependencies": { "@opentui/core": "0.0.0-20260506-6bb5353a" }, "peerDependencies": { "@opentui/react": "0.0.0-20260506-6bb5353a", "@opentui/solid": "0.0.0-20260506-6bb5353a", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-eUwvt8V2Jt9vRV3bCo8rc9ohCJyt7g7k3QROuq4xxabrNAt5oa+/n8yfE/dNQzX14wgtrlXqF3E0UEtO9iaYVw=="], - "@opentui/solid": ["@opentui/solid@0.2.3", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.3", "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-Qah/I26uPH3uJYMziPj8EI3yuUe1lDLoj3QRe2OBptIeLbUsCF9SDiN7DZtfMvdUCozezPYEiQUA4ppzwoUrEg=="], + "@opentui/solid": ["@opentui/solid@0.0.0-20260506-6bb5353a", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.0.0-20260506-6bb5353a", "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-TmJ0ue9R4imVykpzdDxa8KnVGWG84jRhDQ6SYVuHGhrp+ebHhYsBraEHesVYT55em7SA1gAFIJl5x+HJjbuqSg=="], "@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 49d87ef3c385..ef3e25140487 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.3", - "@opentui/keymap": "0.2.3", - "@opentui/solid": "0.2.3", + "@opentui/core": "0.0.0-20260506-6bb5353a", + "@opentui/keymap": "0.0.0-20260506-6bb5353a", + "@opentui/solid": "0.0.0-20260506-6bb5353a", "ulid": "3.0.1", "@kobalte/core": "0.13.11", "@types/luxon": "3.7.1", diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index d1cbf7c0a019..a8cc7946a9be 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -539,15 +539,6 @@ function App(props: { onSnapshot?: () => Promise }) { }, category: "Provider", }, - { - name: "prompt.editor.shortcut", - title: "Open editor shortcut", - category: "Session", - hidden: true, - run: () => { - command.run("prompt.editor") - }, - }, ...(sync.data.console_state.switchableOrgCount > 1 ? [ { @@ -668,7 +659,7 @@ function App(props: { onSnapshot?: () => Promise }) { title: "Suspend terminal", category: "System", hidden: true, - enabled: sections.app.some((binding) => binding.cmd === "terminal.suspend"), + enabled: process.platform !== "win32", run: () => { process.once("SIGCONT", () => { renderer.resume() @@ -757,7 +748,7 @@ function App(props: { onSnapshot?: () => Promise }) { useBindings(() => ({ enabled: command.matcher, - bindings: sections.app, + bindings: sections.global, })) 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 694fd25f5c2a..faa26dc3a62f 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx @@ -6,7 +6,6 @@ import { DialogSelect, type DialogSelectRef, type DialogSelectOption } from "@tu import { useTheme } from "../context/theme" import { TextAttributes } from "@opentui/core" import { useSDK } from "@tui/context/sdk" -import { useTuiConfig } from "../context/tui-config" function Status(props: { enabled: boolean; loading: boolean }) { const { theme } = useTheme() @@ -23,9 +22,6 @@ export function DialogMcp() { const local = useLocal() const sync = useSync() const sdk = useSDK() - const { - keymap: { sections }, - } = useTuiConfig() const [, setRef] = createSignal>() const [loading, setLoading] = createSignal(null) @@ -50,7 +46,7 @@ export function DialogMcp() { const actions = createMemo(() => [ { - command: "dialog.mcp.toggle", + command: "dialog.action.toggle", title: "toggle", onTrigger: async (option: DialogSelectOption) => { // Prevent toggling while an operation is already in progress @@ -81,7 +77,6 @@ export function DialogMcp() { title="MCPs" options={options()} actions={actions()} - bindings={sections.dialog_mcp} onSelect={(_option) => { // Don't close on select, only on escape }} 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 fa00ed549b21..068c6a1e03ff 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx @@ -14,9 +14,7 @@ export function DialogModel(props: { providerID?: string }) { const local = useLocal() const sync = useSync() const dialog = useDialog() - const { - keymap: { sections }, - } = useTuiConfig() + const tuiConfig = useTuiConfig() const [query, setQuery] = createSignal("") const connected = useConnected() @@ -154,14 +152,14 @@ export function DialogModel(props: { providerID?: string }) { options={options()} actions={[ { - command: "dialog.model.provider.list", + command: "model.dialog.provider", title: connected() ? "Connect provider" : "View all providers", onTrigger() { dialog.replace(() => ) }, }, { - command: "dialog.model.favorite.toggle", + command: "model.dialog.favorite", title: "Favorite", disabled: !connected(), onTrigger: (option) => { @@ -169,7 +167,7 @@ export function DialogModel(props: { providerID?: string }) { }, }, ]} - bindings={sections.dialog_model} + 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 92c9411b1c57..245e5cc638ca 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 @@ -16,7 +16,6 @@ import { Spinner } from "./spinner" import { errorMessage } from "@/util/error" import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed" import { WorkspaceLabel } from "./workspace-label" -import { useTuiConfig } from "../context/tui-config" import { useCommandShortcut } from "../keymap" export function DialogSessionList() { @@ -27,13 +26,9 @@ export function DialogSessionList() { const { theme } = useTheme() const sdk = useSDK() const toast = useToast() - const tuiConfig = useTuiConfig() - const { - keymap: { sections }, - } = tuiConfig const [toDelete, setToDelete] = createSignal() const [search, setSearch] = createDebouncedSignal("", 150) - const deleteHint = useCommandShortcut("dialog.session.delete") + const deleteHint = useCommandShortcut("dialog.action.delete") const [searchResults, { refetch }] = createResource( () => ({ query: search(), filter: sync.session.query() }), @@ -192,7 +187,7 @@ export function DialogSessionList() { }} actions={[ { - command: "dialog.session.delete", + command: "dialog.action.delete", title: "delete", onTrigger: async (option) => { if (toDelete() === option.value) { @@ -240,14 +235,13 @@ export function DialogSessionList() { }, }, { - command: "dialog.session.rename", + command: "dialog.action.rename", title: "rename", onTrigger: async (option) => { dialog.replace(() => ) }, }, ]} - bindings={sections.dialog_session_list} /> ) } 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 b70ec33d988b..62843c2527ea 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-stash.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-stash.tsx @@ -4,7 +4,6 @@ import { createMemo, createSignal } from "solid-js" import { Locale } from "@/util/locale" import { useTheme } from "../context/theme" import { usePromptStash, type StashEntry } from "./prompt/stash" -import { useTuiConfig } from "../context/tui-config" import { useCommandShortcut } from "../keymap" function getRelativeTime(timestamp: number): string { @@ -31,13 +30,9 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) { const dialog = useDialog() const stash = usePromptStash() const { theme } = useTheme() - const tuiConfig = useTuiConfig() - const { - keymap: { sections }, - } = tuiConfig const [toDelete, setToDelete] = createSignal() - const deleteHint = useCommandShortcut("dialog.stash.delete") + const deleteHint = useCommandShortcut("dialog.action.delete") const options = createMemo(() => { const entries = stash.list() @@ -75,7 +70,7 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) { }} actions={[ { - command: "dialog.stash.delete", + command: "dialog.action.delete", title: "delete", onTrigger: (option) => { if (toDelete() === option.value) { @@ -87,7 +82,6 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) { }, }, ]} - bindings={sections.dialog_stash} /> ) } 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 579487930666..7a2548704db9 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -565,7 +565,7 @@ export function Autocomplete(props: { }, }, ], - bindings: sections.prompt_autocomplete, + bindings: sections.autocomplete, })) 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 12be7a586b0c..68e96142c461 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -145,9 +145,7 @@ export function Prompt(props: PromptProps) { const project = useProject() const sync = useSync() const tuiConfig = useTuiConfig() - const { - keymap: { sections }, - } = tuiConfig + const keymapConfig = tuiConfig.keymap const dialog = useDialog() const toast = useToast() const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" }) @@ -630,7 +628,16 @@ export function Prompt(props: PromptProps) { useBindings(() => ({ enabled: command.matcher, - bindings: sections.prompt, + bindings: keymapConfig.pick("prompt", [ + "prompt.submit", + "prompt.editor", + "prompt.editor_context.clear", + "prompt.stash", + "prompt.stash.pop", + "prompt.stash.list", + "session.interrupt", + "workspace.set", + ]), })) const ref: PromptRef = { @@ -856,7 +863,7 @@ export function Prompt(props: PromptProps) { return { target: inputTarget, enabled: inputTarget() !== undefined && !props.disabled, - bindings: sections.prompt_paste, + bindings: keymapConfig.pick("prompt", ["prompt.paste"]), } }) @@ -864,7 +871,7 @@ export function Prompt(props: PromptProps) { return { target: inputTarget, enabled: inputTarget() !== undefined && !props.disabled && store.prompt.input !== "", - bindings: sections.prompt_clear, + bindings: keymapConfig.pick("prompt", ["prompt.clear"]), } }) @@ -938,7 +945,7 @@ export function Prompt(props: PromptProps) { }, }, ], - bindings: sections.prompt_history_previous, + bindings: keymapConfig.pick("prompt", ["prompt.history.previous"]), } }) @@ -974,7 +981,7 @@ export function Prompt(props: PromptProps) { }, }, ], - bindings: sections.prompt_history_next, + bindings: keymapConfig.pick("prompt", ["prompt.history.next"]), } }) 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 index 3be8382a9bac..cd6f1eff536b 100644 --- a/packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts +++ b/packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts @@ -2,7 +2,13 @@ import type { KeyEvent, Renderable } from "@opentui/core" import type { Binding } from "@opentui/keymap" import { resolveBindingSections, type BindingSectionsConfig, type BindingValue } from "@opentui/keymap/extras" import { ConfigKeybinds } from "@/config/keybinds" -import { KeymapLeaderTimeoutDefault, KeymapSectionNames, type KeymapInfo, type KeymapSection } from "./tui-schema" +import { + KeymapLeaderTimeoutDefault, + KeymapSectionNames, + keymapBindingDefaults, + type KeymapInfo, + type KeymapSection, +} from "./tui-schema" type LegacyKeybinds = ConfigKeybinds.Keybinds type SectionsConfig = Record>> @@ -56,28 +62,43 @@ function bindingWith(key: string | undefined, input: Omit { + if (!key || key === "none") return [] + return key + .split(",") + .map((part) => part.trim()) + .filter((part) => part && part !== "none") + }), + ), + ) + return result.length ? result.join(",") : "none" +} + export function create(keybinds: LegacyKeybinds): KeymapInfo { const config: SectionsConfig = {} - add(config, "app", "command.palette.show", keybinds.command_list) - add(config, "app", "session.list", keybinds.session_list) - add(config, "app", "session.new", keybinds.session_new) - add(config, "app", "model.list", keybinds.model_list) - add(config, "app", "model.cycle_recent", keybinds.model_cycle_recent) - add(config, "app", "model.cycle_recent_reverse", keybinds.model_cycle_recent_reverse) - add(config, "app", "model.cycle_favorite", keybinds.model_cycle_favorite) - add(config, "app", "model.cycle_favorite_reverse", keybinds.model_cycle_favorite_reverse) - add(config, "app", "agent.list", keybinds.agent_list) - add(config, "app", "agent.cycle", keybinds.agent_cycle) - add(config, "app", "agent.cycle.reverse", keybinds.agent_cycle_reverse) - add(config, "app", "variant.cycle", keybinds.variant_cycle) - add(config, "app", "variant.list", keybinds.variant_list) - add(config, "app", "prompt.editor.shortcut", keybinds.editor_open) - add(config, "app", "opencode.status", keybinds.status_view) - add(config, "app", "theme.switch", keybinds.theme_list) - add(config, "app", "app.exit", keybinds.app_exit) - add(config, "app", "terminal.suspend", keybinds.terminal_suspend) - add(config, "app", "terminal.title.toggle", keybinds.terminal_title_toggle) + 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) @@ -111,16 +132,16 @@ export function create(keybinds: LegacyKeybinds): KeymapInfo { add(config, "session", "session.child.previous", keybinds.session_child_cycle_reverse) add(config, "prompt", "session.interrupt", keybinds.session_interrupt) - add(config, "prompt_clear", "prompt.clear", keybinds.input_clear) - add(config, "prompt_paste", "prompt.paste", bindingWith(keybinds.input_paste, { preventDefault: false })) - add(config, "prompt_history_previous", "prompt.history.previous", keybinds.history_previous) - add(config, "prompt_history_next", "prompt.history.next", keybinds.history_next) + 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, "prompt_autocomplete", "prompt.autocomplete.prev", keybinds["prompt.autocomplete.prev"]) - add(config, "prompt_autocomplete", "prompt.autocomplete.next", keybinds["prompt.autocomplete.next"]) - add(config, "prompt_autocomplete", "prompt.autocomplete.hide", keybinds["prompt.autocomplete.hide"]) - add(config, "prompt_autocomplete", "prompt.autocomplete.select", keybinds["prompt.autocomplete.select"]) - add(config, "prompt_autocomplete", "prompt.autocomplete.complete", keybinds["prompt.autocomplete.complete"]) + 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]) @@ -133,31 +154,29 @@ export function create(keybinds: LegacyKeybinds): KeymapInfo { 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_stash", "dialog.stash.delete", keybinds.stash_delete) - add(config, "dialog_session_list", "dialog.session.delete", keybinds.session_delete) - add(config, "dialog_session_list", "dialog.session.rename", keybinds.session_rename) - add(config, "dialog_model", "dialog.model.provider.list", keybinds.model_provider_list) - add(config, "dialog_model", "dialog.model.favorite.toggle", keybinds.model_favorite_toggle) - add(config, "dialog_mcp", "dialog.mcp.toggle", keybinds["dialog.mcp.toggle"]) - - add(config, "permission_reject", "permission.reject.cancel", keybinds.app_exit) - add(config, "permission_prompt_escape", "permission.prompt.escape", keybinds.app_exit) - add(config, "permission_prompt_fullscreen", "permission.prompt.fullscreen", keybinds["permission.prompt.fullscreen"]) + 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_edit", "question.edit.clear", keybinds.input_clear) + add(config, "question", "question.edit.clear", keybinds.input_clear) add(config, "plugins", "plugins.list", keybinds.plugin_manager) - add(config, "dialog_plugins", "plugins.toggle", keybinds["plugins.toggle"]) - add(config, "dialog_plugins", "dialog.plugins.install", keybinds["dialog.plugins.install"]) + add(config, "plugins", "plugin.dialog.install", keybinds["dialog.plugins.install"]) add(config, "home_tips", "tips.toggle", keybinds.tips_toggle) return { leader: !keybinds.leader || keybinds.leader === "none" ? "ctrl+x" : keybinds.leader, leader_timeout: KeymapLeaderTimeoutDefault, - sections: resolveBindingSections(config, { + ...resolveBindingSections(config, { sections: KeymapSectionNames, - }).sections, + bindingDefaults: keymapBindingDefaults, + }), } } 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 e10a28411a12..8b6e7b9fbe31 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts @@ -1,7 +1,7 @@ import z from "zod" import type { KeyEvent, Renderable } from "@opentui/core" import type { Binding } from "@opentui/keymap" -import type { BindingSectionsConfig, BindingValue } from "@opentui/keymap/extras" +import type { ResolvedBindingSections } from "@opentui/keymap/extras" import { ConfigPlugin } from "@/config/plugin" import { ConfigKeybinds } from "@/config/keybinds" @@ -15,27 +15,17 @@ const KeybindOverride = z .strict() export const KeymapSectionNames = [ - "app", + "global", "session", "prompt", - "prompt_clear", - "prompt_paste", - "prompt_history_previous", - "prompt_history_next", - "prompt_autocomplete", + "autocomplete", "input", "dialog_select", - "dialog_stash", - "dialog_session_list", - "dialog_model", - "dialog_mcp", - "permission_reject", - "permission_prompt_escape", - "permission_prompt_fullscreen", + "dialog_actions", + "model", + "permission", "question", - "question_edit", "plugins", - "dialog_plugins", "home_tips", ] as const @@ -45,7 +35,27 @@ export const KeymapLeaderTimeoutDefault = 2000 export type KeymapInfo = { leader: string leader_timeout: number - sections: KeymapSections +} & ResolvedBindingSections + +export const KeymapSectionGroups = { + global: "Global", + 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] } } const KeyStroke = z diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts index 78d31b0aff4a..1c8067e10b70 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts @@ -23,8 +23,13 @@ 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 { KeymapLeaderTimeoutDefault, KeymapSectionNames, type KeymapInfo, type KeymapSection } from "./tui-schema" -import type { Binding } from "@opentui/keymap" +import { + KeymapLeaderTimeoutDefault, + KeymapSectionNames, + keymapBindingDefaults, + type KeymapInfo, + type KeymapSection, +} from "./tui-schema" const log = Log.create({ service: "tui.config" }) @@ -36,31 +41,6 @@ type Acc = { plugin_origins: ConfigPlugin.Origin[] } -const KeymapSectionGroups = { - app: "Global", - session: "Session", - prompt: "Prompt", - prompt_clear: "Prompt", - prompt_paste: "Prompt", - prompt_history_previous: "Prompt", - prompt_history_next: "Prompt", - prompt_autocomplete: "Autocomplete", - input: "Text Editing", - dialog_select: "Dialog", - dialog_stash: "Stash", - dialog_session_list: "Session", - dialog_model: "Agent", - dialog_mcp: "Agent", - permission_reject: "Permission", - permission_prompt_escape: "Permission", - permission_prompt_fullscreen: "Permission", - question: "Question", - question_edit: "Question", - plugins: "Plugins", - dialog_plugins: "Plugins", - home_tips: "Home", -} satisfies Record - export type Resolved = Omit & { keybinds: ConfigKeybinds.Keybinds keymap: KeymapInfo @@ -97,21 +77,6 @@ function normalize(raw: Record) { } } -function withDefaultGroups(sections: KeymapInfo["sections"]): KeymapInfo["sections"] { - return Object.fromEntries( - KeymapSectionNames.map((section) => [ - section, - sections[section].map((binding) => { - if ((binding as Binding & { group?: unknown }).group !== undefined) return binding - return { - ...binding, - group: KeymapSectionGroups[section], - } - }), - ]), - ) as KeymapInfo["sections"] -} - const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: string }) { const afs = yield* AppFileSystem.Service @@ -235,14 +200,15 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: ? { leader: !configuredKeymap.leader || configuredKeymap.leader === "none" ? "ctrl+x" : configuredKeymap.leader, leader_timeout: configuredKeymap.leader_timeout ?? KeymapLeaderTimeoutDefault, - sections: resolveBindingSections< + ...resolveBindingSections< Renderable, KeyEvent, BindingSectionsConfig, KeymapSection >(configuredKeymap.sections ?? {}, { sections: KeymapSectionNames, - }).sections, + bindingDefaults: keymapBindingDefaults, + }), } : LegacyKeymapTransform.create(parsedKeybinds) const result: Resolved = { @@ -252,10 +218,7 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: // `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: { - ...keymap, - sections: withDefaultGroups(keymap.sections), - }, + keymap, } 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 c0692ce4031d..655118500232 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 @@ -148,7 +148,6 @@ function showInstall(api: TuiPluginApi) { function View(props: { api: TuiPluginApi }) { const size = useTerminalDimensions() - const sections = props.api.tuiConfig.keymap.sections const [list, setList] = createSignal(props.api.plugins.list()) const [cur, setCur] = createSignal() const [lock, setLock] = createSignal(false) @@ -207,7 +206,7 @@ function View(props: { api: TuiPluginApi }) { actions={[ { title: "toggle", - command: "plugins.toggle", + command: "dialog.action.toggle", disabled: lock(), onTrigger: (item) => { setCur(item.value) @@ -216,14 +215,14 @@ function View(props: { api: TuiPluginApi }) { }, { title: "install", - command: "dialog.plugins.install", + command: "plugin.dialog.install", disabled: lock(), onTrigger: () => { showInstall(props.api) }, }, ]} - bindings={sections.dialog_plugins} + 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.sections.plugins, + bindings: api.tuiConfig.keymap.omit("plugins", ["plugin.dialog.install"]), }) } 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 aab21e3f3f4f..5e7e80b66aea 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -462,9 +462,7 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: ( let input: TextareaRenderable const { theme } = useTheme() const tuiConfig = useTuiConfig() - const { - keymap: { sections }, - } = tuiConfig + const keymapConfig = tuiConfig.keymap const dimensions = useTerminalDimensions() const narrow = createMemo(() => dimensions().width < 80) const dialog = useDialog() @@ -480,7 +478,7 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: ( ], bindings: [ { key: "escape", cmd: () => props.onCancel() }, - ...sections.permission_reject, + ...keymapConfig.pick("permission", ["permission.reject.cancel"]), { key: "return", cmd: () => props.onConfirm(input.plainText) }, ], })) @@ -547,9 +545,7 @@ function Prompt>(props: { }) { const { theme } = useTheme() const tuiConfig = useTuiConfig() - const { - keymap: { sections }, - } = tuiConfig + const keymapConfig = tuiConfig.keymap const dimensions = useTerminalDimensions() const keys = Object.keys(props.options) as (keyof T)[] const [store, setStore] = createStore({ @@ -613,8 +609,8 @@ function Prompt>(props: { }, { key: "return", cmd: () => props.onSelect(store.selected) }, ...(props.escapeKey ? [{ key: "escape", cmd: () => props.onSelect(props.escapeKey!) }] : []), - ...(props.escapeKey ? sections.permission_prompt_escape : []), - ...(props.fullscreen ? sections.permission_prompt_fullscreen : []), + ...(props.escapeKey ? keymapConfig.pick("permission", ["permission.prompt.escape"]) : []), + ...(props.fullscreen ? keymapConfig.pick("permission", ["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 8c6f5243031f..617ede6395b4 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx @@ -16,6 +16,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { const { keymap: { sections }, } = tuiConfig + const keymapConfig = tuiConfig.keymap const questions = createMemo(() => props.request.questions) const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true) @@ -145,7 +146,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { setStore("editing", false) }, }, - ...sections.question_edit, + ...keymapConfig.pick("question", ["question.edit.clear"]), { key: "return", cmd: () => { 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 c0ead3585ae8..cbf5d2dbfcde 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -293,6 +293,10 @@ export function DialogSelect(props: DialogSelectProps) { ], bindings: [ ...sections.dialog_select, + ...tuiConfig.keymap.pick( + "dialog_actions", + enabledActions.map((item) => 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/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index 5053a7e1f794..b7c289aa95ab 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -389,6 +389,97 @@ test("merges keybind overrides across precedence layers", async () => { expect(config.keybinds?.theme_list).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" }, + 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.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 () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "tui.json"), + JSON.stringify({ + keybinds: { + command_list: "alt+p", + editor_open: "ctrl+e", + "prompt.autocomplete.next": "ctrl+j", + "dialog.mcp.toggle": "ctrl+t", + "dialog.plugins.install": "shift+i", + plugin_manager: "ctrl+shift+p", + }, + }), + ) + }, + }) + + const config = await getTuiConfig(tmp.path) + expect(Object.keys(config.keymap.sections)).toEqual([ + "global", + "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.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,space") + 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", + ]) +}) + wintest("defaults Ctrl+Z to input undo on Windows", async () => { await using tmp = await tmpdir() const config = await getTuiConfig(tmp.path) diff --git a/packages/opencode/test/fixture/tui-plugin.ts b/packages/opencode/test/fixture/tui-plugin.ts index 0f7cfd43a9c0..86a9a5ed23b0 100644 --- a/packages/opencode/test/fixture/tui-plugin.ts +++ b/packages/opencode/test/fixture/tui-plugin.ts @@ -1,6 +1,8 @@ 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" type Count = { event_add: number @@ -109,13 +111,11 @@ type Opts = { } function tuiConfig(input?: Partial): HostPluginApi["tuiConfig"] { + const keybinds = ConfigKeybinds.Keybinds.parse(input?.keybinds ?? {}) return { ...input, - keymap: input?.keymap ?? { - leader: "ctrl+x", - leader_timeout: 2000, - sections: {}, - }, + keybinds, + keymap: input?.keymap ?? LegacyKeymapTransform.create(keybinds), } } diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 51cf78d9cf49..c1d4caca25de 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -22,9 +22,9 @@ "zod": "catalog:" }, "peerDependencies": { - "@opentui/core": ">=0.2.3", - "@opentui/keymap": ">=0.2.3", - "@opentui/solid": ">=0.2.3" + "@opentui/core": ">=0.0.0-20260506-6bb5353a", + "@opentui/keymap": ">=0.0.0-20260506-6bb5353a", + "@opentui/solid": ">=0.0.0-20260506-6bb5353a" }, "peerDependenciesMeta": { "@opentui/core": { diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index c840b6f5da2d..86175c389168 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -293,6 +293,9 @@ type TuiConfigView = Pick>> + get: (section: string, cmd: string) => ReadonlyArray> | undefined + pick: (section: string, commands: readonly string[]) => Binding[] + omit: (section: string, commands: readonly string[]) => Binding[] } } diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index fd4eee60ff14..405ef30e237e 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -534,7 +534,7 @@ Customize TUI keyboard shortcuts in `tui.json` with `keymap`. "$schema": "https://opencode.ai/tui.json", "keymap": { "sections": { - "app": { + "global": { "command.palette.show": "ctrl+p" } } diff --git a/packages/web/src/content/docs/keybinds.mdx b/packages/web/src/content/docs/keybinds.mdx index 4ecec785c42f..18330afa13d0 100644 --- a/packages/web/src/content/docs/keybinds.mdx +++ b/packages/web/src/content/docs/keybinds.mdx @@ -30,7 +30,7 @@ You do not need to use a leader key, but we recommend doing so. "leader": "ctrl+x", "leader_timeout": 2000, "sections": { - "app": { + "global": { "command.palette.show": "ctrl+p", "session.new": "n", "session.list": "l" @@ -53,13 +53,13 @@ You do not need to use a leader key, but we recommend doing so. ## Keymap structure -`keymap.sections` is grouped by TUI area. Each section contains command names and the key sequence that triggers them. +`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 section names to command bindings. | +| `sections` | A map of TUI areas to command bindings. | --- @@ -89,7 +89,7 @@ For advanced cases, use an object with `key`, `event`, `preventDefault`, or `fal "$schema": "https://opencode.ai/tui.json", "keymap": { "sections": { - "prompt_paste": { + "prompt": { "prompt.paste": { "key": "ctrl+v", "preventDefault": false @@ -113,7 +113,7 @@ This example lists the built-in sections, command names, and default fallback bi "leader": "ctrl+x", "leader_timeout": 2000, "sections": { - "app": { + "global": { "command.palette.show": "ctrl+p", "session.list": "l", "session.new": "n", @@ -129,7 +129,6 @@ This example lists the built-in sections, command names, and default fallback bi "variant.cycle": "ctrl+t", "variant.list": "none", "provider.connect": "none", - "prompt.editor.shortcut": "e", "console.org.switch": "none", "opencode.status": "s", "theme.switch": "t", @@ -186,29 +185,21 @@ This example lists the built-in sections, command names, and default fallback bi }, "prompt": { "prompt.submit": "none", - "prompt.editor": "none", + "prompt.editor": "e", "prompt.editor_context.clear": "none", "prompt.stash": "none", "prompt.stash.pop": "none", "prompt.stash.list": "none", - "session.interrupt": "escape" - }, - "prompt_clear": { - "prompt.clear": "ctrl+c" - }, - "prompt_paste": { + "session.interrupt": "escape", + "prompt.clear": "ctrl+c", "prompt.paste": { "key": "ctrl+v", "preventDefault": false - } - }, - "prompt_history_previous": { - "prompt.history.previous": "up" - }, - "prompt_history_next": { + }, + "prompt.history.previous": "up", "prompt.history.next": "down" }, - "prompt_autocomplete": { + "autocomplete": { "prompt.autocomplete.prev": "up,ctrl+p", "prompt.autocomplete.next": "down,ctrl+n", "prompt.autocomplete.hide": "escape", @@ -262,42 +253,28 @@ This example lists the built-in sections, command names, and default fallback bi "dialog.select.end": "end", "dialog.select.submit": "return" }, - "dialog_stash": { - "dialog.stash.delete": "ctrl+d" + "dialog_actions": { + "dialog.action.toggle": "space", + "dialog.action.delete": "ctrl+d", + "dialog.action.rename": "ctrl+r" }, - "dialog_session_list": { - "dialog.session.delete": "ctrl+d", - "dialog.session.rename": "ctrl+r" + "model": { + "model.dialog.provider": "ctrl+a", + "model.dialog.favorite": "ctrl+f" }, - "dialog_model": { - "dialog.model.provider.list": "ctrl+a", - "dialog.model.favorite.toggle": "ctrl+f" - }, - "dialog_mcp": { - "dialog.mcp.toggle": "space" - }, - "permission_reject": { - "permission.reject.cancel": "ctrl+c,ctrl+d,q" - }, - "permission_prompt_escape": { - "permission.prompt.escape": "ctrl+c,ctrl+d,q" - }, - "permission_prompt_fullscreen": { + "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": { + "question.reject": "ctrl+c,ctrl+d,q", "question.edit.clear": "ctrl+c" }, "plugins": { "plugins.list": "none", - "plugins.install": "none" - }, - "dialog_plugins": { - "plugins.toggle": "space", - "dialog.plugins.install": "shift+i" + "plugins.install": "none", + "plugin.dialog.install": "shift+i" }, "home_tips": { "tips.toggle": "h" diff --git a/packages/web/src/content/docs/tui.mdx b/packages/web/src/content/docs/tui.mdx index 828792fb6b8d..fd10f1c9ff59 100644 --- a/packages/web/src/content/docs/tui.mdx +++ b/packages/web/src/content/docs/tui.mdx @@ -357,7 +357,7 @@ You can customize TUI behavior through `tui.json` (or `tui.jsonc`). "leader": "ctrl+x", "leader_timeout": 2000, "sections": { - "app": { + "global": { "command.palette.show": "ctrl+p" } } From 21fd4b1e116243618f05e78ebeccca18e6b6e44e Mon Sep 17 00:00:00 2001 From: Sebastian Herrlinger Date: Thu, 7 May 2026 02:16:43 +0200 Subject: [PATCH 4/7] platform --- .../opencode/src/cli/cmd/tui/config/tui.ts | 25 +++++++- packages/opencode/test/config/tui.test.ts | 60 +++++++++++++++++++ packages/web/src/content/docs/keybinds.mdx | 6 +- 3 files changed, 86 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts index 1c8067e10b70..fda50a4c66cf 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts @@ -2,7 +2,7 @@ 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 { resolveBindingSections, type BindingSectionsConfig, type BindingValue } from "@opentui/keymap/extras" import { mergeDeep, unique } from "remeda" import { Context, Effect, Fiber, Layer } from "effect" import { ConfigParse } from "@/config/parse" @@ -77,6 +77,27 @@ function normalize(raw: Record) { } } +function withPlatformKeymapSections(sections: BindingSectionsConfig | undefined) { + const result = Object.fromEntries( + Object.entries(sections ?? {}).map(([section, bindings]) => [section, { ...bindings }]), + ) as Record>> + + if (process.platform !== "win32") return result + + result.global = { + ...(result.global ?? {}), + "terminal.suspend": "none", + } + result.input = { + ...(result.input ?? {}), + ...(result.input?.["input.undo"] === undefined && { + "input.undo": unique(["ctrl+z", ...ConfigKeybinds.Keybinds.shape.input_undo.parse(undefined).split(",")]).join(","), + }), + } + + return result +} + const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: string }) { const afs = yield* AppFileSystem.Service @@ -205,7 +226,7 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: KeyEvent, BindingSectionsConfig, KeymapSection - >(configuredKeymap.sections ?? {}, { + >(withPlatformKeymapSections(configuredKeymap.sections), { sections: KeymapSectionNames, bindingDefaults: keymapBindingDefaults, }), diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index b7c289aa95ab..00a605c32b59 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -30,6 +30,19 @@ const getTuiConfig = async (directory: string) => ), ) +async function withPlatform(platform: typeof process.platform, fn: () => Promise) { + const original = Object.getOwnPropertyDescriptor(process, "platform") + Object.defineProperty(process, "platform", { + ...original, + value: platform, + }) + try { + return await fn() + } finally { + if (original) Object.defineProperty(process, "platform", original) + } +} + afterEach(async () => { delete process.env.OPENCODE_CONFIG delete process.env.OPENCODE_TUI_CONFIG @@ -510,6 +523,53 @@ wintest("ignores terminal suspend bindings on Windows", async () => { expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z") }) +test("applies Windows keymap adjustments to configured keymap", 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" }, + }, + }, + }), + ) + }, + }) + + 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", + ) + }) +}) + +test("keeps explicit configured keymap 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" }, + }, + }, + }), + ) + }, + }) + + const config = await getTuiConfig(tmp.path) + expect(config.keymap.sections.input.find((binding) => binding.cmd === "input.undo")?.key).toBe("ctrl+y") + }) +}) + test("OPENCODE_TUI_CONFIG provides settings when no project config exists", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/packages/web/src/content/docs/keybinds.mdx b/packages/web/src/content/docs/keybinds.mdx index 18330afa13d0..ae25908fa424 100644 --- a/packages/web/src/content/docs/keybinds.mdx +++ b/packages/web/src/content/docs/keybinds.mdx @@ -304,10 +304,10 @@ Only use `keybinds` when `keymap` is not present. If both fields are set, `keyma ``` :::note -When using legacy `keybinds` on Windows, the defaults for `input_undo` and `terminal_suspend` are different: +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` (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. +- `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. ::: --- From 3e2ccc5738474db3ccfb5770626afb3c70a1fabb Mon Sep 17 00:00:00 2001 From: Sebastian Herrlinger Date: Thu, 7 May 2026 03:44:22 +0200 Subject: [PATCH 5/7] schema --- .../cmd/tui/config/legacy-keymap-transform.ts | 34 +- .../src/cli/cmd/tui/config/tui-schema.ts | 301 +++++++++++++++--- .../opencode/src/cli/cmd/tui/config/tui.ts | 55 +--- packages/opencode/test/config/tui.test.ts | 22 +- packages/opencode/test/fixture/tui-plugin.ts | 3 +- packages/opencode/test/fixture/tui-runtime.ts | 26 +- packages/web/src/content/docs/config.mdx | 2 +- packages/web/src/content/docs/keybinds.mdx | 4 +- packages/web/src/content/docs/tui.mdx | 2 +- 9 files changed, 337 insertions(+), 112 deletions(-) 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 index cd6f1eff536b..c0c621862e76 100644 --- a/packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts +++ b/packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts @@ -1,16 +1,10 @@ import type { KeyEvent, Renderable } from "@opentui/core" import type { Binding } from "@opentui/keymap" -import { resolveBindingSections, type BindingSectionsConfig, type BindingValue } from "@opentui/keymap/extras" +import type { BindingValue } from "@opentui/keymap/extras" import { ConfigKeybinds } from "@/config/keybinds" -import { - KeymapLeaderTimeoutDefault, - KeymapSectionNames, - keymapBindingDefaults, - type KeymapInfo, - type KeymapSection, -} from "./tui-schema" - -type LegacyKeybinds = ConfigKeybinds.Keybinds +import { type KeymapConfigInput, type KeymapSection } from "./tui-schema" + +type LegacyKeybinds = Partial type SectionsConfig = Record>> const inputCommands = { @@ -53,12 +47,14 @@ const inputCommands = { } 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 ?? "none" + config[section][command] = binding } function bindingWith(key: string | undefined, input: Omit, "key" | "cmd">) { - if (!key || key === "none") return "none" + if (!key) return undefined + if (key === "none") return "none" return { ...input, key } } @@ -74,10 +70,12 @@ function combineBindings(...keys: (string | undefined)[]) { }), ), ) - return result.length ? result.join(",") : "none" + if (result.length) return result.join(",") + if (keys.some((key) => key === "none")) return "none" + return undefined } -export function create(keybinds: LegacyKeybinds): KeymapInfo { +export function create(keybinds: LegacyKeybinds): KeymapConfigInput { const config: SectionsConfig = {} add(config, "global", "command.palette.show", keybinds.command_list) @@ -171,12 +169,8 @@ export function create(keybinds: LegacyKeybinds): KeymapInfo { add(config, "home_tips", "tips.toggle", keybinds.tips_toggle) return { - leader: !keybinds.leader || keybinds.leader === "none" ? "ctrl+x" : keybinds.leader, - leader_timeout: KeymapLeaderTimeoutDefault, - ...resolveBindingSections(config, { - sections: KeymapSectionNames, - bindingDefaults: keymapBindingDefaults, - }), + ...(keybinds.leader && keybinds.leader !== "none" && { leader: keybinds.leader }), + sections: config, } } 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 8b6e7b9fbe31..1103fd7ce1b2 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts @@ -14,23 +14,255 @@ const KeybindOverride = z ) .strict() -export const KeymapSectionNames = [ - "global", - "session", - "prompt", - "autocomplete", - "input", - "dialog_select", - "dialog_actions", - "model", - "permission", - "question", - "plugins", - "home_tips", -] as const - -export type KeymapSection = (typeof KeymapSectionNames)[number] -export type KeymapSections = Record[]> +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 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), + 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(), + 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 @@ -58,39 +290,26 @@ export function keymapBindingDefaults(input: { section: string; binding: Readonl return { group: KeymapSectionGroups[input.section as KeymapSection] } } -const KeyStroke = z +export const KeymapConfig = 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(), + 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 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 KeymapSectionsConfig = z.record(z.string(), z.record(z.string(), KeymapBindingValue)) - -export const KeymapConfig = z +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: KeymapSectionsConfig.optional(), + sections: KeymapSectionsInput, }) .strict() .describe("TUI keymap configuration") -export type KeymapConfig = z.output +export type KeymapConfigInput = z.output export const TuiOptions = z.object({ scroll_speed: z.number().min(0.001).optional().describe("TUI scroll speed"), @@ -115,7 +334,7 @@ export const TuiInfo = z deprecated: true, description: "Use keymap instead. This will be removed in opencode v2.0.", }), - keymap: KeymapConfig.optional(), + keymap: KeymapConfigInput.optional(), plugin: ConfigPlugin.Spec.zod.array().optional(), plugin_enabled: z.record(z.string(), z.boolean()).optional(), }) diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts index fda50a4c66cf..eef08f667d94 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts @@ -2,13 +2,13 @@ export * as TuiConfig from "./tui" import type z from "zod" import type { KeyEvent, Renderable } from "@opentui/core" -import { resolveBindingSections, type BindingSectionsConfig, type BindingValue } from "@opentui/keymap/extras" +import { resolveBindingSections, type BindingSectionsConfig } 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 { TuiInfo } from "./tui-schema" +import { KeymapConfig, TuiInfo } from "./tui-schema" import { Flag } from "@opencode-ai/core/flag/flag" import { isRecord } from "@/util/record" import { Global } from "@opencode-ai/core/global" @@ -24,7 +24,6 @@ import { ConfigVariable } from "@/config/variable" import { Npm } from "@opencode-ai/core/npm" import { LegacyKeymapTransform } from "./legacy-keymap-transform" import { - KeymapLeaderTimeoutDefault, KeymapSectionNames, keymapBindingDefaults, type KeymapInfo, @@ -77,27 +76,6 @@ function normalize(raw: Record) { } } -function withPlatformKeymapSections(sections: BindingSectionsConfig | undefined) { - const result = Object.fromEntries( - Object.entries(sections ?? {}).map(([section, bindings]) => [section, { ...bindings }]), - ) as Record>> - - if (process.platform !== "win32") return result - - result.global = { - ...(result.global ?? {}), - "terminal.suspend": "none", - } - result.input = { - ...(result.input ?? {}), - ...(result.input?.["input.undo"] === undefined && { - "input.undo": unique(["ctrl+z", ...ConfigKeybinds.Keybinds.shape.input_undo.parse(undefined).split(",")]).join(","), - }), - } - - return result -} - const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: string }) { const afs = yield* AppFileSystem.Service @@ -216,22 +194,19 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: ]).join(",") } const parsedKeybinds = ConfigKeybinds.Keybinds.parse(keybinds) - const configuredKeymap = acc.result.keymap - const keymap = configuredKeymap - ? { - leader: !configuredKeymap.leader || configuredKeymap.leader === "none" ? "ctrl+x" : configuredKeymap.leader, - leader_timeout: configuredKeymap.leader_timeout ?? KeymapLeaderTimeoutDefault, - ...resolveBindingSections< - Renderable, - KeyEvent, - BindingSectionsConfig, - KeymapSection - >(withPlatformKeymapSections(configuredKeymap.sections), { - sections: KeymapSectionNames, - bindingDefaults: keymapBindingDefaults, - }), - } - : LegacyKeymapTransform.create(parsedKeybinds) + 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 result: Resolved = { ...acc.result, keybinds: parsedKeybinds, diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index 00a605c32b59..5acc3d84fa3b 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -426,6 +426,7 @@ test("resolves semantic keymap sections", async () => { 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.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") @@ -477,7 +478,7 @@ test("legacy keybinds transform into semantic keymap sections", async () => { expect(config.keymap.sections.global.find((binding) => binding.cmd === "command.palette.show")?.key).toBe("alt+p") 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,space") + 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") @@ -523,7 +524,19 @@ wintest("ignores terminal suspend bindings on Windows", async () => { expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z") }) -test("applies Windows keymap adjustments to configured keymap", async () => { +test("applies Windows keymap 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", + ) + }) +}) + +test("keeps explicit configured keymap terminal suspend binding on Windows", async () => { await withPlatform("win32", async () => { await using tmp = await tmpdir({ init: async (dir) => { @@ -541,10 +554,7 @@ test("applies Windows keymap adjustments to configured keymap", async () => { }) 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.keymap.sections.global.find((binding) => binding.cmd === "terminal.suspend")?.key).toBe("alt+z") }) }) diff --git a/packages/opencode/test/fixture/tui-plugin.ts b/packages/opencode/test/fixture/tui-plugin.ts index 86a9a5ed23b0..a4a5aaad6087 100644 --- a/packages/opencode/test/fixture/tui-plugin.ts +++ b/packages/opencode/test/fixture/tui-plugin.ts @@ -3,6 +3,7 @@ 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" type Count = { event_add: number @@ -115,7 +116,7 @@ function tuiConfig(input?: Partial): HostPluginApi[" return { ...input, keybinds, - keymap: input?.keymap ?? LegacyKeymapTransform.create(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 e3912ecaa3c5..d1e4c744b0ae 100644 --- a/packages/opencode/test/fixture/tui-runtime.ts +++ b/packages/opencode/test/fixture/tui-runtime.ts @@ -1,8 +1,17 @@ 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 { 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" type PluginSpec = string | [string, Record] type ResolvedInput = Omit & { @@ -10,12 +19,27 @@ type ResolvedInput = Omit & { keymap?: TuiConfig.Resolved["keymap"] } +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 createTuiResolvedConfig(input: ResolvedInput = {}): TuiConfig.Resolved { const keybinds = input.keybinds ?? ConfigKeybinds.Keybinds.parse({}) return { ...input, keybinds, - keymap: input.keymap ?? LegacyKeymapTransform.create(keybinds), + keymap: input.keymap ?? createTuiResolvedKeymap(LegacyKeymapTransform.create(input.keybinds ?? {})), } } diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index 405ef30e237e..39c9974c56c5 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -542,7 +542,7 @@ Customize TUI keyboard shortcuts in `tui.json` with `keymap`. } ``` -When `keymap` is set, include every shortcut you want to keep active. It does not merge with the deprecated `keybinds` fallback. +`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. diff --git a/packages/web/src/content/docs/keybinds.mdx b/packages/web/src/content/docs/keybinds.mdx index ae25908fa424..a137aef37f8f 100644 --- a/packages/web/src/content/docs/keybinds.mdx +++ b/packages/web/src/content/docs/keybinds.mdx @@ -7,7 +7,7 @@ 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. -When you define `keymap`, include every shortcut you want to keep active. It does not merge with the legacy `keybinds` fallback. +`keymap` is merged with built-in defaults, so you only need to configure the shortcuts you want to change. --- @@ -187,9 +187,11 @@ This example lists the built-in sections, command names, and default fallback bi "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": { diff --git a/packages/web/src/content/docs/tui.mdx b/packages/web/src/content/docs/tui.mdx index fd10f1c9ff59..99e9aa752bc9 100644 --- a/packages/web/src/content/docs/tui.mdx +++ b/packages/web/src/content/docs/tui.mdx @@ -373,7 +373,7 @@ You can customize TUI behavior through `tui.json` (or `tui.jsonc`). This is separate from `opencode.json`, which configures server/runtime behavior. -When `keymap` is set, include every shortcut you want to keep active. It does not merge with the deprecated `keybinds` fallback. +`keymap` is merged with built-in defaults, so you only need to configure the shortcuts you want to change. ### Options From 03070521e6fa6761ec3a04a8db4f3da897858c63 Mon Sep 17 00:00:00 2001 From: Sebastian Herrlinger Date: Thu, 7 May 2026 03:56:17 +0200 Subject: [PATCH 6/7] schema --- packages/opencode/script/schema.ts | 2 +- packages/opencode/src/cli/cmd/tui/config/tui-schema.ts | 4 ++++ packages/opencode/src/cli/cmd/tui/config/tui.ts | 3 ++- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/opencode/script/schema.ts b/packages/opencode/script/schema.ts index 7a7cc4a7306f..b1a587075eb7 100755 --- a/packages/opencode/script/schema.ts +++ b/packages/opencode/script/schema.ts @@ -59,5 +59,5 @@ await Bun.write(configFile, JSON.stringify(generate(Config.Info.zod), null, 2)) if (tuiFile) { console.log(tuiFile) - await Bun.write(tuiFile, JSON.stringify(generate(TuiConfig.Info), null, 2)) + await Bun.write(tuiFile, JSON.stringify(generate(TuiConfig.JsonSchemaInfo), null, 2)) } 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 1103fd7ce1b2..400eb3852846 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts @@ -340,3 +340,7 @@ export const TuiInfo = z }) .extend(TuiOptions.shape) .strict() + +export const TuiJsonSchemaInfo = TuiInfo.extend({ + keymap: KeymapConfig.optional(), +}).strict() diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts index eef08f667d94..095bc2c882ca 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts @@ -8,7 +8,7 @@ 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 } from "./tui-schema" +import { KeymapConfig, 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" @@ -33,6 +33,7 @@ import { const log = Log.create({ service: "tui.config" }) export const Info = TuiInfo +export const JsonSchemaInfo = TuiJsonSchemaInfo export type Info = z.output type Acc = { From 695640c38f259fe1d2352697677460d7b4e0d513 Mon Sep 17 00:00:00 2001 From: Sebastian Herrlinger Date: Thu, 7 May 2026 16:40:20 +0200 Subject: [PATCH 7/7] upgrade opentui --- bun.lock | 30 +++++++++++++++--------------- package.json | 6 +++--- packages/plugin/package.json | 6 +++--- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/bun.lock b/bun.lock index 8c16faaee13d..5b52b32da8d5 100644 --- a/bun.lock +++ b/bun.lock @@ -487,9 +487,9 @@ "typescript": "catalog:", }, "peerDependencies": { - "@opentui/core": ">=0.0.0-20260506-6bb5353a", - "@opentui/keymap": ">=0.0.0-20260506-6bb5353a", - "@opentui/solid": ">=0.0.0-20260506-6bb5353a", + "@opentui/core": ">=0.2.4", + "@opentui/keymap": ">=0.2.4", + "@opentui/solid": ">=0.2.4", }, "optionalPeers": [ "@opentui/core", @@ -668,9 +668,9 @@ "@npmcli/arborist": "9.4.0", "@octokit/rest": "22.0.0", "@openauthjs/openauth": "0.0.0-20250322224806", - "@opentui/core": "0.0.0-20260506-6bb5353a", - "@opentui/keymap": "0.0.0-20260506-6bb5353a", - "@opentui/solid": "0.0.0-20260506-6bb5353a", + "@opentui/core": "0.2.4", + "@opentui/keymap": "0.2.4", + "@opentui/solid": "0.2.4", "@pierre/diffs": "1.1.0-beta.18", "@playwright/test": "1.59.1", "@sentry/solid": "10.36.0", @@ -1595,23 +1595,23 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], - "@opentui/core": ["@opentui/core@0.0.0-20260506-6bb5353a", "", { "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.0.0-20260506-6bb5353a", "@opentui/core-darwin-x64": "0.0.0-20260506-6bb5353a", "@opentui/core-linux-arm64": "0.0.0-20260506-6bb5353a", "@opentui/core-linux-x64": "0.0.0-20260506-6bb5353a", "@opentui/core-win32-arm64": "0.0.0-20260506-6bb5353a", "@opentui/core-win32-x64": "0.0.0-20260506-6bb5353a" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-4M1k841QkLmeNvBNGGd53YosXgMWULpuoxGedXPSggNGDaNOoUQ2waPlZxlBHGpzGUtbrcEzjSze9DzZNZb6DA=="], + "@opentui/core": ["@opentui/core@0.2.4", "", { "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.4", "@opentui/core-darwin-x64": "0.2.4", "@opentui/core-linux-arm64": "0.2.4", "@opentui/core-linux-x64": "0.2.4", "@opentui/core-win32-arm64": "0.2.4", "@opentui/core-win32-x64": "0.2.4" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-6xRdxmSgCFsEIwUwv7Pr+XKS1gBOwYF0tS/DE4KxTNzuH39VQDot7blzm8UKl6okdurFAxkt2+1HJJStl+rICw=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.0.0-20260506-6bb5353a", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JhxBaEUE/5hpC8XRqQdPR95f/Y2mZhFxDZnQwU4FsH00++HQwQX/m6+DhJtbwEOi9M/o1T4qAUd0I1y2JRbxjg=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2GlEndoBQkA8qSxr9RQEOgprdheCBRZvbUIfui5AUUmREZfgIQP+w399cJwmhlwSoNVNtfzLQGHxUFGfPhzMrQ=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.0.0-20260506-6bb5353a", "", { "os": "darwin", "cpu": "x64" }, "sha512-Xc1z9V7NKKrcwykwY6pp3t95YWuZ1T8AD+5CYmUBTMHiEgAxpOWSsO7G/wjvshD6Fb9BKbDVpjwAmm2Ru82hCw=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-5V/Fcwg1rYTeKH9/bj3pMG1837APMIaYPfNWz0Ha87m5wcUKjodQOMf/xTzz2NJuLE/m5rydSuvY9uh5EJx3QA=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.0.0-20260506-6bb5353a", "", { "os": "linux", "cpu": "arm64" }, "sha512-W5smYekMyg39LDLac2AJswBueeLcVtf/Ke2UttsGYlV50GXoWnrrNRB41sM6n0MCPsngUKiGFBTpZuKspF3BsQ=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-yQoWlEH9sQ/OfpCYcoGxzV4mbPaMCbYIl3thD/vcvIqOSa386vjKZFdTeU5Lu1PHiz3MMU/8Fzej5pJ6ZFJFZA=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.0.0-20260506-6bb5353a", "", { "os": "linux", "cpu": "x64" }, "sha512-GHsZAN/Z5O/p1PoYh0kp9haW09jTHn6trYqwP/WvifOJWk3y+0LBY6Gh7/gTVfXtO7JW/PhWZqVwVq1zae/19A=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-OxhBUqFHNcIhiKzon3+HYo4T2Me0ooRJQJW8bDVfEc7gtcWGm3ix/+8o7feQAdcbQW63Chwzed2glvbrOrDCzg=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.0.0-20260506-6bb5353a", "", { "os": "win32", "cpu": "arm64" }, "sha512-crRbkPzGEpAUbiBLyoX8rzb4uZle4EJohrhNj6lYQf/a2Nfbanr+/HmjPyZFOeAX0CTJ7i2n9vndPTtpdHUeJw=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-UWECe8y9vzdcotRf1ljOvWFOjNiGqAnmeC/SyylhvvoNhh/TqvbZawHVFifw/GZUlBEBdZcXF0f3XGGsW4M5Nw=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.0.0-20260506-6bb5353a", "", { "os": "win32", "cpu": "x64" }, "sha512-fqTu+AMOjJzSSDh0VPhtmzrjwFFF2zTuYLr0k/13TKise+zNlbHabDCeJ+KOKo44f9MeTcf3YQKqlvVyjSLWEA=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-P7yBRAWwiMZXDnVzzXCNksjkCzAvQ5b2X32JzL3gACf+yobs4bvA9F47Ud+XgKZiqILf/c7fa0cud2E0cfWxlA=="], - "@opentui/keymap": ["@opentui/keymap@0.0.0-20260506-6bb5353a", "", { "dependencies": { "@opentui/core": "0.0.0-20260506-6bb5353a" }, "peerDependencies": { "@opentui/react": "0.0.0-20260506-6bb5353a", "@opentui/solid": "0.0.0-20260506-6bb5353a", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-eUwvt8V2Jt9vRV3bCo8rc9ohCJyt7g7k3QROuq4xxabrNAt5oa+/n8yfE/dNQzX14wgtrlXqF3E0UEtO9iaYVw=="], + "@opentui/keymap": ["@opentui/keymap@0.2.4", "", { "dependencies": { "@opentui/core": "0.2.4" }, "peerDependencies": { "@opentui/react": "0.2.4", "@opentui/solid": "0.2.4", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-TOEkPKlcfhP2Xqo6xtU6zYTsvwHv4syqZ2v89hjNLCuY476j4UMTxeszJCfuSABplwm0OfllV94rVFl0BheWVw=="], - "@opentui/solid": ["@opentui/solid@0.0.0-20260506-6bb5353a", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.0.0-20260506-6bb5353a", "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-TmJ0ue9R4imVykpzdDxa8KnVGWG84jRhDQ6SYVuHGhrp+ebHhYsBraEHesVYT55em7SA1gAFIJl5x+HJjbuqSg=="], + "@opentui/solid": ["@opentui/solid@0.2.4", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.4", "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-EKuUwcTElRW0jKrXNJrTiWVOBvok78wk8viVwsyy3h8sD9qcLyCQA+XGmOINapADNGvgBohW9dKOSTFsqjZlvA=="], "@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 ef3e25140487..15d96e131c66 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.0.0-20260506-6bb5353a", - "@opentui/keymap": "0.0.0-20260506-6bb5353a", - "@opentui/solid": "0.0.0-20260506-6bb5353a", + "@opentui/core": "0.2.4", + "@opentui/keymap": "0.2.4", + "@opentui/solid": "0.2.4", "ulid": "3.0.1", "@kobalte/core": "0.13.11", "@types/luxon": "3.7.1", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index c1d4caca25de..db11de3dd341 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -22,9 +22,9 @@ "zod": "catalog:" }, "peerDependencies": { - "@opentui/core": ">=0.0.0-20260506-6bb5353a", - "@opentui/keymap": ">=0.0.0-20260506-6bb5353a", - "@opentui/solid": ">=0.0.0-20260506-6bb5353a" + "@opentui/core": ">=0.2.4", + "@opentui/keymap": ">=0.2.4", + "@opentui/solid": ">=0.2.4" }, "peerDependenciesMeta": { "@opentui/core": {