mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-13 15:44:56 +00:00
flatten to keybind compatible config (#26421)
This commit is contained in:
@@ -2,69 +2,47 @@
|
||||
import { useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import { useBindings, useKeymapSelector } from "@opentui/keymap/solid"
|
||||
import { RGBA, VignetteEffect, type KeyEvent, type Renderable } from "@opentui/core"
|
||||
import { resolveBindingSections, type BindingSectionsConfig, type BindingValue } from "@opentui/keymap/extras"
|
||||
import type { Binding } from "@opentui/keymap"
|
||||
import { createBindingLookup, type BindingConfig } from "@opentui/keymap/extras"
|
||||
import type { TuiPlugin, TuiPluginApi, TuiPluginMeta, TuiPluginModule, TuiSlotPlugin } from "@opencode-ai/plugin/tui"
|
||||
|
||||
const tabs = ["overview", "counter", "help"]
|
||||
const command = {
|
||||
modal: "plugin.smoke.modal",
|
||||
screen: "plugin.smoke.screen",
|
||||
alert: "plugin.smoke.alert",
|
||||
confirm: "plugin.smoke.confirm",
|
||||
prompt: "plugin.smoke.prompt",
|
||||
select: "plugin.smoke.select",
|
||||
host: "plugin.smoke.host",
|
||||
home: "plugin.smoke.home",
|
||||
toast: "plugin.smoke.toast",
|
||||
dialog_close: "plugin.smoke.dialog.close",
|
||||
local_push: "plugin.smoke.local.push",
|
||||
local_pop: "plugin.smoke.local.pop",
|
||||
screen_home: "plugin.smoke.screen.home",
|
||||
screen_left: "plugin.smoke.screen.left",
|
||||
screen_right: "plugin.smoke.screen.right",
|
||||
screen_up: "plugin.smoke.screen.up",
|
||||
screen_down: "plugin.smoke.screen.down",
|
||||
screen_modal: "plugin.smoke.screen.modal",
|
||||
screen_local: "plugin.smoke.screen.local",
|
||||
screen_host: "plugin.smoke.screen.host",
|
||||
screen_alert: "plugin.smoke.screen.alert",
|
||||
screen_confirm: "plugin.smoke.screen.confirm",
|
||||
screen_prompt: "plugin.smoke.screen.prompt",
|
||||
screen_select: "plugin.smoke.screen.select",
|
||||
modal_accept: "plugin.smoke.modal.accept",
|
||||
modal_close: "plugin.smoke.modal.close",
|
||||
} as const
|
||||
|
||||
const sectionNames = ["global", "dialog", "local", "screen", "modal"] as const
|
||||
type SectionName = (typeof sectionNames)[number]
|
||||
type SectionConfig = Record<string, BindingValue<Renderable, KeyEvent>>
|
||||
type ResolvedSections = Record<SectionName, Binding<Renderable, KeyEvent>[]>
|
||||
type SmokeKeymap = {
|
||||
sections?: Partial<Record<SectionName, SectionConfig>>
|
||||
modal: "smoke_modal",
|
||||
screen: "smoke_screen",
|
||||
alert: "smoke_alert",
|
||||
confirm: "smoke_confirm",
|
||||
prompt: "smoke_prompt",
|
||||
select: "smoke_select",
|
||||
host: "smoke_host",
|
||||
home: "smoke_home",
|
||||
toast: "smoke_toast",
|
||||
dialog_close: "smoke_dialog_close",
|
||||
local_push: "smoke_local_push",
|
||||
local_pop: "smoke_local_pop",
|
||||
screen_home: "smoke_screen_home",
|
||||
screen_left: "smoke_screen_left",
|
||||
screen_right: "smoke_screen_right",
|
||||
screen_up: "smoke_screen_up",
|
||||
screen_down: "smoke_screen_down",
|
||||
screen_modal: "smoke_screen_modal",
|
||||
screen_local: "smoke_screen_local",
|
||||
screen_host: "smoke_screen_host",
|
||||
screen_alert: "smoke_screen_alert",
|
||||
screen_confirm: "smoke_screen_confirm",
|
||||
screen_prompt: "smoke_screen_prompt",
|
||||
screen_select: "smoke_screen_select",
|
||||
modal_accept: "smoke_modal_accept",
|
||||
modal_close: "smoke_modal_close",
|
||||
}
|
||||
|
||||
type SmokeOptions = {
|
||||
enabled?: boolean
|
||||
label?: unknown
|
||||
route?: unknown
|
||||
vignette?: unknown
|
||||
keymap?: SmokeKeymap
|
||||
}
|
||||
type SmokeBindings = BindingConfig<Renderable, KeyEvent>
|
||||
|
||||
const defaultKeymap = {
|
||||
global: {
|
||||
[command.modal]: "ctrl+shift+m",
|
||||
[command.screen]: "ctrl+shift+o",
|
||||
},
|
||||
dialog: {
|
||||
[command.dialog_close]: "escape",
|
||||
},
|
||||
local: {
|
||||
[command.local_push]: "enter,return",
|
||||
[command.local_pop]: "escape,q,backspace",
|
||||
},
|
||||
screen: {
|
||||
[command.screen_home]: "escape,ctrl+h",
|
||||
[command.screen_left]: "left,h",
|
||||
[command.screen_right]: "right,l",
|
||||
@@ -77,12 +55,9 @@ const defaultKeymap = {
|
||||
[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) => {
|
||||
if (typeof value !== "string") return fallback
|
||||
@@ -95,11 +70,14 @@ const num = (value: unknown, fallback: number) => {
|
||||
return value
|
||||
}
|
||||
|
||||
const record = (value: unknown): value is Record<string, unknown> =>
|
||||
!!value && typeof value === "object" && !Array.isArray(value)
|
||||
|
||||
type Cfg = {
|
||||
label: string
|
||||
route: string
|
||||
vignette: number
|
||||
keymap: SmokeKeymap | undefined
|
||||
keybinds: SmokeBindings | undefined
|
||||
}
|
||||
|
||||
type Route = {
|
||||
@@ -116,12 +94,12 @@ type State = {
|
||||
local: number
|
||||
}
|
||||
|
||||
const cfg = (options: SmokeOptions | undefined) => {
|
||||
const cfg = (options: Record<string, unknown> | undefined) => {
|
||||
return {
|
||||
label: pick(options?.label, "smoke"),
|
||||
route: pick(options?.route, "workspace-smoke"),
|
||||
vignette: Math.max(0, num(options?.vignette, 0.35)),
|
||||
keymap: options?.keymap,
|
||||
keybinds: record(options?.keybinds) ? (options.keybinds as SmokeBindings) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,21 +110,8 @@ const names = (input: Cfg) => {
|
||||
}
|
||||
}
|
||||
|
||||
function createKeys(input: SmokeKeymap | undefined): { sections: ResolvedSections } {
|
||||
const sections = resolveBindingSections(
|
||||
{
|
||||
global: { ...defaultKeymap.global, ...input?.sections?.global },
|
||||
dialog: { ...defaultKeymap.dialog, ...input?.sections?.dialog },
|
||||
local: { ...defaultKeymap.local, ...input?.sections?.local },
|
||||
screen: { ...defaultKeymap.screen, ...input?.sections?.screen },
|
||||
modal: { ...defaultKeymap.modal, ...input?.sections?.modal },
|
||||
} satisfies BindingSectionsConfig<Renderable, KeyEvent>,
|
||||
{ sections: sectionNames },
|
||||
).sections
|
||||
|
||||
return {
|
||||
sections,
|
||||
}
|
||||
function createKeys(input: SmokeBindings | undefined) {
|
||||
return createBindingLookup({ ...defaultKeymap, ...input })
|
||||
}
|
||||
|
||||
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(() => ({
|
||||
@@ -395,7 +360,7 @@ const Screen = (props: {
|
||||
},
|
||||
},
|
||||
],
|
||||
bindings: props.keys.sections.local,
|
||||
bindings: props.keys.gather("smoke.local", [command.local_push, command.local_pop]),
|
||||
}))
|
||||
|
||||
useBindings(() => ({
|
||||
@@ -478,7 +443,20 @@ const Screen = (props: {
|
||||
},
|
||||
},
|
||||
],
|
||||
bindings: props.keys.sections.screen,
|
||||
bindings: props.keys.gather("smoke.screen", [
|
||||
command.screen_home,
|
||||
command.screen_left,
|
||||
command.screen_right,
|
||||
command.screen_up,
|
||||
command.screen_down,
|
||||
command.screen_modal,
|
||||
command.screen_local,
|
||||
command.screen_host,
|
||||
command.screen_alert,
|
||||
command.screen_confirm,
|
||||
command.screen_prompt,
|
||||
command.screen_select,
|
||||
]),
|
||||
}))
|
||||
const shortcuts = useKeymapSelector((keymap) => {
|
||||
const bindings = keymap.getCommandBindings({
|
||||
@@ -687,7 +665,7 @@ const Modal = (props: {
|
||||
},
|
||||
},
|
||||
],
|
||||
bindings: props.keys.sections.modal,
|
||||
bindings: props.keys.gather("smoke.modal", [command.modal_accept, command.modal_close]),
|
||||
}))
|
||||
const shortcuts = useKeymapSelector((keymap) => {
|
||||
const bindings = keymap.getCommandBindings({
|
||||
@@ -766,25 +744,8 @@ const home = (api: TuiPluginApi, input: Cfg) => ({
|
||||
},
|
||||
home_prompt(ctx, value) {
|
||||
const skin = look(ctx.theme.current)
|
||||
type Prompt = (props: {
|
||||
workspaceID?: string
|
||||
visible?: boolean
|
||||
disabled?: boolean
|
||||
onSubmit?: () => void
|
||||
hint?: JSX.Element
|
||||
right?: JSX.Element
|
||||
showPlaceholder?: boolean
|
||||
placeholders?: {
|
||||
normal?: string[]
|
||||
shell?: string[]
|
||||
}
|
||||
}) => JSX.Element
|
||||
type Slot = (
|
||||
props: { name: string; mode?: unknown; children?: JSX.Element } & Record<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 Prompt = api.ui.Prompt
|
||||
const Slot = api.ui.Slot
|
||||
const normal = [
|
||||
`[SMOKE] route check for ${input.label}`,
|
||||
"[SMOKE] confirm home_prompt slot override",
|
||||
@@ -1003,20 +964,29 @@ const reg = (api: TuiPluginApi, input: Cfg, keys: Keys) => {
|
||||
},
|
||||
},
|
||||
],
|
||||
bindings: keys.sections.global,
|
||||
bindings: keys.gather("smoke.global", [
|
||||
command.modal,
|
||||
command.screen,
|
||||
command.alert,
|
||||
command.confirm,
|
||||
command.prompt,
|
||||
command.select,
|
||||
command.host,
|
||||
command.home,
|
||||
command.toast,
|
||||
]),
|
||||
})
|
||||
}
|
||||
|
||||
const tui: TuiPlugin = async (api, options, meta) => {
|
||||
const input = options as SmokeOptions | undefined
|
||||
if (input?.enabled === false) return
|
||||
if (options?.enabled === false) return
|
||||
|
||||
await api.theme.install("./smoke-theme.json")
|
||||
api.theme.set("smoke-theme")
|
||||
|
||||
const value = cfg(input)
|
||||
const value = cfg(options)
|
||||
const route = names(value)
|
||||
const keys = createKeys(value.keymap)
|
||||
const keys = createKeys(value.keybinds)
|
||||
const fx = new VignetteEffect(value.vignette)
|
||||
const post = fx.apply.bind(fx)
|
||||
api.renderer.addPostProcessFn(post)
|
||||
|
||||
@@ -6,20 +6,12 @@
|
||||
{
|
||||
"enabled": false,
|
||||
"label": "workspace",
|
||||
"keymap": {
|
||||
"sections": {
|
||||
"global": {
|
||||
"plugin.smoke.modal": "ctrl+alt+m",
|
||||
"plugin.smoke.screen": "ctrl+alt+o"
|
||||
},
|
||||
"screen": {
|
||||
"plugin.smoke.screen.home": "escape,ctrl+shift+h",
|
||||
"plugin.smoke.screen.modal": "ctrl+alt+m"
|
||||
},
|
||||
"dialog": {
|
||||
"plugin.smoke.dialog.close": "escape,q"
|
||||
}
|
||||
}
|
||||
"keybinds": {
|
||||
"smoke_modal": "ctrl+alt+m",
|
||||
"smoke_screen": "ctrl+alt+o",
|
||||
"smoke_screen_home": "escape,ctrl+shift+h",
|
||||
"smoke_screen_modal": "ctrl+alt+m",
|
||||
"smoke_dialog_close": "escape,q"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
30
bun.lock
30
bun.lock
@@ -519,9 +519,9 @@
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.2.5",
|
||||
"@opentui/keymap": ">=0.2.5",
|
||||
"@opentui/solid": ">=0.2.5",
|
||||
"@opentui/core": ">=0.2.6",
|
||||
"@opentui/keymap": ">=0.2.6",
|
||||
"@opentui/solid": ">=0.2.6",
|
||||
},
|
||||
"optionalPeers": [
|
||||
"@opentui/core",
|
||||
@@ -700,9 +700,9 @@
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opentui/core": "0.2.5",
|
||||
"@opentui/keymap": "0.2.5",
|
||||
"@opentui/solid": "0.2.5",
|
||||
"@opentui/core": "0.2.6",
|
||||
"@opentui/keymap": "0.2.6",
|
||||
"@opentui/solid": "0.2.6",
|
||||
"@pierre/diffs": "1.1.0-beta.18",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@sentry/solid": "10.36.0",
|
||||
@@ -1631,23 +1631,23 @@
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.2.5", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.5", "@opentui/core-darwin-x64": "0.2.5", "@opentui/core-linux-arm64": "0.2.5", "@opentui/core-linux-x64": "0.2.5", "@opentui/core-win32-arm64": "0.2.5", "@opentui/core-win32-x64": "0.2.5" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-A5DNOW39S60LtOcBdWYx7fuIGsPcClzbdKz9WuLp+wgy0Bt/jPw5XX6dk3k4dCX4jmhA1nX7x7680+GXLHPL6Q=="],
|
||||
"@opentui/core": ["@opentui/core@0.2.6", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.6", "@opentui/core-darwin-x64": "0.2.6", "@opentui/core-linux-arm64": "0.2.6", "@opentui/core-linux-x64": "0.2.6", "@opentui/core-win32-arm64": "0.2.6", "@opentui/core-win32-x64": "0.2.6" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-dBpMaWVM7wtW2/2TlGPrkPjg6gOL3MVU/5XXk+U1LDJB8L4q4NeYWVdzfAVNcEvgmuuCy/cVqdY2D4ei+e7MMg=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Jdl8TN7oxV8NTaKZsUAt0B/A4hIYiyUKwXNSe4w1OchNMlgjwF1fx/7RhgHXSvWh1Fcqi1IH5FfhsmO89Aed1A=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-hR5nsxNj+059utzenTCF0kealUlibON6fLuebFUCGM/5kJnqa+shIh0XbUDFm0+F47vqVUgZufBdUuieQZIbvQ=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-78sKg0ZvwFHzZZGJCeaSNIVi2dadDxQymHAmrK698zEgnQr4eLVVB+MxNpxJx55/z9Y+YqbfSZaobC6w6Q3y5A=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-pJ/bH4WC/mbBaakM1YdH6TVo67jhy0KPd61bCz97w0I/PJGr8fmNKvhmMt/AwyFgOQi3FYZiEKLMpGdvUcSsrQ=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-BtqbOjP64hKQaVd0ApHunt0MjkEEKTvxpaBwk7OhwVCoYakQBDZTZXUQ9zuPXvaHc9IF286z1PnJGLu0t11BAw=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Pnd3kOxig8ii+/IqYheOPEgferylsQA0L6tKBnHQ9jRlCJOcu0Rv65Jepueh212vevdV9DzPURJnhejG06J6g=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.5", "", { "os": "linux", "cpu": "x64" }, "sha512-c3sEXtmOd1E5R4wfWh/MejplxgApYKqzyJ0AVMTU8pU1MHRAMwD8UFDMSVQhl7rYMTuBYPWok3IoCK2u8a2A4A=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-458Mx9tBzEPzfft8cSt5ZaIpEepoxBXBOL6AUVmDTKWaZ3uouraPcEKraGAyvOTDQp2XDI3R8c/2GdaR77FaUQ=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WlgpYkgmuvMPc2mYGJSwN7c+VGAxiZvMKwZEbS+w9PMj7sJhvY+zFrOJNFpvjbAFw8vS3Kz39km4Nj7GF8JH6w=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-BDUrdrT1RCcVnQoHJmUut4y811jDBAEtc6GJFB4Gs265Be8SrTjVCus6p2fSQ7j9sZQ1OcjO+5+4NkheSZICDQ=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.5", "", { "os": "win32", "cpu": "x64" }, "sha512-4X7BHJ7Wztzj7p0E+SsN0d4goUVU7Dy2VnhnD4n65ODgVbW59iqasAvbnPLbX3ghjgKiwQ+2SD+ImCIHE6uCAA=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-SUYAzRJ9TSoD2Qt8kn6FJz6dbTrFEPVig5mScB4zFGgGQO/Bbod2/Q31vLS/IQrX+FDb67WaErD+kuMCnMPPLA=="],
|
||||
|
||||
"@opentui/keymap": ["@opentui/keymap@0.2.5", "", { "dependencies": { "@opentui/core": "0.2.5" }, "peerDependencies": { "@opentui/react": "0.2.5", "@opentui/solid": "0.2.5", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-/B6Gy9LLRRKhvyDV1rFX0p7BUN8NQOcXwTV8E0xb7ym1yREvVmij+hCRkXXddMme2HW9NmV0+RRHo4kJzJxkNQ=="],
|
||||
"@opentui/keymap": ["@opentui/keymap@0.2.6", "", { "dependencies": { "@opentui/core": "0.2.6" }, "peerDependencies": { "@opentui/react": "0.2.6", "@opentui/solid": "0.2.6", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-+6OYuedrFCKVo4ryGFNwws++2VOmPcXU3PwpY0mP47gYQY2nvQ+etWIs2Y7r5eMIqUfxVCldkKsrzcEcA4tb/A=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.2.5", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.5", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-M8MxDYJzjtF8TvxB6Q7656GOSS+QIg89jD0jf/asfF4qeip5TQhNZ3ba+R1v2fVuIkQCyRJzTtOtMZiglzGKPQ=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.2.6", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.6", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-2y225WlOGi/fCaajkxBmLyVW8Cr+OmhowHdvrYcz5w2kBD15sKbJLIYu1G9DxceirT1uIyasGy2TGzRRcVkTDg=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
|
||||
@@ -35,9 +35,9 @@
|
||||
"@types/cross-spawn": "6.0.6",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.2.5",
|
||||
"@opentui/keymap": "0.2.5",
|
||||
"@opentui/solid": "0.2.5",
|
||||
"@opentui/core": "0.2.6",
|
||||
"@opentui/keymap": "0.2.6",
|
||||
"@opentui/solid": "0.2.6",
|
||||
"ulid": "3.0.1",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@types/luxon": "3.7.1",
|
||||
|
||||
@@ -20,6 +20,12 @@ Example:
|
||||
{
|
||||
"$schema": "https://opencode.ai/tui.json",
|
||||
"theme": "smoke-theme",
|
||||
"leader_timeout": 2000,
|
||||
"keybinds": {
|
||||
"leader": "ctrl+x",
|
||||
"command_list": "ctrl+p",
|
||||
"session_new": "<leader>n"
|
||||
},
|
||||
"plugin": ["@acme/opencode-plugin@1.2.3", ["./plugins/demo.tsx", { "label": "demo" }]],
|
||||
"plugin_enabled": {
|
||||
"acme.demo": false
|
||||
@@ -39,6 +45,9 @@ Example:
|
||||
- Internal plugins can declare `enabled: false` to be registered but inactive by default; `plugin_enabled` and runtime KV can still enable them by id.
|
||||
- `plugin_enabled` is merged across config layers.
|
||||
- Runtime enable/disable state is also stored in KV under `plugin_enabled`; that KV state overrides config on startup.
|
||||
- `leader_timeout` is a top-level TUI setting.
|
||||
- `keybinds` is a flat object keyed by command id; values are key binding values (`false`, `"none"`, a key string/object, a binding object, or an array of key strings/objects/binding objects).
|
||||
- `keybinds.leader` sets the key used by `<leader>` shortcuts.
|
||||
|
||||
## Author package shape
|
||||
|
||||
@@ -228,14 +237,14 @@ Top-level API groups exposed to `tui(api, options, meta)`:
|
||||
- To surface a command in the host command palette, set `namespace: "palette"` and provide metadata such as `title`, `category`, `desc`, `suggested`, `hidden`, `enabled`, `slashName`, and `slashAliases` on the command.
|
||||
- Use `api.keymap.dispatchCommand(name)` for user-style execution semantics and `api.keymap.runCommand(name)` only for forced programmatic execution.
|
||||
- Disposers returned by `api.keymap` registrations and `acquireResource(...)` are automatically cleaned up when the plugin deactivates. You do not need to add those disposers to `api.lifecycle.onDispose(...)` yourself.
|
||||
- Built-in which-key shortcuts are resolved from `keymap.sections.which_key`, not plugin options.
|
||||
- Built-in which-key shortcuts are resolved from flat `keybinds` command ids such as `which_key_toggle`, not plugin options.
|
||||
|
||||
### Keys
|
||||
|
||||
- `api.keys` exposes host-formatted shortcut display helpers for plugin UI.
|
||||
- `formatSequence(parts)` formats parsed key sequence parts using the host's display policy.
|
||||
- `formatBindings(bindings)` formats binding lists and returns `undefined` when there is nothing to show.
|
||||
- For generic config-to-bindings helpers, import `resolveBindingSections` from `@opencode-ai/plugin/tui`.
|
||||
- For generic config-to-bindings helpers, import `createBindingLookup` from `@opencode-ai/plugin/tui`.
|
||||
|
||||
### Routes
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -6,7 +6,9 @@
|
||||
// history ring. All are async because they read config or hit the SDK, but
|
||||
// none block each other.
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { stringifyKeyStroke } from "@opentui/keymap"
|
||||
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
|
||||
import { TuiKeybind } from "@/cli/cmd/tui/config/keybind"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { reusePendingTask } from "./runtime.shared"
|
||||
import { resolveSession, sessionHistory } from "./session.shared"
|
||||
@@ -14,7 +16,7 @@ import type { FooterKeybinds, RunDiffStyle, RunInput, RunPrompt, RunProvider } f
|
||||
import { pickVariant } from "./variant.shared"
|
||||
|
||||
const DEFAULT_KEYBINDS: FooterKeybinds = {
|
||||
leader: "ctrl+x",
|
||||
leader: TuiKeybind.LeaderDefault,
|
||||
leaderTimeout: 2000,
|
||||
commandList: [{ key: "ctrl+p" }],
|
||||
variantCycle: [{ key: "ctrl+t" }],
|
||||
@@ -78,22 +80,28 @@ function emptySessionInfo(): SessionInfo {
|
||||
}
|
||||
}
|
||||
|
||||
function leaderKey(config: Config) {
|
||||
const key = config.keybinds.get("leader")?.[0]?.key
|
||||
if (!key) return TuiKeybind.LeaderDefault
|
||||
return typeof key === "string" ? key : stringifyKeyStroke(key)
|
||||
}
|
||||
|
||||
function footerKeybinds(config: Config | undefined): FooterKeybinds {
|
||||
if (!config) {
|
||||
return DEFAULT_KEYBINDS
|
||||
}
|
||||
|
||||
return {
|
||||
leader: config.keymap.leader,
|
||||
leaderTimeout: config.keymap.leader_timeout,
|
||||
commandList: config.keymap.get("global", "command.palette.show") ?? [],
|
||||
variantCycle: config.keymap.get("global", "variant.cycle") ?? [],
|
||||
interrupt: config.keymap.get("prompt", "session.interrupt") ?? [],
|
||||
historyPrevious: config.keymap.get("prompt", "prompt.history.previous") ?? [],
|
||||
historyNext: config.keymap.get("prompt", "prompt.history.next") ?? [],
|
||||
inputClear: config.keymap.get("prompt", "prompt.clear") ?? [],
|
||||
inputSubmit: config.keymap.get("input", "input.submit") ?? [],
|
||||
inputNewline: config.keymap.get("input", "input.newline") ?? [],
|
||||
leader: leaderKey(config),
|
||||
leaderTimeout: config.leader_timeout,
|
||||
commandList: config.keybinds.get("command.palette.show"),
|
||||
variantCycle: config.keybinds.get("variant.cycle"),
|
||||
interrupt: config.keybinds.get("session.interrupt"),
|
||||
historyPrevious: config.keybinds.get("prompt.history.previous"),
|
||||
historyNext: config.keybinds.get("prompt.history.next"),
|
||||
inputClear: config.keybinds.get("prompt.clear"),
|
||||
inputSubmit: config.keybinds.get("input.submit"),
|
||||
inputNewline: config.keybinds.get("input.newline"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,42 @@ import { OpencodeKeymapProvider, registerOpencodeKeymap, useBindings, useOpencod
|
||||
import type { EventSource } from "./context/sdk"
|
||||
import { DialogVariant } from "./component/dialog-variant"
|
||||
|
||||
const appBindingCommands = [
|
||||
"command.palette.show",
|
||||
"session.list",
|
||||
"session.new",
|
||||
"model.list",
|
||||
"model.cycle_recent",
|
||||
"model.cycle_recent_reverse",
|
||||
"model.cycle_favorite",
|
||||
"model.cycle_favorite_reverse",
|
||||
"agent.list",
|
||||
"mcp.list",
|
||||
"agent.cycle",
|
||||
"agent.cycle.reverse",
|
||||
"variant.cycle",
|
||||
"variant.list",
|
||||
"provider.connect",
|
||||
"console.org.switch",
|
||||
"opencode.status",
|
||||
"theme.switch",
|
||||
"theme.switch_mode",
|
||||
"theme.mode.lock",
|
||||
"help.show",
|
||||
"docs.open",
|
||||
"app.exit",
|
||||
"app.debug",
|
||||
"app.console",
|
||||
"app.heap_snapshot",
|
||||
"terminal.suspend",
|
||||
"terminal.title.toggle",
|
||||
"app.toggle.animations",
|
||||
"app.toggle.file_context",
|
||||
"app.toggle.diffwrap",
|
||||
"app.toggle.paste_summary",
|
||||
"app.toggle.session_directory_filter",
|
||||
] as const
|
||||
|
||||
function rendererConfig(_config: TuiConfig.Resolved): CliRendererConfig {
|
||||
const mouseEnabled = !Flag.OPENCODE_DISABLE_MOUSE && (_config.mouse ?? true)
|
||||
|
||||
@@ -215,9 +251,6 @@ export function tui(input: {
|
||||
|
||||
function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
const tuiConfig = useTuiConfig()
|
||||
const {
|
||||
keymap: { sections },
|
||||
} = tuiConfig
|
||||
const route = useRoute()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const renderer = useRenderer()
|
||||
@@ -749,7 +782,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
|
||||
useBindings(() => ({
|
||||
enabled: command.matcher,
|
||||
bindings: sections.global,
|
||||
bindings: tuiConfig.keybinds.gather("app", appBindingCommands),
|
||||
}))
|
||||
|
||||
event.on(TuiEvent.CommandExecute.type, (evt) => {
|
||||
|
||||
@@ -46,7 +46,7 @@ export function DialogMcp() {
|
||||
|
||||
const actions = createMemo(() => [
|
||||
{
|
||||
command: "dialog.action.toggle",
|
||||
command: "dialog.mcp.toggle",
|
||||
title: "toggle",
|
||||
onTrigger: async (option: DialogSelectOption<string>) => {
|
||||
// Prevent toggling while an operation is already in progress
|
||||
|
||||
@@ -8,13 +8,11 @@ import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
|
||||
import { DialogVariant } from "./dialog-variant"
|
||||
import * as fuzzysort from "fuzzysort"
|
||||
import { useConnected } from "./use-connected"
|
||||
import { useTuiConfig } from "../context/tui-config"
|
||||
|
||||
export function DialogModel(props: { providerID?: string }) {
|
||||
const local = useLocal()
|
||||
const sync = useSync()
|
||||
const dialog = useDialog()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const [query, setQuery] = createSignal("")
|
||||
|
||||
const connected = useConnected()
|
||||
@@ -167,7 +165,6 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
},
|
||||
},
|
||||
]}
|
||||
bindings={tuiConfig.keymap.sections.model}
|
||||
onFilter={setQuery}
|
||||
flat={true}
|
||||
skipFilter={true}
|
||||
|
||||
@@ -28,7 +28,7 @@ export function DialogSessionList() {
|
||||
const toast = useToast()
|
||||
const [toDelete, setToDelete] = createSignal<string>()
|
||||
const [search, setSearch] = createDebouncedSignal("", 150)
|
||||
const deleteHint = useCommandShortcut("dialog.action.delete")
|
||||
const deleteHint = useCommandShortcut("session.delete")
|
||||
|
||||
const [searchResults, { refetch }] = createResource(
|
||||
() => ({ query: search(), filter: sync.session.query() }),
|
||||
@@ -190,7 +190,7 @@ export function DialogSessionList() {
|
||||
}}
|
||||
actions={[
|
||||
{
|
||||
command: "dialog.action.delete",
|
||||
command: "session.delete",
|
||||
title: "delete",
|
||||
onTrigger: async (option) => {
|
||||
if (toDelete() === option.value) {
|
||||
@@ -238,7 +238,7 @@ export function DialogSessionList() {
|
||||
},
|
||||
},
|
||||
{
|
||||
command: "dialog.action.rename",
|
||||
command: "session.rename",
|
||||
title: "rename",
|
||||
onTrigger: async (option) => {
|
||||
dialog.replace(() => <DialogSessionRename session={option.value} />)
|
||||
|
||||
@@ -32,7 +32,7 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
|
||||
const { theme } = useTheme()
|
||||
|
||||
const [toDelete, setToDelete] = createSignal<number>()
|
||||
const deleteHint = useCommandShortcut("dialog.action.delete")
|
||||
const deleteHint = useCommandShortcut("stash.delete")
|
||||
|
||||
const options = createMemo(() => {
|
||||
const entries = stash.list()
|
||||
@@ -70,7 +70,7 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
|
||||
}}
|
||||
actions={[
|
||||
{
|
||||
command: "dialog.action.delete",
|
||||
command: "stash.delete",
|
||||
title: "delete",
|
||||
onTrigger: (option) => {
|
||||
if (toDelete() === option.value) {
|
||||
|
||||
@@ -87,9 +87,6 @@ export function Autocomplete(props: {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const frecency = useFrecency()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const {
|
||||
keymap: { sections },
|
||||
} = tuiConfig
|
||||
const [store, setStore] = createStore({
|
||||
index: 0,
|
||||
selected: 0,
|
||||
@@ -575,7 +572,13 @@ export function Autocomplete(props: {
|
||||
},
|
||||
},
|
||||
],
|
||||
bindings: sections.autocomplete,
|
||||
bindings: tuiConfig.keybinds.gather("prompt.autocomplete", [
|
||||
"prompt.autocomplete.prev",
|
||||
"prompt.autocomplete.next",
|
||||
"prompt.autocomplete.hide",
|
||||
"prompt.autocomplete.select",
|
||||
"prompt.autocomplete.complete",
|
||||
]),
|
||||
}))
|
||||
|
||||
function show(mode: "@" | "/") {
|
||||
|
||||
@@ -147,7 +147,6 @@ export function Prompt(props: PromptProps) {
|
||||
const project = useProject()
|
||||
const sync = useSync()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const keymapConfig = tuiConfig.keymap
|
||||
const dialog = useDialog()
|
||||
const toast = useToast()
|
||||
const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" })
|
||||
@@ -630,7 +629,7 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
useBindings(() => ({
|
||||
enabled: command.matcher,
|
||||
bindings: keymapConfig.pick("prompt", [
|
||||
bindings: tuiConfig.keybinds.gather("prompt.palette", [
|
||||
"prompt.submit",
|
||||
"prompt.editor",
|
||||
"prompt.editor_context.clear",
|
||||
@@ -865,7 +864,7 @@ export function Prompt(props: PromptProps) {
|
||||
return {
|
||||
target: inputTarget,
|
||||
enabled: inputTarget() !== undefined && !props.disabled,
|
||||
bindings: keymapConfig.pick("prompt", ["prompt.paste"]),
|
||||
bindings: tuiConfig.keybinds.get("prompt.paste"),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -873,7 +872,7 @@ export function Prompt(props: PromptProps) {
|
||||
return {
|
||||
target: inputTarget,
|
||||
enabled: inputTarget() !== undefined && !props.disabled && store.prompt.input !== "",
|
||||
bindings: keymapConfig.pick("prompt", ["prompt.clear"]),
|
||||
bindings: tuiConfig.keybinds.get("prompt.clear"),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -957,7 +956,7 @@ export function Prompt(props: PromptProps) {
|
||||
},
|
||||
},
|
||||
],
|
||||
bindings: keymapConfig.pick("prompt", ["prompt.history.previous"]),
|
||||
bindings: tuiConfig.keybinds.get("prompt.history.previous"),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -995,7 +994,7 @@ export function Prompt(props: PromptProps) {
|
||||
},
|
||||
},
|
||||
],
|
||||
bindings: keymapConfig.pick("prompt", ["prompt.history.next"]),
|
||||
bindings: tuiConfig.keybinds.get("prompt.history.next"),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
384
packages/opencode/src/cli/cmd/tui/config/keybind.ts
Normal file
384
packages/opencode/src/cli/cmd/tui/config/keybind.ts
Normal 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] }
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -1,339 +1,12 @@
|
||||
import z from "zod"
|
||||
import type { KeyEvent, Renderable } from "@opentui/core"
|
||||
import type { Binding } from "@opentui/keymap"
|
||||
import type { ResolvedBindingSections } from "@opentui/keymap/extras"
|
||||
import { ConfigPlugin } from "@/config/plugin"
|
||||
import { ConfigKeybinds } from "@/config/keybinds"
|
||||
import { TuiKeybind } from "./keybind"
|
||||
|
||||
const KeybindOverride = z
|
||||
.object(
|
||||
Object.fromEntries(Object.keys(ConfigKeybinds.Keybinds.shape).map((key) => [key, z.string().optional()])) as Record<
|
||||
string,
|
||||
z.ZodOptional<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 type KeymapInfo = {
|
||||
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>
|
||||
const KeymapLeaderTimeout = z.number().int().positive().describe("Leader key timeout in milliseconds")
|
||||
|
||||
export const TuiOptions = z.object({
|
||||
leader_timeout: KeymapLeaderTimeout.optional(),
|
||||
scroll_speed: z.number().min(0.001).optional().describe("TUI scroll speed"),
|
||||
scroll_acceleration: z
|
||||
.object({
|
||||
@@ -352,17 +25,11 @@ export const TuiInfo = z
|
||||
.object({
|
||||
$schema: z.string().optional(),
|
||||
theme: z.string().optional(),
|
||||
keybinds: KeybindOverride.optional().meta({
|
||||
deprecated: true,
|
||||
description: "Use keymap instead. This will be removed in opencode v2.0.",
|
||||
}),
|
||||
keymap: KeymapConfigInput.optional(),
|
||||
keybinds: TuiKeybind.KeybindOverrides.optional(),
|
||||
plugin: ConfigPlugin.Spec.zod.array().optional(),
|
||||
plugin_enabled: z.record(z.string(), z.boolean()).optional(),
|
||||
})
|
||||
.extend(TuiOptions.shape)
|
||||
.strict()
|
||||
|
||||
export const TuiJsonSchemaInfo = TuiInfo.extend({
|
||||
keymap: KeymapConfig.optional(),
|
||||
}).strict()
|
||||
export const TuiJsonSchemaInfo = TuiInfo
|
||||
|
||||
@@ -1,29 +1,26 @@
|
||||
export * as TuiConfig from "./tui"
|
||||
|
||||
import type z from "zod"
|
||||
import type { KeyEvent, Renderable } from "@opentui/core"
|
||||
import { resolveBindingSections, type BindingSectionsConfig } from "@opentui/keymap/extras"
|
||||
import { createBindingLookup } from "@opentui/keymap/extras"
|
||||
import { mergeDeep, unique } from "remeda"
|
||||
import { Context, Effect, Fiber, Layer } from "effect"
|
||||
import { ConfigParse } from "@/config/parse"
|
||||
import * as ConfigPaths from "@/config/paths"
|
||||
import { migrateTuiConfig } from "./tui-migrate"
|
||||
import { KeymapConfig, TuiInfo, TuiJsonSchemaInfo } from "./tui-schema"
|
||||
import { KeymapLeaderTimeoutDefault, TuiInfo, TuiJsonSchemaInfo } from "./tui-schema"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { CurrentWorkingDirectory } from "./cwd"
|
||||
import { ConfigPlugin } from "@/config/plugin"
|
||||
import { ConfigKeybinds } from "@/config/keybinds"
|
||||
import { TuiKeybind } from "./keybind"
|
||||
import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { ConfigVariable } from "@/config/variable"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
import { LegacyKeymapTransform } from "./legacy-keymap-transform"
|
||||
import { KeymapSectionNames, keymapBindingDefaults, type KeymapInfo, type KeymapSection } from "./tui-schema"
|
||||
|
||||
const log = Log.create({ service: "tui.config" })
|
||||
|
||||
@@ -36,9 +33,9 @@ type Acc = {
|
||||
plugin_origins: ConfigPlugin.Origin[]
|
||||
}
|
||||
|
||||
export type Resolved = Omit<Info, "keybinds" | "keymap"> & {
|
||||
keybinds: ConfigKeybinds.Keybinds
|
||||
keymap: KeymapInfo
|
||||
export type Resolved = Omit<Info, "keybinds" | "leader_timeout"> & {
|
||||
keybinds: TuiKeybind.BindingLookupView
|
||||
leader_timeout: number
|
||||
// Internal resolved plugin list used by runtime loading.
|
||||
plugin_origins?: ConfigPlugin.Origin[]
|
||||
}
|
||||
@@ -186,31 +183,18 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
|
||||
keybinds.terminal_suspend = "none"
|
||||
keybinds.input_undo ??= unique([
|
||||
"ctrl+z",
|
||||
...ConfigKeybinds.Keybinds.shape.input_undo.parse(undefined).split(","),
|
||||
...String(TuiKeybind.Keybinds.shape.input_undo.parse(undefined)).split(","),
|
||||
]).join(",")
|
||||
}
|
||||
const parsedKeybinds = ConfigKeybinds.Keybinds.parse(keybinds)
|
||||
const keymapInput = acc.result.keymap ?? LegacyKeymapTransform.create(acc.result.keybinds ?? {})
|
||||
const keymapConfig = KeymapConfig.parse(keymapInput)
|
||||
const keymap = {
|
||||
leader: !keymapConfig.leader || keymapConfig.leader === "none" ? "ctrl+x" : keymapConfig.leader,
|
||||
leader_timeout: keymapConfig.leader_timeout,
|
||||
...resolveBindingSections<Renderable, KeyEvent, BindingSectionsConfig<Renderable, KeyEvent>, KeymapSection>(
|
||||
keymapConfig.sections,
|
||||
{
|
||||
sections: KeymapSectionNames,
|
||||
bindingDefaults: keymapBindingDefaults,
|
||||
},
|
||||
),
|
||||
}
|
||||
const parsedKeybinds = TuiKeybind.Keybinds.parse(keybinds)
|
||||
const result: Resolved = {
|
||||
...acc.result,
|
||||
keybinds: parsedKeybinds,
|
||||
keybinds: createBindingLookup(TuiKeybind.toBindingConfig(parsedKeybinds), {
|
||||
commandMap: TuiKeybind.CommandMap,
|
||||
bindingDefaults: TuiKeybind.bindingDefaults(),
|
||||
}),
|
||||
leader_timeout: acc.result.leader_timeout ?? KeymapLeaderTimeoutDefault,
|
||||
plugin_origins: acc.plugin_origins.length ? acc.plugin_origins : undefined,
|
||||
// `keybinds` is deprecated and will be removed in opencode v2.0. Keep it
|
||||
// only as the legacy fallback; once `keymap` is configured, ignore
|
||||
// `keybinds` for keymap resolution.
|
||||
keymap,
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -207,7 +207,7 @@ function View(props: { api: TuiPluginApi }) {
|
||||
actions={[
|
||||
{
|
||||
title: "toggle",
|
||||
command: "dialog.action.toggle",
|
||||
command: "plugins.toggle",
|
||||
disabled: lock(),
|
||||
onTrigger: (item) => {
|
||||
setCur(item.value)
|
||||
@@ -216,14 +216,13 @@ function View(props: { api: TuiPluginApi }) {
|
||||
},
|
||||
{
|
||||
title: "install",
|
||||
command: "plugin.dialog.install",
|
||||
command: "dialog.plugins.install",
|
||||
disabled: lock(),
|
||||
onTrigger: () => {
|
||||
showInstall(props.api)
|
||||
},
|
||||
},
|
||||
]}
|
||||
bindings={props.api.tuiConfig.keymap.pick("plugins", ["plugin.dialog.install"])}
|
||||
onSelect={(item) => {
|
||||
setCur(item.value)
|
||||
flip(item.value)
|
||||
@@ -258,7 +257,7 @@ const tui: TuiPlugin = async (api) => {
|
||||
},
|
||||
},
|
||||
],
|
||||
bindings: api.tuiConfig.keymap.omit("plugins", ["plugin.dialog.install"]),
|
||||
bindings: api.tuiConfig.keybinds.gather("plugins.palette", ["plugins.list", "plugins.install"]),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -8,17 +8,17 @@ import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||
import type { InternalTuiPlugin } from "../../plugin/internal"
|
||||
|
||||
const command = {
|
||||
toggle: "tui-which-key.toggle",
|
||||
toggleLayout: "tui-which-key.layout.toggle",
|
||||
togglePending: "tui-which-key.pending.toggle",
|
||||
groupPrevious: "tui-which-key.group.previous",
|
||||
groupNext: "tui-which-key.group.next",
|
||||
scrollUp: "tui-which-key.scroll.up",
|
||||
scrollDown: "tui-which-key.scroll.down",
|
||||
pageUp: "tui-which-key.page.up",
|
||||
pageDown: "tui-which-key.page.down",
|
||||
home: "tui-which-key.home",
|
||||
end: "tui-which-key.end",
|
||||
toggle: "which-key.toggle",
|
||||
toggleLayout: "which-key.layout.toggle",
|
||||
togglePending: "which-key.pending.toggle",
|
||||
groupPrevious: "which-key.group.previous",
|
||||
groupNext: "which-key.group.next",
|
||||
scrollUp: "which-key.scroll.up",
|
||||
scrollDown: "which-key.scroll.down",
|
||||
pageUp: "which-key.page.up",
|
||||
pageDown: "which-key.page.down",
|
||||
home: "which-key.home",
|
||||
end: "which-key.end",
|
||||
} as const
|
||||
|
||||
const LAYER_PRIORITY = 900
|
||||
@@ -112,8 +112,7 @@ function skin(api: TuiPluginApi): Skin {
|
||||
}
|
||||
|
||||
function activeKeyLabel(active: ActiveKey<Renderable, KeyEvent>) {
|
||||
const group = text(active.bindingAttrs?.group)
|
||||
if (active.continues) return group ?? text(active.tokenName) ?? UNKNOWN
|
||||
if (active.continues) return text(active.tokenName) ?? text(active.display) ?? UNKNOWN
|
||||
return (
|
||||
text(active.commandAttrs?.title) ?? text(active.bindingAttrs?.desc) ?? text(active.commandAttrs?.desc) ?? UNKNOWN
|
||||
)
|
||||
@@ -361,7 +360,9 @@ function WhichKeyPanel(props: {
|
||||
},
|
||||
},
|
||||
],
|
||||
bindings: props.api.tuiConfig.keymap.pick("which_key", pendingMode() ? scrollCommands : panelCommands),
|
||||
bindings: pendingMode()
|
||||
? props.api.tuiConfig.keybinds.gather("which-key.scroll", scrollCommands)
|
||||
: props.api.tuiConfig.keybinds.gather("which-key.panel", panelCommands),
|
||||
}))
|
||||
|
||||
createEffect(() => {
|
||||
@@ -571,7 +572,7 @@ const tui: TuiPlugin = async (api) => {
|
||||
},
|
||||
},
|
||||
],
|
||||
bindings: api.tuiConfig.keymap.pick("which_key", toggleCommands),
|
||||
bindings: api.tuiConfig.keybinds.gather("which-key.toggle", toggleCommands),
|
||||
})
|
||||
|
||||
api.slots.register({
|
||||
@@ -599,7 +600,7 @@ const tui: TuiPlugin = async (api) => {
|
||||
}
|
||||
|
||||
const plugin: InternalTuiPlugin = {
|
||||
id: "tui-which-key",
|
||||
id: "which-key",
|
||||
enabled: false,
|
||||
tui,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type CliRenderer } from "@opentui/core"
|
||||
import * as addons from "@opentui/keymap/addons/opentui"
|
||||
import { stringifyKeyStroke } from "@opentui/keymap"
|
||||
import {
|
||||
formatCommandBindings as formatCommandBindingsExtra,
|
||||
formatKeySequence as formatKeySequenceExtra,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { TuiConfig } from "./config/tui"
|
||||
import { useTuiConfig } from "./context/tui-config"
|
||||
import { TuiKeybind } from "./config/keybind"
|
||||
|
||||
export const LEADER_TOKEN = "leader"
|
||||
|
||||
@@ -24,10 +26,55 @@ export { reactiveMatcherFromSignal, useBindings, useKeymapSelector }
|
||||
|
||||
export type OpenTuiKeymap = ReturnType<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) {
|
||||
return {
|
||||
tokenDisplay: {
|
||||
[LEADER_TOKEN]: config.keymap.leader,
|
||||
[LEADER_TOKEN]: leaderDisplay(config),
|
||||
},
|
||||
keyNameAliases: {
|
||||
pageup: "pgup",
|
||||
@@ -55,19 +102,23 @@ export function registerOpencodeKeymap(keymap: OpenTuiKeymap, renderer: CliRende
|
||||
const offCommaBindings = addons.registerCommaBindings(keymap)
|
||||
const offBaseLayout = addons.registerBaseLayoutFallback(keymap)
|
||||
const offLeader = addons.registerTimedLeader(keymap, {
|
||||
trigger: config.keymap.leader,
|
||||
trigger: config.keybinds.get(LEADER_TOKEN),
|
||||
name: LEADER_TOKEN,
|
||||
timeoutMs: config.keymap.leader_timeout,
|
||||
timeoutMs: config.leader_timeout,
|
||||
})
|
||||
const offEscape = addons.registerEscapeClearsPendingSequence(keymap)
|
||||
const offBackspace = addons.registerBackspacePopsPendingSequence(keymap)
|
||||
const offInputBindings = addons.registerManagedTextareaLayer(keymap, renderer, {
|
||||
const offInputCommands = addons.registerEditBufferCommands(keymap, renderer)
|
||||
const offInputSuspension = addons.registerTextareaMappingSuspension(keymap, renderer)
|
||||
const offInputBindings = keymap.registerLayer({
|
||||
enabled: () => renderer.currentFocusedEditor !== null,
|
||||
bindings: config.keymap.sections.input,
|
||||
bindings: config.keybinds.gather("input", inputCommands),
|
||||
})
|
||||
|
||||
return () => {
|
||||
offInputBindings()
|
||||
offInputSuspension()
|
||||
offInputCommands()
|
||||
offBackspace()
|
||||
offEscape()
|
||||
offLeader()
|
||||
|
||||
@@ -117,6 +117,42 @@ function goUpsellKeys(action: SessionRetry.Retryable["action"]) {
|
||||
}
|
||||
}
|
||||
|
||||
const sessionBindingCommands = [
|
||||
"session.share",
|
||||
"session.rename",
|
||||
"session.timeline",
|
||||
"session.fork",
|
||||
"session.compact",
|
||||
"session.unshare",
|
||||
"session.undo",
|
||||
"session.redo",
|
||||
"session.sidebar.toggle",
|
||||
"session.toggle.conceal",
|
||||
"session.toggle.timestamps",
|
||||
"session.toggle.thinking",
|
||||
"session.toggle.actions",
|
||||
"session.toggle.scrollbar",
|
||||
"session.toggle.generic_tool_output",
|
||||
"session.page.up",
|
||||
"session.page.down",
|
||||
"session.line.up",
|
||||
"session.line.down",
|
||||
"session.half.page.up",
|
||||
"session.half.page.down",
|
||||
"session.first",
|
||||
"session.last",
|
||||
"session.messages_last_user",
|
||||
"session.message.next",
|
||||
"session.message.previous",
|
||||
"messages.copy",
|
||||
"session.copy",
|
||||
"session.export",
|
||||
"session.child.first",
|
||||
"session.parent",
|
||||
"session.child.next",
|
||||
"session.child.previous",
|
||||
] as const
|
||||
|
||||
const context = createContext<{
|
||||
width: number
|
||||
sessionID: string
|
||||
@@ -144,9 +180,6 @@ export function Session() {
|
||||
const event = useEvent()
|
||||
const project = useProject()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const {
|
||||
keymap: { sections },
|
||||
} = tuiConfig
|
||||
const kv = useKV()
|
||||
const { theme } = useTheme()
|
||||
const promptRef = usePromptRef()
|
||||
@@ -1015,7 +1048,7 @@ export function Session() {
|
||||
|
||||
useBindings(() => ({
|
||||
enabled: command.matcher,
|
||||
bindings: sections.session,
|
||||
bindings: tuiConfig.keybinds.gather("session", sessionBindingCommands),
|
||||
}))
|
||||
|
||||
const revertInfo = createMemo(() => session()?.revert)
|
||||
|
||||
@@ -463,7 +463,6 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: (
|
||||
let input: TextareaRenderable
|
||||
const { theme } = useTheme()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const keymapConfig = tuiConfig.keymap
|
||||
const dimensions = useTerminalDimensions()
|
||||
const narrow = createMemo(() => dimensions().width < 80)
|
||||
const dialog = useDialog()
|
||||
@@ -471,7 +470,7 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: (
|
||||
enabled: dialog.stack.length === 0,
|
||||
commands: [
|
||||
{
|
||||
name: "permission.reject.cancel",
|
||||
name: "app.exit",
|
||||
title: "Cancel permission rejection",
|
||||
category: "Permission",
|
||||
run() {
|
||||
@@ -481,7 +480,7 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: (
|
||||
],
|
||||
bindings: [
|
||||
{ key: "escape", desc: "Cancel permission rejection", group: "Permission", cmd: () => props.onCancel() },
|
||||
...keymapConfig.pick("permission", ["permission.reject.cancel"]),
|
||||
...tuiConfig.keybinds.get("app.exit"),
|
||||
{
|
||||
key: "return",
|
||||
desc: "Confirm permission rejection",
|
||||
@@ -553,7 +552,6 @@ function Prompt<const T extends Record<string, string>>(props: {
|
||||
}) {
|
||||
const { theme } = useTheme()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const keymapConfig = tuiConfig.keymap
|
||||
const dimensions = useTerminalDimensions()
|
||||
const keys = Object.keys(props.options) as (keyof T)[]
|
||||
const [store, setStore] = createStore({
|
||||
@@ -568,7 +566,7 @@ function Prompt<const T extends Record<string, string>>(props: {
|
||||
enabled: dialog.stack.length === 0,
|
||||
commands: [
|
||||
{
|
||||
name: "permission.prompt.escape",
|
||||
name: "app.exit",
|
||||
title: "Reject permission",
|
||||
category: "Permission",
|
||||
run() {
|
||||
@@ -643,8 +641,8 @@ function Prompt<const T extends Record<string, string>>(props: {
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(props.escapeKey ? keymapConfig.pick("permission", ["permission.prompt.escape"]) : []),
|
||||
...(props.fullscreen ? keymapConfig.pick("permission", ["permission.prompt.fullscreen"]) : []),
|
||||
...(props.escapeKey ? tuiConfig.keybinds.get("app.exit") : []),
|
||||
...(props.fullscreen ? tuiConfig.keybinds.get("permission.prompt.fullscreen") : []),
|
||||
],
|
||||
}))
|
||||
|
||||
|
||||
@@ -13,10 +13,6 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
const sdk = useSDK()
|
||||
const { theme } = useTheme()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const {
|
||||
keymap: { sections },
|
||||
} = tuiConfig
|
||||
const keymapConfig = tuiConfig.keymap
|
||||
|
||||
const questions = createMemo(() => props.request.questions)
|
||||
const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true)
|
||||
@@ -128,7 +124,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
enabled: store.editing && !confirm(),
|
||||
commands: [
|
||||
{
|
||||
name: "question.edit.clear",
|
||||
name: "prompt.clear",
|
||||
title: "Clear answer edit",
|
||||
category: "Question",
|
||||
run() {
|
||||
@@ -150,7 +146,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
setStore("editing", false)
|
||||
},
|
||||
},
|
||||
...keymapConfig.pick("question", ["question.edit.clear"]),
|
||||
...tuiConfig.keybinds.get("prompt.clear"),
|
||||
{
|
||||
key: "return",
|
||||
desc: "Submit answer edit",
|
||||
@@ -208,7 +204,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
enabled: dialog.stack.length === 0 && !store.editing,
|
||||
commands: [
|
||||
{
|
||||
name: "question.reject",
|
||||
name: "app.exit",
|
||||
title: "Reject question",
|
||||
category: "Question",
|
||||
run() {
|
||||
@@ -243,7 +239,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
? [
|
||||
{ key: "return", desc: "Submit answer", group: "Question", cmd: () => submit() },
|
||||
{ key: "escape", desc: "Reject question", group: "Question", cmd: () => reject() },
|
||||
...sections.question,
|
||||
...tuiConfig.keybinds.get("app.exit"),
|
||||
]
|
||||
: [
|
||||
...Array.from({ length: max }, (_, index) => ({
|
||||
@@ -271,7 +267,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
{ key: "j", desc: "Next answer", group: "Question", cmd: () => moveTo((store.selected + 1) % total) },
|
||||
{ key: "return", desc: "Select answer", group: "Question", cmd: () => selectOption() },
|
||||
{ key: "escape", desc: "Reject question", group: "Question", cmd: () => reject() },
|
||||
...sections.question,
|
||||
...tuiConfig.keybinds.get("app.exit"),
|
||||
]),
|
||||
],
|
||||
}
|
||||
|
||||
@@ -65,9 +65,6 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
const dialog = useDialog()
|
||||
const { theme } = useTheme()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const {
|
||||
keymap: { sections },
|
||||
} = tuiConfig
|
||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
@@ -308,11 +305,16 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
})),
|
||||
],
|
||||
bindings: [
|
||||
...sections.dialog_select,
|
||||
...tuiConfig.keymap.pick(
|
||||
"dialog_actions",
|
||||
enabledActions.map((item) => item.command),
|
||||
),
|
||||
...tuiConfig.keybinds.gather("dialog.select", [
|
||||
"dialog.select.prev",
|
||||
"dialog.select.next",
|
||||
"dialog.select.page_up",
|
||||
"dialog.select.page_down",
|
||||
"dialog.select.home",
|
||||
"dialog.select.end",
|
||||
"dialog.select.submit",
|
||||
]),
|
||||
...enabledActions.flatMap((item) => tuiConfig.keybinds.get(item.command)),
|
||||
...(props.bindings ?? []).filter((binding) => {
|
||||
if (typeof binding.cmd !== "string") return true
|
||||
return enabledActions.some((item) => item.command === binding.cmd)
|
||||
|
||||
@@ -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>>>
|
||||
>
|
||||
@@ -1,12 +1,11 @@
|
||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||
import type { KeyEvent, Renderable } from "@opentui/core"
|
||||
import type { Binding } from "@opentui/keymap"
|
||||
import { resolveBindingSections, type BindingSectionsConfig } from "@opentui/keymap/extras"
|
||||
import { createBindingLookup } from "@opentui/keymap/extras"
|
||||
import { OpencodeClient, type Provider } from "@opencode-ai/sdk/v2"
|
||||
import { TuiConfig, type Resolved } from "@/cli/cmd/tui/config/tui"
|
||||
import { formatBindings } from "@/cli/cmd/run/keymap.shared"
|
||||
import { KeymapSectionNames, keymapBindingDefaults, type KeymapSection } from "@/cli/cmd/tui/config/tui-schema"
|
||||
import { ConfigKeybinds } from "@/config/keybinds"
|
||||
import { TuiKeybind } from "@/cli/cmd/tui/config/keybind"
|
||||
import { resolveDiffStyle, resolveFooterKeybinds, resolveModelInfo } from "@/cli/cmd/run/runtime.boot"
|
||||
|
||||
type RunBinding = Binding<Renderable, KeyEvent>
|
||||
@@ -82,34 +81,24 @@ function config(input?: {
|
||||
}>
|
||||
}): Resolved {
|
||||
const bind = input?.bindings
|
||||
const sections = {
|
||||
global: Object.fromEntries([
|
||||
...(bind?.commandList ? [["command.palette.show", bind.commandList] as const] : []),
|
||||
...(bind?.variantCycle ? [["variant.cycle", bind.variantCycle] as const] : []),
|
||||
]),
|
||||
prompt: Object.fromEntries([
|
||||
...(bind?.interrupt ? [["session.interrupt", bind.interrupt] as const] : []),
|
||||
...(bind?.historyPrevious ? [["prompt.history.previous", bind.historyPrevious] as const] : []),
|
||||
...(bind?.historyNext ? [["prompt.history.next", bind.historyNext] as const] : []),
|
||||
...(bind?.inputClear ? [["prompt.clear", bind.inputClear] as const] : []),
|
||||
]),
|
||||
input: Object.fromEntries([
|
||||
...(bind?.inputSubmit ? [["input.submit", bind.inputSubmit] as const] : []),
|
||||
...(bind?.inputNewline ? [["input.newline", bind.inputNewline] as const] : []),
|
||||
]),
|
||||
} satisfies BindingSectionsConfig<Renderable, KeyEvent>
|
||||
|
||||
const keybinds = TuiKeybind.Keybinds.parse({
|
||||
...(input?.leader && { leader: input.leader }),
|
||||
...(bind?.commandList && { command_list: bind.commandList }),
|
||||
...(bind?.variantCycle && { variant_cycle: bind.variantCycle }),
|
||||
...(bind?.interrupt && { session_interrupt: bind.interrupt }),
|
||||
...(bind?.historyPrevious && { history_previous: bind.historyPrevious }),
|
||||
...(bind?.historyNext && { history_next: bind.historyNext }),
|
||||
...(bind?.inputClear && { input_clear: bind.inputClear }),
|
||||
...(bind?.inputSubmit && { input_submit: bind.inputSubmit }),
|
||||
...(bind?.inputNewline && { input_newline: bind.inputNewline }),
|
||||
})
|
||||
return {
|
||||
diff_style: input?.diff_style,
|
||||
keybinds: ConfigKeybinds.Keybinds.parse({}),
|
||||
keymap: {
|
||||
leader: input?.leader ?? "ctrl+x",
|
||||
leader_timeout: input?.leaderTimeout ?? 2000,
|
||||
...resolveBindingSections<Renderable, KeyEvent, typeof sections, KeymapSection>(sections, {
|
||||
sections: KeymapSectionNames,
|
||||
bindingDefaults: keymapBindingDefaults,
|
||||
keybinds: createBindingLookup(TuiKeybind.toBindingConfig(keybinds), {
|
||||
commandMap: TuiKeybind.CommandMap,
|
||||
bindingDefaults: TuiKeybind.bindingDefaults(),
|
||||
}),
|
||||
},
|
||||
leader_timeout: input?.leaderTimeout ?? 2000,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +107,7 @@ describe("run runtime boot", () => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
test("reads footer keybinds from resolved keymap config", async () => {
|
||||
test("reads footer keybinds from resolved keybind config", async () => {
|
||||
spyOn(TuiConfig, "get").mockResolvedValue(
|
||||
config({
|
||||
leader: "ctrl+g",
|
||||
|
||||
@@ -81,7 +81,7 @@ async function load(): Promise<Data> {
|
||||
|
||||
await Bun.write(
|
||||
localPluginPath,
|
||||
`import { resolveBindingSections } from "@opentui/keymap/extras"
|
||||
`import { createBindingLookup } from "@opentui/keymap/extras"
|
||||
import { useBindings } from "@opentui/keymap/solid"
|
||||
|
||||
export const ignored = async (_input, options) => {
|
||||
@@ -97,20 +97,18 @@ export default {
|
||||
const cfg_diff = api.tuiConfig.diff_style
|
||||
const cfg_speed = api.tuiConfig.scroll_speed
|
||||
const cfg_accel = api.tuiConfig.scroll_acceleration?.enabled
|
||||
const cfg_submit = api.tuiConfig.keybinds?.input_submit
|
||||
const has_keys = typeof api.keys.formatBindings === "function"
|
||||
const keymap = resolveBindingSections(options.keymap?.sections ?? {
|
||||
main: {
|
||||
const keybinds = createBindingLookup(options.keybinds ?? {
|
||||
"plugin.loader.local": "ctrl+shift+m",
|
||||
"plugin.loader.close": "escape",
|
||||
},
|
||||
}, { sections: ["main"] }).sections
|
||||
const key_modal = keymap.main.find((item) => item.cmd === "plugin.loader.local")?.key
|
||||
const key_close = keymap.main.find((item) => item.cmd === "plugin.loader.close")?.key
|
||||
})
|
||||
const bindings = keybinds.gather("plugin.loader", ["plugin.loader.local", "plugin.loader.close"])
|
||||
const key_modal = bindings.find((item) => item.cmd === "plugin.loader.local")?.key
|
||||
const key_close = bindings.find((item) => item.cmd === "plugin.loader.close")?.key
|
||||
const key_unknown = "ctrl+k"
|
||||
const off = api.keymap.registerLayer({
|
||||
commands: [{ name: "plugin.loader.local", run() {} }, { name: "plugin.loader.close", run() {} }],
|
||||
bindings: keymap.main,
|
||||
bindings,
|
||||
})
|
||||
off()
|
||||
const kv_before = api.kv.get(options.kv_key, "missing")
|
||||
@@ -153,7 +151,7 @@ export default {
|
||||
key_unknown,
|
||||
has_keys,
|
||||
has_keymap: typeof api.keymap.registerLayer === "function",
|
||||
has_resolve_binding_sections: typeof resolveBindingSections === "function",
|
||||
has_create_binding_lookup: typeof createBindingLookup === "function",
|
||||
has_keymap_solid: typeof useBindings === "function",
|
||||
kv_before,
|
||||
kv_after,
|
||||
@@ -176,7 +174,6 @@ export default {
|
||||
cfg_diff,
|
||||
cfg_speed,
|
||||
cfg_accel,
|
||||
cfg_submit,
|
||||
}),
|
||||
)
|
||||
},
|
||||
@@ -356,14 +353,10 @@ export default {
|
||||
theme_name: tmp.extra.localThemeName,
|
||||
kv_key: "plugin_state_key",
|
||||
session_id: "ses_test",
|
||||
keymap: {
|
||||
sections: {
|
||||
main: {
|
||||
keybinds: {
|
||||
"plugin.loader.local": "ctrl+alt+m",
|
||||
"plugin.loader.close": "q",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
const invalidOpts = {
|
||||
marker: tmp.extra.invalidMarker,
|
||||
@@ -408,9 +401,6 @@ export default {
|
||||
diff_style: "stacked",
|
||||
scroll_speed: 1.5,
|
||||
scroll_acceleration: { enabled: true },
|
||||
keybinds: {
|
||||
input_submit: "ctrl+enter",
|
||||
},
|
||||
},
|
||||
state: {
|
||||
session: {
|
||||
@@ -670,7 +660,7 @@ describe("tui.plugin.loader", () => {
|
||||
expect(data.local.key_unknown).toBe("ctrl+k")
|
||||
expect(data.local.has_keys).toBe(true)
|
||||
expect(data.local.has_keymap).toBe(true)
|
||||
expect(data.local.has_resolve_binding_sections).toBe(true)
|
||||
expect(data.local.has_create_binding_lookup).toBe(true)
|
||||
expect(data.local.has_keymap_solid).toBe(true)
|
||||
expect(data.local.kv_before).toBe("missing")
|
||||
expect(data.local.kv_after).toBe("stored")
|
||||
@@ -693,7 +683,6 @@ describe("tui.plugin.loader", () => {
|
||||
expect(data.local.cfg_diff).toBe("stacked")
|
||||
expect(data.local.cfg_speed).toBe(1.5)
|
||||
expect(data.local.cfg_accel).toBe(true)
|
||||
expect(data.local.cfg_submit).toBe("ctrl+enter")
|
||||
})
|
||||
|
||||
test("installs themes in the correct scope and remains resilient", () => {
|
||||
|
||||
@@ -171,26 +171,26 @@ test("loads disabled-by-default internal plugin inactive and activates on demand
|
||||
enabled: true,
|
||||
active: true,
|
||||
})
|
||||
expect(TuiPluginRuntime.list().find((item) => item.id === "tui-which-key")).toEqual({
|
||||
id: "tui-which-key",
|
||||
expect(TuiPluginRuntime.list().find((item) => item.id === "which-key")).toEqual({
|
||||
id: "which-key",
|
||||
source: "internal",
|
||||
spec: "tui-which-key",
|
||||
target: "tui-which-key",
|
||||
spec: "which-key",
|
||||
target: "which-key",
|
||||
enabled: false,
|
||||
active: false,
|
||||
})
|
||||
|
||||
await expect(TuiPluginRuntime.activatePlugin("tui-which-key")).resolves.toBe(true)
|
||||
expect(TuiPluginRuntime.list().find((item) => item.id === "tui-which-key")).toEqual({
|
||||
id: "tui-which-key",
|
||||
await expect(TuiPluginRuntime.activatePlugin("which-key")).resolves.toBe(true)
|
||||
expect(TuiPluginRuntime.list().find((item) => item.id === "which-key")).toEqual({
|
||||
id: "which-key",
|
||||
source: "internal",
|
||||
spec: "tui-which-key",
|
||||
target: "tui-which-key",
|
||||
spec: "which-key",
|
||||
target: "which-key",
|
||||
enabled: true,
|
||||
active: true,
|
||||
})
|
||||
expect(api.kv.get("plugin_enabled", {})).toEqual({
|
||||
"tui-which-key": true,
|
||||
"which-key": true,
|
||||
})
|
||||
} finally {
|
||||
await TuiPluginRuntime.dispose()
|
||||
|
||||
@@ -163,7 +163,7 @@ test("migrates tui-specific keys from opencode.json when tui.json does not exist
|
||||
const config = await getTuiConfig(tmp.path)
|
||||
expect(config.theme).toBe("migrated-theme")
|
||||
expect(config.scroll_speed).toBe(5)
|
||||
expect(config.keybinds?.app_exit).toBe("ctrl+q")
|
||||
expect(config.keybinds.get("app.exit")?.[0]?.key).toBe("ctrl+q")
|
||||
const text = await Filesystem.readText(path.join(tmp.path, "tui.json"))
|
||||
expect(JSON.parse(text)).toMatchObject({
|
||||
theme: "migrated-theme",
|
||||
@@ -398,83 +398,64 @@ test("merges keybind overrides across precedence layers", async () => {
|
||||
},
|
||||
})
|
||||
const config = await getTuiConfig(tmp.path)
|
||||
expect(config.keybinds?.app_exit).toBe("ctrl+q")
|
||||
expect(config.keybinds?.theme_list).toBe("ctrl+k")
|
||||
expect(config.keybinds.get("app.exit")?.[0]?.key).toBe("ctrl+q")
|
||||
expect(config.keybinds.get("theme.switch")?.[0]?.key).toBe("ctrl+k")
|
||||
})
|
||||
|
||||
test("resolves semantic keymap sections", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "tui.json"),
|
||||
JSON.stringify({
|
||||
keybinds: { command_list: "ctrl+z" },
|
||||
keymap: {
|
||||
sections: {
|
||||
global: { "command.palette.show": "alt+p" },
|
||||
which_key: { "tui-which-key.toggle": "alt+k" },
|
||||
prompt: { "prompt.editor": "ctrl+e" },
|
||||
autocomplete: { "prompt.autocomplete.next": "ctrl+j" },
|
||||
dialog_actions: { "dialog.action.toggle": "ctrl+t" },
|
||||
model: { "model.dialog.favorite": "ctrl+f" },
|
||||
plugins: { "plugin.dialog.install": "shift+i" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const config = await getTuiConfig(tmp.path)
|
||||
expect(config.keymap.sections.global.find((binding) => binding.cmd === "command.palette.show")?.key).toBe("alt+p")
|
||||
expect(config.keymap.sections.global.find((binding) => binding.cmd === "session.new")?.key).toBe("<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 () => {
|
||||
test("resolves keybind lookup from canonical keybinds", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "tui.json"),
|
||||
JSON.stringify({
|
||||
keybinds: {
|
||||
leader: { key: { name: "g", ctrl: true } },
|
||||
command_list: "alt+p",
|
||||
which_key_toggle: "alt+k",
|
||||
editor_open: "ctrl+e",
|
||||
"prompt.autocomplete.next": "ctrl+j",
|
||||
"dialog.mcp.toggle": "ctrl+t",
|
||||
model_favorite_toggle: "ctrl+f",
|
||||
"dialog.plugins.install": "shift+i",
|
||||
},
|
||||
leader_timeout: 1234,
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const config = await getTuiConfig(tmp.path)
|
||||
expect(config.keybinds.get("leader")?.[0]?.key).toEqual({ name: "g", ctrl: true })
|
||||
expect(config.leader_timeout).toBe(1234)
|
||||
expect(config.keybinds.get("command.palette.show")?.[0]?.key).toBe("alt+p")
|
||||
expect(config.keybinds.get("session.new")?.[0]?.key).toBe("<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",
|
||||
},
|
||||
}),
|
||||
@@ -483,52 +464,23 @@ test("legacy keybinds transform into semantic keymap sections", async () => {
|
||||
})
|
||||
|
||||
const config = await getTuiConfig(tmp.path)
|
||||
expect(Object.keys(config.keymap.sections)).toEqual([
|
||||
"global",
|
||||
"which_key",
|
||||
"session",
|
||||
"prompt",
|
||||
"autocomplete",
|
||||
"input",
|
||||
"dialog_select",
|
||||
"dialog_actions",
|
||||
"model",
|
||||
"permission",
|
||||
"question",
|
||||
"plugins",
|
||||
"home_tips",
|
||||
])
|
||||
expect(config.keymap.sections.global.find((binding) => binding.cmd === "command.palette.show")?.key).toBe("alt+p")
|
||||
expect(config.keymap.sections.which_key.find((binding) => binding.cmd === "tui-which-key.toggle")?.key).toBe(
|
||||
"ctrl+alt+k",
|
||||
)
|
||||
expect(config.keymap.sections.prompt.find((binding) => binding.cmd === "prompt.editor")?.key).toBe("ctrl+e")
|
||||
expect(config.keymap.sections.autocomplete.find((binding) => binding.cmd === "prompt.autocomplete.next")?.key).toBe(
|
||||
"ctrl+j",
|
||||
)
|
||||
expect(config.keymap.sections.dialog_actions.find((binding) => binding.cmd === "dialog.action.toggle")?.key).toBe(
|
||||
"ctrl+t",
|
||||
)
|
||||
expect(config.keymap.sections.model.find((binding) => binding.cmd === "model.dialog.provider")?.key).toBe("ctrl+a")
|
||||
expect(config.keymap.sections.model.find((binding) => binding.cmd === "model.dialog.favorite")?.key).toBe("ctrl+f")
|
||||
expect(config.keymap.sections.plugins.find((binding) => binding.cmd === "plugin.dialog.install")?.key).toBe("shift+i")
|
||||
expect(config.keymap.sections.plugins.find((binding) => binding.cmd === "plugins.list")?.key).toBe("ctrl+shift+p")
|
||||
expect(config.keymap.pick("plugins", ["plugin.dialog.install"]).map((binding) => binding.cmd)).toEqual([
|
||||
"plugin.dialog.install",
|
||||
])
|
||||
expect((config.keymap.omit("plugins", ["plugin.dialog.install"])[0] as { group?: unknown } | undefined)?.group).toBe(
|
||||
"Plugins",
|
||||
)
|
||||
expect(config.keymap.omit("plugins", ["plugin.dialog.install"]).map((binding) => binding.cmd)).toEqual([
|
||||
"plugins.list",
|
||||
expect(config.keybinds.get("command.palette.show")).toEqual([
|
||||
{ key: "alt+p", cmd: "command.palette.show", preventDefault: false, desc: "List available commands" },
|
||||
])
|
||||
expect(config.keybinds.get("prompt.editor")?.[0]).toMatchObject({
|
||||
key: { name: "e", ctrl: true },
|
||||
cmd: "prompt.editor",
|
||||
group: "Explicit",
|
||||
})
|
||||
expect(config.keybinds.get("prompt.autocomplete.next")).toEqual([])
|
||||
expect(config.keybinds.get("plugins.list")?.[0]?.key).toBe("ctrl+shift+p")
|
||||
})
|
||||
|
||||
wintest("defaults Ctrl+Z to input undo on Windows", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const config = await getTuiConfig(tmp.path)
|
||||
expect(config.keybinds?.terminal_suspend).toBe("none")
|
||||
expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z")
|
||||
expect(config.keybinds.get("terminal.suspend")).toEqual([])
|
||||
expect(config.keybinds.get("input.undo")?.[0]?.key).toBe("ctrl+z,ctrl+-,super+z")
|
||||
})
|
||||
|
||||
wintest("keeps explicit input undo overrides on Windows", async () => {
|
||||
@@ -538,8 +490,8 @@ wintest("keeps explicit input undo overrides on Windows", async () => {
|
||||
},
|
||||
})
|
||||
const config = await getTuiConfig(tmp.path)
|
||||
expect(config.keybinds?.terminal_suspend).toBe("none")
|
||||
expect(config.keybinds?.input_undo).toBe("ctrl+y")
|
||||
expect(config.keybinds.get("terminal.suspend")).toEqual([])
|
||||
expect(config.keybinds.get("input.undo")?.[0]?.key).toBe("ctrl+y")
|
||||
})
|
||||
|
||||
wintest("ignores terminal suspend bindings on Windows", async () => {
|
||||
@@ -550,33 +502,29 @@ wintest("ignores terminal suspend bindings on Windows", async () => {
|
||||
})
|
||||
|
||||
const config = await getTuiConfig(tmp.path)
|
||||
expect(config.keybinds?.terminal_suspend).toBe("none")
|
||||
expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z")
|
||||
expect(config.keybinds.get("terminal.suspend")).toEqual([])
|
||||
expect(config.keybinds.get("input.undo")?.[0]?.key).toBe("ctrl+z,ctrl+-,super+z")
|
||||
})
|
||||
|
||||
test("applies Windows keymap defaults", async () => {
|
||||
test("applies Windows keybind defaults", async () => {
|
||||
await withPlatform("win32", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
const config = await getTuiConfig(tmp.path)
|
||||
expect(config.keymap.sections.global.find((binding) => binding.cmd === "terminal.suspend")).toBeUndefined()
|
||||
expect(config.keymap.sections.input.find((binding) => binding.cmd === "input.undo")?.key).toBe(
|
||||
"ctrl+z,ctrl+-,super+z",
|
||||
)
|
||||
expect(config.keybinds.get("terminal.suspend")).toEqual([])
|
||||
expect(config.keybinds.get("input.undo")?.[0]?.key).toBe("ctrl+z,ctrl+-,super+z")
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps explicit configured keymap terminal suspend binding on Windows", async () => {
|
||||
test("ignores explicit keybind terminal suspend binding on Windows", async () => {
|
||||
await withPlatform("win32", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "tui.json"),
|
||||
JSON.stringify({
|
||||
keymap: {
|
||||
sections: {
|
||||
global: { "terminal.suspend": "alt+z" },
|
||||
},
|
||||
keybinds: {
|
||||
terminal_suspend: "alt+z",
|
||||
},
|
||||
}),
|
||||
)
|
||||
@@ -584,21 +532,19 @@ test("keeps explicit configured keymap terminal suspend binding on Windows", asy
|
||||
})
|
||||
|
||||
const config = await getTuiConfig(tmp.path)
|
||||
expect(config.keymap.sections.global.find((binding) => binding.cmd === "terminal.suspend")?.key).toBe("alt+z")
|
||||
expect(config.keybinds.get("terminal.suspend")).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps explicit configured keymap input undo on Windows", async () => {
|
||||
test("keeps explicit configured keybind input undo on Windows", async () => {
|
||||
await withPlatform("win32", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "tui.json"),
|
||||
JSON.stringify({
|
||||
keymap: {
|
||||
sections: {
|
||||
input: { "input.undo": "ctrl+y" },
|
||||
},
|
||||
keybinds: {
|
||||
input_undo: "ctrl+y",
|
||||
},
|
||||
}),
|
||||
)
|
||||
@@ -606,7 +552,7 @@ test("keeps explicit configured keymap input undo on Windows", async () => {
|
||||
})
|
||||
|
||||
const config = await getTuiConfig(tmp.path)
|
||||
expect(config.keymap.sections.input.find((binding) => binding.cmd === "input.undo")?.key).toBe("ctrl+y")
|
||||
expect(config.keybinds.get("input.undo")?.[0]?.key).toBe("ctrl+y")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -655,7 +601,7 @@ test("applies env and file substitutions in tui.json", async () => {
|
||||
})
|
||||
const config = await getTuiConfig(tmp.path)
|
||||
expect(config.theme).toBe("env-theme")
|
||||
expect(config.keybinds?.app_exit).toBe("ctrl+q")
|
||||
expect(config.keybinds.get("app.exit")?.[0]?.key).toBe("ctrl+q")
|
||||
} finally {
|
||||
if (original === undefined) delete process.env.TUI_THEME_TEST
|
||||
else process.env.TUI_THEME_TEST = original
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { RGBA, type CliRenderer } from "@opentui/core"
|
||||
import type { HostPluginApi } from "../../src/cli/cmd/tui/plugin/slots"
|
||||
import { LegacyKeymapTransform } from "../../src/cli/cmd/tui/config/legacy-keymap-transform"
|
||||
import { ConfigKeybinds } from "../../src/config/keybinds"
|
||||
import { createTuiResolvedKeymap } from "./tui-runtime"
|
||||
import { createTuiResolvedConfig } from "./tui-runtime"
|
||||
|
||||
type Count = {
|
||||
event_add: number
|
||||
@@ -112,11 +110,9 @@ type Opts = {
|
||||
}
|
||||
|
||||
function tuiConfig(input?: Partial<HostPluginApi["tuiConfig"]>): HostPluginApi["tuiConfig"] {
|
||||
const keybinds = ConfigKeybinds.Keybinds.parse(input?.keybinds ?? {})
|
||||
return {
|
||||
...createTuiResolvedConfig(),
|
||||
...input,
|
||||
keybinds,
|
||||
keymap: input?.keymap ?? createTuiResolvedKeymap(LegacyKeymapTransform.create(input?.keybinds ?? {})),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,45 +1,29 @@
|
||||
import { spyOn } from "bun:test"
|
||||
import path from "path"
|
||||
import type { KeyEvent, Renderable } from "@opentui/core"
|
||||
import { resolveBindingSections, type BindingSectionsConfig } from "@opentui/keymap/extras"
|
||||
import { createBindingLookup } from "@opentui/keymap/extras"
|
||||
import { TuiConfig } from "../../src/cli/cmd/tui/config/tui"
|
||||
import { LegacyKeymapTransform } from "../../src/cli/cmd/tui/config/legacy-keymap-transform"
|
||||
import { ConfigKeybinds } from "../../src/config/keybinds"
|
||||
import {
|
||||
KeymapConfig,
|
||||
KeymapSectionNames,
|
||||
keymapBindingDefaults,
|
||||
type KeymapConfigInput,
|
||||
type KeymapSection,
|
||||
} from "../../src/cli/cmd/tui/config/tui-schema"
|
||||
import { TuiKeybind } from "../../src/cli/cmd/tui/config/keybind"
|
||||
|
||||
type PluginSpec = string | [string, Record<string, unknown>]
|
||||
type ResolvedInput = Omit<TuiConfig.Resolved, "keybinds" | "keymap"> & {
|
||||
keybinds?: TuiConfig.Resolved["keybinds"]
|
||||
keymap?: TuiConfig.Resolved["keymap"]
|
||||
type ResolvedInput = Omit<TuiConfig.Resolved, "keybinds" | "leader_timeout"> & {
|
||||
keybinds?: Partial<TuiKeybind.Keybinds>
|
||||
leader_timeout?: number
|
||||
}
|
||||
|
||||
export function createTuiResolvedKeymap(input: KeymapConfigInput): TuiConfig.Resolved["keymap"] {
|
||||
const config = KeymapConfig.parse(input)
|
||||
return {
|
||||
leader: !config.leader || config.leader === "none" ? "ctrl+x" : config.leader,
|
||||
leader_timeout: config.leader_timeout,
|
||||
...resolveBindingSections<Renderable, KeyEvent, BindingSectionsConfig<Renderable, KeyEvent>, KeymapSection>(
|
||||
config.sections,
|
||||
{
|
||||
sections: KeymapSectionNames,
|
||||
bindingDefaults: keymapBindingDefaults,
|
||||
},
|
||||
),
|
||||
}
|
||||
export function createTuiResolvedKeybinds(input: Partial<TuiKeybind.Keybinds> = {}): TuiConfig.Resolved["keybinds"] {
|
||||
const keybinds = TuiKeybind.Keybinds.parse(input)
|
||||
return createBindingLookup(TuiKeybind.toBindingConfig(keybinds), {
|
||||
commandMap: TuiKeybind.CommandMap,
|
||||
bindingDefaults: TuiKeybind.bindingDefaults(),
|
||||
})
|
||||
}
|
||||
|
||||
export function createTuiResolvedConfig(input: ResolvedInput = {}): TuiConfig.Resolved {
|
||||
const keybinds = input.keybinds ?? ConfigKeybinds.Keybinds.parse({})
|
||||
const keybinds = TuiKeybind.Keybinds.parse(input.keybinds ?? {})
|
||||
return {
|
||||
...input,
|
||||
keybinds,
|
||||
keymap: input.keymap ?? createTuiResolvedKeymap(LegacyKeymapTransform.create(input.keybinds ?? {})),
|
||||
keybinds: createTuiResolvedKeybinds(keybinds),
|
||||
leader_timeout: input.leader_timeout ?? 2000,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,9 +22,9 @@
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.2.5",
|
||||
"@opentui/keymap": ">=0.2.5",
|
||||
"@opentui/solid": ">=0.2.5"
|
||||
"@opentui/core": ">=0.2.6",
|
||||
"@opentui/keymap": ">=0.2.6",
|
||||
"@opentui/solid": ">=0.2.6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@opentui/core": {
|
||||
|
||||
@@ -18,8 +18,9 @@ import type {
|
||||
import type { CliRenderer, KeyEvent, RGBA, Renderable, SlotMode } from "@opentui/core"
|
||||
import type { Binding, Keymap } from "@opentui/keymap"
|
||||
import {
|
||||
resolveBindingSections as resolveKeymapBindingSections,
|
||||
type BindingSectionsConfig,
|
||||
createBindingLookup as createKeymapBindingLookup,
|
||||
type BindingConfig,
|
||||
type CreateBindingLookupOptions,
|
||||
type KeySequenceFormatPart,
|
||||
type SequenceBindingLike,
|
||||
} from "@opentui/keymap/extras"
|
||||
@@ -31,22 +32,21 @@ export { stringifyKeySequence, stringifyKeyStroke } from "@opentui/keymap"
|
||||
export type { Binding, KeyLike, KeySequencePart, KeyStringifyInput, StringifyOptions } from "@opentui/keymap"
|
||||
export { formatCommandBindings, formatKeySequence } from "@opentui/keymap/extras"
|
||||
export type {
|
||||
BindingSectionsConfig,
|
||||
BindingConfig,
|
||||
BindingLookup,
|
||||
BindingValue,
|
||||
CreateBindingLookupOptions,
|
||||
FormatCommandBindingsOptions,
|
||||
FormatKeySequenceOptions,
|
||||
KeySequenceFormatPart,
|
||||
SequenceBindingLike,
|
||||
} from "@opentui/keymap/extras"
|
||||
|
||||
export function resolveBindingSections<Section extends string>(
|
||||
config: BindingSectionsConfig<Renderable, KeyEvent> | undefined,
|
||||
options: { sections: readonly Section[] },
|
||||
export function createBindingLookup(
|
||||
config: BindingConfig<Renderable, KeyEvent> | undefined,
|
||||
options?: CreateBindingLookupOptions<Renderable, KeyEvent>,
|
||||
) {
|
||||
return resolveKeymapBindingSections<Renderable, KeyEvent, BindingSectionsConfig<Renderable, KeyEvent>, Section>(
|
||||
config ?? {},
|
||||
options,
|
||||
)
|
||||
return createKeymapBindingLookup<Renderable, KeyEvent>(config ?? {}, options)
|
||||
}
|
||||
|
||||
export type TuiRouteCurrent =
|
||||
@@ -286,17 +286,20 @@ export type TuiState = {
|
||||
mcp: () => ReadonlyArray<TuiSidebarMcpItem>
|
||||
}
|
||||
|
||||
type TuiConfigView = Pick<PluginConfig, "$schema" | "theme" | "keybinds" | "plugin"> &
|
||||
NonNullable<PluginConfig["tui"]> & {
|
||||
plugin_enabled?: Record<string, boolean>
|
||||
keymap: {
|
||||
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>[]
|
||||
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"]> & {
|
||||
leader_timeout: number
|
||||
plugin_enabled?: Record<string, boolean>
|
||||
keybinds: TuiBindingLookupView
|
||||
}
|
||||
|
||||
export type TuiApp = {
|
||||
|
||||
@@ -525,26 +525,20 @@ You can also define commands using markdown files in `~/.config/opencode/command
|
||||
|
||||
---
|
||||
|
||||
### Keymap
|
||||
### Keybinds
|
||||
|
||||
Customize TUI keyboard shortcuts in `tui.json` with `keymap`.
|
||||
Customize TUI keyboard shortcuts in `tui.json` with `keybinds`.
|
||||
|
||||
```json title="tui.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/tui.json",
|
||||
"keymap": {
|
||||
"sections": {
|
||||
"global": {
|
||||
"command.palette.show": "ctrl+p"
|
||||
}
|
||||
}
|
||||
"keybinds": {
|
||||
"command_list": "ctrl+p"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`keymap` is merged with built-in defaults, so you only need to configure the shortcuts you want to change.
|
||||
|
||||
The older `keybinds` field is deprecated and only applies when `keymap` is not present.
|
||||
`keybinds` is merged with built-in defaults, so you only need to configure the shortcuts you want to change.
|
||||
|
||||
[Learn more here](/docs/keybinds).
|
||||
|
||||
|
||||
@@ -1,320 +1,241 @@
|
||||
---
|
||||
title: Keybinds
|
||||
description: Customize your keyboard shortcuts.
|
||||
description: Customize your keybinds.
|
||||
---
|
||||
|
||||
OpenCode customizes TUI keyboard shortcuts with `keymap` in `tui.json`.
|
||||
|
||||
The older `keybinds` field is still accepted as a migration fallback, but it is deprecated and will be removed in OpenCode v2.0. If `keymap` is present, OpenCode ignores `keybinds` for shortcut resolution.
|
||||
|
||||
`keymap` is merged with built-in defaults, so you only need to configure the shortcuts you want to change.
|
||||
|
||||
---
|
||||
|
||||
## Leader key
|
||||
|
||||
OpenCode uses a `leader` key for many shortcuts. This avoids conflicts in your terminal.
|
||||
|
||||
By default, `ctrl+x` is the leader key and leader shortcuts require you to first press the leader key and then the shortcut. For example, to start a new session you first press `ctrl+x` and then press `n`.
|
||||
|
||||
You do not need to use a leader key, but we recommend doing so.
|
||||
|
||||
---
|
||||
|
||||
## Minimal example
|
||||
OpenCode has a list of keybinds that you can customize through `tui.json`.
|
||||
|
||||
```json title="tui.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/tui.json",
|
||||
"keymap": {
|
||||
"leader": "ctrl+x",
|
||||
"leader_timeout": 2000,
|
||||
"sections": {
|
||||
"global": {
|
||||
"command.palette.show": "ctrl+p",
|
||||
"session.new": "<leader>n",
|
||||
"session.list": "<leader>l"
|
||||
},
|
||||
"session": {
|
||||
"session.compact": "<leader>c",
|
||||
"session.undo": "<leader>u",
|
||||
"session.redo": "<leader>r"
|
||||
},
|
||||
"input": {
|
||||
"input.submit": "return",
|
||||
"input.newline": ["shift+return", "ctrl+return", "alt+return", "ctrl+j"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Keymap structure
|
||||
|
||||
`keymap.sections` is grouped by semantic area. Each section contains command names and the key sequence that triggers them.
|
||||
|
||||
| Field | Description |
|
||||
| ---------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| `leader` | The key used by `<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
|
||||
|
||||
A string can contain one shortcut or multiple comma-separated shortcuts. You can also use an array for multiple shortcuts, or `"none"`/`false` to disable a command.
|
||||
|
||||
```json title="tui.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/tui.json",
|
||||
"keymap": {
|
||||
"sections": {
|
||||
"session": {
|
||||
"session.compact": "none",
|
||||
"session.export": "<leader>x,ctrl+shift+x",
|
||||
"session.copy": ["<leader>y", "ctrl+shift+c"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For advanced cases, use an object with `key`, `event`, `preventDefault`, or `fallthrough`.
|
||||
|
||||
```json title="tui.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/tui.json",
|
||||
"keymap": {
|
||||
"sections": {
|
||||
"prompt": {
|
||||
"prompt.paste": {
|
||||
"key": "ctrl+v",
|
||||
"preventDefault": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Complete keymap reference
|
||||
|
||||
This example lists the built-in sections, command names, and default fallback bindings. Commands set to `"none"` are available to bind but disabled by default.
|
||||
|
||||
```json title="tui.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/tui.json",
|
||||
"keymap": {
|
||||
"keybinds": {
|
||||
"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": {
|
||||
"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",
|
||||
"command_list": "ctrl+p",
|
||||
"help_show": "none",
|
||||
"docs_open": "none",
|
||||
|
||||
"editor_open": "<leader>e",
|
||||
"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
|
||||
},
|
||||
"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": {
|
||||
"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_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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
"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",
|
||||
|
||||
## 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"
|
||||
{
|
||||
"$schema": "https://opencode.ai/tui.json",
|
||||
"keybinds": {
|
||||
"command_list": "ctrl+p",
|
||||
"session_new": "<leader>n",
|
||||
"session_compact": "<leader>c"
|
||||
"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 native Windows, the defaults for undo and terminal suspend are different for both `keymap` and legacy `keybinds`:
|
||||
On Windows, the defaults for `input_undo` and `terminal_suspend` are different:
|
||||
|
||||
- `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.
|
||||
- `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.
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## Desktop prompt shortcuts
|
||||
## Leader Key
|
||||
|
||||
OpenCode uses a `leader` key for many keybinds. This avoids conflicts in your terminal.
|
||||
|
||||
By default, `ctrl+x` is the leader key and many actions require you to first press the leader key and then the shortcut. For example, to start a new session you first press `ctrl+x` and then press `n`.
|
||||
|
||||
You don't need to use a leader key for your keybinds but we recommend doing so.
|
||||
|
||||
Some navigation keybinds intentionally do not use the leader key by default. For subagent sessions, the defaults are `session_child_first` = `<leader>down`, `session_child_cycle` = `right`, `session_child_cycle_reverse` = `left`, and `session_parent` = `up`.
|
||||
|
||||
`leader_timeout` controls how long OpenCode waits for the next key after the leader key. It defaults to `2000` milliseconds.
|
||||
|
||||
---
|
||||
|
||||
## Binding Values
|
||||
|
||||
A string can contain one shortcut or multiple comma-separated shortcuts. You can also use an array for multiple shortcuts.
|
||||
|
||||
For advanced cases, use an object with `key`, `event`, `preventDefault`, or `fallthrough`.
|
||||
|
||||
```json title="tui.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/tui.json",
|
||||
"keybinds": {
|
||||
"messages_copy": ["<leader>y", "ctrl+shift+c"],
|
||||
"input_paste": {
|
||||
"key": "ctrl+v",
|
||||
"preventDefault": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Disable Keybind
|
||||
|
||||
You can disable a keybind by adding the key to `tui.json` with a value of `"none"` or `false`.
|
||||
|
||||
```json title="tui.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/tui.json",
|
||||
"keybinds": {
|
||||
"session_compact": "none"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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`.
|
||||
|
||||
|
||||
@@ -353,14 +353,10 @@ You can customize TUI behavior through `tui.json` (or `tui.jsonc`).
|
||||
{
|
||||
"$schema": "https://opencode.ai/tui.json",
|
||||
"theme": "opencode",
|
||||
"keymap": {
|
||||
"leader": "ctrl+x",
|
||||
"leader_timeout": 2000,
|
||||
"sections": {
|
||||
"global": {
|
||||
"command.palette.show": "ctrl+p"
|
||||
}
|
||||
}
|
||||
"keybinds": {
|
||||
"leader": "ctrl+x",
|
||||
"command_list": "ctrl+p"
|
||||
},
|
||||
"scroll_speed": 3,
|
||||
"scroll_acceleration": {
|
||||
@@ -373,13 +369,13 @@ You can customize TUI behavior through `tui.json` (or `tui.jsonc`).
|
||||
|
||||
This is separate from `opencode.json`, which configures server/runtime behavior.
|
||||
|
||||
`keymap` is merged with built-in defaults, so you only need to configure the shortcuts you want to change.
|
||||
`keybinds` is merged with built-in defaults, so you only need to configure the shortcuts you want to change.
|
||||
|
||||
### Options
|
||||
|
||||
- `theme` - Sets your UI theme. [Learn more](/docs/themes).
|
||||
- `keymap` - Customizes keyboard shortcuts. [Learn more](/docs/keybinds).
|
||||
- `keybinds` - Deprecated legacy shortcut config. This only applies when `keymap` is not present.
|
||||
- `keybinds` - Customizes keyboard shortcuts. [Learn more](/docs/keybinds).
|
||||
- `leader_timeout` - Controls how long OpenCode waits after the leader key. Defaults to `2000`.
|
||||
- `scroll_acceleration.enabled` - Enable macOS-style scroll acceleration for smooth, natural scrolling. When enabled, scroll speed increases with rapid scrolling gestures and stays precise for slower movements. **This setting takes precedence over `scroll_speed` and overrides it when enabled.**
|
||||
- `scroll_speed` - Controls how fast the TUI scrolls when using scroll commands (minimum: `0.001`, supports decimal values). Defaults to `3`. **Note: This is ignored if `scroll_acceleration.enabled` is set to `true`.**
|
||||
- `diff_style` - Controls diff rendering. `"auto"` adapts to terminal width, `"stacked"` always shows a single-column layout.
|
||||
|
||||
Reference in New Issue
Block a user