feat(tui): add v2 plugin runtime

This commit is contained in:
Dax Raad 2026-07-14 12:43:53 -04:00
commit 4a93972a78
63 changed files with 1722 additions and 1701 deletions

View file

@ -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)))
}),
)

View file

@ -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> =
Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
@ -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<Node extends Spec.Any> = () => Promise<{
default: (
@ -25,7 +26,7 @@ type Loader<Node extends Spec.Any> = () => 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<Node extends Spec.Any> = keyof Node["commands"] extends never

View file

@ -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,

View file

@ -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) => {

View file

@ -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"
},

View file

@ -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<string, any>
}
export type Destination = Route | Omit<Extract<Route, { readonly type: "plugin" }>, "id">
export interface Page {
readonly name: string
readonly render: (input: { readonly params: any }) => JSX.Element
readonly render: (input: { readonly data?: Record<string, any> }) => 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<string, any>) => 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<Record<string, unknown>>
readonly options: Readonly<Record<string, any>>
readonly client: OpenCodeClient
readonly data: Data
readonly ui: UI

View file

@ -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<void>>()
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<unknown>()
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",
}}
>
<TuiTerminalEnvironmentProvider
<TuiLifecycleProvider
value={{
platform: process.platform,
multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
displayServer: process.env.WAYLAND_DISPLAY
? "wayland"
: process.env.DISPLAY
? "x11"
: undefined,
add(finalizer) {
finalizers.add(finalizer)
return () => finalizers.delete(finalizer)
},
}}
>
<TuiStartupProvider
<TuiTerminalEnvironmentProvider
value={{
initialRoute: process.env.OPENCODE_SCRAP
? { type: "plugin", id: "scrap" }
: process.env.OPENCODE_ROUTE
? JSON.parse(process.env.OPENCODE_ROUTE)
platform: process.platform,
multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
displayServer: process.env.WAYLAND_DISPLAY
? "wayland"
: process.env.DISPLAY
? "x11"
: undefined,
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
}}
>
<ClipboardProvider>
<OpencodeKeymapProvider keymap={keymap}>
<TuiStartupProvider
value={{
initialRoute: process.env.OPENCODE_SCRAP
? { type: "plugin", id: "scrap", name: "scrap" }
: process.env.OPENCODE_ROUTE
? JSON.parse(process.env.OPENCODE_ROUTE)
: undefined,
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
}}
>
<ClipboardProvider>
<ArgsProvider {...input.args}>
<ConfigProvider
config={config}
service={input.config}
options={{ terminalSuspend: process.platform !== "win32" }}
>
<ToastProvider>
<RouteProvider
initialRoute={
input.args.continue
? {
type: "session",
sessionID: "dummy",
}
: undefined
}
>
<PluginRuntimeProvider value={pluginRuntime}>
<ClientProvider
api={api}
reconnect={reconnect}
reload={input.server.reload}
>
<PermissionProvider>
<ProjectProvider>
<DataProvider>
<Keymap.Provider>
<ToastProvider>
<RouteProvider
initialRoute={
input.args.continue
? {
type: "session",
sessionID: "dummy",
}
: undefined
}
>
<PluginRuntimeProvider value={pluginRuntime}>
<ClientProvider api={api} reconnect={reconnect} reload={input.server.reload}>
<PermissionProvider>
<ProjectProvider>
<DataProvider>
<ThemeProvider mode={mode}>
<LocalProvider>
<PromptStashProvider>
@ -349,17 +342,18 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<PromptRefProvider>
<EditorContextProvider>
<LocationProvider>
<App
pluginHost={input.pluginHost}
pair={
input.server.endpoint.auth
? input.server.endpoint.auth
: {
username: "opencode",
password: "",
}
}
/>
<PluginProvider packages={input.packages}>
<App
pair={
input.server.endpoint.auth
? input.server.endpoint.auth
: {
username: "opencode",
password: "",
}
}
/>
</PluginProvider>
</LocationProvider>
</EditorContextProvider>
</PromptRefProvider>
@ -369,19 +363,20 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
</PromptStashProvider>
</LocalProvider>
</ThemeProvider>
</DataProvider>
</ProjectProvider>
</PermissionProvider>
</ClientProvider>
</PluginRuntimeProvider>
</RouteProvider>
</ToastProvider>
</DataProvider>
</ProjectProvider>
</PermissionProvider>
</ClientProvider>
</PluginRuntimeProvider>
</RouteProvider>
</ToastProvider>
</Keymap.Provider>
</ConfigProvider>
</ArgsProvider>
</OpencodeKeymapProvider>
</ClipboardProvider>
</TuiStartupProvider>
</TuiTerminalEnvironmentProvider>
</ClipboardProvider>
</TuiStartupProvider>
</TuiTerminalEnvironmentProvider>
</TuiLifecycleProvider>
</TuiPathsProvider>
</ErrorBoundary>
</EpilogueProvider>
@ -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(() => <DialogSessionList />)
},
@ -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(() => <DialogModel />)
},
@ -712,7 +666,7 @@ function App(props: {
name: "agent.list",
title: "Switch agent",
category: "Agent",
slashName: "agents",
slash: { name: "agents" },
run: () => {
dialog.replace(() => <DialogAgent />)
},
@ -721,7 +675,7 @@ function App(props: {
name: "mcp.list",
title: "MCP servers",
category: "Agent",
slashName: "mcps",
slash: { name: "mcps" },
run: () => {
dialog.replace(() => <DialogMcp />)
},
@ -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(() => (
<DialogIntegration
@ -786,7 +740,7 @@ function App(props: {
{
name: "opencode.settings",
title: "Open settings",
slashName: "settings",
slash: { name: "settings" },
run: () => {
dialog.replace(() => <DialogConfig />)
},
@ -795,7 +749,7 @@ function App(props: {
{
name: "opencode.status",
title: "View status",
slashName: "status",
slash: { name: "status" },
run: () => {
dialog.replace(() => <DialogStatus />)
},
@ -804,7 +758,7 @@ function App(props: {
{
name: "server.pair",
title: "Pair device",
slashName: "pair",
slash: { name: "pair" },
run: () => {
dialog.replace(() => <DialogPair credentials={props.pair} />)
},
@ -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(() => <DialogDebug />)
},
@ -841,7 +795,7 @@ function App(props: {
{
name: "theme.switch",
title: "Switch theme",
slashName: "themes",
slash: { name: "themes" },
run: () => {
dialog.replace(() => <DialogThemeList />)
},
@ -871,7 +825,7 @@ function App(props: {
{
name: "help.show",
title: "Help",
slashName: "help",
slash: { name: "help" },
run: () => {
dialog.replace(() => <DialogHelp />)
},
@ -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 <PluginRouteMissing id={route.data.id} onHome={() => 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: {
<Show when={Flag.OPENCODE_SHOW_TTFD}>
<TimeToFirstDraw />
</Show>
<Show when={ready()}>
<Show when={plugins.ready()}>
<box flexGrow={1} minHeight={0} flexDirection="column">
<Switch>
<Match when={route.data.type === "home"}>
@ -1155,16 +1103,22 @@ function App(props: {
{(_) => <Session />}
</Show>
</Match>
<Match when={route.data.type === "plugin"}>
<PluginRoute
fallback={(id, name) => (
<PluginRouteMissing id={id} name={name} onHome={() => route.navigate({ type: "home" })} />
)}
/>
</Match>
</Switch>
{plugin()}
</box>
<box flexShrink={0}>
<pluginRuntime.Slot name="app_bottom" />
<PluginSlot name="app.bottom" />
</box>
<pluginRuntime.Slot name="app" />
<PluginSlot name="app" />
</Show>
<Show when={!startup.skipInitialLoading}>
<StartupLoading ready={ready} />
<StartupLoading ready={plugins.ready} />
</Show>
<Show when={showReconnecting()}>
<Reconnecting attempt={client.connection.attempt()} error={client.connection.error()} />

View file

@ -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),
},
]}
/>

View file

@ -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 (

View file

@ -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<typeof setTimeout> | 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)

View file

@ -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) => {

View file

@ -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<string>()
const [removing, setRemoving] = createSignal(props.initialRemoving)
const [replacementCurrent, setReplacementCurrent] = createSignal<string>()
const [loadError, setLoadError] = createSignal<unknown>()
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 ? (
<span style={{ fg: theme.error }}>Deleting {item.location}</span>
) : deleting ? (
<span style={{ fg: theme.text }}>Press {deleteHint()} again to confirm</span>
<span style={{ fg: theme.text }}>Press {shortcuts.get("dialog.move_session.delete")} again to confirm</span>
) : suffix ? (
<>
{visible.slice(0, split)}

View file

@ -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<InputRenderable>()
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 <span style={{ fg: theme.textMuted }}>submit</span>
</text>
<text fg={theme.text}>
{generateShortcut()} <span style={{ fg: theme.textMuted }}>generate one</span>
{shortcuts.get("dialog.project_copy.generate")} <span style={{ fg: theme.textMuted }}>generate one</span>
</text>
</box>
</box>
@ -82,7 +80,10 @@ export function DialogProjectCopyName(props: { onConfirm: (name: string) => void
DialogProjectCopyName.show = (dialog: DialogContext) =>
new Promise<string | null>((resolve) => {
dialog.replace(() => <DialogProjectCopyName onConfirm={resolve} />, () => resolve(null))
dialog.replace(
() => <DialogProjectCopyName onConfirm={resolve} />,
() => resolve(null),
)
})
function slugify(input: string) {

View file

@ -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)
},

View file

@ -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"),
},
],
}))

View file

@ -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<string>()
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,

View file

@ -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<number>()
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),

View file

@ -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 (
<box width="100%" height="100%" alignItems="center" justifyContent="center" flexDirection="column" gap={1}>
<text fg={theme.warning}>Unknown plugin route: {props.id}</text>
<text fg={theme.warning}>
Unknown plugin route: {props.id}/{props.name}
</text>
<box onMouseUp={props.onHome} backgroundColor={theme.backgroundElement} paddingLeft={1} paddingRight={1}>
<text fg={theme.text}>go home</text>
</box>

View file

@ -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: "@" | "/") {

View file

@ -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(() => (
<DialogSkill
@ -520,7 +520,7 @@ export function Prompt(props: PromptProps) {
desc: "Move to another project dir",
name: "session.move",
category: "Session",
slashName: "move",
slash: { name: "move" },
run: () => {
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({

View file

@ -7,6 +7,7 @@ import { createStore, reconcile } from "solid-js/store"
import { TuiKeybind } from "./keybind"
export interface Interface {
readonly path?: string
readonly get: () => Promise<Info>
readonly update: (update: (draft: any) => void) => Promise<Info>
}
@ -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 (
<ConfigContext.Provider value={{ data: config, update }}>{props.children}</ConfigContext.Provider>
<ConfigContext.Provider value={{ data: config, path: host?.path, update }}>{props.children}</ConfigContext.Provider>
)
}

View file

@ -417,9 +417,6 @@ export type BindingLookupView = {
readonly bindings: readonly Binding<Renderable, KeyEvent>[]
get(command: string): readonly Binding<Renderable, KeyEvent>[]
has(command: string): boolean
gather(name: string, commands: readonly string[]): readonly Binding<Renderable, KeyEvent>[]
pick(name: string, commands: readonly string[]): Binding<Renderable, KeyEvent>[]
omit(name: string, commands: readonly string[]): Binding<Renderable, KeyEvent>[]
}
export function toBindingConfig(keybinds: Keybinds): BindingConfig<Renderable, KeyEvent> {

View file

@ -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<typeof KeymapProvider>[0]["keymap"]
type Mode = ReturnType<typeof createMode>
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 (
<KeymapProvider keymap={keymap}>
<Context.Provider value={{ keymap, mode }}>{props.children}</Context.Provider>
</KeymapProvider>
)
}
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<void>
}
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<KeymapCommand & { readonly id: string }>,
inline: [] as Array<KeymapCommand & { readonly id?: undefined; readonly bind: string }>,
},
)
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<readonly KeymapCommand[]> {
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<typeof useConfig>["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
}

View file

@ -17,6 +17,7 @@ export type SessionRoute = {
export type PluginRoute = {
type: "plugin"
id: string
name: string
data?: Record<string, unknown>
}
@ -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 }
}
}

View file

@ -18,9 +18,14 @@ export type TuiStartup = Readonly<{
skipInitialLoading: boolean
}>
export type TuiLifecycle = Readonly<{
add(finalizer: () => Promise<void>): () => void
}>
const PathsContext = createContext<TuiPaths>()
const TerminalEnvironmentContext = createContext<TuiTerminalEnvironment>()
const StartupContext = createContext<TuiStartup>()
const LifecycleContext = createContext<TuiLifecycle>()
function provider<T>(context: ReturnType<typeof createContext<T>>, 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<T>(context: ReturnType<typeof createContext<T>>, 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")
}

View file

@ -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<TuiPluginModule, "id"> & {
id: string
@ -19,25 +12,10 @@ export type BuiltinTuiPlugin = Omit<TuiPluginModule, "id"> & {
}
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> = []

View file

@ -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 (
<Show when={dir()}>
{(value) => {
const suffix = () => (value().branch ? `:${value().branch}` : "")
const suffixWidth = () => Math.min(Bun.stringWidth(suffix()), Math.max(0, props.maxWidth - 2))
return (
<box flexDirection="row" minWidth={0}>
<FilePath
value={value().path}
maxWidth={Math.max(2, props.maxWidth - suffixWidth())}
fg={theme().textMuted}
/>
<Show when={suffix()}>
<text width={suffixWidth()} wrapMode="none" truncate fg={theme().textMuted}>
{suffix()}
</text>
</Show>
</box>
)
}}
<Show when={directory()}>
{(value) => <FilePath value={value()} maxWidth={props.maxWidth} fg={theme.textMuted} />}
</Show>
)
}
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 (
<Show when={has()}>
<Show when={list().length}>
<box gap={1} flexDirection="row" flexShrink={0}>
<text fg={theme().text}>
<text fg={theme.text}>
<Switch>
<Match when={err()}>
<span style={{ fg: theme().error }}> </span>
<Match when={failed()}>
<span style={{ fg: theme.error }}> </span>
</Match>
<Match when={true}>
<span style={{ fg: count() > 0 ? theme().success : theme().textMuted }}> </span>
<span style={{ fg: count() > 0 ? theme.success : theme.textMuted }}> </span>
</Match>
</Switch>
{count()} MCP
</text>
<text fg={theme().textMuted}>/status</text>
<text fg={theme.textMuted}>/status</text>
</box>
</Show>
)
}
function Version(props: { api: TuiPluginApi }) {
const theme = () => props.api.theme.current
return (
<box flexShrink={0}>
<text fg={theme().textMuted}>{props.api.app.version}</text>
</box>
)
}
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 (
<box
width="100%"
@ -104,28 +72,22 @@ function View(props: { api: TuiPluginApi }) {
flexShrink={0}
gap={2}
>
<Directory api={props.api} maxWidth={directoryWidth()} />
<Mcp api={props.api} />
<Directory
context={props.context}
maxWidth={Math.max(2, dimensions().width - 8 - Bun.stringWidth(InstallationVersion) - mcpWidth())}
/>
<Mcp context={props.context} />
<box flexGrow={1} />
<Version api={props.api} />
<box flexShrink={0}>
<text fg={theme.textMuted}>{InstallationVersion}</text>
</box>
</box>
)
}
const tui: TuiPlugin = async (api) => {
api.slots.register({
order: 100,
slots: {
home_footer() {
return <View api={api} />
},
},
})
}
const plugin: BuiltinTuiPlugin = {
id,
tui,
}
export default plugin
export default Plugin.define({
id: "opencode.home-footer",
setup(context) {
context.ui.slot("home.footer", () => <View context={context} />)
},
})

View file

@ -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<string>
type TipShortcut = Accessor<string | undefined>
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",

View file

@ -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 (
<box width="100%" maxWidth={75} alignItems="center" paddingTop={3} flexShrink={1}>
<Show when={props.show}>
<Tips api={props.api} connected={props.connected} />
<Show when={show()}>
<Tips connected={connected()} />
</Show>
</box>
)
}
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 <View api={api} hidden={hidden()} show={show()} connected={connected()} />
},
},
})
}
const plugin: BuiltinTuiPlugin = {
id,
tui,
}
export default plugin
export default Plugin.define({
id: "internal:home-tips",
setup(context) {
context.ui.slot("home.bottom", () => <View />)
},
})

View file

@ -1,59 +1,46 @@
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 { useData } from "../../context/data"
import { useTheme } from "../../context/theme"
import { contextUsage } from "../../util/session"
const id = "internal:sidebar-context"
const money = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
})
function View(props: { api: TuiPluginApi; session_id: string }) {
const data = useData()
const theme = () => props.api.theme.current
const msg = createMemo(() => data.session.message.list(props.session_id))
const session = createMemo(() => data.session.get(props.session_id))
const cost = createMemo(() => data.session.cost(props.session_id))
function View(props: { context: Plugin.Context; sessionID: string }) {
const { theme } = useTheme()
const msg = createMemo(() => props.context.data.session.message.list(props.sessionID))
const session = createMemo(() => props.context.data.session.get(props.sessionID))
const cost = createMemo(() => props.context.data.session.cost(props.sessionID))
const state = createMemo(() => contextUsage(msg(), data.location.model.list(session()?.location), session()?.revert?.messageID))
const state = createMemo(() =>
contextUsage(msg(), props.context.data.location.model.list(session()?.location), session()?.revert?.messageID),
)
return (
<box>
<text fg={theme().text}>
<text fg={theme.text}>
<b>Context</b>
</text>
<Show when={state()} fallback={<text fg={theme().textMuted}>Not measured</text>}>
<Show when={state()} fallback={<text fg={theme.textMuted}>Not measured</text>}>
{(value) => (
<>
<text fg={theme().textMuted}>{value().tokens.toLocaleString()} tokens</text>
<text fg={theme.textMuted}>{value().tokens.toLocaleString()} tokens</text>
<Show when={value().percent !== undefined}>
<text fg={theme().textMuted}>{value().percent}% used</text>
<text fg={theme.textMuted}>{value().percent}% used</text>
</Show>
</>
)}
</Show>
<text fg={theme().textMuted}>{money.format(cost())} spent</text>
<text fg={theme.textMuted}>{money.format(cost())} spent</text>
</box>
)
}
const tui: TuiPlugin = async (api) => {
api.slots.register({
order: 100,
slots: {
sidebar_content(_ctx, props) {
return <View api={api} session_id={props.session_id} />
},
},
})
}
const plugin: BuiltinTuiPlugin = {
id,
tui,
}
export default plugin
export default Plugin.define({
id: "internal:sidebar-context",
setup(context) {
context.ui.slot("sidebar.content", (props) => <View context={context} sessionID={props.sessionID} />)
},
})

View file

@ -1,113 +1,14 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { BuiltinTuiPlugin } from "../builtins"
import { createMemo, Show } from "solid-js"
import { abbreviateHome } from "../../runtime"
import { useTuiPaths } from "../../context/runtime"
import { FilePath } from "../../ui/file-path"
import { useConfig } from "../../config"
import { Plugin } from "@opencode-ai/plugin/v2/tui"
import { useTheme } from "../../context/theme"
const id = "internal:sidebar-footer"
function View(props: { api: TuiPluginApi; directory: string }) {
const paths = useTuiPaths()
const config = useConfig()
const theme = () => props.api.theme.current
const has = createMemo(() =>
props.api.state.provider.some(
(item) => item.id !== "opencode" || Object.values(item.models).some((model) => model.cost?.input !== 0),
),
)
const done = createMemo(() => !(config.data.hints?.onboarding ?? true))
const show = createMemo(() => !has() && !done())
const location = createMemo(() => {
const branch = props.directory === props.api.state.path.directory ? props.api.state.vcs?.branch : undefined
return { path: abbreviateHome(props.directory, paths.home), branch }
})
const suffix = createMemo(() => (location().branch ? `:${location().branch}` : ""))
const suffixWidth = createMemo(() => Math.min(Bun.stringWidth(suffix()), 36))
return (
<box gap={1}>
<Show when={show()}>
<box
backgroundColor={theme().backgroundElement}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
flexDirection="row"
gap={1}
>
<text flexShrink={0} fg={theme().text}>
</text>
<box flexGrow={1} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme().text}>
<b>Getting started</b>
</text>
<text
fg={theme().textMuted}
onMouseDown={() =>
void config
.update((draft) => {
draft.hints = { ...draft.hints, onboarding: false }
})
.catch(() => {})
}
>
</text>
</box>
<text fg={theme().textMuted}>OpenCode includes free models so you can start immediately.</text>
<text fg={theme().textMuted}>
Connect from 75+ providers to use other models, including Claude, GPT, Gemini etc
</text>
<box flexDirection="row" gap={1} justifyContent="space-between">
<text fg={theme().text}>Connect provider</text>
<text fg={theme().textMuted}>/connect</text>
</box>
</box>
</box>
</Show>
<box flexDirection="row" minWidth={0}>
<FilePath
value={location().path}
maxWidth={Math.max(2, 38 - suffixWidth())}
fg={theme().textMuted}
basenameFg={theme().text}
/>
<Show when={suffix()}>
<text width={suffixWidth()} wrapMode="none" truncate fg={theme().textMuted}>
{suffix()}
</text>
</Show>
</box>
<text fg={theme().textMuted}>
<span style={{ fg: theme().success }}></span> <b>Open</b>
<span style={{ fg: theme().text }}>
<b>Code</b>
</span>{" "}
<span>{props.api.app.version}</span>
</text>
</box>
)
function View() {
const { theme } = useTheme()
return <text fg={theme.textMuted}>Sidebar footer unavailable</text>
}
const tui: TuiPlugin = async (api) => {
api.slots.register({
order: 100,
slots: {
sidebar_footer(_ctx, props) {
return <View api={api} directory={props.directory} />
},
},
})
}
const plugin: BuiltinTuiPlugin = {
id,
tui,
}
export default plugin
export default Plugin.define({
id: "opencode.sidebar-footer",
setup(context) {
context.ui.slot("sidebar.footer", () => <View />)
},
})

View file

@ -1,65 +1,21 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { BuiltinTuiPlugin } from "../builtins"
import { createMemo, For, Show, createSignal } from "solid-js"
const id = "internal:sidebar-lsp"
function View(props: { api: TuiPluginApi }) {
const [open, setOpen] = createSignal(true)
const theme = () => props.api.theme.current
const list = createMemo(() => props.api.state.lsp())
const off = createMemo(() => !props.api.state.config.lsp)
import { Plugin } from "@opencode-ai/plugin/v2/tui"
import { useTheme } from "../../context/theme"
function View() {
const { theme } = useTheme()
return (
<box>
<box flexDirection="row" gap={1} onMouseDown={() => list().length > 2 && setOpen((x) => !x)}>
<Show when={list().length > 2}>
<text fg={theme().text}>{open() ? "▼" : "▶"}</text>
</Show>
<text fg={theme().text}>
<b>LSP</b>
</text>
</box>
<Show when={list().length <= 2 || open()}>
<Show when={list().length === 0}>
<text fg={theme().textMuted}>{off() ? "LSPs are disabled" : "LSPs will activate as files are read"}</text>
</Show>
<For each={list()}>
{(item) => (
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
style={{
fg: item.status === "connected" ? theme().success : theme().error,
}}
>
</text>
<text fg={theme().textMuted}>
{item.id} {item.root}
</text>
</box>
)}
</For>
</Show>
<text fg={theme.text}>
<b>LSP</b>
</text>
<text fg={theme.textMuted}>LSP status unavailable</text>
</box>
)
}
const tui: TuiPlugin = async (api) => {
api.slots.register({
order: 300,
slots: {
sidebar_content() {
return <View api={api} />
},
},
})
}
const plugin: BuiltinTuiPlugin = {
id,
tui,
}
export default plugin
export default Plugin.define({
id: "opencode.sidebar-lsp",
setup(context) {
context.ui.slot("sidebar.content", () => <View />)
},
})

View file

@ -1,29 +1,30 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { BuiltinTuiPlugin } from "../builtins"
import { Plugin } from "@opencode-ai/plugin/v2/tui"
import { createMemo, For, Match, Show, Switch, createSignal } from "solid-js"
import { useTheme } from "../../context/theme"
const id = "internal:sidebar-mcp"
function View(props: { api: TuiPluginApi }) {
function View(props: { context: Plugin.Context; sessionID: string }) {
const [open, setOpen] = createSignal(true)
const theme = () => props.api.theme.current
const list = createMemo(() => props.api.state.mcp())
const on = createMemo(() => list().filter((item) => item.status === "connected").length)
const { theme } = useTheme()
const session = createMemo(() => props.context.data.session.get(props.sessionID))
const list = createMemo(() => props.context.data.location.mcp.server.list(session()?.location) ?? [])
const on = createMemo(() => list().filter((item) => item.status.status === "connected").length)
const bad = createMemo(
() =>
list().filter(
(item) =>
item.status === "failed" || item.status === "needs_auth" || item.status === "needs_client_registration",
item.status.status === "failed" ||
item.status.status === "needs_auth" ||
item.status.status === "needs_client_registration",
).length,
)
const dot = (status: string) => {
if (status === "connected") return theme().success
if (status === "failed") return theme().error
if (status === "disabled") return theme().textMuted
if (status === "needs_auth") return theme().warning
if (status === "needs_client_registration") return theme().error
return theme().textMuted
if (status === "connected") return theme.success
if (status === "failed") return theme.error
if (status === "disabled") return theme.textMuted
if (status === "needs_auth") return theme.warning
if (status === "needs_client_registration") return theme.error
return theme.textMuted
}
return (
@ -31,12 +32,12 @@ function View(props: { api: TuiPluginApi }) {
<box>
<box flexDirection="row" gap={1} onMouseDown={() => list().length > 2 && setOpen((x) => !x)}>
<Show when={list().length > 2}>
<text fg={theme().text}>{open() ? "▼" : "▶"}</text>
<text fg={theme.text}>{open() ? "▼" : "▶"}</text>
</Show>
<text fg={theme().text}>
<text fg={theme.text}>
<b>MCP</b>
<Show when={!open()}>
<span style={{ fg: theme().textMuted }}>
<span style={{ fg: theme.textMuted }}>
{" "}
({on()} active{bad() > 0 ? `, ${bad()} error${bad() > 1 ? "s" : ""}` : ""})
</span>
@ -50,22 +51,22 @@ function View(props: { api: TuiPluginApi }) {
<text
flexShrink={0}
style={{
fg: dot(item.status),
fg: dot(item.status.status),
}}
>
</text>
<text fg={theme().text} wrapMode="word">
<text fg={theme.text} wrapMode="word">
{item.name}{" "}
<span style={{ fg: theme().textMuted }}>
<Switch fallback={item.status}>
<Match when={item.status === "connected"}>Connected</Match>
<Match when={item.status === "failed"}>
<i>{item.error}</i>
<span style={{ fg: theme.textMuted }}>
<Switch fallback={item.status.status}>
<Match when={item.status.status === "connected"}>Connected</Match>
<Match when={item.status.status === "failed"}>
<i>{item.status.status === "failed" ? item.status.error : undefined}</i>
</Match>
<Match when={item.status === "disabled"}>Disabled</Match>
<Match when={item.status === "needs_auth"}>Needs auth</Match>
<Match when={item.status === "needs_client_registration"}>Needs client ID</Match>
<Match when={item.status.status === "disabled"}>Disabled</Match>
<Match when={item.status.status === "needs_auth"}>Needs auth</Match>
<Match when={item.status.status === "needs_client_registration"}>Needs client ID</Match>
</Switch>
</span>
</text>
@ -78,20 +79,9 @@ function View(props: { api: TuiPluginApi }) {
)
}
const tui: TuiPlugin = async (api) => {
api.slots.register({
order: 200,
slots: {
sidebar_content() {
return <View api={api} />
},
},
})
}
const plugin: BuiltinTuiPlugin = {
id,
tui,
}
export default plugin
export default Plugin.define({
id: "internal:sidebar-mcp",
setup(context) {
context.ui.slot("sidebar.content", (props) => <View context={context} sessionID={props.sessionID} />)
},
})

View file

@ -732,10 +732,7 @@ function DiffViewer(props: { api: TuiPluginApi }) {
{ key: "pagedown,ctrl+f", cmd: "diff.page.down", desc: "Page diff viewer down" },
{ key: "pageup,ctrl+b", cmd: "diff.page.up", desc: "Page diff viewer up" },
{ key: "m", cmd: "diff.mark_reviewed", desc: "Mark selected file reviewed" },
...props.api.tuiConfig.keybinds.gather(
"diff",
commands.map((command) => command.name),
),
...commands.flatMap((command) => props.api.tuiConfig.keybinds.get(command.name)),
],
}))
@ -1047,7 +1044,7 @@ const tui: TuiPlugin = async (api) => {
{
name: "diff.open",
title: "Open diff viewer",
slashName: "diff",
slash: { name: "diff" },
category: "VCS",
namespace: "palette",
run() {

View file

@ -258,7 +258,7 @@ const tui: TuiPlugin = async (api) => {
},
},
],
bindings: api.tuiConfig.keybinds.gather("plugins.palette", ["plugins.list", "plugins.install"]),
bindings: ["plugins.list", "plugins.install"].flatMap((command) => api.tuiConfig.keybinds.get(command)),
})
}

View file

@ -1,24 +1,41 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import { Plugin } from "@opencode-ai/plugin/v2/tui"
import { useTerminalDimensions } from "@opentui/solid"
import { Keymap } from "../../context/keymap"
import { useTheme } from "../../context/theme"
import { useBindings } from "../../keymap"
import type { BuiltinTuiPlugin } from "../builtins"
import { useDialog } from "../../ui/dialog"
const id = "internal:scrap"
const route = "scrap"
function Commands(props: { context: Plugin.Context }) {
const dialog = useDialog()
Keymap.createLayer(() => ({
mode: "global",
commands: [
{
id: "app.scrap",
title: "Open scrap screen",
group: "Debug",
palette: true,
run() {
props.context.ui.router.navigate({ type: "plugin", name: "scrap" })
dialog.clear()
},
},
],
}))
return null
}
function Scrap(props: { api: TuiPluginApi }) {
function Scrap(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions()
const { theme } = useTheme()
useBindings(() => ({
bindings: [
Keymap.createLayer(() => ({
commands: [
{
key: "escape",
desc: "Back home",
bind: "escape",
title: "Back home",
group: "Scrap",
cmd() {
props.api.route.navigate("home")
run() {
props.context.ui.router.navigate({ type: "home" })
},
},
],
@ -43,24 +60,10 @@ function Scrap(props: { api: TuiPluginApi }) {
)
}
const tui: TuiPlugin = async (api) => {
api.route.register([{ name: route, render: () => <Scrap api={api} /> }])
api.keymap.registerLayer({
commands: [
{
name: "app.scrap",
title: "Open scrap screen",
category: "Debug",
namespace: "palette",
run() {
api.route.navigate(route)
api.ui.dialog.clear()
},
},
],
})
}
const plugin: BuiltinTuiPlugin = { id, tui }
export default plugin
export default Plugin.define({
id: "opencode.scrap",
setup(context) {
context.ui.router.register({ name: "scrap", render: () => <Scrap context={context} /> })
context.ui.slot("app", () => <Commands context={context} />)
},
})

View file

@ -358,9 +358,9 @@ function WhichKeyPanel(props: {
},
},
],
bindings: pendingMode()
? props.api.tuiConfig.keybinds.gather("which-key.scroll", scrollCommands)
: props.api.tuiConfig.keybinds.gather("which-key.panel", panelCommands),
bindings: (pendingMode() ? scrollCommands : panelCommands).flatMap((command) =>
props.api.tuiConfig.keybinds.get(command),
),
}))
createEffect(() => {
@ -568,7 +568,7 @@ const tui: TuiPlugin = async (api) => {
},
},
],
bindings: api.tuiConfig.keybinds.gather("which-key.toggle", toggleCommands),
bindings: toggleCommands.flatMap((command) => api.tuiConfig.keybinds.get(command)),
})
api.slots.register({

View file

@ -17,17 +17,26 @@ import { createMemo, type Accessor } from "solid-js"
import { useConfig } from "./config"
import { TuiKeybind } from "./config/keybind"
declare module "@opentui/keymap" {
interface Command {
slash?: {
name: string
aliases?: string[]
}
}
}
export const LEADER_TOKEN = "leader"
export const OPENCODE_BASE_MODE = "base"
export const COMMAND_PALETTE_COMMAND = "command.palette.show"
const OPENCODE_MODE_KEY = "opencode.mode"
export { useBindings, useKeymapSelector }
export const OpencodeKeymapProvider = KeymapProvider
export const useOpencodeKeymap = useKeymap
export { useBindings, useKeymapSelector }
export type OpenTuiKeymap = ReturnType<typeof useKeymap>
type OpencodeModeStack = ReturnType<typeof createOpencodeModeStack>
type CommandSlashEntry = {
@ -36,17 +45,16 @@ type CommandSlashEntry = {
aliases?: string[]
onSelect: () => void
}
type Command = ReturnType<OpenTuiKeymap["getCommands"]>[number]
type RegisteredCommand = ReturnType<OpenTuiKeymap["getCommands"]>[number]
type BindingLookup = {
get(command: string): readonly Binding<Renderable, KeyEvent>[]
gather(name: string, commands: readonly string[]): readonly Binding<Renderable, KeyEvent>[]
}
type FormatConfig = { keybinds: BindingLookup }
type ResolvedKeymapConfig = FormatConfig & ({ leader: { timeout: number } } | { leader_timeout: number })
const modeStacks = new WeakMap<OpenTuiKeymap, OpencodeModeStack>()
function isVisiblePaletteCommand(command: Command) {
function isVisiblePaletteCommand(command: RegisteredCommand) {
return command.hidden !== true && command.name !== COMMAND_PALETTE_COMMAND
}
@ -232,7 +240,7 @@ export function registerOpencodeKeymap(keymap: OpenTuiKeymap, renderer: CliRende
const offBackspace = registerBackspacePopsPendingSequence(keymap)
const offInputBindings = registerManagedTextareaLayer(keymap, renderer, {
enabled: () => hasManagedTextareaFocus(renderer),
bindings: config.keybinds.gather("input", inputCommands),
bindings: inputCommands.flatMap((command) => config.keybinds.get(command)),
})
return () => {
@ -273,20 +281,17 @@ export function useCommandSlashes(): Accessor<readonly CommandSlashEntry[]> {
return createMemo<CommandSlashEntry[]>(() =>
entries().flatMap((entry) => {
const slashName = entry.command.slashName
if (typeof slashName !== "string" || !slashName) return []
const slashAliases = entry.command.slashAliases
const slash = entry.command.slash
if (!slash) return []
return {
display: `/${slashName}`,
display: `/${slash.name}`,
description:
typeof entry.command.desc === "string"
? entry.command.desc
: typeof entry.command.title === "string"
? entry.command.title
: undefined,
aliases: Array.isArray(slashAliases)
? slashAliases.filter((alias): alias is string => typeof alias === "string").map((alias) => `/${alias}`)
: undefined,
aliases: slash.aliases?.map((alias) => `/${alias}`),
onSelect: () => keymap.dispatchCommand(entry.command.name),
}
}),

View file

@ -1,356 +0,0 @@
import type { TuiDialogSelectOption, TuiPluginApi, TuiSlotProps } from "@opencode-ai/plugin/tui"
import type { Config } from "../config"
import type { useEvent } from "../context/event"
import type { useRoute } from "../context/route"
import type { useClient } from "../context/client"
import type { useData } from "../context/data"
import type { useProject } from "../context/project"
import type { useTheme } from "../context/theme"
import { Dialog as DialogUI, type useDialog } from "../ui/dialog"
import type { useOpencodeKeymap } from "../keymap"
import { DialogAlert } from "../ui/dialog-alert"
import { DialogConfirm } from "../ui/dialog-confirm"
import { DialogPrompt } from "../ui/dialog-prompt"
import { DialogSelect, type DialogSelectOption as SelectOption } from "../ui/dialog-select"
import { Prompt } from "../component/prompt"
import type { useToast } from "../ui/toast"
import * as Keymap from "../keymap"
import { createCommandShim } from "./command-shim"
import type { PluginRoutes } from "./api"
export type { RouteMap } from "./api"
export { createPluginRoutes, createTuiApi } from "./api"
type Input = {
version: string
tuiConfig: Config.Resolved
dialog: ReturnType<typeof useDialog>
keymap: ReturnType<typeof useOpencodeKeymap>
route: ReturnType<typeof useRoute>
routes: PluginRoutes
event: ReturnType<typeof useEvent>
client: ReturnType<typeof useClient>
project: ReturnType<typeof useProject>
data: ReturnType<typeof useData>
theme: ReturnType<typeof useTheme>
toast: ReturnType<typeof useToast>
renderer: TuiPluginApi["renderer"]
attention: TuiPluginApi["attention"]
Slot: TuiPluginApi["ui"]["Slot"]
}
function routeNavigate(route: ReturnType<typeof useRoute>, name: string, params?: Record<string, unknown>) {
if (name === "home") {
route.navigate({ type: "home" })
return
}
if (name === "session") {
const sessionID = params?.sessionID
if (typeof sessionID !== "string") return
route.navigate({ type: "session", sessionID })
return
}
route.navigate({ type: "plugin", id: name, data: params })
}
function routeCurrent(route: ReturnType<typeof useRoute>): TuiPluginApi["route"]["current"] {
if (route.data.type === "home") return { name: "home" }
if (route.data.type === "session") {
return {
name: "session",
params: {
sessionID: route.data.sessionID,
prompt: route.data.prompt,
},
}
}
return {
name: route.data.id,
params: route.data.data,
}
}
function mapOption<Value>(item: TuiDialogSelectOption<Value>): SelectOption<Value> {
return {
...item,
onSelect: () => item.onSelect?.(),
}
}
function pickOption<Value>(item: SelectOption<Value>): TuiDialogSelectOption<Value> {
return {
title: item.title,
value: item.value,
description: item.description,
footer: item.footer,
category: item.category,
disabled: item.disabled,
}
}
function mapOptionCb<Value>(cb?: (item: TuiDialogSelectOption<Value>) => void) {
if (!cb) return
return (item: SelectOption<Value>) => cb(pickOption(item))
}
function stateApi(project: ReturnType<typeof useProject>, data: ReturnType<typeof useData>): TuiPluginApi["state"] {
return {
get ready() {
return true
},
get config() {
return {}
},
get provider() {
return []
},
get path() {
return project.instance.path()
},
get vcs() {
return undefined
},
session: {
count() {
return data.session.list().length
},
get(_sessionID) {
return undefined
},
diff(_sessionID) {
return []
},
messages(_sessionID) {
return []
},
status(sessionID) {
return data.session.status(sessionID) === "running" ? { type: "busy" } : { type: "idle" }
},
permission(_sessionID) {
return []
},
question(_sessionID) {
return []
},
},
part(_messageID) {
return []
},
lsp() {
return []
},
mcp() {
return (data.location.mcp.server.list() ?? [])
.toSorted((a, b) => a.name.localeCompare(b.name))
.flatMap((item) =>
item.status.status === "pending"
? []
: [
{
name: item.name,
status: item.status.status,
error: item.status.status === "failed" ? item.status.error : undefined,
},
],
)
},
}
}
function appApi(version: string): TuiPluginApi["app"] {
return {
get version() {
return version
},
}
}
const unsupportedClient = new Proxy(
{},
{
get() {
throw new Error("The legacy plugin client is not supported in V2")
},
},
) as TuiPluginApi["client"]
export function createTuiApiAdapters(input: Input): Omit<TuiPluginApi, "lifecycle"> {
return {
app: appApi(input.version),
attention: input.attention,
// Keep deprecated `api.command` working for v1 plugins; remove in v2.
command: createCommandShim(input.keymap, input.dialog, input.tuiConfig.keybinds),
keys: {
formatSequence(parts) {
return Keymap.formatKeySequence(parts, input.tuiConfig)
},
formatBindings(bindings) {
return Keymap.formatKeyBindings(bindings, input.tuiConfig)
},
},
keymap: input.keymap,
mode: {
current() {
return Keymap.getOpencodeModeStack(input.keymap).current()
},
push(mode) {
return Keymap.getOpencodeModeStack(input.keymap).push(mode)
},
},
route: {
register(list) {
return input.routes.register(list)
},
navigate(name, params) {
routeNavigate(input.route, name, params)
},
get current() {
return routeCurrent(input.route)
},
},
ui: {
Dialog(props) {
return (
<DialogUI size={props.size} onClose={props.onClose}>
{props.children}
</DialogUI>
)
},
DialogAlert(props) {
return <DialogAlert {...props} />
},
DialogConfirm(props) {
return <DialogConfirm {...props} />
},
DialogPrompt(props) {
return <DialogPrompt {...props} description={props.description} />
},
DialogSelect(props) {
return (
<DialogSelect
title={props.title}
placeholder={props.placeholder}
options={props.options.map(mapOption)}
flat={props.flat}
onMove={mapOptionCb(props.onMove)}
onFilter={props.onFilter}
onSelect={mapOptionCb(props.onSelect)}
skipFilter={props.skipFilter}
current={props.current}
/>
)
},
Slot<Name extends string>(props: TuiSlotProps<Name>) {
return <input.Slot {...props} />
},
Prompt(props) {
return (
<Prompt
sessionID={props.sessionID}
visible={props.visible}
disabled={props.disabled}
onSubmit={props.onSubmit}
ref={props.ref}
hint={props.hint}
right={props.right}
showPlaceholder={props.showPlaceholder}
placeholders={props.placeholders}
/>
)
},
toast(inputToast) {
input.toast.show({
title: inputToast.title,
message: inputToast.message,
variant: inputToast.variant ?? "info",
duration: inputToast.duration,
})
},
dialog: {
replace(render, onClose) {
input.dialog.replace(render, onClose)
},
clear() {
input.dialog.clear()
},
setSize(size) {
input.dialog.setSize(size)
},
get size() {
return input.dialog.size
},
get depth() {
return input.dialog.stack.length
},
get open() {
return input.dialog.stack.length > 0
},
},
},
get tuiConfig() {
return input.tuiConfig
},
kv: {
get(_key, fallback) {
if (fallback === undefined) throw new Error("Persistent TUI KV storage is not supported")
return fallback
},
set() {},
ready: true,
},
state: stateApi(input.project, input.data),
client: unsupportedClient,
event: input.event,
renderer: input.renderer,
slots: {
register() {
throw new Error("slots.register is only available in plugin context")
},
},
plugins: {
list() {
return []
},
async activate() {
return false
},
async deactivate() {
return false
},
async add() {
return false
},
async install() {
return {
ok: false,
message: "plugins.install is only available in plugin context",
}
},
},
theme: {
get current() {
return input.theme.theme
},
get selected() {
return input.theme.selected
},
has(name) {
return input.theme.has(name)
},
set(name) {
return input.theme.set(name)
},
async install(_jsonPath) {
throw new Error("theme.install is only available in plugin context")
},
mode() {
return input.theme.mode()
},
get ready() {
return input.theme.ready
},
},
}
}

View file

@ -1,4 +1,4 @@
import type { TuiPluginApi, TuiRouteDefinition } from "@opencode-ai/plugin/tui"
import type { TuiRouteDefinition } from "@opencode-ai/plugin/tui"
import { createSignal } from "solid-js"
type RouteEntry = {
@ -38,15 +38,3 @@ export function createPluginRoutes() {
}
export type PluginRoutes = ReturnType<typeof createPluginRoutes>
export function createTuiApi(input: Omit<TuiPluginApi, "lifecycle">): TuiPluginApi {
return {
...input,
lifecycle: {
signal: new AbortController().signal,
onDispose() {
return () => {}
},
},
}
}

View file

@ -0,0 +1,9 @@
import HomeFooter from "../feature-plugins/home/footer"
import HomeTips from "../feature-plugins/home/tips"
import SidebarContext from "../feature-plugins/sidebar/context"
import SidebarFooter from "../feature-plugins/sidebar/footer"
import SidebarLsp from "../feature-plugins/sidebar/lsp"
import SidebarMcp from "../feature-plugins/sidebar/mcp"
import Scrap from "../feature-plugins/system/scrap"
export const builtins = [HomeFooter, HomeTips, SidebarContext, SidebarMcp, SidebarLsp, SidebarFooter, Scrap]

View file

@ -56,8 +56,7 @@ function toCommand(item: TuiCommand, dialog: LegacyDialog) {
suggested: item.suggested,
hidden: item.hidden,
enabled: item.enabled,
slashName: item.slash?.name,
slashAliases: item.slash?.aliases,
slash: item.slash,
run() {
return item.onSelect?.(dialog)
},

View file

@ -0,0 +1,383 @@
import type { Plugin } from "@opencode-ai/plugin/v2/tui"
import {
batch,
createContext,
createMemo,
For,
onCleanup,
onMount,
useContext,
type JSX,
type ParentProps,
} from "solid-js"
import path from "path"
import { stat } from "fs/promises"
import { fileURLToPath, pathToFileURL } from "url"
import type { Context, Page, Slot } from "@opencode-ai/plugin/v2/tui/context"
import { createStore, produce, reconcile as reconcileStore } from "solid-js/store"
import { useConfig } from "../config"
import { useClient } from "../context/client"
import { useData } from "../context/data"
import { useRoute } from "../context/route"
import { useTuiLifecycle } from "../context/runtime"
import { builtins } from "./builtins"
export interface PackageResolver {
readonly resolve: (spec: string) => Promise<string | undefined>
}
type State =
| { readonly target: string; readonly status: "loading" }
| { readonly target: string; readonly id: string; readonly status: "active" | "inactive" }
| { readonly target: string; readonly status: "unsupported" }
| { readonly target: string; readonly status: "failed"; readonly error: string }
type Value = {
readonly ready: () => boolean
readonly list: () => ReadonlyArray<State>
readonly route: (id: string, name: string) => Page["render"] | undefined
readonly slot: (name: string) => ReadonlyArray<Slot>
readonly activate: (id: string) => Promise<boolean>
readonly deactivate: (id: string) => Promise<boolean>
}
type Dispose = () => Promise<void>
type Registration = {
target: string
plugin: Plugin.Definition
options?: Readonly<Record<string, any>>
active: boolean
routes: Record<string, Page>
slots: Record<string, Slot>
cleanups: Dispose[]
}
const PluginContext = createContext<Value>()
export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>) {
const client = useClient()
const data = useData()
const route = useRoute()
const config = useConfig()
const lifecycle = useTuiLifecycle()
const directory = config.path ? path.dirname(config.path) : process.cwd()
const [store, setStore] = createStore({
ready: false,
states: [] as ReadonlyArray<State>,
registrations: {} as Record<string, Registration>,
})
const activate = async (id: string) => {
const item = store.registrations[id]
if (!item) return false
await deactivate(id)
batch(() => {
setStore("registrations", id, "routes", reconcileStore({}))
setStore("registrations", id, "slots", reconcileStore({}))
setStore("registrations", id, "cleanups", [])
})
const owned: Dispose[] = []
const context: Context = {
options: item.options ?? {},
client: client.api,
data,
ui: {
router: {
register(page) {
if (store.registrations[item.plugin.id]?.routes[page.name])
throw new Error(`Route already registered: ${page.name}`)
setStore("registrations", item.plugin.id, "routes", page.name, page)
let registered = true
const unregister = () => {
if (!registered) return
registered = false
if (!store.registrations[item.plugin.id]?.active) return
setStore(
"registrations",
produce((registrations) => {
if (!registrations[item.plugin.id]) return
delete registrations[item.plugin.id].routes[page.name]
}),
)
}
owned.push(async () => unregister())
return unregister
},
navigate(destination) {
if (destination.type === "plugin") {
route.navigate({ ...destination, id: "id" in destination ? destination.id : item.plugin.id })
return
}
route.navigate(destination)
},
current() {
return route.data
},
},
slot(name, render) {
if (store.registrations[item.plugin.id]?.slots[name]) throw new Error(`Slot already registered: ${name}`)
setStore("registrations", item.plugin.id, "slots", name, () => render)
let registered = true
const unregister = () => {
if (!registered) return
registered = false
if (!store.registrations[item.plugin.id]?.active) return
setStore(
"registrations",
produce((registrations) => {
if (!registrations[item.plugin.id]) return
delete registrations[item.plugin.id].slots[name]
}),
)
}
owned.push(async () => unregister())
return unregister
},
},
}
const cleanup = await setup(item.plugin, context, owned).catch((error) => {
setStore("registrations", id, "routes", reconcileStore({}))
setStore("registrations", id, "slots", reconcileStore({}))
throw error
})
if (cleanup) owned.push(async () => cleanup())
batch(() => {
setStore("registrations", id, "cleanups", owned)
setStore("registrations", id, "active", true)
setStore("states", (items) =>
items.map((state) =>
"id" in state && state.id === id ? { target: state.target, id, status: "active" } : state,
),
)
})
return true
}
const deactivate = async (id: string) => {
const item = store.registrations[id]
if (!item?.active) return false
const cleanups = [...item.cleanups]
batch(() => {
setStore("registrations", id, "active", false)
setStore("registrations", id, "cleanups", [])
})
await disposeAll(cleanups).finally(() =>
batch(() => {
if (store.registrations[id]) {
setStore("registrations", id, "routes", reconcileStore({}))
setStore("registrations", id, "slots", reconcileStore({}))
}
setStore("states", (items) =>
items.map((state) =>
"id" in state && state.id === id ? { target: state.target, id, status: "inactive" } : state,
),
)
}),
)
return true
}
const reconcile = async () => {
await Promise.all(
Object.entries(store.registrations)
.filter(([, registration]) => registration.active)
.map(([id]) => deactivate(id)),
)
const entries = config.data.plugins ?? []
batch(() => {
setStore("registrations", reconcileStore({}))
setStore("states", [])
})
for (const plugin of builtins) {
setStore("registrations", plugin.id, {
target: plugin.id,
plugin,
active: false,
routes: {},
slots: {},
cleanups: [],
})
await activate(plugin.id)
}
for (const entry of entries) {
const target = typeof entry === "string" ? entry : entry.package
if (target.startsWith("-")) {
for (const id of Object.keys(store.registrations).filter((id) => matches(target.slice(1), id)))
await deactivate(id)
continue
}
const selected = Object.keys(store.registrations).filter((id) => matches(target, id))
if (selected.length || target === "*" || target.endsWith(".*") || target.startsWith("opencode.")) {
for (const id of selected) await activate(id)
continue
}
const options = typeof entry === "string" ? undefined : entry.options
setStore("states", (items) => [...items, { target, status: "loading" }])
const plugin = await loadPlugin(target, directory, props.packages).catch((error) => {
setStore("states", (items) =>
items.map((state) =>
state.target === target
? { target, status: "failed", error: error instanceof Error ? error.message : String(error) }
: state,
),
)
return undefined
})
if (!plugin) {
setStore("states", (items) =>
items.map((state) =>
state.target === target && state.status !== "failed" ? { target, status: "unsupported" } : state,
),
)
continue
}
const item = { target, plugin, options }
setStore("registrations", item.plugin.id, {
...item,
active: false,
routes: {},
slots: {},
cleanups: [],
})
const error = await activate(item.plugin.id).then(
() => undefined,
(error) => (error instanceof Error ? error.message : String(error)),
)
setStore("states", (items) => [
...items.filter((state) => state.target !== item.target && (!("id" in state) || state.id !== item.plugin.id)),
error
? { target: item.target, status: "failed", error }
: { target: item.target, id: item.plugin.id, status: "active" },
])
}
}
onMount(() => {
const loading = reconcile()
let disposing: Promise<void> | undefined
const dispose = () => {
if (disposing) return disposing
disposing = loading
.catch(() => undefined)
.then(() =>
Promise.all(
Object.entries(store.registrations)
.filter(([, registration]) => registration.active)
.map(([id]) => deactivate(id)),
),
)
.then(() => setStore("registrations", reconcileStore({})))
return disposing
}
const unregister = lifecycle.add(dispose)
onCleanup(() => {
unregister()
void dispose()
})
void loading.finally(() => setStore("ready", true))
})
return (
<PluginContext.Provider
value={{
ready: () => store.ready,
list: () => store.states,
route: (id, name) => store.registrations[id]?.routes[name]?.render,
slot: (name) =>
Object.values(store.registrations).flatMap((registration) =>
registration.active && registration.slots[name] ? [registration.slots[name]] : [],
),
activate,
deactivate,
}}
>
{props.children}
</PluginContext.Provider>
)
}
async function disposeAll(cleanups: Dispose[]) {
const failures: unknown[] = []
for (const cleanup of cleanups.splice(0).reverse()) await cleanup().catch((error) => failures.push(error))
if (failures.length) throw failures[0]
}
async function setup(plugin: Plugin.Definition, context: Plugin.Context, owned: Dispose[]) {
try {
return await plugin.setup(context)
} catch (error) {
await disposeAll(owned).catch(() => undefined)
throw error
}
}
function matches(selector: string, id: string) {
return selector === "*" || selector === id || (selector.endsWith(".*") && id.startsWith(selector.slice(0, -1)))
}
async function loadPlugin(spec: string, directory: string, packages: PackageResolver) {
const local = spec.startsWith("file://")
? new URL(spec)
: spec.startsWith("./") || spec.startsWith("../") || path.isAbsolute(spec)
? pathToFileURL(path.resolve(directory, spec))
: undefined
const entrypoint = local ? await resolveLocal(local) : await packages.resolve(spec)
if (!entrypoint) return
const mod: { readonly default?: unknown } = await import(entrypoint)
if (!isPlugin(mod.default)) throw new Error(`Invalid V2 TUI plugin module: ${spec}`)
return mod.default
}
async function resolveLocal(url: URL) {
const info = await stat(url)
if (info.isFile()) return url.href
if (!info.isDirectory()) return
return resolve(pathToFileURL(path.join(fileURLToPath(url), "tui")).href)
}
function resolve(specifier: string) {
try {
return import.meta.resolve(specifier)
} catch {
return undefined
}
}
function isPlugin(value: unknown): value is Plugin.Definition {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
typeof value.id === "string" &&
value.id.length > 0 &&
"setup" in value &&
typeof value.setup === "function"
)
}
export function usePlugin() {
const value = useContext(PluginContext)
if (!value) throw new Error("PluginProvider is missing")
return value
}
export function PluginRoute(props: { readonly fallback: (id: string, name: string) => JSX.Element }) {
const plugins = usePlugin()
const route = useRoute()
const content = createMemo(() => {
if (route.data.type !== "plugin") return
const render = plugins.route(route.data.id, route.data.name)
if (!render) return props.fallback(route.data.id, route.data.name)
return render({ data: route.data.data })
})
return <>{content()}</>
}
export function PluginSlot(props: { readonly name: string; readonly input?: Record<string, any> }) {
const plugins = usePlugin()
return <For each={plugins.slot(props.name)}>{(render) => render(props.input ?? {})}</For>
}

View file

@ -23,7 +23,7 @@ function isHostSlotPlugin(value: unknown): value is HostSlotPlugin<Record<string
}
export function createSlots() {
const empty: SlotView = () => null
const empty: SlotView = (props) => props.children ?? null
const [view, setView] = createSignal<SlotView>(empty)
const Slot: SlotView = (props) => view()(props)

View file

@ -12,6 +12,7 @@ import { HomeSessionDestinationProvider } from "./home/session-destination"
import { useData } from "../context/data"
import { LocationProvider } from "../context/location"
import { FormPrompt } from "./session/form"
import { PluginSlot } from "../plugin/context"
let once = false
const placeholder = {
@ -84,26 +85,18 @@ export function Home() {
/>
</pluginRuntime.Slot>
</box>
<pluginRuntime.Slot name="home_bottom" />
<PluginSlot name="home.bottom" />
<box flexGrow={1} minHeight={0} />
<Toast />
</box>
<box width="100%" flexShrink={0}>
<pluginRuntime.Slot name="home_footer" mode="single_winner" />
<PluginSlot name="home.footer" />
</box>
<Show when={forms()[0]?.id} keyed>
{(_) => {
const form = forms()[0]
return form ? (
<box
position="absolute"
zIndex={2000}
left={0}
right={0}
bottom={1}
paddingLeft={2}
paddingRight={2}
>
<box position="absolute" zIndex={2000} left={0} right={0} bottom={1} paddingLeft={2} paddingRight={2}>
<box width="100%">
<FormPrompt form={form} />
</box>

View file

@ -3,7 +3,7 @@ import { createStore } from "solid-js/store"
import { TextAttributes } from "@opentui/core"
import { useTheme } from "../../../context/theme"
import { SplitBorder } from "../../../ui/border"
import { useBindings, useOpencodeModeStack } from "../../../keymap"
import { Keymap } from "../../../context/keymap"
import { SubagentsTab } from "./subagents-tab"
import { ShellTab } from "./shell-tab"
@ -75,10 +75,10 @@ export function Composer(props: ComposerProps) {
},
}
const modeStack = useOpencodeModeStack()
const keymap = Keymap.use()
createEffect(() => {
if (!props.open) return
const popMode = modeStack.push("composer")
const popMode = keymap.mode.push("composer")
onCleanup(popMode)
})
@ -89,18 +89,18 @@ export function Composer(props: ComposerProps) {
setStore("active", tabs[(idx + dir + tabs.length) % tabs.length].id)
}
useBindings(() => ({
Keymap.createLayer(() => ({
mode: "composer",
enabled: () => props.open,
bindings: [
{ key: "left", desc: "Previous tab", group: "Composer", cmd: () => switchTab(-1) },
{ key: "right", desc: "Next tab", group: "Composer", cmd: () => switchTab(1) },
{ key: "escape", desc: "Close composer", group: "Composer", cmd: close },
commands: [
{ bind: "left", title: "Previous tab", group: "Composer", run: () => switchTab(-1) },
{ bind: "right", title: "Next tab", group: "Composer", run: () => switchTab(1) },
{ bind: "escape", title: "Close composer", group: "Composer", run: close },
{
key: "<leader>down",
desc: "Toggle composer",
bind: "<leader>down",
title: "Toggle composer",
group: "Composer",
cmd: close,
run: close,
},
],
}))

View file

@ -5,7 +5,7 @@ import { useData } from "../../../context/data"
import { useLocation } from "../../../context/location"
import { useClient } from "../../../context/client"
import { useTheme, selectedForeground } from "../../../context/theme"
import { useBindings, useCommandShortcut } from "../../../keymap"
import { Keymap } from "../../../context/keymap"
import { useComposerTab } from "./index"
export function ShellTab(props: { sessionID: string }) {
@ -15,7 +15,7 @@ export function ShellTab(props: { sessionID: string }) {
const { theme } = useTheme()
const fg = selectedForeground(theme)
const composer = useComposerTab()
const killHint = useCommandShortcut("composer.shell.kill")
const shortcuts = Keymap.useShortcuts()
const entries = createMemo(() =>
data.shell
@ -47,19 +47,21 @@ export function ShellTab(props: { sessionID: string }) {
const cleanup = composer.register({
id: "shell",
label: "Shell",
hints: () => (selectedEntry() ? [{ label: "kill", shortcut: killHint() }] : []),
hints: () =>
selectedEntry() ? [{ label: "kill", shortcut: shortcuts.get("composer.shell.kill") ?? "" }] : [],
})
onCleanup(cleanup)
})
useBindings(() => ({
Keymap.createLayer(() => ({
mode: "composer",
enabled: () => composer.active("shell"),
commands: [
{
name: "composer.shell.up",
id: "composer.shell.up",
title: "Previous shell",
category: "Composer",
group: "Composer",
bind: "up",
run() {
const list = entries()
if (list.length === 0) return
@ -67,9 +69,10 @@ export function ShellTab(props: { sessionID: string }) {
},
},
{
name: "composer.shell.down",
id: "composer.shell.down",
title: "Next shell",
category: "Composer",
group: "Composer",
bind: "down",
run() {
const list = entries()
if (list.length === 0) return
@ -77,9 +80,10 @@ export function ShellTab(props: { sessionID: string }) {
},
},
{
name: "composer.shell.kill",
id: "composer.shell.kill",
title: "Kill shell command",
category: "Composer",
group: "Composer",
bind: "ctrl+d",
run() {
const entry = selectedEntry()
if (!entry) return
@ -91,11 +95,6 @@ export function ShellTab(props: { sessionID: string }) {
},
},
],
bindings: [
{ key: "up", desc: "Previous shell", group: "Shell", cmd: "composer.shell.up" },
{ key: "down", desc: "Next shell", group: "Shell", cmd: "composer.shell.down" },
{ key: "ctrl+d", desc: "Kill shell command", group: "Shell", cmd: "composer.shell.kill" },
],
}))
return (

View file

@ -6,7 +6,7 @@ import { useData } from "../../../context/data"
import { useClient } from "../../../context/client"
import { useTheme, selectedForeground } from "../../../context/theme"
import { Locale } from "../../../util/locale"
import { useBindings, useCommandShortcut } from "../../../keymap"
import { Keymap } from "../../../context/keymap"
import { useComposerTab } from "./index"
interface SubagentEntry {
@ -25,7 +25,7 @@ export function SubagentsTab(props: { sessionID: string }) {
const fg = selectedForeground(theme)
const navigate = useRoute().navigate
const composer = useComposerTab()
const interruptHint = useCommandShortcut("composer.subagent.interrupt")
const shortcuts = Keymap.useShortcuts()
const session = createMemo(() => data.session.get(props.sessionID))
@ -133,7 +133,7 @@ export function SubagentsTab(props: { sessionID: string }) {
hints: () => {
const entry = selectedEntry()
if (!entry || entry.status !== "running") return []
return [{ label: "interrupt", shortcut: interruptHint() }]
return [{ label: "interrupt", shortcut: shortcuts.get("composer.subagent.interrupt") ?? "" }]
},
onClose: () => {
const parentID = session()?.parentID
@ -143,14 +143,15 @@ export function SubagentsTab(props: { sessionID: string }) {
onCleanup(cleanup)
})
useBindings(() => ({
Keymap.createLayer(() => ({
mode: "composer",
enabled: () => composer.active("subagents"),
commands: [
{
name: "composer.subagent.up",
id: "composer.subagent.up",
title: "Previous subagent",
category: "Composer",
group: "Composer",
bind: "up",
run() {
const list = entries()
if (list.length === 0) return
@ -158,9 +159,10 @@ export function SubagentsTab(props: { sessionID: string }) {
},
},
{
name: "composer.subagent.down",
id: "composer.subagent.down",
title: "Next subagent",
category: "Composer",
group: "Composer",
bind: "down",
run() {
const list = entries()
if (list.length === 0) return
@ -168,18 +170,20 @@ export function SubagentsTab(props: { sessionID: string }) {
},
},
{
name: "composer.subagent.select",
id: "composer.subagent.select",
title: "Navigate to subagent",
category: "Composer",
group: "Composer",
bind: "return",
run() {
const entry = entries()[store.selected]
if (entry) navigate({ type: "session", sessionID: entry.sessionID })
},
},
{
name: "composer.subagent.interrupt",
id: "composer.subagent.interrupt",
title: "Interrupt subagent",
category: "Composer",
group: "Composer",
bind: "ctrl+d",
run() {
const entry = selectedEntry()
if (!entry || entry.status !== "running") return
@ -187,12 +191,6 @@ export function SubagentsTab(props: { sessionID: string }) {
},
},
],
bindings: [
{ key: "up", desc: "Previous subagent", group: "Subagents", cmd: "composer.subagent.up" },
{ key: "down", desc: "Next subagent", group: "Subagents", cmd: "composer.subagent.down" },
{ key: "return", desc: "Navigate to subagent", group: "Subagents", cmd: "composer.subagent.select" },
{ key: "ctrl+d", desc: "Interrupt subagent", group: "Subagents", cmd: "composer.subagent.interrupt" },
],
}))
return (

View file

@ -11,8 +11,7 @@ import { useClient } from "../../context/client"
import { useClipboard } from "../../context/clipboard"
import { SplitBorder } from "../../ui/border"
import { useToast } from "../../ui/toast"
import { useConfig } from "../../config"
import { useBindings, useOpencodeModeStack } from "../../keymap"
import { Keymap } from "../../context/keymap"
const FORM_MODE = "form"
@ -150,8 +149,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
const { theme } = useTheme()
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const config = useConfig().data
const modeStack = useOpencodeModeStack()
const keymap = Keymap.use()
const clipboard = useClipboard()
const toast = useToast()
const configuredFields = props.form.fields.filter(isField)
@ -555,16 +553,16 @@ export function FormPrompt(props: { form: FormWithLocation }) {
})
}
onMount(() => onCleanup(modeStack.push(FORM_MODE)))
onMount(() => onCleanup(keymap.mode.push(FORM_MODE)))
useBindings(() => ({
Keymap.createLayer(() => ({
mode: FORM_MODE,
enabled: (store.editing || textual()) && !confirm(),
commands: [
{
name: "prompt.clear",
id: "prompt.clear",
title: "Clear answer edit",
category: "Form",
group: "Form",
run() {
const text = textarea?.plainText ?? ""
if (!text) {
@ -574,13 +572,11 @@ export function FormPrompt(props: { form: FormWithLocation }) {
textarea?.setText("")
},
},
],
bindings: [
{
key: "escape",
desc: "Cancel answer edit",
bind: "escape",
title: "Cancel answer edit",
group: "Form",
cmd: () => {
run: () => {
if (textual()) {
void client.api.form.cancel(
{ sessionID: props.form.sessionID, formID: props.form.id },
@ -591,30 +587,29 @@ export function FormPrompt(props: { form: FormWithLocation }) {
setStore("editing", false)
},
},
...config.keybinds.get("prompt.clear"),
{
key: "tab",
desc: "Next field",
bind: "tab",
title: "Next field",
group: "Form",
cmd: () => {
run: () => {
const text = textarea?.plainText?.trim() ?? ""
submitInput(text)
},
},
{
key: "shift+tab",
desc: "Previous field",
bind: "shift+tab",
title: "Previous field",
group: "Form",
cmd: () => {
run: () => {
const text = textarea?.plainText?.trim() ?? ""
submitInput(text, -1)
},
},
{
key: "return",
desc: "Submit answer edit",
bind: "return",
title: "Submit answer edit",
group: "Form",
cmd: () => {
run: () => {
const text = textarea?.plainText?.trim() ?? ""
const current = answerField()
if (!current) return
@ -634,7 +629,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
],
}))
useBindings(() => {
Keymap.createLayer(() => {
const total = rows().length + (custom() ? 1 : 0)
const max = Math.min(total, 9)
const external = externalField()
@ -644,118 +639,113 @@ export function FormPrompt(props: { form: FormWithLocation }) {
enabled: !store.editing && !textual(),
commands: [
{
name: "app.exit",
id: "app.exit",
title: "Dismiss form",
category: "Form",
group: "Form",
run: cancel,
},
],
bindings: [
{
key: "left",
desc: "Previous field",
bind: "left",
title: "Previous field",
group: "Form",
cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()),
run: () => selectTab((store.tab - 1 + tabs()) % tabs()),
},
{
key: "h",
desc: "Previous field",
bind: "h",
title: "Previous field",
group: "Form",
cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()),
run: () => selectTab((store.tab - 1 + tabs()) % tabs()),
},
{ key: "right", desc: "Next field", group: "Form", cmd: () => selectTab((store.tab + 1) % tabs()) },
{ key: "l", desc: "Next field", group: "Form", cmd: () => selectTab((store.tab + 1) % tabs()) },
{ bind: "right", title: "Next field", group: "Form", run: () => selectTab((store.tab + 1) % tabs()) },
{ bind: "l", title: "Next field", group: "Form", run: () => selectTab((store.tab + 1) % tabs()) },
{
key: "tab",
desc: "Next field",
bind: "tab",
title: "Next field",
group: "Form",
cmd: () => selectTab((store.tab + 1) % tabs()),
run: () => selectTab((store.tab + 1) % tabs()),
},
{
key: "shift+tab",
desc: "Previous field",
bind: "shift+tab",
title: "Previous field",
group: "Form",
cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()),
run: () => selectTab((store.tab - 1 + tabs()) % tabs()),
},
...(external
? [
{
key: "return",
desc:
bind: "return",
title:
store.answers[external.key] === true
? "Continue"
: store.externalReady[external.key]
? "Confirm completion"
: "Open link",
group: "Form",
cmd: acknowledgeExternal,
run: acknowledgeExternal,
},
{ key: "c", desc: "Copy link", group: "Form", cmd: copyExternal },
{ key: "escape", desc: "Dismiss form", group: "Form", cmd: cancel },
...config.keybinds.get("app.exit"),
{ bind: "c", title: "Copy link", group: "Form", run: copyExternal },
{ bind: "escape", title: "Dismiss form", group: "Form", run: cancel },
]
: confirm()
? [
{
key: "return",
desc: "Submit form",
bind: "return",
title: "Submit form",
group: "Form",
cmd: submit,
run: submit,
},
{
key: "escape",
desc: "Dismiss form",
bind: "escape",
title: "Dismiss form",
group: "Form",
cmd: cancel,
run: cancel,
},
{ key: "up", desc: "Scroll review", group: "Form", cmd: () => review?.scrollBy(-1) },
{ key: "k", desc: "Scroll review", group: "Form", cmd: () => review?.scrollBy(-1) },
{ key: "down", desc: "Scroll review", group: "Form", cmd: () => review?.scrollBy(1) },
{ key: "j", desc: "Scroll review", group: "Form", cmd: () => review?.scrollBy(1) },
...config.keybinds.get("app.exit"),
{ bind: "up", title: "Scroll review", group: "Form", run: () => review?.scrollBy(-1) },
{ bind: "k", title: "Scroll review", group: "Form", run: () => review?.scrollBy(-1) },
{ bind: "down", title: "Scroll review", group: "Form", run: () => review?.scrollBy(1) },
{ bind: "j", title: "Scroll review", group: "Form", run: () => review?.scrollBy(1) },
]
: [
...Array.from({ length: max }, (_, index) => ({
key: String(index + 1),
desc: `Select answer ${index + 1}`,
bind: String(index + 1),
title: `Select answer ${index + 1}`,
group: "Form",
cmd: () => {
run: () => {
setStore("selected", index)
selectOption()
},
})),
{
key: "up",
desc: "Previous answer",
bind: "up",
title: "Previous answer",
group: "Form",
cmd: () => setStore("selected", (store.selected - 1 + total) % total),
run: () => setStore("selected", (store.selected - 1 + total) % total),
},
{
key: "k",
desc: "Previous answer",
bind: "k",
title: "Previous answer",
group: "Form",
cmd: () => setStore("selected", (store.selected - 1 + total) % total),
run: () => setStore("selected", (store.selected - 1 + total) % total),
},
{
key: "down",
desc: "Next answer",
bind: "down",
title: "Next answer",
group: "Form",
cmd: () => setStore("selected", (store.selected + 1) % total),
run: () => setStore("selected", (store.selected + 1) % total),
},
{
key: "j",
desc: "Next answer",
bind: "j",
title: "Next answer",
group: "Form",
cmd: () => setStore("selected", (store.selected + 1) % total),
run: () => setStore("selected", (store.selected + 1) % total),
},
{ key: "return", desc: "Select answer", group: "Form", cmd: () => selectOption() },
{ bind: "return", title: "Select answer", group: "Form", run: () => selectOption() },
{
key: "escape",
desc: "Dismiss form",
bind: "escape",
title: "Dismiss form",
group: "Form",
cmd: cancel,
run: cancel,
},
...config.keybinds.get("app.exit"),
]),
],
}

View file

@ -77,45 +77,6 @@ import { switchLabel } from "../../util/model"
addDefaultParsers(parsers.parsers)
const sessionBindingCommands = [
"session.share",
"session.rename",
"session.timeline",
"session.fork",
"session.compact",
"session.unshare",
"session.undo",
"session.redo",
"session.sidebar.toggle",
"session.toggle.thinking",
"session.toggle.scrollbar",
"session.toggle.exploration_grouping",
"session.first",
"session.last",
"session.messages_last_user",
"session.message.next",
"session.message.previous",
"messages.copy",
"session.copy",
"session.export",
"session.background",
"session.child.first",
"session.parent",
"session.child.next",
"session.child.previous",
] as const
const sessionGlobalBindingCommands = [
"session.page.up",
"session.page.down",
"session.line.up",
"session.line.down",
"session.half.page.up",
"session.half.page.down",
] as const
const sessionGlobalUnfocusedBindingCommands = ["session.first", "session.last"] as const
const context = createContext<{
width: number
sessionID: string
@ -339,10 +300,96 @@ export function Session() {
}, 50)
}
const sessionCommandList = createMemo(() => [
const globalCommands = [
{
name: "session.page.up",
title: "Page up",
category: "Session",
hidden: true,
run: () => {
scroll.scrollBy(-scroll.height / 2)
dialog.clear()
},
},
{
name: "session.page.down",
title: "Page down",
category: "Session",
hidden: true,
run: () => {
scroll.scrollBy(scroll.height / 2)
dialog.clear()
},
},
{
name: "session.line.up",
title: "Line up",
category: "Session",
hidden: true,
run: () => {
scroll.scrollBy(-1)
dialog.clear()
},
},
{
name: "session.line.down",
title: "Line down",
category: "Session",
hidden: true,
run: () => {
scroll.scrollBy(1)
dialog.clear()
},
},
{
name: "session.half.page.up",
title: "Half page up",
category: "Session",
hidden: true,
run: () => {
scroll.scrollBy(-scroll.height / 4)
dialog.clear()
},
},
{
name: "session.half.page.down",
title: "Half page down",
category: "Session",
hidden: true,
run: () => {
scroll.scrollBy(scroll.height / 4)
dialog.clear()
},
},
]
const baseAndUnfocusedCommands = [
{
name: "session.first",
title: "First message",
category: "Session",
hidden: true,
run: () => {
scroll.scrollTo(0)
dialog.clear()
},
},
{
name: "session.last",
title: "Last message",
category: "Session",
hidden: true,
run: () => {
scroll.scrollTo(scroll.scrollHeight)
dialog.clear()
},
},
]
const baseCommands = createMemo(() => [
{
title: "Share session",
value: "session.share",
name: "session.share",
suggested: route.type === "session",
category: "Session",
slash: { name: "share" },
@ -350,21 +397,21 @@ export function Session() {
},
{
title: "Rename session",
value: "session.rename",
name: "session.rename",
category: "Session",
slash: { name: "rename" },
run: () => DialogSessionRename.show(dialog, route.sessionID, session()?.title),
},
{
title: "Jump to message",
value: "session.timeline",
name: "session.timeline",
category: "Session",
slash: { name: "timeline" },
run: () => unavailable("The message timeline"),
},
{
title: "Fork session",
value: "session.fork",
name: "session.fork",
category: "Session",
slash: { name: "fork" },
run: () => {
@ -382,7 +429,7 @@ export function Session() {
},
{
title: "Compact session",
value: "session.compact",
name: "session.compact",
category: "Session",
slash: {
name: "compact",
@ -395,7 +442,7 @@ export function Session() {
},
{
title: "Unshare session",
value: "session.unshare",
name: "session.unshare",
category: "Session",
enabled: false,
slash: { name: "unshare" },
@ -403,7 +450,7 @@ export function Session() {
},
{
title: "Undo previous message",
value: "session.undo",
name: "session.undo",
category: "Session",
slash: { name: "undo" },
run: () => {
@ -439,7 +486,7 @@ export function Session() {
},
{
title: "Redo",
value: "session.redo",
name: "session.redo",
category: "Session",
enabled: !!session()?.revert?.messageID,
slash: { name: "redo" },
@ -456,7 +503,7 @@ export function Session() {
},
{
title: sidebarVisible() ? "Hide sidebar" : "Show sidebar",
value: "session.sidebar.toggle",
name: "session.sidebar.toggle",
category: "Session",
run: () => {
batch(() => {
@ -477,7 +524,7 @@ export function Session() {
if (next === "hide") return "Collapse thinking"
return "Expand thinking"
})(),
value: "session.toggle.thinking",
name: "session.toggle.thinking",
category: "Session",
hidden: true,
slash: {
@ -495,7 +542,7 @@ export function Session() {
},
{
title: "Toggle session scrollbar",
value: "session.toggle.scrollbar",
name: "session.toggle.scrollbar",
category: "Session",
hidden: true,
run: () => {
@ -509,7 +556,7 @@ export function Session() {
},
{
title: groupExploration() ? "Show tool calls individually" : "Group related tool calls",
value: "session.toggle.exploration_grouping",
name: "session.toggle.exploration_grouping",
category: "Session",
hidden: true,
run: () => {
@ -521,89 +568,9 @@ export function Session() {
dialog.clear()
},
},
{
title: "Page up",
value: "session.page.up",
category: "Session",
hidden: true,
run: () => {
scroll.scrollBy(-scroll.height / 2)
dialog.clear()
},
},
{
title: "Page down",
value: "session.page.down",
category: "Session",
hidden: true,
run: () => {
scroll.scrollBy(scroll.height / 2)
dialog.clear()
},
},
{
title: "Line up",
value: "session.line.up",
category: "Session",
hidden: true,
run: () => {
scroll.scrollBy(-1)
dialog.clear()
},
},
{
title: "Line down",
value: "session.line.down",
category: "Session",
hidden: true,
run: () => {
scroll.scrollBy(1)
dialog.clear()
},
},
{
title: "Half page up",
value: "session.half.page.up",
category: "Session",
hidden: true,
run: () => {
scroll.scrollBy(-scroll.height / 4)
dialog.clear()
},
},
{
title: "Half page down",
value: "session.half.page.down",
category: "Session",
hidden: true,
run: () => {
scroll.scrollBy(scroll.height / 4)
dialog.clear()
},
},
{
title: "First message",
value: "session.first",
category: "Session",
hidden: true,
run: () => {
scroll.scrollTo(0)
dialog.clear()
},
},
{
title: "Last message",
value: "session.last",
category: "Session",
hidden: true,
run: () => {
scroll.scrollTo(scroll.scrollHeight)
dialog.clear()
},
},
{
title: "Jump to last user message",
value: "session.messages_last_user",
name: "session.messages_last_user",
category: "Session",
hidden: true,
run: () => {
@ -626,21 +593,21 @@ export function Session() {
},
{
title: "Next message",
value: "session.message.next",
name: "session.message.next",
category: "Session",
hidden: true,
run: () => scrollToMessage("next", dialog),
},
{
title: "Previous message",
value: "session.message.previous",
name: "session.message.previous",
category: "Session",
hidden: true,
run: () => scrollToMessage("prev", dialog),
},
{
title: "Copy last assistant message",
value: "messages.copy",
name: "messages.copy",
category: "Session",
run: () => {
const revertID = session()?.revert?.messageID
@ -682,7 +649,7 @@ export function Session() {
},
{
title: "Copy session transcript",
value: "session.copy",
name: "session.copy",
category: "Session",
slash: {
name: "copy",
@ -702,7 +669,7 @@ export function Session() {
},
{
title: "Export session transcript",
value: "session.export",
name: "session.export",
category: "Session",
slash: {
name: "export",
@ -772,7 +739,7 @@ export function Session() {
},
{
title: "Background blocking tools",
value: "session.background",
name: "session.background",
category: "Session",
hidden: true,
run: () => {
@ -782,7 +749,7 @@ export function Session() {
},
{
title: "Toggle subagent picker",
value: "session.child.first",
name: "session.child.first",
category: "Session",
run: () => {
if (composer.open || session()?.parentID) setComposer("open", false)
@ -792,7 +759,7 @@ export function Session() {
},
{
title: "Go to parent session",
value: "session.parent",
name: "session.parent",
category: "Session",
hidden: true,
enabled: !!session()?.parentID,
@ -809,7 +776,7 @@ export function Session() {
},
{
title: "Next child session",
value: "session.child.next",
name: "session.child.next",
category: "Session",
hidden: true,
enabled: !!session()?.parentID,
@ -817,7 +784,7 @@ export function Session() {
},
{
title: "Previous child session",
value: "session.child.previous",
name: "session.child.previous",
category: "Session",
hidden: true,
enabled: !!session()?.parentID,
@ -825,33 +792,25 @@ export function Session() {
},
])
const sessionCommands = createMemo(() =>
sessionCommandList().map((command) => ({
useBindings(() => ({
commands: [...globalCommands, ...baseAndUnfocusedCommands, ...baseCommands()].map((command) => ({
namespace: "palette",
name: command.value,
desc: "description" in command ? command.description : undefined,
slashName: "slash" in command ? command.slash?.name : undefined,
slashAliases: "slash" in command ? command.slash?.aliases : undefined,
...command,
})),
)
useBindings(() => ({
commands: sessionCommands(),
}))
useBindings(() => ({
bindings: config.keybinds.gather("session.global", sessionGlobalBindingCommands),
bindings: globalCommands.flatMap((command) => config.keybinds.get(command.name)),
}))
useBindings(() => ({
enabled: () => renderer.currentFocusedEditor === null,
bindings: config.keybinds.gather("session.global.unfocused", sessionGlobalUnfocusedBindingCommands),
bindings: baseAndUnfocusedCommands.flatMap((command) => config.keybinds.get(command.name)),
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
bindings: config.keybinds.gather("session", sessionBindingCommands),
bindings: [...baseAndUnfocusedCommands, ...baseCommands()].flatMap((command) => config.keybinds.get(command.name)),
}))
// snap to bottom when session changes

View file

@ -13,7 +13,7 @@ import { Locale } from "../../util/locale"
import { webSearchProviderLabel } from "../../util/tool-display"
import { getScrollAcceleration } from "../../util/scroll"
import { useConfig } from "../../config"
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut } from "../../keymap"
import { Keymap } from "../../context/keymap"
import { usePathFormatter } from "../../context/path-format"
type PermissionStage = "permission" | "always" | "reject"
@ -470,29 +470,25 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: () => void }) {
let input: TextareaRenderable
const { theme } = useTheme()
const config = useConfig().data
const dimensions = useTerminalDimensions()
const narrow = createMemo(() => dimensions().width < 80)
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
Keymap.createLayer(() => ({
mode: "base",
commands: [
{
name: "app.exit",
id: "app.exit",
title: "Cancel permission rejection",
category: "Permission",
group: "Permission",
run() {
props.onCancel()
},
},
],
bindings: [
{ key: "escape", desc: "Cancel permission rejection", group: "Permission", cmd: () => props.onCancel() },
...config.keybinds.get("app.exit"),
{ bind: "escape", title: "Cancel permission rejection", group: "Permission", run: () => props.onCancel() },
{
key: "return",
desc: "Confirm permission rejection",
bind: "return",
title: "Confirm permission rejection",
group: "Permission",
cmd: () => props.onConfirm(input.plainText),
run: () => props.onConfirm(input.plainText),
},
],
}))
@ -558,7 +554,6 @@ function Prompt<const T extends Record<string, string>>(props: {
onSelect: (option: keyof T) => void
}) {
const { theme } = useTheme()
const config = useConfig().data
const dimensions = useTerminalDimensions()
const keys = Object.keys(props.options) as (keyof T)[]
const [store, setStore] = createStore({
@ -566,89 +561,91 @@ function Prompt<const T extends Record<string, string>>(props: {
expanded: false,
})
const narrow = createMemo(() => dimensions().width < 80)
const fullscreenHint = useCommandShortcut("permission.prompt.fullscreen")
const shortcuts = Keymap.useShortcuts()
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
Keymap.createLayer(() => ({
mode: "base",
commands: [
{
name: "app.exit",
id: "app.exit",
title: "Reject permission",
category: "Permission",
group: "Permission",
bind: false,
run() {
if (!props.escapeKey) return
props.onSelect(props.escapeKey)
},
},
{
name: "permission.prompt.fullscreen",
id: "permission.prompt.fullscreen",
title: "Toggle permission fullscreen",
category: "Permission",
group: "Permission",
bind: false,
run() {
if (!props.fullscreen) return
setStore("expanded", (v) => !v)
},
},
],
bindings: [
{
key: "left",
desc: "Previous permission option",
bind: "left",
title: "Previous permission option",
group: "Permission",
cmd: () => {
run: () => {
const idx = keys.indexOf(store.selected)
const next = keys[(idx - 1 + keys.length) % keys.length]
setStore("selected", next)
},
},
{
key: "h",
desc: "Previous permission option",
bind: "h",
title: "Previous permission option",
group: "Permission",
cmd: () => {
run: () => {
const idx = keys.indexOf(store.selected)
const next = keys[(idx - 1 + keys.length) % keys.length]
setStore("selected", next)
},
},
{
key: "right",
desc: "Next permission option",
bind: "right",
title: "Next permission option",
group: "Permission",
cmd: () => {
run: () => {
const idx = keys.indexOf(store.selected)
const next = keys[(idx + 1) % keys.length]
setStore("selected", next)
},
},
{
key: "l",
desc: "Next permission option",
bind: "l",
title: "Next permission option",
group: "Permission",
cmd: () => {
run: () => {
const idx = keys.indexOf(store.selected)
const next = keys[(idx + 1) % keys.length]
setStore("selected", next)
},
},
{
key: "return",
desc: "Select permission option",
bind: "return",
title: "Select permission option",
group: "Permission",
cmd: () => props.onSelect(store.selected),
run: () => props.onSelect(store.selected),
},
...(props.escapeKey
? [
{
key: "escape",
desc: "Reject permission",
bind: "escape",
title: "Reject permission",
group: "Permission",
cmd: () => props.onSelect(props.escapeKey!),
run: () => props.onSelect(props.escapeKey!),
},
]
: []),
...(props.escapeKey ? config.keybinds.get("app.exit") : []),
...(props.fullscreen ? config.keybinds.get("permission.prompt.fullscreen") : []),
],
bindings: [
...(props.escapeKey ? ["app.exit"] : []),
...(props.fullscreen ? ["permission.prompt.fullscreen"] : []),
],
}))
@ -723,7 +720,7 @@ function Prompt<const T extends Record<string, string>>(props: {
<box flexDirection="row" gap={2} flexShrink={0}>
<Show when={props.fullscreen}>
<text fg={theme.text}>
{fullscreenHint()} <span style={{ fg: theme.textMuted }}>{hint()}</span>
{shortcuts.get("permission.prompt.fullscreen")} <span style={{ fg: theme.textMuted }}>{hint()}</span>
</text>
</Show>
<text fg={theme.text}>

View file

@ -2,8 +2,8 @@ import { useData } from "../../context/data"
import { createMemo, Show } from "solid-js"
import { useTheme } from "../../context/theme"
import { useConfig } from "../../config"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { usePluginRuntime } from "../../plugin/runtime"
import { PluginSlot } from "../../plugin/context"
import { getScrollAcceleration } from "../../util/scroll"
@ -49,31 +49,16 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
<b>{session()!.title}</b>
</text>
<Show when={session()!.location.workspaceID}>
<text fg={theme.textMuted}>
{session()!.location.workspaceID}
</text>
<text fg={theme.textMuted}>{session()!.location.workspaceID}</text>
</Show>
</box>
</pluginRuntime.Slot>
<pluginRuntime.Slot name="sidebar_content" session_id={props.sessionID} />
<PluginSlot name="sidebar.content" input={{ sessionID: props.sessionID }} />
</box>
</scrollbox>
<box flexShrink={0} gap={1} paddingTop={1}>
<pluginRuntime.Slot
name="sidebar_footer"
mode="single_winner"
session_id={props.sessionID}
directory={session()?.location.directory ?? ""}
>
<text fg={theme.textMuted}>
<span style={{ fg: theme.success }}></span> <b>Open</b>
<span style={{ fg: theme.text }}>
<b>Code</b>
</span>{" "}
<span>{InstallationVersion}</span>
</text>
</pluginRuntime.Slot>
<PluginSlot name="sidebar.footer" />
</box>
</box>
</Show>

View file

@ -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() {
<box
onMouseOver={() => setHover("parent")}
onMouseOut={() => setHover(null)}
onMouseUp={() => keymap.dispatchCommand("session.parent")}
onMouseUp={() => keymap.dispatch("session.parent")}
backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel}
>
<text fg={theme.text}>
Parent <span style={{ fg: theme.textMuted }}>{parentShortcut()}</span>
Parent <span style={{ fg: theme.textMuted }}>{shortcuts.get("session.parent")}</span>
</text>
</box>
<box
onMouseOver={() => setHover("prev")}
onMouseOut={() => setHover(null)}
onMouseUp={() => keymap.dispatchCommand("session.child.previous")}
onMouseUp={() => keymap.dispatch("session.child.previous")}
backgroundColor={hover() === "prev" ? theme.backgroundElement : theme.backgroundPanel}
>
<text fg={theme.text}>
Prev <span style={{ fg: theme.textMuted }}>{previousShortcut()}</span>
Prev <span style={{ fg: theme.textMuted }}>{shortcuts.get("session.child.previous")}</span>
</text>
</box>
<box
onMouseOver={() => setHover("next")}
onMouseOut={() => setHover(null)}
onMouseUp={() => keymap.dispatchCommand("session.child.next")}
onMouseUp={() => keymap.dispatch("session.child.next")}
backgroundColor={hover() === "next" ? theme.backgroundElement : theme.backgroundPanel}
>
<text fg={theme.text}>
Next <span style={{ fg: theme.textMuted }}>{nextShortcut()}</span>
Next <span style={{ fg: theme.textMuted }}>{shortcuts.get("session.child.next")}</span>
</text>
</box>
</box>

View file

@ -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()
},

View file

@ -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")
},
},

View file

@ -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,
},
],
}))

View file

@ -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 }
<text fg={theme.text}>{props.path}</text>
</box>
<box flexDirection="row" justifyContent="flex-end" gap={1} paddingBottom={1}>
<box
paddingLeft={3}
paddingRight={3}
backgroundColor={theme.primary}
onMouseUp={close}
>
<box paddingLeft={3} paddingRight={3} backgroundColor={theme.primary} onMouseUp={close}>
<text fg={theme.selectedListItemText}>Close</text>
</box>
</box>

View file

@ -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() {
</box>
<box paddingBottom={1}>
<text fg={theme.textMuted}>
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.
</text>
</box>
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>

View file

@ -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<TextareaRenderable>()
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) {
</box>
<box paddingBottom={1} gap={1} flexDirection="row">
<Show when={!props.busy} fallback={<text fg={theme.textMuted}>processing...</text>}>
<Show when={submitShortcut()}>
<Show when={shortcuts.get("dialog.prompt.submit")}>
<text fg={theme.text}>
{submitShortcut()} <span style={{ fg: theme.textMuted }}>submit</span>
{shortcuts.get("dialog.prompt.submit")} <span style={{ fg: theme.textMuted }}>submit</span>
</text>
</Show>
</Show>

View file

@ -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<T> {
title: string
@ -43,7 +36,7 @@ export interface DialogSelectProps<T> {
label: string
side?: "left" | "right"
}[]
bindings?: readonly Binding<Renderable, KeyEvent>[]
bindings?: readonly KeymapCommand[]
current?: T
focusCurrent?: boolean
}
@ -385,51 +378,52 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
})
}
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<T>(props: DialogSelectProps<T>) {
},
},
{
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<T>(props: DialogSelectProps<T>) {
},
},
{
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 ?? []),
],
}
})

View file

@ -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()
}

View file

@ -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<void>((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<void>((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<void>((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")

View file

@ -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(