introduce opentui keymap as sole key/cmd engine (#26053)
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
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,4 +1,7 @@
|
||||
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"
|
||||
|
||||
@@ -11,6 +14,303 @@ const KeybindOverride = z
|
||||
)
|
||||
.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 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),
|
||||
session: keymapSection(SessionKeymapSection),
|
||||
prompt: keymapSection(PromptKeymapSection),
|
||||
autocomplete: keymapSection(AutocompleteKeymapSection),
|
||||
input: keymapSection(InputKeymapSection),
|
||||
dialog_select: keymapSection(DialogSelectKeymapSection),
|
||||
dialog_actions: keymapSection(DialogActionsKeymapSection),
|
||||
model: keymapSection(ModelKeymapSection),
|
||||
permission: keymapSection(PermissionKeymapSection),
|
||||
question: keymapSection(QuestionKeymapSection),
|
||||
plugins: keymapSection(PluginsKeymapSection),
|
||||
home_tips: keymapSection(HomeTipsKeymapSection),
|
||||
}
|
||||
|
||||
const KeymapSectionsInputShape = {
|
||||
global: keymapSectionInput(GlobalKeymapSection).optional(),
|
||||
session: keymapSectionInput(SessionKeymapSection).optional(),
|
||||
prompt: keymapSectionInput(PromptKeymapSection).optional(),
|
||||
autocomplete: keymapSectionInput(AutocompleteKeymapSection).optional(),
|
||||
input: keymapSectionInput(InputKeymapSection).optional(),
|
||||
dialog_select: keymapSectionInput(DialogSelectKeymapSection).optional(),
|
||||
dialog_actions: keymapSectionInput(DialogActionsKeymapSection).optional(),
|
||||
model: keymapSectionInput(ModelKeymapSection).optional(),
|
||||
permission: keymapSectionInput(PermissionKeymapSection).optional(),
|
||||
question: keymapSectionInput(QuestionKeymapSection).optional(),
|
||||
plugins: keymapSectionInput(PluginsKeymapSection).optional(),
|
||||
home_tips: keymapSectionInput(HomeTipsKeymapSection).optional(),
|
||||
}
|
||||
|
||||
export const KeymapSections = z.object(KeymapSectionsShape).strict().prefault({})
|
||||
export type KeymapSections = z.output<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",
|
||||
session: "Session",
|
||||
prompt: "Prompt",
|
||||
autocomplete: "Autocomplete",
|
||||
input: "Text Editing",
|
||||
dialog_select: "Dialog",
|
||||
dialog_actions: "Dialog",
|
||||
model: "Model",
|
||||
permission: "Permission",
|
||||
question: "Question",
|
||||
plugins: "Plugins",
|
||||
home_tips: "Home",
|
||||
} satisfies Record<KeymapSection, string>
|
||||
|
||||
export function keymapBindingDefaults(input: { section: string; binding: Readonly<Binding<Renderable, KeyEvent>> }) {
|
||||
if (input.binding.group !== undefined) return
|
||||
if (!Object.hasOwn(KeymapSectionGroups, input.section)) return
|
||||
return { group: KeymapSectionGroups[input.section as KeymapSection] }
|
||||
}
|
||||
|
||||
export const KeymapConfig = z
|
||||
.object({
|
||||
leader: z.string().prefault("ctrl+x"),
|
||||
leader_timeout: z.number().int().positive().prefault(KeymapLeaderTimeoutDefault).describe("Leader key timeout in milliseconds"),
|
||||
sections: KeymapSections,
|
||||
})
|
||||
.strict()
|
||||
.describe("TUI keymap configuration")
|
||||
export type KeymapConfig = z.output<typeof KeymapConfig>
|
||||
|
||||
const KeymapSectionsInput = z.object(KeymapSectionsInputShape).strict().optional()
|
||||
export const KeymapConfigInput = z
|
||||
.object({
|
||||
leader: z.string().optional(),
|
||||
leader_timeout: z.number().int().positive().optional().describe("Leader key timeout in milliseconds"),
|
||||
sections: KeymapSectionsInput,
|
||||
})
|
||||
.strict()
|
||||
.describe("TUI keymap configuration")
|
||||
export type KeymapConfigInput = z.output<typeof KeymapConfigInput>
|
||||
|
||||
export const TuiOptions = z.object({
|
||||
scroll_speed: z.number().min(0.001).optional().describe("TUI scroll speed"),
|
||||
scroll_acceleration: z
|
||||
@@ -30,9 +330,17 @@ export const TuiInfo = z
|
||||
.object({
|
||||
$schema: z.string().optional(),
|
||||
theme: z.string().optional(),
|
||||
keybinds: KeybindOverride.optional(),
|
||||
keybinds: KeybindOverride.optional().meta({
|
||||
deprecated: true,
|
||||
description: "Use keymap instead. This will be removed in opencode v2.0.",
|
||||
}),
|
||||
keymap: KeymapConfigInput.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()
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
export * as TuiConfig from "./tui"
|
||||
|
||||
import z from "zod"
|
||||
import type z from "zod"
|
||||
import type { KeyEvent, Renderable } from "@opentui/core"
|
||||
import { resolveBindingSections, type BindingSectionsConfig } from "@opentui/keymap/extras"
|
||||
import { mergeDeep, unique } from "remeda"
|
||||
import { Context, Effect, Fiber, Layer } from "effect"
|
||||
import { ConfigParse } from "@/config/parse"
|
||||
import * as ConfigPaths from "@/config/paths"
|
||||
import { migrateTuiConfig } from "./tui-migrate"
|
||||
import { TuiInfo } from "./tui-schema"
|
||||
import { KeymapConfig, TuiInfo, TuiJsonSchemaInfo } from "./tui-schema"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
@@ -20,27 +22,34 @@ 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" })
|
||||
|
||||
export const Info = TuiInfo
|
||||
export const JsonSchemaInfo = TuiJsonSchemaInfo
|
||||
export type Info = z.output<typeof Info>
|
||||
|
||||
type Acc = {
|
||||
result: Info
|
||||
plugin_origins: ConfigPlugin.Origin[]
|
||||
}
|
||||
|
||||
type State = {
|
||||
config: Info
|
||||
deps: Array<Fiber.Fiber<void, AppFileSystem.Error>>
|
||||
}
|
||||
|
||||
export type Info = z.output<typeof Info> & {
|
||||
export type Resolved = Omit<Info, "keybinds" | "keymap"> & {
|
||||
keybinds: ConfigKeybinds.Keybinds
|
||||
keymap: KeymapInfo
|
||||
// Internal resolved plugin list used by runtime loading.
|
||||
plugin_origins?: ConfigPlugin.Origin[]
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly get: () => Effect.Effect<Info>
|
||||
readonly get: () => Effect.Effect<Resolved>
|
||||
readonly waitForDependencies: () => Effect.Effect<void>
|
||||
}
|
||||
|
||||
@@ -128,11 +137,11 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
|
||||
|
||||
const scope = pluginScope(file, ctx)
|
||||
const plugins = ConfigPlugin.deduplicatePluginOrigins([
|
||||
...(acc.result.plugin_origins ?? []),
|
||||
...acc.plugin_origins,
|
||||
...data.plugin.map((spec) => ({ spec, scope, source: file })),
|
||||
])
|
||||
acc.result.plugin = plugins.map((item) => item.spec)
|
||||
acc.result.plugin_origins = plugins
|
||||
acc.plugin_origins = plugins
|
||||
})
|
||||
|
||||
// Every config dir we may read from: global config dir, any `.opencode`
|
||||
@@ -144,6 +153,7 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
|
||||
|
||||
const acc: Acc = {
|
||||
result: {},
|
||||
plugin_origins: [],
|
||||
}
|
||||
|
||||
// 1. Global tui config (lowest precedence).
|
||||
@@ -184,11 +194,33 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
|
||||
...ConfigKeybinds.Keybinds.shape.input_undo.parse(undefined).split(","),
|
||||
]).join(",")
|
||||
}
|
||||
acc.result.keybinds = ConfigKeybinds.Keybinds.parse(keybinds)
|
||||
const parsedKeybinds = ConfigKeybinds.Keybinds.parse(keybinds)
|
||||
const keymapInput = acc.result.keymap ?? LegacyKeymapTransform.create(acc.result.keybinds ?? {})
|
||||
const keymapConfig = KeymapConfig.parse(keymapInput)
|
||||
const keymap = {
|
||||
leader: !keymapConfig.leader || keymapConfig.leader === "none" ? "ctrl+x" : keymapConfig.leader,
|
||||
leader_timeout: keymapConfig.leader_timeout,
|
||||
...resolveBindingSections<Renderable, KeyEvent, BindingSectionsConfig<Renderable, KeyEvent>, KeymapSection>(
|
||||
keymapConfig.sections,
|
||||
{
|
||||
sections: KeymapSectionNames,
|
||||
bindingDefaults: keymapBindingDefaults,
|
||||
},
|
||||
),
|
||||
}
|
||||
const result: Resolved = {
|
||||
...acc.result,
|
||||
keybinds: parsedKeybinds,
|
||||
plugin_origins: acc.plugin_origins.length ? acc.plugin_origins : undefined,
|
||||
// `keybinds` is deprecated and will be removed in opencode v2.0. Keep it
|
||||
// only as the legacy fallback; once `keymap` is configured, ignore
|
||||
// `keybinds` for keymap resolution.
|
||||
keymap,
|
||||
}
|
||||
|
||||
return {
|
||||
config: acc.result,
|
||||
dirs: acc.result.plugin?.length ? dirs : [],
|
||||
config: result,
|
||||
dirs: result.plugin?.length ? dirs : [],
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user