mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-21 19:35:10 +00:00
use keymap state for layer visibility (#26246)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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()} />
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user