diff --git a/packages/cli/src/commands/handlers/default.ts b/packages/cli/src/commands/handlers/default.ts index 474d2d184d..8143c26352 100644 --- a/packages/cli/src/commands/handlers/default.ts +++ b/packages/cli/src/commands/handlers/default.ts @@ -1,7 +1,6 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Global } from "@opencode-ai/core/global" import { run } from "@opencode-ai/tui" -import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins" import { Commands } from "../commands" import { Runtime } from "../../framework/runtime" import { Config } from "../../config" @@ -9,6 +8,7 @@ import { Effect, Option } from "effect" import { Server } from "../../services/server" import { Updater } from "../../services/updater" import { UpdatePreflight } from "../../services/update-preflight" +import { Npm } from "@opencode-ai/core/npm" export default Runtime.handler(Commands, (input) => Effect.gen(function* () { @@ -36,7 +36,7 @@ export default Runtime.handler(Commands, (input) => ) preflight.loading() const config = yield* Config.Service - let disposeSlots: (() => void) | undefined + const npm = yield* Npm.Service const context = yield* Effect.context() const runFork = Effect.runForkWith(context) const runPromise = Effect.runPromiseWith(context) @@ -44,9 +44,14 @@ export default Runtime.handler(Commands, (input) => server, args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) }, config: { + path: config.path, get: () => runPromise(config.get()), update: (update) => runPromise(config.update(update)), }, + packages: { + resolve: (spec) => + runPromise(npm.add(spec, { subpaths: ["tui"] }).pipe(Effect.map((result) => result.entrypoint))), + }, terminalHandoff: () => preflight.finish(), log: (level, message, tags) => { const effect = @@ -59,14 +64,6 @@ export default Runtime.handler(Commands, (input) => : Effect.logInfo(message, tags) runFork(effect) }, - pluginHost: { - async start(pluginInput) { - disposeSlots = await loadBuiltinPlugins(pluginInput.api, pluginInput.runtime) - }, - async dispose() { - disposeSlots?.() - }, - }, }).pipe(Effect.provide(AppNodeBuilder.build(Global.node))) }), ) diff --git a/packages/cli/src/framework/runtime.ts b/packages/cli/src/framework/runtime.ts index cffd80e772..a353a38a20 100644 --- a/packages/cli/src/framework/runtime.ts +++ b/packages/cli/src/framework/runtime.ts @@ -4,6 +4,7 @@ import { Spec } from "./spec" import { Global } from "@opencode-ai/core/global" import { Updater } from "../services/updater" import { Config } from "../config" +import { Npm } from "@opencode-ai/core/npm" export type Input = Value extends Spec.Node @@ -17,7 +18,7 @@ type RuntimeHandler = ( ) => Effect.Effect< void, unknown, - FileSystem.FileSystem | Global.Service | Updater.Service | Config.Service | Scope.Scope + FileSystem.FileSystem | Global.Service | Npm.Service | Updater.Service | Config.Service | Scope.Scope > type Loader = () => Promise<{ default: ( @@ -25,7 +26,7 @@ type Loader = () => Promise<{ ) => Effect.Effect< void, any, - FileSystem.FileSystem | Global.Service | Updater.Service | Config.Service | Scope.Scope + FileSystem.FileSystem | Global.Service | Npm.Service | Updater.Service | Config.Service | Scope.Scope > }> type ProvidedCommand = Command.Command< @@ -33,7 +34,7 @@ type ProvidedCommand = Command.Command< unknown, unknown, unknown, - FileSystem.FileSystem | Global.Service | Updater.Service | Config.Service | Scope.Scope + FileSystem.FileSystem | Global.Service | Npm.Service | Updater.Service | Config.Service | Scope.Scope > export type Handlers = keyof Node["commands"] extends never diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 2128495526..fd59472a86 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -12,6 +12,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Global } from "@opencode-ai/core/global" import { AppProcess } from "@opencode-ai/core/process" import { Config } from "./config" +import { Npm } from "@opencode-ai/core/npm" const Handlers = Runtime.handlers(Commands, { $: () => import("./commands/handlers/default"), @@ -54,7 +55,7 @@ Effect.logInfo("cli starting", { Effect.annotateLogs({ role: "cli" }), Effect.provide(Config.layer), Effect.provide(Updater.layer), - Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node]))), + Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node, Npm.node]))), Effect.provide(Observability.layer), Effect.provide(NodeServices.layer), Effect.scoped, diff --git a/packages/cli/src/mini/footer.prompt.tsx b/packages/cli/src/mini/footer.prompt.tsx index 7d30159126..df1aa9312a 100644 --- a/packages/cli/src/mini/footer.prompt.tsx +++ b/packages/cli/src/mini/footer.prompt.tsx @@ -1164,13 +1164,13 @@ export function createPromptState(input: PromptInput): PromptState { }, }, ], - bindings: input.tuiConfig.keybinds.gather("run.prompt.autocomplete", [ + bindings: [ "prompt.autocomplete.prev", "prompt.autocomplete.next", "prompt.autocomplete.hide", "prompt.autocomplete.select", "prompt.autocomplete.complete", - ]), + ].flatMap((command) => input.tuiConfig.keybinds.get(command)), })) const onKeyDown = (event: KeyEvent) => { diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 1666138155..bae8482a36 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -16,6 +16,7 @@ "./v2/effect": "./src/v2/effect/index.ts", "./v2/effect/*": "./src/v2/effect/*.ts", "./v2/tui": "./src/v2/tui/index.ts", + "./v2/tui/*": "./src/v2/tui/*.ts", "./v2": "./src/v2/promise/index.ts", "./v2/*": "./src/v2/promise/*.ts" }, diff --git a/packages/plugin/src/v2/tui/context.ts b/packages/plugin/src/v2/tui/context.ts index ad0d782b61..1f139fd772 100644 --- a/packages/plugin/src/v2/tui/context.ts +++ b/packages/plugin/src/v2/tui/context.ts @@ -86,26 +86,36 @@ export interface Data { } } -export interface RouteDefinition { +export type Route = + | { readonly type: "home" } + | { readonly type: "session"; readonly sessionID: string } + | { + readonly type: "plugin" + readonly id: string + readonly name: string + readonly data?: Record + } + +export type Destination = Route | Omit, "id"> + +export interface Page { readonly name: string - readonly render: (input: { readonly params: any }) => JSX.Element + readonly render: (input: { readonly data?: Record }) => JSX.Element } -export interface Route { - register(definition: RouteDefinition): () => void - navigate(input: { readonly name: string; readonly params?: any }): void - current(): { - readonly name: string - readonly params: any - } -} +export type Slot = (props: Record) => JSX.Element export interface UI { - readonly route: Route + readonly router: { + register(page: Page): () => void + navigate(destination: Destination): void + current(): Route + } + readonly slot: (name: string, render: Slot) => () => void } export interface Context { - readonly options: Readonly> + readonly options: Readonly> readonly client: OpenCodeClient readonly data: Data readonly ui: UI diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 99a58b2e47..9672bba540 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -1,12 +1,10 @@ import { render, TimeToFirstDraw, useRenderer, useTerminalDimensions } from "@opentui/solid" import { registerOpencodeSpinner } from "./component/register-spinner" -import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" import { Deferred, Effect } from "effect" import { Service } from "@opencode-ai/client/effect" import { OpenCode } from "@opencode-ai/client" import { Global } from "@opencode-ai/core/global" import { Flag } from "@opencode-ai/core/flag/flag" -import { InstallationVersion } from "@opencode-ai/core/installation/version" import { ClipboardProvider, useClipboard } from "./context/clipboard" import { LogProvider, useLog, type LogSink } from "./context/log" import { ExitProvider, useExit } from "./context/exit" @@ -33,7 +31,13 @@ import { batch, Show, } from "solid-js" -import { TuiPathsProvider, TuiStartupProvider, TuiTerminalEnvironmentProvider, useTuiStartup } from "./context/runtime" +import { + TuiLifecycleProvider, + TuiPathsProvider, + TuiStartupProvider, + TuiTerminalEnvironmentProvider, + useTuiStartup, +} from "./context/runtime" import { DialogProvider, useDialog } from "./ui/dialog" import { DialogIntegration } from "./component/dialog-integration" import { ErrorComponent } from "./component/error-component" @@ -72,22 +76,13 @@ import { ArgsProvider, useArgs, type Args } from "./context/args" import open from "open" import { PromptRefProvider, usePromptRef } from "./context/prompt" import { Config, ConfigProvider, useConfig } from "./config" -import { createTuiApiAdapters } from "./plugin/adapters" -import { createTuiApi } from "./plugin/api" -import { createPluginRuntime, PluginRuntimeProvider, usePluginRuntime, type TuiPluginHost } from "./plugin/runtime" +import { createPluginRuntime, PluginRuntimeProvider, usePluginRuntime } from "./plugin/runtime" +import { PluginProvider, PluginRoute, PluginSlot, usePlugin, type PackageResolver } from "./plugin/context" import { CommandPaletteDialog } from "./component/command-palette" -import { - COMMAND_PALETTE_COMMAND, - OPENCODE_BASE_MODE, - OpencodeKeymapProvider, - registerOpencodeKeymap, - useBindings, - useOpencodeKeymap, -} from "./keymap" +import { COMMAND_PALETTE_COMMAND, OPENCODE_BASE_MODE, useBindings, useOpencodeKeymap } from "./keymap" +import { Keymap } from "./context/keymap" import { DialogVariant } from "./component/dialog-variant" -import { createTuiAttention } from "./attention" -import * as TuiAudio from "./audio" import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-win32" import { destroyRenderer } from "./util/renderer" import { cliErrorMessage, errorFormat } from "./util/error" @@ -149,7 +144,7 @@ export type TuiInput = { } args: Args config: Config.Interface - pluginHost: TuiPluginHost + packages: PackageResolver terminalHandoff?: () => Promise< | { readonly renderer: CliRenderer @@ -239,21 +234,15 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { }), ) win32DisableProcessedInput() - const keymap = createDefaultOpenTuiKeymap(renderer) - yield* Effect.acquireRelease( - Effect.sync(() => registerOpencodeKeymap(keymap, renderer, config)), - (unregister) => Effect.sync(unregister), - ) + const finalizers = new Set<() => Promise>() yield* Effect.addFinalizer(() => Effect.promise(async () => { - try { - await input.pluginHost.dispose() - } catch (error) { - log("error", "Failed to dispose TUI plugins", { error }) - } + const results = await Promise.allSettled([...finalizers].reverse().map((finalizer) => finalizer())) + results + .filter((result): result is PromiseRejectedResult => result.status === "rejected") + .forEach((result) => log("error", "Failed to dispose TUI resource", { error: result.reason })) }), ) - yield* Effect.addFinalizer(() => Effect.sync(TuiAudio.dispose)) const shutdown = yield* Deferred.make() const onSighup = () => destroyRenderer(renderer) yield* Effect.acquireRelease( @@ -291,55 +280,59 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { worktree: global.data + "/worktree", }} > - finalizers.delete(finalizer) + }, }} > - - - + + - - - - - - - + + + + + + + + @@ -349,17 +342,18 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { - + + + @@ -369,19 +363,20 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { - - - - - - - + + + + + + + + - - - - + + + + @@ -406,14 +401,10 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { }) }) -function App(props: { - pluginHost: TuiPluginHost - pair?: DialogPairCredentials -}) { +function App(props: { pair?: DialogPairCredentials }) { const log = useLog({ component: "app" }) const startup = useTuiStartup() - const configState = useConfig() - const config = configState.data + const config = useConfig() const route = useRoute() const dimensions = useTerminalDimensions() const renderer = useRenderer() @@ -430,7 +421,7 @@ function App(props: { const exit = useExit() const promptRef = usePromptRef() const pluginRuntime = usePluginRuntime() - const attention = createTuiAttention({ renderer, config, update: configState.update }) + const plugins = usePlugin() const clipboard = useClipboard() // Toast once when an MCP server enters a failed or needs-auth state so the user knows to act, @@ -461,39 +452,6 @@ function App(props: { } }) - const api = createTuiApi( - createTuiApiAdapters({ - version: InstallationVersion, - tuiConfig: config, - dialog, - keymap, - route, - routes: pluginRuntime.routes, - event, - client, - project, - data, - theme: themeState, - toast, - renderer, - attention, - Slot: pluginRuntime.Slot, - }), - ) - const [ready, setReady] = createSignal(false) - props.pluginHost - .start({ - api, - runtime: pluginRuntime, - dispose: () => attention.dispose(), - }) - .catch((error) => { - log.error("Failed to load TUI plugins", { error }) - }) - .finally(() => { - setReady(true) - }) - // Let selection copy/dismiss win ahead of normal bindings when explicit copy is required. const offSelectionKeys = keymap.intercept( "key", @@ -505,7 +463,6 @@ function App(props: { ) onCleanup(() => { offSelectionKeys() - attention.dispose() }) // Wire up console copy-to-clipboard via opentui's onCopySelection callback @@ -519,11 +476,11 @@ function App(props: { renderer.clearSelection() } - const terminalTitleEnabled = () => config.terminal?.title ?? true - const pasteSummaryEnabled = () => config.prompt?.paste !== "full" + const terminalTitleEnabled = () => config.data.terminal?.title ?? true + const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full" createEffect(() => { - renderer.useMouse = !Flag.OPENCODE_DISABLE_MOUSE && config.mouse + renderer.useMouse = !Flag.OPENCODE_DISABLE_MOUSE && config.data.mouse }) // Update terminal window title based on current route and session @@ -548,7 +505,7 @@ function App(props: { } if (route.data.type === "plugin") { - renderer.setTerminalTitle(`OC | ${route.data.id}`) + renderer.setTerminalTitle(`OC | ${route.data.name}`) } }) @@ -631,8 +588,7 @@ function App(props: { title: "Switch session", category: "Session", suggested: data.session.list().length > 0, - slashName: "sessions", - slashAliases: ["resume", "continue"], + slash: { name: "sessions", aliases: ["resume", "continue"] }, run: () => { dialog.replace(() => ) }, @@ -642,8 +598,7 @@ function App(props: { title: "New session", suggested: route.data.type === "session", category: "Session", - slashName: "new", - slashAliases: ["clear"], + slash: { name: "new", aliases: ["clear"] }, run: () => { route.navigate({ type: "home", @@ -665,9 +620,8 @@ function App(props: { title: "Switch model", suggested: true, category: "Agent", - slashName: "models", // Bias /mo toward /models over /move without changing global fuzzy scoring. - slashAliases: ["mo"], + slash: { name: "models", aliases: ["mo"] }, run: () => { dialog.replace(() => ) }, @@ -712,7 +666,7 @@ function App(props: { name: "agent.list", title: "Switch agent", category: "Agent", - slashName: "agents", + slash: { name: "agents" }, run: () => { dialog.replace(() => ) }, @@ -721,7 +675,7 @@ function App(props: { name: "mcp.list", title: "MCP servers", category: "Agent", - slashName: "mcps", + slash: { name: "mcps" }, run: () => { dialog.replace(() => ) }, @@ -748,7 +702,7 @@ function App(props: { title: "Switch model variant", category: "Agent", hidden: local.model.variant.list().length === 0, - slashName: "variants", + slash: { name: "variants" }, run: () => { if (local.model.variant.list().length === 0) { return toast.show({ @@ -773,7 +727,7 @@ function App(props: { name: "provider.connect", title: "Connect integration", suggested: !connected(), - slashName: "connect", + slash: { name: "connect" }, run: () => { dialog.replace(() => ( { dialog.replace(() => ) }, @@ -795,7 +749,7 @@ function App(props: { { name: "opencode.status", title: "View status", - slashName: "status", + slash: { name: "status" }, run: () => { dialog.replace(() => ) }, @@ -804,7 +758,7 @@ function App(props: { { name: "server.pair", title: "Pair device", - slashName: "pair", + slash: { name: "pair" }, run: () => { dialog.replace(() => ) }, @@ -815,7 +769,7 @@ function App(props: { { name: "server.reload", title: "Reload server", - slashName: "reload", + slash: { name: "reload" }, run: async () => { dialog.clear() toast.show({ variant: "info", message: "Reloading server...", duration: 30000 }) @@ -832,7 +786,7 @@ function App(props: { { name: "opencode.debug", title: "View debug info", - slashName: "debug", + slash: { name: "debug" }, run: () => { dialog.replace(() => ) }, @@ -841,7 +795,7 @@ function App(props: { { name: "theme.switch", title: "Switch theme", - slashName: "themes", + slash: { name: "themes" }, run: () => { dialog.replace(() => ) }, @@ -871,7 +825,7 @@ function App(props: { { name: "help.show", title: "Help", - slashName: "help", + slash: { name: "help" }, run: () => { dialog.replace(() => ) }, @@ -889,8 +843,7 @@ function App(props: { { name: "app.exit", title: "Exit the app", - slashName: "exit", - slashAliases: ["quit", "q"], + slash: { name: "exit", aliases: ["quit", "q"] }, run: () => exit(), category: "System", }, @@ -932,7 +885,7 @@ function App(props: { run: () => { const next = !terminalTitleEnabled() if (!next) renderer.setTerminalTitle("") - void configState + void config .update((draft) => { draft.terminal = { ...draft.terminal, title: next } }) @@ -942,13 +895,13 @@ function App(props: { }, { name: "app.toggle.animations", - title: (config.animations ?? true) ? "Disable animations" : "Enable animations", + title: (config.data.animations ?? true) ? "Disable animations" : "Enable animations", category: "System", hidden: true, run: () => { - void configState + void config .update((draft) => { - draft.animations = !(config.animations ?? true) + draft.animations = !(config.data.animations ?? true) }) .catch(toast.error) dialog.clear() @@ -956,13 +909,13 @@ function App(props: { }, { name: "app.toggle.file_context", - title: (config.prompt?.editor ?? true) ? "Disable file context" : "Enable file context", + title: (config.data.prompt?.editor ?? true) ? "Disable file context" : "Enable file context", category: "System", hidden: true, run: () => { - void configState + void config .update((draft) => { - draft.prompt = { ...draft.prompt, editor: !(config.prompt?.editor ?? true) } + draft.prompt = { ...draft.prompt, editor: !(config.data.prompt?.editor ?? true) } }) .catch(toast.error) dialog.clear() @@ -970,13 +923,16 @@ function App(props: { }, { name: "app.toggle.diffwrap", - title: (config.diffs?.wrap ?? "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping", + title: (config.data.diffs?.wrap ?? "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping", category: "System", hidden: true, run: () => { - void configState + void config .update((draft) => { - draft.diffs = { ...draft.diffs, wrap: (config.diffs?.wrap ?? "word") === "word" ? "none" : "word" } + draft.diffs = { + ...draft.diffs, + wrap: (config.data.diffs?.wrap ?? "word") === "word" ? "none" : "word", + } }) .catch(toast.error) dialog.clear() @@ -988,7 +944,7 @@ function App(props: { category: "System", hidden: true, run: () => { - void configState + void config .update((draft) => { draft.prompt = { ...draft.prompt, paste: pasteSummaryEnabled() ? "full" : "compact" } }) @@ -1018,11 +974,11 @@ function App(props: { useBindings(() => ({ mode: OPENCODE_BASE_MODE, - bindings: config.keybinds.gather("app", appBindingCommands), + bindings: appBindingCommands.flatMap((command) => config.data.keybinds.get(command)), })) useBindings(() => ({ - bindings: config.keybinds.gather("app.global", appGlobalBindingCommands), + bindings: appGlobalBindingCommands.flatMap((command) => config.data.keybinds.get(command)), })) useBindings(() => ({ @@ -1032,7 +988,7 @@ function App(props: { if (!current?.focused) return true return current.current.text === "" }, - bindings: config.keybinds.gather("app_exit", ["app.exit"]), + bindings: config.data.keybinds.get("app.exit"), })) event.on("tui.command.execute", (evt, { workspace }) => { @@ -1087,14 +1043,6 @@ function App(props: { }) }) - const plugin = createMemo(() => { - if (!ready()) return - if (route.data.type !== "plugin") return - const render = pluginRuntime.routes.get(route.data.id) - if (!render) return route.navigate({ type: "home" })} /> - return render({ params: route.data.data }) - }) - // Suppress the full-screen overlay for transient startup and event-stream retry states. // Initial connection gets a longer grace period; retries surface more quickly. const [showReconnecting, setShowReconnecting] = createSignal(false) @@ -1144,7 +1092,7 @@ function App(props: { - + @@ -1155,16 +1103,22 @@ function App(props: { {(_) => } + + ( + route.navigate({ type: "home" })} /> + )} + /> + - {plugin()} - + - + - + diff --git a/packages/tui/src/component/dialog-config.tsx b/packages/tui/src/component/dialog-config.tsx index 7e964d3437..64df044591 100644 --- a/packages/tui/src/component/dialog-config.tsx +++ b/packages/tui/src/component/dialog-config.tsx @@ -281,16 +281,16 @@ export function DialogConfig() { footerHints={[{ title: "←/→", label: "change" }]} bindings={[ { - key: "left", - desc: "Previous value", + bind: "left", + title: "Previous value", group: "Settings", - cmd: () => void change(-1), + run: () => void change(-1), }, { - key: "right", - desc: "Next value", + bind: "right", + title: "Next value", group: "Settings", - cmd: () => void change(1), + run: () => void change(1), }, ]} /> diff --git a/packages/tui/src/component/dialog-debug.tsx b/packages/tui/src/component/dialog-debug.tsx index 21554aac95..2f88617f02 100644 --- a/packages/tui/src/component/dialog-debug.tsx +++ b/packages/tui/src/component/dialog-debug.tsx @@ -1,13 +1,13 @@ import { TextAttributes } from "@opentui/core" import { createMemo, createSignal, For } from "solid-js" import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version" +import { Keymap } from "../context/keymap" import { useTheme } from "../context/theme" import { useDialog } from "../ui/dialog" import { useRoute } from "../context/route" import { useLocal } from "../context/local" import { useClipboard } from "../context/clipboard" import { useToast } from "../ui/toast" -import { useBindings } from "../keymap" import { describeOS, describeTerminal } from "../util/system" export function DialogDebug() { @@ -46,8 +46,9 @@ export function DialogDebug() { .catch(toast.error) } - useBindings(() => ({ - bindings: [{ key: "return", desc: "Copy debug info", group: "Dialog", cmd: copy }], + Keymap.createLayer(() => ({ + mode: "modal", + commands: [{ bind: "return", title: "Copy debug info", group: "Dialog", run: copy }], })) return ( diff --git a/packages/tui/src/component/dialog-integration.tsx b/packages/tui/src/component/dialog-integration.tsx index de95f8c14c..58b18e16f4 100644 --- a/packages/tui/src/component/dialog-integration.tsx +++ b/packages/tui/src/component/dialog-integration.tsx @@ -9,8 +9,8 @@ import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js" import { useClipboard } from "../context/clipboard" import { useData } from "../context/data" import { useClient } from "../context/client" +import { Keymap } from "../context/keymap" import { useTheme } from "../context/theme" -import { useBindings } from "../keymap" import { useDialog } from "../ui/dialog" import { DialogPrompt } from "../ui/dialog-prompt" import { DialogSelect } from "../ui/dialog-select" @@ -278,13 +278,14 @@ function OAuthAuto(props: { let timer: ReturnType | undefined let settled = false - useBindings(() => ({ - bindings: [ + Keymap.createLayer(() => ({ + mode: "modal", + commands: [ { - key: "c", - desc: "Copy authorization details", + bind: "c", + title: "Copy authorization details", group: "Dialog", - cmd: () => { + run: () => { const value = props.attempt.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.attempt.url clipboard .write?.(value) diff --git a/packages/tui/src/component/dialog-mcp.tsx b/packages/tui/src/component/dialog-mcp.tsx index fc21b2b610..4f9a4d83d6 100644 --- a/packages/tui/src/component/dialog-mcp.tsx +++ b/packages/tui/src/component/dialog-mcp.tsx @@ -1,5 +1,6 @@ import { createEffect, createMemo, createSignal, onMount, Show } from "solid-js" import { useData } from "../context/data" +import { Keymap } from "../context/keymap" import { pipe, sortBy } from "remeda" import { DialogSelect } from "../ui/dialog-select" import { useDialog } from "../ui/dialog" @@ -11,7 +12,6 @@ import { useToast } from "../ui/toast" import { useKeyboard, useTerminalDimensions } from "@opentui/solid" import { useConfig } from "../config" import { getScrollAcceleration } from "../util/scroll" -import { useBindings } from "../keymap" // Sort by how much attention a server needs: auth prompts first, then failures, // then healthy servers, and intentionally-off servers last. @@ -134,8 +134,9 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) { .catch(toast.error) } - useBindings(() => ({ - bindings: [{ key: "escape", desc: "Back to MCP servers", group: "Dialog", cmd: props.onBack }], + Keymap.createLayer(() => ({ + mode: "modal", + commands: [{ bind: "escape", title: "Back to MCP servers", group: "Dialog", run: props.onBack }], })) useKeyboard((event) => { diff --git a/packages/tui/src/component/dialog-move-session.tsx b/packages/tui/src/component/dialog-move-session.tsx index 3f75fd7f2a..6ff94d690c 100644 --- a/packages/tui/src/component/dialog-move-session.tsx +++ b/packages/tui/src/component/dialog-move-session.tsx @@ -5,6 +5,7 @@ import path from "path" import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select" import { useDialog } from "../ui/dialog" import { useClient } from "../context/client" +import { Keymap } from "../context/keymap" import { useTheme } from "../context/theme" import { useData } from "../context/data" import { abbreviateHome } from "../runtime" @@ -13,7 +14,6 @@ import { Locale } from "../util/locale" import { errorMessage } from "../util/error" import { isRecord } from "../util/record" import { useToast } from "../ui/toast" -import { useCommandShortcut } from "../keymap" import { useProject } from "../context/project" import { Spinner } from "./spinner" import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes" @@ -45,12 +45,12 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { const route = useRoute() const toast = useToast() const paths = useTuiPaths() + const shortcuts = Keymap.useShortcuts() const [working, setWorking] = createSignal(Boolean(props.initialRemoving)) const [toDelete, setToDelete] = createSignal() const [removing, setRemoving] = createSignal(props.initialRemoving) const [replacementCurrent, setReplacementCurrent] = createSignal() const [loadError, setLoadError] = createSignal() - const deleteHint = useCommandShortcut("dialog.move_session.delete") onMount(() => dialog.setSize("xlarge")) function reopen(initialRemoving?: string) { @@ -175,7 +175,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { titleView: isRemoving ? ( Deleting {item.location} ) : deleting ? ( - Press {deleteHint()} again to confirm + Press {shortcuts.get("dialog.move_session.delete")} again to confirm ) : suffix ? ( <> {visible.slice(0, split)} diff --git a/packages/tui/src/component/dialog-project-copy-name.tsx b/packages/tui/src/component/dialog-project-copy-name.tsx index 3ae331da59..98bb711f7b 100644 --- a/packages/tui/src/component/dialog-project-copy-name.tsx +++ b/packages/tui/src/component/dialog-project-copy-name.tsx @@ -1,16 +1,14 @@ import { InputRenderable, TextAttributes } from "@opentui/core" import { Slug } from "@opencode-ai/core/util/slug" import { createSignal, onMount } from "solid-js" -import { useConfig } from "../config" +import { Keymap } from "../context/keymap" import { useTheme } from "../context/theme" -import { useBindings, useCommandShortcut } from "../keymap" import { useDialog, type DialogContext } from "../ui/dialog" export function DialogProjectCopyName(props: { onConfirm: (name: string) => void }) { const dialog = useDialog() const { theme } = useTheme() - const config = useConfig().data - const generateShortcut = useCommandShortcut("dialog.project_copy.generate") + const shortcuts = Keymap.useShortcuts() const [inputTarget, setInputTarget] = createSignal() let input: InputRenderable @@ -23,19 +21,19 @@ export function DialogProjectCopyName(props: { onConfirm: (name: string) => void props.onConfirm(slugify(input.value) || Slug.create()) } - useBindings(() => ({ + Keymap.createLayer(() => ({ + mode: "modal", target: inputTarget, enabled: inputTarget() !== undefined, priority: 1, commands: [ { - name: "dialog.project_copy.generate", + id: "dialog.project_copy.generate", title: "Generate project copy name", - category: "Dialog", + group: "Dialog", run: generate, }, ], - bindings: config.keybinds.get("dialog.project_copy.generate"), })) onMount(() => { @@ -73,7 +71,7 @@ export function DialogProjectCopyName(props: { onConfirm: (name: string) => void enter submit - {generateShortcut()} generate one + {shortcuts.get("dialog.project_copy.generate")} generate one @@ -82,7 +80,10 @@ export function DialogProjectCopyName(props: { onConfirm: (name: string) => void DialogProjectCopyName.show = (dialog: DialogContext) => new Promise((resolve) => { - dialog.replace(() => , () => resolve(null)) + dialog.replace( + () => , + () => resolve(null), + ) }) function slugify(input: string) { diff --git a/packages/tui/src/component/dialog-retry-action.tsx b/packages/tui/src/component/dialog-retry-action.tsx index b52a6e9b9f..25befc571a 100644 --- a/packages/tui/src/component/dialog-retry-action.tsx +++ b/packages/tui/src/component/dialog-retry-action.tsx @@ -1,11 +1,11 @@ import { RGBA, TextAttributes } from "@opentui/core" import open from "open" import { createSignal } from "solid-js" +import { Keymap } from "../context/keymap" import { selectedForeground, useTheme } from "../context/theme" import { useDialog, type DialogContext } from "../ui/dialog" import { Link } from "../ui/link" import { BgPulse } from "./bg-pulse" -import { useBindings } from "../keymap" const GO_URL = "https://opencode.ai/go" const PAD_X = 3 @@ -44,31 +44,32 @@ export function DialogRetryAction(props: DialogRetryActionProps) { const textBg = () => (showGoTreatment() ? panelOverlay(theme.backgroundPanel) : undefined) const [selected, setSelected] = createSignal<"dismiss" | "action">("action") - useBindings(() => ({ - bindings: [ + Keymap.createLayer(() => ({ + mode: "modal", + commands: [ { - key: "left", - desc: "Previous retry option", + bind: "left", + title: "Previous retry option", group: "Dialog", - cmd: () => setSelected((value) => (value === "action" ? "dismiss" : "action")), + run: () => setSelected((value) => (value === "action" ? "dismiss" : "action")), }, { - key: "right", - desc: "Next retry option", + bind: "right", + title: "Next retry option", group: "Dialog", - cmd: () => setSelected((value) => (value === "action" ? "dismiss" : "action")), + run: () => setSelected((value) => (value === "action" ? "dismiss" : "action")), }, { - key: "tab", - desc: "Next retry option", + bind: "tab", + title: "Next retry option", group: "Dialog", - cmd: () => setSelected((value) => (value === "action" ? "dismiss" : "action")), + run: () => setSelected((value) => (value === "action" ? "dismiss" : "action")), }, { - key: "return", - desc: "Confirm retry option", + bind: "return", + title: "Confirm retry option", group: "Dialog", - cmd: () => { + run: () => { if (selected() === "action") runAction(props, dialog) else dismiss(props, dialog) }, diff --git a/packages/tui/src/component/dialog-session-delete-failed.tsx b/packages/tui/src/component/dialog-session-delete-failed.tsx index f3617a5347..8754fd03db 100644 --- a/packages/tui/src/component/dialog-session-delete-failed.tsx +++ b/packages/tui/src/component/dialog-session-delete-failed.tsx @@ -1,9 +1,9 @@ import { TextAttributes } from "@opentui/core" +import { Keymap } from "../context/keymap" import { useTheme } from "../context/theme" import { useDialog } from "../ui/dialog" import { createStore } from "solid-js/store" import { For } from "solid-js" -import { useBindings } from "../keymap" export function DialogSessionDeleteFailed(props: { session: string @@ -40,13 +40,24 @@ export function DialogSessionDeleteFailed(props: { if (!props.onDone) dialog.clear() } - useBindings(() => ({ - bindings: [ - { key: "return", desc: "Confirm recovery option", group: "Dialog", cmd: () => void confirm() }, - { key: "left", desc: "Delete broken session", group: "Dialog", cmd: () => setStore("active", "delete") }, - { key: "up", desc: "Delete broken session", group: "Dialog", cmd: () => setStore("active", "delete") }, - { key: "right", desc: "Restore broken session", group: "Dialog", cmd: () => setStore("active", "restore") }, - { key: "down", desc: "Restore broken session", group: "Dialog", cmd: () => setStore("active", "restore") }, + Keymap.createLayer(() => ({ + mode: "modal", + commands: [ + { bind: "return", title: "Confirm recovery option", group: "Dialog", run: () => void confirm() }, + { bind: "left", title: "Delete broken session", group: "Dialog", run: () => setStore("active", "delete") }, + { bind: "up", title: "Delete broken session", group: "Dialog", run: () => setStore("active", "delete") }, + { + bind: "right", + title: "Restore broken session", + group: "Dialog", + run: () => setStore("active", "restore"), + }, + { + bind: "down", + title: "Restore broken session", + group: "Dialog", + run: () => setStore("active", "restore"), + }, ], })) diff --git a/packages/tui/src/component/dialog-session-list.tsx b/packages/tui/src/component/dialog-session-list.tsx index d9e6067b01..c7eb69ce37 100644 --- a/packages/tui/src/component/dialog-session-list.tsx +++ b/packages/tui/src/component/dialog-session-list.tsx @@ -5,6 +5,7 @@ import { useDialog } from "../ui/dialog" import { DialogSelect } from "../ui/dialog-select" import { useRoute } from "../context/route" import { useData } from "../context/data" +import { Keymap } from "../context/keymap" import { Locale } from "../util/locale" import { useProject } from "../context/project" import { useTheme } from "../context/theme" @@ -12,7 +13,6 @@ import { useClient } from "../context/client" import { useLocal } from "../context/local" import { createDebouncedSignal } from "../util/signal" import { useToast } from "../ui/toast" -import { useCommandShortcut } from "../keymap" import { DialogSessionRename } from "./dialog-session-rename" import { Spinner } from "./spinner" import { errorMessage } from "../util/error" @@ -27,11 +27,9 @@ export function DialogSessionList() { const local = useLocal() const toast = useToast() const [filter, setFilter] = createSignal("") + const shortcuts = Keymap.useShortcuts() const [search, setSearch] = createDebouncedSignal("", 150) const [toDelete, setToDelete] = createSignal() - const quickSwitch1 = useCommandShortcut("session.quick_switch.1") - const quickSwitch9 = useCommandShortcut("session.quick_switch.9") - const deleteHint = useCommandShortcut("session.delete") const [searchResults] = createResource(search, async (query) => { if (!query) return @@ -80,8 +78,8 @@ export function DialogSessionList() { }) const quickSwitchHint = createMemo(() => { - const first = quickSwitch1() - const last = quickSwitch9() + const first = shortcuts.get("session.quick_switch.1") + const last = shortcuts.get("session.quick_switch.9") if (!first || !last) return return quickSwitchRange(first, last) }) @@ -107,7 +105,7 @@ export function DialogSessionList() { const slot = slotByID.get(session.id) const deleting = toDelete() === session.id return { - title: deleting ? `Press ${deleteHint()} again to confirm` : session.title, + title: deleting ? `Press ${shortcuts.get("session.delete")} again to confirm` : session.title, value: session.id, category, footer, diff --git a/packages/tui/src/component/dialog-stash.tsx b/packages/tui/src/component/dialog-stash.tsx index b08886f79f..cefe315ee3 100644 --- a/packages/tui/src/component/dialog-stash.tsx +++ b/packages/tui/src/component/dialog-stash.tsx @@ -2,9 +2,9 @@ import { useDialog } from "../ui/dialog" import { DialogSelect } from "../ui/dialog-select" import { createMemo, createSignal } from "solid-js" import { Locale } from "../util/locale" +import { Keymap } from "../context/keymap" import { useTheme } from "../context/theme" import { usePromptStash, type StashEntry } from "./prompt/stash" -import { useCommandShortcut } from "../keymap" function getRelativeTime(timestamp: number): string { const now = Date.now() @@ -30,9 +30,9 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) { const dialog = useDialog() const stash = usePromptStash() const { theme } = useTheme() + const shortcuts = Keymap.useShortcuts() const [toDelete, setToDelete] = createSignal() - const deleteHint = useCommandShortcut("stash.delete") const options = createMemo(() => { const entries = stash.list() @@ -42,7 +42,9 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) { const isDeleting = toDelete() === index const lineCount = (entry.prompt.text.match(/\n/g)?.length ?? 0) + 1 return { - title: isDeleting ? `Press ${deleteHint()} again to confirm` : getStashPreview(entry.prompt.text), + title: isDeleting + ? `Press ${shortcuts.get("stash.delete")} again to confirm` + : getStashPreview(entry.prompt.text), bg: isDeleting ? theme.error : undefined, value: index, description: getRelativeTime(entry.timestamp), diff --git a/packages/tui/src/component/plugin-route-missing.tsx b/packages/tui/src/component/plugin-route-missing.tsx index 77e2ea8dd3..98fb3efb3b 100644 --- a/packages/tui/src/component/plugin-route-missing.tsx +++ b/packages/tui/src/component/plugin-route-missing.tsx @@ -1,11 +1,13 @@ import { useTheme } from "../context/theme" -export function PluginRouteMissing(props: { id: string; onHome: () => void }) { +export function PluginRouteMissing(props: { id: string; name: string; onHome: () => void }) { const { theme } = useTheme() return ( - Unknown plugin route: {props.id} + + Unknown plugin route: {props.id}/{props.name} + go home diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index cd67084115..5b14926896 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -19,7 +19,8 @@ import { useTerminalDimensions } from "@opentui/solid" import { Locale } from "../../util/locale" import type { PromptInfo, PromptPartRef } from "../../prompt/history" import { useFrecency } from "../../prompt/frecency" -import { useBindings, useCommandSlashes, useOpencodeModeStack } from "../../keymap" +import { useBindings, useCommandSlashes } from "../../keymap" +import { Keymap } from "../../context/keymap" import { displayCharAt, mentionTriggerIndex } from "../../prompt/display" import type { FileSystemEntry } from "@opencode-ai/client" @@ -88,7 +89,7 @@ export function Autocomplete(props: { const data = useData() const project = useProject() const slashes = useCommandSlashes() - const modeStack = useOpencodeModeStack() + const keymap = Keymap.use() const { theme } = useTheme() const dimensions = useTerminalDimensions() const frecency = useFrecency() @@ -106,7 +107,7 @@ export function Autocomplete(props: { createEffect(() => { if (!store.visible) return - const popMode = modeStack.push("autocomplete") + const popMode = keymap.mode.push("autocomplete") onCleanup(popMode) }) @@ -627,13 +628,13 @@ export function Autocomplete(props: { }, }, ], - bindings: config.keybinds.gather("prompt.autocomplete", [ + bindings: [ "prompt.autocomplete.prev", "prompt.autocomplete.next", "prompt.autocomplete.hide", "prompt.autocomplete.select", "prompt.autocomplete.complete", - ]), + ].flatMap((command) => config.keybinds.get(command)), })) function show(mode: "@" | "/") { diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 94ecbb4654..7d8dec1379 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -450,7 +450,7 @@ export function Prompt(props: PromptProps) { title: "Open editor", category: "Session", name: "prompt.editor", - slashName: "editor", + slash: { name: "editor" }, run: async () => { dialog.clear() @@ -498,7 +498,7 @@ export function Prompt(props: PromptProps) { title: "Skills", name: "prompt.skills", category: "Prompt", - slashName: "skills", + slash: { name: "skills" }, run: () => { dialog.replace(() => ( { move.open() }, @@ -537,7 +537,7 @@ export function Prompt(props: PromptProps) { useBindings(() => ({ mode: OPENCODE_BASE_MODE, - bindings: config.keybinds.gather("prompt.palette", [ + bindings: [ "prompt.submit", "prompt.editor", "prompt.editor_context.clear", @@ -548,7 +548,7 @@ export function Prompt(props: PromptProps) { "session.interrupt", "session.background", "session.move", - ]), + ].flatMap((command) => config.keybinds.get(command)), })) const ref: PromptRef = { @@ -1188,10 +1188,7 @@ export function Prompt(props: PromptProps) { } const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1 - if ( - (lineCount >= 3 || pastedContent.length > 150) && - config.prompt?.paste !== "full" - ) { + if ((lineCount >= 3 || pastedContent.length > 150) && config.prompt?.paste !== "full") { pasteText(pastedContent, `[Pasted ~${lineCount} lines]`) return } @@ -1298,10 +1295,7 @@ export function Prompt(props: PromptProps) { }) const spinnerDef = createMemo(() => { - const agent = - status() === "running" - ? local.agent.current() - : local.agent.current() + const agent = status() === "running" ? local.agent.current() : local.agent.current() const color = agent ? local.agent.color(agent.id) : theme.border return { frames: createFrames({ diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx index ea75ef0699..7025effc16 100644 --- a/packages/tui/src/config/index.tsx +++ b/packages/tui/src/config/index.tsx @@ -7,6 +7,7 @@ import { createStore, reconcile } from "solid-js/store" import { TuiKeybind } from "./keybind" export interface Interface { + readonly path?: string readonly get: () => Promise readonly update: (update: (draft: any) => void) => Promise } @@ -71,12 +72,9 @@ export const Info = Schema.Struct({ Schema.Number.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(1)), ).annotate({ description: "Attention sound volume from 0 to 1" }), sound_pack: Schema.optional(Schema.String).annotate({ description: "Active attention sound pack ID" }), - sounds: Schema.optional( - Schema.Record( - AttentionSoundName, - Schema.optionalKey(Schema.String), - ), - ).annotate({ description: "Sound file overrides by attention event" }), + sounds: Schema.optional(Schema.Record(AttentionSoundName, Schema.optionalKey(Schema.String))).annotate({ + description: "Sound file overrides by attention event", + }), }), ).annotate({ description: "System notification and sound settings" }), diffs: Schema.optional( @@ -181,6 +179,7 @@ export function resolve(input: Info, options: { terminalSuspend: boolean }): Res const ConfigContext = createContext<{ data: Resolved + path?: string update: Interface["update"] }>() @@ -199,7 +198,7 @@ export function ConfigProvider(props: { return info } return ( - {props.children} + {props.children} ) } diff --git a/packages/tui/src/config/v1/keybind.ts b/packages/tui/src/config/v1/keybind.ts index 18c68a2ba4..df00187838 100644 --- a/packages/tui/src/config/v1/keybind.ts +++ b/packages/tui/src/config/v1/keybind.ts @@ -417,9 +417,6 @@ export type BindingLookupView = { readonly bindings: readonly Binding[] get(command: string): readonly Binding[] has(command: string): boolean - gather(name: string, commands: readonly string[]): readonly Binding[] - pick(name: string, commands: readonly string[]): Binding[] - omit(name: string, commands: readonly string[]): Binding[] } export function toBindingConfig(keybinds: Keybinds): BindingConfig { diff --git a/packages/tui/src/context/keymap.tsx b/packages/tui/src/context/keymap.tsx new file mode 100644 index 0000000000..4f6de9d911 --- /dev/null +++ b/packages/tui/src/context/keymap.tsx @@ -0,0 +1,368 @@ +import { InputRenderable, TextareaRenderable, type Renderable } from "@opentui/core" +import { stringifyKeyStroke } from "@opentui/keymap" +import { + registerBackspacePopsPendingSequence, + registerBaseLayoutFallback, + registerCommaBindings, + registerEscapeClearsPendingSequence, + registerManagedTextareaLayer, + registerTimedLeader, +} from "@opentui/keymap/addons/opentui" +import { formatKeySequence } from "@opentui/keymap/extras" +import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" +import { KeymapProvider, useBindings, useKeymapSelector } from "@opentui/keymap/solid" +import { useRenderer } from "@opentui/solid" +import { createContext, onCleanup, useContext, type Accessor, type ParentProps } from "solid-js" +import { useConfig } from "../config" +import { TuiKeybind } from "../config/keybind" + +declare module "@opentui/keymap" { + interface Command { + slash?: { + name: string + aliases?: string[] + } + } +} + +const MODE = { key: "opencode.mode", base: "base" } as const + +type OpenTuiKeymap = Parameters[0]["keymap"] +type Mode = ReturnType + +const Context = createContext<{ readonly keymap: OpenTuiKeymap; readonly mode: Mode }>() + +function Provider(props: ParentProps) { + const renderer = useRenderer() + const config = useConfig() + const keymap = createDefaultOpenTuiKeymap(renderer) + const mode = createMode(keymap) + const dispose = [ + registerCommaBindings(keymap), + keymap.appendBindingExpander((context) => { + const key = Object.entries({ enter: "return", esc: "escape", pgdown: "pagedown", pgup: "pageup" }).reduce( + (result, [alias, value]) => + result.replace(new RegExp(`(^|[+,\\s>])${alias}(?=$|[+,\\s<])`, "gi"), `$1${value}`), + context.input, + ) + if (key === context.input) return + return [{ key, displays: context.displays }] + }), + registerBaseLayoutFallback(keymap), + registerEscapeClearsPendingSequence(keymap), + registerBackspacePopsPendingSequence(keymap), + registerManagedTextareaLayer(keymap, renderer, { + enabled: () => { + const editor = renderer.currentFocusedEditor + return editor instanceof TextareaRenderable && !(editor instanceof InputRenderable) + }, + bindings: [ + "input.move.left", + "input.move.right", + "input.move.up", + "input.move.down", + "input.select.left", + "input.select.right", + "input.select.up", + "input.select.down", + "input.line.home", + "input.line.end", + "input.select.line.home", + "input.select.line.end", + "input.visual.line.home", + "input.visual.line.end", + "input.select.visual.line.home", + "input.select.visual.line.end", + "input.buffer.home", + "input.buffer.end", + "input.select.buffer.home", + "input.select.buffer.end", + "input.delete.line", + "input.delete.to.line.end", + "input.delete.to.line.start", + "input.backspace", + "input.delete", + "input.newline", + "input.undo", + "input.redo", + "input.word.forward", + "input.word.backward", + "input.select.word.forward", + "input.select.word.backward", + "input.delete.word.forward", + "input.delete.word.backward", + "input.select.all", + "input.submit", + ].flatMap((command) => config.data.keybinds.get(command)), + }), + ] + const leader = config.data.keybinds.get("leader")?.[0]?.key + if (leader) { + dispose.push( + registerTimedLeader(keymap, { + trigger: leader, + name: "leader", + timeoutMs: config.data.leader.timeout, + }), + ) + } + onCleanup(() => { + dispose.reverse().forEach((item) => item()) + mode.dispose() + }) + return ( + + {props.children} + + ) +} + +export interface KeymapCommand { + /** Stable command and config keybind identifier. Omit for an inline command. */ + readonly id?: string + /** Optional label used by command discovery and keyboard-help UI. */ + readonly title?: string + /** Optional longer description. */ + readonly description?: string + /** Groups the command in discovery and keyboard-help UI. */ + readonly group?: string + /** Enables or disables the command. */ + readonly enabled?: boolean | (() => boolean) + /** Configures automatic binding, or disables it for a named command. */ + readonly bind?: false | string + /** Adds a named command to the command palette. */ + readonly palette?: true + /** Adds a named command to prompt slash completion. */ + readonly slash?: { + readonly name: string + readonly aliases?: string[] + } + /** Executes the command. Return false to let keymap dispatch continue. */ + readonly run: () => void | false | Promise +} + +export interface KeymapLayer { + /** Limits the layer to one OpenCode input mode. Use global to opt out; defaults to base. */ + readonly mode?: string + /** Enables or disables the complete layer. */ + readonly enabled?: boolean | (() => boolean) + /** Limits the layer to a focused renderable. */ + readonly target?: () => Renderable | null | undefined + /** Resolves conflicts with other active layers. */ + readonly priority?: number + /** Commands owned by this layer. */ + readonly commands?: readonly KeymapCommand[] + /** IDs of commands whose configured bindings should be active in this layer. */ + readonly bindings?: readonly string[] +} + +export interface Keymap { + /** Dispatches a reachable command by ID. */ + dispatch(id: string): void + /** Controls mutually exclusive OpenCode input modes. */ + readonly mode: { + /** Returns the active mode. */ + current(): string + /** Pushes a mode until the returned cleanup is called. */ + push(mode: string): () => void + } +} + +function use(): Keymap { + const value = useValue() + return { + dispatch(id) { + value.keymap.dispatchCommand(id) + }, + mode: value.mode, + } +} + +function createLayer(input: () => KeymapLayer) { + useValue() + const config = useConfig() + useBindings(() => { + const layer = input() + const { commands, bindings, mode, ...options } = layer + const grouped = (commands ?? []).reduce( + (result, command) => { + if (command.id !== undefined) { + if (!command.id) throw new Error("Keymap command IDs cannot be empty") + if (typeof command.bind === "string" && !command.bind) + throw new Error("Keymap command bindings cannot be empty") + result.named.push({ ...command, id: command.id }) + return result + } + if (command.palette) throw new Error("Palette commands require an ID") + if (command.slash) throw new Error("Slash commands require an ID") + if (typeof command.bind !== "string") throw new Error("Inline keymap commands require bind") + if (!command.bind) throw new Error("Keymap command bindings cannot be empty") + result.inline.push({ ...command, id: undefined, bind: command.bind }) + return result + }, + { + named: [] as Array, + inline: [] as Array, + }, + ) + return { + ...options, + ...(mode === "global" ? {} : { mode: mode ?? MODE.base }), + commands: grouped.named.map((command) => { + const { id, description, group, palette, bind, ...definition } = command + return { + ...definition, + name: id, + ...(description === undefined ? {} : { desc: description }), + ...(group === undefined ? {} : { category: group }), + ...(palette === undefined ? {} : { namespace: "palette" }), + } + }), + bindings: [ + ...grouped.inline.map((command) => ({ + key: command.bind, + cmd: () => { + if (command.enabled === false) return false + if (typeof command.enabled === "function" && !command.enabled()) return false + return command.run() + }, + ...(command.title === undefined && command.description === undefined + ? {} + : { desc: command.title ?? command.description }), + ...(command.group === undefined ? {} : { group: command.group }), + })), + ...grouped.named.flatMap((command) => { + if (command.bind === false) return [] + const configured = config.data.keybinds.get(command.id) + if (configured.length) return configured + if (typeof command.bind !== "string") return [] + return [{ key: command.bind, cmd: command.id }] + }), + ...(bindings ?? []).flatMap((id) => config.data.keybinds.get(id)), + ], + } + }) +} + +function useShortcuts() { + useValue() + const config = useConfig() + const shortcuts = useKeymapSelector((keymap) => { + const commands = keymap.getCommands({ visibility: "registered" }).map((command) => command.name) + const bindings = keymap.getCommandBindings({ visibility: "registered", commands }) + return new Map( + commands.map((id) => [id, formatKeySequence(bindings.get(id)?.[0]?.sequence, formatOptions(config.data))]), + ) + }) + return { + get(id: string) { + return shortcuts().get(id) + }, + } +} + +function useCommands(): Accessor { + const value = useValue() + return useKeymapSelector((keymap) => + keymap + .getCommandEntries({ + visibility: "reachable", + }) + .map((entry) => ({ + id: entry.command.name, + title: typeof entry.command.title === "string" ? entry.command.title : entry.command.name, + description: typeof entry.command.desc === "string" ? entry.command.desc : undefined, + group: typeof entry.command.category === "string" ? entry.command.category : undefined, + palette: entry.command.namespace === "palette" ? true : undefined, + slash: entry.command.slash, + run: () => { + value.keymap.dispatchCommand(entry.command.name) + }, + })), + ) +} + +function usePendingSequence() { + useValue() + return useKeymapSelector((keymap) => keymap.getPendingSequence()) +} + +function useActiveKeys() { + useValue() + return useKeymapSelector((keymap) => keymap.getActiveKeys({ includeMetadata: true })) +} + +function useValue() { + const value = useContext(Context) + if (!value) throw new Error("Keymap.Provider is missing") + return value +} + +export const Keymap = { + Provider, + use, + createLayer, + useShortcuts, + useCommands, + usePendingSequence, + useActiveKeys, +} as const + +function createMode(keymap: OpenTuiKeymap) { + keymap.setData(MODE.key, MODE.base) + const unregister = keymap.registerLayerFields({ + mode(value, context) { + context.require(MODE.key, value) + }, + }) + const stack: { readonly id: symbol; readonly mode: string }[] = [] + let disposed = false + + const update = () => keymap.setData(MODE.key, stack.at(-1)?.mode ?? MODE.base) + + return { + current() { + return stack.at(-1)?.mode ?? MODE.base + }, + push(mode: string) { + if (disposed) return () => {} + const id = Symbol(mode) + stack.push({ id, mode }) + update() + return () => { + const index = stack.findIndex((item) => item.id === id) + if (index < 0) return + stack.splice(index, 1) + update() + } + }, + dispose() { + if (disposed) return + disposed = true + stack.length = 0 + unregister() + keymap.setData(MODE.key, undefined) + }, + } +} + +function formatOptions(config: ReturnType["data"]) { + const leader = config.keybinds.get("leader")?.[0]?.key + return { + tokenDisplay: { + leader: leader ? (typeof leader === "string" ? leader : stringifyKeyStroke(leader)) : TuiKeybind.LeaderDefault, + }, + keyNameAliases: { + up: "↑", + down: "↓", + left: "←", + right: "→", + pageup: "pgup", + pagedown: "pgdn", + delete: "del", + }, + modifierAliases: { + meta: "alt", + }, + } as const +} diff --git a/packages/tui/src/context/route.tsx b/packages/tui/src/context/route.tsx index 7355fe54d7..fa8a5a39c1 100644 --- a/packages/tui/src/context/route.tsx +++ b/packages/tui/src/context/route.tsx @@ -17,6 +17,7 @@ export type SessionRoute = { export type PluginRoute = { type: "plugin" id: string + name: string data?: Record } @@ -47,8 +48,14 @@ function initialRoute(value: unknown): Route | undefined { if (value.type === "session" && "sessionID" in value && typeof value.sessionID === "string") { return { type: "session", sessionID: value.sessionID } } - if (value.type === "plugin" && "id" in value && typeof value.id === "string") { - return { type: "plugin", id: value.id } + if ( + value.type === "plugin" && + "id" in value && + typeof value.id === "string" && + "name" in value && + typeof value.name === "string" + ) { + return { type: "plugin", id: value.id, name: value.name } } } diff --git a/packages/tui/src/context/runtime.tsx b/packages/tui/src/context/runtime.tsx index 281049fe57..ced1cf1a44 100644 --- a/packages/tui/src/context/runtime.tsx +++ b/packages/tui/src/context/runtime.tsx @@ -18,9 +18,14 @@ export type TuiStartup = Readonly<{ skipInitialLoading: boolean }> +export type TuiLifecycle = Readonly<{ + add(finalizer: () => Promise): () => void +}> + const PathsContext = createContext() const TerminalEnvironmentContext = createContext() const StartupContext = createContext() +const LifecycleContext = createContext() function provider(context: ReturnType>, value: T, children: () => JSX.Element) { return createComponent(context.Provider, { @@ -43,6 +48,10 @@ export function TuiStartupProvider(props: { value: TuiStartup; children: JSX.Ele return provider(StartupContext, props.value, () => props.children) } +export function TuiLifecycleProvider(props: { value: TuiLifecycle; children: JSX.Element }) { + return provider(LifecycleContext, props.value, () => props.children) +} + function required(context: ReturnType>, name: string) { const value = useContext(context) if (!value) throw new Error(`${name} is missing`) @@ -60,3 +69,7 @@ export function useTuiTerminalEnvironment() { export function useTuiStartup() { return required(StartupContext, "TuiStartupProvider") } + +export function useTuiLifecycle() { + return required(LifecycleContext, "TuiLifecycleProvider") +} diff --git a/packages/tui/src/feature-plugins/builtins.ts b/packages/tui/src/feature-plugins/builtins.ts index 9684b71334..8f4ffa50fd 100644 --- a/packages/tui/src/feature-plugins/builtins.ts +++ b/packages/tui/src/feature-plugins/builtins.ts @@ -1,16 +1,9 @@ import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui" import type { PluginRuntime } from "../plugin/runtime" -import HomeFooter from "./home/footer" -import HomeTips from "./home/tips" -import SidebarContext from "./sidebar/context" -import SidebarFooter from "./sidebar/footer" -import SidebarLsp from "./sidebar/lsp" -import SidebarMcp from "./sidebar/mcp" import DiffViewer from "./system/diff-viewer" import Notifications from "./system/notifications" import PluginManager from "./system/plugins" import WhichKey from "./system/which-key" -import Scrap from "./system/scrap" export type BuiltinTuiPlugin = Omit & { id: string @@ -19,25 +12,10 @@ export type BuiltinTuiPlugin = Omit & { } export function createBuiltinPlugins(): BuiltinTuiPlugin[] { - return [ - HomeFooter, - HomeTips, - SidebarContext, - SidebarMcp, - SidebarLsp, - SidebarFooter, - Notifications, - PluginManager, - WhichKey, - Scrap, - DiffViewer, - ] + return [Notifications, PluginManager, WhichKey, DiffViewer] } -export async function loadBuiltinPlugins( - api: TuiPluginApi, - runtime: PluginRuntime, -) { +export async function loadBuiltinPlugins(api: TuiPluginApi, runtime: PluginRuntime) { const slots = runtime.setupSlots(api) const dispose: Array<() => void> = [] diff --git a/packages/tui/src/feature-plugins/home/footer.tsx b/packages/tui/src/feature-plugins/home/footer.tsx index af1277b5c2..6956ad8678 100644 --- a/packages/tui/src/feature-plugins/home/footer.tsx +++ b/packages/tui/src/feature-plugins/home/footer.tsx @@ -1,98 +1,66 @@ -import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" -import type { BuiltinTuiPlugin } from "../builtins" +import { Plugin } from "@opencode-ai/plugin/v2/tui" +import { InstallationVersion } from "@opencode-ai/core/installation/version" import { createMemo, Match, Show, Switch } from "solid-js" -import { abbreviateHome } from "../../runtime" -import { useTuiPaths } from "../../context/runtime" -import { useHomeSessionDestination } from "../../routes/home/session-destination" -import { FilePath } from "../../ui/file-path" import { useTerminalDimensions } from "@opentui/solid" +import { useTuiPaths } from "../../context/runtime" +import { useTheme } from "../../context/theme" +import { useHomeSessionDestination } from "../../routes/home/session-destination" +import { abbreviateHome } from "../../runtime" +import { FilePath } from "../../ui/file-path" -const id = "internal:home-footer" - -function Directory(props: { api: TuiPluginApi; maxWidth: number }) { - const theme = () => props.api.theme.current +function Directory(props: { context: Plugin.Context; maxWidth: number }) { + const { theme } = useTheme() const destination = useHomeSessionDestination() const paths = useTuiPaths() - const dir = createMemo(() => { + const directory = createMemo(() => { const selected = destination?.destination() if (!selected || selected.type === "new") return - const branch = - selected.directory === (props.api.state.path.directory || paths.cwd) ? props.api.state.vcs?.branch : undefined - return { path: abbreviateHome(selected.directory, paths.home), branch } + return abbreviateHome(selected.directory || props.context.data.location.default().directory, paths.home) }) return ( - - {(value) => { - const suffix = () => (value().branch ? `:${value().branch}` : "") - const suffixWidth = () => Math.min(Bun.stringWidth(suffix()), Math.max(0, props.maxWidth - 2)) - return ( - - - - - {suffix()} - - - - ) - }} + + {(value) => } ) } -function Mcp(props: { api: TuiPluginApi }) { - const theme = () => props.api.theme.current - const list = createMemo(() => props.api.state.mcp()) - const has = createMemo(() => list().length > 0) - const err = createMemo(() => list().some((item) => item.status === "failed")) - const count = createMemo(() => list().filter((item) => item.status === "connected").length) +function Mcp(props: { context: Plugin.Context }) { + const { theme } = useTheme() + const list = createMemo(() => props.context.data.location.mcp.server.list() ?? []) + const failed = createMemo(() => list().some((item) => item.status.status === "failed")) + const count = createMemo(() => list().filter((item) => item.status.status === "connected").length) return ( - + - + - - + + - 0 ? theme().success : theme().textMuted }}>⊙ + 0 ? theme.success : theme.textMuted }}>⊙ {count()} MCP - /status + /status ) } -function Version(props: { api: TuiPluginApi }) { - const theme = () => props.api.theme.current - - return ( - - {props.api.app.version} - - ) -} - -function View(props: { api: TuiPluginApi }) { +function View(props: { context: Plugin.Context }) { + const { theme } = useTheme() const dimensions = useTerminalDimensions() const mcpWidth = createMemo(() => { - const list = props.api.state.mcp() + const list = props.context.data.location.mcp.server.list() ?? [] if (list.length === 0) return 0 - const count = list.filter((item) => item.status === "connected").length + const count = list.filter((item) => item.status.status === "connected").length return Bun.stringWidth(`⊙ ${count} MCP /status`) + 2 }) - const directoryWidth = createMemo(() => - Math.max(2, dimensions().width - 8 - Bun.stringWidth(props.api.app.version) - mcpWidth()), - ) + return ( - - + + - + + {InstallationVersion} + ) } -const tui: TuiPlugin = async (api) => { - api.slots.register({ - order: 100, - slots: { - home_footer() { - return - }, - }, - }) -} - -const plugin: BuiltinTuiPlugin = { - id, - tui, -} - -export default plugin +export default Plugin.define({ + id: "opencode.home-footer", + setup(context) { + context.ui.slot("home.footer", () => ) + }, +}) diff --git a/packages/tui/src/feature-plugins/home/tips-view.tsx b/packages/tui/src/feature-plugins/home/tips-view.tsx index 92623cde29..8c65dbd603 100644 --- a/packages/tui/src/feature-plugins/home/tips-view.tsx +++ b/packages/tui/src/feature-plugins/home/tips-view.tsx @@ -1,12 +1,11 @@ -import type { TuiPluginApi } from "@opencode-ai/plugin/tui" import { createMemo, For, type Accessor } from "solid-js" import { DEFAULT_THEMES, useTheme } from "../../context/theme" -import { useCommandShortcut } from "../../keymap" +import { Keymap } from "../../context/keymap" const themeCount = Object.keys(DEFAULT_THEMES).length type TipPart = { text: string; highlight: boolean } -type TipShortcut = Accessor +type TipShortcut = Accessor type Shortcuts = { agentCycle: TipShortcut childFirst: TipShortcut @@ -74,61 +73,54 @@ function shortcutText(value: string) { return `{highlight}${value}{/highlight}` } -function commandText(command: string, shortcut: string) { +function commandText(command: string, shortcut: string | undefined) { if (!shortcut) return shortcutText(command) return `${shortcutText(command)} or ${shortcutText(shortcut)}` } -function press(shortcut: string, text: string) { +function press(shortcut: string | undefined, text: string) { if (!shortcut) return undefined return `Press ${shortcutText(shortcut)} ${text}` } -function configShortcut(api: TuiPluginApi, command: string): TipShortcut { - return () => - api.tuiConfig.keybinds - .get(command) - .map((binding) => api.keys.formatSequence(Array.from(api.keymap.parseKeySequence(binding.key)))) - .filter(Boolean) - .join(", ") -} - -export function Tips(props: { api: TuiPluginApi; connected?: boolean }) { +export function Tips(props: { connected?: boolean }) { const theme = useTheme().theme + const keymap = Keymap.useShortcuts() const tipOffset = Math.random() + const shortcut = (id: string) => () => keymap.get(id) const shortcuts: Shortcuts = { - agentCycle: useCommandShortcut("agent.cycle"), - childFirst: configShortcut(props.api, "session.child.first"), - childNext: configShortcut(props.api, "session.child.next"), - childPrevious: configShortcut(props.api, "session.child.previous"), - commandList: useCommandShortcut("command.palette.show"), - editorOpen: useCommandShortcut("prompt.editor"), - helpShow: useCommandShortcut("help.show"), - inputClear: useCommandShortcut("prompt.clear"), - inputNewline: useCommandShortcut("input.newline"), - inputPaste: useCommandShortcut("prompt.paste"), - inputUndo: useCommandShortcut("input.undo"), - leader: configShortcut(props.api, "leader"), - messagesCopy: configShortcut(props.api, "messages.copy"), - messagesFirst: configShortcut(props.api, "session.first"), - messagesLast: configShortcut(props.api, "session.last"), - messagesPageDown: configShortcut(props.api, "session.page.down"), - messagesPageUp: configShortcut(props.api, "session.page.up"), - modelCycleRecent: useCommandShortcut("model.cycle_recent"), - modelList: useCommandShortcut("model.list"), - sessionExport: configShortcut(props.api, "session.export"), - sessionInterrupt: configShortcut(props.api, "session.interrupt"), - sessionList: useCommandShortcut("session.list"), - sessionNew: useCommandShortcut("session.new"), - sessionParent: configShortcut(props.api, "session.parent"), - sessionPinToggle: configShortcut(props.api, "session.pin.toggle"), - sessionQuickSwitch1: useCommandShortcut("session.quick_switch.1"), - sessionQuickSwitch9: useCommandShortcut("session.quick_switch.9"), - sessionSidebarToggle: configShortcut(props.api, "session.sidebar.toggle"), - sessionTimeline: configShortcut(props.api, "session.timeline"), - statusView: useCommandShortcut("opencode.status"), - terminalSuspend: useCommandShortcut("terminal.suspend"), - themeList: useCommandShortcut("theme.switch"), + agentCycle: shortcut("agent.cycle"), + childFirst: shortcut("session.child.first"), + childNext: shortcut("session.child.next"), + childPrevious: shortcut("session.child.previous"), + commandList: shortcut("command.palette.show"), + editorOpen: shortcut("prompt.editor"), + helpShow: shortcut("help.show"), + inputClear: shortcut("prompt.clear"), + inputNewline: shortcut("input.newline"), + inputPaste: shortcut("prompt.paste"), + inputUndo: shortcut("input.undo"), + leader: shortcut("leader"), + messagesCopy: shortcut("messages.copy"), + messagesFirst: shortcut("session.first"), + messagesLast: shortcut("session.last"), + messagesPageDown: shortcut("session.page.down"), + messagesPageUp: shortcut("session.page.up"), + modelCycleRecent: shortcut("model.cycle_recent"), + modelList: shortcut("model.list"), + sessionExport: shortcut("session.export"), + sessionInterrupt: shortcut("session.interrupt"), + sessionList: shortcut("session.list"), + sessionNew: shortcut("session.new"), + sessionParent: shortcut("session.parent"), + sessionPinToggle: shortcut("session.pin.toggle"), + sessionQuickSwitch1: shortcut("session.quick_switch.1"), + sessionQuickSwitch9: shortcut("session.quick_switch.9"), + sessionSidebarToggle: shortcut("session.sidebar.toggle"), + sessionTimeline: shortcut("session.timeline"), + statusView: shortcut("opencode.status"), + terminalSuspend: shortcut("terminal.suspend"), + themeList: shortcut("theme.switch"), } const tip = createMemo(() => { if (props.connected === false) return NO_MODELS_TIP @@ -175,22 +167,30 @@ const TIPS: Tip[] = [ (shortcuts) => `Use ${commandText("/new", shortcuts.sessionNew())} to start a fresh conversation session`, (shortcuts) => `Use ${commandText("/sessions", shortcuts.sessionList())} to list, pin, and continue sessions`, (shortcuts) => press(shortcuts.sessionPinToggle(), "in the session list to pin one at the top"), - (shortcuts) => - shortcuts.sessionQuickSwitch1() && shortcuts.sessionQuickSwitch9() - ? `Use ${shortcutText(shortcuts.sessionQuickSwitch1())} through ${shortcutText(shortcuts.sessionQuickSwitch9())} to switch pinned sessions` - : undefined, + (shortcuts) => { + const first = shortcuts.sessionQuickSwitch1() + const last = shortcuts.sessionQuickSwitch9() + if (!first || !last) return undefined + return `Use ${shortcutText(first)} through ${shortcutText(last)} to switch pinned sessions` + }, "Run {highlight}/compact{/highlight} to summarize long sessions near context limits", (shortcuts) => `Use ${commandText("/export", shortcuts.sessionExport())} to save the conversation as Markdown`, (shortcuts) => press(shortcuts.messagesCopy(), "to copy the assistant's last message to clipboard"), (shortcuts) => press(shortcuts.commandList(), "to see all available actions and commands"), "Run {highlight}/connect{/highlight} to add API keys for 75+ supported LLM providers", - (shortcuts) => `The leader key is ${shortcutText(shortcuts.leader())}; combine with other keys for quick actions`, + (shortcuts) => { + const leader = shortcuts.leader() + if (!leader) return undefined + return `The leader key is ${shortcutText(leader)}; combine with other keys for quick actions` + }, (shortcuts) => press(shortcuts.modelCycleRecent(), "to quickly switch between recently used models"), (shortcuts) => press(shortcuts.sessionSidebarToggle(), "in a session to show or hide the sidebar panel"), - (shortcuts) => - shortcuts.messagesPageUp() && shortcuts.messagesPageDown() - ? `Use ${shortcutText(shortcuts.messagesPageUp())}/${shortcutText(shortcuts.messagesPageDown())} to navigate through conversation history` - : undefined, + (shortcuts) => { + const up = shortcuts.messagesPageUp() + const down = shortcuts.messagesPageDown() + if (!up || !down) return undefined + return `Use ${shortcutText(up)}/${shortcutText(down)} to navigate through conversation history` + }, (shortcuts) => press(shortcuts.messagesFirst(), "to jump to the beginning of the conversation"), (shortcuts) => press(shortcuts.messagesLast(), "to jump to the most recent message"), (shortcuts) => press(shortcuts.inputNewline(), "to add newlines in your prompt"), @@ -204,7 +204,7 @@ const TIPS: Tip[] = [ shortcuts.childFirst(), shortcuts.childPrevious(), shortcuts.childNext(), - ].filter(Boolean) + ].filter((item): item is string => Boolean(item)) if (!items.length) return undefined return `Use ${items.map(shortcutText).join(" / ")} for parent/child sessions` }, @@ -267,10 +267,12 @@ const TIPS: Tip[] = [ (shortcuts) => `Use ${commandText("/timeline", shortcuts.sessionTimeline())} to jump to specific messages`, (shortcuts) => `Use ${commandText("/status", shortcuts.statusView())} to see system status info`, "Enable {highlight}scroll.acceleration{/highlight} in {highlight}cli.json{/highlight} for smooth scrolling", - (shortcuts) => - shortcuts.commandList() - ? `Toggle username display in chat via the command palette (${shortcutText(shortcuts.commandList())})` - : "Toggle username display in chat via the command palette", + (shortcuts) => { + const commandList = shortcuts.commandList() + return commandList + ? `Toggle username display in chat via the command palette (${shortcutText(commandList)})` + : "Toggle username display in chat via the command palette" + }, "Run {highlight}docker run -it --rm ghcr.io/anomalyco/opencode{/highlight} in a container", "Use {highlight}/connect{/highlight} with OpenCode Zen for curated, tested models", "Commit your project's {highlight}AGENTS.md{/highlight} file to Git for team sharing", diff --git a/packages/tui/src/feature-plugins/home/tips.tsx b/packages/tui/src/feature-plugins/home/tips.tsx index 2c516b8f32..0fedb29be5 100644 --- a/packages/tui/src/feature-plugins/home/tips.tsx +++ b/packages/tui/src/feature-plugins/home/tips.tsx @@ -1,66 +1,51 @@ -import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" -import type { BuiltinTuiPlugin } from "../builtins" +import { Plugin } from "@opencode-ai/plugin/v2/tui" import { createMemo, Show } from "solid-js" import { Tips } from "./tips-view" -import { useBindings } from "../../keymap" +import { Keymap } from "../../context/keymap" import { useData } from "../../context/data" import { hasConnectedProvider } from "../../util/connected-provider" import { useConfig } from "../../config" +import { useDialog } from "../../ui/dialog" -const id = "internal:home-tips" - -function View(props: { api: TuiPluginApi; hidden: boolean; show: boolean; connected: boolean }) { +function View() { const config = useConfig() - useBindings(() => ({ + const data = useData() + const dialog = useDialog() + const hidden = createMemo(() => !(config.data.hints?.tips ?? true)) + const first = createMemo(() => data.session.list().length === 0) + const connected = createMemo(() => hasConnectedProvider(data.location.integration.list() ?? [])) + const show = createMemo(() => (!first() || !connected()) && !hidden()) + + Keymap.createLayer(() => ({ commands: [ { - name: "tips.toggle", - title: props.hidden ? "Show tips" : "Hide tips", - category: "System", - namespace: "palette", - hidden: true, + id: "tips.toggle", + title: hidden() ? "Show tips" : "Hide tips", + group: "System", run() { void config .update((draft) => { - draft.hints = { ...draft.hints, tips: props.hidden } + draft.hints = { ...draft.hints, tips: hidden() } }) .catch(() => {}) - props.api.ui.dialog.clear() + dialog.clear() }, }, ], - bindings: props.api.tuiConfig.keybinds.get("tips.toggle"), })) return ( - - + + ) } -const tui: TuiPlugin = async (api) => { - api.slots.register({ - order: 100, - slots: { - home_bottom() { - const data = useData() - const config = useConfig().data - const hidden = createMemo(() => !(config.hints?.tips ?? true)) - const first = createMemo(() => api.state.session.count() === 0) - const connected = createMemo(() => hasConnectedProvider(data.location.integration.list() ?? [])) - const show = createMemo(() => (!first() || !connected()) && !hidden()) - return diff --git a/packages/tui/src/routes/session/subagent-footer.tsx b/packages/tui/src/routes/session/subagent-footer.tsx index 2d32055e4f..fa4c3232ec 100644 --- a/packages/tui/src/routes/session/subagent-footer.tsx +++ b/packages/tui/src/routes/session/subagent-footer.tsx @@ -5,7 +5,7 @@ import { useTheme } from "../../context/theme" import { SplitBorder } from "../../ui/border" import { Locale } from "../../util/locale" import { useTerminalDimensions } from "@opentui/solid" -import { useCommandShortcut, useOpencodeKeymap } from "../../keymap" +import { Keymap } from "../../context/keymap" import { contextUsage } from "../../util/session" const money = new Intl.NumberFormat("en-US", { @@ -47,10 +47,8 @@ export function SubagentFooter() { }) const { theme } = useTheme() - const keymap = useOpencodeKeymap() - const parentShortcut = useCommandShortcut("session.parent") - const previousShortcut = useCommandShortcut("session.child.previous") - const nextShortcut = useCommandShortcut("session.child.next") + const keymap = Keymap.use() + const shortcuts = Keymap.useShortcuts() const [hover, setHover] = createSignal<"parent" | "prev" | "next" | null>(null) useTerminalDimensions() @@ -84,31 +82,31 @@ export function SubagentFooter() { setHover("parent")} onMouseOut={() => setHover(null)} - onMouseUp={() => keymap.dispatchCommand("session.parent")} + onMouseUp={() => keymap.dispatch("session.parent")} backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel} > - Parent {parentShortcut()} + Parent {shortcuts.get("session.parent")} setHover("prev")} onMouseOut={() => setHover(null)} - onMouseUp={() => keymap.dispatchCommand("session.child.previous")} + onMouseUp={() => keymap.dispatch("session.child.previous")} backgroundColor={hover() === "prev" ? theme.backgroundElement : theme.backgroundPanel} > - Prev {previousShortcut()} + Prev {shortcuts.get("session.child.previous")} setHover("next")} onMouseOut={() => setHover(null)} - onMouseUp={() => keymap.dispatchCommand("session.child.next")} + onMouseUp={() => keymap.dispatch("session.child.next")} backgroundColor={hover() === "next" ? theme.backgroundElement : theme.backgroundPanel} > - Next {nextShortcut()} + Next {shortcuts.get("session.child.next")} diff --git a/packages/tui/src/ui/dialog-alert.tsx b/packages/tui/src/ui/dialog-alert.tsx index 9fe15de6b7..9983982723 100644 --- a/packages/tui/src/ui/dialog-alert.tsx +++ b/packages/tui/src/ui/dialog-alert.tsx @@ -1,7 +1,7 @@ import { TextAttributes } from "@opentui/core" +import { Keymap } from "../context/keymap" import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" -import { useBindings } from "../keymap" export type DialogAlertProps = { title: string @@ -13,13 +13,14 @@ export function DialogAlert(props: DialogAlertProps) { const dialog = useDialog() const { theme } = useTheme() - useBindings(() => ({ - bindings: [ + Keymap.createLayer(() => ({ + mode: "modal", + commands: [ { - key: "return", - desc: "Confirm alert", + bind: "return", + title: "Confirm alert", group: "Dialog", - cmd: () => { + run: () => { props.onConfirm?.() dialog.clear() }, diff --git a/packages/tui/src/ui/dialog-confirm.tsx b/packages/tui/src/ui/dialog-confirm.tsx index a09847af2f..c9bb952fd5 100644 --- a/packages/tui/src/ui/dialog-confirm.tsx +++ b/packages/tui/src/ui/dialog-confirm.tsx @@ -1,10 +1,10 @@ import { TextAttributes } from "@opentui/core" +import { Keymap } from "../context/keymap" import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" import { createStore } from "solid-js/store" import { For } from "solid-js" import { Locale } from "../util/locale" -import { useBindings } from "../keymap" export type DialogConfirmProps = { title: string @@ -23,31 +23,32 @@ export function DialogConfirm(props: DialogConfirmProps) { active: "confirm" as "confirm" | "cancel", }) - useBindings(() => ({ - bindings: [ + Keymap.createLayer(() => ({ + mode: "modal", + commands: [ { - key: "return", - desc: "Confirm dialog selection", + bind: "return", + title: "Confirm dialog selection", group: "Dialog", - cmd: () => { + run: () => { if (store.active === "confirm") props.onConfirm?.() if (store.active === "cancel") props.onCancel?.() dialog.clear() }, }, { - key: "left", - desc: "Previous dialog option", + bind: "left", + title: "Previous dialog option", group: "Dialog", - cmd: () => { + run: () => { setStore("active", store.active === "confirm" ? "cancel" : "confirm") }, }, { - key: "right", - desc: "Next dialog option", + bind: "right", + title: "Next dialog option", group: "Dialog", - cmd: () => { + run: () => { setStore("active", store.active === "confirm" ? "cancel" : "confirm") }, }, diff --git a/packages/tui/src/ui/dialog-export-options.tsx b/packages/tui/src/ui/dialog-export-options.tsx index db05529158..6e5b9f472f 100644 --- a/packages/tui/src/ui/dialog-export-options.tsx +++ b/packages/tui/src/ui/dialog-export-options.tsx @@ -1,9 +1,9 @@ import { TextAttributes } from "@opentui/core" +import { Keymap } from "../context/keymap" import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" import { createStore } from "solid-js/store" import { For, Show } from "solid-js" -import { useBindings } from "../keymap" export type ExportFormat = "markdown" | "json" @@ -43,13 +43,14 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { if (store.active === "copy" || store.active === "export") confirm(store.active) } - useBindings(() => ({ - bindings: [ + Keymap.createLayer(() => ({ + mode: "modal", + commands: [ { - key: "tab", - desc: "Next export option", + bind: "tab", + title: "Next export option", group: "Dialog", - cmd: () => { + run: () => { const order: Active[] = store.format === "markdown" ? ["markdown", "json", "thinking", "copy", "export"] @@ -58,10 +59,10 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { }, }, { - key: "return", - desc: "Select export option", + bind: "return", + title: "Select export option", group: "Dialog", - cmd: activate, + run: activate, }, ], })) diff --git a/packages/tui/src/ui/dialog-export-result.tsx b/packages/tui/src/ui/dialog-export-result.tsx index 672590867a..f7d7cb199e 100644 --- a/packages/tui/src/ui/dialog-export-result.tsx +++ b/packages/tui/src/ui/dialog-export-result.tsx @@ -1,6 +1,6 @@ import { TextAttributes } from "@opentui/core" +import { Keymap } from "../context/keymap" import { useTheme } from "../context/theme" -import { useBindings } from "../keymap" import { useDialog, type DialogContext } from "./dialog" export function DialogExportResult(props: { path: string; onClose?: () => void }) { @@ -12,13 +12,14 @@ export function DialogExportResult(props: { path: string; onClose?: () => void } dialog.clear() } - useBindings(() => ({ - bindings: [ + Keymap.createLayer(() => ({ + mode: "modal", + commands: [ { - key: "return", - desc: "Close export result", + bind: "return", + title: "Close export result", group: "Dialog", - cmd: close, + run: close, }, ], })) @@ -37,12 +38,7 @@ export function DialogExportResult(props: { path: string; onClose?: () => void } {props.path} - + Close diff --git a/packages/tui/src/ui/dialog-help.tsx b/packages/tui/src/ui/dialog-help.tsx index 1d49d60edf..a78b1f7d96 100644 --- a/packages/tui/src/ui/dialog-help.tsx +++ b/packages/tui/src/ui/dialog-help.tsx @@ -1,17 +1,18 @@ import { TextAttributes } from "@opentui/core" +import { Keymap } from "../context/keymap" import { useTheme } from "../context/theme" import { useDialog } from "./dialog" -import { useBindings, useCommandShortcut } from "../keymap" export function DialogHelp() { const dialog = useDialog() const { theme } = useTheme() - const commandShortcut = useCommandShortcut("command.palette.show") + const shortcuts = Keymap.useShortcuts() - useBindings(() => ({ - bindings: [ - { key: "return", desc: "Close help", group: "Dialog", cmd: () => dialog.clear() }, - { key: "escape", desc: "Close help", group: "Dialog", cmd: () => dialog.clear() }, + Keymap.createLayer(() => ({ + mode: "modal", + commands: [ + { bind: "return", title: "Close help", group: "Dialog", run: () => dialog.clear() }, + { bind: "escape", title: "Close help", group: "Dialog", run: () => dialog.clear() }, ], })) @@ -27,7 +28,7 @@ export function DialogHelp() { - Press {commandShortcut()} to see all available actions and commands in any context. + Press {shortcuts.get("command.palette.show")} to see all available actions and commands in any context. diff --git a/packages/tui/src/ui/dialog-prompt.tsx b/packages/tui/src/ui/dialog-prompt.tsx index 76cef90070..094e87e58b 100644 --- a/packages/tui/src/ui/dialog-prompt.tsx +++ b/packages/tui/src/ui/dialog-prompt.tsx @@ -1,10 +1,9 @@ import { TextareaRenderable, TextAttributes } from "@opentui/core" +import { Keymap } from "../context/keymap" import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" import { Show, createEffect, createSignal, onMount, type JSX } from "solid-js" import { Spinner } from "../component/spinner" -import { useConfig } from "../config" -import { useBindings, useCommandShortcut } from "../keymap" export type DialogPromptProps = { title: string @@ -20,8 +19,7 @@ export type DialogPromptProps = { export function DialogPrompt(props: DialogPromptProps) { const dialog = useDialog() const { theme } = useTheme() - const config = useConfig().data - const submitShortcut = useCommandShortcut("dialog.prompt.submit") + const shortcuts = Keymap.useShortcuts() const [textareaTarget, setTextareaTarget] = createSignal() let textarea: TextareaRenderable @@ -30,20 +28,20 @@ export function DialogPrompt(props: DialogPromptProps) { props.onConfirm?.(textarea.plainText) } - useBindings(() => ({ + Keymap.createLayer(() => ({ + mode: "modal", target: textareaTarget, enabled: textareaTarget() !== undefined && !props.busy, // Dialog form semantics must win over the global managed textarea input layer. priority: 1, commands: [ { - name: "dialog.prompt.submit", + id: "dialog.prompt.submit", title: "Submit dialog prompt", - category: "Dialog", + group: "Dialog", run: confirm, }, ], - bindings: config.keybinds.gather("dialog.prompt", ["dialog.prompt.submit"]), })) onMount(() => { @@ -103,9 +101,9 @@ export function DialogPrompt(props: DialogPromptProps) { processing...}> - + - {submitShortcut()} submit + {shortcuts.get("dialog.prompt.submit")} submit diff --git a/packages/tui/src/ui/dialog-select.tsx b/packages/tui/src/ui/dialog-select.tsx index a33a149bd5..fc5261de93 100644 --- a/packages/tui/src/ui/dialog-select.tsx +++ b/packages/tui/src/ui/dialog-select.tsx @@ -1,12 +1,5 @@ -import { - InputRenderable, - RGBA, - ScrollBoxRenderable, - TextAttributes, - type KeyEvent, - type Renderable, -} from "@opentui/core" -import type { Binding } from "@opentui/keymap" +import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core" +import { Keymap, type KeymapCommand } from "../context/keymap" import { useTheme, selectedForeground } from "../context/theme" import { entries, filter, flatMap, groupBy, pipe } from "remeda" import { batch, createEffect, createMemo, createSignal, For, Show, type JSX, on, onCleanup } from "solid-js" @@ -18,7 +11,7 @@ import { useDialog, type DialogContext } from "./dialog" import { Locale } from "../util/locale" import { getScrollAcceleration } from "../util/scroll" import { useConfig } from "../config" -import { formatKeyBindings, useBindings, useKeymapSelector } from "../keymap" +import { formatKeyBindings, useKeymapSelector } from "../keymap" export interface DialogSelectProps { title: string @@ -43,7 +36,7 @@ export interface DialogSelectProps { label: string side?: "left" | "right" }[] - bindings?: readonly Binding[] + bindings?: readonly KeymapCommand[] current?: T focusCurrent?: boolean } @@ -385,51 +378,52 @@ export function DialogSelect(props: DialogSelectProps) { }) } - useBindings(() => { + Keymap.createLayer(() => { const visible = shownActions() return { + mode: "modal", commands: [ { - name: "dialog.select.prev", + id: "dialog.select.prev", title: "Previous item", - category: "Dialog", + group: "Dialog", run() { setStore("input", "keyboard") move(-1) }, }, { - name: "dialog.select.next", + id: "dialog.select.next", title: "Next item", - category: "Dialog", + group: "Dialog", run() { setStore("input", "keyboard") move(1) }, }, { - name: "dialog.select.page_up", + id: "dialog.select.page_up", title: "Page up", - category: "Dialog", + group: "Dialog", run() { setStore("input", "keyboard") move(-10) }, }, { - name: "dialog.select.page_down", + id: "dialog.select.page_down", title: "Page down", - category: "Dialog", + group: "Dialog", run() { setStore("input", "keyboard") move(10) }, }, { - name: "dialog.select.home", + id: "dialog.select.home", title: "First item", - category: "Dialog", + group: "Dialog", run() { if (props.locked) return setStore("input", "keyboard") @@ -437,9 +431,9 @@ export function DialogSelect(props: DialogSelectProps) { }, }, { - name: "dialog.select.end", + id: "dialog.select.end", title: "Last item", - category: "Dialog", + group: "Dialog", run() { if (props.locked) return setStore("input", "keyboard") @@ -447,49 +441,34 @@ export function DialogSelect(props: DialogSelectProps) { }, }, { - name: "dialog.select.submit", + id: "dialog.select.submit", title: "Select item", - category: "Dialog", + group: "Dialog", run: submit, }, ...visible.map((item) => ({ - name: item.command, + id: item.command, title: item.title, - category: "Dialog", + group: "Dialog", run: () => trigger(item), })), - ], - bindings: [ - ...config.keybinds.gather("dialog.select", [ - "dialog.select.prev", - "dialog.select.next", - "dialog.select.page_up", - "dialog.select.page_down", - "dialog.select.home", - "dialog.select.end", - "dialog.select.submit", - ]), - ...visible.flatMap((item) => config.keybinds.get(item.command)), ...(visible.length ? [ { - key: "tab", - desc: "Next dialog action", + bind: "tab", + title: "Next dialog action", group: "Dialog", - cmd: () => moveAction(1), + run: () => moveAction(1), }, { - key: "shift+tab", - desc: "Previous dialog action", + bind: "shift+tab", + title: "Previous dialog action", group: "Dialog", - cmd: () => moveAction(-1), + run: () => moveAction(-1), }, ] : []), - ...(props.bindings ?? []).filter((binding) => { - if (typeof binding.cmd !== "string") return true - return visible.some((item) => item.command === binding.cmd) - }), + ...(props.bindings ?? []), ], } }) diff --git a/packages/tui/src/ui/dialog.tsx b/packages/tui/src/ui/dialog.tsx index e98da46453..f9e6358a6f 100644 --- a/packages/tui/src/ui/dialog.tsx +++ b/packages/tui/src/ui/dialog.tsx @@ -1,11 +1,11 @@ import { useRenderer, useTerminalDimensions } from "@opentui/solid" import { batch, createContext, createEffect, onCleanup, Show, useContext, type JSX, type ParentProps } from "solid-js" +import { Keymap } from "../context/keymap" import { useTheme } from "../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 { useBindings, useOpencodeModeStack } from "../keymap" import { useClipboard } from "../context/clipboard" export function Dialog( @@ -79,11 +79,11 @@ function init() { }) const renderer = useRenderer() - const modeStack = useOpencodeModeStack() + const keymap = Keymap.use() createEffect(() => { if (store.stack.length === 0) return - const popMode = modeStack.push("modal") + const popMode = keymap.mode.push("modal") onCleanup(popMode) }) @@ -106,14 +106,15 @@ function init() { }, 1) } - useBindings(() => ({ + Keymap.createLayer(() => ({ + mode: "modal", enabled: store.stack.length > 0 && !renderer.getSelection()?.getSelectedText(), - bindings: [ + commands: [ { - key: "escape", - desc: "Close dialog", + bind: "escape", + title: "Close dialog", group: "Dialog", - cmd: () => { + run: () => { if (renderer.getSelection()) { renderer.clearSelection() } @@ -124,10 +125,10 @@ function init() { }, }, { - key: "ctrl+c", - desc: "Close dialog", + bind: "ctrl+c", + title: "Close dialog", group: "Dialog", - cmd: () => { + run: () => { if (renderer.getSelection()) { renderer.clearSelection() } diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index d5b1736a5a..bad8245fcd 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -1,5 +1,4 @@ import { expect, mock, test } from "bun:test" -import type { TuiPluginApi } from "@opencode-ai/plugin/tui" import { createTestRenderer } from "@opentui/core/testing" import { Effect } from "effect" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" @@ -11,37 +10,29 @@ test("SIGHUP clears title and disposes scoped resources once", async () => { const core = await import("@opentui/core") mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer })) const titles: string[] = [] + let started!: () => void + const ready = new Promise((resolve) => { + started = resolve + }) const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer) setup.renderer.setTerminalTitle = (title) => { titles.push(title) + if (title === "OpenCode") started() setTitle(title) } const listeners = new Set(process.listeners("SIGHUP")) const events = createEventStream() const calls = createFetch(undefined, events) const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) }) - let started!: () => void - const ready = new Promise((resolve) => { - started = resolve - }) - let disposes = 0 - try { const { run } = await import("../src/app") const task = Effect.runPromise( run({ server: { endpoint: { url: server.url.toString() } }, config: { get: async () => ({}), update: async () => ({}) }, + packages: { resolve: async () => undefined }, args: {}, log: () => {}, - pluginHost: { - async start() { - started() - }, - async dispose() { - disposes++ - }, - }, }).pipe(Effect.provide(AppNodeBuilder.build(Global.node))), ) await ready @@ -50,7 +41,6 @@ test("SIGHUP clears title and disposes scoped resources once", async () => { expect(setup.renderer.isDestroyed).toBe(true) expect(titles.at(-1)).toBe("") - expect(disposes).toBe(1) expect(process.listeners("SIGHUP").every((listener) => listeners.has(listener))).toBe(true) } finally { if (!setup.renderer.isDestroyed) setup.renderer.destroy() @@ -101,12 +91,6 @@ test("session lifecycle updates the terminal title and prints the epilogue after const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) }) const originalWrite = process.stdout.write.bind(process.stdout) let stdout = "" - let api: TuiPluginApi | undefined - let started!: () => void - const ready = new Promise((resolve) => { - started = resolve - }) - process.stdout.write = ((chunk: string | Uint8Array) => { stdout += String(chunk) return true @@ -118,19 +102,12 @@ test("session lifecycle updates the terminal title and prints the epilogue after run({ server: { endpoint: { url: server.url.toString() } }, config: { get: async () => ({}), update: async () => ({}) }, + packages: { resolve: async () => undefined }, args: { sessionID: "dummy" }, log: () => {}, - pluginHost: { - async start(input) { - api = input.api - started() - }, - async dispose() {}, - }, }).pipe(Effect.provide(AppNodeBuilder.build(Global.node))), ) - await ready await initialTitleSet events.emit({ id: "evt_renamed", @@ -140,7 +117,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after data: { sessionID: "dummy", title: "Renamed session" }, }) await renamedTitleSet - api?.keymap.dispatchCommand("app.exit") + setup.renderer.destroy() await task expect(stdout).toContain("Renamed session") diff --git a/packages/tui/test/keymap.test.tsx b/packages/tui/test/keymap.test.tsx index fb3b685dbd..3f2ebcdf76 100644 --- a/packages/tui/test/keymap.test.tsx +++ b/packages/tui/test/keymap.test.tsx @@ -36,7 +36,7 @@ test("legacy page key aliases compile as page keys", async () => { }) const offKeymap = registerOpencodeKeymap(keymap, renderer, config) const offLayer = keymap.registerLayer({ - bindings: config.keybinds.gather("session", ["session.page.up", "session.page.down"]), + bindings: ["session.page.up", "session.page.down"].flatMap((command) => config.keybinds.get(command)), }) const bindings = keymap.getCommandBindings({ visibility: "registered", @@ -79,7 +79,7 @@ test("formats navigation keys as arrows", async () => { const offKeymap = registerOpencodeKeymap(keymap, renderer, config) const commands = ["session.parent", "session.child.first", "session.child.previous", "session.child.next"] const offLayer = keymap.registerLayer({ - bindings: config.keybinds.gather("test.arrows", commands), + bindings: commands.flatMap((command) => config.keybinds.get(command)), }) const bindings = keymap.getCommandBindings({ visibility: "registered", commands }) commands.forEach((command) => { @@ -125,17 +125,14 @@ test("mode-less bindings stay active when opencode mode changes", async () => { { name: "session.page.up", run() {} }, { name: "session.first", run() {} }, ], - bindings: config.keybinds.gather("test.global", [ - "session.list", - "session.new", - "session.page.up", - "session.first", - ]), + bindings: ["session.list", "session.new", "session.page.up", "session.first"].flatMap((command) => + config.keybinds.get(command), + ), }) const offBase = keymap.registerLayer({ mode: OPENCODE_BASE_MODE, commands: [{ name: "model.list", run() {} }], - bindings: config.keybinds.gather("test.base", ["model.list"]), + bindings: config.keybinds.get("model.list"), }) const activeCounts = () => Object.fromEntries(