diff --git a/packages/opencode/specs/tui-plugins.md b/packages/opencode/specs/tui-plugins.md index e9ece6c0a2..06f7757d51 100644 --- a/packages/opencode/specs/tui-plugins.md +++ b/packages/opencode/specs/tui-plugins.md @@ -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 ( + + demo + + ) + }, + }, +]) + +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. diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 19a15a26ec..e326a39b59 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -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: { - - - - - - - - - - - + + + + + + + + + @@ -267,7 +272,6 @@ function App(props: { onSnapshot?: () => Promise }) { 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 }) { const appCommands = createMemo(() => [ { - name: "command.palette.show", + name: COMMAND_PALETTE_COMMAND, title: "Show command palette", category: "System", hidden: true, run: () => { - command.show() + dialog.replace(() => ) }, }, { @@ -801,14 +805,13 @@ function App(props: { onSnapshot?: () => Promise }) { })) 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 }) { })) event.on(TuiEvent.CommandExecute.type, (evt) => { - command.run(evt.properties.command) + keymap.dispatchCommand(evt.properties.command) }) event.on(TuiEvent.ToastShow.type, (evt) => { diff --git a/packages/opencode/src/cli/cmd/tui/component/command-palette.tsx b/packages/opencode/src/cli/cmd/tui/component/command-palette.tsx new file mode 100644 index 0000000000..c54e67d4f2 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/command-palette.tsx @@ -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[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 + const list = () => { + if (ref?.filter) return options() + return [ + ...options() + .filter((option) => option.suggested) + .map((option) => ({ + ...option, + value: `suggested:${option.value}`, + category: "Suggested", + })), + ...options(), + ] + } + + return (ref = value)} title="Commands" options={list()} /> +} diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index db4d650d52..2bda73cff7 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -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) } diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index eb93a75ddc..f6724c7582 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -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", diff --git a/packages/opencode/src/cli/cmd/tui/context/command-palette.tsx b/packages/opencode/src/cli/cmd/tui/context/command-palette.tsx deleted file mode 100644 index 07cca99074..0000000000 --- a/packages/opencode/src/cli/cmd/tui/context/command-palette.tsx +++ /dev/null @@ -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 - suspend(enabled: boolean): void - readonly suspended: boolean - matcher: ReturnType -} - -const COMMAND_PALETTE_DIALOG = "command.palette.show" -const ctx = createContext() -type PaletteCommandEntry = ReturnType[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(() => - 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(() => ) - }, - 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 {props.children} -} - -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 - const list = () => { - if (ref?.filter) return options() - return [ - ...options() - .filter((option) => option.suggested) - .map((option) => ({ - ...option, - value: `suggested:${option.value}`, - category: "Suggested", - })), - ...options(), - ] - } - - return (ref = value)} title="Commands" options={list()} /> -} - -export function useCommandSlashes(): Accessor { - return useCommandPalette().slashes -} diff --git a/packages/opencode/src/cli/cmd/tui/keymap.tsx b/packages/opencode/src/cli/cmd/tui/keymap.tsx index 41a7b08612..6da518a0ba 100644 --- a/packages/opencode/src/cli/cmd/tui/keymap.tsx +++ b/packages/opencode/src/cli/cmd/tui/keymap.tsx @@ -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 +type OpencodeModeStack = ReturnType +type CommandSlashEntry = { + display: string + description?: string + aliases?: string[] + onSelect: () => void +} +type Command = ReturnType[number] + +const modeStacks = new WeakMap() + +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, ) { + 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 { export function useLeaderActive(): Accessor { return useKeymapSelector((keymap: OpenTuiKeymap) => keymap.getPendingSequence()[0]?.tokenName === LEADER_TOKEN) } + +export function useCommandSlashes(): Accessor { + const keymap = useOpencodeKeymap() + const entries = useKeymapSelector((keymap: OpenTuiKeymap) => + keymap + .getCommandEntries({ + visibility: "reachable", + namespace: "palette", + filter: isVisiblePaletteCommand, + }) + ) + + return createMemo(() => + 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), + } + }), + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx index 05bfa31d14..a704286835 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx +++ b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx @@ -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) diff --git a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts index 62f04c9707..515e617563 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts @@ -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 { @@ -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, diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index e8e29a40c9..3f2e902bdf 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -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() { {(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") } } diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index 5b40c3c318..ac8c45a6ee 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -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>(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", diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx index 46fc220bdc..ba38e34eae 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx @@ -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", diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/subagent-footer.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/subagent-footer.tsx index f4a458b63d..e1be4286f1 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/subagent-footer.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/subagent-footer.tsx @@ -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() { setHover("parent")} onMouseOut={() => setHover(null)} - onMouseUp={() => command.run("session.parent")} + onMouseUp={() => keymap.dispatchCommand("session.parent")} backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel} > @@ -108,7 +107,7 @@ export function SubagentFooter() { setHover("prev")} onMouseOut={() => setHover(null)} - onMouseUp={() => command.run("session.child.previous")} + onMouseUp={() => keymap.dispatchCommand("session.child.previous")} backgroundColor={hover() === "prev" ? theme.backgroundElement : theme.backgroundPanel} > @@ -118,7 +117,7 @@ export function SubagentFooter() { setHover("next")} onMouseOut={() => setHover(null)} - onMouseUp={() => command.run("session.child.next")} + onMouseUp={() => keymap.dispatchCommand("session.child.next")} backgroundColor={hover() === "next" ? theme.backgroundElement : theme.backgroundPanel} > diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx index 3c74ded5fd..73d61c3ba6 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx @@ -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() { diff --git a/packages/opencode/test/cli/tui/plugin-toggle.test.ts b/packages/opencode/test/cli/tui/plugin-toggle.test.ts index a3ee744bff..902f0265c7 100644 --- a/packages/opencode/test/cli/tui/plugin-toggle.test.ts +++ b/packages/opencode/test/cli/tui/plugin-toggle.test.ts @@ -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) => { diff --git a/packages/opencode/test/fixture/tui-plugin.ts b/packages/opencode/test/fixture/tui-plugin.ts index f950254945..ef18801571 100644 --- a/packages/opencode/test/fixture/tui-plugin.ts +++ b/packages/opencode/test/fixture/tui-plugin.ts @@ -89,6 +89,7 @@ type Opts = { renderer?: HostPluginApi["renderer"] attention?: AttentionOpts event?: HostPluginApi["event"] + mode?: HostPluginApi["mode"] count?: Count keymap?: HostPluginApi["keymap"] tuiConfig?: Partial @@ -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 diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index 354e44abcd..d78c607f80 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -78,6 +78,11 @@ export type TuiKeys = { export type TuiKeymap = Keymap +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) => void