use keymap state for layer visibility (#26246)

This commit is contained in:
Sebastian
2026-05-19 17:07:26 +02:00
committed by GitHub
parent 18b9cec50d
commit 8dd6448c90
17 changed files with 418 additions and 222 deletions

View File

@@ -230,6 +230,7 @@ Top-level API groups exposed to `tui(api, options, meta)`:
- `api.attention.notify(input)`
- `api.keys.formatSequence(parts)`, `formatBindings(bindings)`
- `api.keymap`
- `api.mode.current()`, `api.mode.push(mode)`
- `api.route.register(routes)` / `api.route.navigate(name, params?)` / `api.route.current`
- `api.ui.Dialog`, `DialogAlert`, `DialogConfirm`, `DialogPrompt`, `DialogSelect`, `Slot`, `Prompt`, `ui.toast`, `ui.dialog`
- `api.tuiConfig`
@@ -255,6 +256,68 @@ Top-level API groups exposed to `tui(api, options, meta)`:
- Disposers returned by `api.keymap` registrations and `acquireResource(...)` are automatically cleaned up when the plugin deactivates. You do not need to add those disposers to `api.lifecycle.onDispose(...)` yourself.
- Built-in which-key shortcuts are resolved from flat `keybinds` command ids such as `which_key_toggle`, not plugin options.
#### Mode-aware layers
OpenCode registers a `mode` layer field on the host keymap. Plugins can use it to keep bindings active only in the relevant UI state.
Built-in modes:
- `base`: normal app, route, and prompt interaction.
- `modal`: host dialog stack is open, including dialogs rendered through `api.ui.dialog` and `api.ui.Dialog*` components.
- `autocomplete`: host prompt autocomplete is open.
- `api.mode.current()` returns the active top mode, or `base` when no pushed mode is active.
Example: register a command and shortcut that are active only in normal app mode:
```tsx
api.keymap.registerLayer({
mode: "base",
commands: [
{
name: "demo.open",
title: "Demo",
category: "Plugin",
namespace: "palette",
run() {
api.route.navigate("demo")
},
},
],
bindings: [{ key: "ctrl+shift+m", cmd: "demo.open", desc: "Open demo" }],
})
```
Layers without `mode` are not mode-gated and can remain active while dialogs or autocomplete are open. Use that only for intentionally global commands or low-level keymap extensions.
Plugins that own a full-screen route or modal-like UI can temporarily push a plugin-specific mode with `api.mode.push(...)`. Use a plugin-scoped mode name. The returned disposer pops that specific stack entry and is idempotent, so popping an older mode while a newer mode is on top leaves the newer mode active.
```tsx
import { onCleanup } from "solid-js"
api.route.register([
{
name: "demo",
render: () => {
const popMode = api.mode.push("acme.demo")
onCleanup(popMode)
return (
<box>
<text>demo</text>
</box>
)
},
},
])
api.keymap.registerLayer({
mode: "acme.demo",
bindings: [{ key: "escape", cmd: () => api.route.navigate("home"), desc: "Close demo" }],
})
```
Mode pushes are automatically tracked by the plugin runtime. If a plugin is disabled, fails during activation, or the TUI shuts down before the plugin calls the disposer, OpenCode pops the plugin's pushed modes during plugin cleanup. Calling the disposer yourself is still recommended for component lifetimes; cleanup remains idempotent.
### Keys
- `api.keys` exposes host-formatted shortcut display helpers for plugin UI.

View File

@@ -66,8 +66,15 @@ import { createTuiApi } from "@/cli/cmd/tui/plugin/api"
import type { RouteMap } from "@/cli/cmd/tui/plugin/api"
import { createTuiAttention } from "@/cli/cmd/tui/attention"
import { FormatError, FormatUnknownError } from "@/cli/error"
import { CommandPaletteProvider, useCommandPalette } from "./context/command-palette"
import { OpencodeKeymapProvider, registerOpencodeKeymap, useBindings, useOpencodeKeymap } from "./keymap"
import { CommandPaletteDialog } from "./component/command-palette"
import {
COMMAND_PALETTE_COMMAND,
OPENCODE_BASE_MODE,
OpencodeKeymapProvider,
registerOpencodeKeymap,
useBindings,
useOpencodeKeymap,
} from "./keymap"
import type { EventSource } from "./context/sdk"
import { DialogVariant } from "./component/dialog-variant"
@@ -227,17 +234,15 @@ export function tui(input: {
<LocalProvider>
<PromptStashProvider>
<DialogProvider>
<CommandPaletteProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<EditorContextProvider>
<App onSnapshot={input.onSnapshot} />
</EditorContextProvider>
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</CommandPaletteProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<EditorContextProvider>
<App onSnapshot={input.onSnapshot} />
</EditorContextProvider>
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</DialogProvider>
</PromptStashProvider>
</LocalProvider>
@@ -267,7 +272,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
const dialog = useDialog()
const local = useLocal()
const kv = useKV()
const command = useCommandPalette()
const keymap = useOpencodeKeymap()
const event = useEvent()
const sdk = useSDK()
@@ -446,12 +450,12 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
const appCommands = createMemo(() =>
[
{
name: "command.palette.show",
name: COMMAND_PALETTE_COMMAND,
title: "Show command palette",
category: "System",
hidden: true,
run: () => {
command.show()
dialog.replace(() => <CommandPaletteDialog />)
},
},
{
@@ -801,14 +805,13 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
}))
useBindings(() => ({
enabled: command.matcher,
mode: OPENCODE_BASE_MODE,
bindings: tuiConfig.keybinds.gather("app", appBindingCommands),
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
enabled: () => {
const ok = command.matcher.get()
if (!ok) return false
const current = promptRef.current
if (!current?.focused) return true
return current.current.input === ""
@@ -817,7 +820,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
}))
event.on(TuiEvent.CommandExecute.type, (evt) => {
command.run(evt.properties.command)
keymap.dispatchCommand(evt.properties.command)
})
event.on(TuiEvent.ToastShow.type, (evt) => {

View File

@@ -0,0 +1,80 @@
import { createMemo } from "solid-js"
import { DialogSelect, type DialogSelectRef } from "@tui/ui/dialog-select"
import { type DialogContext } from "@tui/ui/dialog"
import {
COMMAND_PALETTE_COMMAND,
formatKeyBindings,
type OpenTuiKeymap,
useKeymapSelector,
useOpencodeKeymap,
} from "../keymap"
import { useTuiConfig } from "../context/tui-config"
type PaletteCommandEntry = ReturnType<OpenTuiKeymap["getCommandEntries"]>[number]
function isVisiblePaletteCommand(command: PaletteCommandEntry["command"]) {
return command.hidden !== true && command.name !== COMMAND_PALETTE_COMMAND
}
function isSuggestedPaletteCommand(entry: PaletteCommandEntry) {
const suggested = entry.command.suggested
if (typeof suggested === "boolean") return suggested
if (typeof suggested === "function") return suggested() === true
return false
}
export function CommandPaletteDialog() {
const config = useTuiConfig()
const keymap = useOpencodeKeymap()
const entries = useKeymapSelector((keymap: OpenTuiKeymap) => {
const query = {
namespace: "palette",
}
const reachable = keymap
.getCommandEntries({
...query,
visibility: "reachable",
filter: isVisiblePaletteCommand,
})
const registeredBindings = keymap.getCommandBindings({
visibility: "registered",
commands: reachable.map((entry) => entry.command.name),
})
return reachable.map((entry) => ({
...entry,
bindings: registeredBindings.get(entry.command.name) ?? entry.bindings,
}))
})
const options = createMemo(() =>
entries().map((entry) => ({
title: typeof entry.command.title === "string" ? entry.command.title : entry.command.name,
description: typeof entry.command.desc === "string" ? entry.command.desc : undefined,
category: typeof entry.command.category === "string" ? entry.command.category : undefined,
footer: formatKeyBindings(entry.bindings, config),
value: entry.command.name,
suggested: isSuggestedPaletteCommand(entry),
onSelect: (dialog: DialogContext) => {
dialog.clear()
keymap.dispatchCommand(entry.command.name)
},
})),
)
let ref: DialogSelectRef<string>
const list = () => {
if (ref?.filter) return options()
return [
...options()
.filter((option) => option.suggested)
.map((option) => ({
...option,
value: `suggested:${option.value}`,
category: "Suggested",
})),
...options(),
]
}
return <DialogSelect ref={(value) => (ref = value)} title="Commands" options={list()} />
}

View File

@@ -13,12 +13,11 @@ import { getScrollAcceleration } from "../../util/scroll"
import { useTuiConfig } from "../../context/tui-config"
import { useTheme, selectedForeground } from "@tui/context/theme"
import { SplitBorder } from "@tui/component/border"
import { useCommandPalette } from "../../context/command-palette"
import { useTerminalDimensions } from "@opentui/solid"
import { Locale } from "@/util/locale"
import type { PromptInfo } from "./history"
import { useFrecency } from "./frecency"
import { useBindings } from "../../keymap"
import { useBindings, useCommandSlashes, useOpencodeModeStack } from "../../keymap"
import { Reference } from "@/reference/reference"
import { ConfigReference } from "@/config/reference"
import { displayCharAt, mentionTriggerIndex } from "@/cli/cmd/prompt-display"
@@ -87,7 +86,8 @@ export function Autocomplete(props: {
const sdk = useSDK()
const sync = useSync()
const project = useProject()
const command = useCommandPalette()
const slashes = useCommandSlashes()
const modeStack = useOpencodeModeStack()
const { theme } = useTheme()
const dimensions = useTerminalDimensions()
const frecency = useFrecency()
@@ -101,6 +101,12 @@ export function Autocomplete(props: {
const [positionTick, setPositionTick] = createSignal(0)
createEffect(() => {
if (!store.visible) return
const popMode = modeStack.push("autocomplete")
onCleanup(popMode)
})
createEffect(() => {
if (store.visible) {
let lastPos = { x: 0, y: 0, width: 0 }
@@ -367,7 +373,6 @@ export function Autocomplete(props: {
const { filename, part } = createFilePart(item, lineRange)
const index = store.visible === "@" ? store.index : props.input().cursorOffset
command.suspend(false)
setStore("visible", false)
setStore("index", index)
insertPart(filename, part)
@@ -539,7 +544,7 @@ export function Autocomplete(props: {
)
const commands = createMemo((): AutocompleteOption[] => {
const results: AutocompleteOption[] = [...command.slashes()]
const results: AutocompleteOption[] = [...slashes()]
for (const serverCommand of sync.data.command) {
if (serverCommand.source === "skill") continue
@@ -730,7 +735,6 @@ export function Autocomplete(props: {
}))
function show(mode: "@" | "/") {
command.suspend(true)
setStore({
visible: mode,
index: props.input().cursorOffset,
@@ -747,7 +751,6 @@ export function Autocomplete(props: {
draft.input = props.input().plainText
})
}
command.suspend(false)
setStore("visible", false)
}

View File

@@ -59,8 +59,13 @@ import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
import { useArgs } from "@tui/context/args"
import { Flag } from "@opencode-ai/core/flag/flag"
import { type WorkspaceStatus } from "../workspace-label"
import { useCommandPalette } from "../../context/command-palette"
import { useBindings, useCommandShortcut, useLeaderActive, useOpencodeKeymap } from "../../keymap"
import {
OPENCODE_BASE_MODE,
useBindings,
useCommandShortcut,
useLeaderActive,
useOpencodeKeymap,
} from "../../keymap"
import { useTuiConfig } from "../../context/tui-config"
export type PromptProps = {
@@ -152,7 +157,6 @@ export function Prompt(props: PromptProps) {
const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" })
const history = usePromptHistory()
const stash = usePromptStash()
const command = useCommandPalette()
const keymap = useOpencodeKeymap()
const agentShortcut = useCommandShortcut("agent.cycle")
const paletteShortcut = useCommandShortcut("command.palette.show")
@@ -629,7 +633,7 @@ export function Prompt(props: PromptProps) {
}))
useBindings(() => ({
enabled: command.matcher,
mode: OPENCODE_BASE_MODE,
bindings: tuiConfig.keybinds.gather("prompt.palette", [
"prompt.submit",
"prompt.editor",

View File

@@ -1,163 +0,0 @@
import { createContext, createMemo, createSignal, useContext, type Accessor, type ParentProps } from "solid-js"
import { DialogSelect, type DialogSelectRef } from "@tui/ui/dialog-select"
import { useDialog, type DialogContext } from "@tui/ui/dialog"
import {
formatKeyBindings,
reactiveMatcherFromSignal,
type OpenTuiKeymap,
useKeymapSelector,
useOpencodeKeymap,
} from "../keymap"
import { useTuiConfig } from "./tui-config"
type SlashEntry = {
display: string
description?: string
aliases?: string[]
onSelect: () => void
}
type CommandPaletteContext = {
run(command: string): void
show(): void
slashes: Accessor<readonly SlashEntry[]>
suspend(enabled: boolean): void
readonly suspended: boolean
matcher: ReturnType<typeof reactiveMatcherFromSignal>
}
const COMMAND_PALETTE_DIALOG = "command.palette.show"
const ctx = createContext<CommandPaletteContext>()
type PaletteCommandEntry = ReturnType<OpenTuiKeymap["getCommandEntries"]>[number]
function isVisiblePaletteCommand(entry: PaletteCommandEntry) {
return entry.command.hidden !== true && entry.command.name !== COMMAND_PALETTE_DIALOG
}
function isSuggestedPaletteCommand(entry: PaletteCommandEntry) {
const suggested = entry.command.suggested
if (typeof suggested === "boolean") return suggested
if (typeof suggested === "function") return suggested() === true
return false
}
export function CommandPaletteProvider(props: ParentProps) {
const dialog = useDialog()
const keymap = useOpencodeKeymap()
const [suspendCount, setSuspendCount] = createSignal(0)
const entries = useKeymapSelector((keymap: OpenTuiKeymap) =>
keymap
.getCommandEntries({
visibility: "reachable",
namespace: "palette",
})
.filter(isVisiblePaletteCommand),
)
const run = (command: string) => {
keymap.dispatchCommand(command)
}
const slashes = createMemo<SlashEntry[]>(() =>
entries().flatMap((entry) => {
const slashName = entry.command.slashName
if (typeof slashName !== "string" || !slashName) return []
const slashAliases = entry.command.slashAliases
return {
display: `/${slashName}`,
description:
typeof entry.command.desc === "string"
? entry.command.desc
: typeof entry.command.title === "string"
? entry.command.title
: undefined,
aliases: Array.isArray(slashAliases)
? slashAliases.filter((alias): alias is string => typeof alias === "string").map((alias) => `/${alias}`)
: undefined,
onSelect: () => run(entry.command.name),
}
}),
)
const value: CommandPaletteContext = {
run,
show() {
dialog.replace(() => <CommandPaletteDialog run={run} />)
},
slashes,
suspend(enabled: boolean) {
setSuspendCount((count) => Math.max(0, count + (enabled ? 1 : -1)))
},
get suspended() {
return suspendCount() > 0 || dialog.stack.length > 0
},
matcher: reactiveMatcherFromSignal(() => suspendCount() === 0 && dialog.stack.length === 0),
}
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
}
export function useCommandPalette() {
const value = useContext(ctx)
if (!value) throw new Error("CommandPalette context must be used within a CommandPaletteProvider")
return value
}
function CommandPaletteDialog(props: { run(command: string): void }) {
const config = useTuiConfig()
const entries = useKeymapSelector((keymap: OpenTuiKeymap) => {
const query = {
namespace: "palette",
}
const reachable = keymap
.getCommandEntries({
...query,
visibility: "reachable",
})
.filter(isVisiblePaletteCommand)
const registeredBindings = keymap.getCommandBindings({
visibility: "registered",
commands: reachable.map((entry) => entry.command.name),
})
return reachable.map((entry) => ({
...entry,
bindings: registeredBindings.get(entry.command.name) ?? entry.bindings,
}))
})
const options = createMemo(() =>
entries().map((entry) => ({
title: typeof entry.command.title === "string" ? entry.command.title : entry.command.name,
description: typeof entry.command.desc === "string" ? entry.command.desc : undefined,
category: typeof entry.command.category === "string" ? entry.command.category : undefined,
footer: formatKeyBindings(entry.bindings, config),
value: entry.command.name,
suggested: isSuggestedPaletteCommand(entry),
onSelect: (dialog: DialogContext) => {
dialog.clear()
props.run(entry.command.name)
},
})),
)
let ref: DialogSelectRef<string>
const list = () => {
if (ref?.filter) return options()
return [
...options()
.filter((option) => option.suggested)
.map((option) => ({
...option,
value: `suggested:${option.value}`,
category: "Suggested",
})),
...options(),
]
}
return <DialogSelect ref={(value) => (ref = value)} title="Commands" options={list()} />
}
export function useCommandSlashes(): Accessor<readonly SlashEntry[]> {
return useCommandPalette().slashes
}

View File

@@ -7,24 +7,100 @@ import {
} from "@opentui/keymap/extras"
import {
KeymapProvider,
reactiveMatcherFromSignal,
useKeymap,
useKeymapSelector,
useBindings,
} from "@opentui/keymap/solid"
import type { Accessor } from "solid-js"
import { createMemo, 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"
export const OPENCODE_BASE_MODE = "base"
export const COMMAND_PALETTE_COMMAND = "command.palette.show"
const OPENCODE_MODE_KEY = "opencode.mode"
export const OpencodeKeymapProvider = KeymapProvider
export const useOpencodeKeymap = useKeymap
export { reactiveMatcherFromSignal, useBindings, useKeymapSelector }
export { useBindings, useKeymapSelector }
export type OpenTuiKeymap = ReturnType<typeof useKeymap>
type OpencodeModeStack = ReturnType<typeof createOpencodeModeStack>
type CommandSlashEntry = {
display: string
description?: string
aliases?: string[]
onSelect: () => void
}
type Command = ReturnType<OpenTuiKeymap["getCommands"]>[number]
const modeStacks = new WeakMap<OpenTuiKeymap, OpencodeModeStack>()
function isVisiblePaletteCommand(command: Command) {
return command.hidden !== true && command.name !== COMMAND_PALETTE_COMMAND
}
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",
@@ -127,6 +203,7 @@ export function registerOpencodeKeymap(
renderer: CliRenderer,
config: Pick<TuiConfig.Resolved, "keybinds" | "leader_timeout">,
) {
const modeStack = createOpencodeModeStack(keymap)
const offCommaBindings = addons.registerCommaBindings(keymap)
const offAliasExpander = registerKeyAliases(keymap)
const offBaseLayout = addons.registerBaseLayoutFallback(keymap)
@@ -150,6 +227,7 @@ export function registerOpencodeKeymap(
offAliasExpander()
offBaseLayout()
offCommaBindings()
modeStack.dispose()
}
}
@@ -166,3 +244,36 @@ export function useCommandShortcut(command: string): Accessor<string> {
export function useLeaderActive(): Accessor<boolean> {
return useKeymapSelector((keymap: OpenTuiKeymap) => keymap.getPendingSequence()[0]?.tokenName === LEADER_TOKEN)
}
export function useCommandSlashes(): Accessor<readonly CommandSlashEntry[]> {
const keymap = useOpencodeKeymap()
const entries = useKeymapSelector((keymap: OpenTuiKeymap) =>
keymap
.getCommandEntries({
visibility: "reachable",
namespace: "palette",
filter: isVisiblePaletteCommand,
})
)
return createMemo<CommandSlashEntry[]>(() =>
entries().flatMap((entry) => {
const slashName = entry.command.slashName
if (typeof slashName !== "string" || !slashName) return []
const slashAliases = entry.command.slashAliases
return {
display: `/${slashName}`,
description:
typeof entry.command.desc === "string"
? entry.command.desc
: typeof entry.command.title === "string"
? entry.command.title
: undefined,
aliases: Array.isArray(slashAliases)
? slashAliases.filter((alias): alias is string => typeof alias === "string").map((alias) => `/${alias}`)
: undefined,
onSelect: () => keymap.dispatchCommand(entry.command.name),
}
}),
)
}

View File

@@ -219,6 +219,14 @@ export function createTuiApi(input: Input): TuiPluginApi {
},
},
keymap: input.keymap,
mode: {
current() {
return Keymap.getOpencodeModeStack(input.keymap).current()
},
push(mode) {
return Keymap.getOpencodeModeStack(input.keymap).push(mode)
},
},
route: {
register(list) {
return routeRegister(input.routes, list, input.bump)

View File

@@ -192,6 +192,17 @@ function createScopedAttention(
}
}
function createScopedMode(mode: TuiPluginApi["mode"], scope: PluginScope): TuiPluginApi["mode"] {
return {
current() {
return mode.current()
},
push(value) {
return scope.track(mode.push(value))
},
}
}
type CleanupResult = { type: "ok" } | { type: "error"; error: unknown } | { type: "timeout" }
function runCleanup(fn: () => unknown, ms: number): Promise<CleanupResult> {
@@ -616,6 +627,7 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop
command: createCommandShim(keymap, api.ui.dialog, api.tuiConfig.keybinds),
keys: api.keys,
keymap,
mode: createScopedMode(api.mode, scope),
route,
ui: api.ui,
tuiConfig: api.tuiConfig,

View File

@@ -89,8 +89,7 @@ import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
import { DialogRetryAction } from "../../component/dialog-retry-action"
import { SessionRetry } from "@/session/retry"
import { getRevertDiffFiles } from "../../util/revert-diff"
import { useCommandPalette } from "../../context/command-palette"
import { useBindings, useCommandShortcut } from "../../keymap"
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useOpencodeKeymap } from "../../keymap"
import { PathFormatterProvider, usePathFormatter } from "../../context/path-format"
addDefaultParsers(parsers.parsers)
@@ -311,7 +310,7 @@ export function Session() {
seeded = true
r.set(route.prompt)
}
const command = useCommandPalette()
const keymap = useOpencodeKeymap()
const dialog = useDialog()
const renderer = useRenderer()
@@ -1056,7 +1055,7 @@ export function Session() {
}))
useBindings(() => ({
enabled: command.matcher,
mode: OPENCODE_BASE_MODE,
bindings: tuiConfig.keybinds.gather("session", sessionBindingCommands),
}))
@@ -1133,7 +1132,6 @@ export function Session() {
<Switch>
<Match when={message.id === revert()?.messageID}>
{(function () {
const command = useCommandPalette()
const redoShortcut = useCommandShortcut("session.redo")
const [hover, setHover] = createSignal(false)
const dialog = useDialog()
@@ -1145,7 +1143,7 @@ export function Session() {
"Are you sure you want to restore the reverted messages?",
)
if (confirmed) {
command.run("session.redo")
keymap.dispatchCommand("session.redo")
}
}

View File

@@ -13,10 +13,9 @@ import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
import { Locale } from "@/util/locale"
import { ShellID } from "@/tool/shell/id"
import { webSearchProviderLabel } from "@/tool/websearch"
import { useDialog } from "../../ui/dialog"
import { getScrollAcceleration } from "../../util/scroll"
import { useTuiConfig } from "../../context/tui-config"
import { useBindings, useCommandShortcut } from "../../keymap"
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut } from "../../keymap"
import { usePathFormatter } from "../../context/path-format"
type PermissionStage = "permission" | "always" | "reject"
@@ -448,9 +447,8 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: (
const tuiConfig = useTuiConfig()
const dimensions = useTerminalDimensions()
const narrow = createMemo(() => dimensions().width < 80)
const dialog = useDialog()
useBindings(() => ({
enabled: dialog.stack.length === 0,
mode: OPENCODE_BASE_MODE,
commands: [
{
name: "app.exit",
@@ -542,11 +540,10 @@ function Prompt<const T extends Record<string, string>>(props: {
expanded: false,
})
const narrow = createMemo(() => dimensions().width < 80)
const dialog = useDialog()
const fullscreenHint = useCommandShortcut("permission.prompt.fullscreen")
useBindings(() => ({
enabled: dialog.stack.length === 0,
mode: OPENCODE_BASE_MODE,
commands: [
{
name: "app.exit",

View File

@@ -6,9 +6,8 @@ import { selectedForeground, tint, useTheme } from "../../context/theme"
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
import { useSDK } from "../../context/sdk"
import { SplitBorder } from "../../component/border"
import { useDialog } from "../../ui/dialog"
import { useTuiConfig } from "../../context/tui-config"
import { useBindings } from "../../keymap"
import { OPENCODE_BASE_MODE, useBindings } from "../../keymap"
export function QuestionPrompt(props: { request: QuestionRequest }) {
const sdk = useSDK()
@@ -120,9 +119,8 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
pick(opt.label)
}
const dialog = useDialog()
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
enabled: store.editing && !confirm(),
commands: [
{
@@ -203,7 +201,8 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
const max = Math.min(total, 9)
return {
enabled: dialog.stack.length === 0 && !store.editing,
mode: OPENCODE_BASE_MODE,
enabled: !store.editing,
commands: [
{
name: "app.exit",

View File

@@ -6,8 +6,7 @@ import { SplitBorder } from "@tui/component/border"
import type { AssistantMessage } from "@opencode-ai/sdk/v2"
import { Locale } from "@/util/locale"
import { useTerminalDimensions } from "@opentui/solid"
import { useCommandPalette } from "../../context/command-palette"
import { useCommandShortcut } from "../../keymap"
import { useCommandShortcut, useOpencodeKeymap } from "../../keymap"
export function SubagentFooter() {
const route = useRouteData("session")
@@ -56,7 +55,7 @@ export function SubagentFooter() {
})
const { theme } = useTheme()
const command = useCommandPalette()
const keymap = useOpencodeKeymap()
const parentShortcut = useCommandShortcut("session.parent")
const previousShortcut = useCommandShortcut("session.child.previous")
const nextShortcut = useCommandShortcut("session.child.next")
@@ -98,7 +97,7 @@ export function SubagentFooter() {
<box
onMouseOver={() => setHover("parent")}
onMouseOut={() => setHover(null)}
onMouseUp={() => command.run("session.parent")}
onMouseUp={() => keymap.dispatchCommand("session.parent")}
backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel}
>
<text fg={theme.text}>
@@ -108,7 +107,7 @@ export function SubagentFooter() {
<box
onMouseOver={() => setHover("prev")}
onMouseOut={() => setHover(null)}
onMouseUp={() => command.run("session.child.previous")}
onMouseUp={() => keymap.dispatchCommand("session.child.previous")}
backgroundColor={hover() === "prev" ? theme.backgroundElement : theme.backgroundPanel}
>
<text fg={theme.text}>
@@ -118,7 +117,7 @@ export function SubagentFooter() {
<box
onMouseOver={() => setHover("next")}
onMouseOut={() => setHover(null)}
onMouseUp={() => command.run("session.child.next")}
onMouseUp={() => keymap.dispatchCommand("session.child.next")}
backgroundColor={hover() === "next" ? theme.backgroundElement : theme.backgroundPanel}
>
<text fg={theme.text}>

View File

@@ -1,12 +1,12 @@
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
import { batch, createContext, Show, useContext, type JSX, type ParentProps } from "solid-js"
import { batch, createContext, createEffect, onCleanup, Show, useContext, type JSX, type ParentProps } from "solid-js"
import { useTheme } from "@tui/context/theme"
import { MouseButton, Renderable, RGBA } from "@opentui/core"
import { createStore } from "solid-js/store"
import { useToast } from "./toast"
import { Flag } from "@opencode-ai/core/flag/flag"
import * as Selection from "@tui/util/selection"
import { useBindings } from "../keymap"
import { useBindings, useOpencodeModeStack } from "../keymap"
export function Dialog(
props: ParentProps<{
@@ -73,6 +73,13 @@ function init() {
})
const renderer = useRenderer()
const modeStack = useOpencodeModeStack()
createEffect(() => {
if (store.stack.length === 0) return
const popMode = modeStack.push("modal")
onCleanup(popMode)
})
let focus: Renderable | null
function refocus() {

View File

@@ -91,6 +91,70 @@ test("toggles plugin runtime state by exported id", async () => {
}
})
test("deactivating plugin pops pushed mode", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const file = path.join(dir, "mode-plugin.ts")
const spec = pathToFileURL(file).href
await Bun.write(
file,
`export default {
id: "demo.mode",
tui: async (api) => {
api.mode.push("demo.mode")
},
}
`,
)
return { spec }
},
})
const stack: { id: symbol; mode: string }[] = []
let popCount = 0
const api = createTuiPluginApi({
mode: {
current: () => stack.at(-1)?.mode ?? "base",
push(mode) {
const id = Symbol(mode)
let active = true
stack.push({ id, mode })
return () => {
if (!active) return
active = false
popCount += 1
const index = stack.findIndex((item) => item.id === id)
if (index !== -1) stack.splice(index, 1)
}
},
},
})
const config = createTuiResolvedConfig({
plugin: [tmp.extra.spec],
plugin_origins: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }],
})
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
try {
await TuiPluginRuntime.init({ api, config })
expect(api.mode.current()).toBe("demo.mode")
expect(popCount).toBe(0)
await expect(TuiPluginRuntime.deactivatePlugin("demo.mode")).resolves.toBe(true)
expect(api.mode.current()).toBe("base")
expect(popCount).toBe(1)
} finally {
await TuiPluginRuntime.dispose()
cwd.mockRestore()
wait.mockRestore()
}
})
test("kv plugin_enabled overrides tui config on startup", async () => {
await using tmp = await tmpdir({
init: async (dir) => {

View File

@@ -89,6 +89,7 @@ type Opts = {
renderer?: HostPluginApi["renderer"]
attention?: AttentionOpts
event?: HostPluginApi["event"]
mode?: HostPluginApi["mode"]
count?: Count
keymap?: HostPluginApi["keymap"]
tuiConfig?: Partial<HostPluginApi["tuiConfig"]>
@@ -237,6 +238,10 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
},
},
keymap,
mode: opts.mode ?? {
current: () => "base",
push: () => () => {},
},
route: {
register: () => {
if (count) count.route_add += 1

View File

@@ -78,6 +78,11 @@ export type TuiKeys = {
export type TuiKeymap = Keymap<Renderable, KeyEvent>
export type TuiModeApi = {
current: () => string
push: (mode: string) => () => void
}
/**
* Legacy `api.command` shape kept so v1 plugins can initialize. Remove in v2.
*
@@ -589,6 +594,7 @@ export type TuiPluginApi = {
command?: TuiCommandApi
keys: TuiKeys
keymap: TuiKeymap
mode: TuiModeApi
route: {
register: (routes: TuiRouteDefinition[]) => () => void
navigate: (name: string, params?: Record<string, unknown>) => void