flatten to keybind compatible config (#26421)
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user