introduce opentui keymap as sole key/cmd engine (#26053)

This commit is contained in:
Sebastian
2026-05-07 20:35:31 +02:00
committed by GitHub
parent 474e311f6f
commit 98f5e6e713
67 changed files with 3858 additions and 2977 deletions
@@ -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()
+46 -14
View File
@@ -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 : [],
}
})