refactor(tui): remove legacy keymap layer (#37206)

Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: Dax Raad <d@ironbay.co>
This commit is contained in:
opencode-agent[bot] 2026-07-16 21:33:15 +00:00 committed by GitHub
commit b4a4ef0b3c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 535 additions and 1025 deletions

View file

@ -80,8 +80,7 @@ import { Config, ConfigProvider, useConfig } from "./config"
import { createPluginRuntime, PluginRuntimeProvider, usePluginRuntime } from "./plugin/runtime"
import { PluginProvider, PluginRoute, PluginSlot, usePlugin, type PackageResolver } from "./plugin/context"
import { CommandPaletteDialog } from "./component/command-palette"
import { COMMAND_PALETTE_COMMAND, OPENCODE_BASE_MODE, useBindings, useOpencodeKeymap } from "./keymap"
import { Keymap } from "./context/keymap"
import { COMMAND_PALETTE_COMMAND, Keymap, type KeymapCommand } from "./context/keymap"
import { DialogVariant } from "./component/dialog-variant"
import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-win32"
@ -416,7 +415,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
const renderer = useRenderer()
const dialog = useDialog()
const local = useLocal()
const keymap = useOpencodeKeymap()
const keymap = Keymap.use()
const event = useEvent()
const client = useClient()
const toast = useToast()
@ -589,7 +588,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
name: COMMAND_PALETTE_COMMAND,
title: "Show command palette",
category: "System",
hidden: true,
palette: undefined,
run: () => {
dialog.replace(() => <CommandPaletteDialog />)
},
@ -621,7 +620,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
name: `session.quick_switch.${i + 1}`,
title: `Switch to session in quick slot ${i + 1}`,
category: "Session",
hidden: true,
palette: undefined,
run: () => {
local.session.quickSwitch(i + 1)
},
@ -641,7 +640,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
name: "model.cycle_recent",
title: "Model cycle",
category: "Agent",
hidden: true,
palette: undefined,
run: () => {
local.model.cycle(1)
},
@ -650,7 +649,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
name: "model.cycle_recent_reverse",
title: "Model cycle reverse",
category: "Agent",
hidden: true,
palette: undefined,
run: () => {
local.model.cycle(-1)
},
@ -659,7 +658,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
name: "model.cycle_favorite",
title: "Favorite cycle",
category: "Agent",
hidden: true,
palette: undefined,
run: () => {
local.model.cycleFavorite(1)
},
@ -668,7 +667,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
name: "model.cycle_favorite_reverse",
title: "Favorite cycle reverse",
category: "Agent",
hidden: true,
palette: undefined,
run: () => {
local.model.cycleFavorite(-1)
},
@ -695,7 +694,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
name: "agent.cycle",
title: "Agent cycle",
category: "Agent",
hidden: true,
palette: undefined,
run: () => {
local.agent.move(1)
},
@ -712,7 +711,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
name: "variant.list",
title: "Switch model variant",
category: "Agent",
hidden: local.model.variant.list().length === 0,
palette: local.model.variant.list().length === 0 ? undefined : (true as const),
slash: { name: "variants" },
run: () => {
if (local.model.variant.list().length === 0) {
@ -729,7 +728,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
name: "agent.cycle.reverse",
title: "Agent cycle reverse",
category: "Agent",
hidden: true,
palette: undefined,
run: () => {
local.agent.move(-1)
},
@ -818,7 +817,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
{
name: "theme.switch_mode",
title: mode() === "dark" ? "Switch to light mode" : "Switch to dark mode",
hidden: true,
palette: undefined,
run: () => {
setMode(mode() === "dark" ? "light" : "dark")
dialog.clear()
@ -828,7 +827,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
{
name: "theme.mode.lock",
title: locked() ? "Unlock theme mode" : "Lock theme mode",
hidden: true,
palette: undefined,
run: () => {
if (locked()) unlock()
else lock()
@ -883,7 +882,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
name: "terminal.suspend",
title: "Suspend terminal",
category: "System",
hidden: true,
palette: undefined,
enabled: process.platform !== "win32",
run: () => {
renderer.suspend()
@ -895,7 +894,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
name: "terminal.title.toggle",
title: terminalTitleEnabled() ? "Disable terminal title" : "Enable terminal title",
category: "System",
hidden: true,
palette: undefined,
run: () => {
const next = !terminalTitleEnabled()
if (!next) renderer.setTerminalTitle("")
@ -911,7 +910,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
name: "app.toggle.animations",
title: (config.data.animations ?? true) ? "Disable animations" : "Enable animations",
category: "System",
hidden: true,
palette: undefined,
run: () => {
void config
.update((draft) => {
@ -925,7 +924,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
name: "app.toggle.file_context",
title: (config.data.prompt?.editor ?? true) ? "Disable file context" : "Enable file context",
category: "System",
hidden: true,
palette: undefined,
run: () => {
void config
.update((draft) => {
@ -939,7 +938,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
name: "app.toggle.diffwrap",
title: (config.data.diffs?.wrap ?? "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping",
category: "System",
hidden: true,
palette: undefined,
run: () => {
void config
.update((draft) => {
@ -956,7 +955,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
name: "app.toggle.paste_summary",
title: pasteSummaryEnabled() ? "Disable paste summary" : "Enable paste summary",
category: "System",
hidden: true,
palette: undefined,
run: () => {
void config
.update((draft) => {
@ -976,38 +975,44 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
dialog.clear()
},
},
].map((command) => ({
namespace: "palette",
...command,
})),
].map(
({ name, category, ...command }) =>
({
id: name,
group: category,
bind: false,
palette: true as const,
...command,
}) satisfies KeymapCommand,
),
)
useBindings(() => ({
Keymap.createLayer(() => ({
mode: "global",
commands: appCommands(),
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
bindings: appBindingCommands.flatMap((command) => config.data.keybinds.get(command)),
Keymap.createLayer(() => ({
bindings: appBindingCommands,
}))
useBindings(() => ({
bindings: appGlobalBindingCommands.flatMap((command) => config.data.keybinds.get(command)),
Keymap.createLayer(() => ({
mode: "global",
bindings: appGlobalBindingCommands,
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
Keymap.createLayer(() => ({
enabled: () => {
const current = promptRef.current
if (!current?.focused) return true
return current.current.text === ""
},
bindings: config.data.keybinds.get("app.exit"),
bindings: ["app.exit"],
}))
event.on("tui.command.execute", (evt, { workspace }) => {
if (workspace !== (location.current?.workspaceID ?? data.location.default().workspaceID)) return
keymap.dispatchCommand(evt.data.command)
keymap.dispatch(evt.data.command)
})
event.on("tui.toast.show", (evt, { workspace }) => {

View file

@ -1,8 +1,7 @@
import { createMemo } from "solid-js"
import { DialogSelect, type DialogSelectRef } from "../ui/dialog-select"
import { type DialogContext } from "../ui/dialog"
import { COMMAND_PALETTE_COMMAND } from "../keymap"
import { Keymap, type KeymapCommand } from "../context/keymap"
import { COMMAND_PALETTE_COMMAND, Keymap, type KeymapCommand } from "../context/keymap"
function isSuggestedPaletteCommand(command: KeymapCommand) {
const suggested = command.suggested

View file

@ -18,7 +18,6 @@ import { useTerminalDimensions } from "@opentui/solid"
import { Locale } from "../../util/locale"
import type { PromptInfo, PromptPartRef } from "../../prompt/history"
import { useFrecency } from "../../prompt/frecency"
import { useBindings } from "../../keymap"
import { Keymap } from "../../context/keymap"
import { displayCharAt, mentionTriggerIndex } from "../../prompt/display"
import type { FileSystemEntry } from "@opencode-ai/client"
@ -578,48 +577,49 @@ export function Autocomplete(props: {
setStore("selected", 0)
}
useBindings(() => ({
Keymap.createLayer(() => ({
mode: "autocomplete",
target: props.input,
enabled: () => Boolean(store.visible),
commands: [
{
name: "prompt.autocomplete.prev",
id: "prompt.autocomplete.prev",
title: "Previous autocomplete item",
category: "Autocomplete",
group: "Autocomplete",
run() {
setStore("input", "keyboard")
move(-1)
},
},
{
name: "prompt.autocomplete.next",
id: "prompt.autocomplete.next",
title: "Next autocomplete item",
category: "Autocomplete",
group: "Autocomplete",
run() {
setStore("input", "keyboard")
move(1)
},
},
{
name: "prompt.autocomplete.hide",
id: "prompt.autocomplete.hide",
title: "Hide autocomplete",
category: "Autocomplete",
group: "Autocomplete",
run() {
hide()
},
},
{
name: "prompt.autocomplete.select",
id: "prompt.autocomplete.select",
title: "Select autocomplete item",
category: "Autocomplete",
group: "Autocomplete",
run() {
select()
},
},
{
name: "prompt.autocomplete.complete",
id: "prompt.autocomplete.complete",
title: "Complete autocomplete item",
category: "Autocomplete",
group: "Autocomplete",
run() {
const selected = options()[store.selected]
if (selected?.isDirectory) {
@ -631,13 +631,6 @@ export function Autocomplete(props: {
},
},
],
bindings: [
"prompt.autocomplete.prev",
"prompt.autocomplete.next",
"prompt.autocomplete.hide",
"prompt.autocomplete.select",
"prompt.autocomplete.complete",
].flatMap((command) => config.keybinds.get(command)),
}))
function show(mode: "@" | "/") {

View file

@ -6,9 +6,7 @@ import {
PasteEvent,
decodePasteBytes,
type KeyEvent,
type Renderable,
} from "@opentui/core"
import type { CommandContext } from "@opentui/keymap"
import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
import { registerOpencodeSpinner } from "../register-spinner"
import path from "path"
@ -46,7 +44,6 @@ import { useToast } from "../../ui/toast"
import { createFadeIn } from "../../util/signal"
import { DialogSkill } from "../dialog-skill"
import { useArgs } from "../../context/args"
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useLeaderActive, useOpencodeKeymap } from "../../keymap"
import { useConfig } from "../../config"
import { usePromptMove } from "./move"
import { readLocalAttachment } from "./local-attachment"
@ -155,7 +152,7 @@ export function Prompt(props: PromptProps) {
let anchor: BoxRenderable
const [inputTarget, setInputTarget] = createSignal<TextareaRenderable | undefined>()
const leader = useLeaderActive()
const leader = Keymap.useLeaderActive()
const local = useLocal()
const args = useArgs()
const paths = useTuiPaths()
@ -183,10 +180,10 @@ export function Prompt(props: PromptProps) {
)
const history = usePromptHistory()
const stash = usePromptStash()
const keymap = useOpencodeKeymap()
const agentShortcut = useCommandShortcut("agent.cycle")
const paletteShortcut = useCommandShortcut("command.palette.show")
const liveWorkShortcut = useCommandShortcut("session.child.first")
const keymap = Keymap.use()
const agentShortcut = Keymap.useShortcut("agent.cycle")
const paletteShortcut = Keymap.useShortcut("command.palette.show")
const liveWorkShortcut = Keymap.useShortcut("session.child.first")
const renderer = useRenderer()
const exit = useExit()
const dimensions = useTerminalDimensions()
@ -387,7 +384,7 @@ export function Prompt(props: PromptProps) {
title: "Clear prompt",
name: "prompt.clear",
category: "Prompt",
hidden: true,
palette: undefined,
run: () => {
clearPrompt()
dialog.clear()
@ -397,8 +394,10 @@ export function Prompt(props: PromptProps) {
title: "Submit prompt",
name: "prompt.submit",
category: "Prompt",
hidden: true,
run: async () => {
palette: undefined,
run: async (_input: string | undefined, event?: KeyEvent) => {
event?.preventDefault()
event?.stopPropagation()
if (!input.focused) return
const handled = await submit()
if (!handled) return
@ -420,10 +419,10 @@ export function Prompt(props: PromptProps) {
title: "Paste",
name: "prompt.paste",
category: "Prompt",
hidden: true,
run: async (ctx: CommandContext<Renderable, KeyEvent>) => {
ctx.event.preventDefault()
ctx.event.stopPropagation()
palette: undefined,
run: async (_input: string | undefined, event?: KeyEvent) => {
event?.preventDefault()
event?.stopPropagation()
const content = await clipboard.read?.()
if (content?.mime.startsWith("image/")) {
await pasteAttachment({
@ -441,7 +440,7 @@ export function Prompt(props: PromptProps) {
title: "Interrupt session",
name: "session.interrupt",
category: "Session",
hidden: true,
palette: undefined,
enabled: status() === "running",
run: () => {
if (auto()?.visible) return
@ -472,7 +471,7 @@ export function Prompt(props: PromptProps) {
title: "Background blocking tools",
name: "session.background",
category: "Session",
hidden: true,
palette: undefined,
enabled: status() === "running",
run: () => {
if (auto()?.visible) return
@ -564,18 +563,24 @@ export function Prompt(props: PromptProps) {
move.open()
},
},
].map((entry) => ({
namespace: "palette",
...entry,
})),
].map(
({ name, category, ...command }) =>
({
id: name,
group: category,
bind: false,
palette: true as const,
...command,
}) satisfies KeymapCommand,
),
)
useBindings(() => ({
Keymap.createLayer(() => ({
mode: "global",
commands: promptCommands(),
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
Keymap.createLayer(() => ({
bindings: [
"prompt.submit",
"prompt.editor",
@ -587,7 +592,7 @@ export function Prompt(props: PromptProps) {
"session.interrupt",
"session.background",
"session.move",
].flatMap((command) => config.keybinds.get(command)),
],
}))
const ref: PromptRef = {
@ -803,33 +808,40 @@ export function Prompt(props: PromptProps) {
))
},
},
].map((entry) => ({
namespace: "palette",
...entry,
})),
].map(
({ name, category, ...command }) =>
({
id: name,
group: category,
bind: false,
palette: true as const,
...command,
}) satisfies KeymapCommand,
),
)
useBindings(() => ({
Keymap.createLayer(() => ({
mode: "global",
commands: stashCommands(),
}))
useBindings(() => {
Keymap.createLayer(() => {
return {
target: inputTarget,
enabled: inputTarget() !== undefined && !props.disabled,
bindings: config.keybinds.get("prompt.paste"),
bindings: ["prompt.paste"],
}
})
useBindings(() => {
Keymap.createLayer(() => {
return {
target: inputTarget,
enabled: inputTarget() !== undefined && !props.disabled && store.prompt.text !== "",
bindings: config.keybinds.get("prompt.clear"),
bindings: ["prompt.clear"],
}
})
useBindings(() => {
Keymap.createLayer(() => {
return {
target: inputTarget,
enabled: (() => {
@ -842,12 +854,12 @@ export function Prompt(props: PromptProps) {
input?.visualCursor.offset === 0
)
})(),
bindings: [
commands: [
{
key: "!",
desc: "Shell mode",
bind: "!",
title: "Shell mode",
group: "Prompt",
cmd: () => {
run: () => {
setStore("placeholder", randomIndex(shell().length))
setStore("mode", "shell")
},
@ -856,26 +868,28 @@ export function Prompt(props: PromptProps) {
}
})
useBindings(() => {
Keymap.createLayer(() => {
return {
target: inputTarget,
enabled: inputTarget() !== undefined && store.mode === "shell",
bindings: [{ key: "escape", desc: "Exit shell mode", group: "Prompt", cmd: () => setStore("mode", "normal") }],
commands: [{ bind: "escape", title: "Exit shell mode", group: "Prompt", run: () => setStore("mode", "normal") }],
}
})
useBindings(() => {
Keymap.createLayer(() => {
return {
target: inputTarget,
enabled: (() => {
cursorVersion()
return inputTarget() !== undefined && store.mode === "shell" && input?.visualCursor.offset === 0
})(),
bindings: [{ key: "backspace", desc: "Exit shell mode", group: "Prompt", cmd: () => setStore("mode", "normal") }],
commands: [
{ bind: "backspace", title: "Exit shell mode", group: "Prompt", run: () => setStore("mode", "normal") },
],
}
})
useBindings(() => {
Keymap.createLayer(() => {
return {
priority: 1,
target: inputTarget,
@ -885,9 +899,9 @@ export function Prompt(props: PromptProps) {
})(),
commands: [
{
name: "prompt.history.previous",
id: "prompt.history.previous",
title: "Previous prompt history",
category: "Prompt",
group: "Prompt",
run() {
if (input.cursorOffset !== 0) {
if (input.scrollY + input.visualCursor.visualRow === 0) {
@ -908,11 +922,10 @@ export function Prompt(props: PromptProps) {
},
},
],
bindings: config.keybinds.get("prompt.history.previous"),
}
})
useBindings(() => {
Keymap.createLayer(() => {
return {
priority: 1,
target: inputTarget,
@ -922,9 +935,9 @@ export function Prompt(props: PromptProps) {
})(),
commands: [
{
name: "prompt.history.next",
id: "prompt.history.next",
title: "Next prompt history",
category: "Prompt",
group: "Prompt",
run() {
if (input.cursorOffset !== input.plainText.length) {
if (
@ -948,7 +961,6 @@ export function Prompt(props: PromptProps) {
},
},
],
bindings: config.keybinds.get("prompt.history.next"),
}
})
@ -1429,7 +1441,7 @@ export function Prompt(props: PromptProps) {
// Windows Terminal <1.25 can surface image-only clipboard as an
// empty bracketed paste. Windows Terminal 1.25+ does not.
if (!pastedContent) {
keymap.dispatchCommand("prompt.paste")
keymap.dispatch("prompt.paste")
return
}
@ -1589,10 +1601,7 @@ export function Prompt(props: PromptProps) {
</box>
</Match>
<Match when={true}>
<Show
when={!props.hint && locationLabel()}
fallback={props.hint ?? <text />}
>
<Show when={!props.hint && locationLabel()} fallback={props.hint ?? <text />}>
{(location) => (
<text fg={themeV2.text.subdued()} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
{location()}

View file

@ -1,6 +1,6 @@
import type { KeymapCommand, KeymapLayer } from "@opencode-ai/plugin/v2/tui/context"
import { InputRenderable, TextareaRenderable } from "@opentui/core"
import { stringifyKeyStroke } from "@opentui/keymap"
import { InputRenderable, TextareaRenderable, type KeyEvent, type Renderable } from "@opentui/core"
import { stringifyKeyStroke, type Binding, type CommandContext } from "@opentui/keymap"
import {
registerBackspacePopsPendingSequence,
registerBaseLayoutFallback,
@ -32,17 +32,27 @@ const MODE = { key: "opencode.mode", base: "base" } as const
type OpenTuiKeymap = Parameters<typeof KeymapProvider>[0]["keymap"]
type Mode = ReturnType<typeof createMode>
type KeymapConfig = {
readonly keybinds: {
get(command: string): readonly Binding<Renderable, KeyEvent>[]
}
readonly leader?: { readonly timeout: number }
readonly leader_timeout?: number
}
export const COMMAND_PALETTE_COMMAND = "command.palette.show"
const Context = createContext<{
readonly keymap: OpenTuiKeymap
readonly config: KeymapConfig
readonly mode: Mode
readonly dispatch: (id: string, input?: string) => void
readonly input: (id: string) => string | undefined
}>()
function Provider(props: ParentProps) {
function Provider(props: ParentProps<{ config?: KeymapConfig }>) {
const renderer = useRenderer()
const config = useConfig()
const config: KeymapConfig = props.config ?? useConfig().data
const keymap = createDefaultOpenTuiKeymap(renderer)
const mode = createMode(keymap)
let invocation: { readonly id: string; readonly input?: string } | undefined
@ -111,16 +121,16 @@ function Provider(props: ParentProps) {
"input.delete.word.backward",
"input.select.all",
"input.submit",
].flatMap((command) => config.data.keybinds.get(command)),
].flatMap((command) => config.keybinds.get(command)),
}),
]
const leader = config.data.keybinds.get("leader")?.[0]?.key
const leader = config.keybinds.get("leader")?.[0]?.key
if (leader) {
dispose.push(
registerTimedLeader(keymap, {
trigger: leader,
name: "leader",
timeoutMs: config.data.leader.timeout,
timeoutMs: config.leader?.timeout ?? ("leader_timeout" in config ? config.leader_timeout : undefined) ?? 2000,
}),
)
}
@ -131,7 +141,13 @@ function Provider(props: ParentProps) {
return (
<KeymapProvider keymap={keymap}>
<Context.Provider
value={{ keymap, mode, dispatch, input: (id) => (invocation?.id === id ? invocation.input : undefined) }}
value={{
keymap,
config,
mode,
dispatch,
input: (id) => (invocation?.id === id ? invocation.input : undefined),
}}
>
{props.children}
</Context.Provider>
@ -151,6 +167,8 @@ export interface Keymap {
/** Pushes a mode until the returned cleanup is called. */
push(mode: string): () => void
}
/** Registers a low-level keymap interceptor. */
intercept: OpenTuiKeymap["intercept"]
}
function use(): Keymap {
@ -160,12 +178,12 @@ function use(): Keymap {
value.dispatch(id, input)
},
mode: value.mode,
intercept: value.keymap.intercept.bind(value.keymap),
}
}
function createLayer(input: () => KeymapLayer) {
const value = useValue()
const config = useConfig()
useBindings(() => {
const layer = input()
const { commands, bindings, mode, ...options } = layer
@ -199,7 +217,7 @@ function createLayer(input: () => KeymapLayer) {
...definition,
name: id,
opencode: command,
run: () => run(value.input(id)),
run: (context: CommandContext<Renderable, KeyEvent>) => run(value.input(id), context.event),
...(description === undefined ? {} : { desc: description }),
...(group === undefined ? {} : { category: group }),
...(palette === undefined ? {} : { namespace: "palette" }),
@ -220,20 +238,19 @@ function createLayer(input: () => KeymapLayer) {
})),
...grouped.named.flatMap((command) => {
if (command.bind === false) return []
const configured = config.data.keybinds.get(command.id)
const configured = value.config.keybinds.get(command.id)
if (configured.length) return configured
if (typeof command.bind !== "string") return []
return [{ key: command.bind, cmd: command.id }]
}),
...(bindings ?? []).flatMap((id) => config.data.keybinds.get(id)),
...(bindings ?? []).flatMap((id) => value.config.keybinds.get(id)),
],
}
})
}
function useShortcuts() {
useValue()
const config = useConfig()
const value = useValue()
const shortcuts = useKeymapSelector((keymap) => {
const commands = keymap.getCommands({ visibility: "registered" }).map((command) => command.name)
const bindings = keymap.getCommandBindings({ visibility: "registered", commands })
@ -241,8 +258,8 @@ function useShortcuts() {
commands.map((id) => [
id,
{
first: formatKeySequence(bindings.get(id)?.[0]?.sequence, formatOptions(config.data)),
all: formatCommandBindings(bindings.get(id) ?? [], formatOptions(config.data)),
first: formatKeySequence(bindings.get(id)?.[0]?.sequence, formatOptions(value.config)),
all: formatCommandBindings(bindings.get(id) ?? [], formatOptions(value.config)),
},
]),
)
@ -257,6 +274,16 @@ function useShortcuts() {
}
}
function useShortcut(id: string) {
const shortcuts = useShortcuts()
return () => shortcuts.get(id)
}
function useLeaderActive() {
const pending = usePendingSequence()
return () => pending()[0]?.tokenName === "leader"
}
function useCommands(): Accessor<readonly KeymapCommand[]> {
const value = useValue()
return useKeymapSelector((keymap) =>
@ -312,6 +339,8 @@ export const Keymap = {
use,
createLayer,
useShortcuts,
useShortcut,
useLeaderActive,
useCommands,
usePendingSequence,
useActiveKeys,
@ -355,7 +384,7 @@ function createMode(keymap: OpenTuiKeymap) {
}
}
function formatOptions(config: ReturnType<typeof useConfig>["data"]) {
function formatOptions(config: KeymapConfig) {
const leader = config.keybinds.get("leader")?.[0]?.key
return {
tokenDisplay: {

View file

@ -4,7 +4,7 @@ import { useTerminalDimensions } from "@opentui/solid"
import { fileURLToPath } from "url"
import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select"
import { Show, createEffect, createMemo, createSignal } from "solid-js"
import { useBindings } from "../../keymap"
import { Keymap } from "../../context/keymap"
const id = "internal:plugin-manager"
@ -39,9 +39,19 @@ function Install(props: { api: TuiPluginApi }) {
const [global, setGlobal] = createSignal(false)
const [busy, setBusy] = createSignal(false)
useBindings(() => ({
Keymap.createLayer(() => ({
mode: "modal",
enabled: !busy(),
bindings: [{ key: "tab", desc: "Toggle install scope", group: "Plugins", cmd: () => setGlobal((value) => !value) }],
commands: [
{
bind: "tab",
title: "Toggle install scope",
group: "Plugins",
run: () => {
setGlobal((value) => !value)
},
},
],
}))
return (

View file

@ -2,7 +2,7 @@
import { RGBA, TextAttributes, type KeyEvent, type Renderable } from "@opentui/core"
import { useTerminalDimensions } from "@opentui/solid"
import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
import { useBindings, useKeymapSelector } from "../../keymap"
import { Keymap } from "../../context/keymap"
import type { ActiveKey } from "@opentui/keymap"
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { BuiltinTuiPlugin } from "../builtins"
@ -153,12 +153,9 @@ function grouped(entries: Entry[]): Group[] {
.toSorted((a, b) => a.label.localeCompare(b.label))
}
function commandShortcut(api: TuiPluginApi, name: string) {
return useKeymapSelector((keymap) =>
api.keys.formatSequence(
keymap.getCommandBindings({ visibility: "registered", commands: [name] }).get(name)?.[0]?.sequence,
),
)
function commandShortcut(_api: TuiPluginApi, name: string) {
const shortcuts = Keymap.useShortcuts()
return () => shortcuts.get(name) ?? ""
}
function layout(value: unknown): Layout {
@ -189,8 +186,8 @@ function WhichKeyPanel(props: {
const dimensions = useTerminalDimensions()
const [offset, setOffset] = createSignal(0)
const [activeGroup, setActiveGroup] = createSignal<string | undefined>()
const pending = useKeymapSelector((keymap) => keymap.getPendingSequence())
const active = useKeymapSelector((keymap) => keymap.getActiveKeys({ includeMetadata: true }))
const pending = Keymap.usePendingSequence()
const active = Keymap.useActiveKeys()
const pendingActive = createMemo(() => pending().length > 0 && active().length > 0)
const pendingAutoVisible = createMemo(() => props.mode() === "overlay" && props.pendingPreview() && pendingActive())
const visible = createMemo(() => props.pinned() || pendingAutoVisible())
@ -281,86 +278,92 @@ function WhichKeyPanel(props: {
setOffset(0)
}
useBindings(() => ({
Keymap.createLayer(() => ({
priority: 1000,
enabled: visible(),
commands: [
{
name: command.groupPrevious,
id: command.groupPrevious,
bind: false,
title: "Previous key binding group",
desc: "Show the previous which-key group",
category: "System",
description: "Show the previous which-key group",
group: "System",
run() {
moveGroup(-1)
},
},
{
name: command.groupNext,
id: command.groupNext,
bind: false,
title: "Next key binding group",
desc: "Show the next which-key group",
category: "System",
description: "Show the next which-key group",
group: "System",
run() {
moveGroup(1)
},
},
{
name: command.scrollUp,
id: command.scrollUp,
bind: false,
title: "Scroll key bindings up",
desc: "Scroll the which-key panel up",
category: "System",
description: "Scroll the which-key panel up",
group: "System",
run() {
scroll(-columns())
},
},
{
name: command.scrollDown,
id: command.scrollDown,
bind: false,
title: "Scroll key bindings down",
desc: "Scroll the which-key panel down",
category: "System",
description: "Scroll the which-key panel down",
group: "System",
run() {
scroll(columns())
},
},
{
name: command.pageUp,
id: command.pageUp,
bind: false,
title: "Page key bindings up",
desc: "Page the which-key panel up",
category: "System",
description: "Page the which-key panel up",
group: "System",
run() {
scroll(-pageSize())
},
},
{
name: command.pageDown,
id: command.pageDown,
bind: false,
title: "Page key bindings down",
desc: "Page the which-key panel down",
category: "System",
description: "Page the which-key panel down",
group: "System",
run() {
scroll(pageSize())
},
},
{
name: command.home,
id: command.home,
bind: false,
title: "First key binding",
desc: "Jump to the first which-key binding",
category: "System",
description: "Jump to the first which-key binding",
group: "System",
run() {
setOffset(0)
},
},
{
name: command.end,
id: command.end,
bind: false,
title: "Last key binding",
desc: "Jump to the last which-key binding",
category: "System",
description: "Jump to the last which-key binding",
group: "System",
run() {
setOffset(maxOffset())
},
},
],
bindings: (pendingMode() ? scrollCommands : panelCommands).flatMap((command) =>
props.api.tuiConfig.keybinds.get(command),
),
bindings: pendingMode() ? scrollCommands : panelCommands,
}))
createEffect(() => {

View file

@ -1,262 +0,0 @@
import { InputRenderable, TextareaRenderable, type CliRenderer, type KeyEvent, type Renderable } from "@opentui/core"
import {
registerBackspacePopsPendingSequence,
registerBaseLayoutFallback,
registerCommaBindings,
registerEscapeClearsPendingSequence,
registerManagedTextareaLayer,
registerTimedLeader,
} from "@opentui/keymap/addons/opentui"
import { stringifyKeyStroke, type Binding } from "@opentui/keymap"
import {
formatCommandBindings as formatCommandBindingsExtra,
formatKeySequence as formatKeySequenceExtra,
} from "@opentui/keymap/extras"
import { KeymapProvider, useKeymap, useKeymapSelector, useBindings } from "@opentui/keymap/solid"
import type { Accessor } from "solid-js"
import { useConfig } from "./config"
import { TuiKeybind } from "./config/keybind"
import type { KeymapCommand } from "@opencode-ai/plugin/v2/tui/context"
declare module "@opentui/keymap" {
interface Command {
opencode?: KeymapCommand
slash?: {
name: string
aliases?: string[]
arguments?: true
}
}
}
export const LEADER_TOKEN = "leader"
export const OPENCODE_BASE_MODE = "base"
export const COMMAND_PALETTE_COMMAND = "command.palette.show"
const OPENCODE_MODE_KEY = "opencode.mode"
export { useBindings, useKeymapSelector }
export const OpencodeKeymapProvider = KeymapProvider
export const useOpencodeKeymap = useKeymap
export type OpenTuiKeymap = ReturnType<typeof useKeymap>
type OpencodeModeStack = ReturnType<typeof createOpencodeModeStack>
type BindingLookup = {
get(command: string): readonly Binding<Renderable, KeyEvent>[]
}
type FormatConfig = { keybinds: BindingLookup }
type ResolvedKeymapConfig = FormatConfig & ({ leader: { timeout: number } } | { leader_timeout: number })
const modeStacks = new WeakMap<OpenTuiKeymap, OpencodeModeStack>()
export function createOpencodeModeStack(keymap: OpenTuiKeymap) {
keymap.setData(OPENCODE_MODE_KEY, OPENCODE_BASE_MODE)
const offFields = keymap.registerLayerFields({
mode(value, ctx) {
ctx.require(OPENCODE_MODE_KEY, value)
},
})
const stack: { id: symbol; mode: string }[] = []
let disposed = false
const update = () => {
keymap.setData(OPENCODE_MODE_KEY, stack.at(-1)?.mode ?? OPENCODE_BASE_MODE)
}
const stackApi = {
current() {
return stack.at(-1)?.mode ?? OPENCODE_BASE_MODE
},
push(mode: string) {
if (disposed) return () => {}
const id = Symbol(mode)
let active = true
stack.push({ id, mode })
update()
return () => {
if (!active) return
active = false
const index = stack.findIndex((item) => item.id === id)
if (index !== -1) stack.splice(index, 1)
update()
}
},
dispose() {
if (disposed) return
disposed = true
stack.length = 0
offFields()
keymap.setData(OPENCODE_MODE_KEY, undefined)
modeStacks.delete(keymap)
},
}
modeStacks.set(keymap, stackApi)
return stackApi
}
export function useOpencodeModeStack() {
return getOpencodeModeStack(useOpencodeKeymap())
}
export function getOpencodeModeStack(keymap: OpenTuiKeymap) {
const value = modeStacks.get(keymap)
if (!value) throw new Error("Opencode mode stack is not registered for this keymap")
return value
}
const KEY_ALIASES = {
enter: "return",
esc: "escape",
pgdown: "pagedown",
pgup: "pageup",
} as const
function expandKeyAliases(input: string) {
const result = Object.entries(KEY_ALIASES).reduce(
(acc, [alias, key]) => acc.replace(new RegExp(`(^|[+,\\s>])${alias}(?=$|[+,\\s<])`, "gi"), `$1${key}`),
input,
)
if (result === input) return
return result
}
function registerKeyAliases(keymap: OpenTuiKeymap) {
return keymap.appendBindingExpander((ctx) => {
const key = expandKeyAliases(ctx.input)
if (!key) return
return [{ key, displays: ctx.displays }]
})
}
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 hasManagedTextareaFocus(renderer: CliRenderer) {
const editor = renderer.currentFocusedEditor
return editor instanceof TextareaRenderable && !(editor instanceof InputRenderable)
}
function leaderDisplay(config: FormatConfig) {
const key = config.keybinds.get(LEADER_TOKEN)?.[0]?.key
if (!key) return TuiKeybind.LeaderDefault
return typeof key === "string" ? key : stringifyKeyStroke(key)
}
function leaderKey(config: FormatConfig) {
return config.keybinds.get(LEADER_TOKEN)?.[0]?.key
}
function formatOptions(config: FormatConfig) {
return {
tokenDisplay: {
[LEADER_TOKEN]: leaderDisplay(config),
},
keyNameAliases: {
up: "↑",
down: "↓",
left: "←",
right: "→",
pageup: "pgup",
pagedown: "pgdn",
delete: "del",
},
modifierAliases: {
meta: "alt",
},
} as const
}
export function formatKeySequence(parts: Parameters<typeof formatKeySequenceExtra>[0], config: FormatConfig) {
return formatKeySequenceExtra(parts, formatOptions(config))
}
export function formatKeyBindings(bindings: Parameters<typeof formatCommandBindingsExtra>[0], config: FormatConfig) {
return formatCommandBindingsExtra(bindings, formatOptions(config))
}
export function registerOpencodeKeymap(keymap: OpenTuiKeymap, renderer: CliRenderer, config: ResolvedKeymapConfig) {
const modeStack = createOpencodeModeStack(keymap)
const offCommaBindings = registerCommaBindings(keymap)
const offAliasExpander = registerKeyAliases(keymap)
const offBaseLayout = registerBaseLayoutFallback(keymap)
const leader = leaderKey(config)
const offLeader = leader
? registerTimedLeader(keymap, {
trigger: leader,
name: LEADER_TOKEN,
timeoutMs: "leader" in config ? config.leader.timeout : config.leader_timeout,
})
: () => {}
const offEscape = registerEscapeClearsPendingSequence(keymap)
const offBackspace = registerBackspacePopsPendingSequence(keymap)
const offInputBindings = registerManagedTextareaLayer(keymap, renderer, {
enabled: () => hasManagedTextareaFocus(renderer),
bindings: inputCommands.flatMap((command) => config.keybinds.get(command)),
})
return () => {
offInputBindings()
offBackspace()
offEscape()
offLeader()
offAliasExpander()
offBaseLayout()
offCommaBindings()
modeStack.dispose()
}
}
export function useLeaderActive(): Accessor<boolean> {
return useKeymapSelector((keymap: OpenTuiKeymap) => keymap.getPendingSequence()[0]?.tokenName === LEADER_TOKEN)
}
export function useCommandShortcut(command: string): Accessor<string> {
const config = useConfig().data
return useKeymapSelector((keymap: OpenTuiKeymap) =>
formatKeySequence(
keymap.getCommandBindings({ visibility: "registered", commands: [command] }).get(command)?.[0]?.sequence,
config,
),
)
}

View file

@ -68,7 +68,7 @@ import { nextThinkingMode, reasoningSummary, type ThinkingMode } from "../../con
import { getScrollAcceleration } from "../../util/scroll"
import { collapseToolOutput } from "../../util/collapse-tool-output"
import { usePluginRuntime } from "../../plugin/runtime"
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut } from "../../keymap"
import { Keymap, type KeymapCommand } from "../../context/keymap"
import { usePathFormatter } from "../../context/path-format"
import { useLocation } from "../../context/location"
import { createSessionRows, messageBoundaryIDs, resolvePart, type PartRef, type SessionRow } from "./rows"
@ -317,10 +317,10 @@ export function Session() {
const globalCommands = [
{
name: "session.page.up",
id: "session.page.up",
title: "Page up",
category: "Session",
hidden: true,
group: "Session",
palette: undefined,
run: () => {
clearMessageNavigation()
scroll.scrollBy(-scroll.height / 2)
@ -328,10 +328,10 @@ export function Session() {
},
},
{
name: "session.page.down",
id: "session.page.down",
title: "Page down",
category: "Session",
hidden: true,
group: "Session",
palette: undefined,
run: () => {
clearMessageNavigation()
scroll.scrollBy(scroll.height / 2)
@ -339,10 +339,10 @@ export function Session() {
},
},
{
name: "session.line.up",
id: "session.line.up",
title: "Line up",
category: "Session",
hidden: true,
group: "Session",
palette: undefined,
run: () => {
clearMessageNavigation()
scroll.scrollBy(-1)
@ -350,10 +350,10 @@ export function Session() {
},
},
{
name: "session.line.down",
id: "session.line.down",
title: "Line down",
category: "Session",
hidden: true,
group: "Session",
palette: undefined,
run: () => {
clearMessageNavigation()
scroll.scrollBy(1)
@ -361,10 +361,10 @@ export function Session() {
},
},
{
name: "session.half.page.up",
id: "session.half.page.up",
title: "Half page up",
category: "Session",
hidden: true,
group: "Session",
palette: undefined,
run: () => {
clearMessageNavigation()
scroll.scrollBy(-scroll.height / 4)
@ -372,10 +372,10 @@ export function Session() {
},
},
{
name: "session.half.page.down",
id: "session.half.page.down",
title: "Half page down",
category: "Session",
hidden: true,
group: "Session",
palette: undefined,
run: () => {
clearMessageNavigation()
scroll.scrollBy(scroll.height / 4)
@ -386,10 +386,10 @@ export function Session() {
const baseAndUnfocusedCommands = [
{
name: "session.first",
id: "session.first",
title: "First message",
category: "Session",
hidden: true,
group: "Session",
palette: undefined,
run: () => {
clearMessageNavigation()
scroll.scrollTo(0)
@ -397,10 +397,10 @@ export function Session() {
},
},
{
name: "session.last",
id: "session.last",
title: "Last message",
category: "Session",
hidden: true,
group: "Session",
palette: undefined,
run: () => {
clearMessageNavigation()
scroll.scrollTo(scroll.scrollHeight)
@ -412,30 +412,30 @@ export function Session() {
const baseCommands = createMemo(() => [
{
title: "Share session",
name: "session.share",
id: "session.share",
suggested: route.type === "session",
category: "Session",
group: "Session",
slash: { name: "share" },
run: () => unavailable("Sharing"),
},
{
title: "Rename session",
name: "session.rename",
category: "Session",
id: "session.rename",
group: "Session",
slash: { name: "rename" },
run: () => DialogSessionRename.show(dialog, route.sessionID, session()?.title),
},
{
title: "Jump to message",
name: "session.timeline",
category: "Session",
id: "session.timeline",
group: "Session",
slash: { name: "timeline" },
run: () => unavailable("The message timeline"),
},
{
title: "Fork session",
name: "session.fork",
category: "Session",
id: "session.fork",
group: "Session",
slash: { name: "fork" },
run: () => {
dialog.replace(() => (
@ -451,8 +451,8 @@ export function Session() {
},
{
title: "Compact session",
name: "session.compact",
category: "Session",
id: "session.compact",
group: "Session",
slash: {
name: "compact",
aliases: ["summarize"],
@ -464,16 +464,16 @@ export function Session() {
},
{
title: "Unshare session",
name: "session.unshare",
category: "Session",
id: "session.unshare",
group: "Session",
enabled: false,
slash: { name: "unshare" },
run: () => unavailable("Unsharing"),
},
{
title: "Undo previous message",
name: "session.undo",
category: "Session",
id: "session.undo",
group: "Session",
slash: { name: "undo" },
run: () => {
const boundary = session()?.revert?.messageID
@ -508,8 +508,8 @@ export function Session() {
},
{
title: "Redo",
name: "session.redo",
category: "Session",
id: "session.redo",
group: "Session",
enabled: !!session()?.revert?.messageID,
slash: { name: "redo" },
run: () => {
@ -525,8 +525,8 @@ export function Session() {
},
{
title: sidebarVisible() ? "Hide sidebar" : "Show sidebar",
name: "session.sidebar.toggle",
category: "Session",
id: "session.sidebar.toggle",
group: "Session",
run: () => {
batch(() => {
const isVisible = sidebarVisible()
@ -546,9 +546,9 @@ export function Session() {
if (next === "hide") return "Collapse thinking"
return "Expand thinking"
})(),
name: "session.toggle.thinking",
category: "Session",
hidden: true,
id: "session.toggle.thinking",
group: "Session",
palette: undefined,
slash: {
name: "thinking",
aliases: ["toggle-thinking"],
@ -564,9 +564,9 @@ export function Session() {
},
{
title: "Toggle session scrollbar",
name: "session.toggle.scrollbar",
category: "Session",
hidden: true,
id: "session.toggle.scrollbar",
group: "Session",
palette: undefined,
run: () => {
void configState
.update((draft) => {
@ -578,9 +578,9 @@ export function Session() {
},
{
title: groupExploration() ? "Show tool calls individually" : "Group related tool calls",
name: "session.toggle.exploration_grouping",
category: "Session",
hidden: true,
id: "session.toggle.exploration_grouping",
group: "Session",
palette: undefined,
run: () => {
void configState
.update((draft) => {
@ -592,9 +592,9 @@ export function Session() {
},
{
title: "Jump to last user message",
name: "session.messages_last_user",
category: "Session",
hidden: true,
id: "session.messages_last_user",
group: "Session",
palette: undefined,
run: () => {
const messages = data.session.message.list(route.sessionID)
if (!messages || !messages.length) return
@ -612,36 +612,36 @@ export function Session() {
},
{
title: "Next message",
name: "session.message.next",
category: "Session",
hidden: true,
id: "session.message.next",
group: "Session",
palette: undefined,
run: () => scrollToMessage("next", dialog),
},
{
title: "Previous message",
name: "session.message.previous",
category: "Session",
hidden: true,
id: "session.message.previous",
group: "Session",
palette: undefined,
run: () => scrollToMessage("prev", dialog),
},
{
title: "Next user message",
name: "session.message.user.next",
category: "Session",
hidden: true,
id: "session.message.user.next",
group: "Session",
palette: undefined,
run: () => scrollToMessage("next", dialog, true),
},
{
title: "Previous user message",
name: "session.message.user.previous",
category: "Session",
hidden: true,
id: "session.message.user.previous",
group: "Session",
palette: undefined,
run: () => scrollToMessage("prev", dialog, true),
},
{
title: "Copy last assistant message",
name: "messages.copy",
category: "Session",
id: "messages.copy",
group: "Session",
run: () => {
const revertID = session()?.revert?.messageID
const lastAssistantMessage = messages().findLast(
@ -682,8 +682,8 @@ export function Session() {
},
{
title: "Copy session transcript",
name: "session.copy",
category: "Session",
id: "session.copy",
group: "Session",
slash: {
name: "copy",
},
@ -702,8 +702,8 @@ export function Session() {
},
{
title: "Export session transcript",
name: "session.export",
category: "Session",
id: "session.export",
group: "Session",
slash: {
name: "export",
},
@ -772,9 +772,9 @@ export function Session() {
},
{
title: "Background blocking tools",
name: "session.background",
category: "Session",
hidden: true,
id: "session.background",
group: "Session",
palette: undefined,
run: () => {
void client.api.session.background({ sessionID: route.sessionID })
dialog.clear()
@ -782,8 +782,8 @@ export function Session() {
},
{
title: "Toggle subagent picker",
name: "session.child.first",
category: "Session",
id: "session.child.first",
group: "Session",
run: () => {
if (composer.open || session()?.parentID) setComposer("open", false)
else setComposer("open", true)
@ -792,9 +792,9 @@ export function Session() {
},
{
title: "Go to parent session",
name: "session.parent",
category: "Session",
hidden: true,
id: "session.parent",
group: "Session",
palette: undefined,
enabled: !!session()?.parentID,
run: () => {
const parentID = session()?.parentID
@ -809,41 +809,46 @@ export function Session() {
},
{
title: "Next subagent",
name: "session.child.next",
category: "Session",
hidden: true,
id: "session.child.next",
group: "Session",
palette: undefined,
enabled: !!session()?.parentID,
run: () => unavailable("Subagent navigation"),
},
{
title: "Previous subagent",
name: "session.child.previous",
category: "Session",
hidden: true,
id: "session.child.previous",
group: "Session",
palette: undefined,
enabled: !!session()?.parentID,
run: () => unavailable("Subagent navigation"),
},
])
useBindings(() => ({
commands: [...globalCommands, ...baseAndUnfocusedCommands, ...baseCommands()].map((command) => ({
namespace: "palette",
...command,
})),
const commands = createMemo(() =>
[...globalCommands, ...baseAndUnfocusedCommands, ...baseCommands()].map(
(command) =>
({
bind: false,
palette: true as const,
...command,
}) satisfies KeymapCommand,
),
)
Keymap.createLayer(() => ({
mode: "global",
commands: commands(),
bindings: globalCommands.map((command) => command.id),
}))
useBindings(() => ({
bindings: globalCommands.flatMap((command) => config.keybinds.get(command.name)),
}))
useBindings(() => ({
Keymap.createLayer(() => ({
enabled: () => renderer.currentFocusedEditor === null,
bindings: baseAndUnfocusedCommands.flatMap((command) => config.keybinds.get(command.name)),
bindings: baseAndUnfocusedCommands.map((command) => command.id),
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
bindings: [...baseAndUnfocusedCommands, ...baseCommands()].flatMap((command) => config.keybinds.get(command.name)),
Keymap.createLayer(() => ({
bindings: [...baseAndUnfocusedCommands, ...baseCommands()].map((command) => command.id),
}))
// snap to bottom when session changes
@ -1040,7 +1045,7 @@ function SessionRowView(props: {
function BackgroundToolHint(props: { messages: SessionMessageInfo[] }) {
const { themeV2 } = useTheme()
const shortcut = useCommandShortcut("session.background")
const shortcut = Keymap.useShortcut("session.background")
const visible = createMemo(() => {
const current = props.messages.findLast(
(message): message is SessionMessageAssistant => message.type === "assistant" && !message.time.completed,
@ -1508,7 +1513,7 @@ function RevertMessage(props: {
const toast = useToast()
const renderer = useRenderer()
const [hover, setHover] = createSignal(false)
const redoKey = useCommandShortcut("session.redo")
const redoKey = Keymap.useShortcut("session.redo")
return (
<box
onMouseOver={() => setHover(true)}

View file

@ -11,7 +11,6 @@ import { useDialog, type DialogContext } from "./dialog"
import { Locale } from "../util/locale"
import { getScrollAcceleration } from "../util/scroll"
import { useConfig } from "../config"
import { formatKeyBindings, useKeymapSelector } from "../keymap"
export interface DialogSelectProps<T> {
title: string
@ -126,18 +125,13 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
const actions = createMemo(() => props.actions ?? [])
const shownActions = createMemo(() => actions().filter((item) => !item.hidden))
const actionBindings = useKeymapSelector((keymap) =>
keymap.getCommandBindings({
visibility: "registered",
commands: shownActions().map((item) => item.command),
}),
)
const shortcuts = Keymap.useShortcuts()
const actionLabels = createMemo(() => {
const labels = new Map<string, string>()
for (const action of shownActions()) {
const label = formatKeyBindings(actionBindings().get(action.command), config)
const label = shortcuts.all(action.command)
if (label) labels.set(action.command, label)
}