flatten to keybind compatible config (#26421)

This commit is contained in:
Sebastian
2026-05-09 01:29:13 +02:00
committed by GitHub
parent 35deef6175
commit a0fc27e424
38 changed files with 1096 additions and 1518 deletions

View File

@@ -2,87 +2,62 @@
import { useTerminalDimensions, type JSX } from "@opentui/solid" import { useTerminalDimensions, type JSX } from "@opentui/solid"
import { useBindings, useKeymapSelector } from "@opentui/keymap/solid" import { useBindings, useKeymapSelector } from "@opentui/keymap/solid"
import { RGBA, VignetteEffect, type KeyEvent, type Renderable } from "@opentui/core" import { RGBA, VignetteEffect, type KeyEvent, type Renderable } from "@opentui/core"
import { resolveBindingSections, type BindingSectionsConfig, type BindingValue } from "@opentui/keymap/extras" import { createBindingLookup, type BindingConfig } from "@opentui/keymap/extras"
import type { Binding } from "@opentui/keymap"
import type { TuiPlugin, TuiPluginApi, TuiPluginMeta, TuiPluginModule, TuiSlotPlugin } from "@opencode-ai/plugin/tui" import type { TuiPlugin, TuiPluginApi, TuiPluginMeta, TuiPluginModule, TuiSlotPlugin } from "@opencode-ai/plugin/tui"
const tabs = ["overview", "counter", "help"] const tabs = ["overview", "counter", "help"]
const command = { const command = {
modal: "plugin.smoke.modal", modal: "smoke_modal",
screen: "plugin.smoke.screen", screen: "smoke_screen",
alert: "plugin.smoke.alert", alert: "smoke_alert",
confirm: "plugin.smoke.confirm", confirm: "smoke_confirm",
prompt: "plugin.smoke.prompt", prompt: "smoke_prompt",
select: "plugin.smoke.select", select: "smoke_select",
host: "plugin.smoke.host", host: "smoke_host",
home: "plugin.smoke.home", home: "smoke_home",
toast: "plugin.smoke.toast", toast: "smoke_toast",
dialog_close: "plugin.smoke.dialog.close", dialog_close: "smoke_dialog_close",
local_push: "plugin.smoke.local.push", local_push: "smoke_local_push",
local_pop: "plugin.smoke.local.pop", local_pop: "smoke_local_pop",
screen_home: "plugin.smoke.screen.home", screen_home: "smoke_screen_home",
screen_left: "plugin.smoke.screen.left", screen_left: "smoke_screen_left",
screen_right: "plugin.smoke.screen.right", screen_right: "smoke_screen_right",
screen_up: "plugin.smoke.screen.up", screen_up: "smoke_screen_up",
screen_down: "plugin.smoke.screen.down", screen_down: "smoke_screen_down",
screen_modal: "plugin.smoke.screen.modal", screen_modal: "smoke_screen_modal",
screen_local: "plugin.smoke.screen.local", screen_local: "smoke_screen_local",
screen_host: "plugin.smoke.screen.host", screen_host: "smoke_screen_host",
screen_alert: "plugin.smoke.screen.alert", screen_alert: "smoke_screen_alert",
screen_confirm: "plugin.smoke.screen.confirm", screen_confirm: "smoke_screen_confirm",
screen_prompt: "plugin.smoke.screen.prompt", screen_prompt: "smoke_screen_prompt",
screen_select: "plugin.smoke.screen.select", screen_select: "smoke_screen_select",
modal_accept: "plugin.smoke.modal.accept", modal_accept: "smoke_modal_accept",
modal_close: "plugin.smoke.modal.close", modal_close: "smoke_modal_close",
} as const
const sectionNames = ["global", "dialog", "local", "screen", "modal"] as const
type SectionName = (typeof sectionNames)[number]
type SectionConfig = Record<string, BindingValue<Renderable, KeyEvent>>
type ResolvedSections = Record<SectionName, Binding<Renderable, KeyEvent>[]>
type SmokeKeymap = {
sections?: Partial<Record<SectionName, SectionConfig>>
} }
type SmokeOptions = { type SmokeBindings = BindingConfig<Renderable, KeyEvent>
enabled?: boolean
label?: unknown
route?: unknown
vignette?: unknown
keymap?: SmokeKeymap
}
const defaultKeymap = { const defaultKeymap = {
global: { [command.modal]: "ctrl+shift+m",
[command.modal]: "ctrl+shift+m", [command.screen]: "ctrl+shift+o",
[command.screen]: "ctrl+shift+o", [command.dialog_close]: "escape",
}, [command.local_push]: "enter,return",
dialog: { [command.local_pop]: "escape,q,backspace",
[command.dialog_close]: "escape", [command.screen_home]: "escape,ctrl+h",
}, [command.screen_left]: "left,h",
local: { [command.screen_right]: "right,l",
[command.local_push]: "enter,return", [command.screen_up]: "up,k",
[command.local_pop]: "escape,q,backspace", [command.screen_down]: "down,j",
}, [command.screen_modal]: "ctrl+shift+m",
screen: { [command.screen_local]: "x",
[command.screen_home]: "escape,ctrl+h", [command.screen_host]: "z",
[command.screen_left]: "left,h", [command.screen_alert]: "a",
[command.screen_right]: "right,l", [command.screen_confirm]: "c",
[command.screen_up]: "up,k", [command.screen_prompt]: "p",
[command.screen_down]: "down,j", [command.screen_select]: "s",
[command.screen_modal]: "ctrl+shift+m", [command.modal_accept]: "enter,return",
[command.screen_local]: "x", [command.modal_close]: "escape",
[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<SectionName, SectionConfig>
const pick = (value: unknown, fallback: string) => { const pick = (value: unknown, fallback: string) => {
if (typeof value !== "string") return fallback if (typeof value !== "string") return fallback
@@ -95,11 +70,14 @@ const num = (value: unknown, fallback: number) => {
return value return value
} }
const record = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === "object" && !Array.isArray(value)
type Cfg = { type Cfg = {
label: string label: string
route: string route: string
vignette: number vignette: number
keymap: SmokeKeymap | undefined keybinds: SmokeBindings | undefined
} }
type Route = { type Route = {
@@ -116,12 +94,12 @@ type State = {
local: number local: number
} }
const cfg = (options: SmokeOptions | undefined) => { const cfg = (options: Record<string, unknown> | undefined) => {
return { return {
label: pick(options?.label, "smoke"), label: pick(options?.label, "smoke"),
route: pick(options?.route, "workspace-smoke"), route: pick(options?.route, "workspace-smoke"),
vignette: Math.max(0, num(options?.vignette, 0.35)), vignette: Math.max(0, num(options?.vignette, 0.35)),
keymap: options?.keymap, keybinds: record(options?.keybinds) ? (options.keybinds as SmokeBindings) : undefined,
} }
} }
@@ -132,21 +110,8 @@ const names = (input: Cfg) => {
} }
} }
function createKeys(input: SmokeKeymap | undefined): { sections: ResolvedSections } { function createKeys(input: SmokeBindings | undefined) {
const sections = resolveBindingSections( return createBindingLookup({ ...defaultKeymap, ...input })
{
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<Renderable, KeyEvent>,
{ sections: sectionNames },
).sections
return {
sections,
}
} }
type Keys = ReturnType<typeof createKeys> type Keys = ReturnType<typeof createKeys>
@@ -376,7 +341,7 @@ const Screen = (props: {
}, },
}, },
], ],
bindings: props.keys.sections.dialog, bindings: props.keys.gather("smoke.dialog", [command.dialog_close]),
})) }))
useBindings(() => ({ useBindings(() => ({
@@ -395,7 +360,7 @@ const Screen = (props: {
}, },
}, },
], ],
bindings: props.keys.sections.local, bindings: props.keys.gather("smoke.local", [command.local_push, command.local_pop]),
})) }))
useBindings(() => ({ useBindings(() => ({
@@ -478,7 +443,20 @@ const Screen = (props: {
}, },
}, },
], ],
bindings: props.keys.sections.screen, bindings: props.keys.gather("smoke.screen", [
command.screen_home,
command.screen_left,
command.screen_right,
command.screen_up,
command.screen_down,
command.screen_modal,
command.screen_local,
command.screen_host,
command.screen_alert,
command.screen_confirm,
command.screen_prompt,
command.screen_select,
]),
})) }))
const shortcuts = useKeymapSelector((keymap) => { const shortcuts = useKeymapSelector((keymap) => {
const bindings = keymap.getCommandBindings({ const bindings = keymap.getCommandBindings({
@@ -687,7 +665,7 @@ const Modal = (props: {
}, },
}, },
], ],
bindings: props.keys.sections.modal, bindings: props.keys.gather("smoke.modal", [command.modal_accept, command.modal_close]),
})) }))
const shortcuts = useKeymapSelector((keymap) => { const shortcuts = useKeymapSelector((keymap) => {
const bindings = keymap.getCommandBindings({ const bindings = keymap.getCommandBindings({
@@ -766,25 +744,8 @@ const home = (api: TuiPluginApi, input: Cfg) => ({
}, },
home_prompt(ctx, value) { home_prompt(ctx, value) {
const skin = look(ctx.theme.current) const skin = look(ctx.theme.current)
type Prompt = (props: { const Prompt = api.ui.Prompt
workspaceID?: string const Slot = api.ui.Slot
visible?: boolean
disabled?: boolean
onSubmit?: () => void
hint?: JSX.Element
right?: JSX.Element
showPlaceholder?: boolean
placeholders?: {
normal?: string[]
shell?: string[]
}
}) => JSX.Element
type Slot = (
props: { name: string; mode?: unknown; children?: JSX.Element } & Record<string, unknown>,
) => JSX.Element | null
const ui = api.ui as TuiPluginApi["ui"] & { Prompt: Prompt; Slot: Slot }
const Prompt = ui.Prompt
const Slot = ui.Slot
const normal = [ const normal = [
`[SMOKE] route check for ${input.label}`, `[SMOKE] route check for ${input.label}`,
"[SMOKE] confirm home_prompt slot override", "[SMOKE] confirm home_prompt slot override",
@@ -1003,20 +964,29 @@ const reg = (api: TuiPluginApi, input: Cfg, keys: Keys) => {
}, },
}, },
], ],
bindings: keys.sections.global, bindings: keys.gather("smoke.global", [
command.modal,
command.screen,
command.alert,
command.confirm,
command.prompt,
command.select,
command.host,
command.home,
command.toast,
]),
}) })
} }
const tui: TuiPlugin = async (api, options, meta) => { const tui: TuiPlugin = async (api, options, meta) => {
const input = options as SmokeOptions | undefined if (options?.enabled === false) return
if (input?.enabled === false) return
await api.theme.install("./smoke-theme.json") await api.theme.install("./smoke-theme.json")
api.theme.set("smoke-theme") api.theme.set("smoke-theme")
const value = cfg(input) const value = cfg(options)
const route = names(value) const route = names(value)
const keys = createKeys(value.keymap) const keys = createKeys(value.keybinds)
const fx = new VignetteEffect(value.vignette) const fx = new VignetteEffect(value.vignette)
const post = fx.apply.bind(fx) const post = fx.apply.bind(fx)
api.renderer.addPostProcessFn(post) api.renderer.addPostProcessFn(post)

View File

@@ -6,20 +6,12 @@
{ {
"enabled": false, "enabled": false,
"label": "workspace", "label": "workspace",
"keymap": { "keybinds": {
"sections": { "smoke_modal": "ctrl+alt+m",
"global": { "smoke_screen": "ctrl+alt+o",
"plugin.smoke.modal": "ctrl+alt+m", "smoke_screen_home": "escape,ctrl+shift+h",
"plugin.smoke.screen": "ctrl+alt+o" "smoke_screen_modal": "ctrl+alt+m",
}, "smoke_dialog_close": "escape,q"
"screen": {
"plugin.smoke.screen.home": "escape,ctrl+shift+h",
"plugin.smoke.screen.modal": "ctrl+alt+m"
},
"dialog": {
"plugin.smoke.dialog.close": "escape,q"
}
}
} }
} }
] ]

View File

@@ -519,9 +519,9 @@
"typescript": "catalog:", "typescript": "catalog:",
}, },
"peerDependencies": { "peerDependencies": {
"@opentui/core": ">=0.2.5", "@opentui/core": ">=0.2.6",
"@opentui/keymap": ">=0.2.5", "@opentui/keymap": ">=0.2.6",
"@opentui/solid": ">=0.2.5", "@opentui/solid": ">=0.2.6",
}, },
"optionalPeers": [ "optionalPeers": [
"@opentui/core", "@opentui/core",
@@ -700,9 +700,9 @@
"@npmcli/arborist": "9.4.0", "@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806", "@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.2.5", "@opentui/core": "0.2.6",
"@opentui/keymap": "0.2.5", "@opentui/keymap": "0.2.6",
"@opentui/solid": "0.2.5", "@opentui/solid": "0.2.6",
"@pierre/diffs": "1.1.0-beta.18", "@pierre/diffs": "1.1.0-beta.18",
"@playwright/test": "1.59.1", "@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0", "@sentry/solid": "10.36.0",
@@ -1631,23 +1631,23 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
"@opentui/core": ["@opentui/core@0.2.5", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.5", "@opentui/core-darwin-x64": "0.2.5", "@opentui/core-linux-arm64": "0.2.5", "@opentui/core-linux-x64": "0.2.5", "@opentui/core-win32-arm64": "0.2.5", "@opentui/core-win32-x64": "0.2.5" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-A5DNOW39S60LtOcBdWYx7fuIGsPcClzbdKz9WuLp+wgy0Bt/jPw5XX6dk3k4dCX4jmhA1nX7x7680+GXLHPL6Q=="], "@opentui/core": ["@opentui/core@0.2.6", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.6", "@opentui/core-darwin-x64": "0.2.6", "@opentui/core-linux-arm64": "0.2.6", "@opentui/core-linux-x64": "0.2.6", "@opentui/core-win32-arm64": "0.2.6", "@opentui/core-win32-x64": "0.2.6" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-dBpMaWVM7wtW2/2TlGPrkPjg6gOL3MVU/5XXk+U1LDJB8L4q4NeYWVdzfAVNcEvgmuuCy/cVqdY2D4ei+e7MMg=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Jdl8TN7oxV8NTaKZsUAt0B/A4hIYiyUKwXNSe4w1OchNMlgjwF1fx/7RhgHXSvWh1Fcqi1IH5FfhsmO89Aed1A=="], "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-hR5nsxNj+059utzenTCF0kealUlibON6fLuebFUCGM/5kJnqa+shIh0XbUDFm0+F47vqVUgZufBdUuieQZIbvQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-78sKg0ZvwFHzZZGJCeaSNIVi2dadDxQymHAmrK698zEgnQr4eLVVB+MxNpxJx55/z9Y+YqbfSZaobC6w6Q3y5A=="], "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-pJ/bH4WC/mbBaakM1YdH6TVo67jhy0KPd61bCz97w0I/PJGr8fmNKvhmMt/AwyFgOQi3FYZiEKLMpGdvUcSsrQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-BtqbOjP64hKQaVd0ApHunt0MjkEEKTvxpaBwk7OhwVCoYakQBDZTZXUQ9zuPXvaHc9IF286z1PnJGLu0t11BAw=="], "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Pnd3kOxig8ii+/IqYheOPEgferylsQA0L6tKBnHQ9jRlCJOcu0Rv65Jepueh212vevdV9DzPURJnhejG06J6g=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.5", "", { "os": "linux", "cpu": "x64" }, "sha512-c3sEXtmOd1E5R4wfWh/MejplxgApYKqzyJ0AVMTU8pU1MHRAMwD8UFDMSVQhl7rYMTuBYPWok3IoCK2u8a2A4A=="], "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-458Mx9tBzEPzfft8cSt5ZaIpEepoxBXBOL6AUVmDTKWaZ3uouraPcEKraGAyvOTDQp2XDI3R8c/2GdaR77FaUQ=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WlgpYkgmuvMPc2mYGJSwN7c+VGAxiZvMKwZEbS+w9PMj7sJhvY+zFrOJNFpvjbAFw8vS3Kz39km4Nj7GF8JH6w=="], "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-BDUrdrT1RCcVnQoHJmUut4y811jDBAEtc6GJFB4Gs265Be8SrTjVCus6p2fSQ7j9sZQ1OcjO+5+4NkheSZICDQ=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.5", "", { "os": "win32", "cpu": "x64" }, "sha512-4X7BHJ7Wztzj7p0E+SsN0d4goUVU7Dy2VnhnD4n65ODgVbW59iqasAvbnPLbX3ghjgKiwQ+2SD+ImCIHE6uCAA=="], "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-SUYAzRJ9TSoD2Qt8kn6FJz6dbTrFEPVig5mScB4zFGgGQO/Bbod2/Q31vLS/IQrX+FDb67WaErD+kuMCnMPPLA=="],
"@opentui/keymap": ["@opentui/keymap@0.2.5", "", { "dependencies": { "@opentui/core": "0.2.5" }, "peerDependencies": { "@opentui/react": "0.2.5", "@opentui/solid": "0.2.5", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-/B6Gy9LLRRKhvyDV1rFX0p7BUN8NQOcXwTV8E0xb7ym1yREvVmij+hCRkXXddMme2HW9NmV0+RRHo4kJzJxkNQ=="], "@opentui/keymap": ["@opentui/keymap@0.2.6", "", { "dependencies": { "@opentui/core": "0.2.6" }, "peerDependencies": { "@opentui/react": "0.2.6", "@opentui/solid": "0.2.6", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-+6OYuedrFCKVo4ryGFNwws++2VOmPcXU3PwpY0mP47gYQY2nvQ+etWIs2Y7r5eMIqUfxVCldkKsrzcEcA4tb/A=="],
"@opentui/solid": ["@opentui/solid@0.2.5", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.5", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-M8MxDYJzjtF8TvxB6Q7656GOSS+QIg89jD0jf/asfF4qeip5TQhNZ3ba+R1v2fVuIkQCyRJzTtOtMZiglzGKPQ=="], "@opentui/solid": ["@opentui/solid@0.2.6", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.6", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-2y225WlOGi/fCaajkxBmLyVW8Cr+OmhowHdvrYcz5w2kBD15sKbJLIYu1G9DxceirT1uIyasGy2TGzRRcVkTDg=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],

View File

@@ -35,9 +35,9 @@
"@types/cross-spawn": "6.0.6", "@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2", "@hono/zod-validator": "0.4.2",
"@opentui/core": "0.2.5", "@opentui/core": "0.2.6",
"@opentui/keymap": "0.2.5", "@opentui/keymap": "0.2.6",
"@opentui/solid": "0.2.5", "@opentui/solid": "0.2.6",
"ulid": "3.0.1", "ulid": "3.0.1",
"@kobalte/core": "0.13.11", "@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1", "@types/luxon": "3.7.1",

View File

@@ -20,6 +20,12 @@ Example:
{ {
"$schema": "https://opencode.ai/tui.json", "$schema": "https://opencode.ai/tui.json",
"theme": "smoke-theme", "theme": "smoke-theme",
"leader_timeout": 2000,
"keybinds": {
"leader": "ctrl+x",
"command_list": "ctrl+p",
"session_new": "<leader>n"
},
"plugin": ["@acme/opencode-plugin@1.2.3", ["./plugins/demo.tsx", { "label": "demo" }]], "plugin": ["@acme/opencode-plugin@1.2.3", ["./plugins/demo.tsx", { "label": "demo" }]],
"plugin_enabled": { "plugin_enabled": {
"acme.demo": false "acme.demo": false
@@ -39,6 +45,9 @@ Example:
- Internal plugins can declare `enabled: false` to be registered but inactive by default; `plugin_enabled` and runtime KV can still enable them by id. - Internal plugins can declare `enabled: false` to be registered but inactive by default; `plugin_enabled` and runtime KV can still enable them by id.
- `plugin_enabled` is merged across config layers. - `plugin_enabled` is merged across config layers.
- Runtime enable/disable state is also stored in KV under `plugin_enabled`; that KV state overrides config on startup. - Runtime enable/disable state is also stored in KV under `plugin_enabled`; that KV state overrides config on startup.
- `leader_timeout` is a top-level TUI setting.
- `keybinds` is a flat object keyed by command id; values are key binding values (`false`, `"none"`, a key string/object, a binding object, or an array of key strings/objects/binding objects).
- `keybinds.leader` sets the key used by `<leader>` shortcuts.
## Author package shape ## Author package shape
@@ -228,14 +237,14 @@ Top-level API groups exposed to `tui(api, options, meta)`:
- To surface a command in the host command palette, set `namespace: "palette"` and provide metadata such as `title`, `category`, `desc`, `suggested`, `hidden`, `enabled`, `slashName`, and `slashAliases` on the command. - 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. - 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. - Disposers returned by `api.keymap` registrations and `acquireResource(...)` are automatically cleaned up when the plugin deactivates. You do not need to add those disposers to `api.lifecycle.onDispose(...)` yourself.
- Built-in which-key shortcuts are resolved from `keymap.sections.which_key`, not plugin options. - Built-in which-key shortcuts are resolved from flat `keybinds` command ids such as `which_key_toggle`, not plugin options.
### Keys ### Keys
- `api.keys` exposes host-formatted shortcut display helpers for plugin UI. - `api.keys` exposes host-formatted shortcut display helpers for plugin UI.
- `formatSequence(parts)` formats parsed key sequence parts using the host's display policy. - `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. - `formatBindings(bindings)` formats binding lists and returns `undefined` when there is nothing to show.
- For generic config-to-bindings helpers, import `resolveBindingSections` from `@opencode-ai/plugin/tui`. - For generic config-to-bindings helpers, import `createBindingLookup` from `@opencode-ai/plugin/tui`.
### Routes ### Routes

View File

@@ -1,36 +0,0 @@
# Keybindings vs. Keymappings
Make it `keymappings`, closer to neovim. Can be layered like `<leader>abc`. Commands don't define their binding, but have an id that a key can be mapped to like
```ts
{ key: "ctrl+w", cmd: string | function, description }
```
_Why_
Currently its keybindings that have an `id` like `message_redo` and then a command can use that or define it's own binding. While some keybindings are just used with `.match` in arbitrary key handlers and there is no info what the key is used for, except the binding id maybe. It also is unknown in which context/scope what binding is active, so a plugin like `which-key` is nearly impossible to get right.
## OpenTUI Keymap Migration
The v2 TUI uses `@opentui/keymap` as the key/cmd engine. The remaining legacy compatibility is config-only and exists to migrate users from `keybinds` to `keymap`:
- `packages/opencode/src/config/keybinds.ts`: old `keybinds` schema, defaults, and legacy key names.
- `packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts`: transforms parsed legacy `keybinds` into OpenTUI `keymap` sections.
- `packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts`: migrates legacy TUI keys from `opencode.json` into `tui.json`, including `theme`, `keybinds`, and nested `tui`.
- `packages/opencode/src/cli/cmd/tui/config/tui-schema.ts`: still accepts deprecated `keybinds` via `KeybindOverride` and marks it as deprecated. This file also contains the new `keymap` config schema.
- `packages/opencode/src/cli/cmd/tui/config/tui.ts`: parses legacy `keybinds`, applies the Windows `terminal_suspend`/`input_undo` adjustment, and uses `LegacyKeymapTransform.create(...)` as the fallback when no `keymap` section is configured.
- `packages/plugin/src/tui.ts`: plugin-facing `tuiConfig` still includes `keybinds` through `PluginConfig`; this should be removed when the public plugin API no longer exposes legacy config.
The transform must stay while users are migrating. It lets users upgrade without first rewriting their existing `keybinds` config. If `keymap` is configured, `keybinds` are ignored for keymap resolution. If `keymap` is missing, `legacy-keymap-transform.ts` turns legacy `keybinds` into the resolved `keymap` consumed by OpenTUI.
## Removing Legacy Later
When switching fully to the new config style, remove legacy support with these exact changes:
- Delete `packages/opencode/src/config/keybinds.ts`.
- Delete `packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts`.
- Delete `packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts`.
- In `packages/opencode/src/cli/cmd/tui/config/tui-schema.ts`, remove the `ConfigKeybinds` import, remove `KeybindOverride`, and delete the deprecated `keybinds` field from `TuiInfo`.
- In `packages/opencode/src/cli/cmd/tui/config/tui.ts`, remove `migrateTuiConfig(...)`, remove `ConfigKeybinds`, remove the Windows legacy keybind adjustment, remove `LegacyKeymapTransform.create(...)`, and require/default `keymap` through the new config path instead.
- In `packages/opencode/src/cli/cmd/tui/config/tui.ts`, remove `keybinds` from `Resolved`; resolved TUI config should expose `keymap` only.
- In `packages/plugin/src/tui.ts`, remove `keybinds` from plugin-facing `TuiConfigView`.
- Remove or rewrite tests that write or assert `keybinds`, especially in `packages/opencode/test/config/tui.test.ts`, `packages/opencode/test/fixture/tui-runtime.ts`, and TUI plugin loader tests.

View File

@@ -6,7 +6,9 @@
// history ring. All are async because they read config or hit the SDK, but // history ring. All are async because they read config or hit the SDK, but
// none block each other. // none block each other.
import { Context, Effect, Layer } from "effect" import { Context, Effect, Layer } from "effect"
import { stringifyKeyStroke } from "@opentui/keymap"
import { TuiConfig } from "@/cli/cmd/tui/config/tui" import { TuiConfig } from "@/cli/cmd/tui/config/tui"
import { TuiKeybind } from "@/cli/cmd/tui/config/keybind"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
import { reusePendingTask } from "./runtime.shared" import { reusePendingTask } from "./runtime.shared"
import { resolveSession, sessionHistory } from "./session.shared" import { resolveSession, sessionHistory } from "./session.shared"
@@ -14,7 +16,7 @@ import type { FooterKeybinds, RunDiffStyle, RunInput, RunPrompt, RunProvider } f
import { pickVariant } from "./variant.shared" import { pickVariant } from "./variant.shared"
const DEFAULT_KEYBINDS: FooterKeybinds = { const DEFAULT_KEYBINDS: FooterKeybinds = {
leader: "ctrl+x", leader: TuiKeybind.LeaderDefault,
leaderTimeout: 2000, leaderTimeout: 2000,
commandList: [{ key: "ctrl+p" }], commandList: [{ key: "ctrl+p" }],
variantCycle: [{ key: "ctrl+t" }], variantCycle: [{ key: "ctrl+t" }],
@@ -78,22 +80,28 @@ function emptySessionInfo(): SessionInfo {
} }
} }
function leaderKey(config: Config) {
const key = config.keybinds.get("leader")?.[0]?.key
if (!key) return TuiKeybind.LeaderDefault
return typeof key === "string" ? key : stringifyKeyStroke(key)
}
function footerKeybinds(config: Config | undefined): FooterKeybinds { function footerKeybinds(config: Config | undefined): FooterKeybinds {
if (!config) { if (!config) {
return DEFAULT_KEYBINDS return DEFAULT_KEYBINDS
} }
return { return {
leader: config.keymap.leader, leader: leaderKey(config),
leaderTimeout: config.keymap.leader_timeout, leaderTimeout: config.leader_timeout,
commandList: config.keymap.get("global", "command.palette.show") ?? [], commandList: config.keybinds.get("command.palette.show"),
variantCycle: config.keymap.get("global", "variant.cycle") ?? [], variantCycle: config.keybinds.get("variant.cycle"),
interrupt: config.keymap.get("prompt", "session.interrupt") ?? [], interrupt: config.keybinds.get("session.interrupt"),
historyPrevious: config.keymap.get("prompt", "prompt.history.previous") ?? [], historyPrevious: config.keybinds.get("prompt.history.previous"),
historyNext: config.keymap.get("prompt", "prompt.history.next") ?? [], historyNext: config.keybinds.get("prompt.history.next"),
inputClear: config.keymap.get("prompt", "prompt.clear") ?? [], inputClear: config.keybinds.get("prompt.clear"),
inputSubmit: config.keymap.get("input", "input.submit") ?? [], inputSubmit: config.keybinds.get("input.submit"),
inputNewline: config.keymap.get("input", "input.newline") ?? [], inputNewline: config.keybinds.get("input.newline"),
} }
} }

View File

@@ -70,6 +70,42 @@ import { OpencodeKeymapProvider, registerOpencodeKeymap, useBindings, useOpencod
import type { EventSource } from "./context/sdk" import type { EventSource } from "./context/sdk"
import { DialogVariant } from "./component/dialog-variant" import { DialogVariant } from "./component/dialog-variant"
const appBindingCommands = [
"command.palette.show",
"session.list",
"session.new",
"model.list",
"model.cycle_recent",
"model.cycle_recent_reverse",
"model.cycle_favorite",
"model.cycle_favorite_reverse",
"agent.list",
"mcp.list",
"agent.cycle",
"agent.cycle.reverse",
"variant.cycle",
"variant.list",
"provider.connect",
"console.org.switch",
"opencode.status",
"theme.switch",
"theme.switch_mode",
"theme.mode.lock",
"help.show",
"docs.open",
"app.exit",
"app.debug",
"app.console",
"app.heap_snapshot",
"terminal.suspend",
"terminal.title.toggle",
"app.toggle.animations",
"app.toggle.file_context",
"app.toggle.diffwrap",
"app.toggle.paste_summary",
"app.toggle.session_directory_filter",
] as const
function rendererConfig(_config: TuiConfig.Resolved): CliRendererConfig { function rendererConfig(_config: TuiConfig.Resolved): CliRendererConfig {
const mouseEnabled = !Flag.OPENCODE_DISABLE_MOUSE && (_config.mouse ?? true) const mouseEnabled = !Flag.OPENCODE_DISABLE_MOUSE && (_config.mouse ?? true)
@@ -215,9 +251,6 @@ export function tui(input: {
function App(props: { onSnapshot?: () => Promise<string[]> }) { function App(props: { onSnapshot?: () => Promise<string[]> }) {
const tuiConfig = useTuiConfig() const tuiConfig = useTuiConfig()
const {
keymap: { sections },
} = tuiConfig
const route = useRoute() const route = useRoute()
const dimensions = useTerminalDimensions() const dimensions = useTerminalDimensions()
const renderer = useRenderer() const renderer = useRenderer()
@@ -749,7 +782,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
useBindings(() => ({ useBindings(() => ({
enabled: command.matcher, enabled: command.matcher,
bindings: sections.global, bindings: tuiConfig.keybinds.gather("app", appBindingCommands),
})) }))
event.on(TuiEvent.CommandExecute.type, (evt) => { event.on(TuiEvent.CommandExecute.type, (evt) => {

View File

@@ -46,7 +46,7 @@ export function DialogMcp() {
const actions = createMemo(() => [ const actions = createMemo(() => [
{ {
command: "dialog.action.toggle", command: "dialog.mcp.toggle",
title: "toggle", title: "toggle",
onTrigger: async (option: DialogSelectOption<string>) => { onTrigger: async (option: DialogSelectOption<string>) => {
// Prevent toggling while an operation is already in progress // Prevent toggling while an operation is already in progress

View File

@@ -8,13 +8,11 @@ import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
import { DialogVariant } from "./dialog-variant" import { DialogVariant } from "./dialog-variant"
import * as fuzzysort from "fuzzysort" import * as fuzzysort from "fuzzysort"
import { useConnected } from "./use-connected" import { useConnected } from "./use-connected"
import { useTuiConfig } from "../context/tui-config"
export function DialogModel(props: { providerID?: string }) { export function DialogModel(props: { providerID?: string }) {
const local = useLocal() const local = useLocal()
const sync = useSync() const sync = useSync()
const dialog = useDialog() const dialog = useDialog()
const tuiConfig = useTuiConfig()
const [query, setQuery] = createSignal("") const [query, setQuery] = createSignal("")
const connected = useConnected() const connected = useConnected()
@@ -167,7 +165,6 @@ export function DialogModel(props: { providerID?: string }) {
}, },
}, },
]} ]}
bindings={tuiConfig.keymap.sections.model}
onFilter={setQuery} onFilter={setQuery}
flat={true} flat={true}
skipFilter={true} skipFilter={true}

View File

@@ -28,7 +28,7 @@ export function DialogSessionList() {
const toast = useToast() const toast = useToast()
const [toDelete, setToDelete] = createSignal<string>() const [toDelete, setToDelete] = createSignal<string>()
const [search, setSearch] = createDebouncedSignal("", 150) const [search, setSearch] = createDebouncedSignal("", 150)
const deleteHint = useCommandShortcut("dialog.action.delete") const deleteHint = useCommandShortcut("session.delete")
const [searchResults, { refetch }] = createResource( const [searchResults, { refetch }] = createResource(
() => ({ query: search(), filter: sync.session.query() }), () => ({ query: search(), filter: sync.session.query() }),
@@ -190,7 +190,7 @@ export function DialogSessionList() {
}} }}
actions={[ actions={[
{ {
command: "dialog.action.delete", command: "session.delete",
title: "delete", title: "delete",
onTrigger: async (option) => { onTrigger: async (option) => {
if (toDelete() === option.value) { if (toDelete() === option.value) {
@@ -238,7 +238,7 @@ export function DialogSessionList() {
}, },
}, },
{ {
command: "dialog.action.rename", command: "session.rename",
title: "rename", title: "rename",
onTrigger: async (option) => { onTrigger: async (option) => {
dialog.replace(() => <DialogSessionRename session={option.value} />) dialog.replace(() => <DialogSessionRename session={option.value} />)

View File

@@ -32,7 +32,7 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
const { theme } = useTheme() const { theme } = useTheme()
const [toDelete, setToDelete] = createSignal<number>() const [toDelete, setToDelete] = createSignal<number>()
const deleteHint = useCommandShortcut("dialog.action.delete") const deleteHint = useCommandShortcut("stash.delete")
const options = createMemo(() => { const options = createMemo(() => {
const entries = stash.list() const entries = stash.list()
@@ -70,7 +70,7 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
}} }}
actions={[ actions={[
{ {
command: "dialog.action.delete", command: "stash.delete",
title: "delete", title: "delete",
onTrigger: (option) => { onTrigger: (option) => {
if (toDelete() === option.value) { if (toDelete() === option.value) {

View File

@@ -87,9 +87,6 @@ export function Autocomplete(props: {
const dimensions = useTerminalDimensions() const dimensions = useTerminalDimensions()
const frecency = useFrecency() const frecency = useFrecency()
const tuiConfig = useTuiConfig() const tuiConfig = useTuiConfig()
const {
keymap: { sections },
} = tuiConfig
const [store, setStore] = createStore({ const [store, setStore] = createStore({
index: 0, index: 0,
selected: 0, selected: 0,
@@ -575,7 +572,13 @@ export function Autocomplete(props: {
}, },
}, },
], ],
bindings: sections.autocomplete, bindings: tuiConfig.keybinds.gather("prompt.autocomplete", [
"prompt.autocomplete.prev",
"prompt.autocomplete.next",
"prompt.autocomplete.hide",
"prompt.autocomplete.select",
"prompt.autocomplete.complete",
]),
})) }))
function show(mode: "@" | "/") { function show(mode: "@" | "/") {

View File

@@ -147,7 +147,6 @@ export function Prompt(props: PromptProps) {
const project = useProject() const project = useProject()
const sync = useSync() const sync = useSync()
const tuiConfig = useTuiConfig() const tuiConfig = useTuiConfig()
const keymapConfig = tuiConfig.keymap
const dialog = useDialog() const dialog = useDialog()
const toast = useToast() const toast = useToast()
const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" }) const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" })
@@ -630,7 +629,7 @@ export function Prompt(props: PromptProps) {
useBindings(() => ({ useBindings(() => ({
enabled: command.matcher, enabled: command.matcher,
bindings: keymapConfig.pick("prompt", [ bindings: tuiConfig.keybinds.gather("prompt.palette", [
"prompt.submit", "prompt.submit",
"prompt.editor", "prompt.editor",
"prompt.editor_context.clear", "prompt.editor_context.clear",
@@ -865,7 +864,7 @@ export function Prompt(props: PromptProps) {
return { return {
target: inputTarget, target: inputTarget,
enabled: inputTarget() !== undefined && !props.disabled, enabled: inputTarget() !== undefined && !props.disabled,
bindings: keymapConfig.pick("prompt", ["prompt.paste"]), bindings: tuiConfig.keybinds.get("prompt.paste"),
} }
}) })
@@ -873,7 +872,7 @@ export function Prompt(props: PromptProps) {
return { return {
target: inputTarget, target: inputTarget,
enabled: inputTarget() !== undefined && !props.disabled && store.prompt.input !== "", enabled: inputTarget() !== undefined && !props.disabled && store.prompt.input !== "",
bindings: keymapConfig.pick("prompt", ["prompt.clear"]), bindings: tuiConfig.keybinds.get("prompt.clear"),
} }
}) })
@@ -957,7 +956,7 @@ export function Prompt(props: PromptProps) {
}, },
}, },
], ],
bindings: keymapConfig.pick("prompt", ["prompt.history.previous"]), bindings: tuiConfig.keybinds.get("prompt.history.previous"),
} }
}) })
@@ -995,7 +994,7 @@ export function Prompt(props: PromptProps) {
}, },
}, },
], ],
bindings: keymapConfig.pick("prompt", ["prompt.history.next"]), bindings: tuiConfig.keybinds.get("prompt.history.next"),
} }
}) })

View File

@@ -0,0 +1,384 @@
export * as TuiKeybind from "./keybind"
import type { KeyEvent, Renderable } from "@opentui/core"
import type { Binding } from "@opentui/keymap"
import type { BindingCommandMap, BindingConfig, BindingDefaults, BindingValue } from "@opentui/keymap/extras"
import z from "zod"
const KeyStroke = z
.object({
name: z.string(),
ctrl: z.boolean().optional(),
shift: z.boolean().optional(),
meta: z.boolean().optional(),
super: z.boolean().optional(),
hyper: z.boolean().optional(),
})
.strict()
const BindingObject = z
.object({
key: z.union([z.string(), KeyStroke]),
event: z.enum(["press", "release"]).optional(),
preventDefault: z.boolean().optional(),
fallthrough: z.boolean().optional(),
})
.passthrough()
const BindingItem = z.union([z.string(), KeyStroke, BindingObject])
export const BindingValueSchema = z.union([z.literal(false), z.literal("none"), BindingItem, z.array(BindingItem)])
type Definition = {
default: z.input<typeof BindingValueSchema>
description: string
}
const inputUndoDefault = process.platform === "win32" ? "ctrl+z,ctrl+-,super+z" : "ctrl+-,super+z"
export const LeaderDefault = "ctrl+x"
const keybind = (value: Definition["default"], description: string): Definition => ({ default: value, description })
const Definitions = {
leader: keybind(LeaderDefault, "Leader key for keybind combinations"),
app_exit: keybind("ctrl+c,ctrl+d,<leader>q", "Exit the application"),
app_debug: keybind("none", "Toggle debug panel"),
app_console: keybind("none", "Toggle console"),
app_heap_snapshot: keybind("none", "Write heap snapshot"),
app_toggle_animations: keybind("none", "Toggle animations"),
app_toggle_file_context: keybind("none", "Toggle file context"),
app_toggle_diffwrap: keybind("none", "Toggle diff wrapping"),
app_toggle_paste_summary: keybind("none", "Toggle paste summary"),
app_toggle_session_directory_filter: keybind("none", "Toggle session directory filtering"),
command_list: keybind("ctrl+p", "List available commands"),
help_show: keybind("none", "Open help dialog"),
docs_open: keybind("none", "Open documentation"),
editor_open: keybind("<leader>e", "Open external editor"),
theme_list: keybind("<leader>t", "List available themes"),
theme_switch_mode: keybind("none", "Switch between light and dark theme mode"),
theme_mode_lock: keybind("none", "Lock or unlock theme mode"),
sidebar_toggle: keybind("<leader>b", "Toggle sidebar"),
scrollbar_toggle: keybind("none", "Toggle session scrollbar"),
status_view: keybind("<leader>s", "View status"),
session_export: keybind("<leader>x", "Export session to editor"),
session_copy: keybind("none", "Copy session transcript"),
session_new: keybind("<leader>n", "Create a new session"),
session_list: keybind("<leader>l", "List all sessions"),
session_timeline: keybind("<leader>g", "Show session timeline"),
session_fork: keybind("none", "Fork session from message"),
session_rename: keybind("ctrl+r", "Rename session"),
session_delete: keybind("ctrl+d", "Delete session"),
session_share: keybind("none", "Share current session"),
session_unshare: keybind("none", "Unshare current session"),
session_interrupt: keybind("escape", "Interrupt current session"),
session_compact: keybind("<leader>c", "Compact the session"),
session_toggle_timestamps: keybind("none", "Toggle message timestamps"),
session_toggle_generic_tool_output: keybind("none", "Toggle generic tool output"),
session_child_first: keybind("<leader>down", "Go to first child session"),
session_child_cycle: keybind("right", "Go to next child session"),
session_child_cycle_reverse: keybind("left", "Go to previous child session"),
session_parent: keybind("up", "Go to parent session"),
stash_delete: keybind("ctrl+d", "Delete stash entry"),
model_provider_list: keybind("ctrl+a", "Open provider list from model dialog"),
model_favorite_toggle: keybind("ctrl+f", "Toggle model favorite status"),
model_list: keybind("<leader>m", "List available models"),
model_cycle_recent: keybind("f2", "Next recently used model"),
model_cycle_recent_reverse: keybind("shift+f2", "Previous recently used model"),
model_cycle_favorite: keybind("none", "Next favorite model"),
model_cycle_favorite_reverse: keybind("none", "Previous favorite model"),
mcp_list: keybind("none", "List MCP servers"),
provider_connect: keybind("none", "Connect provider"),
console_org_switch: keybind("none", "Switch console organization"),
agent_list: keybind("<leader>a", "List agents"),
agent_cycle: keybind("tab", "Next agent"),
agent_cycle_reverse: keybind("shift+tab", "Previous agent"),
variant_cycle: keybind("ctrl+t", "Cycle model variants"),
variant_list: keybind("none", "List model variants"),
messages_page_up: keybind("pageup,ctrl+alt+b", "Scroll messages up by one page"),
messages_page_down: keybind("pagedown,ctrl+alt+f", "Scroll messages down by one page"),
messages_line_up: keybind("ctrl+alt+y", "Scroll messages up by one line"),
messages_line_down: keybind("ctrl+alt+e", "Scroll messages down by one line"),
messages_half_page_up: keybind("ctrl+alt+u", "Scroll messages up by half page"),
messages_half_page_down: keybind("ctrl+alt+d", "Scroll messages down by half page"),
messages_first: keybind("ctrl+g,home", "Navigate to first message"),
messages_last: keybind("ctrl+alt+g,end", "Navigate to last message"),
messages_next: keybind("none", "Navigate to next message"),
messages_previous: keybind("none", "Navigate to previous message"),
messages_last_user: keybind("none", "Navigate to last user message"),
messages_copy: keybind("<leader>y", "Copy message"),
messages_undo: keybind("<leader>u", "Undo message"),
messages_redo: keybind("<leader>r", "Redo message"),
messages_toggle_conceal: keybind("<leader>h", "Toggle code block concealment in messages"),
tool_details: keybind("none", "Toggle tool details visibility"),
display_thinking: keybind("none", "Toggle thinking blocks visibility"),
prompt_submit: keybind("none", "Submit prompt"),
prompt_editor_context_clear: keybind("none", "Clear editor context"),
prompt_skills: keybind("none", "Open skill selector"),
prompt_stash: keybind("none", "Stash prompt"),
prompt_stash_pop: keybind("none", "Pop stashed prompt"),
prompt_stash_list: keybind("none", "List stashed prompts"),
workspace_set: keybind("none", "Set workspace"),
input_clear: keybind("ctrl+c", "Clear input field"),
input_paste: keybind({ key: "ctrl+v", preventDefault: false }, "Paste from clipboard"),
input_submit: keybind("return", "Submit input"),
input_newline: keybind("shift+return,ctrl+return,alt+return,ctrl+j", "Insert newline in input"),
input_move_left: keybind("left,ctrl+b", "Move cursor left in input"),
input_move_right: keybind("right,ctrl+f", "Move cursor right in input"),
input_move_up: keybind("up", "Move cursor up in input"),
input_move_down: keybind("down", "Move cursor down in input"),
input_select_left: keybind("shift+left", "Select left in input"),
input_select_right: keybind("shift+right", "Select right in input"),
input_select_up: keybind("shift+up", "Select up in input"),
input_select_down: keybind("shift+down", "Select down in input"),
input_line_home: keybind("ctrl+a", "Move to start of line in input"),
input_line_end: keybind("ctrl+e", "Move to end of line in input"),
input_select_line_home: keybind("ctrl+shift+a", "Select to start of line in input"),
input_select_line_end: keybind("ctrl+shift+e", "Select to end of line in input"),
input_visual_line_home: keybind("alt+a", "Move to start of visual line in input"),
input_visual_line_end: keybind("alt+e", "Move to end of visual line in input"),
input_select_visual_line_home: keybind("alt+shift+a", "Select to start of visual line in input"),
input_select_visual_line_end: keybind("alt+shift+e", "Select to end of visual line in input"),
input_buffer_home: keybind("home", "Move to start of buffer in input"),
input_buffer_end: keybind("end", "Move to end of buffer in input"),
input_select_buffer_home: keybind("shift+home", "Select to start of buffer in input"),
input_select_buffer_end: keybind("shift+end", "Select to end of buffer in input"),
input_delete_line: keybind("ctrl+shift+d", "Delete line in input"),
input_delete_to_line_end: keybind("ctrl+k", "Delete to end of line in input"),
input_delete_to_line_start: keybind("ctrl+u", "Delete to start of line in input"),
input_backspace: keybind("backspace,shift+backspace", "Backspace in input"),
input_delete: keybind("ctrl+d,delete,shift+delete", "Delete character in input"),
input_undo: keybind(inputUndoDefault, "Undo in input"),
input_redo: keybind("ctrl+.,super+shift+z", "Redo in input"),
input_word_forward: keybind("alt+f,alt+right,ctrl+right", "Move word forward in input"),
input_word_backward: keybind("alt+b,alt+left,ctrl+left", "Move word backward in input"),
input_select_word_forward: keybind("alt+shift+f,alt+shift+right", "Select word forward in input"),
input_select_word_backward: keybind("alt+shift+b,alt+shift+left", "Select word backward in input"),
input_delete_word_forward: keybind("alt+d,alt+delete,ctrl+delete", "Delete word forward in input"),
input_delete_word_backward: keybind("ctrl+w,ctrl+backspace,alt+backspace", "Delete word backward in input"),
input_select_all: keybind("super+a", "Select all in input"),
history_previous: keybind("up", "Previous history item"),
history_next: keybind("down", "Next history item"),
"dialog.select.prev": keybind("up,ctrl+p", "Move to previous dialog item"),
"dialog.select.next": keybind("down,ctrl+n", "Move to next dialog item"),
"dialog.select.page_up": keybind("pageup", "Move up one page in dialog"),
"dialog.select.page_down": keybind("pagedown", "Move down one page in dialog"),
"dialog.select.home": keybind("home", "Move to first dialog item"),
"dialog.select.end": keybind("end", "Move to last dialog item"),
"dialog.select.submit": keybind("return", "Submit selected dialog item"),
"dialog.mcp.toggle": keybind("space", "Toggle MCP in MCP dialog"),
"prompt.autocomplete.prev": keybind("up,ctrl+p", "Move to previous autocomplete item"),
"prompt.autocomplete.next": keybind("down,ctrl+n", "Move to next autocomplete item"),
"prompt.autocomplete.hide": keybind("escape", "Hide autocomplete"),
"prompt.autocomplete.select": keybind("return", "Select autocomplete item"),
"prompt.autocomplete.complete": keybind("tab", "Complete autocomplete item"),
"permission.prompt.fullscreen": keybind("ctrl+f", "Toggle permission prompt fullscreen"),
"plugins.toggle": keybind("space", "Toggle plugin"),
"dialog.plugins.install": keybind("shift+i", "Install plugin from plugin dialog"),
terminal_suspend: keybind("ctrl+z", "Suspend terminal"),
terminal_title_toggle: keybind("none", "Toggle terminal title"),
tips_toggle: keybind("<leader>h", "Toggle tips on home screen"),
plugin_manager: keybind("none", "Open plugin manager dialog"),
plugin_install: keybind("none", "Install plugin"),
which_key_toggle: keybind("ctrl+alt+k", "Toggle which-key panel"),
which_key_layout_toggle: keybind("ctrl+alt+shift+k", "Switch which-key layout"),
which_key_pending_toggle: keybind("ctrl+alt+shift+p", "Toggle which-key pending preview"),
which_key_group_previous: keybind("ctrl+alt+left,ctrl+alt+[", "Previous which-key group"),
which_key_group_next: keybind("ctrl+alt+right,ctrl+alt+]", "Next which-key group"),
which_key_scroll_up: keybind("ctrl+alt+up,ctrl+alt+p", "Scroll which-key up"),
which_key_scroll_down: keybind("ctrl+alt+down,ctrl+alt+n", "Scroll which-key down"),
which_key_page_up: keybind("ctrl+alt+pageup", "Page which-key up"),
which_key_page_down: keybind("ctrl+alt+pagedown", "Page which-key down"),
which_key_home: keybind("ctrl+alt+home", "Jump to first which-key binding"),
which_key_end: keybind("ctrl+alt+end", "Jump to last which-key binding"),
} satisfies Record<string, Definition>
type KeybindName = keyof typeof Definitions & string
const KeybindShape = Object.fromEntries(
Object.entries(Definitions).map(([name, item]) => [
name,
BindingValueSchema.optional().default(item.default).describe(item.description),
]),
) as Record<KeybindName, z.ZodDefault<z.ZodOptional<typeof BindingValueSchema>>>
const KeybindOverrideShape = Object.fromEntries(
Object.entries(Definitions).map(([name, item]) => [name, BindingValueSchema.optional().describe(item.description)]),
) as Record<KeybindName, z.ZodOptional<typeof BindingValueSchema>>
export const Keybinds = z.strictObject(KeybindShape).describe("TUI keybinding configuration")
export const KeybindOverrides = z.strictObject(KeybindOverrideShape).describe("TUI keybinding overrides")
export const Descriptions = Object.fromEntries(
Object.entries(Definitions).map(([name, item]) => [name, item.description]),
) as Record<KeybindName, string>
export const CommandMap = {
app_exit: "app.exit",
app_debug: "app.debug",
app_console: "app.console",
app_heap_snapshot: "app.heap_snapshot",
app_toggle_animations: "app.toggle.animations",
app_toggle_file_context: "app.toggle.file_context",
app_toggle_diffwrap: "app.toggle.diffwrap",
app_toggle_paste_summary: "app.toggle.paste_summary",
app_toggle_session_directory_filter: "app.toggle.session_directory_filter",
command_list: "command.palette.show",
help_show: "help.show",
docs_open: "docs.open",
editor_open: "prompt.editor",
theme_list: "theme.switch",
theme_switch_mode: "theme.switch_mode",
theme_mode_lock: "theme.mode.lock",
sidebar_toggle: "session.sidebar.toggle",
scrollbar_toggle: "session.toggle.scrollbar",
status_view: "opencode.status",
session_export: "session.export",
session_copy: "session.copy",
session_new: "session.new",
session_list: "session.list",
session_timeline: "session.timeline",
session_fork: "session.fork",
session_rename: "session.rename",
session_delete: "session.delete",
session_share: "session.share",
session_unshare: "session.unshare",
session_interrupt: "session.interrupt",
session_compact: "session.compact",
session_toggle_timestamps: "session.toggle.timestamps",
session_toggle_generic_tool_output: "session.toggle.generic_tool_output",
session_child_first: "session.child.first",
session_child_cycle: "session.child.next",
session_child_cycle_reverse: "session.child.previous",
session_parent: "session.parent",
stash_delete: "stash.delete",
model_provider_list: "model.dialog.provider",
model_favorite_toggle: "model.dialog.favorite",
model_list: "model.list",
model_cycle_recent: "model.cycle_recent",
model_cycle_recent_reverse: "model.cycle_recent_reverse",
model_cycle_favorite: "model.cycle_favorite",
model_cycle_favorite_reverse: "model.cycle_favorite_reverse",
mcp_list: "mcp.list",
provider_connect: "provider.connect",
console_org_switch: "console.org.switch",
agent_list: "agent.list",
agent_cycle: "agent.cycle",
agent_cycle_reverse: "agent.cycle.reverse",
variant_cycle: "variant.cycle",
variant_list: "variant.list",
messages_page_up: "session.page.up",
messages_page_down: "session.page.down",
messages_line_up: "session.line.up",
messages_line_down: "session.line.down",
messages_half_page_up: "session.half.page.up",
messages_half_page_down: "session.half.page.down",
messages_first: "session.first",
messages_last: "session.last",
messages_next: "session.message.next",
messages_previous: "session.message.previous",
messages_last_user: "session.messages_last_user",
messages_copy: "messages.copy",
messages_undo: "session.undo",
messages_redo: "session.redo",
messages_toggle_conceal: "session.toggle.conceal",
tool_details: "session.toggle.actions",
display_thinking: "session.toggle.thinking",
prompt_submit: "prompt.submit",
prompt_editor_context_clear: "prompt.editor_context.clear",
prompt_skills: "prompt.skills",
prompt_stash: "prompt.stash",
prompt_stash_pop: "prompt.stash.pop",
prompt_stash_list: "prompt.stash.list",
workspace_set: "workspace.set",
input_clear: "prompt.clear",
input_paste: "prompt.paste",
input_submit: "input.submit",
input_newline: "input.newline",
input_move_left: "input.move.left",
input_move_right: "input.move.right",
input_move_up: "input.move.up",
input_move_down: "input.move.down",
input_select_left: "input.select.left",
input_select_right: "input.select.right",
input_select_up: "input.select.up",
input_select_down: "input.select.down",
input_line_home: "input.line.home",
input_line_end: "input.line.end",
input_select_line_home: "input.select.line.home",
input_select_line_end: "input.select.line.end",
input_visual_line_home: "input.visual.line.home",
input_visual_line_end: "input.visual.line.end",
input_select_visual_line_home: "input.select.visual.line.home",
input_select_visual_line_end: "input.select.visual.line.end",
input_buffer_home: "input.buffer.home",
input_buffer_end: "input.buffer.end",
input_select_buffer_home: "input.select.buffer.home",
input_select_buffer_end: "input.select.buffer.end",
input_delete_line: "input.delete.line",
input_delete_to_line_end: "input.delete.to.line.end",
input_delete_to_line_start: "input.delete.to.line.start",
input_backspace: "input.backspace",
input_delete: "input.delete",
input_undo: "input.undo",
input_redo: "input.redo",
input_word_forward: "input.word.forward",
input_word_backward: "input.word.backward",
input_select_word_forward: "input.select.word.forward",
input_select_word_backward: "input.select.word.backward",
input_delete_word_forward: "input.delete.word.forward",
input_delete_word_backward: "input.delete.word.backward",
input_select_all: "input.select.all",
history_previous: "prompt.history.previous",
history_next: "prompt.history.next",
terminal_suspend: "terminal.suspend",
terminal_title_toggle: "terminal.title.toggle",
tips_toggle: "tips.toggle",
plugin_manager: "plugins.list",
plugin_install: "plugins.install",
which_key_toggle: "which-key.toggle",
which_key_layout_toggle: "which-key.layout.toggle",
which_key_pending_toggle: "which-key.pending.toggle",
which_key_group_previous: "which-key.group.previous",
which_key_group_next: "which-key.group.next",
which_key_scroll_up: "which-key.scroll.up",
which_key_scroll_down: "which-key.scroll.down",
which_key_page_up: "which-key.page.up",
which_key_page_down: "which-key.page.down",
which_key_home: "which-key.home",
which_key_end: "which-key.end",
} satisfies BindingCommandMap
const CommandDescriptions = Object.fromEntries(
Object.entries(Definitions).map(([name, item]) => [
CommandMap[name as keyof typeof CommandMap] ?? name,
item.description,
]),
) as Record<string, string>
export type Keybinds = z.output<typeof Keybinds>
export type KeybindOverrides = z.output<typeof KeybindOverrides>
export type BindingLookupView = {
readonly bindings: readonly Binding<Renderable, KeyEvent>[]
get(command: string): readonly Binding<Renderable, KeyEvent>[]
has(command: string): boolean
gather(name: string, commands: readonly string[]): readonly Binding<Renderable, KeyEvent>[]
pick(name: string, commands: readonly string[]): Binding<Renderable, KeyEvent>[]
omit(name: string, commands: readonly string[]): Binding<Renderable, KeyEvent>[]
}
export function toBindingConfig(keybinds: Keybinds): BindingConfig<Renderable, KeyEvent> {
return Object.fromEntries(Object.entries(keybinds)) as BindingConfig<Renderable, KeyEvent>
}
export function bindingDefaults(): BindingDefaults<Renderable, KeyEvent> {
return ({ command, binding }) => {
if (binding.desc !== undefined) return
return { desc: CommandDescriptions[command] }
}
}

View File

@@ -1,187 +0,0 @@
import type { KeyEvent, Renderable } from "@opentui/core"
import type { Binding } from "@opentui/keymap"
import type { BindingValue } from "@opentui/keymap/extras"
import { ConfigKeybinds } from "@/config/keybinds"
import { type KeymapConfigInput, type KeymapSection } from "./tui-schema"
type LegacyKeybinds = Partial<ConfigKeybinds.Keybinds>
type SectionsConfig = Record<string, Record<string, BindingValue<Renderable, KeyEvent>>>
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<Record<keyof LegacyKeybinds, string>>
function add(
config: SectionsConfig,
section: KeymapSection,
command: string,
binding: BindingValue<Renderable, KeyEvent> | undefined,
) {
if (binding === undefined) return
config[section] ??= {}
config[section][command] = binding
}
function bindingWith(key: string | undefined, input: Omit<Binding<Renderable, KeyEvent>, "key" | "cmd">) {
if (!key) return undefined
if (key === "none") return "none"
return { ...input, key }
}
function combineBindings(...keys: (string | undefined)[]) {
const result = Array.from(
new Set(
keys.flatMap((key) => {
if (!key || key === "none") return []
return key
.split(",")
.map((part) => part.trim())
.filter((part) => part && part !== "none")
}),
),
)
if (result.length) return result.join(",")
if (keys.some((key) => key === "none")) return "none"
return undefined
}
export function create(keybinds: LegacyKeybinds): KeymapConfigInput {
const config: SectionsConfig = {}
add(config, "global", "command.palette.show", keybinds.command_list)
add(config, "global", "session.list", keybinds.session_list)
add(config, "global", "session.new", keybinds.session_new)
add(config, "global", "model.list", keybinds.model_list)
add(config, "global", "model.cycle_recent", keybinds.model_cycle_recent)
add(config, "global", "model.cycle_recent_reverse", keybinds.model_cycle_recent_reverse)
add(config, "global", "model.cycle_favorite", keybinds.model_cycle_favorite)
add(config, "global", "model.cycle_favorite_reverse", keybinds.model_cycle_favorite_reverse)
add(config, "global", "agent.list", keybinds.agent_list)
add(config, "global", "agent.cycle", keybinds.agent_cycle)
add(config, "global", "agent.cycle.reverse", keybinds.agent_cycle_reverse)
add(config, "global", "variant.cycle", keybinds.variant_cycle)
add(config, "global", "variant.list", keybinds.variant_list)
add(config, "prompt", "prompt.editor", keybinds.editor_open)
add(config, "global", "opencode.status", keybinds.status_view)
add(config, "global", "theme.switch", keybinds.theme_list)
add(config, "global", "app.exit", keybinds.app_exit)
add(config, "global", "terminal.suspend", keybinds.terminal_suspend)
add(config, "global", "terminal.title.toggle", keybinds.terminal_title_toggle)
add(config, "session", "session.share", keybinds.session_share)
add(config, "session", "session.rename", keybinds.session_rename)
add(config, "session", "session.timeline", keybinds.session_timeline)
add(config, "session", "session.fork", keybinds.session_fork)
add(config, "session", "session.compact", keybinds.session_compact)
add(config, "session", "session.unshare", keybinds.session_unshare)
add(config, "session", "session.undo", keybinds.messages_undo)
add(config, "session", "session.redo", keybinds.messages_redo)
add(config, "session", "session.sidebar.toggle", keybinds.sidebar_toggle)
add(config, "session", "session.toggle.conceal", keybinds.messages_toggle_conceal)
add(config, "session", "session.toggle.thinking", keybinds.display_thinking)
add(config, "session", "session.toggle.actions", keybinds.tool_details)
add(config, "session", "session.toggle.scrollbar", keybinds.scrollbar_toggle)
add(config, "session", "session.page.up", keybinds.messages_page_up)
add(config, "session", "session.page.down", keybinds.messages_page_down)
add(config, "session", "session.line.up", keybinds.messages_line_up)
add(config, "session", "session.line.down", keybinds.messages_line_down)
add(config, "session", "session.half.page.up", keybinds.messages_half_page_up)
add(config, "session", "session.half.page.down", keybinds.messages_half_page_down)
add(config, "session", "session.first", keybinds.messages_first)
add(config, "session", "session.last", keybinds.messages_last)
add(config, "session", "session.messages_last_user", keybinds.messages_last_user)
add(config, "session", "session.message.next", keybinds.messages_next)
add(config, "session", "session.message.previous", keybinds.messages_previous)
add(config, "session", "messages.copy", keybinds.messages_copy)
add(config, "session", "session.export", keybinds.session_export)
add(config, "session", "session.child.first", keybinds.session_child_first)
add(config, "session", "session.parent", keybinds.session_parent)
add(config, "session", "session.child.next", keybinds.session_child_cycle)
add(config, "session", "session.child.previous", keybinds.session_child_cycle_reverse)
add(config, "prompt", "session.interrupt", keybinds.session_interrupt)
add(config, "prompt", "prompt.clear", keybinds.input_clear)
add(config, "prompt", "prompt.paste", bindingWith(keybinds.input_paste, { preventDefault: false }))
add(config, "prompt", "prompt.history.previous", keybinds.history_previous)
add(config, "prompt", "prompt.history.next", keybinds.history_next)
add(config, "autocomplete", "prompt.autocomplete.prev", keybinds["prompt.autocomplete.prev"])
add(config, "autocomplete", "prompt.autocomplete.next", keybinds["prompt.autocomplete.next"])
add(config, "autocomplete", "prompt.autocomplete.hide", keybinds["prompt.autocomplete.hide"])
add(config, "autocomplete", "prompt.autocomplete.select", keybinds["prompt.autocomplete.select"])
add(config, "autocomplete", "prompt.autocomplete.complete", keybinds["prompt.autocomplete.complete"])
for (const [legacy, command] of Object.entries(inputCommands) as [keyof typeof inputCommands, string][]) {
add(config, "input", command, keybinds[legacy])
}
add(config, "dialog_select", "dialog.select.prev", keybinds["dialog.select.prev"])
add(config, "dialog_select", "dialog.select.next", keybinds["dialog.select.next"])
add(config, "dialog_select", "dialog.select.page_up", keybinds["dialog.select.page_up"])
add(config, "dialog_select", "dialog.select.page_down", keybinds["dialog.select.page_down"])
add(config, "dialog_select", "dialog.select.home", keybinds["dialog.select.home"])
add(config, "dialog_select", "dialog.select.end", keybinds["dialog.select.end"])
add(config, "dialog_select", "dialog.select.submit", keybinds["dialog.select.submit"])
add(config, "dialog_actions", "dialog.action.delete", combineBindings(keybinds.stash_delete, keybinds.session_delete))
add(config, "dialog_actions", "dialog.action.rename", keybinds.session_rename)
add(
config,
"dialog_actions",
"dialog.action.toggle",
combineBindings(keybinds["dialog.mcp.toggle"], keybinds["plugins.toggle"]),
)
add(config, "model", "model.dialog.provider", keybinds.model_provider_list)
add(config, "model", "model.dialog.favorite", keybinds.model_favorite_toggle)
add(config, "permission", "permission.reject.cancel", keybinds.app_exit)
add(config, "permission", "permission.prompt.escape", keybinds.app_exit)
add(config, "permission", "permission.prompt.fullscreen", keybinds["permission.prompt.fullscreen"])
add(config, "question", "question.reject", keybinds.app_exit)
add(config, "question", "question.edit.clear", keybinds.input_clear)
add(config, "plugins", "plugins.list", keybinds.plugin_manager)
add(config, "plugins", "plugin.dialog.install", keybinds["dialog.plugins.install"])
add(config, "home_tips", "tips.toggle", keybinds.tips_toggle)
return {
...(keybinds.leader && keybinds.leader !== "none" && { leader: keybinds.leader }),
sections: config,
}
}
export * as LegacyKeymapTransform from "./legacy-keymap-transform"

View File

@@ -1,339 +1,12 @@
import z from "zod" import z from "zod"
import type { KeyEvent, Renderable } from "@opentui/core"
import type { Binding } from "@opentui/keymap"
import type { ResolvedBindingSections } from "@opentui/keymap/extras"
import { ConfigPlugin } from "@/config/plugin" import { ConfigPlugin } from "@/config/plugin"
import { ConfigKeybinds } from "@/config/keybinds" import { TuiKeybind } from "./keybind"
const KeybindOverride = z
.object(
Object.fromEntries(Object.keys(ConfigKeybinds.Keybinds.shape).map((key) => [key, z.string().optional()])) as Record<
string,
z.ZodOptional<z.ZodString>
>,
)
.strict()
const KeyStroke = z
.object({
name: z.string(),
ctrl: z.boolean().optional(),
shift: z.boolean().optional(),
meta: z.boolean().optional(),
super: z.boolean().optional(),
hyper: z.boolean().optional(),
})
.strict()
const KeymapBindingObject = z
.object({
key: z.union([z.string(), KeyStroke]),
event: z.enum(["press", "release"]).optional(),
preventDefault: z.boolean().optional(),
fallthrough: z.boolean().optional(),
})
.passthrough()
const KeymapBindingItem = z.union([z.string(), KeyStroke, KeymapBindingObject])
const KeymapBindingValue = z.union([z.literal(false), z.literal("none"), KeymapBindingItem, z.array(KeymapBindingItem)])
const keymapBinding = (value: z.input<typeof KeymapBindingValue> | (() => z.input<typeof KeymapBindingValue>)) =>
KeymapBindingValue.prefault(value)
const keymapSection = <Shape extends z.ZodRawShape>(shape: Shape) => {
const schema = z.object(shape).strict()
return schema.prefault({} as z.input<typeof schema>)
}
const keymapSectionInput = <Shape extends z.ZodRawShape>(shape: Shape) =>
z
.object(
Object.fromEntries(Object.keys(shape).map((key) => [key, KeymapBindingValue.optional()])) as {
[Key in keyof Shape]: z.ZodOptional<typeof KeymapBindingValue>
},
)
.strict()
const GlobalKeymapSection = {
"command.palette.show": keymapBinding("ctrl+p"),
"session.list": keymapBinding("<leader>l"),
"session.new": keymapBinding("<leader>n"),
"model.list": keymapBinding("<leader>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("<leader>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("<leader>s"),
"theme.switch": keymapBinding("<leader>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,<leader>q"),
"app.debug": keymapBinding("none"),
"app.console": keymapBinding("none"),
"app.heap_snapshot": keymapBinding("none"),
"app.toggle.animations": keymapBinding("none"),
"app.toggle.file_context": keymapBinding("none"),
"app.toggle.diffwrap": keymapBinding("none"),
"app.toggle.paste_summary": keymapBinding("none"),
"app.toggle.session_directory_filter": keymapBinding("none"),
"terminal.suspend": keymapBinding(() => (process.platform === "win32" ? "none" : "ctrl+z")),
"terminal.title.toggle": keymapBinding("none"),
}
const WhichKeyKeymapSection = {
"tui-which-key.toggle": keymapBinding("ctrl+alt+k"),
"tui-which-key.layout.toggle": keymapBinding("ctrl+alt+shift+k"),
"tui-which-key.pending.toggle": keymapBinding("ctrl+alt+shift+p"),
"tui-which-key.group.previous": keymapBinding("ctrl+alt+left,ctrl+alt+["),
"tui-which-key.group.next": keymapBinding("ctrl+alt+right,ctrl+alt+]"),
"tui-which-key.scroll.up": keymapBinding("ctrl+alt+up,ctrl+alt+p"),
"tui-which-key.scroll.down": keymapBinding("ctrl+alt+down,ctrl+alt+n"),
"tui-which-key.page.up": keymapBinding("ctrl+alt+pageup"),
"tui-which-key.page.down": keymapBinding("ctrl+alt+pagedown"),
"tui-which-key.home": keymapBinding("ctrl+alt+home"),
"tui-which-key.end": keymapBinding("ctrl+alt+end"),
}
const SessionKeymapSection = {
"session.share": keymapBinding("none"),
"session.rename": keymapBinding("ctrl+r"),
"session.timeline": keymapBinding("<leader>g"),
"session.fork": keymapBinding("none"),
"session.compact": keymapBinding("<leader>c"),
"session.unshare": keymapBinding("none"),
"session.undo": keymapBinding("<leader>u"),
"session.redo": keymapBinding("<leader>r"),
"session.sidebar.toggle": keymapBinding("<leader>b"),
"session.toggle.conceal": keymapBinding("<leader>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("<leader>y"),
"session.copy": keymapBinding("none"),
"session.export": keymapBinding("<leader>x"),
"session.child.first": keymapBinding("<leader>down"),
"session.parent": keymapBinding("up"),
"session.child.next": keymapBinding("right"),
"session.child.previous": keymapBinding("left"),
}
const PromptKeymapSection = {
"prompt.submit": keymapBinding("none"),
"prompt.editor": keymapBinding("<leader>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,<leader>q"),
"permission.prompt.escape": keymapBinding("ctrl+c,ctrl+d,<leader>q"),
"permission.prompt.fullscreen": keymapBinding("ctrl+f"),
}
const QuestionKeymapSection = {
"question.reject": keymapBinding("ctrl+c,ctrl+d,<leader>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("<leader>h"),
}
const KeymapSectionsShape = {
global: keymapSection(GlobalKeymapSection),
which_key: keymapSection(WhichKeyKeymapSection),
session: keymapSection(SessionKeymapSection),
prompt: keymapSection(PromptKeymapSection),
autocomplete: keymapSection(AutocompleteKeymapSection),
input: keymapSection(InputKeymapSection),
dialog_select: keymapSection(DialogSelectKeymapSection),
dialog_actions: keymapSection(DialogActionsKeymapSection),
model: keymapSection(ModelKeymapSection),
permission: keymapSection(PermissionKeymapSection),
question: keymapSection(QuestionKeymapSection),
plugins: keymapSection(PluginsKeymapSection),
home_tips: keymapSection(HomeTipsKeymapSection),
}
const KeymapSectionsInputShape = {
global: keymapSectionInput(GlobalKeymapSection).optional(),
which_key: keymapSectionInput(WhichKeyKeymapSection).optional(),
session: keymapSectionInput(SessionKeymapSection).optional(),
prompt: keymapSectionInput(PromptKeymapSection).optional(),
autocomplete: keymapSectionInput(AutocompleteKeymapSection).optional(),
input: keymapSectionInput(InputKeymapSection).optional(),
dialog_select: keymapSectionInput(DialogSelectKeymapSection).optional(),
dialog_actions: keymapSectionInput(DialogActionsKeymapSection).optional(),
model: keymapSectionInput(ModelKeymapSection).optional(),
permission: keymapSectionInput(PermissionKeymapSection).optional(),
question: keymapSectionInput(QuestionKeymapSection).optional(),
plugins: keymapSectionInput(PluginsKeymapSection).optional(),
home_tips: keymapSectionInput(HomeTipsKeymapSection).optional(),
}
export const KeymapSections = z.object(KeymapSectionsShape).strict().prefault({})
export type KeymapSections = z.output<typeof KeymapSections>
export type KeymapSection = keyof KeymapSections
export const KeymapSectionNames = Object.keys(KeymapSectionsShape) as KeymapSection[]
export const KeymapLeaderTimeoutDefault = 2000 export const KeymapLeaderTimeoutDefault = 2000
export type KeymapInfo = { const KeymapLeaderTimeout = z.number().int().positive().describe("Leader key timeout in milliseconds")
leader: string
leader_timeout: number
} & ResolvedBindingSections<Renderable, KeyEvent, KeymapSection>
export const KeymapSectionGroups = {
global: "Global",
which_key: "System",
session: "Session",
prompt: "Prompt",
autocomplete: "Autocomplete",
input: "Text Editing",
dialog_select: "Dialog",
dialog_actions: "Dialog",
model: "Model",
permission: "Permission",
question: "Question",
plugins: "Plugins",
home_tips: "Home",
} satisfies Record<KeymapSection, string>
export function keymapBindingDefaults(input: { section: string; binding: Readonly<Binding<Renderable, KeyEvent>> }) {
if (input.binding.group !== undefined) return
if (!Object.hasOwn(KeymapSectionGroups, input.section)) return
return { group: KeymapSectionGroups[input.section as KeymapSection] }
}
export const KeymapConfig = z
.object({
leader: z.string().prefault("ctrl+x"),
leader_timeout: z
.number()
.int()
.positive()
.prefault(KeymapLeaderTimeoutDefault)
.describe("Leader key timeout in milliseconds"),
sections: KeymapSections,
})
.strict()
.describe("TUI keymap configuration")
export type KeymapConfig = z.output<typeof KeymapConfig>
const KeymapSectionsInput = z.object(KeymapSectionsInputShape).strict().optional()
export const KeymapConfigInput = z
.object({
leader: z.string().optional(),
leader_timeout: z.number().int().positive().optional().describe("Leader key timeout in milliseconds"),
sections: KeymapSectionsInput,
})
.strict()
.describe("TUI keymap configuration")
export type KeymapConfigInput = z.output<typeof KeymapConfigInput>
export const TuiOptions = z.object({ export const TuiOptions = z.object({
leader_timeout: KeymapLeaderTimeout.optional(),
scroll_speed: z.number().min(0.001).optional().describe("TUI scroll speed"), scroll_speed: z.number().min(0.001).optional().describe("TUI scroll speed"),
scroll_acceleration: z scroll_acceleration: z
.object({ .object({
@@ -352,17 +25,11 @@ export const TuiInfo = z
.object({ .object({
$schema: z.string().optional(), $schema: z.string().optional(),
theme: z.string().optional(), theme: z.string().optional(),
keybinds: KeybindOverride.optional().meta({ keybinds: TuiKeybind.KeybindOverrides.optional(),
deprecated: true,
description: "Use keymap instead. This will be removed in opencode v2.0.",
}),
keymap: KeymapConfigInput.optional(),
plugin: ConfigPlugin.Spec.zod.array().optional(), plugin: ConfigPlugin.Spec.zod.array().optional(),
plugin_enabled: z.record(z.string(), z.boolean()).optional(), plugin_enabled: z.record(z.string(), z.boolean()).optional(),
}) })
.extend(TuiOptions.shape) .extend(TuiOptions.shape)
.strict() .strict()
export const TuiJsonSchemaInfo = TuiInfo.extend({ export const TuiJsonSchemaInfo = TuiInfo
keymap: KeymapConfig.optional(),
}).strict()

View File

@@ -1,29 +1,26 @@
export * as TuiConfig from "./tui" export * as TuiConfig from "./tui"
import type z from "zod" import type z from "zod"
import type { KeyEvent, Renderable } from "@opentui/core" import { createBindingLookup } from "@opentui/keymap/extras"
import { resolveBindingSections, type BindingSectionsConfig } from "@opentui/keymap/extras"
import { mergeDeep, unique } from "remeda" import { mergeDeep, unique } from "remeda"
import { Context, Effect, Fiber, Layer } from "effect" import { Context, Effect, Fiber, Layer } from "effect"
import { ConfigParse } from "@/config/parse" import { ConfigParse } from "@/config/parse"
import * as ConfigPaths from "@/config/paths" import * as ConfigPaths from "@/config/paths"
import { migrateTuiConfig } from "./tui-migrate" import { migrateTuiConfig } from "./tui-migrate"
import { KeymapConfig, TuiInfo, TuiJsonSchemaInfo } from "./tui-schema" import { KeymapLeaderTimeoutDefault, TuiInfo, TuiJsonSchemaInfo } from "./tui-schema"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { isRecord } from "@/util/record" import { isRecord } from "@/util/record"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { AppFileSystem } from "@opencode-ai/core/filesystem" import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { CurrentWorkingDirectory } from "./cwd" import { CurrentWorkingDirectory } from "./cwd"
import { ConfigPlugin } from "@/config/plugin" import { ConfigPlugin } from "@/config/plugin"
import { ConfigKeybinds } from "@/config/keybinds" import { TuiKeybind } from "./keybind"
import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version" import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version"
import { makeRuntime } from "@opencode-ai/core/effect/runtime" import { makeRuntime } from "@opencode-ai/core/effect/runtime"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
import { ConfigVariable } from "@/config/variable" import { ConfigVariable } from "@/config/variable"
import { Npm } from "@opencode-ai/core/npm" import { Npm } from "@opencode-ai/core/npm"
import { LegacyKeymapTransform } from "./legacy-keymap-transform"
import { KeymapSectionNames, keymapBindingDefaults, type KeymapInfo, type KeymapSection } from "./tui-schema"
const log = Log.create({ service: "tui.config" }) const log = Log.create({ service: "tui.config" })
@@ -36,9 +33,9 @@ type Acc = {
plugin_origins: ConfigPlugin.Origin[] plugin_origins: ConfigPlugin.Origin[]
} }
export type Resolved = Omit<Info, "keybinds" | "keymap"> & { export type Resolved = Omit<Info, "keybinds" | "leader_timeout"> & {
keybinds: ConfigKeybinds.Keybinds keybinds: TuiKeybind.BindingLookupView
keymap: KeymapInfo leader_timeout: number
// Internal resolved plugin list used by runtime loading. // Internal resolved plugin list used by runtime loading.
plugin_origins?: ConfigPlugin.Origin[] plugin_origins?: ConfigPlugin.Origin[]
} }
@@ -186,31 +183,18 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
keybinds.terminal_suspend = "none" keybinds.terminal_suspend = "none"
keybinds.input_undo ??= unique([ keybinds.input_undo ??= unique([
"ctrl+z", "ctrl+z",
...ConfigKeybinds.Keybinds.shape.input_undo.parse(undefined).split(","), ...String(TuiKeybind.Keybinds.shape.input_undo.parse(undefined)).split(","),
]).join(",") ]).join(",")
} }
const parsedKeybinds = ConfigKeybinds.Keybinds.parse(keybinds) const parsedKeybinds = TuiKeybind.Keybinds.parse(keybinds)
const keymapInput = acc.result.keymap ?? LegacyKeymapTransform.create(acc.result.keybinds ?? {})
const keymapConfig = KeymapConfig.parse(keymapInput)
const keymap = {
leader: !keymapConfig.leader || keymapConfig.leader === "none" ? "ctrl+x" : keymapConfig.leader,
leader_timeout: keymapConfig.leader_timeout,
...resolveBindingSections<Renderable, KeyEvent, BindingSectionsConfig<Renderable, KeyEvent>, KeymapSection>(
keymapConfig.sections,
{
sections: KeymapSectionNames,
bindingDefaults: keymapBindingDefaults,
},
),
}
const result: Resolved = { const result: Resolved = {
...acc.result, ...acc.result,
keybinds: parsedKeybinds, keybinds: createBindingLookup(TuiKeybind.toBindingConfig(parsedKeybinds), {
commandMap: TuiKeybind.CommandMap,
bindingDefaults: TuiKeybind.bindingDefaults(),
}),
leader_timeout: acc.result.leader_timeout ?? KeymapLeaderTimeoutDefault,
plugin_origins: acc.plugin_origins.length ? acc.plugin_origins : undefined, plugin_origins: acc.plugin_origins.length ? acc.plugin_origins : undefined,
// `keybinds` is deprecated and will be removed in opencode v2.0. Keep it
// only as the legacy fallback; once `keymap` is configured, ignore
// `keybinds` for keymap resolution.
keymap,
} }
return { return {

View File

@@ -20,7 +20,7 @@ function View(props: { api: TuiPluginApi; hidden: boolean; show: boolean; connec
}, },
}, },
], ],
bindings: props.api.tuiConfig.keymap.sections.home_tips, bindings: props.api.tuiConfig.keybinds.get("tips.toggle"),
})) }))
return ( return (

View File

@@ -207,7 +207,7 @@ function View(props: { api: TuiPluginApi }) {
actions={[ actions={[
{ {
title: "toggle", title: "toggle",
command: "dialog.action.toggle", command: "plugins.toggle",
disabled: lock(), disabled: lock(),
onTrigger: (item) => { onTrigger: (item) => {
setCur(item.value) setCur(item.value)
@@ -216,14 +216,13 @@ function View(props: { api: TuiPluginApi }) {
}, },
{ {
title: "install", title: "install",
command: "plugin.dialog.install", command: "dialog.plugins.install",
disabled: lock(), disabled: lock(),
onTrigger: () => { onTrigger: () => {
showInstall(props.api) showInstall(props.api)
}, },
}, },
]} ]}
bindings={props.api.tuiConfig.keymap.pick("plugins", ["plugin.dialog.install"])}
onSelect={(item) => { onSelect={(item) => {
setCur(item.value) setCur(item.value)
flip(item.value) flip(item.value)
@@ -258,7 +257,7 @@ const tui: TuiPlugin = async (api) => {
}, },
}, },
], ],
bindings: api.tuiConfig.keymap.omit("plugins", ["plugin.dialog.install"]), bindings: api.tuiConfig.keybinds.gather("plugins.palette", ["plugins.list", "plugins.install"]),
}) })
} }

View File

@@ -8,17 +8,17 @@ import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { InternalTuiPlugin } from "../../plugin/internal" import type { InternalTuiPlugin } from "../../plugin/internal"
const command = { const command = {
toggle: "tui-which-key.toggle", toggle: "which-key.toggle",
toggleLayout: "tui-which-key.layout.toggle", toggleLayout: "which-key.layout.toggle",
togglePending: "tui-which-key.pending.toggle", togglePending: "which-key.pending.toggle",
groupPrevious: "tui-which-key.group.previous", groupPrevious: "which-key.group.previous",
groupNext: "tui-which-key.group.next", groupNext: "which-key.group.next",
scrollUp: "tui-which-key.scroll.up", scrollUp: "which-key.scroll.up",
scrollDown: "tui-which-key.scroll.down", scrollDown: "which-key.scroll.down",
pageUp: "tui-which-key.page.up", pageUp: "which-key.page.up",
pageDown: "tui-which-key.page.down", pageDown: "which-key.page.down",
home: "tui-which-key.home", home: "which-key.home",
end: "tui-which-key.end", end: "which-key.end",
} as const } as const
const LAYER_PRIORITY = 900 const LAYER_PRIORITY = 900
@@ -112,8 +112,7 @@ function skin(api: TuiPluginApi): Skin {
} }
function activeKeyLabel(active: ActiveKey<Renderable, KeyEvent>) { function activeKeyLabel(active: ActiveKey<Renderable, KeyEvent>) {
const group = text(active.bindingAttrs?.group) if (active.continues) return text(active.tokenName) ?? text(active.display) ?? UNKNOWN
if (active.continues) return group ?? text(active.tokenName) ?? UNKNOWN
return ( return (
text(active.commandAttrs?.title) ?? text(active.bindingAttrs?.desc) ?? text(active.commandAttrs?.desc) ?? UNKNOWN text(active.commandAttrs?.title) ?? text(active.bindingAttrs?.desc) ?? text(active.commandAttrs?.desc) ?? UNKNOWN
) )
@@ -361,7 +360,9 @@ function WhichKeyPanel(props: {
}, },
}, },
], ],
bindings: props.api.tuiConfig.keymap.pick("which_key", pendingMode() ? scrollCommands : panelCommands), bindings: pendingMode()
? props.api.tuiConfig.keybinds.gather("which-key.scroll", scrollCommands)
: props.api.tuiConfig.keybinds.gather("which-key.panel", panelCommands),
})) }))
createEffect(() => { createEffect(() => {
@@ -571,7 +572,7 @@ const tui: TuiPlugin = async (api) => {
}, },
}, },
], ],
bindings: api.tuiConfig.keymap.pick("which_key", toggleCommands), bindings: api.tuiConfig.keybinds.gather("which-key.toggle", toggleCommands),
}) })
api.slots.register({ api.slots.register({
@@ -599,7 +600,7 @@ const tui: TuiPlugin = async (api) => {
} }
const plugin: InternalTuiPlugin = { const plugin: InternalTuiPlugin = {
id: "tui-which-key", id: "which-key",
enabled: false, enabled: false,
tui, tui,
} }

View File

@@ -1,5 +1,6 @@
import { type CliRenderer } from "@opentui/core" import { type CliRenderer } from "@opentui/core"
import * as addons from "@opentui/keymap/addons/opentui" import * as addons from "@opentui/keymap/addons/opentui"
import { stringifyKeyStroke } from "@opentui/keymap"
import { import {
formatCommandBindings as formatCommandBindingsExtra, formatCommandBindings as formatCommandBindingsExtra,
formatKeySequence as formatKeySequenceExtra, formatKeySequence as formatKeySequenceExtra,
@@ -14,6 +15,7 @@ import {
import type { Accessor } from "solid-js" import type { Accessor } from "solid-js"
import type { TuiConfig } from "./config/tui" import type { TuiConfig } from "./config/tui"
import { useTuiConfig } from "./context/tui-config" import { useTuiConfig } from "./context/tui-config"
import { TuiKeybind } from "./config/keybind"
export const LEADER_TOKEN = "leader" export const LEADER_TOKEN = "leader"
@@ -24,10 +26,55 @@ export { reactiveMatcherFromSignal, useBindings, useKeymapSelector }
export type OpenTuiKeymap = ReturnType<typeof useKeymap> export type OpenTuiKeymap = ReturnType<typeof useKeymap>
const inputCommands = [
"input.move.left",
"input.move.right",
"input.move.up",
"input.move.down",
"input.select.left",
"input.select.right",
"input.select.up",
"input.select.down",
"input.line.home",
"input.line.end",
"input.select.line.home",
"input.select.line.end",
"input.visual.line.home",
"input.visual.line.end",
"input.select.visual.line.home",
"input.select.visual.line.end",
"input.buffer.home",
"input.buffer.end",
"input.select.buffer.home",
"input.select.buffer.end",
"input.delete.line",
"input.delete.to.line.end",
"input.delete.to.line.start",
"input.backspace",
"input.delete",
"input.newline",
"input.undo",
"input.redo",
"input.word.forward",
"input.word.backward",
"input.select.word.forward",
"input.select.word.backward",
"input.delete.word.forward",
"input.delete.word.backward",
"input.select.all",
"input.submit",
] as const
function leaderDisplay(config: TuiConfig.Resolved) {
const key = config.keybinds.get(LEADER_TOKEN)?.[0]?.key
if (!key) return TuiKeybind.LeaderDefault
return typeof key === "string" ? key : stringifyKeyStroke(key)
}
function formatOptions(config: TuiConfig.Resolved) { function formatOptions(config: TuiConfig.Resolved) {
return { return {
tokenDisplay: { tokenDisplay: {
[LEADER_TOKEN]: config.keymap.leader, [LEADER_TOKEN]: leaderDisplay(config),
}, },
keyNameAliases: { keyNameAliases: {
pageup: "pgup", pageup: "pgup",
@@ -55,19 +102,23 @@ export function registerOpencodeKeymap(keymap: OpenTuiKeymap, renderer: CliRende
const offCommaBindings = addons.registerCommaBindings(keymap) const offCommaBindings = addons.registerCommaBindings(keymap)
const offBaseLayout = addons.registerBaseLayoutFallback(keymap) const offBaseLayout = addons.registerBaseLayoutFallback(keymap)
const offLeader = addons.registerTimedLeader(keymap, { const offLeader = addons.registerTimedLeader(keymap, {
trigger: config.keymap.leader, trigger: config.keybinds.get(LEADER_TOKEN),
name: LEADER_TOKEN, name: LEADER_TOKEN,
timeoutMs: config.keymap.leader_timeout, timeoutMs: config.leader_timeout,
}) })
const offEscape = addons.registerEscapeClearsPendingSequence(keymap) const offEscape = addons.registerEscapeClearsPendingSequence(keymap)
const offBackspace = addons.registerBackspacePopsPendingSequence(keymap) const offBackspace = addons.registerBackspacePopsPendingSequence(keymap)
const offInputBindings = addons.registerManagedTextareaLayer(keymap, renderer, { const offInputCommands = addons.registerEditBufferCommands(keymap, renderer)
const offInputSuspension = addons.registerTextareaMappingSuspension(keymap, renderer)
const offInputBindings = keymap.registerLayer({
enabled: () => renderer.currentFocusedEditor !== null, enabled: () => renderer.currentFocusedEditor !== null,
bindings: config.keymap.sections.input, bindings: config.keybinds.gather("input", inputCommands),
}) })
return () => { return () => {
offInputBindings() offInputBindings()
offInputSuspension()
offInputCommands()
offBackspace() offBackspace()
offEscape() offEscape()
offLeader() offLeader()

View File

@@ -117,6 +117,42 @@ function goUpsellKeys(action: SessionRetry.Retryable["action"]) {
} }
} }
const sessionBindingCommands = [
"session.share",
"session.rename",
"session.timeline",
"session.fork",
"session.compact",
"session.unshare",
"session.undo",
"session.redo",
"session.sidebar.toggle",
"session.toggle.conceal",
"session.toggle.timestamps",
"session.toggle.thinking",
"session.toggle.actions",
"session.toggle.scrollbar",
"session.toggle.generic_tool_output",
"session.page.up",
"session.page.down",
"session.line.up",
"session.line.down",
"session.half.page.up",
"session.half.page.down",
"session.first",
"session.last",
"session.messages_last_user",
"session.message.next",
"session.message.previous",
"messages.copy",
"session.copy",
"session.export",
"session.child.first",
"session.parent",
"session.child.next",
"session.child.previous",
] as const
const context = createContext<{ const context = createContext<{
width: number width: number
sessionID: string sessionID: string
@@ -144,9 +180,6 @@ export function Session() {
const event = useEvent() const event = useEvent()
const project = useProject() const project = useProject()
const tuiConfig = useTuiConfig() const tuiConfig = useTuiConfig()
const {
keymap: { sections },
} = tuiConfig
const kv = useKV() const kv = useKV()
const { theme } = useTheme() const { theme } = useTheme()
const promptRef = usePromptRef() const promptRef = usePromptRef()
@@ -1015,7 +1048,7 @@ export function Session() {
useBindings(() => ({ useBindings(() => ({
enabled: command.matcher, enabled: command.matcher,
bindings: sections.session, bindings: tuiConfig.keybinds.gather("session", sessionBindingCommands),
})) }))
const revertInfo = createMemo(() => session()?.revert) const revertInfo = createMemo(() => session()?.revert)

View File

@@ -463,7 +463,6 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: (
let input: TextareaRenderable let input: TextareaRenderable
const { theme } = useTheme() const { theme } = useTheme()
const tuiConfig = useTuiConfig() const tuiConfig = useTuiConfig()
const keymapConfig = tuiConfig.keymap
const dimensions = useTerminalDimensions() const dimensions = useTerminalDimensions()
const narrow = createMemo(() => dimensions().width < 80) const narrow = createMemo(() => dimensions().width < 80)
const dialog = useDialog() const dialog = useDialog()
@@ -471,7 +470,7 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: (
enabled: dialog.stack.length === 0, enabled: dialog.stack.length === 0,
commands: [ commands: [
{ {
name: "permission.reject.cancel", name: "app.exit",
title: "Cancel permission rejection", title: "Cancel permission rejection",
category: "Permission", category: "Permission",
run() { run() {
@@ -481,7 +480,7 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: (
], ],
bindings: [ bindings: [
{ key: "escape", desc: "Cancel permission rejection", group: "Permission", cmd: () => props.onCancel() }, { key: "escape", desc: "Cancel permission rejection", group: "Permission", cmd: () => props.onCancel() },
...keymapConfig.pick("permission", ["permission.reject.cancel"]), ...tuiConfig.keybinds.get("app.exit"),
{ {
key: "return", key: "return",
desc: "Confirm permission rejection", desc: "Confirm permission rejection",
@@ -553,7 +552,6 @@ function Prompt<const T extends Record<string, string>>(props: {
}) { }) {
const { theme } = useTheme() const { theme } = useTheme()
const tuiConfig = useTuiConfig() const tuiConfig = useTuiConfig()
const keymapConfig = tuiConfig.keymap
const dimensions = useTerminalDimensions() const dimensions = useTerminalDimensions()
const keys = Object.keys(props.options) as (keyof T)[] const keys = Object.keys(props.options) as (keyof T)[]
const [store, setStore] = createStore({ const [store, setStore] = createStore({
@@ -568,7 +566,7 @@ function Prompt<const T extends Record<string, string>>(props: {
enabled: dialog.stack.length === 0, enabled: dialog.stack.length === 0,
commands: [ commands: [
{ {
name: "permission.prompt.escape", name: "app.exit",
title: "Reject permission", title: "Reject permission",
category: "Permission", category: "Permission",
run() { run() {
@@ -643,8 +641,8 @@ function Prompt<const T extends Record<string, string>>(props: {
}, },
] ]
: []), : []),
...(props.escapeKey ? keymapConfig.pick("permission", ["permission.prompt.escape"]) : []), ...(props.escapeKey ? tuiConfig.keybinds.get("app.exit") : []),
...(props.fullscreen ? keymapConfig.pick("permission", ["permission.prompt.fullscreen"]) : []), ...(props.fullscreen ? tuiConfig.keybinds.get("permission.prompt.fullscreen") : []),
], ],
})) }))

View File

@@ -13,10 +13,6 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
const sdk = useSDK() const sdk = useSDK()
const { theme } = useTheme() const { theme } = useTheme()
const tuiConfig = useTuiConfig() const tuiConfig = useTuiConfig()
const {
keymap: { sections },
} = tuiConfig
const keymapConfig = tuiConfig.keymap
const questions = createMemo(() => props.request.questions) const questions = createMemo(() => props.request.questions)
const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true) const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true)
@@ -128,7 +124,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
enabled: store.editing && !confirm(), enabled: store.editing && !confirm(),
commands: [ commands: [
{ {
name: "question.edit.clear", name: "prompt.clear",
title: "Clear answer edit", title: "Clear answer edit",
category: "Question", category: "Question",
run() { run() {
@@ -150,7 +146,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
setStore("editing", false) setStore("editing", false)
}, },
}, },
...keymapConfig.pick("question", ["question.edit.clear"]), ...tuiConfig.keybinds.get("prompt.clear"),
{ {
key: "return", key: "return",
desc: "Submit answer edit", desc: "Submit answer edit",
@@ -208,7 +204,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
enabled: dialog.stack.length === 0 && !store.editing, enabled: dialog.stack.length === 0 && !store.editing,
commands: [ commands: [
{ {
name: "question.reject", name: "app.exit",
title: "Reject question", title: "Reject question",
category: "Question", category: "Question",
run() { run() {
@@ -243,7 +239,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
? [ ? [
{ key: "return", desc: "Submit answer", group: "Question", cmd: () => submit() }, { key: "return", desc: "Submit answer", group: "Question", cmd: () => submit() },
{ key: "escape", desc: "Reject question", group: "Question", cmd: () => reject() }, { key: "escape", desc: "Reject question", group: "Question", cmd: () => reject() },
...sections.question, ...tuiConfig.keybinds.get("app.exit"),
] ]
: [ : [
...Array.from({ length: max }, (_, index) => ({ ...Array.from({ length: max }, (_, index) => ({
@@ -271,7 +267,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
{ key: "j", desc: "Next answer", group: "Question", cmd: () => moveTo((store.selected + 1) % total) }, { key: "j", desc: "Next answer", group: "Question", cmd: () => moveTo((store.selected + 1) % total) },
{ key: "return", desc: "Select answer", group: "Question", cmd: () => selectOption() }, { key: "return", desc: "Select answer", group: "Question", cmd: () => selectOption() },
{ key: "escape", desc: "Reject question", group: "Question", cmd: () => reject() }, { key: "escape", desc: "Reject question", group: "Question", cmd: () => reject() },
...sections.question, ...tuiConfig.keybinds.get("app.exit"),
]), ]),
], ],
} }

View File

@@ -65,9 +65,6 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
const dialog = useDialog() const dialog = useDialog()
const { theme } = useTheme() const { theme } = useTheme()
const tuiConfig = useTuiConfig() const tuiConfig = useTuiConfig()
const {
keymap: { sections },
} = tuiConfig
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig)) const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
const [store, setStore] = createStore({ const [store, setStore] = createStore({
@@ -308,11 +305,16 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
})), })),
], ],
bindings: [ bindings: [
...sections.dialog_select, ...tuiConfig.keybinds.gather("dialog.select", [
...tuiConfig.keymap.pick( "dialog.select.prev",
"dialog_actions", "dialog.select.next",
enabledActions.map((item) => item.command), "dialog.select.page_up",
), "dialog.select.page_down",
"dialog.select.home",
"dialog.select.end",
"dialog.select.submit",
]),
...enabledActions.flatMap((item) => tuiConfig.keybinds.get(item.command)),
...(props.bindings ?? []).filter((binding) => { ...(props.bindings ?? []).filter((binding) => {
if (typeof binding.cmd !== "string") return true if (typeof binding.cmd !== "string") return true
return enabledActions.some((item) => item.command === binding.cmd) return enabledActions.some((item) => item.command === binding.cmd)

View File

@@ -1,143 +0,0 @@
export * as ConfigKeybinds from "./keybinds"
import { Effect, Schema } from "effect"
import type z from "zod"
import { zod } from "@/util/effect-zod"
// Every keybind field has the same shape: an optional string with a default
// binding and a human description. `keybind()` keeps the declaration list
// below dense and readable.
const keybind = (value: string, description: string) =>
Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(value))).annotate({ description })
// Windows prepends ctrl+z to the undo binding because `terminal_suspend`
// cannot consume ctrl+z on native Windows terminals (no POSIX suspend).
const inputUndoDefault = process.platform === "win32" ? "ctrl+z,ctrl+-,super+z" : "ctrl+-,super+z"
const KeybindsSchema = Schema.Struct({
leader: keybind("ctrl+x", "Leader key for keybind combinations"),
app_exit: keybind("ctrl+c,ctrl+d,<leader>q", "Exit the application"),
editor_open: keybind("<leader>e", "Open external editor"),
theme_list: keybind("<leader>t", "List available themes"),
sidebar_toggle: keybind("<leader>b", "Toggle sidebar"),
scrollbar_toggle: keybind("none", "Toggle session scrollbar"),
status_view: keybind("<leader>s", "View status"),
session_export: keybind("<leader>x", "Export session to editor"),
session_new: keybind("<leader>n", "Create a new session"),
session_list: keybind("<leader>l", "List all sessions"),
session_timeline: keybind("<leader>g", "Show session timeline"),
session_fork: keybind("none", "Fork session from message"),
session_rename: keybind("ctrl+r", "Rename session"),
session_delete: keybind("ctrl+d", "Delete session"),
stash_delete: keybind("ctrl+d", "Delete stash entry"),
model_provider_list: keybind("ctrl+a", "Open provider list from model dialog"),
model_favorite_toggle: keybind("ctrl+f", "Toggle model favorite status"),
session_share: keybind("none", "Share current session"),
session_unshare: keybind("none", "Unshare current session"),
session_interrupt: keybind("escape", "Interrupt current session"),
session_compact: keybind("<leader>c", "Compact the session"),
messages_page_up: keybind("pageup,ctrl+alt+b", "Scroll messages up by one page"),
messages_page_down: keybind("pagedown,ctrl+alt+f", "Scroll messages down by one page"),
messages_line_up: keybind("ctrl+alt+y", "Scroll messages up by one line"),
messages_line_down: keybind("ctrl+alt+e", "Scroll messages down by one line"),
messages_half_page_up: keybind("ctrl+alt+u", "Scroll messages up by half page"),
messages_half_page_down: keybind("ctrl+alt+d", "Scroll messages down by half page"),
messages_first: keybind("ctrl+g,home", "Navigate to first message"),
messages_last: keybind("ctrl+alt+g,end", "Navigate to last message"),
messages_next: keybind("none", "Navigate to next message"),
messages_previous: keybind("none", "Navigate to previous message"),
messages_last_user: keybind("none", "Navigate to last user message"),
messages_copy: keybind("<leader>y", "Copy message"),
messages_undo: keybind("<leader>u", "Undo message"),
messages_redo: keybind("<leader>r", "Redo message"),
messages_toggle_conceal: keybind("<leader>h", "Toggle code block concealment in messages"),
tool_details: keybind("none", "Toggle tool details visibility"),
model_list: keybind("<leader>m", "List available models"),
model_cycle_recent: keybind("f2", "Next recently used model"),
model_cycle_recent_reverse: keybind("shift+f2", "Previous recently used model"),
model_cycle_favorite: keybind("none", "Next favorite model"),
model_cycle_favorite_reverse: keybind("none", "Previous favorite model"),
command_list: keybind("ctrl+p", "List available commands"),
"dialog.select.prev": keybind("up,ctrl+p", "Move to previous dialog item"),
"dialog.select.next": keybind("down,ctrl+n", "Move to next dialog item"),
"dialog.select.page_up": keybind("pageup", "Move up one page in dialog"),
"dialog.select.page_down": keybind("pagedown", "Move down one page in dialog"),
"dialog.select.home": keybind("home", "Move to first dialog item"),
"dialog.select.end": keybind("end", "Move to last dialog item"),
"dialog.select.submit": keybind("return", "Submit selected dialog item"),
"dialog.mcp.toggle": keybind("space", "Toggle MCP in MCP dialog"),
"prompt.autocomplete.prev": keybind("up,ctrl+p", "Move to previous autocomplete item"),
"prompt.autocomplete.next": keybind("down,ctrl+n", "Move to next autocomplete item"),
"prompt.autocomplete.hide": keybind("escape", "Hide autocomplete"),
"prompt.autocomplete.select": keybind("return", "Select autocomplete item"),
"prompt.autocomplete.complete": keybind("tab", "Complete autocomplete item"),
"permission.prompt.fullscreen": keybind("ctrl+f", "Toggle permission prompt fullscreen"),
"plugins.toggle": keybind("space", "Toggle plugin"),
"dialog.plugins.install": keybind("shift+i", "Install plugin from plugin dialog"),
agent_list: keybind("<leader>a", "List agents"),
agent_cycle: keybind("tab", "Next agent"),
agent_cycle_reverse: keybind("shift+tab", "Previous agent"),
variant_cycle: keybind("ctrl+t", "Cycle model variants"),
variant_list: keybind("none", "List model variants"),
input_clear: keybind("ctrl+c", "Clear input field"),
input_paste: keybind("ctrl+v", "Paste from clipboard"),
input_submit: keybind("return", "Submit input"),
input_newline: keybind("shift+return,ctrl+return,alt+return,ctrl+j", "Insert newline in input"),
input_move_left: keybind("left,ctrl+b", "Move cursor left in input"),
input_move_right: keybind("right,ctrl+f", "Move cursor right in input"),
input_move_up: keybind("up", "Move cursor up in input"),
input_move_down: keybind("down", "Move cursor down in input"),
input_select_left: keybind("shift+left", "Select left in input"),
input_select_right: keybind("shift+right", "Select right in input"),
input_select_up: keybind("shift+up", "Select up in input"),
input_select_down: keybind("shift+down", "Select down in input"),
input_line_home: keybind("ctrl+a", "Move to start of line in input"),
input_line_end: keybind("ctrl+e", "Move to end of line in input"),
input_select_line_home: keybind("ctrl+shift+a", "Select to start of line in input"),
input_select_line_end: keybind("ctrl+shift+e", "Select to end of line in input"),
input_visual_line_home: keybind("alt+a", "Move to start of visual line in input"),
input_visual_line_end: keybind("alt+e", "Move to end of visual line in input"),
input_select_visual_line_home: keybind("alt+shift+a", "Select to start of visual line in input"),
input_select_visual_line_end: keybind("alt+shift+e", "Select to end of visual line in input"),
input_buffer_home: keybind("home", "Move to start of buffer in input"),
input_buffer_end: keybind("end", "Move to end of buffer in input"),
input_select_buffer_home: keybind("shift+home", "Select to start of buffer in input"),
input_select_buffer_end: keybind("shift+end", "Select to end of buffer in input"),
input_delete_line: keybind("ctrl+shift+d", "Delete line in input"),
input_delete_to_line_end: keybind("ctrl+k", "Delete to end of line in input"),
input_delete_to_line_start: keybind("ctrl+u", "Delete to start of line in input"),
input_backspace: keybind("backspace,shift+backspace", "Backspace in input"),
input_delete: keybind("ctrl+d,delete,shift+delete", "Delete character in input"),
input_undo: keybind(inputUndoDefault, "Undo in input"),
input_redo: keybind("ctrl+.,super+shift+z", "Redo in input"),
input_word_forward: keybind("alt+f,alt+right,ctrl+right", "Move word forward in input"),
input_word_backward: keybind("alt+b,alt+left,ctrl+left", "Move word backward in input"),
input_select_word_forward: keybind("alt+shift+f,alt+shift+right", "Select word forward in input"),
input_select_word_backward: keybind("alt+shift+b,alt+shift+left", "Select word backward in input"),
input_delete_word_forward: keybind("alt+d,alt+delete,ctrl+delete", "Delete word forward in input"),
input_delete_word_backward: keybind("ctrl+w,ctrl+backspace,alt+backspace", "Delete word backward in input"),
input_select_all: keybind("super+a", "Select all in input"),
history_previous: keybind("up", "Previous history item"),
history_next: keybind("down", "Next history item"),
session_child_first: keybind("<leader>down", "Go to first child session"),
session_child_cycle: keybind("right", "Go to next child session"),
session_child_cycle_reverse: keybind("left", "Go to previous child session"),
session_parent: keybind("up", "Go to parent session"),
// `terminal_suspend` was formerly `.default("ctrl+z").transform((v) => win32 ? "none" : v)`,
// but `tui.ts` already forces the binding to "none" on win32 before calling
// `Keybinds.parse(...)`, so the schema-level transform was redundant.
terminal_suspend: keybind("ctrl+z", "Suspend terminal"),
terminal_title_toggle: keybind("none", "Toggle terminal title"),
tips_toggle: keybind("<leader>h", "Toggle tips on home screen"),
plugin_manager: keybind("none", "Open plugin manager dialog"),
display_thinking: keybind("none", "Toggle thinking blocks visibility"),
}).annotate({ identifier: "KeybindsConfig" })
export type Keybinds = Schema.Schema.Type<typeof KeybindsSchema>
// Consumers access `Keybinds.shape` and `Keybinds.shape.X.parse(undefined)`,
// which requires the runtime type to be a ZodObject, not just ZodType. Every
// field is `string().optional().default(...)` at runtime, so widen to that.
export const Keybinds = zod(KeybindsSchema) as unknown as z.ZodObject<
Record<keyof Keybinds, z.ZodDefault<z.ZodOptional<z.ZodString>>>
>

View File

@@ -1,12 +1,11 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import type { KeyEvent, Renderable } from "@opentui/core" import type { KeyEvent, Renderable } from "@opentui/core"
import type { Binding } from "@opentui/keymap" import type { Binding } from "@opentui/keymap"
import { resolveBindingSections, type BindingSectionsConfig } from "@opentui/keymap/extras" import { createBindingLookup } from "@opentui/keymap/extras"
import { OpencodeClient, type Provider } from "@opencode-ai/sdk/v2" import { OpencodeClient, type Provider } from "@opencode-ai/sdk/v2"
import { TuiConfig, type Resolved } from "@/cli/cmd/tui/config/tui" import { TuiConfig, type Resolved } from "@/cli/cmd/tui/config/tui"
import { formatBindings } from "@/cli/cmd/run/keymap.shared" import { formatBindings } from "@/cli/cmd/run/keymap.shared"
import { KeymapSectionNames, keymapBindingDefaults, type KeymapSection } from "@/cli/cmd/tui/config/tui-schema" import { TuiKeybind } from "@/cli/cmd/tui/config/keybind"
import { ConfigKeybinds } from "@/config/keybinds"
import { resolveDiffStyle, resolveFooterKeybinds, resolveModelInfo } from "@/cli/cmd/run/runtime.boot" import { resolveDiffStyle, resolveFooterKeybinds, resolveModelInfo } from "@/cli/cmd/run/runtime.boot"
type RunBinding = Binding<Renderable, KeyEvent> type RunBinding = Binding<Renderable, KeyEvent>
@@ -82,34 +81,24 @@ function config(input?: {
}> }>
}): Resolved { }): Resolved {
const bind = input?.bindings const bind = input?.bindings
const sections = { const keybinds = TuiKeybind.Keybinds.parse({
global: Object.fromEntries([ ...(input?.leader && { leader: input.leader }),
...(bind?.commandList ? [["command.palette.show", bind.commandList] as const] : []), ...(bind?.commandList && { command_list: bind.commandList }),
...(bind?.variantCycle ? [["variant.cycle", bind.variantCycle] as const] : []), ...(bind?.variantCycle && { variant_cycle: bind.variantCycle }),
]), ...(bind?.interrupt && { session_interrupt: bind.interrupt }),
prompt: Object.fromEntries([ ...(bind?.historyPrevious && { history_previous: bind.historyPrevious }),
...(bind?.interrupt ? [["session.interrupt", bind.interrupt] as const] : []), ...(bind?.historyNext && { history_next: bind.historyNext }),
...(bind?.historyPrevious ? [["prompt.history.previous", bind.historyPrevious] as const] : []), ...(bind?.inputClear && { input_clear: bind.inputClear }),
...(bind?.historyNext ? [["prompt.history.next", bind.historyNext] as const] : []), ...(bind?.inputSubmit && { input_submit: bind.inputSubmit }),
...(bind?.inputClear ? [["prompt.clear", bind.inputClear] as const] : []), ...(bind?.inputNewline && { input_newline: bind.inputNewline }),
]), })
input: Object.fromEntries([
...(bind?.inputSubmit ? [["input.submit", bind.inputSubmit] as const] : []),
...(bind?.inputNewline ? [["input.newline", bind.inputNewline] as const] : []),
]),
} satisfies BindingSectionsConfig<Renderable, KeyEvent>
return { return {
diff_style: input?.diff_style, diff_style: input?.diff_style,
keybinds: ConfigKeybinds.Keybinds.parse({}), keybinds: createBindingLookup(TuiKeybind.toBindingConfig(keybinds), {
keymap: { commandMap: TuiKeybind.CommandMap,
leader: input?.leader ?? "ctrl+x", bindingDefaults: TuiKeybind.bindingDefaults(),
leader_timeout: input?.leaderTimeout ?? 2000, }),
...resolveBindingSections<Renderable, KeyEvent, typeof sections, KeymapSection>(sections, { leader_timeout: input?.leaderTimeout ?? 2000,
sections: KeymapSectionNames,
bindingDefaults: keymapBindingDefaults,
}),
},
} }
} }
@@ -118,7 +107,7 @@ describe("run runtime boot", () => {
mock.restore() mock.restore()
}) })
test("reads footer keybinds from resolved keymap config", async () => { test("reads footer keybinds from resolved keybind config", async () => {
spyOn(TuiConfig, "get").mockResolvedValue( spyOn(TuiConfig, "get").mockResolvedValue(
config({ config({
leader: "ctrl+g", leader: "ctrl+g",

View File

@@ -81,7 +81,7 @@ async function load(): Promise<Data> {
await Bun.write( await Bun.write(
localPluginPath, localPluginPath,
`import { resolveBindingSections } from "@opentui/keymap/extras" `import { createBindingLookup } from "@opentui/keymap/extras"
import { useBindings } from "@opentui/keymap/solid" import { useBindings } from "@opentui/keymap/solid"
export const ignored = async (_input, options) => { export const ignored = async (_input, options) => {
@@ -97,20 +97,18 @@ export default {
const cfg_diff = api.tuiConfig.diff_style const cfg_diff = api.tuiConfig.diff_style
const cfg_speed = api.tuiConfig.scroll_speed const cfg_speed = api.tuiConfig.scroll_speed
const cfg_accel = api.tuiConfig.scroll_acceleration?.enabled const cfg_accel = api.tuiConfig.scroll_acceleration?.enabled
const cfg_submit = api.tuiConfig.keybinds?.input_submit
const has_keys = typeof api.keys.formatBindings === "function" const has_keys = typeof api.keys.formatBindings === "function"
const keymap = resolveBindingSections(options.keymap?.sections ?? { const keybinds = createBindingLookup(options.keybinds ?? {
main: { "plugin.loader.local": "ctrl+shift+m",
"plugin.loader.local": "ctrl+shift+m", "plugin.loader.close": "escape",
"plugin.loader.close": "escape", })
}, const bindings = keybinds.gather("plugin.loader", ["plugin.loader.local", "plugin.loader.close"])
}, { sections: ["main"] }).sections const key_modal = bindings.find((item) => item.cmd === "plugin.loader.local")?.key
const key_modal = keymap.main.find((item) => item.cmd === "plugin.loader.local")?.key const key_close = bindings.find((item) => item.cmd === "plugin.loader.close")?.key
const key_close = keymap.main.find((item) => item.cmd === "plugin.loader.close")?.key
const key_unknown = "ctrl+k" const key_unknown = "ctrl+k"
const off = api.keymap.registerLayer({ const off = api.keymap.registerLayer({
commands: [{ name: "plugin.loader.local", run() {} }, { name: "plugin.loader.close", run() {} }], commands: [{ name: "plugin.loader.local", run() {} }, { name: "plugin.loader.close", run() {} }],
bindings: keymap.main, bindings,
}) })
off() off()
const kv_before = api.kv.get(options.kv_key, "missing") const kv_before = api.kv.get(options.kv_key, "missing")
@@ -153,7 +151,7 @@ export default {
key_unknown, key_unknown,
has_keys, has_keys,
has_keymap: typeof api.keymap.registerLayer === "function", has_keymap: typeof api.keymap.registerLayer === "function",
has_resolve_binding_sections: typeof resolveBindingSections === "function", has_create_binding_lookup: typeof createBindingLookup === "function",
has_keymap_solid: typeof useBindings === "function", has_keymap_solid: typeof useBindings === "function",
kv_before, kv_before,
kv_after, kv_after,
@@ -176,7 +174,6 @@ export default {
cfg_diff, cfg_diff,
cfg_speed, cfg_speed,
cfg_accel, cfg_accel,
cfg_submit,
}), }),
) )
}, },
@@ -356,13 +353,9 @@ export default {
theme_name: tmp.extra.localThemeName, theme_name: tmp.extra.localThemeName,
kv_key: "plugin_state_key", kv_key: "plugin_state_key",
session_id: "ses_test", session_id: "ses_test",
keymap: { keybinds: {
sections: { "plugin.loader.local": "ctrl+alt+m",
main: { "plugin.loader.close": "q",
"plugin.loader.local": "ctrl+alt+m",
"plugin.loader.close": "q",
},
},
}, },
} }
const invalidOpts = { const invalidOpts = {
@@ -408,9 +401,6 @@ export default {
diff_style: "stacked", diff_style: "stacked",
scroll_speed: 1.5, scroll_speed: 1.5,
scroll_acceleration: { enabled: true }, scroll_acceleration: { enabled: true },
keybinds: {
input_submit: "ctrl+enter",
},
}, },
state: { state: {
session: { session: {
@@ -670,7 +660,7 @@ describe("tui.plugin.loader", () => {
expect(data.local.key_unknown).toBe("ctrl+k") expect(data.local.key_unknown).toBe("ctrl+k")
expect(data.local.has_keys).toBe(true) expect(data.local.has_keys).toBe(true)
expect(data.local.has_keymap).toBe(true) expect(data.local.has_keymap).toBe(true)
expect(data.local.has_resolve_binding_sections).toBe(true) expect(data.local.has_create_binding_lookup).toBe(true)
expect(data.local.has_keymap_solid).toBe(true) expect(data.local.has_keymap_solid).toBe(true)
expect(data.local.kv_before).toBe("missing") expect(data.local.kv_before).toBe("missing")
expect(data.local.kv_after).toBe("stored") expect(data.local.kv_after).toBe("stored")
@@ -693,7 +683,6 @@ describe("tui.plugin.loader", () => {
expect(data.local.cfg_diff).toBe("stacked") expect(data.local.cfg_diff).toBe("stacked")
expect(data.local.cfg_speed).toBe(1.5) expect(data.local.cfg_speed).toBe(1.5)
expect(data.local.cfg_accel).toBe(true) expect(data.local.cfg_accel).toBe(true)
expect(data.local.cfg_submit).toBe("ctrl+enter")
}) })
test("installs themes in the correct scope and remains resilient", () => { test("installs themes in the correct scope and remains resilient", () => {

View File

@@ -171,26 +171,26 @@ test("loads disabled-by-default internal plugin inactive and activates on demand
enabled: true, enabled: true,
active: true, active: true,
}) })
expect(TuiPluginRuntime.list().find((item) => item.id === "tui-which-key")).toEqual({ expect(TuiPluginRuntime.list().find((item) => item.id === "which-key")).toEqual({
id: "tui-which-key", id: "which-key",
source: "internal", source: "internal",
spec: "tui-which-key", spec: "which-key",
target: "tui-which-key", target: "which-key",
enabled: false, enabled: false,
active: false, active: false,
}) })
await expect(TuiPluginRuntime.activatePlugin("tui-which-key")).resolves.toBe(true) await expect(TuiPluginRuntime.activatePlugin("which-key")).resolves.toBe(true)
expect(TuiPluginRuntime.list().find((item) => item.id === "tui-which-key")).toEqual({ expect(TuiPluginRuntime.list().find((item) => item.id === "which-key")).toEqual({
id: "tui-which-key", id: "which-key",
source: "internal", source: "internal",
spec: "tui-which-key", spec: "which-key",
target: "tui-which-key", target: "which-key",
enabled: true, enabled: true,
active: true, active: true,
}) })
expect(api.kv.get("plugin_enabled", {})).toEqual({ expect(api.kv.get("plugin_enabled", {})).toEqual({
"tui-which-key": true, "which-key": true,
}) })
} finally { } finally {
await TuiPluginRuntime.dispose() await TuiPluginRuntime.dispose()

View File

@@ -163,7 +163,7 @@ test("migrates tui-specific keys from opencode.json when tui.json does not exist
const config = await getTuiConfig(tmp.path) const config = await getTuiConfig(tmp.path)
expect(config.theme).toBe("migrated-theme") expect(config.theme).toBe("migrated-theme")
expect(config.scroll_speed).toBe(5) expect(config.scroll_speed).toBe(5)
expect(config.keybinds?.app_exit).toBe("ctrl+q") expect(config.keybinds.get("app.exit")?.[0]?.key).toBe("ctrl+q")
const text = await Filesystem.readText(path.join(tmp.path, "tui.json")) const text = await Filesystem.readText(path.join(tmp.path, "tui.json"))
expect(JSON.parse(text)).toMatchObject({ expect(JSON.parse(text)).toMatchObject({
theme: "migrated-theme", theme: "migrated-theme",
@@ -398,83 +398,64 @@ test("merges keybind overrides across precedence layers", async () => {
}, },
}) })
const config = await getTuiConfig(tmp.path) const config = await getTuiConfig(tmp.path)
expect(config.keybinds?.app_exit).toBe("ctrl+q") expect(config.keybinds.get("app.exit")?.[0]?.key).toBe("ctrl+q")
expect(config.keybinds?.theme_list).toBe("ctrl+k") expect(config.keybinds.get("theme.switch")?.[0]?.key).toBe("ctrl+k")
}) })
test("resolves semantic keymap sections", async () => { test("resolves keybind lookup from canonical keybinds", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "tui.json"),
JSON.stringify({
keybinds: { command_list: "ctrl+z" },
keymap: {
sections: {
global: { "command.palette.show": "alt+p" },
which_key: { "tui-which-key.toggle": "alt+k" },
prompt: { "prompt.editor": "ctrl+e" },
autocomplete: { "prompt.autocomplete.next": "ctrl+j" },
dialog_actions: { "dialog.action.toggle": "ctrl+t" },
model: { "model.dialog.favorite": "ctrl+f" },
plugins: { "plugin.dialog.install": "shift+i" },
},
},
}),
)
},
})
const config = await getTuiConfig(tmp.path)
expect(config.keymap.sections.global.find((binding) => binding.cmd === "command.palette.show")?.key).toBe("alt+p")
expect(config.keymap.sections.global.find((binding) => binding.cmd === "session.new")?.key).toBe("<leader>n")
expect(config.keymap.sections.which_key.find((binding) => binding.cmd === "tui-which-key.toggle")?.key).toBe("alt+k")
expect(config.keymap.sections.which_key.find((binding) => binding.cmd === "tui-which-key.layout.toggle")?.key).toBe(
"ctrl+alt+shift+k",
)
expect(config.keymap.sections.which_key.find((binding) => binding.cmd === "tui-which-key.pending.toggle")?.key).toBe(
"ctrl+alt+shift+p",
)
expect(config.keymap.sections.which_key.find((binding) => binding.cmd === "tui-which-key.group.next")?.key).toBe(
"ctrl+alt+right,ctrl+alt+]",
)
expect(
(
config.keymap.sections.which_key.find((binding) => binding.cmd === "tui-which-key.toggle") as
| { group?: unknown }
| undefined
)?.group,
).toBe("System")
expect(config.keymap.sections.prompt.find((binding) => binding.cmd === "prompt.editor")?.key).toBe("ctrl+e")
expect(config.keymap.sections.autocomplete.find((binding) => binding.cmd === "prompt.autocomplete.next")?.key).toBe(
"ctrl+j",
)
expect(config.keymap.sections.dialog_actions.find((binding) => binding.cmd === "dialog.action.toggle")?.key).toBe(
"ctrl+t",
)
expect(config.keymap.sections.model.find((binding) => binding.cmd === "model.dialog.favorite")?.key).toBe("ctrl+f")
expect(config.keymap.sections.plugins.find((binding) => binding.cmd === "plugin.dialog.install")?.key).toBe("shift+i")
expect(config.keymap.pick("plugins", ["plugin.dialog.install"]).map((binding) => binding.cmd)).toEqual([
"plugin.dialog.install",
])
expect((config.keymap.pick("plugins", ["plugin.dialog.install"])[0] as { group?: unknown } | undefined)?.group).toBe(
"Plugins",
)
expect(config.keymap.omit("plugins", ["plugin.dialog.install"]).map((binding) => binding.cmd)).toEqual([])
})
test("legacy keybinds transform into semantic keymap sections", async () => {
await using tmp = await tmpdir({ await using tmp = await tmpdir({
init: async (dir) => { init: async (dir) => {
await Bun.write( await Bun.write(
path.join(dir, "tui.json"), path.join(dir, "tui.json"),
JSON.stringify({ JSON.stringify({
keybinds: { keybinds: {
leader: { key: { name: "g", ctrl: true } },
command_list: "alt+p", command_list: "alt+p",
which_key_toggle: "alt+k",
editor_open: "ctrl+e", editor_open: "ctrl+e",
"prompt.autocomplete.next": "ctrl+j", "prompt.autocomplete.next": "ctrl+j",
"dialog.mcp.toggle": "ctrl+t", "dialog.mcp.toggle": "ctrl+t",
model_favorite_toggle: "ctrl+f",
"dialog.plugins.install": "shift+i", "dialog.plugins.install": "shift+i",
},
leader_timeout: 1234,
}),
)
},
})
const config = await getTuiConfig(tmp.path)
expect(config.keybinds.get("leader")?.[0]?.key).toEqual({ name: "g", ctrl: true })
expect(config.leader_timeout).toBe(1234)
expect(config.keybinds.get("command.palette.show")?.[0]?.key).toBe("alt+p")
expect(config.keybinds.get("session.new")?.[0]?.key).toBe("<leader>n")
expect(config.keybinds.get("which-key.toggle")?.[0]?.key).toBe("alt+k")
expect(config.keybinds.get("which-key.layout.toggle")?.[0]?.key).toBe("ctrl+alt+shift+k")
expect(config.keybinds.get("which-key.pending.toggle")?.[0]?.key).toBe("ctrl+alt+shift+p")
expect(config.keybinds.get("which-key.group.next")?.[0]?.key).toBe("ctrl+alt+right,ctrl+alt+]")
expect((config.keybinds.get("which-key.toggle")?.[0] as { desc?: unknown } | undefined)?.desc).toBe(
"Toggle which-key panel",
)
expect(config.keybinds.get("prompt.editor")?.[0]?.key).toBe("ctrl+e")
expect(config.keybinds.get("prompt.autocomplete.next")?.[0]?.key).toBe("ctrl+j")
expect(config.keybinds.get("dialog.mcp.toggle")?.[0]?.key).toBe("ctrl+t")
expect(config.keybinds.get("model.dialog.favorite")?.[0]?.key).toBe("ctrl+f")
expect(config.keybinds.get("dialog.plugins.install")?.[0]?.key).toBe("shift+i")
expect(config.keybinds.gather("plugins.dialog", ["dialog.plugins.install"]).map((binding) => binding.cmd)).toEqual([
"dialog.plugins.install",
])
})
test("keybinds accept OpenTUI binding specs", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "tui.json"),
JSON.stringify({
keybinds: {
command_list: [{ key: "alt+p", preventDefault: false }],
editor_open: { key: { name: "e", ctrl: true }, group: "Explicit" },
"prompt.autocomplete.next": false,
plugin_manager: "ctrl+shift+p", plugin_manager: "ctrl+shift+p",
}, },
}), }),
@@ -483,52 +464,23 @@ test("legacy keybinds transform into semantic keymap sections", async () => {
}) })
const config = await getTuiConfig(tmp.path) const config = await getTuiConfig(tmp.path)
expect(Object.keys(config.keymap.sections)).toEqual([ expect(config.keybinds.get("command.palette.show")).toEqual([
"global", { key: "alt+p", cmd: "command.palette.show", preventDefault: false, desc: "List available commands" },
"which_key",
"session",
"prompt",
"autocomplete",
"input",
"dialog_select",
"dialog_actions",
"model",
"permission",
"question",
"plugins",
"home_tips",
])
expect(config.keymap.sections.global.find((binding) => binding.cmd === "command.palette.show")?.key).toBe("alt+p")
expect(config.keymap.sections.which_key.find((binding) => binding.cmd === "tui-which-key.toggle")?.key).toBe(
"ctrl+alt+k",
)
expect(config.keymap.sections.prompt.find((binding) => binding.cmd === "prompt.editor")?.key).toBe("ctrl+e")
expect(config.keymap.sections.autocomplete.find((binding) => binding.cmd === "prompt.autocomplete.next")?.key).toBe(
"ctrl+j",
)
expect(config.keymap.sections.dialog_actions.find((binding) => binding.cmd === "dialog.action.toggle")?.key).toBe(
"ctrl+t",
)
expect(config.keymap.sections.model.find((binding) => binding.cmd === "model.dialog.provider")?.key).toBe("ctrl+a")
expect(config.keymap.sections.model.find((binding) => binding.cmd === "model.dialog.favorite")?.key).toBe("ctrl+f")
expect(config.keymap.sections.plugins.find((binding) => binding.cmd === "plugin.dialog.install")?.key).toBe("shift+i")
expect(config.keymap.sections.plugins.find((binding) => binding.cmd === "plugins.list")?.key).toBe("ctrl+shift+p")
expect(config.keymap.pick("plugins", ["plugin.dialog.install"]).map((binding) => binding.cmd)).toEqual([
"plugin.dialog.install",
])
expect((config.keymap.omit("plugins", ["plugin.dialog.install"])[0] as { group?: unknown } | undefined)?.group).toBe(
"Plugins",
)
expect(config.keymap.omit("plugins", ["plugin.dialog.install"]).map((binding) => binding.cmd)).toEqual([
"plugins.list",
]) ])
expect(config.keybinds.get("prompt.editor")?.[0]).toMatchObject({
key: { name: "e", ctrl: true },
cmd: "prompt.editor",
group: "Explicit",
})
expect(config.keybinds.get("prompt.autocomplete.next")).toEqual([])
expect(config.keybinds.get("plugins.list")?.[0]?.key).toBe("ctrl+shift+p")
}) })
wintest("defaults Ctrl+Z to input undo on Windows", async () => { wintest("defaults Ctrl+Z to input undo on Windows", async () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
const config = await getTuiConfig(tmp.path) const config = await getTuiConfig(tmp.path)
expect(config.keybinds?.terminal_suspend).toBe("none") expect(config.keybinds.get("terminal.suspend")).toEqual([])
expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z") expect(config.keybinds.get("input.undo")?.[0]?.key).toBe("ctrl+z,ctrl+-,super+z")
}) })
wintest("keeps explicit input undo overrides on Windows", async () => { wintest("keeps explicit input undo overrides on Windows", async () => {
@@ -538,8 +490,8 @@ wintest("keeps explicit input undo overrides on Windows", async () => {
}, },
}) })
const config = await getTuiConfig(tmp.path) const config = await getTuiConfig(tmp.path)
expect(config.keybinds?.terminal_suspend).toBe("none") expect(config.keybinds.get("terminal.suspend")).toEqual([])
expect(config.keybinds?.input_undo).toBe("ctrl+y") expect(config.keybinds.get("input.undo")?.[0]?.key).toBe("ctrl+y")
}) })
wintest("ignores terminal suspend bindings on Windows", async () => { wintest("ignores terminal suspend bindings on Windows", async () => {
@@ -550,33 +502,29 @@ wintest("ignores terminal suspend bindings on Windows", async () => {
}) })
const config = await getTuiConfig(tmp.path) const config = await getTuiConfig(tmp.path)
expect(config.keybinds?.terminal_suspend).toBe("none") expect(config.keybinds.get("terminal.suspend")).toEqual([])
expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z") expect(config.keybinds.get("input.undo")?.[0]?.key).toBe("ctrl+z,ctrl+-,super+z")
}) })
test("applies Windows keymap defaults", async () => { test("applies Windows keybind defaults", async () => {
await withPlatform("win32", async () => { await withPlatform("win32", async () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
const config = await getTuiConfig(tmp.path) const config = await getTuiConfig(tmp.path)
expect(config.keymap.sections.global.find((binding) => binding.cmd === "terminal.suspend")).toBeUndefined() expect(config.keybinds.get("terminal.suspend")).toEqual([])
expect(config.keymap.sections.input.find((binding) => binding.cmd === "input.undo")?.key).toBe( expect(config.keybinds.get("input.undo")?.[0]?.key).toBe("ctrl+z,ctrl+-,super+z")
"ctrl+z,ctrl+-,super+z",
)
}) })
}) })
test("keeps explicit configured keymap terminal suspend binding on Windows", async () => { test("ignores explicit keybind terminal suspend binding on Windows", async () => {
await withPlatform("win32", async () => { await withPlatform("win32", async () => {
await using tmp = await tmpdir({ await using tmp = await tmpdir({
init: async (dir) => { init: async (dir) => {
await Bun.write( await Bun.write(
path.join(dir, "tui.json"), path.join(dir, "tui.json"),
JSON.stringify({ JSON.stringify({
keymap: { keybinds: {
sections: { terminal_suspend: "alt+z",
global: { "terminal.suspend": "alt+z" },
},
}, },
}), }),
) )
@@ -584,21 +532,19 @@ test("keeps explicit configured keymap terminal suspend binding on Windows", asy
}) })
const config = await getTuiConfig(tmp.path) const config = await getTuiConfig(tmp.path)
expect(config.keymap.sections.global.find((binding) => binding.cmd === "terminal.suspend")?.key).toBe("alt+z") expect(config.keybinds.get("terminal.suspend")).toEqual([])
}) })
}) })
test("keeps explicit configured keymap input undo on Windows", async () => { test("keeps explicit configured keybind input undo on Windows", async () => {
await withPlatform("win32", async () => { await withPlatform("win32", async () => {
await using tmp = await tmpdir({ await using tmp = await tmpdir({
init: async (dir) => { init: async (dir) => {
await Bun.write( await Bun.write(
path.join(dir, "tui.json"), path.join(dir, "tui.json"),
JSON.stringify({ JSON.stringify({
keymap: { keybinds: {
sections: { input_undo: "ctrl+y",
input: { "input.undo": "ctrl+y" },
},
}, },
}), }),
) )
@@ -606,7 +552,7 @@ test("keeps explicit configured keymap input undo on Windows", async () => {
}) })
const config = await getTuiConfig(tmp.path) const config = await getTuiConfig(tmp.path)
expect(config.keymap.sections.input.find((binding) => binding.cmd === "input.undo")?.key).toBe("ctrl+y") expect(config.keybinds.get("input.undo")?.[0]?.key).toBe("ctrl+y")
}) })
}) })
@@ -655,7 +601,7 @@ test("applies env and file substitutions in tui.json", async () => {
}) })
const config = await getTuiConfig(tmp.path) const config = await getTuiConfig(tmp.path)
expect(config.theme).toBe("env-theme") expect(config.theme).toBe("env-theme")
expect(config.keybinds?.app_exit).toBe("ctrl+q") expect(config.keybinds.get("app.exit")?.[0]?.key).toBe("ctrl+q")
} finally { } finally {
if (original === undefined) delete process.env.TUI_THEME_TEST if (original === undefined) delete process.env.TUI_THEME_TEST
else process.env.TUI_THEME_TEST = original else process.env.TUI_THEME_TEST = original

View File

@@ -1,9 +1,7 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { RGBA, type CliRenderer } from "@opentui/core" import { RGBA, type CliRenderer } from "@opentui/core"
import type { HostPluginApi } from "../../src/cli/cmd/tui/plugin/slots" import type { HostPluginApi } from "../../src/cli/cmd/tui/plugin/slots"
import { LegacyKeymapTransform } from "../../src/cli/cmd/tui/config/legacy-keymap-transform" import { createTuiResolvedConfig } from "./tui-runtime"
import { ConfigKeybinds } from "../../src/config/keybinds"
import { createTuiResolvedKeymap } from "./tui-runtime"
type Count = { type Count = {
event_add: number event_add: number
@@ -112,11 +110,9 @@ type Opts = {
} }
function tuiConfig(input?: Partial<HostPluginApi["tuiConfig"]>): HostPluginApi["tuiConfig"] { function tuiConfig(input?: Partial<HostPluginApi["tuiConfig"]>): HostPluginApi["tuiConfig"] {
const keybinds = ConfigKeybinds.Keybinds.parse(input?.keybinds ?? {})
return { return {
...createTuiResolvedConfig(),
...input, ...input,
keybinds,
keymap: input?.keymap ?? createTuiResolvedKeymap(LegacyKeymapTransform.create(input?.keybinds ?? {})),
} }
} }

View File

@@ -1,45 +1,29 @@
import { spyOn } from "bun:test" import { spyOn } from "bun:test"
import path from "path" import path from "path"
import type { KeyEvent, Renderable } from "@opentui/core" import { createBindingLookup } from "@opentui/keymap/extras"
import { resolveBindingSections, type BindingSectionsConfig } from "@opentui/keymap/extras"
import { TuiConfig } from "../../src/cli/cmd/tui/config/tui" import { TuiConfig } from "../../src/cli/cmd/tui/config/tui"
import { LegacyKeymapTransform } from "../../src/cli/cmd/tui/config/legacy-keymap-transform" import { TuiKeybind } from "../../src/cli/cmd/tui/config/keybind"
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<string, unknown>] type PluginSpec = string | [string, Record<string, unknown>]
type ResolvedInput = Omit<TuiConfig.Resolved, "keybinds" | "keymap"> & { type ResolvedInput = Omit<TuiConfig.Resolved, "keybinds" | "leader_timeout"> & {
keybinds?: TuiConfig.Resolved["keybinds"] keybinds?: Partial<TuiKeybind.Keybinds>
keymap?: TuiConfig.Resolved["keymap"] leader_timeout?: number
} }
export function createTuiResolvedKeymap(input: KeymapConfigInput): TuiConfig.Resolved["keymap"] { export function createTuiResolvedKeybinds(input: Partial<TuiKeybind.Keybinds> = {}): TuiConfig.Resolved["keybinds"] {
const config = KeymapConfig.parse(input) const keybinds = TuiKeybind.Keybinds.parse(input)
return { return createBindingLookup(TuiKeybind.toBindingConfig(keybinds), {
leader: !config.leader || config.leader === "none" ? "ctrl+x" : config.leader, commandMap: TuiKeybind.CommandMap,
leader_timeout: config.leader_timeout, bindingDefaults: TuiKeybind.bindingDefaults(),
...resolveBindingSections<Renderable, KeyEvent, BindingSectionsConfig<Renderable, KeyEvent>, KeymapSection>( })
config.sections,
{
sections: KeymapSectionNames,
bindingDefaults: keymapBindingDefaults,
},
),
}
} }
export function createTuiResolvedConfig(input: ResolvedInput = {}): TuiConfig.Resolved { export function createTuiResolvedConfig(input: ResolvedInput = {}): TuiConfig.Resolved {
const keybinds = input.keybinds ?? ConfigKeybinds.Keybinds.parse({}) const keybinds = TuiKeybind.Keybinds.parse(input.keybinds ?? {})
return { return {
...input, ...input,
keybinds, keybinds: createTuiResolvedKeybinds(keybinds),
keymap: input.keymap ?? createTuiResolvedKeymap(LegacyKeymapTransform.create(input.keybinds ?? {})), leader_timeout: input.leader_timeout ?? 2000,
} }
} }

View File

@@ -22,9 +22,9 @@
"zod": "catalog:" "zod": "catalog:"
}, },
"peerDependencies": { "peerDependencies": {
"@opentui/core": ">=0.2.5", "@opentui/core": ">=0.2.6",
"@opentui/keymap": ">=0.2.5", "@opentui/keymap": ">=0.2.6",
"@opentui/solid": ">=0.2.5" "@opentui/solid": ">=0.2.6"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@opentui/core": { "@opentui/core": {

View File

@@ -18,8 +18,9 @@ import type {
import type { CliRenderer, KeyEvent, RGBA, Renderable, SlotMode } from "@opentui/core" import type { CliRenderer, KeyEvent, RGBA, Renderable, SlotMode } from "@opentui/core"
import type { Binding, Keymap } from "@opentui/keymap" import type { Binding, Keymap } from "@opentui/keymap"
import { import {
resolveBindingSections as resolveKeymapBindingSections, createBindingLookup as createKeymapBindingLookup,
type BindingSectionsConfig, type BindingConfig,
type CreateBindingLookupOptions,
type KeySequenceFormatPart, type KeySequenceFormatPart,
type SequenceBindingLike, type SequenceBindingLike,
} from "@opentui/keymap/extras" } from "@opentui/keymap/extras"
@@ -31,22 +32,21 @@ export { stringifyKeySequence, stringifyKeyStroke } from "@opentui/keymap"
export type { Binding, KeyLike, KeySequencePart, KeyStringifyInput, StringifyOptions } from "@opentui/keymap" export type { Binding, KeyLike, KeySequencePart, KeyStringifyInput, StringifyOptions } from "@opentui/keymap"
export { formatCommandBindings, formatKeySequence } from "@opentui/keymap/extras" export { formatCommandBindings, formatKeySequence } from "@opentui/keymap/extras"
export type { export type {
BindingSectionsConfig, BindingConfig,
BindingLookup,
BindingValue, BindingValue,
CreateBindingLookupOptions,
FormatCommandBindingsOptions, FormatCommandBindingsOptions,
FormatKeySequenceOptions, FormatKeySequenceOptions,
KeySequenceFormatPart, KeySequenceFormatPart,
SequenceBindingLike, SequenceBindingLike,
} from "@opentui/keymap/extras" } from "@opentui/keymap/extras"
export function resolveBindingSections<Section extends string>( export function createBindingLookup(
config: BindingSectionsConfig<Renderable, KeyEvent> | undefined, config: BindingConfig<Renderable, KeyEvent> | undefined,
options: { sections: readonly Section[] }, options?: CreateBindingLookupOptions<Renderable, KeyEvent>,
) { ) {
return resolveKeymapBindingSections<Renderable, KeyEvent, BindingSectionsConfig<Renderable, KeyEvent>, Section>( return createKeymapBindingLookup<Renderable, KeyEvent>(config ?? {}, options)
config ?? {},
options,
)
} }
export type TuiRouteCurrent = export type TuiRouteCurrent =
@@ -286,17 +286,20 @@ export type TuiState = {
mcp: () => ReadonlyArray<TuiSidebarMcpItem> mcp: () => ReadonlyArray<TuiSidebarMcpItem>
} }
type TuiConfigView = Pick<PluginConfig, "$schema" | "theme" | "keybinds" | "plugin"> & type TuiBindingLookupView = {
readonly bindings: ReadonlyArray<Binding<Renderable, KeyEvent>>
get: (command: string) => ReadonlyArray<Binding<Renderable, KeyEvent>>
has: (command: string) => boolean
gather: (name: string, commands: readonly string[]) => ReadonlyArray<Binding<Renderable, KeyEvent>>
pick: (name: string, commands: readonly string[]) => Binding<Renderable, KeyEvent>[]
omit: (name: string, commands: readonly string[]) => Binding<Renderable, KeyEvent>[]
}
type TuiConfigView = Pick<PluginConfig, "$schema" | "theme" | "plugin"> &
NonNullable<PluginConfig["tui"]> & { NonNullable<PluginConfig["tui"]> & {
leader_timeout: number
plugin_enabled?: Record<string, boolean> plugin_enabled?: Record<string, boolean>
keymap: { keybinds: TuiBindingLookupView
leader: string
leader_timeout: number
sections: Record<string, ReadonlyArray<Binding<Renderable, KeyEvent>>>
get: (section: string, cmd: string) => ReadonlyArray<Binding<Renderable, KeyEvent>> | undefined
pick: (section: string, commands: readonly string[]) => Binding<Renderable, KeyEvent>[]
omit: (section: string, commands: readonly string[]) => Binding<Renderable, KeyEvent>[]
}
} }
export type TuiApp = { export type TuiApp = {

View File

@@ -525,26 +525,20 @@ You can also define commands using markdown files in `~/.config/opencode/command
--- ---
### Keymap ### Keybinds
Customize TUI keyboard shortcuts in `tui.json` with `keymap`. Customize TUI keyboard shortcuts in `tui.json` with `keybinds`.
```json title="tui.json" ```json title="tui.json"
{ {
"$schema": "https://opencode.ai/tui.json", "$schema": "https://opencode.ai/tui.json",
"keymap": { "keybinds": {
"sections": { "command_list": "ctrl+p"
"global": {
"command.palette.show": "ctrl+p"
}
}
} }
} }
``` ```
`keymap` is merged with built-in defaults, so you only need to configure the shortcuts you want to change. `keybinds` is merged with built-in defaults, so you only need to configure the shortcuts you want to change.
The older `keybinds` field is deprecated and only applies when `keymap` is not present.
[Learn more here](/docs/keybinds). [Learn more here](/docs/keybinds).

View File

@@ -1,100 +1,218 @@
--- ---
title: Keybinds title: Keybinds
description: Customize your keyboard shortcuts. description: Customize your keybinds.
--- ---
OpenCode customizes TUI keyboard shortcuts with `keymap` in `tui.json`. OpenCode has a list of keybinds that you can customize through `tui.json`.
The older `keybinds` field is still accepted as a migration fallback, but it is deprecated and will be removed in OpenCode v2.0. If `keymap` is present, OpenCode ignores `keybinds` for shortcut resolution.
`keymap` is merged with built-in defaults, so you only need to configure the shortcuts you want to change.
---
## Leader key
OpenCode uses a `leader` key for many shortcuts. This avoids conflicts in your terminal.
By default, `ctrl+x` is the leader key and leader shortcuts require you to first press the leader key and then the shortcut. For example, to start a new session you first press `ctrl+x` and then press `n`.
You do not need to use a leader key, but we recommend doing so.
---
## Minimal example
```json title="tui.json" ```json title="tui.json"
{ {
"$schema": "https://opencode.ai/tui.json", "$schema": "https://opencode.ai/tui.json",
"keymap": { "leader_timeout": 2000,
"keybinds": {
"leader": "ctrl+x", "leader": "ctrl+x",
"leader_timeout": 2000, "app_exit": "ctrl+c,ctrl+d,<leader>q",
"sections": { "app_debug": "none",
"global": { "app_console": "none",
"command.palette.show": "ctrl+p", "app_heap_snapshot": "none",
"session.new": "<leader>n", "app_toggle_animations": "none",
"session.list": "<leader>l" "app_toggle_file_context": "none",
}, "app_toggle_diffwrap": "none",
"session": { "app_toggle_paste_summary": "none",
"session.compact": "<leader>c", "app_toggle_session_directory_filter": "none",
"session.undo": "<leader>u", "command_list": "ctrl+p",
"session.redo": "<leader>r" "help_show": "none",
}, "docs_open": "none",
"input": {
"input.submit": "return", "editor_open": "<leader>e",
"input.newline": ["shift+return", "ctrl+return", "alt+return", "ctrl+j"] "theme_list": "<leader>t",
} "theme_switch_mode": "none",
} "theme_mode_lock": "none",
"sidebar_toggle": "<leader>b",
"scrollbar_toggle": "none",
"status_view": "<leader>s",
"session_export": "<leader>x",
"session_copy": "none",
"session_new": "<leader>n",
"session_list": "<leader>l",
"session_timeline": "<leader>g",
"session_fork": "none",
"session_rename": "ctrl+r",
"session_delete": "ctrl+d",
"session_share": "none",
"session_unshare": "none",
"session_interrupt": "escape",
"session_compact": "<leader>c",
"session_toggle_timestamps": "none",
"session_toggle_generic_tool_output": "none",
"session_child_first": "<leader>down",
"session_child_cycle": "right",
"session_child_cycle_reverse": "left",
"session_parent": "up",
"stash_delete": "ctrl+d",
"model_provider_list": "ctrl+a",
"model_favorite_toggle": "ctrl+f",
"model_list": "<leader>m",
"model_cycle_recent": "f2",
"model_cycle_recent_reverse": "shift+f2",
"model_cycle_favorite": "none",
"model_cycle_favorite_reverse": "none",
"mcp_list": "none",
"provider_connect": "none",
"console_org_switch": "none",
"agent_list": "<leader>a",
"agent_cycle": "tab",
"agent_cycle_reverse": "shift+tab",
"variant_cycle": "ctrl+t",
"variant_list": "none",
"messages_page_up": "pageup,ctrl+alt+b",
"messages_page_down": "pagedown,ctrl+alt+f",
"messages_line_up": "ctrl+alt+y",
"messages_line_down": "ctrl+alt+e",
"messages_half_page_up": "ctrl+alt+u",
"messages_half_page_down": "ctrl+alt+d",
"messages_first": "ctrl+g,home",
"messages_last": "ctrl+alt+g,end",
"messages_next": "none",
"messages_previous": "none",
"messages_last_user": "none",
"messages_copy": "<leader>y",
"messages_undo": "<leader>u",
"messages_redo": "<leader>r",
"messages_toggle_conceal": "<leader>h",
"tool_details": "none",
"display_thinking": "none",
"prompt_submit": "none",
"prompt_editor_context_clear": "none",
"prompt_skills": "none",
"prompt_stash": "none",
"prompt_stash_pop": "none",
"prompt_stash_list": "none",
"workspace_set": "none",
"input_clear": "ctrl+c",
"input_paste": {
"key": "ctrl+v",
"preventDefault": false
},
"input_submit": "return",
"input_newline": "shift+return,ctrl+return,alt+return,ctrl+j",
"input_move_left": "left,ctrl+b",
"input_move_right": "right,ctrl+f",
"input_move_up": "up",
"input_move_down": "down",
"input_select_left": "shift+left",
"input_select_right": "shift+right",
"input_select_up": "shift+up",
"input_select_down": "shift+down",
"input_line_home": "ctrl+a",
"input_line_end": "ctrl+e",
"input_select_line_home": "ctrl+shift+a",
"input_select_line_end": "ctrl+shift+e",
"input_visual_line_home": "alt+a",
"input_visual_line_end": "alt+e",
"input_select_visual_line_home": "alt+shift+a",
"input_select_visual_line_end": "alt+shift+e",
"input_buffer_home": "home",
"input_buffer_end": "end",
"input_select_buffer_home": "shift+home",
"input_select_buffer_end": "shift+end",
"input_delete_line": "ctrl+shift+d",
"input_delete_to_line_end": "ctrl+k",
"input_delete_to_line_start": "ctrl+u",
"input_backspace": "backspace,shift+backspace",
"input_delete": "ctrl+d,delete,shift+delete",
"input_undo": "ctrl+-,super+z",
"input_redo": "ctrl+.,super+shift+z",
"input_word_forward": "alt+f,alt+right,ctrl+right",
"input_word_backward": "alt+b,alt+left,ctrl+left",
"input_select_word_forward": "alt+shift+f,alt+shift+right",
"input_select_word_backward": "alt+shift+b,alt+shift+left",
"input_delete_word_forward": "alt+d,alt+delete,ctrl+delete",
"input_delete_word_backward": "ctrl+w,ctrl+backspace,alt+backspace",
"input_select_all": "super+a",
"history_previous": "up",
"history_next": "down",
"dialog.select.prev": "up,ctrl+p",
"dialog.select.next": "down,ctrl+n",
"dialog.select.page_up": "pageup",
"dialog.select.page_down": "pagedown",
"dialog.select.home": "home",
"dialog.select.end": "end",
"dialog.select.submit": "return",
"dialog.mcp.toggle": "space",
"prompt.autocomplete.prev": "up,ctrl+p",
"prompt.autocomplete.next": "down,ctrl+n",
"prompt.autocomplete.hide": "escape",
"prompt.autocomplete.select": "return",
"prompt.autocomplete.complete": "tab",
"permission.prompt.fullscreen": "ctrl+f",
"plugins.toggle": "space",
"dialog.plugins.install": "shift+i",
"terminal_suspend": "ctrl+z",
"terminal_title_toggle": "none",
"tips_toggle": "<leader>h",
"plugin_manager": "none",
"plugin_install": "none",
"which_key_toggle": "ctrl+alt+k",
"which_key_layout_toggle": "ctrl+alt+shift+k",
"which_key_pending_toggle": "ctrl+alt+shift+p",
"which_key_group_previous": "ctrl+alt+left,ctrl+alt+[",
"which_key_group_next": "ctrl+alt+right,ctrl+alt+]",
"which_key_scroll_up": "ctrl+alt+up,ctrl+alt+p",
"which_key_scroll_down": "ctrl+alt+down,ctrl+alt+n",
"which_key_page_up": "ctrl+alt+pageup",
"which_key_page_down": "ctrl+alt+pagedown",
"which_key_home": "ctrl+alt+home",
"which_key_end": "ctrl+alt+end"
} }
} }
``` ```
--- :::note
On Windows, the defaults for `input_undo` and `terminal_suspend` are different:
## Keymap structure - `input_undo` defaults to `ctrl+z,ctrl+-,super+z` when it is not explicitly configured. The `ctrl+z` binding is added because Windows terminals do not support POSIX suspend.
- `terminal_suspend` is forced to `none` because native Windows terminals do not support POSIX suspend.
`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 `<leader>` sequences. Defaults to `ctrl+x`. |
| `leader_timeout` | How long OpenCode waits for the next key after the leader key, in milliseconds. Defaults to `2000`. |
| `sections` | A map of TUI areas to command bindings. |
--- ---
## Binding values ## Leader Key
A string can contain one shortcut or multiple comma-separated shortcuts. You can also use an array for multiple shortcuts, or `"none"`/`false` to disable a command. OpenCode uses a `leader` key for many keybinds. This avoids conflicts in your terminal.
```json title="tui.json" By default, `ctrl+x` is the leader key and many actions require you to first press the leader key and then the shortcut. For example, to start a new session you first press `ctrl+x` and then press `n`.
{
"$schema": "https://opencode.ai/tui.json", You don't need to use a leader key for your keybinds but we recommend doing so.
"keymap": {
"sections": { Some navigation keybinds intentionally do not use the leader key by default. For subagent sessions, the defaults are `session_child_first` = `<leader>down`, `session_child_cycle` = `right`, `session_child_cycle_reverse` = `left`, and `session_parent` = `up`.
"session": {
"session.compact": "none", `leader_timeout` controls how long OpenCode waits for the next key after the leader key. It defaults to `2000` milliseconds.
"session.export": "<leader>x,ctrl+shift+x",
"session.copy": ["<leader>y", "ctrl+shift+c"] ---
}
} ## Binding Values
}
} A string can contain one shortcut or multiple comma-separated shortcuts. You can also use an array for multiple shortcuts.
```
For advanced cases, use an object with `key`, `event`, `preventDefault`, or `fallthrough`. For advanced cases, use an object with `key`, `event`, `preventDefault`, or `fallthrough`.
```json title="tui.json" ```json title="tui.json"
{ {
"$schema": "https://opencode.ai/tui.json", "$schema": "https://opencode.ai/tui.json",
"keymap": { "keybinds": {
"sections": { "messages_copy": ["<leader>y", "ctrl+shift+c"],
"prompt": { "input_paste": {
"prompt.paste": { "key": "ctrl+v",
"key": "ctrl+v", "preventDefault": false
"preventDefault": false
}
}
} }
} }
} }
@@ -102,219 +220,22 @@ For advanced cases, use an object with `key`, `event`, `preventDefault`, or `fal
--- ---
## Complete keymap reference ## Disable Keybind
This example lists the built-in sections, command names, and default fallback bindings. Commands set to `"none"` are available to bind but disabled by default. You can disable a keybind by adding the key to `tui.json` with a value of `"none"` or `false`.
```json title="tui.json"
{
"$schema": "https://opencode.ai/tui.json",
"keymap": {
"leader": "ctrl+x",
"leader_timeout": 2000,
"sections": {
"global": {
"command.palette.show": "ctrl+p",
"session.list": "<leader>l",
"session.new": "<leader>n",
"model.list": "<leader>m",
"model.cycle_recent": "f2",
"model.cycle_recent_reverse": "shift+f2",
"model.cycle_favorite": "none",
"model.cycle_favorite_reverse": "none",
"agent.list": "<leader>a",
"mcp.list": "none",
"agent.cycle": "tab",
"agent.cycle.reverse": "shift+tab",
"variant.cycle": "ctrl+t",
"variant.list": "none",
"provider.connect": "none",
"console.org.switch": "none",
"opencode.status": "<leader>s",
"theme.switch": "<leader>t",
"theme.switch_mode": "none",
"theme.mode.lock": "none",
"help.show": "none",
"docs.open": "none",
"app.exit": "ctrl+c,ctrl+d,<leader>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": "<leader>g",
"session.fork": "none",
"session.compact": "<leader>c",
"session.unshare": "none",
"session.undo": "<leader>u",
"session.redo": "<leader>r",
"session.sidebar.toggle": "<leader>b",
"session.toggle.conceal": "<leader>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": "<leader>y",
"session.copy": "none",
"session.export": "<leader>x",
"session.child.first": "<leader>down",
"session.parent": "up",
"session.child.next": "right",
"session.child.previous": "left"
},
"prompt": {
"prompt.submit": "none",
"prompt.editor": "<leader>e",
"prompt.editor_context.clear": "none",
"prompt.skills": "none",
"prompt.stash": "none",
"prompt.stash.pop": "none",
"prompt.stash.list": "none",
"workspace.set": "none",
"session.interrupt": "escape",
"prompt.clear": "ctrl+c",
"prompt.paste": {
"key": "ctrl+v",
"preventDefault": false
},
"prompt.history.previous": "up",
"prompt.history.next": "down"
},
"autocomplete": {
"prompt.autocomplete.prev": "up,ctrl+p",
"prompt.autocomplete.next": "down,ctrl+n",
"prompt.autocomplete.hide": "escape",
"prompt.autocomplete.select": "return",
"prompt.autocomplete.complete": "tab"
},
"input": {
"input.submit": "return",
"input.newline": "shift+return,ctrl+return,alt+return,ctrl+j",
"input.move.left": "left,ctrl+b",
"input.move.right": "right,ctrl+f",
"input.move.up": "up",
"input.move.down": "down",
"input.select.left": "shift+left",
"input.select.right": "shift+right",
"input.select.up": "shift+up",
"input.select.down": "shift+down",
"input.line.home": "ctrl+a",
"input.line.end": "ctrl+e",
"input.select.line.home": "ctrl+shift+a",
"input.select.line.end": "ctrl+shift+e",
"input.visual.line.home": "alt+a",
"input.visual.line.end": "alt+e",
"input.select.visual.line.home": "alt+shift+a",
"input.select.visual.line.end": "alt+shift+e",
"input.buffer.home": "home",
"input.buffer.end": "end",
"input.select.buffer.home": "shift+home",
"input.select.buffer.end": "shift+end",
"input.delete.line": "ctrl+shift+d",
"input.delete.to.line.end": "ctrl+k",
"input.delete.to.line.start": "ctrl+u",
"input.backspace": "backspace,shift+backspace",
"input.delete": "ctrl+d,delete,shift+delete",
"input.undo": "ctrl+-,super+z",
"input.redo": "ctrl+.,super+shift+z",
"input.word.forward": "alt+f,alt+right,ctrl+right",
"input.word.backward": "alt+b,alt+left,ctrl+left",
"input.select.word.forward": "alt+shift+f,alt+shift+right",
"input.select.word.backward": "alt+shift+b,alt+shift+left",
"input.delete.word.forward": "alt+d,alt+delete,ctrl+delete",
"input.delete.word.backward": "ctrl+w,ctrl+backspace,alt+backspace",
"input.select.all": "super+a"
},
"dialog_select": {
"dialog.select.prev": "up,ctrl+p",
"dialog.select.next": "down,ctrl+n",
"dialog.select.page_up": "pageup",
"dialog.select.page_down": "pagedown",
"dialog.select.home": "home",
"dialog.select.end": "end",
"dialog.select.submit": "return"
},
"dialog_actions": {
"dialog.action.toggle": "space",
"dialog.action.delete": "ctrl+d",
"dialog.action.rename": "ctrl+r"
},
"model": {
"model.dialog.provider": "ctrl+a",
"model.dialog.favorite": "ctrl+f"
},
"permission": {
"permission.reject.cancel": "ctrl+c,ctrl+d,<leader>q",
"permission.prompt.escape": "ctrl+c,ctrl+d,<leader>q",
"permission.prompt.fullscreen": "ctrl+f"
},
"question": {
"question.reject": "ctrl+c,ctrl+d,<leader>q",
"question.edit.clear": "ctrl+c"
},
"plugins": {
"plugins.list": "none",
"plugins.install": "none",
"plugin.dialog.install": "shift+i"
},
"home_tips": {
"tips.toggle": "<leader>h"
}
}
}
}
```
---
## Legacy keybinds
`keybinds` is deprecated. It is kept so existing configs continue to work while users migrate to `keymap`.
Only use `keybinds` when `keymap` is not present. If both fields are set, `keymap` wins and `keybinds` are ignored for shortcut resolution.
```json title="tui.json" ```json title="tui.json"
{ {
"$schema": "https://opencode.ai/tui.json", "$schema": "https://opencode.ai/tui.json",
"keybinds": { "keybinds": {
"command_list": "ctrl+p", "session_compact": "none"
"session_new": "<leader>n",
"session_compact": "<leader>c"
} }
} }
``` ```
:::note
On native Windows, the defaults for undo and terminal suspend are different for both `keymap` and legacy `keybinds`:
- `input.undo` defaults to `ctrl+z,ctrl+-,super+z` when it is not explicitly configured (the `ctrl+z` binding is added because Windows terminals do not support POSIX suspend).
- `terminal.suspend` is disabled because native Windows terminals do not support POSIX suspend.
:::
--- ---
## Desktop prompt shortcuts ## Desktop Prompt Shortcuts
The OpenCode desktop app prompt input supports common Readline/Emacs-style shortcuts for editing text. These are built-in and currently not configurable via `opencode.json`. The OpenCode desktop app prompt input supports common Readline/Emacs-style shortcuts for editing text. These are built-in and currently not configurable via `opencode.json`.

View File

@@ -353,14 +353,10 @@ You can customize TUI behavior through `tui.json` (or `tui.jsonc`).
{ {
"$schema": "https://opencode.ai/tui.json", "$schema": "https://opencode.ai/tui.json",
"theme": "opencode", "theme": "opencode",
"keymap": { "leader_timeout": 2000,
"keybinds": {
"leader": "ctrl+x", "leader": "ctrl+x",
"leader_timeout": 2000, "command_list": "ctrl+p"
"sections": {
"global": {
"command.palette.show": "ctrl+p"
}
}
}, },
"scroll_speed": 3, "scroll_speed": 3,
"scroll_acceleration": { "scroll_acceleration": {
@@ -373,13 +369,13 @@ You can customize TUI behavior through `tui.json` (or `tui.jsonc`).
This is separate from `opencode.json`, which configures server/runtime behavior. This is separate from `opencode.json`, which configures server/runtime behavior.
`keymap` is merged with built-in defaults, so you only need to configure the shortcuts you want to change. `keybinds` is merged with built-in defaults, so you only need to configure the shortcuts you want to change.
### Options ### Options
- `theme` - Sets your UI theme. [Learn more](/docs/themes). - `theme` - Sets your UI theme. [Learn more](/docs/themes).
- `keymap` - Customizes keyboard shortcuts. [Learn more](/docs/keybinds). - `keybinds` - Customizes keyboard shortcuts. [Learn more](/docs/keybinds).
- `keybinds` - Deprecated legacy shortcut config. This only applies when `keymap` is not present. - `leader_timeout` - Controls how long OpenCode waits after the leader key. Defaults to `2000`.
- `scroll_acceleration.enabled` - Enable macOS-style scroll acceleration for smooth, natural scrolling. When enabled, scroll speed increases with rapid scrolling gestures and stays precise for slower movements. **This setting takes precedence over `scroll_speed` and overrides it when enabled.** - `scroll_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`.** - `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. - `diff_style` - Controls diff rendering. `"auto"` adapts to terminal width, `"stacked"` always shows a single-column layout.