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

@ -23,7 +23,7 @@ import {
movePromptHistory,
pushPromptHistory,
} from "./prompt.shared"
import { OPENCODE_BASE_MODE, useBindings } from "@opencode-ai/tui/keymap"
import { Keymap } from "@opencode-ai/tui/context/keymap"
import { realignEditorPromptParts, resolveEditorSlashValue } from "./prompt.editor"
import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
import type { RunFooterTheme } from "./theme"
@ -993,93 +993,83 @@ export function createPromptState(input: PromptInput): PromptState {
return true
}
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
Keymap.createLayer(() => ({
enabled: baseBindingsEnabled(),
commands: [
{
name: "prompt.clear",
id: "prompt.clear",
title: "Clear prompt or exit",
category: "Prompt",
group: "Prompt",
run() {
if (requestExit()) return
return false
},
},
],
bindings: input.tuiConfig.keybinds.get("prompt.clear"),
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
Keymap.createLayer(() => ({
enabled: input.prompt(),
commands: [
{
name: "session.interrupt",
id: "session.interrupt",
title: "Interrupt session",
category: "Session",
group: "Session",
run() {
if (input.onInterrupt()) return
return false
},
},
],
bindings: input.tuiConfig.keybinds.get("session.interrupt"),
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
Keymap.createLayer(() => ({
enabled: input.prompt() && !visible(),
commands: [
{
name: "prompt.editor",
id: "prompt.editor",
title: "Open editor",
category: "Prompt",
group: "Prompt",
run() {
void openEditor()
},
},
],
bindings: input.tuiConfig.keybinds.get("prompt.editor"),
}))
useBindings(() => ({
Keymap.createLayer(() => ({
priority: 1,
mode: OPENCODE_BASE_MODE,
enabled: input.prompt() && !visible(),
commands: [
{
name: "prompt.history.previous",
id: "prompt.history.previous",
title: "Previous prompt history",
category: "Prompt",
run(ctx: { event: KeyEvent }) {
return historyCommand(-1, ctx.event)
group: "Prompt",
run(_input: string | undefined, event?: KeyEvent) {
if (!event) return false
return historyCommand(-1, event)
},
},
{
name: "prompt.history.next",
id: "prompt.history.next",
title: "Next prompt history",
category: "Prompt",
run(ctx: { event: KeyEvent }) {
return historyCommand(1, ctx.event)
group: "Prompt",
run(_input: string | undefined, event?: KeyEvent) {
if (!event) return false
return historyCommand(1, event)
},
},
],
bindings: [
...input.tuiConfig.keybinds.get("prompt.history.previous"),
...input.tuiConfig.keybinds.get("prompt.history.next"),
],
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
Keymap.createLayer(() => ({
enabled: input.prompt() && !visible(),
bindings: [
commands: [
{
key: "!",
desc: "Shell mode",
bind: "!",
title: "Shell mode",
group: "Prompt",
cmd() {
run() {
if (shell()) return false
if (!area || area.isDestroyed) return false
if (area.cursorOffset !== 0) return false
@ -1089,21 +1079,20 @@ export function createPromptState(input: PromptInput): PromptState {
],
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
Keymap.createLayer(() => ({
enabled: input.prompt() && shell() && !visible(),
bindings: [
commands: [
{
key: "escape",
desc: "Exit shell mode",
bind: "escape",
title: "Exit shell mode",
group: "Prompt",
cmd: () => setShellMode(false),
run: () => setShellMode(false),
},
{
key: "backspace",
desc: "Exit shell mode",
bind: "backspace",
title: "Exit shell mode",
group: "Prompt",
cmd() {
run() {
if (!area || area.isDestroyed) return false
if (area.cursorOffset !== 0) return false
setShellMode(false)
@ -1112,32 +1101,31 @@ export function createPromptState(input: PromptInput): PromptState {
],
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
Keymap.createLayer(() => ({
enabled: input.prompt() && visible(),
commands: [
{
name: "prompt.autocomplete.prev",
id: "prompt.autocomplete.prev",
title: "Previous autocomplete item",
category: "Autocomplete",
group: "Autocomplete",
run: () => menu.move(-1),
},
{
name: "prompt.autocomplete.next",
id: "prompt.autocomplete.next",
title: "Next autocomplete item",
category: "Autocomplete",
group: "Autocomplete",
run: () => menu.move(1),
},
{
name: "prompt.autocomplete.hide",
id: "prompt.autocomplete.hide",
title: "Hide autocomplete",
category: "Autocomplete",
group: "Autocomplete",
run: cancelAutocomplete,
},
{
name: "prompt.autocomplete.select",
id: "prompt.autocomplete.select",
title: "Select autocomplete item",
category: "Autocomplete",
group: "Autocomplete",
run() {
if (mode() === "slash" && options().length === 0) {
hide()
@ -1147,9 +1135,9 @@ export function createPromptState(input: PromptInput): PromptState {
},
},
{
name: "prompt.autocomplete.complete",
id: "prompt.autocomplete.complete",
title: "Complete autocomplete item",
category: "Autocomplete",
group: "Autocomplete",
run() {
if (mode() === "slash" && options().length === 0) {
hide()
@ -1164,13 +1152,6 @@ export function createPromptState(input: PromptInput): PromptState {
},
},
],
bindings: [
"prompt.autocomplete.prev",
"prompt.autocomplete.next",
"prompt.autocomplete.hide",
"prompt.autocomplete.select",
"prompt.autocomplete.complete",
].flatMap((command) => input.tuiConfig.keybinds.get(command)),
}))
const onKeyDown = (event: KeyEvent) => {

View file

@ -24,12 +24,11 @@
// Ctrl-c clears a live prompt draft first; otherwise interrupt and exit use a
// two-press pattern where the first press shows a hint and the second press
// within 5 seconds actually fires the action.
import { CliRenderEvents, type CliRenderer, type KeyEvent, type Renderable, type TreeSitterClient } from "@opentui/core"
import type { Keymap } from "@opentui/keymap"
import { CliRenderEvents, type CliRenderer, type TreeSitterClient } from "@opentui/core"
import { render } from "@opentui/solid"
import { createComponent, createSignal, type Accessor, type Setter } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { OpencodeKeymapProvider } from "@opencode-ai/tui/keymap"
import { Keymap } from "@opencode-ai/tui/context/keymap"
import { RUN_COMMAND_PANEL_ROWS, RUN_SUBAGENT_PANEL_ROWS } from "./footer.command"
import { SUBAGENT_INSPECTOR_ROWS } from "./footer.subagent"
import { PROMPT_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt"
@ -82,7 +81,6 @@ type RunFooterOptions = {
first: boolean
history?: RunPrompt[]
theme: RunTheme
keymap: Keymap<Renderable, KeyEvent>
tuiConfig: RunTuiConfig
diffStyle: RunDiffStyle
onPermissionReply: (input: PermissionReply) => void | Promise<void>
@ -305,8 +303,8 @@ export class RunFooter implements FooterApi {
const footer = this
void render(
() =>
createComponent(OpencodeKeymapProvider, {
keymap: options.keymap,
createComponent(Keymap.Provider, {
config: options.tuiConfig,
get children() {
return createComponent(RunFooterView, {
directory: options.directory,

View file

@ -27,14 +27,8 @@ import { RunPromptBody, createPromptState } from "./footer.prompt"
import { RunPermissionBody } from "./footer.permission"
import { RunQuestionBody } from "./footer.question"
import { footerWidthPolicy } from "./footer.width"
import {
OPENCODE_BASE_MODE,
formatKeyBindings,
formatKeySequence,
useBindings,
useKeymapSelector,
type OpenTuiKeymap,
} from "@opencode-ai/tui/keymap"
import { Keymap } from "@opencode-ai/tui/context/keymap"
import type {
FooterPromptRoute,
FooterQueuedPrompt,
@ -177,75 +171,15 @@ export function RunFooterView(props: RunFooterViewProps) {
const current = route()
return current.type === "subagent" ? subagent().details[current.sessionID] : undefined
})
const command = useKeymapSelector(
(keymap: OpenTuiKeymap) =>
formatKeySequence(
keymap
.getCommandBindings({ visibility: "registered", commands: ["command.palette.show"] })
.get("command.palette.show")?.[0]?.sequence,
props.tuiConfig,
) ?? "",
)
const subagentShortcut = useKeymapSelector(
(keymap: OpenTuiKeymap) =>
formatKeySequence(
keymap
.getCommandBindings({ visibility: "registered", commands: ["session.child.first"] })
.get("session.child.first")?.[0]?.sequence,
props.tuiConfig,
) ?? "",
)
const queuedShortcut = useKeymapSelector(
(keymap: OpenTuiKeymap) =>
formatKeySequence(
keymap
.getCommandBindings({ visibility: "registered", commands: ["session.queued_prompts"] })
.get("session.queued_prompts")?.[0]?.sequence,
props.tuiConfig,
) ?? "",
)
const backgroundShortcut = useKeymapSelector(
(keymap: OpenTuiKeymap) =>
formatKeySequence(
keymap
.getCommandBindings({ visibility: "registered", commands: ["session.background"] })
.get("session.background")?.[0]?.sequence,
props.tuiConfig,
) ?? "",
)
const subagentInterruptShortcut = useKeymapSelector(
(keymap: OpenTuiKeymap) =>
formatKeySequence(
keymap
.getCommandBindings({ visibility: "registered", commands: ["subagent.interrupt"] })
.get("subagent.interrupt")?.[0]?.sequence,
props.tuiConfig,
) ?? "",
)
const interrupt = useKeymapSelector(
(keymap: OpenTuiKeymap) =>
formatKeySequence(
keymap
.getCommandBindings({ visibility: "registered", commands: ["session.interrupt"] })
.get("session.interrupt")?.[0]?.sequence,
props.tuiConfig,
) ?? "",
)
const variantCycle = useKeymapSelector(
(keymap: OpenTuiKeymap) =>
formatKeyBindings(
keymap.getCommandBindings({ visibility: "registered", commands: ["variant.cycle"] }).get("variant.cycle"),
props.tuiConfig,
) ?? "",
)
const clearShortcut = useKeymapSelector(
(keymap: OpenTuiKeymap) =>
formatKeySequence(
keymap.getCommandBindings({ visibility: "registered", commands: ["prompt.clear"] }).get("prompt.clear")?.[0]
?.sequence,
props.tuiConfig,
) ?? "",
)
const shortcuts = Keymap.useShortcuts()
const command = () => shortcuts.get("command.palette.show") ?? ""
const subagentShortcut = () => shortcuts.get("session.child.first") ?? ""
const queuedShortcut = () => shortcuts.get("session.queued_prompts") ?? ""
const backgroundShortcut = () => shortcuts.get("session.background") ?? ""
const subagentInterruptShortcut = () => shortcuts.get("subagent.interrupt") ?? ""
const interrupt = () => shortcuts.get("session.interrupt") ?? ""
const variantCycle = () => shortcuts.all("variant.cycle") ?? ""
const clearShortcut = () => shortcuts.get("prompt.clear") ?? ""
const busy = createMemo(() => props.state().phase === "running")
const armed = createMemo(() => props.state().interrupt > 0)
const exiting = createMemo(() => props.state().exit > 0)
@ -504,74 +438,62 @@ export function RunFooterView(props: RunFooterViewProps) {
props.onRequestExit?.(undefined)
})
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
Keymap.createLayer(() => ({
enabled: active().type === "prompt" && route().type === "composer" && !composer.visible(),
commands: [
{
name: "command.palette.show",
id: "command.palette.show",
title: "Open command palette",
category: "Prompt",
group: "Prompt",
run: openCommand,
},
{
name: "variant.cycle",
id: "variant.cycle",
title: "Cycle model variant",
category: "Model",
group: "Model",
run: props.onCycle,
},
],
bindings: [
...props.tuiConfig.keybinds.get("command.palette.show"),
...props.tuiConfig.keybinds.get("variant.cycle"),
],
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
Keymap.createLayer(() => ({
enabled: active().type === "prompt" && route().type === "composer" && foregroundSubagents() && !!props.onBackground,
priority: 1,
commands: [
{
name: "session.background",
id: "session.background",
title: "Background subagents",
category: "Session",
group: "Session",
run: () => props.onBackground?.(),
},
],
bindings: props.tuiConfig.keybinds.get("session.background"),
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
Keymap.createLayer(() => ({
enabled: active().type === "prompt" && route().type === "composer" && tabs().length > 0,
commands: [
{
name: "session.child.first",
id: "session.child.first",
title: "View subagents",
category: "Session",
group: "Session",
run: openSubagentMenu,
},
],
bindings: props.tuiConfig.keybinds.get("session.child.first"),
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
Keymap.createLayer(() => ({
enabled: active().type === "prompt" && route().type === "composer" && queuedPrompts().length > 0,
commands: [
{
name: "session.queued_prompts",
id: "session.queued_prompts",
title: "Manage queued prompts",
category: "Session",
group: "Session",
run: openQueuedMenu,
},
],
bindings: props.tuiConfig.keybinds.get("session.queued_prompts"),
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
Keymap.createLayer(() => ({
enabled:
active().type === "prompt" &&
route().type === "subagent" &&
@ -580,9 +502,10 @@ export function RunFooterView(props: RunFooterViewProps) {
priority: 1,
commands: [
{
name: "subagent.interrupt",
id: "subagent.interrupt",
title: "Interrupt subagent",
category: "Session",
group: "Session",
bind: "ctrl+d",
run: () => {
const current = selectedTab()
if (current?.status !== "running") {
@ -593,7 +516,6 @@ export function RunFooterView(props: RunFooterViewProps) {
},
},
],
bindings: [{ key: "ctrl+d", desc: "Interrupt subagent", group: "Subagents", cmd: "subagent.interrupt" }],
}))
createEffect(() => {

View file

@ -10,9 +10,7 @@
// back to the usual two-press exit sequence through RunFooter.requestExit().
import path from "path"
import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import { Global } from "@opencode-ai/core/global"
import { registerOpencodeKeymap } from "@opencode-ai/tui/keymap"
import { isDefaultTitle } from "@opencode-ai/tui/util/session"
import { Locale } from "@opencode-ai/tui/util/locale"
import { resolveInteractiveStdin } from "./runtime.stdin"
@ -167,8 +165,6 @@ function queueSplash(
export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lifecycle> {
const source = resolveInteractiveStdin()
const footerTask = import("./footer")
let unregisterKeymap: (() => void) | undefined
try {
const renderer = await createCliRenderer({
stdin: source.stdin,
@ -187,8 +183,6 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
})
const [theme, tuiConfig] = await Promise.all([resolveRunTheme(renderer), input.tuiConfig])
renderer.setBackgroundColor(theme.background)
const keymap = createDefaultOpenTuiKeymap(renderer)
unregisterKeymap = registerOpencodeKeymap(keymap, renderer, tuiConfig)
const state: SplashState = {
entry: false,
exit: false,
@ -233,7 +227,6 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
history: input.history,
theme,
wrote,
keymap,
tuiConfig,
diffStyle: tuiConfig.diff_style ?? "auto",
onPermissionReply: input.onPermissionReply,
@ -333,7 +326,6 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
footer.close()
await footer.idle().catch(() => {})
footer.destroy()
unregisterKeymap?.()
shutdown(renderer)
if (!wroteExit) {
process.stdout.write("\n")
@ -391,7 +383,6 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
close,
}
} catch (error) {
unregisterKeymap?.()
source.cleanup?.()
throw error
}

View file

@ -1,10 +1,9 @@
/** @jsxImportSource @opentui/solid */
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import { testRender, useRenderer } from "@opentui/solid"
import { OpencodeKeymapProvider, registerOpencodeKeymap } from "@opencode-ai/tui/keymap"
import { testRender } from "@opentui/solid"
import { Keymap } from "@opencode-ai/tui/context/keymap"
import { resolve } from "@opencode-ai/tui/config/v1"
import { expect, test } from "bun:test"
import { createComponent, createSignal } from "solid-js"
import { createSignal } from "solid-js"
import { RunFooterView } from "../src/mini/footer.view"
import { RUN_THEME_FALLBACK } from "../src/mini/theme"
import type { FooterState, FooterSubagentState, FooterView } from "../src/mini/types"
@ -42,52 +41,43 @@ test("down opens subagents from an empty prompt", async () => {
{ keybinds: { editor_open: "none", session_queued_prompts: "none" } },
{ terminalSuspend: true },
)
let offKeymap: (() => void) | undefined
function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
offKeymap = registerOpencodeKeymap(keymap, renderer, config)
return createComponent(OpencodeKeymapProvider, {
keymap,
get children() {
return (
<RunFooterView
directory="/tmp"
findFiles={async () => []}
agents={() => []}
references={() => []}
commands={() => []}
providers={() => undefined}
currentModel={() => undefined}
variants={() => []}
currentVariant={() => undefined}
state={state}
view={view}
subagent={subagents}
theme={() => RUN_THEME_FALLBACK}
tuiConfig={config}
agent="opencode"
onSubmit={() => true}
onPermissionReply={() => {}}
onQuestionReply={() => {}}
onQuestionReject={() => {}}
onCycle={() => {}}
onInterrupt={() => false}
onEditorOpen={async () => undefined}
onInputClear={() => {}}
onExit={() => {}}
onModelSelect={() => {}}
onVariantSelect={() => {}}
onRows={() => {}}
onLayout={() => {}}
onStatus={() => {}}
onQueuedRemove={async () => true}
/>
)
},
})
return (
<Keymap.Provider config={config}>
<RunFooterView
directory="/tmp"
findFiles={async () => []}
agents={() => []}
references={() => []}
commands={() => []}
providers={() => undefined}
currentModel={() => undefined}
variants={() => []}
currentVariant={() => undefined}
state={state}
view={view}
subagent={subagents}
theme={() => RUN_THEME_FALLBACK}
tuiConfig={config}
agent="opencode"
onSubmit={() => true}
onPermissionReply={() => {}}
onQuestionReply={() => {}}
onQuestionReject={() => {}}
onCycle={() => {}}
onInterrupt={() => false}
onEditorOpen={async () => undefined}
onInputClear={() => {}}
onExit={() => {}}
onModelSelect={() => {}}
onVariantSelect={() => {}}
onRows={() => {}}
onLayout={() => {}}
onStatus={() => {}}
onQueuedRemove={async () => true}
/>
</Keymap.Provider>
)
}
const app = await testRender(() => <Harness />, { width: 100, height: 8, kittyKeyboard: true })
@ -100,7 +90,6 @@ test("down opens subagents from an empty prompt", async () => {
} finally {
app.renderer.currentFocusedRenderable?.blur()
app.renderer.currentFocusedEditor?.blur()
offKeymap?.()
app.renderer.destroy()
}
})