feat(tui): add v2 plugin runtime

This commit is contained in:
Dax Raad
2026-07-14 12:49:56 -04:00
parent 5c5579e90c
commit 4a93972a78
63 changed files with 1724 additions and 1703 deletions
+7 -10
View File
@@ -1,7 +1,6 @@
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { run } from "@opencode-ai/tui" import { run } from "@opencode-ai/tui"
import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins"
import { Commands } from "../commands" import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime" import { Runtime } from "../../framework/runtime"
import { Config } from "../../config" import { Config } from "../../config"
@@ -9,6 +8,7 @@ import { Effect, Option } from "effect"
import { Server } from "../../services/server" import { Server } from "../../services/server"
import { Updater } from "../../services/updater" import { Updater } from "../../services/updater"
import { UpdatePreflight } from "../../services/update-preflight" import { UpdatePreflight } from "../../services/update-preflight"
import { Npm } from "@opencode-ai/core/npm"
export default Runtime.handler(Commands, (input) => export default Runtime.handler(Commands, (input) =>
Effect.gen(function* () { Effect.gen(function* () {
@@ -36,7 +36,7 @@ export default Runtime.handler(Commands, (input) =>
) )
preflight.loading() preflight.loading()
const config = yield* Config.Service const config = yield* Config.Service
let disposeSlots: (() => void) | undefined const npm = yield* Npm.Service
const context = yield* Effect.context() const context = yield* Effect.context()
const runFork = Effect.runForkWith(context) const runFork = Effect.runForkWith(context)
const runPromise = Effect.runPromiseWith(context) const runPromise = Effect.runPromiseWith(context)
@@ -44,9 +44,14 @@ export default Runtime.handler(Commands, (input) =>
server, server,
args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) }, args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) },
config: { config: {
path: config.path,
get: () => runPromise(config.get()), get: () => runPromise(config.get()),
update: (update) => runPromise(config.update(update)), update: (update) => runPromise(config.update(update)),
}, },
packages: {
resolve: (spec) =>
runPromise(npm.add(spec, { subpaths: ["tui"] }).pipe(Effect.map((result) => result.entrypoint))),
},
terminalHandoff: () => preflight.finish(), terminalHandoff: () => preflight.finish(),
log: (level, message, tags) => { log: (level, message, tags) => {
const effect = const effect =
@@ -59,14 +64,6 @@ export default Runtime.handler(Commands, (input) =>
: Effect.logInfo(message, tags) : Effect.logInfo(message, tags)
runFork(effect) runFork(effect)
}, },
pluginHost: {
async start(pluginInput) {
disposeSlots = await loadBuiltinPlugins(pluginInput.api, pluginInput.runtime)
},
async dispose() {
disposeSlots?.()
},
},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node))) }).pipe(Effect.provide(AppNodeBuilder.build(Global.node)))
}), }),
) )
+4 -3
View File
@@ -4,6 +4,7 @@ import { Spec } from "./spec"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { Updater } from "../services/updater" import { Updater } from "../services/updater"
import { Config } from "../config" import { Config } from "../config"
import { Npm } from "@opencode-ai/core/npm"
export type Input<Value> = export type Input<Value> =
Value extends Spec.Node<infer _Name, infer Command, infer _Commands> Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
@@ -17,7 +18,7 @@ type RuntimeHandler = (
) => Effect.Effect< ) => Effect.Effect<
void, void,
unknown, 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<{ type Loader<Node extends Spec.Any> = () => Promise<{
default: ( default: (
@@ -25,7 +26,7 @@ type Loader<Node extends Spec.Any> = () => Promise<{
) => Effect.Effect< ) => Effect.Effect<
void, void,
any, 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< type ProvidedCommand = Command.Command<
@@ -33,7 +34,7 @@ type ProvidedCommand = Command.Command<
unknown, unknown,
unknown, 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 export type Handlers<Node extends Spec.Any> = keyof Node["commands"] extends never
+2 -1
View File
@@ -12,6 +12,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { AppProcess } from "@opencode-ai/core/process" import { AppProcess } from "@opencode-ai/core/process"
import { Config } from "./config" import { Config } from "./config"
import { Npm } from "@opencode-ai/core/npm"
const Handlers = Runtime.handlers(Commands, { const Handlers = Runtime.handlers(Commands, {
$: () => import("./commands/handlers/default"), $: () => import("./commands/handlers/default"),
@@ -54,7 +55,7 @@ Effect.logInfo("cli starting", {
Effect.annotateLogs({ role: "cli" }), Effect.annotateLogs({ role: "cli" }),
Effect.provide(Config.layer), Effect.provide(Config.layer),
Effect.provide(Updater.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(Observability.layer),
Effect.provide(NodeServices.layer), Effect.provide(NodeServices.layer),
Effect.scoped, Effect.scoped,
+2 -2
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.prev",
"prompt.autocomplete.next", "prompt.autocomplete.next",
"prompt.autocomplete.hide", "prompt.autocomplete.hide",
"prompt.autocomplete.select", "prompt.autocomplete.select",
"prompt.autocomplete.complete", "prompt.autocomplete.complete",
]), ].flatMap((command) => input.tuiConfig.keybinds.get(command)),
})) }))
const onKeyDown = (event: KeyEvent) => { const onKeyDown = (event: KeyEvent) => {
+1
View File
@@ -16,6 +16,7 @@
"./v2/effect": "./src/v2/effect/index.ts", "./v2/effect": "./src/v2/effect/index.ts",
"./v2/effect/*": "./src/v2/effect/*.ts", "./v2/effect/*": "./src/v2/effect/*.ts",
"./v2/tui": "./src/v2/tui/index.ts", "./v2/tui": "./src/v2/tui/index.ts",
"./v2/tui/*": "./src/v2/tui/*.ts",
"./v2": "./src/v2/promise/index.ts", "./v2": "./src/v2/promise/index.ts",
"./v2/*": "./src/v2/promise/*.ts" "./v2/*": "./src/v2/promise/*.ts"
}, },
+22 -12
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 name: string
readonly render: (input: { readonly params: any }) => JSX.Element 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 data?: Record<string, any> }) => JSX.Element
} }
export interface Route { export type Slot = (props: Record<string, any>) => JSX.Element
register(definition: RouteDefinition): () => void
navigate(input: { readonly name: string; readonly params?: any }): void
current(): {
readonly name: string
readonly params: any
}
}
export interface UI { 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 { export interface Context {
readonly options: Readonly<Record<string, unknown>> readonly options: Readonly<Record<string, any>>
readonly client: OpenCodeClient readonly client: OpenCodeClient
readonly data: Data readonly data: Data
readonly ui: UI readonly ui: UI
+82 -128
View File
@@ -1,12 +1,10 @@
import { render, TimeToFirstDraw, useRenderer, useTerminalDimensions } from "@opentui/solid" import { render, TimeToFirstDraw, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { registerOpencodeSpinner } from "./component/register-spinner" import { registerOpencodeSpinner } from "./component/register-spinner"
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import { Deferred, Effect } from "effect" import { Deferred, Effect } from "effect"
import { Service } from "@opencode-ai/client/effect" import { Service } from "@opencode-ai/client/effect"
import { OpenCode } from "@opencode-ai/client" import { OpenCode } from "@opencode-ai/client"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { ClipboardProvider, useClipboard } from "./context/clipboard" import { ClipboardProvider, useClipboard } from "./context/clipboard"
import { LogProvider, useLog, type LogSink } from "./context/log" import { LogProvider, useLog, type LogSink } from "./context/log"
import { ExitProvider, useExit } from "./context/exit" import { ExitProvider, useExit } from "./context/exit"
@@ -33,7 +31,13 @@ import {
batch, batch,
Show, Show,
} from "solid-js" } 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 { DialogProvider, useDialog } from "./ui/dialog"
import { DialogIntegration } from "./component/dialog-integration" import { DialogIntegration } from "./component/dialog-integration"
import { ErrorComponent } from "./component/error-component" import { ErrorComponent } from "./component/error-component"
@@ -72,22 +76,13 @@ import { ArgsProvider, useArgs, type Args } from "./context/args"
import open from "open" import open from "open"
import { PromptRefProvider, usePromptRef } from "./context/prompt" import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { Config, ConfigProvider, useConfig } from "./config" import { Config, ConfigProvider, useConfig } from "./config"
import { createTuiApiAdapters } from "./plugin/adapters" import { createPluginRuntime, PluginRuntimeProvider, usePluginRuntime } from "./plugin/runtime"
import { createTuiApi } from "./plugin/api" import { PluginProvider, PluginRoute, PluginSlot, usePlugin, type PackageResolver } from "./plugin/context"
import { createPluginRuntime, PluginRuntimeProvider, usePluginRuntime, type TuiPluginHost } from "./plugin/runtime"
import { CommandPaletteDialog } from "./component/command-palette" import { CommandPaletteDialog } from "./component/command-palette"
import { import { COMMAND_PALETTE_COMMAND, OPENCODE_BASE_MODE, useBindings, useOpencodeKeymap } from "./keymap"
COMMAND_PALETTE_COMMAND, import { Keymap } from "./context/keymap"
OPENCODE_BASE_MODE,
OpencodeKeymapProvider,
registerOpencodeKeymap,
useBindings,
useOpencodeKeymap,
} from "./keymap"
import { DialogVariant } from "./component/dialog-variant" import { DialogVariant } from "./component/dialog-variant"
import { createTuiAttention } from "./attention"
import * as TuiAudio from "./audio"
import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-win32" import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-win32"
import { destroyRenderer } from "./util/renderer" import { destroyRenderer } from "./util/renderer"
import { cliErrorMessage, errorFormat } from "./util/error" import { cliErrorMessage, errorFormat } from "./util/error"
@@ -149,7 +144,7 @@ export type TuiInput = {
} }
args: Args args: Args
config: Config.Interface config: Config.Interface
pluginHost: TuiPluginHost packages: PackageResolver
terminalHandoff?: () => Promise< terminalHandoff?: () => Promise<
| { | {
readonly renderer: CliRenderer readonly renderer: CliRenderer
@@ -239,21 +234,15 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
}), }),
) )
win32DisableProcessedInput() win32DisableProcessedInput()
const keymap = createDefaultOpenTuiKeymap(renderer) const finalizers = new Set<() => Promise<void>>()
yield* Effect.acquireRelease(
Effect.sync(() => registerOpencodeKeymap(keymap, renderer, config)),
(unregister) => Effect.sync(unregister),
)
yield* Effect.addFinalizer(() => yield* Effect.addFinalizer(() =>
Effect.promise(async () => { Effect.promise(async () => {
try { const results = await Promise.allSettled([...finalizers].reverse().map((finalizer) => finalizer()))
await input.pluginHost.dispose() results
} catch (error) { .filter((result): result is PromiseRejectedResult => result.status === "rejected")
log("error", "Failed to dispose TUI plugins", { error }) .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 shutdown = yield* Deferred.make<unknown>()
const onSighup = () => destroyRenderer(renderer) const onSighup = () => destroyRenderer(renderer)
yield* Effect.acquireRelease( yield* Effect.acquireRelease(
@@ -290,6 +279,14 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
state: global.state, state: global.state,
worktree: global.data + "/worktree", worktree: global.data + "/worktree",
}} }}
>
<TuiLifecycleProvider
value={{
add(finalizer) {
finalizers.add(finalizer)
return () => finalizers.delete(finalizer)
},
}}
> >
<TuiTerminalEnvironmentProvider <TuiTerminalEnvironmentProvider
value={{ value={{
@@ -305,7 +302,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<TuiStartupProvider <TuiStartupProvider
value={{ value={{
initialRoute: process.env.OPENCODE_SCRAP initialRoute: process.env.OPENCODE_SCRAP
? { type: "plugin", id: "scrap" } ? { type: "plugin", id: "scrap", name: "scrap" }
: process.env.OPENCODE_ROUTE : process.env.OPENCODE_ROUTE
? JSON.parse(process.env.OPENCODE_ROUTE) ? JSON.parse(process.env.OPENCODE_ROUTE)
: undefined, : undefined,
@@ -313,13 +310,13 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
}} }}
> >
<ClipboardProvider> <ClipboardProvider>
<OpencodeKeymapProvider keymap={keymap}>
<ArgsProvider {...input.args}> <ArgsProvider {...input.args}>
<ConfigProvider <ConfigProvider
config={config} config={config}
service={input.config} service={input.config}
options={{ terminalSuspend: process.platform !== "win32" }} options={{ terminalSuspend: process.platform !== "win32" }}
> >
<Keymap.Provider>
<ToastProvider> <ToastProvider>
<RouteProvider <RouteProvider
initialRoute={ initialRoute={
@@ -332,11 +329,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
} }
> >
<PluginRuntimeProvider value={pluginRuntime}> <PluginRuntimeProvider value={pluginRuntime}>
<ClientProvider <ClientProvider api={api} reconnect={reconnect} reload={input.server.reload}>
api={api}
reconnect={reconnect}
reload={input.server.reload}
>
<PermissionProvider> <PermissionProvider>
<ProjectProvider> <ProjectProvider>
<DataProvider> <DataProvider>
@@ -349,8 +342,8 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<PromptRefProvider> <PromptRefProvider>
<EditorContextProvider> <EditorContextProvider>
<LocationProvider> <LocationProvider>
<PluginProvider packages={input.packages}>
<App <App
pluginHost={input.pluginHost}
pair={ pair={
input.server.endpoint.auth input.server.endpoint.auth
? input.server.endpoint.auth ? input.server.endpoint.auth
@@ -360,6 +353,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
} }
} }
/> />
</PluginProvider>
</LocationProvider> </LocationProvider>
</EditorContextProvider> </EditorContextProvider>
</PromptRefProvider> </PromptRefProvider>
@@ -376,12 +370,13 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
</PluginRuntimeProvider> </PluginRuntimeProvider>
</RouteProvider> </RouteProvider>
</ToastProvider> </ToastProvider>
</Keymap.Provider>
</ConfigProvider> </ConfigProvider>
</ArgsProvider> </ArgsProvider>
</OpencodeKeymapProvider>
</ClipboardProvider> </ClipboardProvider>
</TuiStartupProvider> </TuiStartupProvider>
</TuiTerminalEnvironmentProvider> </TuiTerminalEnvironmentProvider>
</TuiLifecycleProvider>
</TuiPathsProvider> </TuiPathsProvider>
</ErrorBoundary> </ErrorBoundary>
</EpilogueProvider> </EpilogueProvider>
@@ -406,14 +401,10 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
}) })
}) })
function App(props: { function App(props: { pair?: DialogPairCredentials }) {
pluginHost: TuiPluginHost
pair?: DialogPairCredentials
}) {
const log = useLog({ component: "app" }) const log = useLog({ component: "app" })
const startup = useTuiStartup() const startup = useTuiStartup()
const configState = useConfig() const config = useConfig()
const config = configState.data
const route = useRoute() const route = useRoute()
const dimensions = useTerminalDimensions() const dimensions = useTerminalDimensions()
const renderer = useRenderer() const renderer = useRenderer()
@@ -430,7 +421,7 @@ function App(props: {
const exit = useExit() const exit = useExit()
const promptRef = usePromptRef() const promptRef = usePromptRef()
const pluginRuntime = usePluginRuntime() const pluginRuntime = usePluginRuntime()
const attention = createTuiAttention({ renderer, config, update: configState.update }) const plugins = usePlugin()
const clipboard = useClipboard() const clipboard = useClipboard()
// Toast once when an MCP server enters a failed or needs-auth state so the user knows to act, // 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. // Let selection copy/dismiss win ahead of normal bindings when explicit copy is required.
const offSelectionKeys = keymap.intercept( const offSelectionKeys = keymap.intercept(
"key", "key",
@@ -505,7 +463,6 @@ function App(props: {
) )
onCleanup(() => { onCleanup(() => {
offSelectionKeys() offSelectionKeys()
attention.dispose()
}) })
// Wire up console copy-to-clipboard via opentui's onCopySelection callback // Wire up console copy-to-clipboard via opentui's onCopySelection callback
@@ -519,11 +476,11 @@ function App(props: {
renderer.clearSelection() renderer.clearSelection()
} }
const terminalTitleEnabled = () => config.terminal?.title ?? true const terminalTitleEnabled = () => config.data.terminal?.title ?? true
const pasteSummaryEnabled = () => config.prompt?.paste !== "full" const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full"
createEffect(() => { 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 // Update terminal window title based on current route and session
@@ -548,7 +505,7 @@ function App(props: {
} }
if (route.data.type === "plugin") { 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", title: "Switch session",
category: "Session", category: "Session",
suggested: data.session.list().length > 0, suggested: data.session.list().length > 0,
slashName: "sessions", slash: { name: "sessions", aliases: ["resume", "continue"] },
slashAliases: ["resume", "continue"],
run: () => { run: () => {
dialog.replace(() => <DialogSessionList />) dialog.replace(() => <DialogSessionList />)
}, },
@@ -642,8 +598,7 @@ function App(props: {
title: "New session", title: "New session",
suggested: route.data.type === "session", suggested: route.data.type === "session",
category: "Session", category: "Session",
slashName: "new", slash: { name: "new", aliases: ["clear"] },
slashAliases: ["clear"],
run: () => { run: () => {
route.navigate({ route.navigate({
type: "home", type: "home",
@@ -665,9 +620,8 @@ function App(props: {
title: "Switch model", title: "Switch model",
suggested: true, suggested: true,
category: "Agent", category: "Agent",
slashName: "models",
// Bias /mo toward /models over /move without changing global fuzzy scoring. // Bias /mo toward /models over /move without changing global fuzzy scoring.
slashAliases: ["mo"], slash: { name: "models", aliases: ["mo"] },
run: () => { run: () => {
dialog.replace(() => <DialogModel />) dialog.replace(() => <DialogModel />)
}, },
@@ -712,7 +666,7 @@ function App(props: {
name: "agent.list", name: "agent.list",
title: "Switch agent", title: "Switch agent",
category: "Agent", category: "Agent",
slashName: "agents", slash: { name: "agents" },
run: () => { run: () => {
dialog.replace(() => <DialogAgent />) dialog.replace(() => <DialogAgent />)
}, },
@@ -721,7 +675,7 @@ function App(props: {
name: "mcp.list", name: "mcp.list",
title: "MCP servers", title: "MCP servers",
category: "Agent", category: "Agent",
slashName: "mcps", slash: { name: "mcps" },
run: () => { run: () => {
dialog.replace(() => <DialogMcp />) dialog.replace(() => <DialogMcp />)
}, },
@@ -748,7 +702,7 @@ function App(props: {
title: "Switch model variant", title: "Switch model variant",
category: "Agent", category: "Agent",
hidden: local.model.variant.list().length === 0, hidden: local.model.variant.list().length === 0,
slashName: "variants", slash: { name: "variants" },
run: () => { run: () => {
if (local.model.variant.list().length === 0) { if (local.model.variant.list().length === 0) {
return toast.show({ return toast.show({
@@ -773,7 +727,7 @@ function App(props: {
name: "provider.connect", name: "provider.connect",
title: "Connect integration", title: "Connect integration",
suggested: !connected(), suggested: !connected(),
slashName: "connect", slash: { name: "connect" },
run: () => { run: () => {
dialog.replace(() => ( dialog.replace(() => (
<DialogIntegration <DialogIntegration
@@ -786,7 +740,7 @@ function App(props: {
{ {
name: "opencode.settings", name: "opencode.settings",
title: "Open settings", title: "Open settings",
slashName: "settings", slash: { name: "settings" },
run: () => { run: () => {
dialog.replace(() => <DialogConfig />) dialog.replace(() => <DialogConfig />)
}, },
@@ -795,7 +749,7 @@ function App(props: {
{ {
name: "opencode.status", name: "opencode.status",
title: "View status", title: "View status",
slashName: "status", slash: { name: "status" },
run: () => { run: () => {
dialog.replace(() => <DialogStatus />) dialog.replace(() => <DialogStatus />)
}, },
@@ -804,7 +758,7 @@ function App(props: {
{ {
name: "server.pair", name: "server.pair",
title: "Pair device", title: "Pair device",
slashName: "pair", slash: { name: "pair" },
run: () => { run: () => {
dialog.replace(() => <DialogPair credentials={props.pair} />) dialog.replace(() => <DialogPair credentials={props.pair} />)
}, },
@@ -815,7 +769,7 @@ function App(props: {
{ {
name: "server.reload", name: "server.reload",
title: "Reload server", title: "Reload server",
slashName: "reload", slash: { name: "reload" },
run: async () => { run: async () => {
dialog.clear() dialog.clear()
toast.show({ variant: "info", message: "Reloading server...", duration: 30000 }) toast.show({ variant: "info", message: "Reloading server...", duration: 30000 })
@@ -832,7 +786,7 @@ function App(props: {
{ {
name: "opencode.debug", name: "opencode.debug",
title: "View debug info", title: "View debug info",
slashName: "debug", slash: { name: "debug" },
run: () => { run: () => {
dialog.replace(() => <DialogDebug />) dialog.replace(() => <DialogDebug />)
}, },
@@ -841,7 +795,7 @@ function App(props: {
{ {
name: "theme.switch", name: "theme.switch",
title: "Switch theme", title: "Switch theme",
slashName: "themes", slash: { name: "themes" },
run: () => { run: () => {
dialog.replace(() => <DialogThemeList />) dialog.replace(() => <DialogThemeList />)
}, },
@@ -871,7 +825,7 @@ function App(props: {
{ {
name: "help.show", name: "help.show",
title: "Help", title: "Help",
slashName: "help", slash: { name: "help" },
run: () => { run: () => {
dialog.replace(() => <DialogHelp />) dialog.replace(() => <DialogHelp />)
}, },
@@ -889,8 +843,7 @@ function App(props: {
{ {
name: "app.exit", name: "app.exit",
title: "Exit the app", title: "Exit the app",
slashName: "exit", slash: { name: "exit", aliases: ["quit", "q"] },
slashAliases: ["quit", "q"],
run: () => exit(), run: () => exit(),
category: "System", category: "System",
}, },
@@ -932,7 +885,7 @@ function App(props: {
run: () => { run: () => {
const next = !terminalTitleEnabled() const next = !terminalTitleEnabled()
if (!next) renderer.setTerminalTitle("") if (!next) renderer.setTerminalTitle("")
void configState void config
.update((draft) => { .update((draft) => {
draft.terminal = { ...draft.terminal, title: next } draft.terminal = { ...draft.terminal, title: next }
}) })
@@ -942,13 +895,13 @@ function App(props: {
}, },
{ {
name: "app.toggle.animations", name: "app.toggle.animations",
title: (config.animations ?? true) ? "Disable animations" : "Enable animations", title: (config.data.animations ?? true) ? "Disable animations" : "Enable animations",
category: "System", category: "System",
hidden: true, hidden: true,
run: () => { run: () => {
void configState void config
.update((draft) => { .update((draft) => {
draft.animations = !(config.animations ?? true) draft.animations = !(config.data.animations ?? true)
}) })
.catch(toast.error) .catch(toast.error)
dialog.clear() dialog.clear()
@@ -956,13 +909,13 @@ function App(props: {
}, },
{ {
name: "app.toggle.file_context", 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", category: "System",
hidden: true, hidden: true,
run: () => { run: () => {
void configState void config
.update((draft) => { .update((draft) => {
draft.prompt = { ...draft.prompt, editor: !(config.prompt?.editor ?? true) } draft.prompt = { ...draft.prompt, editor: !(config.data.prompt?.editor ?? true) }
}) })
.catch(toast.error) .catch(toast.error)
dialog.clear() dialog.clear()
@@ -970,13 +923,16 @@ function App(props: {
}, },
{ {
name: "app.toggle.diffwrap", 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", category: "System",
hidden: true, hidden: true,
run: () => { run: () => {
void configState void config
.update((draft) => { .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) .catch(toast.error)
dialog.clear() dialog.clear()
@@ -988,7 +944,7 @@ function App(props: {
category: "System", category: "System",
hidden: true, hidden: true,
run: () => { run: () => {
void configState void config
.update((draft) => { .update((draft) => {
draft.prompt = { ...draft.prompt, paste: pasteSummaryEnabled() ? "full" : "compact" } draft.prompt = { ...draft.prompt, paste: pasteSummaryEnabled() ? "full" : "compact" }
}) })
@@ -1018,11 +974,11 @@ function App(props: {
useBindings(() => ({ useBindings(() => ({
mode: OPENCODE_BASE_MODE, mode: OPENCODE_BASE_MODE,
bindings: config.keybinds.gather("app", appBindingCommands), bindings: appBindingCommands.flatMap((command) => config.data.keybinds.get(command)),
})) }))
useBindings(() => ({ useBindings(() => ({
bindings: config.keybinds.gather("app.global", appGlobalBindingCommands), bindings: appGlobalBindingCommands.flatMap((command) => config.data.keybinds.get(command)),
})) }))
useBindings(() => ({ useBindings(() => ({
@@ -1032,7 +988,7 @@ function App(props: {
if (!current?.focused) return true if (!current?.focused) return true
return current.current.text === "" 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 }) => { 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. // Suppress the full-screen overlay for transient startup and event-stream retry states.
// Initial connection gets a longer grace period; retries surface more quickly. // Initial connection gets a longer grace period; retries surface more quickly.
const [showReconnecting, setShowReconnecting] = createSignal(false) const [showReconnecting, setShowReconnecting] = createSignal(false)
@@ -1144,7 +1092,7 @@ function App(props: {
<Show when={Flag.OPENCODE_SHOW_TTFD}> <Show when={Flag.OPENCODE_SHOW_TTFD}>
<TimeToFirstDraw /> <TimeToFirstDraw />
</Show> </Show>
<Show when={ready()}> <Show when={plugins.ready()}>
<box flexGrow={1} minHeight={0} flexDirection="column"> <box flexGrow={1} minHeight={0} flexDirection="column">
<Switch> <Switch>
<Match when={route.data.type === "home"}> <Match when={route.data.type === "home"}>
@@ -1155,16 +1103,22 @@ function App(props: {
{(_) => <Session />} {(_) => <Session />}
</Show> </Show>
</Match> </Match>
<Match when={route.data.type === "plugin"}>
<PluginRoute
fallback={(id, name) => (
<PluginRouteMissing id={id} name={name} onHome={() => route.navigate({ type: "home" })} />
)}
/>
</Match>
</Switch> </Switch>
{plugin()}
</box> </box>
<box flexShrink={0}> <box flexShrink={0}>
<pluginRuntime.Slot name="app_bottom" /> <PluginSlot name="app.bottom" />
</box> </box>
<pluginRuntime.Slot name="app" /> <PluginSlot name="app" />
</Show> </Show>
<Show when={!startup.skipInitialLoading}> <Show when={!startup.skipInitialLoading}>
<StartupLoading ready={ready} /> <StartupLoading ready={plugins.ready} />
</Show> </Show>
<Show when={showReconnecting()}> <Show when={showReconnecting()}>
<Reconnecting attempt={client.connection.attempt()} error={client.connection.error()} /> <Reconnecting attempt={client.connection.attempt()} error={client.connection.error()} />
+6 -6
View File
@@ -281,16 +281,16 @@ export function DialogConfig() {
footerHints={[{ title: "←/→", label: "change" }]} footerHints={[{ title: "←/→", label: "change" }]}
bindings={[ bindings={[
{ {
key: "left", bind: "left",
desc: "Previous value", title: "Previous value",
group: "Settings", group: "Settings",
cmd: () => void change(-1), run: () => void change(-1),
}, },
{ {
key: "right", bind: "right",
desc: "Next value", title: "Next value",
group: "Settings", group: "Settings",
cmd: () => void change(1), run: () => void change(1),
}, },
]} ]}
/> />
+4 -3
View File
@@ -1,13 +1,13 @@
import { TextAttributes } from "@opentui/core" import { TextAttributes } from "@opentui/core"
import { createMemo, createSignal, For } from "solid-js" import { createMemo, createSignal, For } from "solid-js"
import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version" import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme" import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog" import { useDialog } from "../ui/dialog"
import { useRoute } from "../context/route" import { useRoute } from "../context/route"
import { useLocal } from "../context/local" import { useLocal } from "../context/local"
import { useClipboard } from "../context/clipboard" import { useClipboard } from "../context/clipboard"
import { useToast } from "../ui/toast" import { useToast } from "../ui/toast"
import { useBindings } from "../keymap"
import { describeOS, describeTerminal } from "../util/system" import { describeOS, describeTerminal } from "../util/system"
export function DialogDebug() { export function DialogDebug() {
@@ -46,8 +46,9 @@ export function DialogDebug() {
.catch(toast.error) .catch(toast.error)
} }
useBindings(() => ({ Keymap.createLayer(() => ({
bindings: [{ key: "return", desc: "Copy debug info", group: "Dialog", cmd: copy }], mode: "modal",
commands: [{ bind: "return", title: "Copy debug info", group: "Dialog", run: copy }],
})) }))
return ( return (
@@ -9,8 +9,8 @@ import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
import { useClipboard } from "../context/clipboard" import { useClipboard } from "../context/clipboard"
import { useData } from "../context/data" import { useData } from "../context/data"
import { useClient } from "../context/client" import { useClient } from "../context/client"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme" import { useTheme } from "../context/theme"
import { useBindings } from "../keymap"
import { useDialog } from "../ui/dialog" import { useDialog } from "../ui/dialog"
import { DialogPrompt } from "../ui/dialog-prompt" import { DialogPrompt } from "../ui/dialog-prompt"
import { DialogSelect } from "../ui/dialog-select" import { DialogSelect } from "../ui/dialog-select"
@@ -278,13 +278,14 @@ function OAuthAuto(props: {
let timer: ReturnType<typeof setTimeout> | undefined let timer: ReturnType<typeof setTimeout> | undefined
let settled = false let settled = false
useBindings(() => ({ Keymap.createLayer(() => ({
bindings: [ mode: "modal",
commands: [
{ {
key: "c", bind: "c",
desc: "Copy authorization details", title: "Copy authorization details",
group: "Dialog", group: "Dialog",
cmd: () => { run: () => {
const value = props.attempt.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.attempt.url const value = props.attempt.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.attempt.url
clipboard clipboard
.write?.(value) .write?.(value)
+4 -3
View File
@@ -1,5 +1,6 @@
import { createEffect, createMemo, createSignal, onMount, Show } from "solid-js" import { createEffect, createMemo, createSignal, onMount, Show } from "solid-js"
import { useData } from "../context/data" import { useData } from "../context/data"
import { Keymap } from "../context/keymap"
import { pipe, sortBy } from "remeda" import { pipe, sortBy } from "remeda"
import { DialogSelect } from "../ui/dialog-select" import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog" import { useDialog } from "../ui/dialog"
@@ -11,7 +12,6 @@ import { useToast } from "../ui/toast"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid" import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { useConfig } from "../config" import { useConfig } from "../config"
import { getScrollAcceleration } from "../util/scroll" import { getScrollAcceleration } from "../util/scroll"
import { useBindings } from "../keymap"
// Sort by how much attention a server needs: auth prompts first, then failures, // Sort by how much attention a server needs: auth prompts first, then failures,
// then healthy servers, and intentionally-off servers last. // then healthy servers, and intentionally-off servers last.
@@ -134,8 +134,9 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
.catch(toast.error) .catch(toast.error)
} }
useBindings(() => ({ Keymap.createLayer(() => ({
bindings: [{ key: "escape", desc: "Back to MCP servers", group: "Dialog", cmd: props.onBack }], mode: "modal",
commands: [{ bind: "escape", title: "Back to MCP servers", group: "Dialog", run: props.onBack }],
})) }))
useKeyboard((event) => { useKeyboard((event) => {
@@ -5,6 +5,7 @@ import path from "path"
import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select" import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog" import { useDialog } from "../ui/dialog"
import { useClient } from "../context/client" import { useClient } from "../context/client"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme" import { useTheme } from "../context/theme"
import { useData } from "../context/data" import { useData } from "../context/data"
import { abbreviateHome } from "../runtime" import { abbreviateHome } from "../runtime"
@@ -13,7 +14,6 @@ import { Locale } from "../util/locale"
import { errorMessage } from "../util/error" import { errorMessage } from "../util/error"
import { isRecord } from "../util/record" import { isRecord } from "../util/record"
import { useToast } from "../ui/toast" import { useToast } from "../ui/toast"
import { useCommandShortcut } from "../keymap"
import { useProject } from "../context/project" import { useProject } from "../context/project"
import { Spinner } from "./spinner" import { Spinner } from "./spinner"
import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes" import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes"
@@ -45,12 +45,12 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
const route = useRoute() const route = useRoute()
const toast = useToast() const toast = useToast()
const paths = useTuiPaths() const paths = useTuiPaths()
const shortcuts = Keymap.useShortcuts()
const [working, setWorking] = createSignal(Boolean(props.initialRemoving)) const [working, setWorking] = createSignal(Boolean(props.initialRemoving))
const [toDelete, setToDelete] = createSignal<string>() const [toDelete, setToDelete] = createSignal<string>()
const [removing, setRemoving] = createSignal(props.initialRemoving) const [removing, setRemoving] = createSignal(props.initialRemoving)
const [replacementCurrent, setReplacementCurrent] = createSignal<string>() const [replacementCurrent, setReplacementCurrent] = createSignal<string>()
const [loadError, setLoadError] = createSignal<unknown>() const [loadError, setLoadError] = createSignal<unknown>()
const deleteHint = useCommandShortcut("dialog.move_session.delete")
onMount(() => dialog.setSize("xlarge")) onMount(() => dialog.setSize("xlarge"))
function reopen(initialRemoving?: string) { function reopen(initialRemoving?: string) {
@@ -175,7 +175,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
titleView: isRemoving ? ( titleView: isRemoving ? (
<span style={{ fg: theme.error }}>Deleting {item.location}</span> <span style={{ fg: theme.error }}>Deleting {item.location}</span>
) : deleting ? ( ) : 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 ? ( ) : suffix ? (
<> <>
{visible.slice(0, split)} {visible.slice(0, split)}
@@ -1,16 +1,14 @@
import { InputRenderable, TextAttributes } from "@opentui/core" import { InputRenderable, TextAttributes } from "@opentui/core"
import { Slug } from "@opencode-ai/core/util/slug" import { Slug } from "@opencode-ai/core/util/slug"
import { createSignal, onMount } from "solid-js" import { createSignal, onMount } from "solid-js"
import { useConfig } from "../config" import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme" import { useTheme } from "../context/theme"
import { useBindings, useCommandShortcut } from "../keymap"
import { useDialog, type DialogContext } from "../ui/dialog" import { useDialog, type DialogContext } from "../ui/dialog"
export function DialogProjectCopyName(props: { onConfirm: (name: string) => void }) { export function DialogProjectCopyName(props: { onConfirm: (name: string) => void }) {
const dialog = useDialog() const dialog = useDialog()
const { theme } = useTheme() const { theme } = useTheme()
const config = useConfig().data const shortcuts = Keymap.useShortcuts()
const generateShortcut = useCommandShortcut("dialog.project_copy.generate")
const [inputTarget, setInputTarget] = createSignal<InputRenderable>() const [inputTarget, setInputTarget] = createSignal<InputRenderable>()
let input: InputRenderable let input: InputRenderable
@@ -23,19 +21,19 @@ export function DialogProjectCopyName(props: { onConfirm: (name: string) => void
props.onConfirm(slugify(input.value) || Slug.create()) props.onConfirm(slugify(input.value) || Slug.create())
} }
useBindings(() => ({ Keymap.createLayer(() => ({
mode: "modal",
target: inputTarget, target: inputTarget,
enabled: inputTarget() !== undefined, enabled: inputTarget() !== undefined,
priority: 1, priority: 1,
commands: [ commands: [
{ {
name: "dialog.project_copy.generate", id: "dialog.project_copy.generate",
title: "Generate project copy name", title: "Generate project copy name",
category: "Dialog", group: "Dialog",
run: generate, run: generate,
}, },
], ],
bindings: config.keybinds.get("dialog.project_copy.generate"),
})) }))
onMount(() => { onMount(() => {
@@ -73,7 +71,7 @@ export function DialogProjectCopyName(props: { onConfirm: (name: string) => void
enter <span style={{ fg: theme.textMuted }}>submit</span> enter <span style={{ fg: theme.textMuted }}>submit</span>
</text> </text>
<text fg={theme.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> </text>
</box> </box>
</box> </box>
@@ -82,7 +80,10 @@ export function DialogProjectCopyName(props: { onConfirm: (name: string) => void
DialogProjectCopyName.show = (dialog: DialogContext) => DialogProjectCopyName.show = (dialog: DialogContext) =>
new Promise<string | null>((resolve) => { new Promise<string | null>((resolve) => {
dialog.replace(() => <DialogProjectCopyName onConfirm={resolve} />, () => resolve(null)) dialog.replace(
() => <DialogProjectCopyName onConfirm={resolve} />,
() => resolve(null),
)
}) })
function slugify(input: string) { function slugify(input: string) {
@@ -1,11 +1,11 @@
import { RGBA, TextAttributes } from "@opentui/core" import { RGBA, TextAttributes } from "@opentui/core"
import open from "open" import open from "open"
import { createSignal } from "solid-js" import { createSignal } from "solid-js"
import { Keymap } from "../context/keymap"
import { selectedForeground, useTheme } from "../context/theme" import { selectedForeground, useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "../ui/dialog" import { useDialog, type DialogContext } from "../ui/dialog"
import { Link } from "../ui/link" import { Link } from "../ui/link"
import { BgPulse } from "./bg-pulse" import { BgPulse } from "./bg-pulse"
import { useBindings } from "../keymap"
const GO_URL = "https://opencode.ai/go" const GO_URL = "https://opencode.ai/go"
const PAD_X = 3 const PAD_X = 3
@@ -44,31 +44,32 @@ export function DialogRetryAction(props: DialogRetryActionProps) {
const textBg = () => (showGoTreatment() ? panelOverlay(theme.backgroundPanel) : undefined) const textBg = () => (showGoTreatment() ? panelOverlay(theme.backgroundPanel) : undefined)
const [selected, setSelected] = createSignal<"dismiss" | "action">("action") const [selected, setSelected] = createSignal<"dismiss" | "action">("action")
useBindings(() => ({ Keymap.createLayer(() => ({
bindings: [ mode: "modal",
commands: [
{ {
key: "left", bind: "left",
desc: "Previous retry option", title: "Previous retry option",
group: "Dialog", group: "Dialog",
cmd: () => setSelected((value) => (value === "action" ? "dismiss" : "action")), run: () => setSelected((value) => (value === "action" ? "dismiss" : "action")),
}, },
{ {
key: "right", bind: "right",
desc: "Next retry option", title: "Next retry option",
group: "Dialog", group: "Dialog",
cmd: () => setSelected((value) => (value === "action" ? "dismiss" : "action")), run: () => setSelected((value) => (value === "action" ? "dismiss" : "action")),
}, },
{ {
key: "tab", bind: "tab",
desc: "Next retry option", title: "Next retry option",
group: "Dialog", group: "Dialog",
cmd: () => setSelected((value) => (value === "action" ? "dismiss" : "action")), run: () => setSelected((value) => (value === "action" ? "dismiss" : "action")),
}, },
{ {
key: "return", bind: "return",
desc: "Confirm retry option", title: "Confirm retry option",
group: "Dialog", group: "Dialog",
cmd: () => { run: () => {
if (selected() === "action") runAction(props, dialog) if (selected() === "action") runAction(props, dialog)
else dismiss(props, dialog) else dismiss(props, dialog)
}, },
@@ -1,9 +1,9 @@
import { TextAttributes } from "@opentui/core" import { TextAttributes } from "@opentui/core"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme" import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog" import { useDialog } from "../ui/dialog"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { For } from "solid-js" import { For } from "solid-js"
import { useBindings } from "../keymap"
export function DialogSessionDeleteFailed(props: { export function DialogSessionDeleteFailed(props: {
session: string session: string
@@ -40,13 +40,24 @@ export function DialogSessionDeleteFailed(props: {
if (!props.onDone) dialog.clear() if (!props.onDone) dialog.clear()
} }
useBindings(() => ({ Keymap.createLayer(() => ({
bindings: [ mode: "modal",
{ key: "return", desc: "Confirm recovery option", group: "Dialog", cmd: () => void confirm() }, commands: [
{ key: "left", desc: "Delete broken session", group: "Dialog", cmd: () => setStore("active", "delete") }, { bind: "return", title: "Confirm recovery option", group: "Dialog", run: () => void confirm() },
{ key: "up", desc: "Delete broken session", group: "Dialog", cmd: () => setStore("active", "delete") }, { bind: "left", title: "Delete broken session", group: "Dialog", run: () => setStore("active", "delete") },
{ key: "right", desc: "Restore broken session", group: "Dialog", cmd: () => setStore("active", "restore") }, { bind: "up", title: "Delete broken session", group: "Dialog", run: () => setStore("active", "delete") },
{ key: "down", desc: "Restore broken session", group: "Dialog", cmd: () => setStore("active", "restore") }, {
bind: "right",
title: "Restore broken session",
group: "Dialog",
run: () => setStore("active", "restore"),
},
{
bind: "down",
title: "Restore broken session",
group: "Dialog",
run: () => setStore("active", "restore"),
},
], ],
})) }))
@@ -5,6 +5,7 @@ import { useDialog } from "../ui/dialog"
import { DialogSelect } from "../ui/dialog-select" import { DialogSelect } from "../ui/dialog-select"
import { useRoute } from "../context/route" import { useRoute } from "../context/route"
import { useData } from "../context/data" import { useData } from "../context/data"
import { Keymap } from "../context/keymap"
import { Locale } from "../util/locale" import { Locale } from "../util/locale"
import { useProject } from "../context/project" import { useProject } from "../context/project"
import { useTheme } from "../context/theme" import { useTheme } from "../context/theme"
@@ -12,7 +13,6 @@ import { useClient } from "../context/client"
import { useLocal } from "../context/local" import { useLocal } from "../context/local"
import { createDebouncedSignal } from "../util/signal" import { createDebouncedSignal } from "../util/signal"
import { useToast } from "../ui/toast" import { useToast } from "../ui/toast"
import { useCommandShortcut } from "../keymap"
import { DialogSessionRename } from "./dialog-session-rename" import { DialogSessionRename } from "./dialog-session-rename"
import { Spinner } from "./spinner" import { Spinner } from "./spinner"
import { errorMessage } from "../util/error" import { errorMessage } from "../util/error"
@@ -27,11 +27,9 @@ export function DialogSessionList() {
const local = useLocal() const local = useLocal()
const toast = useToast() const toast = useToast()
const [filter, setFilter] = createSignal("") const [filter, setFilter] = createSignal("")
const shortcuts = Keymap.useShortcuts()
const [search, setSearch] = createDebouncedSignal("", 150) const [search, setSearch] = createDebouncedSignal("", 150)
const [toDelete, setToDelete] = createSignal<string>() 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) => { const [searchResults] = createResource(search, async (query) => {
if (!query) return if (!query) return
@@ -80,8 +78,8 @@ export function DialogSessionList() {
}) })
const quickSwitchHint = createMemo(() => { const quickSwitchHint = createMemo(() => {
const first = quickSwitch1() const first = shortcuts.get("session.quick_switch.1")
const last = quickSwitch9() const last = shortcuts.get("session.quick_switch.9")
if (!first || !last) return if (!first || !last) return
return quickSwitchRange(first, last) return quickSwitchRange(first, last)
}) })
@@ -107,7 +105,7 @@ export function DialogSessionList() {
const slot = slotByID.get(session.id) const slot = slotByID.get(session.id)
const deleting = toDelete() === session.id const deleting = toDelete() === session.id
return { 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, value: session.id,
category, category,
footer, footer,
+5 -3
View File
@@ -2,9 +2,9 @@ import { useDialog } from "../ui/dialog"
import { DialogSelect } from "../ui/dialog-select" import { DialogSelect } from "../ui/dialog-select"
import { createMemo, createSignal } from "solid-js" import { createMemo, createSignal } from "solid-js"
import { Locale } from "../util/locale" import { Locale } from "../util/locale"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme" import { useTheme } from "../context/theme"
import { usePromptStash, type StashEntry } from "./prompt/stash" import { usePromptStash, type StashEntry } from "./prompt/stash"
import { useCommandShortcut } from "../keymap"
function getRelativeTime(timestamp: number): string { function getRelativeTime(timestamp: number): string {
const now = Date.now() const now = Date.now()
@@ -30,9 +30,9 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
const dialog = useDialog() const dialog = useDialog()
const stash = usePromptStash() const stash = usePromptStash()
const { theme } = useTheme() const { theme } = useTheme()
const shortcuts = Keymap.useShortcuts()
const [toDelete, setToDelete] = createSignal<number>() const [toDelete, setToDelete] = createSignal<number>()
const deleteHint = useCommandShortcut("stash.delete")
const options = createMemo(() => { const options = createMemo(() => {
const entries = stash.list() const entries = stash.list()
@@ -42,7 +42,9 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
const isDeleting = toDelete() === index const isDeleting = toDelete() === index
const lineCount = (entry.prompt.text.match(/\n/g)?.length ?? 0) + 1 const lineCount = (entry.prompt.text.match(/\n/g)?.length ?? 0) + 1
return { 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, bg: isDeleting ? theme.error : undefined,
value: index, value: index,
description: getRelativeTime(entry.timestamp), description: getRelativeTime(entry.timestamp),
@@ -1,11 +1,13 @@
import { useTheme } from "../context/theme" 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() const { theme } = useTheme()
return ( return (
<box width="100%" height="100%" alignItems="center" justifyContent="center" flexDirection="column" gap={1}> <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}> <box onMouseUp={props.onHome} backgroundColor={theme.backgroundElement} paddingLeft={1} paddingRight={1}>
<text fg={theme.text}>go home</text> <text fg={theme.text}>go home</text>
</box> </box>
@@ -19,7 +19,8 @@ import { useTerminalDimensions } from "@opentui/solid"
import { Locale } from "../../util/locale" import { Locale } from "../../util/locale"
import type { PromptInfo, PromptPartRef } from "../../prompt/history" import type { PromptInfo, PromptPartRef } from "../../prompt/history"
import { useFrecency } from "../../prompt/frecency" 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 { displayCharAt, mentionTriggerIndex } from "../../prompt/display"
import type { FileSystemEntry } from "@opencode-ai/client" import type { FileSystemEntry } from "@opencode-ai/client"
@@ -88,7 +89,7 @@ export function Autocomplete(props: {
const data = useData() const data = useData()
const project = useProject() const project = useProject()
const slashes = useCommandSlashes() const slashes = useCommandSlashes()
const modeStack = useOpencodeModeStack() const keymap = Keymap.use()
const { theme } = useTheme() const { theme } = useTheme()
const dimensions = useTerminalDimensions() const dimensions = useTerminalDimensions()
const frecency = useFrecency() const frecency = useFrecency()
@@ -106,7 +107,7 @@ export function Autocomplete(props: {
createEffect(() => { createEffect(() => {
if (!store.visible) return if (!store.visible) return
const popMode = modeStack.push("autocomplete") const popMode = keymap.mode.push("autocomplete")
onCleanup(popMode) onCleanup(popMode)
}) })
@@ -627,13 +628,13 @@ export function Autocomplete(props: {
}, },
}, },
], ],
bindings: config.keybinds.gather("prompt.autocomplete", [ bindings: [
"prompt.autocomplete.prev", "prompt.autocomplete.prev",
"prompt.autocomplete.next", "prompt.autocomplete.next",
"prompt.autocomplete.hide", "prompt.autocomplete.hide",
"prompt.autocomplete.select", "prompt.autocomplete.select",
"prompt.autocomplete.complete", "prompt.autocomplete.complete",
]), ].flatMap((command) => config.keybinds.get(command)),
})) }))
function show(mode: "@" | "/") { function show(mode: "@" | "/") {
+7 -13
View File
@@ -450,7 +450,7 @@ export function Prompt(props: PromptProps) {
title: "Open editor", title: "Open editor",
category: "Session", category: "Session",
name: "prompt.editor", name: "prompt.editor",
slashName: "editor", slash: { name: "editor" },
run: async () => { run: async () => {
dialog.clear() dialog.clear()
@@ -498,7 +498,7 @@ export function Prompt(props: PromptProps) {
title: "Skills", title: "Skills",
name: "prompt.skills", name: "prompt.skills",
category: "Prompt", category: "Prompt",
slashName: "skills", slash: { name: "skills" },
run: () => { run: () => {
dialog.replace(() => ( dialog.replace(() => (
<DialogSkill <DialogSkill
@@ -520,7 +520,7 @@ export function Prompt(props: PromptProps) {
desc: "Move to another project dir", desc: "Move to another project dir",
name: "session.move", name: "session.move",
category: "Session", category: "Session",
slashName: "move", slash: { name: "move" },
run: () => { run: () => {
move.open() move.open()
}, },
@@ -537,7 +537,7 @@ export function Prompt(props: PromptProps) {
useBindings(() => ({ useBindings(() => ({
mode: OPENCODE_BASE_MODE, mode: OPENCODE_BASE_MODE,
bindings: config.keybinds.gather("prompt.palette", [ bindings: [
"prompt.submit", "prompt.submit",
"prompt.editor", "prompt.editor",
"prompt.editor_context.clear", "prompt.editor_context.clear",
@@ -548,7 +548,7 @@ export function Prompt(props: PromptProps) {
"session.interrupt", "session.interrupt",
"session.background", "session.background",
"session.move", "session.move",
]), ].flatMap((command) => config.keybinds.get(command)),
})) }))
const ref: PromptRef = { const ref: PromptRef = {
@@ -1188,10 +1188,7 @@ export function Prompt(props: PromptProps) {
} }
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1 const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
if ( if ((lineCount >= 3 || pastedContent.length > 150) && config.prompt?.paste !== "full") {
(lineCount >= 3 || pastedContent.length > 150) &&
config.prompt?.paste !== "full"
) {
pasteText(pastedContent, `[Pasted ~${lineCount} lines]`) pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
return return
} }
@@ -1298,10 +1295,7 @@ export function Prompt(props: PromptProps) {
}) })
const spinnerDef = createMemo(() => { const spinnerDef = createMemo(() => {
const agent = const agent = status() === "running" ? local.agent.current() : local.agent.current()
status() === "running"
? local.agent.current()
: local.agent.current()
const color = agent ? local.agent.color(agent.id) : theme.border const color = agent ? local.agent.color(agent.id) : theme.border
return { return {
frames: createFrames({ frames: createFrames({
+6 -7
View File
@@ -7,6 +7,7 @@ import { createStore, reconcile } from "solid-js/store"
import { TuiKeybind } from "./keybind" import { TuiKeybind } from "./keybind"
export interface Interface { export interface Interface {
readonly path?: string
readonly get: () => Promise<Info> readonly get: () => Promise<Info>
readonly update: (update: (draft: any) => void) => 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)), Schema.Number.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(1)),
).annotate({ description: "Attention sound volume from 0 to 1" }), ).annotate({ description: "Attention sound volume from 0 to 1" }),
sound_pack: Schema.optional(Schema.String).annotate({ description: "Active attention sound pack ID" }), sound_pack: Schema.optional(Schema.String).annotate({ description: "Active attention sound pack ID" }),
sounds: Schema.optional( sounds: Schema.optional(Schema.Record(AttentionSoundName, Schema.optionalKey(Schema.String))).annotate({
Schema.Record( description: "Sound file overrides by attention event",
AttentionSoundName, }),
Schema.optionalKey(Schema.String),
),
).annotate({ description: "Sound file overrides by attention event" }),
}), }),
).annotate({ description: "System notification and sound settings" }), ).annotate({ description: "System notification and sound settings" }),
diffs: Schema.optional( diffs: Schema.optional(
@@ -181,6 +179,7 @@ export function resolve(input: Info, options: { terminalSuspend: boolean }): Res
const ConfigContext = createContext<{ const ConfigContext = createContext<{
data: Resolved data: Resolved
path?: string
update: Interface["update"] update: Interface["update"]
}>() }>()
@@ -199,7 +198,7 @@ export function ConfigProvider(props: {
return info return info
} }
return ( return (
<ConfigContext.Provider value={{ data: config, update }}>{props.children}</ConfigContext.Provider> <ConfigContext.Provider value={{ data: config, path: host?.path, update }}>{props.children}</ConfigContext.Provider>
) )
} }
-3
View File
@@ -417,9 +417,6 @@ export type BindingLookupView = {
readonly bindings: readonly Binding<Renderable, KeyEvent>[] readonly bindings: readonly Binding<Renderable, KeyEvent>[]
get(command: string): readonly Binding<Renderable, KeyEvent>[] get(command: string): readonly Binding<Renderable, KeyEvent>[]
has(command: string): boolean 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> { export function toBindingConfig(keybinds: Keybinds): BindingConfig<Renderable, KeyEvent> {
+368
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
}
+9 -2
View File
@@ -17,6 +17,7 @@ export type SessionRoute = {
export type PluginRoute = { export type PluginRoute = {
type: "plugin" type: "plugin"
id: string id: string
name: string
data?: Record<string, unknown> 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") { if (value.type === "session" && "sessionID" in value && typeof value.sessionID === "string") {
return { type: "session", sessionID: value.sessionID } return { type: "session", sessionID: value.sessionID }
} }
if (value.type === "plugin" && "id" in value && typeof value.id === "string") { if (
return { type: "plugin", id: value.id } 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 }
} }
} }
+13
View File
@@ -18,9 +18,14 @@ export type TuiStartup = Readonly<{
skipInitialLoading: boolean skipInitialLoading: boolean
}> }>
export type TuiLifecycle = Readonly<{
add(finalizer: () => Promise<void>): () => void
}>
const PathsContext = createContext<TuiPaths>() const PathsContext = createContext<TuiPaths>()
const TerminalEnvironmentContext = createContext<TuiTerminalEnvironment>() const TerminalEnvironmentContext = createContext<TuiTerminalEnvironment>()
const StartupContext = createContext<TuiStartup>() const StartupContext = createContext<TuiStartup>()
const LifecycleContext = createContext<TuiLifecycle>()
function provider<T>(context: ReturnType<typeof createContext<T>>, value: T, children: () => JSX.Element) { function provider<T>(context: ReturnType<typeof createContext<T>>, value: T, children: () => JSX.Element) {
return createComponent(context.Provider, { return createComponent(context.Provider, {
@@ -43,6 +48,10 @@ export function TuiStartupProvider(props: { value: TuiStartup; children: JSX.Ele
return provider(StartupContext, props.value, () => props.children) 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) { function required<T>(context: ReturnType<typeof createContext<T>>, name: string) {
const value = useContext(context) const value = useContext(context)
if (!value) throw new Error(`${name} is missing`) if (!value) throw new Error(`${name} is missing`)
@@ -60,3 +69,7 @@ export function useTuiTerminalEnvironment() {
export function useTuiStartup() { export function useTuiStartup() {
return required(StartupContext, "TuiStartupProvider") return required(StartupContext, "TuiStartupProvider")
} }
export function useTuiLifecycle() {
return required(LifecycleContext, "TuiLifecycleProvider")
}
+2 -24
View File
@@ -1,16 +1,9 @@
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui" import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
import type { PluginRuntime } from "../plugin/runtime" 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 DiffViewer from "./system/diff-viewer"
import Notifications from "./system/notifications" import Notifications from "./system/notifications"
import PluginManager from "./system/plugins" import PluginManager from "./system/plugins"
import WhichKey from "./system/which-key" import WhichKey from "./system/which-key"
import Scrap from "./system/scrap"
export type BuiltinTuiPlugin = Omit<TuiPluginModule, "id"> & { export type BuiltinTuiPlugin = Omit<TuiPluginModule, "id"> & {
id: string id: string
@@ -19,25 +12,10 @@ export type BuiltinTuiPlugin = Omit<TuiPluginModule, "id"> & {
} }
export function createBuiltinPlugins(): BuiltinTuiPlugin[] { export function createBuiltinPlugins(): BuiltinTuiPlugin[] {
return [ return [Notifications, PluginManager, WhichKey, DiffViewer]
HomeFooter,
HomeTips,
SidebarContext,
SidebarMcp,
SidebarLsp,
SidebarFooter,
Notifications,
PluginManager,
WhichKey,
Scrap,
DiffViewer,
]
} }
export async function loadBuiltinPlugins( export async function loadBuiltinPlugins(api: TuiPluginApi, runtime: PluginRuntime) {
api: TuiPluginApi,
runtime: PluginRuntime,
) {
const slots = runtime.setupSlots(api) const slots = runtime.setupSlots(api)
const dispose: Array<() => void> = [] const dispose: Array<() => void> = []
@@ -1,98 +1,66 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" import { Plugin } from "@opencode-ai/plugin/v2/tui"
import type { BuiltinTuiPlugin } from "../builtins" import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { createMemo, Match, Show, Switch } from "solid-js" 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 { 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: { context: Plugin.Context; maxWidth: number }) {
const { theme } = useTheme()
function Directory(props: { api: TuiPluginApi; maxWidth: number }) {
const theme = () => props.api.theme.current
const destination = useHomeSessionDestination() const destination = useHomeSessionDestination()
const paths = useTuiPaths() const paths = useTuiPaths()
const dir = createMemo(() => { const directory = createMemo(() => {
const selected = destination?.destination() const selected = destination?.destination()
if (!selected || selected.type === "new") return if (!selected || selected.type === "new") return
const branch = return abbreviateHome(selected.directory || props.context.data.location.default().directory, paths.home)
selected.directory === (props.api.state.path.directory || paths.cwd) ? props.api.state.vcs?.branch : undefined
return { path: abbreviateHome(selected.directory, paths.home), branch }
}) })
return ( return (
<Show when={dir()}> <Show when={directory()}>
{(value) => { {(value) => <FilePath value={value()} maxWidth={props.maxWidth} fg={theme.textMuted} />}
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> </Show>
) )
} }
function Mcp(props: { api: TuiPluginApi }) { function Mcp(props: { context: Plugin.Context }) {
const theme = () => props.api.theme.current const { theme } = useTheme()
const list = createMemo(() => props.api.state.mcp()) const list = createMemo(() => props.context.data.location.mcp.server.list() ?? [])
const has = createMemo(() => list().length > 0) const failed = createMemo(() => list().some((item) => item.status.status === "failed"))
const err = createMemo(() => list().some((item) => item.status === "failed")) const count = createMemo(() => list().filter((item) => item.status.status === "connected").length)
const count = createMemo(() => list().filter((item) => item.status === "connected").length)
return ( return (
<Show when={has()}> <Show when={list().length}>
<box gap={1} flexDirection="row" flexShrink={0}> <box gap={1} flexDirection="row" flexShrink={0}>
<text fg={theme().text}> <text fg={theme.text}>
<Switch> <Switch>
<Match when={err()}> <Match when={failed()}>
<span style={{ fg: theme().error }}> </span> <span style={{ fg: theme.error }}> </span>
</Match> </Match>
<Match when={true}> <Match when={true}>
<span style={{ fg: count() > 0 ? theme().success : theme().textMuted }}> </span> <span style={{ fg: count() > 0 ? theme.success : theme.textMuted }}> </span>
</Match> </Match>
</Switch> </Switch>
{count()} MCP {count()} MCP
</text> </text>
<text fg={theme().textMuted}>/status</text> <text fg={theme.textMuted}>/status</text>
</box> </box>
</Show> </Show>
) )
} }
function Version(props: { api: TuiPluginApi }) { function View(props: { context: Plugin.Context }) {
const theme = () => props.api.theme.current const { theme } = useTheme()
return (
<box flexShrink={0}>
<text fg={theme().textMuted}>{props.api.app.version}</text>
</box>
)
}
function View(props: { api: TuiPluginApi }) {
const dimensions = useTerminalDimensions() const dimensions = useTerminalDimensions()
const mcpWidth = createMemo(() => { const mcpWidth = createMemo(() => {
const list = props.api.state.mcp() const list = props.context.data.location.mcp.server.list() ?? []
if (list.length === 0) return 0 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 return Bun.stringWidth(`${count} MCP /status`) + 2
}) })
const directoryWidth = createMemo(() =>
Math.max(2, dimensions().width - 8 - Bun.stringWidth(props.api.app.version) - mcpWidth()),
)
return ( return (
<box <box
width="100%" width="100%"
@@ -104,28 +72,22 @@ function View(props: { api: TuiPluginApi }) {
flexShrink={0} flexShrink={0}
gap={2} gap={2}
> >
<Directory api={props.api} maxWidth={directoryWidth()} /> <Directory
<Mcp api={props.api} /> context={props.context}
maxWidth={Math.max(2, dimensions().width - 8 - Bun.stringWidth(InstallationVersion) - mcpWidth())}
/>
<Mcp context={props.context} />
<box flexGrow={1} /> <box flexGrow={1} />
<Version api={props.api} /> <box flexShrink={0}>
<text fg={theme.textMuted}>{InstallationVersion}</text>
</box>
</box> </box>
) )
} }
const tui: TuiPlugin = async (api) => { export default Plugin.define({
api.slots.register({ id: "opencode.home-footer",
order: 100, setup(context) {
slots: { context.ui.slot("home.footer", () => <View context={context} />)
home_footer() {
return <View api={api} />
}, },
}, })
})
}
const plugin: BuiltinTuiPlugin = {
id,
tui,
}
export default plugin
@@ -1,12 +1,11 @@
import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
import { createMemo, For, type Accessor } from "solid-js" import { createMemo, For, type Accessor } from "solid-js"
import { DEFAULT_THEMES, useTheme } from "../../context/theme" import { DEFAULT_THEMES, useTheme } from "../../context/theme"
import { useCommandShortcut } from "../../keymap" import { Keymap } from "../../context/keymap"
const themeCount = Object.keys(DEFAULT_THEMES).length const themeCount = Object.keys(DEFAULT_THEMES).length
type TipPart = { text: string; highlight: boolean } type TipPart = { text: string; highlight: boolean }
type TipShortcut = Accessor<string> type TipShortcut = Accessor<string | undefined>
type Shortcuts = { type Shortcuts = {
agentCycle: TipShortcut agentCycle: TipShortcut
childFirst: TipShortcut childFirst: TipShortcut
@@ -74,61 +73,54 @@ function shortcutText(value: string) {
return `{highlight}${value}{/highlight}` return `{highlight}${value}{/highlight}`
} }
function commandText(command: string, shortcut: string) { function commandText(command: string, shortcut: string | undefined) {
if (!shortcut) return shortcutText(command) if (!shortcut) return shortcutText(command)
return `${shortcutText(command)} or ${shortcutText(shortcut)}` return `${shortcutText(command)} or ${shortcutText(shortcut)}`
} }
function press(shortcut: string, text: string) { function press(shortcut: string | undefined, text: string) {
if (!shortcut) return undefined if (!shortcut) return undefined
return `Press ${shortcutText(shortcut)} ${text}` return `Press ${shortcutText(shortcut)} ${text}`
} }
function configShortcut(api: TuiPluginApi, command: string): TipShortcut { export function Tips(props: { connected?: boolean }) {
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 }) {
const theme = useTheme().theme const theme = useTheme().theme
const keymap = Keymap.useShortcuts()
const tipOffset = Math.random() const tipOffset = Math.random()
const shortcut = (id: string) => () => keymap.get(id)
const shortcuts: Shortcuts = { const shortcuts: Shortcuts = {
agentCycle: useCommandShortcut("agent.cycle"), agentCycle: shortcut("agent.cycle"),
childFirst: configShortcut(props.api, "session.child.first"), childFirst: shortcut("session.child.first"),
childNext: configShortcut(props.api, "session.child.next"), childNext: shortcut("session.child.next"),
childPrevious: configShortcut(props.api, "session.child.previous"), childPrevious: shortcut("session.child.previous"),
commandList: useCommandShortcut("command.palette.show"), commandList: shortcut("command.palette.show"),
editorOpen: useCommandShortcut("prompt.editor"), editorOpen: shortcut("prompt.editor"),
helpShow: useCommandShortcut("help.show"), helpShow: shortcut("help.show"),
inputClear: useCommandShortcut("prompt.clear"), inputClear: shortcut("prompt.clear"),
inputNewline: useCommandShortcut("input.newline"), inputNewline: shortcut("input.newline"),
inputPaste: useCommandShortcut("prompt.paste"), inputPaste: shortcut("prompt.paste"),
inputUndo: useCommandShortcut("input.undo"), inputUndo: shortcut("input.undo"),
leader: configShortcut(props.api, "leader"), leader: shortcut("leader"),
messagesCopy: configShortcut(props.api, "messages.copy"), messagesCopy: shortcut("messages.copy"),
messagesFirst: configShortcut(props.api, "session.first"), messagesFirst: shortcut("session.first"),
messagesLast: configShortcut(props.api, "session.last"), messagesLast: shortcut("session.last"),
messagesPageDown: configShortcut(props.api, "session.page.down"), messagesPageDown: shortcut("session.page.down"),
messagesPageUp: configShortcut(props.api, "session.page.up"), messagesPageUp: shortcut("session.page.up"),
modelCycleRecent: useCommandShortcut("model.cycle_recent"), modelCycleRecent: shortcut("model.cycle_recent"),
modelList: useCommandShortcut("model.list"), modelList: shortcut("model.list"),
sessionExport: configShortcut(props.api, "session.export"), sessionExport: shortcut("session.export"),
sessionInterrupt: configShortcut(props.api, "session.interrupt"), sessionInterrupt: shortcut("session.interrupt"),
sessionList: useCommandShortcut("session.list"), sessionList: shortcut("session.list"),
sessionNew: useCommandShortcut("session.new"), sessionNew: shortcut("session.new"),
sessionParent: configShortcut(props.api, "session.parent"), sessionParent: shortcut("session.parent"),
sessionPinToggle: configShortcut(props.api, "session.pin.toggle"), sessionPinToggle: shortcut("session.pin.toggle"),
sessionQuickSwitch1: useCommandShortcut("session.quick_switch.1"), sessionQuickSwitch1: shortcut("session.quick_switch.1"),
sessionQuickSwitch9: useCommandShortcut("session.quick_switch.9"), sessionQuickSwitch9: shortcut("session.quick_switch.9"),
sessionSidebarToggle: configShortcut(props.api, "session.sidebar.toggle"), sessionSidebarToggle: shortcut("session.sidebar.toggle"),
sessionTimeline: configShortcut(props.api, "session.timeline"), sessionTimeline: shortcut("session.timeline"),
statusView: useCommandShortcut("opencode.status"), statusView: shortcut("opencode.status"),
terminalSuspend: useCommandShortcut("terminal.suspend"), terminalSuspend: shortcut("terminal.suspend"),
themeList: useCommandShortcut("theme.switch"), themeList: shortcut("theme.switch"),
} }
const tip = createMemo(() => { const tip = createMemo(() => {
if (props.connected === false) return NO_MODELS_TIP 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("/new", shortcuts.sessionNew())} to start a fresh conversation session`,
(shortcuts) => `Use ${commandText("/sessions", shortcuts.sessionList())} to list, pin, and continue sessions`, (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) => press(shortcuts.sessionPinToggle(), "in the session list to pin one at the top"),
(shortcuts) => (shortcuts) => {
shortcuts.sessionQuickSwitch1() && shortcuts.sessionQuickSwitch9() const first = shortcuts.sessionQuickSwitch1()
? `Use ${shortcutText(shortcuts.sessionQuickSwitch1())} through ${shortcutText(shortcuts.sessionQuickSwitch9())} to switch pinned sessions` const last = shortcuts.sessionQuickSwitch9()
: undefined, 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", "Run {highlight}/compact{/highlight} to summarize long sessions near context limits",
(shortcuts) => `Use ${commandText("/export", shortcuts.sessionExport())} to save the conversation as Markdown`, (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.messagesCopy(), "to copy the assistant's last message to clipboard"),
(shortcuts) => press(shortcuts.commandList(), "to see all available actions and commands"), (shortcuts) => press(shortcuts.commandList(), "to see all available actions and commands"),
"Run {highlight}/connect{/highlight} to add API keys for 75+ supported LLM providers", "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.modelCycleRecent(), "to quickly switch between recently used models"),
(shortcuts) => press(shortcuts.sessionSidebarToggle(), "in a session to show or hide the sidebar panel"), (shortcuts) => press(shortcuts.sessionSidebarToggle(), "in a session to show or hide the sidebar panel"),
(shortcuts) => (shortcuts) => {
shortcuts.messagesPageUp() && shortcuts.messagesPageDown() const up = shortcuts.messagesPageUp()
? `Use ${shortcutText(shortcuts.messagesPageUp())}/${shortcutText(shortcuts.messagesPageDown())} to navigate through conversation history` const down = shortcuts.messagesPageDown()
: undefined, 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.messagesFirst(), "to jump to the beginning of the conversation"),
(shortcuts) => press(shortcuts.messagesLast(), "to jump to the most recent message"), (shortcuts) => press(shortcuts.messagesLast(), "to jump to the most recent message"),
(shortcuts) => press(shortcuts.inputNewline(), "to add newlines in your prompt"), (shortcuts) => press(shortcuts.inputNewline(), "to add newlines in your prompt"),
@@ -204,7 +204,7 @@ const TIPS: Tip[] = [
shortcuts.childFirst(), shortcuts.childFirst(),
shortcuts.childPrevious(), shortcuts.childPrevious(),
shortcuts.childNext(), shortcuts.childNext(),
].filter(Boolean) ].filter((item): item is string => Boolean(item))
if (!items.length) return undefined if (!items.length) return undefined
return `Use ${items.map(shortcutText).join(" / ")} for parent/child sessions` 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("/timeline", shortcuts.sessionTimeline())} to jump to specific messages`,
(shortcuts) => `Use ${commandText("/status", shortcuts.statusView())} to see system status info`, (shortcuts) => `Use ${commandText("/status", shortcuts.statusView())} to see system status info`,
"Enable {highlight}scroll.acceleration{/highlight} in {highlight}cli.json{/highlight} for smooth scrolling", "Enable {highlight}scroll.acceleration{/highlight} in {highlight}cli.json{/highlight} for smooth scrolling",
(shortcuts) => (shortcuts) => {
shortcuts.commandList() const commandList = shortcuts.commandList()
? `Toggle username display in chat via the command palette (${shortcutText(shortcuts.commandList())})` return commandList
: "Toggle username display in chat via the command palette", ? `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", "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", "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", "Commit your project's {highlight}AGENTS.md{/highlight} file to Git for team sharing",
+24 -39
View File
@@ -1,66 +1,51 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" import { Plugin } from "@opencode-ai/plugin/v2/tui"
import type { BuiltinTuiPlugin } from "../builtins"
import { createMemo, Show } from "solid-js" import { createMemo, Show } from "solid-js"
import { Tips } from "./tips-view" import { Tips } from "./tips-view"
import { useBindings } from "../../keymap" import { Keymap } from "../../context/keymap"
import { useData } from "../../context/data" import { useData } from "../../context/data"
import { hasConnectedProvider } from "../../util/connected-provider" import { hasConnectedProvider } from "../../util/connected-provider"
import { useConfig } from "../../config" import { useConfig } from "../../config"
import { useDialog } from "../../ui/dialog"
const id = "internal:home-tips" function View() {
function View(props: { api: TuiPluginApi; hidden: boolean; show: boolean; connected: boolean }) {
const config = useConfig() 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: [ commands: [
{ {
name: "tips.toggle", id: "tips.toggle",
title: props.hidden ? "Show tips" : "Hide tips", title: hidden() ? "Show tips" : "Hide tips",
category: "System", group: "System",
namespace: "palette",
hidden: true,
run() { run() {
void config void config
.update((draft) => { .update((draft) => {
draft.hints = { ...draft.hints, tips: props.hidden } draft.hints = { ...draft.hints, tips: hidden() }
}) })
.catch(() => {}) .catch(() => {})
props.api.ui.dialog.clear() dialog.clear()
}, },
}, },
], ],
bindings: props.api.tuiConfig.keybinds.get("tips.toggle"),
})) }))
return ( return (
<box width="100%" maxWidth={75} alignItems="center" paddingTop={3} flexShrink={1}> <box width="100%" maxWidth={75} alignItems="center" paddingTop={3} flexShrink={1}>
<Show when={props.show}> <Show when={show()}>
<Tips api={props.api} connected={props.connected} /> <Tips connected={connected()} />
</Show> </Show>
</box> </box>
) )
} }
const tui: TuiPlugin = async (api) => { export default Plugin.define({
api.slots.register({ id: "internal:home-tips",
order: 100, setup(context) {
slots: { context.ui.slot("home.bottom", () => <View />)
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
@@ -1,59 +1,46 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" import { Plugin } from "@opencode-ai/plugin/v2/tui"
import type { BuiltinTuiPlugin } from "../builtins"
import { createMemo, Show } from "solid-js" import { createMemo, Show } from "solid-js"
import { useData } from "../../context/data" import { useTheme } from "../../context/theme"
import { contextUsage } from "../../util/session" import { contextUsage } from "../../util/session"
const id = "internal:sidebar-context"
const money = new Intl.NumberFormat("en-US", { const money = new Intl.NumberFormat("en-US", {
style: "currency", style: "currency",
currency: "USD", currency: "USD",
}) })
function View(props: { api: TuiPluginApi; session_id: string }) { function View(props: { context: Plugin.Context; sessionID: string }) {
const data = useData() const { theme } = useTheme()
const theme = () => props.api.theme.current const msg = createMemo(() => props.context.data.session.message.list(props.sessionID))
const msg = createMemo(() => data.session.message.list(props.session_id)) const session = createMemo(() => props.context.data.session.get(props.sessionID))
const session = createMemo(() => data.session.get(props.session_id)) const cost = createMemo(() => props.context.data.session.cost(props.sessionID))
const cost = createMemo(() => data.session.cost(props.session_id))
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 ( return (
<box> <box>
<text fg={theme().text}> <text fg={theme.text}>
<b>Context</b> <b>Context</b>
</text> </text>
<Show when={state()} fallback={<text fg={theme().textMuted}>Not measured</text>}> <Show when={state()} fallback={<text fg={theme.textMuted}>Not measured</text>}>
{(value) => ( {(value) => (
<> <>
<text fg={theme().textMuted}>{value().tokens.toLocaleString()} tokens</text> <text fg={theme.textMuted}>{value().tokens.toLocaleString()} tokens</text>
<Show when={value().percent !== undefined}> <Show when={value().percent !== undefined}>
<text fg={theme().textMuted}>{value().percent}% used</text> <text fg={theme.textMuted}>{value().percent}% used</text>
</Show> </Show>
</> </>
)} )}
</Show> </Show>
<text fg={theme().textMuted}>{money.format(cost())} spent</text> <text fg={theme.textMuted}>{money.format(cost())} spent</text>
</box> </box>
) )
} }
const tui: TuiPlugin = async (api) => { export default Plugin.define({
api.slots.register({ id: "internal:sidebar-context",
order: 100, setup(context) {
slots: { context.ui.slot("sidebar.content", (props) => <View context={context} sessionID={props.sessionID} />)
sidebar_content(_ctx, props) {
return <View api={api} session_id={props.session_id} />
}, },
}, })
})
}
const plugin: BuiltinTuiPlugin = {
id,
tui,
}
export default plugin
@@ -1,113 +1,14 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" import { Plugin } from "@opencode-ai/plugin/v2/tui"
import type { BuiltinTuiPlugin } from "../builtins" import { useTheme } from "../../context/theme"
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"
const id = "internal:sidebar-footer" function View() {
const { theme } = useTheme()
function View(props: { api: TuiPluginApi; directory: string }) { return <text fg={theme.textMuted}>Sidebar footer unavailable</text>
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>
)
} }
const tui: TuiPlugin = async (api) => { export default Plugin.define({
api.slots.register({ id: "opencode.sidebar-footer",
order: 100, setup(context) {
slots: { context.ui.slot("sidebar.footer", () => <View />)
sidebar_footer(_ctx, props) {
return <View api={api} directory={props.directory} />
}, },
}, })
})
}
const plugin: BuiltinTuiPlugin = {
id,
tui,
}
export default plugin
@@ -1,65 +1,21 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" import { Plugin } from "@opencode-ai/plugin/v2/tui"
import type { BuiltinTuiPlugin } from "../builtins" import { useTheme } from "../../context/theme"
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)
function View() {
const { theme } = useTheme()
return ( return (
<box> <box>
<box flexDirection="row" gap={1} onMouseDown={() => list().length > 2 && setOpen((x) => !x)}> <text fg={theme.text}>
<Show when={list().length > 2}>
<text fg={theme().text}>{open() ? "▼" : "▶"}</text>
</Show>
<text fg={theme().text}>
<b>LSP</b> <b>LSP</b>
</text> </text>
</box> <text fg={theme.textMuted}>LSP status unavailable</text>
<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>
</box> </box>
) )
} }
const tui: TuiPlugin = async (api) => { export default Plugin.define({
api.slots.register({ id: "opencode.sidebar-lsp",
order: 300, setup(context) {
slots: { context.ui.slot("sidebar.content", () => <View />)
sidebar_content() {
return <View api={api} />
}, },
}, })
})
}
const plugin: BuiltinTuiPlugin = {
id,
tui,
}
export default plugin
@@ -1,29 +1,30 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" import { Plugin } from "@opencode-ai/plugin/v2/tui"
import type { BuiltinTuiPlugin } from "../builtins"
import { createMemo, For, Match, Show, Switch, createSignal } from "solid-js" import { createMemo, For, Match, Show, Switch, createSignal } from "solid-js"
import { useTheme } from "../../context/theme"
const id = "internal:sidebar-mcp" function View(props: { context: Plugin.Context; sessionID: string }) {
function View(props: { api: TuiPluginApi }) {
const [open, setOpen] = createSignal(true) const [open, setOpen] = createSignal(true)
const theme = () => props.api.theme.current const { theme } = useTheme()
const list = createMemo(() => props.api.state.mcp()) const session = createMemo(() => props.context.data.session.get(props.sessionID))
const on = createMemo(() => list().filter((item) => item.status === "connected").length) 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( const bad = createMemo(
() => () =>
list().filter( list().filter(
(item) => (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, ).length,
) )
const dot = (status: string) => { const dot = (status: string) => {
if (status === "connected") return theme().success if (status === "connected") return theme.success
if (status === "failed") return theme().error if (status === "failed") return theme.error
if (status === "disabled") return theme().textMuted if (status === "disabled") return theme.textMuted
if (status === "needs_auth") return theme().warning if (status === "needs_auth") return theme.warning
if (status === "needs_client_registration") return theme().error if (status === "needs_client_registration") return theme.error
return theme().textMuted return theme.textMuted
} }
return ( return (
@@ -31,12 +32,12 @@ function View(props: { api: TuiPluginApi }) {
<box> <box>
<box flexDirection="row" gap={1} onMouseDown={() => list().length > 2 && setOpen((x) => !x)}> <box flexDirection="row" gap={1} onMouseDown={() => list().length > 2 && setOpen((x) => !x)}>
<Show when={list().length > 2}> <Show when={list().length > 2}>
<text fg={theme().text}>{open() ? "▼" : "▶"}</text> <text fg={theme.text}>{open() ? "▼" : "▶"}</text>
</Show> </Show>
<text fg={theme().text}> <text fg={theme.text}>
<b>MCP</b> <b>MCP</b>
<Show when={!open()}> <Show when={!open()}>
<span style={{ fg: theme().textMuted }}> <span style={{ fg: theme.textMuted }}>
{" "} {" "}
({on()} active{bad() > 0 ? `, ${bad()} error${bad() > 1 ? "s" : ""}` : ""}) ({on()} active{bad() > 0 ? `, ${bad()} error${bad() > 1 ? "s" : ""}` : ""})
</span> </span>
@@ -50,22 +51,22 @@ function View(props: { api: TuiPluginApi }) {
<text <text
flexShrink={0} flexShrink={0}
style={{ style={{
fg: dot(item.status), fg: dot(item.status.status),
}} }}
> >
</text> </text>
<text fg={theme().text} wrapMode="word"> <text fg={theme.text} wrapMode="word">
{item.name}{" "} {item.name}{" "}
<span style={{ fg: theme().textMuted }}> <span style={{ fg: theme.textMuted }}>
<Switch fallback={item.status}> <Switch fallback={item.status.status}>
<Match when={item.status === "connected"}>Connected</Match> <Match when={item.status.status === "connected"}>Connected</Match>
<Match when={item.status === "failed"}> <Match when={item.status.status === "failed"}>
<i>{item.error}</i> <i>{item.status.status === "failed" ? item.status.error : undefined}</i>
</Match> </Match>
<Match when={item.status === "disabled"}>Disabled</Match> <Match when={item.status.status === "disabled"}>Disabled</Match>
<Match when={item.status === "needs_auth"}>Needs auth</Match> <Match when={item.status.status === "needs_auth"}>Needs auth</Match>
<Match when={item.status === "needs_client_registration"}>Needs client ID</Match> <Match when={item.status.status === "needs_client_registration"}>Needs client ID</Match>
</Switch> </Switch>
</span> </span>
</text> </text>
@@ -78,20 +79,9 @@ function View(props: { api: TuiPluginApi }) {
) )
} }
const tui: TuiPlugin = async (api) => { export default Plugin.define({
api.slots.register({ id: "internal:sidebar-mcp",
order: 200, setup(context) {
slots: { context.ui.slot("sidebar.content", (props) => <View context={context} sessionID={props.sessionID} />)
sidebar_content() {
return <View api={api} />
}, },
}, })
})
}
const plugin: BuiltinTuiPlugin = {
id,
tui,
}
export default plugin
@@ -732,10 +732,7 @@ function DiffViewer(props: { api: TuiPluginApi }) {
{ key: "pagedown,ctrl+f", cmd: "diff.page.down", desc: "Page diff viewer down" }, { 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: "pageup,ctrl+b", cmd: "diff.page.up", desc: "Page diff viewer up" },
{ key: "m", cmd: "diff.mark_reviewed", desc: "Mark selected file reviewed" }, { key: "m", cmd: "diff.mark_reviewed", desc: "Mark selected file reviewed" },
...props.api.tuiConfig.keybinds.gather( ...commands.flatMap((command) => props.api.tuiConfig.keybinds.get(command.name)),
"diff",
commands.map((command) => command.name),
),
], ],
})) }))
@@ -1047,7 +1044,7 @@ const tui: TuiPlugin = async (api) => {
{ {
name: "diff.open", name: "diff.open",
title: "Open diff viewer", title: "Open diff viewer",
slashName: "diff", slash: { name: "diff" },
category: "VCS", category: "VCS",
namespace: "palette", namespace: "palette",
run() { run() {
@@ -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)),
}) })
} }
@@ -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 { useTerminalDimensions } from "@opentui/solid"
import { Keymap } from "../../context/keymap"
import { useTheme } from "../../context/theme" import { useTheme } from "../../context/theme"
import { useBindings } from "../../keymap" import { useDialog } from "../../ui/dialog"
import type { BuiltinTuiPlugin } from "../builtins"
const id = "internal:scrap" function Commands(props: { context: Plugin.Context }) {
const route = "scrap" 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 dimensions = useTerminalDimensions()
const { theme } = useTheme() const { theme } = useTheme()
useBindings(() => ({ Keymap.createLayer(() => ({
bindings: [ commands: [
{ {
key: "escape", bind: "escape",
desc: "Back home", title: "Back home",
group: "Scrap", group: "Scrap",
cmd() { run() {
props.api.route.navigate("home") props.context.ui.router.navigate({ type: "home" })
}, },
}, },
], ],
@@ -43,24 +60,10 @@ function Scrap(props: { api: TuiPluginApi }) {
) )
} }
const tui: TuiPlugin = async (api) => { export default Plugin.define({
api.route.register([{ name: route, render: () => <Scrap api={api} /> }]) id: "opencode.scrap",
api.keymap.registerLayer({ setup(context) {
commands: [ context.ui.router.register({ name: "scrap", render: () => <Scrap context={context} /> })
{ context.ui.slot("app", () => <Commands context={context} />)
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
@@ -358,9 +358,9 @@ function WhichKeyPanel(props: {
}, },
}, },
], ],
bindings: pendingMode() bindings: (pendingMode() ? scrollCommands : panelCommands).flatMap((command) =>
? props.api.tuiConfig.keybinds.gather("which-key.scroll", scrollCommands) props.api.tuiConfig.keybinds.get(command),
: props.api.tuiConfig.keybinds.gather("which-key.panel", panelCommands), ),
})) }))
createEffect(() => { 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({ api.slots.register({
+18 -13
View File
@@ -17,17 +17,26 @@ import { createMemo, type Accessor } from "solid-js"
import { useConfig } from "./config" import { useConfig } from "./config"
import { TuiKeybind } from "./config/keybind" import { TuiKeybind } from "./config/keybind"
declare module "@opentui/keymap" {
interface Command {
slash?: {
name: string
aliases?: string[]
}
}
}
export const LEADER_TOKEN = "leader" export const LEADER_TOKEN = "leader"
export const OPENCODE_BASE_MODE = "base" export const OPENCODE_BASE_MODE = "base"
export const COMMAND_PALETTE_COMMAND = "command.palette.show" export const COMMAND_PALETTE_COMMAND = "command.palette.show"
const OPENCODE_MODE_KEY = "opencode.mode" const OPENCODE_MODE_KEY = "opencode.mode"
export { useBindings, useKeymapSelector }
export const OpencodeKeymapProvider = KeymapProvider export const OpencodeKeymapProvider = KeymapProvider
export const useOpencodeKeymap = useKeymap export const useOpencodeKeymap = useKeymap
export { useBindings, useKeymapSelector }
export type OpenTuiKeymap = ReturnType<typeof useKeymap> export type OpenTuiKeymap = ReturnType<typeof useKeymap>
type OpencodeModeStack = ReturnType<typeof createOpencodeModeStack> type OpencodeModeStack = ReturnType<typeof createOpencodeModeStack>
type CommandSlashEntry = { type CommandSlashEntry = {
@@ -36,17 +45,16 @@ type CommandSlashEntry = {
aliases?: string[] aliases?: string[]
onSelect: () => void onSelect: () => void
} }
type Command = ReturnType<OpenTuiKeymap["getCommands"]>[number] type RegisteredCommand = ReturnType<OpenTuiKeymap["getCommands"]>[number]
type BindingLookup = { type BindingLookup = {
get(command: string): readonly Binding<Renderable, KeyEvent>[] get(command: string): readonly Binding<Renderable, KeyEvent>[]
gather(name: string, commands: readonly string[]): readonly Binding<Renderable, KeyEvent>[]
} }
type FormatConfig = { keybinds: BindingLookup } type FormatConfig = { keybinds: BindingLookup }
type ResolvedKeymapConfig = FormatConfig & ({ leader: { timeout: number } } | { leader_timeout: number }) type ResolvedKeymapConfig = FormatConfig & ({ leader: { timeout: number } } | { leader_timeout: number })
const modeStacks = new WeakMap<OpenTuiKeymap, OpencodeModeStack>() const modeStacks = new WeakMap<OpenTuiKeymap, OpencodeModeStack>()
function isVisiblePaletteCommand(command: Command) { function isVisiblePaletteCommand(command: RegisteredCommand) {
return command.hidden !== true && command.name !== COMMAND_PALETTE_COMMAND 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 offBackspace = registerBackspacePopsPendingSequence(keymap)
const offInputBindings = registerManagedTextareaLayer(keymap, renderer, { const offInputBindings = registerManagedTextareaLayer(keymap, renderer, {
enabled: () => hasManagedTextareaFocus(renderer), enabled: () => hasManagedTextareaFocus(renderer),
bindings: config.keybinds.gather("input", inputCommands), bindings: inputCommands.flatMap((command) => config.keybinds.get(command)),
}) })
return () => { return () => {
@@ -273,20 +281,17 @@ export function useCommandSlashes(): Accessor<readonly CommandSlashEntry[]> {
return createMemo<CommandSlashEntry[]>(() => return createMemo<CommandSlashEntry[]>(() =>
entries().flatMap((entry) => { entries().flatMap((entry) => {
const slashName = entry.command.slashName const slash = entry.command.slash
if (typeof slashName !== "string" || !slashName) return [] if (!slash) return []
const slashAliases = entry.command.slashAliases
return { return {
display: `/${slashName}`, display: `/${slash.name}`,
description: description:
typeof entry.command.desc === "string" typeof entry.command.desc === "string"
? entry.command.desc ? entry.command.desc
: typeof entry.command.title === "string" : typeof entry.command.title === "string"
? entry.command.title ? entry.command.title
: undefined, : undefined,
aliases: Array.isArray(slashAliases) aliases: slash.aliases?.map((alias) => `/${alias}`),
? slashAliases.filter((alias): alias is string => typeof alias === "string").map((alias) => `/${alias}`)
: undefined,
onSelect: () => keymap.dispatchCommand(entry.command.name), onSelect: () => keymap.dispatchCommand(entry.command.name),
} }
}), }),
-356
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
},
},
}
}
+1 -13
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" import { createSignal } from "solid-js"
type RouteEntry = { type RouteEntry = {
@@ -38,15 +38,3 @@ export function createPluginRoutes() {
} }
export type PluginRoutes = ReturnType<typeof createPluginRoutes> export type PluginRoutes = ReturnType<typeof createPluginRoutes>
export function createTuiApi(input: Omit<TuiPluginApi, "lifecycle">): TuiPluginApi {
return {
...input,
lifecycle: {
signal: new AbortController().signal,
onDispose() {
return () => {}
},
},
}
}
+9
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]
+1 -2
View File
@@ -56,8 +56,7 @@ function toCommand(item: TuiCommand, dialog: LegacyDialog) {
suggested: item.suggested, suggested: item.suggested,
hidden: item.hidden, hidden: item.hidden,
enabled: item.enabled, enabled: item.enabled,
slashName: item.slash?.name, slash: item.slash,
slashAliases: item.slash?.aliases,
run() { run() {
return item.onSelect?.(dialog) return item.onSelect?.(dialog)
}, },
+383
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>
}
+1 -1
View File
@@ -23,7 +23,7 @@ function isHostSlotPlugin(value: unknown): value is HostSlotPlugin<Record<string
} }
export function createSlots() { export function createSlots() {
const empty: SlotView = () => null const empty: SlotView = (props) => props.children ?? null
const [view, setView] = createSignal<SlotView>(empty) const [view, setView] = createSignal<SlotView>(empty)
const Slot: SlotView = (props) => view()(props) const Slot: SlotView = (props) => view()(props)
+4 -11
View File
@@ -12,6 +12,7 @@ import { HomeSessionDestinationProvider } from "./home/session-destination"
import { useData } from "../context/data" import { useData } from "../context/data"
import { LocationProvider } from "../context/location" import { LocationProvider } from "../context/location"
import { FormPrompt } from "./session/form" import { FormPrompt } from "./session/form"
import { PluginSlot } from "../plugin/context"
let once = false let once = false
const placeholder = { const placeholder = {
@@ -84,26 +85,18 @@ export function Home() {
/> />
</pluginRuntime.Slot> </pluginRuntime.Slot>
</box> </box>
<pluginRuntime.Slot name="home_bottom" /> <PluginSlot name="home.bottom" />
<box flexGrow={1} minHeight={0} /> <box flexGrow={1} minHeight={0} />
<Toast /> <Toast />
</box> </box>
<box width="100%" flexShrink={0}> <box width="100%" flexShrink={0}>
<pluginRuntime.Slot name="home_footer" mode="single_winner" /> <PluginSlot name="home.footer" />
</box> </box>
<Show when={forms()[0]?.id} keyed> <Show when={forms()[0]?.id} keyed>
{(_) => { {(_) => {
const form = forms()[0] const form = forms()[0]
return form ? ( return form ? (
<box <box position="absolute" zIndex={2000} left={0} right={0} bottom={1} paddingLeft={2} paddingRight={2}>
position="absolute"
zIndex={2000}
left={0}
right={0}
bottom={1}
paddingLeft={2}
paddingRight={2}
>
<box width="100%"> <box width="100%">
<FormPrompt form={form} /> <FormPrompt form={form} />
</box> </box>
@@ -3,7 +3,7 @@ import { createStore } from "solid-js/store"
import { TextAttributes } from "@opentui/core" import { TextAttributes } from "@opentui/core"
import { useTheme } from "../../../context/theme" import { useTheme } from "../../../context/theme"
import { SplitBorder } from "../../../ui/border" import { SplitBorder } from "../../../ui/border"
import { useBindings, useOpencodeModeStack } from "../../../keymap" import { Keymap } from "../../../context/keymap"
import { SubagentsTab } from "./subagents-tab" import { SubagentsTab } from "./subagents-tab"
import { ShellTab } from "./shell-tab" import { ShellTab } from "./shell-tab"
@@ -75,10 +75,10 @@ export function Composer(props: ComposerProps) {
}, },
} }
const modeStack = useOpencodeModeStack() const keymap = Keymap.use()
createEffect(() => { createEffect(() => {
if (!props.open) return if (!props.open) return
const popMode = modeStack.push("composer") const popMode = keymap.mode.push("composer")
onCleanup(popMode) onCleanup(popMode)
}) })
@@ -89,18 +89,18 @@ export function Composer(props: ComposerProps) {
setStore("active", tabs[(idx + dir + tabs.length) % tabs.length].id) setStore("active", tabs[(idx + dir + tabs.length) % tabs.length].id)
} }
useBindings(() => ({ Keymap.createLayer(() => ({
mode: "composer", mode: "composer",
enabled: () => props.open, enabled: () => props.open,
bindings: [ commands: [
{ key: "left", desc: "Previous tab", group: "Composer", cmd: () => switchTab(-1) }, { bind: "left", title: "Previous tab", group: "Composer", run: () => switchTab(-1) },
{ key: "right", desc: "Next tab", group: "Composer", cmd: () => switchTab(1) }, { bind: "right", title: "Next tab", group: "Composer", run: () => switchTab(1) },
{ key: "escape", desc: "Close composer", group: "Composer", cmd: close }, { bind: "escape", title: "Close composer", group: "Composer", run: close },
{ {
key: "<leader>down", bind: "<leader>down",
desc: "Toggle composer", title: "Toggle composer",
group: "Composer", group: "Composer",
cmd: close, run: close,
}, },
], ],
})) }))
@@ -5,7 +5,7 @@ import { useData } from "../../../context/data"
import { useLocation } from "../../../context/location" import { useLocation } from "../../../context/location"
import { useClient } from "../../../context/client" import { useClient } from "../../../context/client"
import { useTheme, selectedForeground } from "../../../context/theme" import { useTheme, selectedForeground } from "../../../context/theme"
import { useBindings, useCommandShortcut } from "../../../keymap" import { Keymap } from "../../../context/keymap"
import { useComposerTab } from "./index" import { useComposerTab } from "./index"
export function ShellTab(props: { sessionID: string }) { export function ShellTab(props: { sessionID: string }) {
@@ -15,7 +15,7 @@ export function ShellTab(props: { sessionID: string }) {
const { theme } = useTheme() const { theme } = useTheme()
const fg = selectedForeground(theme) const fg = selectedForeground(theme)
const composer = useComposerTab() const composer = useComposerTab()
const killHint = useCommandShortcut("composer.shell.kill") const shortcuts = Keymap.useShortcuts()
const entries = createMemo(() => const entries = createMemo(() =>
data.shell data.shell
@@ -47,19 +47,21 @@ export function ShellTab(props: { sessionID: string }) {
const cleanup = composer.register({ const cleanup = composer.register({
id: "shell", id: "shell",
label: "Shell", label: "Shell",
hints: () => (selectedEntry() ? [{ label: "kill", shortcut: killHint() }] : []), hints: () =>
selectedEntry() ? [{ label: "kill", shortcut: shortcuts.get("composer.shell.kill") ?? "" }] : [],
}) })
onCleanup(cleanup) onCleanup(cleanup)
}) })
useBindings(() => ({ Keymap.createLayer(() => ({
mode: "composer", mode: "composer",
enabled: () => composer.active("shell"), enabled: () => composer.active("shell"),
commands: [ commands: [
{ {
name: "composer.shell.up", id: "composer.shell.up",
title: "Previous shell", title: "Previous shell",
category: "Composer", group: "Composer",
bind: "up",
run() { run() {
const list = entries() const list = entries()
if (list.length === 0) return 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", title: "Next shell",
category: "Composer", group: "Composer",
bind: "down",
run() { run() {
const list = entries() const list = entries()
if (list.length === 0) return 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", title: "Kill shell command",
category: "Composer", group: "Composer",
bind: "ctrl+d",
run() { run() {
const entry = selectedEntry() const entry = selectedEntry()
if (!entry) return 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 ( return (
@@ -6,7 +6,7 @@ import { useData } from "../../../context/data"
import { useClient } from "../../../context/client" import { useClient } from "../../../context/client"
import { useTheme, selectedForeground } from "../../../context/theme" import { useTheme, selectedForeground } from "../../../context/theme"
import { Locale } from "../../../util/locale" import { Locale } from "../../../util/locale"
import { useBindings, useCommandShortcut } from "../../../keymap" import { Keymap } from "../../../context/keymap"
import { useComposerTab } from "./index" import { useComposerTab } from "./index"
interface SubagentEntry { interface SubagentEntry {
@@ -25,7 +25,7 @@ export function SubagentsTab(props: { sessionID: string }) {
const fg = selectedForeground(theme) const fg = selectedForeground(theme)
const navigate = useRoute().navigate const navigate = useRoute().navigate
const composer = useComposerTab() const composer = useComposerTab()
const interruptHint = useCommandShortcut("composer.subagent.interrupt") const shortcuts = Keymap.useShortcuts()
const session = createMemo(() => data.session.get(props.sessionID)) const session = createMemo(() => data.session.get(props.sessionID))
@@ -133,7 +133,7 @@ export function SubagentsTab(props: { sessionID: string }) {
hints: () => { hints: () => {
const entry = selectedEntry() const entry = selectedEntry()
if (!entry || entry.status !== "running") return [] if (!entry || entry.status !== "running") return []
return [{ label: "interrupt", shortcut: interruptHint() }] return [{ label: "interrupt", shortcut: shortcuts.get("composer.subagent.interrupt") ?? "" }]
}, },
onClose: () => { onClose: () => {
const parentID = session()?.parentID const parentID = session()?.parentID
@@ -143,14 +143,15 @@ export function SubagentsTab(props: { sessionID: string }) {
onCleanup(cleanup) onCleanup(cleanup)
}) })
useBindings(() => ({ Keymap.createLayer(() => ({
mode: "composer", mode: "composer",
enabled: () => composer.active("subagents"), enabled: () => composer.active("subagents"),
commands: [ commands: [
{ {
name: "composer.subagent.up", id: "composer.subagent.up",
title: "Previous subagent", title: "Previous subagent",
category: "Composer", group: "Composer",
bind: "up",
run() { run() {
const list = entries() const list = entries()
if (list.length === 0) return 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", title: "Next subagent",
category: "Composer", group: "Composer",
bind: "down",
run() { run() {
const list = entries() const list = entries()
if (list.length === 0) return 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", title: "Navigate to subagent",
category: "Composer", group: "Composer",
bind: "return",
run() { run() {
const entry = entries()[store.selected] const entry = entries()[store.selected]
if (entry) navigate({ type: "session", sessionID: entry.sessionID }) if (entry) navigate({ type: "session", sessionID: entry.sessionID })
}, },
}, },
{ {
name: "composer.subagent.interrupt", id: "composer.subagent.interrupt",
title: "Interrupt subagent", title: "Interrupt subagent",
category: "Composer", group: "Composer",
bind: "ctrl+d",
run() { run() {
const entry = selectedEntry() const entry = selectedEntry()
if (!entry || entry.status !== "running") return 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 ( return (
+69 -79
View File
@@ -11,8 +11,7 @@ import { useClient } from "../../context/client"
import { useClipboard } from "../../context/clipboard" import { useClipboard } from "../../context/clipboard"
import { SplitBorder } from "../../ui/border" import { SplitBorder } from "../../ui/border"
import { useToast } from "../../ui/toast" import { useToast } from "../../ui/toast"
import { useConfig } from "../../config" import { Keymap } from "../../context/keymap"
import { useBindings, useOpencodeModeStack } from "../../keymap"
const FORM_MODE = "form" const FORM_MODE = "form"
@@ -150,8 +149,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
const { theme } = useTheme() const { theme } = useTheme()
const renderer = useRenderer() const renderer = useRenderer()
const dimensions = useTerminalDimensions() const dimensions = useTerminalDimensions()
const config = useConfig().data const keymap = Keymap.use()
const modeStack = useOpencodeModeStack()
const clipboard = useClipboard() const clipboard = useClipboard()
const toast = useToast() const toast = useToast()
const configuredFields = props.form.fields.filter(isField) 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, mode: FORM_MODE,
enabled: (store.editing || textual()) && !confirm(), enabled: (store.editing || textual()) && !confirm(),
commands: [ commands: [
{ {
name: "prompt.clear", id: "prompt.clear",
title: "Clear answer edit", title: "Clear answer edit",
category: "Form", group: "Form",
run() { run() {
const text = textarea?.plainText ?? "" const text = textarea?.plainText ?? ""
if (!text) { if (!text) {
@@ -574,13 +572,11 @@ export function FormPrompt(props: { form: FormWithLocation }) {
textarea?.setText("") textarea?.setText("")
}, },
}, },
],
bindings: [
{ {
key: "escape", bind: "escape",
desc: "Cancel answer edit", title: "Cancel answer edit",
group: "Form", group: "Form",
cmd: () => { run: () => {
if (textual()) { if (textual()) {
void client.api.form.cancel( void client.api.form.cancel(
{ sessionID: props.form.sessionID, formID: props.form.id }, { sessionID: props.form.sessionID, formID: props.form.id },
@@ -591,30 +587,29 @@ export function FormPrompt(props: { form: FormWithLocation }) {
setStore("editing", false) setStore("editing", false)
}, },
}, },
...config.keybinds.get("prompt.clear"),
{ {
key: "tab", bind: "tab",
desc: "Next field", title: "Next field",
group: "Form", group: "Form",
cmd: () => { run: () => {
const text = textarea?.plainText?.trim() ?? "" const text = textarea?.plainText?.trim() ?? ""
submitInput(text) submitInput(text)
}, },
}, },
{ {
key: "shift+tab", bind: "shift+tab",
desc: "Previous field", title: "Previous field",
group: "Form", group: "Form",
cmd: () => { run: () => {
const text = textarea?.plainText?.trim() ?? "" const text = textarea?.plainText?.trim() ?? ""
submitInput(text, -1) submitInput(text, -1)
}, },
}, },
{ {
key: "return", bind: "return",
desc: "Submit answer edit", title: "Submit answer edit",
group: "Form", group: "Form",
cmd: () => { run: () => {
const text = textarea?.plainText?.trim() ?? "" const text = textarea?.plainText?.trim() ?? ""
const current = answerField() const current = answerField()
if (!current) return if (!current) return
@@ -634,7 +629,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
], ],
})) }))
useBindings(() => { Keymap.createLayer(() => {
const total = rows().length + (custom() ? 1 : 0) const total = rows().length + (custom() ? 1 : 0)
const max = Math.min(total, 9) const max = Math.min(total, 9)
const external = externalField() const external = externalField()
@@ -644,118 +639,113 @@ export function FormPrompt(props: { form: FormWithLocation }) {
enabled: !store.editing && !textual(), enabled: !store.editing && !textual(),
commands: [ commands: [
{ {
name: "app.exit", id: "app.exit",
title: "Dismiss form", title: "Dismiss form",
category: "Form", group: "Form",
run: cancel, run: cancel,
}, },
],
bindings: [
{ {
key: "left", bind: "left",
desc: "Previous field", title: "Previous field",
group: "Form", group: "Form",
cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()), run: () => selectTab((store.tab - 1 + tabs()) % tabs()),
}, },
{ {
key: "h", bind: "h",
desc: "Previous field", title: "Previous field",
group: "Form", 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()) }, { bind: "right", title: "Next field", group: "Form", run: () => selectTab((store.tab + 1) % tabs()) },
{ key: "l", desc: "Next field", group: "Form", cmd: () => selectTab((store.tab + 1) % tabs()) }, { bind: "l", title: "Next field", group: "Form", run: () => selectTab((store.tab + 1) % tabs()) },
{ {
key: "tab", bind: "tab",
desc: "Next field", title: "Next field",
group: "Form", group: "Form",
cmd: () => selectTab((store.tab + 1) % tabs()), run: () => selectTab((store.tab + 1) % tabs()),
}, },
{ {
key: "shift+tab", bind: "shift+tab",
desc: "Previous field", title: "Previous field",
group: "Form", group: "Form",
cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()), run: () => selectTab((store.tab - 1 + tabs()) % tabs()),
}, },
...(external ...(external
? [ ? [
{ {
key: "return", bind: "return",
desc: title:
store.answers[external.key] === true store.answers[external.key] === true
? "Continue" ? "Continue"
: store.externalReady[external.key] : store.externalReady[external.key]
? "Confirm completion" ? "Confirm completion"
: "Open link", : "Open link",
group: "Form", group: "Form",
cmd: acknowledgeExternal, run: acknowledgeExternal,
}, },
{ key: "c", desc: "Copy link", group: "Form", cmd: copyExternal }, { bind: "c", title: "Copy link", group: "Form", run: copyExternal },
{ key: "escape", desc: "Dismiss form", group: "Form", cmd: cancel }, { bind: "escape", title: "Dismiss form", group: "Form", run: cancel },
...config.keybinds.get("app.exit"),
] ]
: confirm() : confirm()
? [ ? [
{ {
key: "return", bind: "return",
desc: "Submit form", title: "Submit form",
group: "Form", group: "Form",
cmd: submit, run: submit,
}, },
{ {
key: "escape", bind: "escape",
desc: "Dismiss form", title: "Dismiss form",
group: "Form", group: "Form",
cmd: cancel, run: cancel,
}, },
{ key: "up", desc: "Scroll review", group: "Form", cmd: () => review?.scrollBy(-1) }, { bind: "up", title: "Scroll review", group: "Form", run: () => review?.scrollBy(-1) },
{ key: "k", desc: "Scroll review", group: "Form", cmd: () => review?.scrollBy(-1) }, { bind: "k", title: "Scroll review", group: "Form", run: () => review?.scrollBy(-1) },
{ key: "down", desc: "Scroll review", group: "Form", cmd: () => review?.scrollBy(1) }, { bind: "down", title: "Scroll review", group: "Form", run: () => review?.scrollBy(1) },
{ key: "j", desc: "Scroll review", group: "Form", cmd: () => review?.scrollBy(1) }, { bind: "j", title: "Scroll review", group: "Form", run: () => review?.scrollBy(1) },
...config.keybinds.get("app.exit"),
] ]
: [ : [
...Array.from({ length: max }, (_, index) => ({ ...Array.from({ length: max }, (_, index) => ({
key: String(index + 1), bind: String(index + 1),
desc: `Select answer ${index + 1}`, title: `Select answer ${index + 1}`,
group: "Form", group: "Form",
cmd: () => { run: () => {
setStore("selected", index) setStore("selected", index)
selectOption() selectOption()
}, },
})), })),
{ {
key: "up", bind: "up",
desc: "Previous answer", title: "Previous answer",
group: "Form", group: "Form",
cmd: () => setStore("selected", (store.selected - 1 + total) % total), run: () => setStore("selected", (store.selected - 1 + total) % total),
}, },
{ {
key: "k", bind: "k",
desc: "Previous answer", title: "Previous answer",
group: "Form", group: "Form",
cmd: () => setStore("selected", (store.selected - 1 + total) % total), run: () => setStore("selected", (store.selected - 1 + total) % total),
}, },
{ {
key: "down", bind: "down",
desc: "Next answer", title: "Next answer",
group: "Form", group: "Form",
cmd: () => setStore("selected", (store.selected + 1) % total), run: () => setStore("selected", (store.selected + 1) % total),
}, },
{ {
key: "j", bind: "j",
desc: "Next answer", title: "Next answer",
group: "Form", 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", bind: "escape",
desc: "Dismiss form", title: "Dismiss form",
group: "Form", group: "Form",
cmd: cancel, run: cancel,
}, },
...config.keybinds.get("app.exit"),
]), ]),
], ],
} }
+115 -156
View File
@@ -77,45 +77,6 @@ import { switchLabel } from "../../util/model"
addDefaultParsers(parsers.parsers) 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<{ const context = createContext<{
width: number width: number
sessionID: string sessionID: string
@@ -339,10 +300,96 @@ export function Session() {
}, 50) }, 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", title: "Share session",
value: "session.share", name: "session.share",
suggested: route.type === "session", suggested: route.type === "session",
category: "Session", category: "Session",
slash: { name: "share" }, slash: { name: "share" },
@@ -350,21 +397,21 @@ export function Session() {
}, },
{ {
title: "Rename session", title: "Rename session",
value: "session.rename", name: "session.rename",
category: "Session", category: "Session",
slash: { name: "rename" }, slash: { name: "rename" },
run: () => DialogSessionRename.show(dialog, route.sessionID, session()?.title), run: () => DialogSessionRename.show(dialog, route.sessionID, session()?.title),
}, },
{ {
title: "Jump to message", title: "Jump to message",
value: "session.timeline", name: "session.timeline",
category: "Session", category: "Session",
slash: { name: "timeline" }, slash: { name: "timeline" },
run: () => unavailable("The message timeline"), run: () => unavailable("The message timeline"),
}, },
{ {
title: "Fork session", title: "Fork session",
value: "session.fork", name: "session.fork",
category: "Session", category: "Session",
slash: { name: "fork" }, slash: { name: "fork" },
run: () => { run: () => {
@@ -382,7 +429,7 @@ export function Session() {
}, },
{ {
title: "Compact session", title: "Compact session",
value: "session.compact", name: "session.compact",
category: "Session", category: "Session",
slash: { slash: {
name: "compact", name: "compact",
@@ -395,7 +442,7 @@ export function Session() {
}, },
{ {
title: "Unshare session", title: "Unshare session",
value: "session.unshare", name: "session.unshare",
category: "Session", category: "Session",
enabled: false, enabled: false,
slash: { name: "unshare" }, slash: { name: "unshare" },
@@ -403,7 +450,7 @@ export function Session() {
}, },
{ {
title: "Undo previous message", title: "Undo previous message",
value: "session.undo", name: "session.undo",
category: "Session", category: "Session",
slash: { name: "undo" }, slash: { name: "undo" },
run: () => { run: () => {
@@ -439,7 +486,7 @@ export function Session() {
}, },
{ {
title: "Redo", title: "Redo",
value: "session.redo", name: "session.redo",
category: "Session", category: "Session",
enabled: !!session()?.revert?.messageID, enabled: !!session()?.revert?.messageID,
slash: { name: "redo" }, slash: { name: "redo" },
@@ -456,7 +503,7 @@ export function Session() {
}, },
{ {
title: sidebarVisible() ? "Hide sidebar" : "Show sidebar", title: sidebarVisible() ? "Hide sidebar" : "Show sidebar",
value: "session.sidebar.toggle", name: "session.sidebar.toggle",
category: "Session", category: "Session",
run: () => { run: () => {
batch(() => { batch(() => {
@@ -477,7 +524,7 @@ export function Session() {
if (next === "hide") return "Collapse thinking" if (next === "hide") return "Collapse thinking"
return "Expand thinking" return "Expand thinking"
})(), })(),
value: "session.toggle.thinking", name: "session.toggle.thinking",
category: "Session", category: "Session",
hidden: true, hidden: true,
slash: { slash: {
@@ -495,7 +542,7 @@ export function Session() {
}, },
{ {
title: "Toggle session scrollbar", title: "Toggle session scrollbar",
value: "session.toggle.scrollbar", name: "session.toggle.scrollbar",
category: "Session", category: "Session",
hidden: true, hidden: true,
run: () => { run: () => {
@@ -509,7 +556,7 @@ export function Session() {
}, },
{ {
title: groupExploration() ? "Show tool calls individually" : "Group related tool calls", title: groupExploration() ? "Show tool calls individually" : "Group related tool calls",
value: "session.toggle.exploration_grouping", name: "session.toggle.exploration_grouping",
category: "Session", category: "Session",
hidden: true, hidden: true,
run: () => { run: () => {
@@ -521,89 +568,9 @@ export function Session() {
dialog.clear() 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", title: "Jump to last user message",
value: "session.messages_last_user", name: "session.messages_last_user",
category: "Session", category: "Session",
hidden: true, hidden: true,
run: () => { run: () => {
@@ -626,21 +593,21 @@ export function Session() {
}, },
{ {
title: "Next message", title: "Next message",
value: "session.message.next", name: "session.message.next",
category: "Session", category: "Session",
hidden: true, hidden: true,
run: () => scrollToMessage("next", dialog), run: () => scrollToMessage("next", dialog),
}, },
{ {
title: "Previous message", title: "Previous message",
value: "session.message.previous", name: "session.message.previous",
category: "Session", category: "Session",
hidden: true, hidden: true,
run: () => scrollToMessage("prev", dialog), run: () => scrollToMessage("prev", dialog),
}, },
{ {
title: "Copy last assistant message", title: "Copy last assistant message",
value: "messages.copy", name: "messages.copy",
category: "Session", category: "Session",
run: () => { run: () => {
const revertID = session()?.revert?.messageID const revertID = session()?.revert?.messageID
@@ -682,7 +649,7 @@ export function Session() {
}, },
{ {
title: "Copy session transcript", title: "Copy session transcript",
value: "session.copy", name: "session.copy",
category: "Session", category: "Session",
slash: { slash: {
name: "copy", name: "copy",
@@ -702,7 +669,7 @@ export function Session() {
}, },
{ {
title: "Export session transcript", title: "Export session transcript",
value: "session.export", name: "session.export",
category: "Session", category: "Session",
slash: { slash: {
name: "export", name: "export",
@@ -772,7 +739,7 @@ export function Session() {
}, },
{ {
title: "Background blocking tools", title: "Background blocking tools",
value: "session.background", name: "session.background",
category: "Session", category: "Session",
hidden: true, hidden: true,
run: () => { run: () => {
@@ -782,7 +749,7 @@ export function Session() {
}, },
{ {
title: "Toggle subagent picker", title: "Toggle subagent picker",
value: "session.child.first", name: "session.child.first",
category: "Session", category: "Session",
run: () => { run: () => {
if (composer.open || session()?.parentID) setComposer("open", false) if (composer.open || session()?.parentID) setComposer("open", false)
@@ -792,7 +759,7 @@ export function Session() {
}, },
{ {
title: "Go to parent session", title: "Go to parent session",
value: "session.parent", name: "session.parent",
category: "Session", category: "Session",
hidden: true, hidden: true,
enabled: !!session()?.parentID, enabled: !!session()?.parentID,
@@ -809,7 +776,7 @@ export function Session() {
}, },
{ {
title: "Next child session", title: "Next child session",
value: "session.child.next", name: "session.child.next",
category: "Session", category: "Session",
hidden: true, hidden: true,
enabled: !!session()?.parentID, enabled: !!session()?.parentID,
@@ -817,7 +784,7 @@ export function Session() {
}, },
{ {
title: "Previous child session", title: "Previous child session",
value: "session.child.previous", name: "session.child.previous",
category: "Session", category: "Session",
hidden: true, hidden: true,
enabled: !!session()?.parentID, enabled: !!session()?.parentID,
@@ -825,33 +792,25 @@ export function Session() {
}, },
]) ])
const sessionCommands = createMemo(() => useBindings(() => ({
sessionCommandList().map((command) => ({ commands: [...globalCommands, ...baseAndUnfocusedCommands, ...baseCommands()].map((command) => ({
namespace: "palette", 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, ...command,
})), })),
)
useBindings(() => ({
commands: sessionCommands(),
})) }))
useBindings(() => ({ useBindings(() => ({
bindings: config.keybinds.gather("session.global", sessionGlobalBindingCommands), bindings: globalCommands.flatMap((command) => config.keybinds.get(command.name)),
})) }))
useBindings(() => ({ useBindings(() => ({
enabled: () => renderer.currentFocusedEditor === null, enabled: () => renderer.currentFocusedEditor === null,
bindings: config.keybinds.gather("session.global.unfocused", sessionGlobalUnfocusedBindingCommands), bindings: baseAndUnfocusedCommands.flatMap((command) => config.keybinds.get(command.name)),
})) }))
useBindings(() => ({ useBindings(() => ({
mode: OPENCODE_BASE_MODE, 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 // snap to bottom when session changes
+41 -44
View File
@@ -13,7 +13,7 @@ import { Locale } from "../../util/locale"
import { webSearchProviderLabel } from "../../util/tool-display" import { webSearchProviderLabel } from "../../util/tool-display"
import { getScrollAcceleration } from "../../util/scroll" import { getScrollAcceleration } from "../../util/scroll"
import { useConfig } from "../../config" import { useConfig } from "../../config"
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut } from "../../keymap" import { Keymap } from "../../context/keymap"
import { usePathFormatter } from "../../context/path-format" import { usePathFormatter } from "../../context/path-format"
type PermissionStage = "permission" | "always" | "reject" 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 }) { function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: () => void }) {
let input: TextareaRenderable let input: TextareaRenderable
const { theme } = useTheme() const { theme } = useTheme()
const config = useConfig().data
const dimensions = useTerminalDimensions() const dimensions = useTerminalDimensions()
const narrow = createMemo(() => dimensions().width < 80) const narrow = createMemo(() => dimensions().width < 80)
useBindings(() => ({ Keymap.createLayer(() => ({
mode: OPENCODE_BASE_MODE, mode: "base",
commands: [ commands: [
{ {
name: "app.exit", id: "app.exit",
title: "Cancel permission rejection", title: "Cancel permission rejection",
category: "Permission", group: "Permission",
run() { run() {
props.onCancel() props.onCancel()
}, },
}, },
], { bind: "escape", title: "Cancel permission rejection", group: "Permission", run: () => props.onCancel() },
bindings: [
{ key: "escape", desc: "Cancel permission rejection", group: "Permission", cmd: () => props.onCancel() },
...config.keybinds.get("app.exit"),
{ {
key: "return", bind: "return",
desc: "Confirm permission rejection", title: "Confirm permission rejection",
group: "Permission", 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 onSelect: (option: keyof T) => void
}) { }) {
const { theme } = useTheme() const { theme } = useTheme()
const config = useConfig().data
const dimensions = useTerminalDimensions() const dimensions = useTerminalDimensions()
const keys = Object.keys(props.options) as (keyof T)[] const keys = Object.keys(props.options) as (keyof T)[]
const [store, setStore] = createStore({ const [store, setStore] = createStore({
@@ -566,89 +561,91 @@ function Prompt<const T extends Record<string, string>>(props: {
expanded: false, expanded: false,
}) })
const narrow = createMemo(() => dimensions().width < 80) const narrow = createMemo(() => dimensions().width < 80)
const fullscreenHint = useCommandShortcut("permission.prompt.fullscreen") const shortcuts = Keymap.useShortcuts()
useBindings(() => ({ Keymap.createLayer(() => ({
mode: OPENCODE_BASE_MODE, mode: "base",
commands: [ commands: [
{ {
name: "app.exit", id: "app.exit",
title: "Reject permission", title: "Reject permission",
category: "Permission", group: "Permission",
bind: false,
run() { run() {
if (!props.escapeKey) return if (!props.escapeKey) return
props.onSelect(props.escapeKey) props.onSelect(props.escapeKey)
}, },
}, },
{ {
name: "permission.prompt.fullscreen", id: "permission.prompt.fullscreen",
title: "Toggle permission fullscreen", title: "Toggle permission fullscreen",
category: "Permission", group: "Permission",
bind: false,
run() { run() {
if (!props.fullscreen) return if (!props.fullscreen) return
setStore("expanded", (v) => !v) setStore("expanded", (v) => !v)
}, },
}, },
],
bindings: [
{ {
key: "left", bind: "left",
desc: "Previous permission option", title: "Previous permission option",
group: "Permission", group: "Permission",
cmd: () => { run: () => {
const idx = keys.indexOf(store.selected) const idx = keys.indexOf(store.selected)
const next = keys[(idx - 1 + keys.length) % keys.length] const next = keys[(idx - 1 + keys.length) % keys.length]
setStore("selected", next) setStore("selected", next)
}, },
}, },
{ {
key: "h", bind: "h",
desc: "Previous permission option", title: "Previous permission option",
group: "Permission", group: "Permission",
cmd: () => { run: () => {
const idx = keys.indexOf(store.selected) const idx = keys.indexOf(store.selected)
const next = keys[(idx - 1 + keys.length) % keys.length] const next = keys[(idx - 1 + keys.length) % keys.length]
setStore("selected", next) setStore("selected", next)
}, },
}, },
{ {
key: "right", bind: "right",
desc: "Next permission option", title: "Next permission option",
group: "Permission", group: "Permission",
cmd: () => { run: () => {
const idx = keys.indexOf(store.selected) const idx = keys.indexOf(store.selected)
const next = keys[(idx + 1) % keys.length] const next = keys[(idx + 1) % keys.length]
setStore("selected", next) setStore("selected", next)
}, },
}, },
{ {
key: "l", bind: "l",
desc: "Next permission option", title: "Next permission option",
group: "Permission", group: "Permission",
cmd: () => { run: () => {
const idx = keys.indexOf(store.selected) const idx = keys.indexOf(store.selected)
const next = keys[(idx + 1) % keys.length] const next = keys[(idx + 1) % keys.length]
setStore("selected", next) setStore("selected", next)
}, },
}, },
{ {
key: "return", bind: "return",
desc: "Select permission option", title: "Select permission option",
group: "Permission", group: "Permission",
cmd: () => props.onSelect(store.selected), run: () => props.onSelect(store.selected),
}, },
...(props.escapeKey ...(props.escapeKey
? [ ? [
{ {
key: "escape", bind: "escape",
desc: "Reject permission", title: "Reject permission",
group: "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}> <box flexDirection="row" gap={2} flexShrink={0}>
<Show when={props.fullscreen}> <Show when={props.fullscreen}>
<text fg={theme.text}> <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> </text>
</Show> </Show>
<text fg={theme.text}> <text fg={theme.text}>
+4 -19
View File
@@ -2,8 +2,8 @@ import { useData } from "../../context/data"
import { createMemo, Show } from "solid-js" import { createMemo, Show } from "solid-js"
import { useTheme } from "../../context/theme" import { useTheme } from "../../context/theme"
import { useConfig } from "../../config" import { useConfig } from "../../config"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { usePluginRuntime } from "../../plugin/runtime" import { usePluginRuntime } from "../../plugin/runtime"
import { PluginSlot } from "../../plugin/context"
import { getScrollAcceleration } from "../../util/scroll" import { getScrollAcceleration } from "../../util/scroll"
@@ -49,31 +49,16 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
<b>{session()!.title}</b> <b>{session()!.title}</b>
</text> </text>
<Show when={session()!.location.workspaceID}> <Show when={session()!.location.workspaceID}>
<text fg={theme.textMuted}> <text fg={theme.textMuted}>{session()!.location.workspaceID}</text>
{session()!.location.workspaceID}
</text>
</Show> </Show>
</box> </box>
</pluginRuntime.Slot> </pluginRuntime.Slot>
<pluginRuntime.Slot name="sidebar_content" session_id={props.sessionID} /> <PluginSlot name="sidebar.content" input={{ sessionID: props.sessionID }} />
</box> </box>
</scrollbox> </scrollbox>
<box flexShrink={0} gap={1} paddingTop={1}> <box flexShrink={0} gap={1} paddingTop={1}>
<pluginRuntime.Slot <PluginSlot name="sidebar.footer" />
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>
</box> </box>
</box> </box>
</Show> </Show>
@@ -5,7 +5,7 @@ import { useTheme } from "../../context/theme"
import { SplitBorder } from "../../ui/border" import { SplitBorder } from "../../ui/border"
import { Locale } from "../../util/locale" import { Locale } from "../../util/locale"
import { useTerminalDimensions } from "@opentui/solid" import { useTerminalDimensions } from "@opentui/solid"
import { useCommandShortcut, useOpencodeKeymap } from "../../keymap" import { Keymap } from "../../context/keymap"
import { contextUsage } from "../../util/session" import { contextUsage } from "../../util/session"
const money = new Intl.NumberFormat("en-US", { const money = new Intl.NumberFormat("en-US", {
@@ -47,10 +47,8 @@ export function SubagentFooter() {
}) })
const { theme } = useTheme() const { theme } = useTheme()
const keymap = useOpencodeKeymap() const keymap = Keymap.use()
const parentShortcut = useCommandShortcut("session.parent") const shortcuts = Keymap.useShortcuts()
const previousShortcut = useCommandShortcut("session.child.previous")
const nextShortcut = useCommandShortcut("session.child.next")
const [hover, setHover] = createSignal<"parent" | "prev" | "next" | null>(null) const [hover, setHover] = createSignal<"parent" | "prev" | "next" | null>(null)
useTerminalDimensions() useTerminalDimensions()
@@ -84,31 +82,31 @@ export function SubagentFooter() {
<box <box
onMouseOver={() => setHover("parent")} onMouseOver={() => setHover("parent")}
onMouseOut={() => setHover(null)} onMouseOut={() => setHover(null)}
onMouseUp={() => keymap.dispatchCommand("session.parent")} onMouseUp={() => keymap.dispatch("session.parent")}
backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel} backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel}
> >
<text fg={theme.text}> <text fg={theme.text}>
Parent <span style={{ fg: theme.textMuted }}>{parentShortcut()}</span> Parent <span style={{ fg: theme.textMuted }}>{shortcuts.get("session.parent")}</span>
</text> </text>
</box> </box>
<box <box
onMouseOver={() => setHover("prev")} onMouseOver={() => setHover("prev")}
onMouseOut={() => setHover(null)} onMouseOut={() => setHover(null)}
onMouseUp={() => keymap.dispatchCommand("session.child.previous")} onMouseUp={() => keymap.dispatch("session.child.previous")}
backgroundColor={hover() === "prev" ? theme.backgroundElement : theme.backgroundPanel} backgroundColor={hover() === "prev" ? theme.backgroundElement : theme.backgroundPanel}
> >
<text fg={theme.text}> <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> </text>
</box> </box>
<box <box
onMouseOver={() => setHover("next")} onMouseOver={() => setHover("next")}
onMouseOut={() => setHover(null)} onMouseOut={() => setHover(null)}
onMouseUp={() => keymap.dispatchCommand("session.child.next")} onMouseUp={() => keymap.dispatch("session.child.next")}
backgroundColor={hover() === "next" ? theme.backgroundElement : theme.backgroundPanel} backgroundColor={hover() === "next" ? theme.backgroundElement : theme.backgroundPanel}
> >
<text fg={theme.text}> <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> </text>
</box> </box>
</box> </box>
+7 -6
View File
@@ -1,7 +1,7 @@
import { TextAttributes } from "@opentui/core" import { TextAttributes } from "@opentui/core"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme" import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog" import { useDialog, type DialogContext } from "./dialog"
import { useBindings } from "../keymap"
export type DialogAlertProps = { export type DialogAlertProps = {
title: string title: string
@@ -13,13 +13,14 @@ export function DialogAlert(props: DialogAlertProps) {
const dialog = useDialog() const dialog = useDialog()
const { theme } = useTheme() const { theme } = useTheme()
useBindings(() => ({ Keymap.createLayer(() => ({
bindings: [ mode: "modal",
commands: [
{ {
key: "return", bind: "return",
desc: "Confirm alert", title: "Confirm alert",
group: "Dialog", group: "Dialog",
cmd: () => { run: () => {
props.onConfirm?.() props.onConfirm?.()
dialog.clear() dialog.clear()
}, },
+13 -12
View File
@@ -1,10 +1,10 @@
import { TextAttributes } from "@opentui/core" import { TextAttributes } from "@opentui/core"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme" import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog" import { useDialog, type DialogContext } from "./dialog"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { For } from "solid-js" import { For } from "solid-js"
import { Locale } from "../util/locale" import { Locale } from "../util/locale"
import { useBindings } from "../keymap"
export type DialogConfirmProps = { export type DialogConfirmProps = {
title: string title: string
@@ -23,31 +23,32 @@ export function DialogConfirm(props: DialogConfirmProps) {
active: "confirm" as "confirm" | "cancel", active: "confirm" as "confirm" | "cancel",
}) })
useBindings(() => ({ Keymap.createLayer(() => ({
bindings: [ mode: "modal",
commands: [
{ {
key: "return", bind: "return",
desc: "Confirm dialog selection", title: "Confirm dialog selection",
group: "Dialog", group: "Dialog",
cmd: () => { run: () => {
if (store.active === "confirm") props.onConfirm?.() if (store.active === "confirm") props.onConfirm?.()
if (store.active === "cancel") props.onCancel?.() if (store.active === "cancel") props.onCancel?.()
dialog.clear() dialog.clear()
}, },
}, },
{ {
key: "left", bind: "left",
desc: "Previous dialog option", title: "Previous dialog option",
group: "Dialog", group: "Dialog",
cmd: () => { run: () => {
setStore("active", store.active === "confirm" ? "cancel" : "confirm") setStore("active", store.active === "confirm" ? "cancel" : "confirm")
}, },
}, },
{ {
key: "right", bind: "right",
desc: "Next dialog option", title: "Next dialog option",
group: "Dialog", group: "Dialog",
cmd: () => { run: () => {
setStore("active", store.active === "confirm" ? "cancel" : "confirm") setStore("active", store.active === "confirm" ? "cancel" : "confirm")
}, },
}, },
+10 -9
View File
@@ -1,9 +1,9 @@
import { TextAttributes } from "@opentui/core" import { TextAttributes } from "@opentui/core"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme" import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog" import { useDialog, type DialogContext } from "./dialog"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { For, Show } from "solid-js" import { For, Show } from "solid-js"
import { useBindings } from "../keymap"
export type ExportFormat = "markdown" | "json" export type ExportFormat = "markdown" | "json"
@@ -43,13 +43,14 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
if (store.active === "copy" || store.active === "export") confirm(store.active) if (store.active === "copy" || store.active === "export") confirm(store.active)
} }
useBindings(() => ({ Keymap.createLayer(() => ({
bindings: [ mode: "modal",
commands: [
{ {
key: "tab", bind: "tab",
desc: "Next export option", title: "Next export option",
group: "Dialog", group: "Dialog",
cmd: () => { run: () => {
const order: Active[] = const order: Active[] =
store.format === "markdown" store.format === "markdown"
? ["markdown", "json", "thinking", "copy", "export"] ? ["markdown", "json", "thinking", "copy", "export"]
@@ -58,10 +59,10 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
}, },
}, },
{ {
key: "return", bind: "return",
desc: "Select export option", title: "Select export option",
group: "Dialog", group: "Dialog",
cmd: activate, run: activate,
}, },
], ],
})) }))
+8 -12
View File
@@ -1,6 +1,6 @@
import { TextAttributes } from "@opentui/core" import { TextAttributes } from "@opentui/core"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme" import { useTheme } from "../context/theme"
import { useBindings } from "../keymap"
import { useDialog, type DialogContext } from "./dialog" import { useDialog, type DialogContext } from "./dialog"
export function DialogExportResult(props: { path: string; onClose?: () => void }) { export function DialogExportResult(props: { path: string; onClose?: () => void }) {
@@ -12,13 +12,14 @@ export function DialogExportResult(props: { path: string; onClose?: () => void }
dialog.clear() dialog.clear()
} }
useBindings(() => ({ Keymap.createLayer(() => ({
bindings: [ mode: "modal",
commands: [
{ {
key: "return", bind: "return",
desc: "Close export result", title: "Close export result",
group: "Dialog", 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> <text fg={theme.text}>{props.path}</text>
</box> </box>
<box flexDirection="row" justifyContent="flex-end" gap={1} paddingBottom={1}> <box flexDirection="row" justifyContent="flex-end" gap={1} paddingBottom={1}>
<box <box paddingLeft={3} paddingRight={3} backgroundColor={theme.primary} onMouseUp={close}>
paddingLeft={3}
paddingRight={3}
backgroundColor={theme.primary}
onMouseUp={close}
>
<text fg={theme.selectedListItemText}>Close</text> <text fg={theme.selectedListItemText}>Close</text>
</box> </box>
</box> </box>
+8 -7
View File
@@ -1,17 +1,18 @@
import { TextAttributes } from "@opentui/core" import { TextAttributes } from "@opentui/core"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme" import { useTheme } from "../context/theme"
import { useDialog } from "./dialog" import { useDialog } from "./dialog"
import { useBindings, useCommandShortcut } from "../keymap"
export function DialogHelp() { export function DialogHelp() {
const dialog = useDialog() const dialog = useDialog()
const { theme } = useTheme() const { theme } = useTheme()
const commandShortcut = useCommandShortcut("command.palette.show") const shortcuts = Keymap.useShortcuts()
useBindings(() => ({ Keymap.createLayer(() => ({
bindings: [ mode: "modal",
{ key: "return", desc: "Close help", group: "Dialog", cmd: () => dialog.clear() }, commands: [
{ key: "escape", desc: "Close help", group: "Dialog", cmd: () => dialog.clear() }, { 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>
<box paddingBottom={1}> <box paddingBottom={1}>
<text fg={theme.textMuted}> <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> </text>
</box> </box>
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}> <box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
+8 -10
View File
@@ -1,10 +1,9 @@
import { TextareaRenderable, TextAttributes } from "@opentui/core" import { TextareaRenderable, TextAttributes } from "@opentui/core"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme" import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog" import { useDialog, type DialogContext } from "./dialog"
import { Show, createEffect, createSignal, onMount, type JSX } from "solid-js" import { Show, createEffect, createSignal, onMount, type JSX } from "solid-js"
import { Spinner } from "../component/spinner" import { Spinner } from "../component/spinner"
import { useConfig } from "../config"
import { useBindings, useCommandShortcut } from "../keymap"
export type DialogPromptProps = { export type DialogPromptProps = {
title: string title: string
@@ -20,8 +19,7 @@ export type DialogPromptProps = {
export function DialogPrompt(props: DialogPromptProps) { export function DialogPrompt(props: DialogPromptProps) {
const dialog = useDialog() const dialog = useDialog()
const { theme } = useTheme() const { theme } = useTheme()
const config = useConfig().data const shortcuts = Keymap.useShortcuts()
const submitShortcut = useCommandShortcut("dialog.prompt.submit")
const [textareaTarget, setTextareaTarget] = createSignal<TextareaRenderable>() const [textareaTarget, setTextareaTarget] = createSignal<TextareaRenderable>()
let textarea: TextareaRenderable let textarea: TextareaRenderable
@@ -30,20 +28,20 @@ export function DialogPrompt(props: DialogPromptProps) {
props.onConfirm?.(textarea.plainText) props.onConfirm?.(textarea.plainText)
} }
useBindings(() => ({ Keymap.createLayer(() => ({
mode: "modal",
target: textareaTarget, target: textareaTarget,
enabled: textareaTarget() !== undefined && !props.busy, enabled: textareaTarget() !== undefined && !props.busy,
// Dialog form semantics must win over the global managed textarea input layer. // Dialog form semantics must win over the global managed textarea input layer.
priority: 1, priority: 1,
commands: [ commands: [
{ {
name: "dialog.prompt.submit", id: "dialog.prompt.submit",
title: "Submit dialog prompt", title: "Submit dialog prompt",
category: "Dialog", group: "Dialog",
run: confirm, run: confirm,
}, },
], ],
bindings: config.keybinds.gather("dialog.prompt", ["dialog.prompt.submit"]),
})) }))
onMount(() => { onMount(() => {
@@ -103,9 +101,9 @@ export function DialogPrompt(props: DialogPromptProps) {
</box> </box>
<box paddingBottom={1} gap={1} flexDirection="row"> <box paddingBottom={1} gap={1} flexDirection="row">
<Show when={!props.busy} fallback={<text fg={theme.textMuted}>processing...</text>}> <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}> <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> </text>
</Show> </Show>
</Show> </Show>
+29 -50
View File
@@ -1,12 +1,5 @@
import { import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
InputRenderable, import { Keymap, type KeymapCommand } from "../context/keymap"
RGBA,
ScrollBoxRenderable,
TextAttributes,
type KeyEvent,
type Renderable,
} from "@opentui/core"
import type { Binding } from "@opentui/keymap"
import { useTheme, selectedForeground } from "../context/theme" import { useTheme, selectedForeground } from "../context/theme"
import { entries, filter, flatMap, groupBy, pipe } from "remeda" import { entries, filter, flatMap, groupBy, pipe } from "remeda"
import { batch, createEffect, createMemo, createSignal, For, Show, type JSX, on, onCleanup } from "solid-js" 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 { Locale } from "../util/locale"
import { getScrollAcceleration } from "../util/scroll" import { getScrollAcceleration } from "../util/scroll"
import { useConfig } from "../config" import { useConfig } from "../config"
import { formatKeyBindings, useBindings, useKeymapSelector } from "../keymap" import { formatKeyBindings, useKeymapSelector } from "../keymap"
export interface DialogSelectProps<T> { export interface DialogSelectProps<T> {
title: string title: string
@@ -43,7 +36,7 @@ export interface DialogSelectProps<T> {
label: string label: string
side?: "left" | "right" side?: "left" | "right"
}[] }[]
bindings?: readonly Binding<Renderable, KeyEvent>[] bindings?: readonly KeymapCommand[]
current?: T current?: T
focusCurrent?: boolean focusCurrent?: boolean
} }
@@ -385,51 +378,52 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
}) })
} }
useBindings(() => { Keymap.createLayer(() => {
const visible = shownActions() const visible = shownActions()
return { return {
mode: "modal",
commands: [ commands: [
{ {
name: "dialog.select.prev", id: "dialog.select.prev",
title: "Previous item", title: "Previous item",
category: "Dialog", group: "Dialog",
run() { run() {
setStore("input", "keyboard") setStore("input", "keyboard")
move(-1) move(-1)
}, },
}, },
{ {
name: "dialog.select.next", id: "dialog.select.next",
title: "Next item", title: "Next item",
category: "Dialog", group: "Dialog",
run() { run() {
setStore("input", "keyboard") setStore("input", "keyboard")
move(1) move(1)
}, },
}, },
{ {
name: "dialog.select.page_up", id: "dialog.select.page_up",
title: "Page up", title: "Page up",
category: "Dialog", group: "Dialog",
run() { run() {
setStore("input", "keyboard") setStore("input", "keyboard")
move(-10) move(-10)
}, },
}, },
{ {
name: "dialog.select.page_down", id: "dialog.select.page_down",
title: "Page down", title: "Page down",
category: "Dialog", group: "Dialog",
run() { run() {
setStore("input", "keyboard") setStore("input", "keyboard")
move(10) move(10)
}, },
}, },
{ {
name: "dialog.select.home", id: "dialog.select.home",
title: "First item", title: "First item",
category: "Dialog", group: "Dialog",
run() { run() {
if (props.locked) return if (props.locked) return
setStore("input", "keyboard") 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", title: "Last item",
category: "Dialog", group: "Dialog",
run() { run() {
if (props.locked) return if (props.locked) return
setStore("input", "keyboard") 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", title: "Select item",
category: "Dialog", group: "Dialog",
run: submit, run: submit,
}, },
...visible.map((item) => ({ ...visible.map((item) => ({
name: item.command, id: item.command,
title: item.title, title: item.title,
category: "Dialog", group: "Dialog",
run: () => trigger(item), 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 ...(visible.length
? [ ? [
{ {
key: "tab", bind: "tab",
desc: "Next dialog action", title: "Next dialog action",
group: "Dialog", group: "Dialog",
cmd: () => moveAction(1), run: () => moveAction(1),
}, },
{ {
key: "shift+tab", bind: "shift+tab",
desc: "Previous dialog action", title: "Previous dialog action",
group: "Dialog", group: "Dialog",
cmd: () => moveAction(-1), run: () => moveAction(-1),
}, },
] ]
: []), : []),
...(props.bindings ?? []).filter((binding) => { ...(props.bindings ?? []),
if (typeof binding.cmd !== "string") return true
return visible.some((item) => item.command === binding.cmd)
}),
], ],
} }
}) })
+12 -11
View File
@@ -1,11 +1,11 @@
import { useRenderer, useTerminalDimensions } from "@opentui/solid" import { useRenderer, useTerminalDimensions } from "@opentui/solid"
import { batch, createContext, createEffect, onCleanup, Show, useContext, type JSX, type ParentProps } from "solid-js" 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 { useTheme } from "../context/theme"
import { MouseButton, Renderable, RGBA } from "@opentui/core" import { MouseButton, Renderable, RGBA } from "@opentui/core"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useToast } from "./toast" import { useToast } from "./toast"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { useBindings, useOpencodeModeStack } from "../keymap"
import { useClipboard } from "../context/clipboard" import { useClipboard } from "../context/clipboard"
export function Dialog( export function Dialog(
@@ -79,11 +79,11 @@ function init() {
}) })
const renderer = useRenderer() const renderer = useRenderer()
const modeStack = useOpencodeModeStack() const keymap = Keymap.use()
createEffect(() => { createEffect(() => {
if (store.stack.length === 0) return if (store.stack.length === 0) return
const popMode = modeStack.push("modal") const popMode = keymap.mode.push("modal")
onCleanup(popMode) onCleanup(popMode)
}) })
@@ -106,14 +106,15 @@ function init() {
}, 1) }, 1)
} }
useBindings(() => ({ Keymap.createLayer(() => ({
mode: "modal",
enabled: store.stack.length > 0 && !renderer.getSelection()?.getSelectedText(), enabled: store.stack.length > 0 && !renderer.getSelection()?.getSelectedText(),
bindings: [ commands: [
{ {
key: "escape", bind: "escape",
desc: "Close dialog", title: "Close dialog",
group: "Dialog", group: "Dialog",
cmd: () => { run: () => {
if (renderer.getSelection()) { if (renderer.getSelection()) {
renderer.clearSelection() renderer.clearSelection()
} }
@@ -124,10 +125,10 @@ function init() {
}, },
}, },
{ {
key: "ctrl+c", bind: "ctrl+c",
desc: "Close dialog", title: "Close dialog",
group: "Dialog", group: "Dialog",
cmd: () => { run: () => {
if (renderer.getSelection()) { if (renderer.getSelection()) {
renderer.clearSelection() renderer.clearSelection()
} }
+8 -31
View File
@@ -1,5 +1,4 @@
import { expect, mock, test } from "bun:test" import { expect, mock, test } from "bun:test"
import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
import { createTestRenderer } from "@opentui/core/testing" import { createTestRenderer } from "@opentui/core/testing"
import { Effect } from "effect" import { Effect } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" 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") const core = await import("@opentui/core")
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer })) mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
const titles: string[] = [] const titles: string[] = []
let started!: () => void
const ready = new Promise<void>((resolve) => {
started = resolve
})
const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer) const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
setup.renderer.setTerminalTitle = (title) => { setup.renderer.setTerminalTitle = (title) => {
titles.push(title) titles.push(title)
if (title === "OpenCode") started()
setTitle(title) setTitle(title)
} }
const listeners = new Set(process.listeners("SIGHUP")) const listeners = new Set(process.listeners("SIGHUP"))
const events = createEventStream() const events = createEventStream()
const calls = createFetch(undefined, events) const calls = createFetch(undefined, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) }) 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 { try {
const { run } = await import("../src/app") const { run } = await import("../src/app")
const task = Effect.runPromise( const task = Effect.runPromise(
run({ run({
server: { endpoint: { url: server.url.toString() } }, server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) }, config: { get: async () => ({}), update: async () => ({}) },
packages: { resolve: async () => undefined },
args: {}, args: {},
log: () => {}, log: () => {},
pluginHost: {
async start() {
started()
},
async dispose() {
disposes++
},
},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node))), }).pipe(Effect.provide(AppNodeBuilder.build(Global.node))),
) )
await ready await ready
@@ -50,7 +41,6 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
expect(setup.renderer.isDestroyed).toBe(true) expect(setup.renderer.isDestroyed).toBe(true)
expect(titles.at(-1)).toBe("") expect(titles.at(-1)).toBe("")
expect(disposes).toBe(1)
expect(process.listeners("SIGHUP").every((listener) => listeners.has(listener))).toBe(true) expect(process.listeners("SIGHUP").every((listener) => listeners.has(listener))).toBe(true)
} finally { } finally {
if (!setup.renderer.isDestroyed) setup.renderer.destroy() 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 server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
const originalWrite = process.stdout.write.bind(process.stdout) const originalWrite = process.stdout.write.bind(process.stdout)
let stdout = "" let stdout = ""
let api: TuiPluginApi | undefined
let started!: () => void
const ready = new Promise<void>((resolve) => {
started = resolve
})
process.stdout.write = ((chunk: string | Uint8Array) => { process.stdout.write = ((chunk: string | Uint8Array) => {
stdout += String(chunk) stdout += String(chunk)
return true return true
@@ -118,19 +102,12 @@ test("session lifecycle updates the terminal title and prints the epilogue after
run({ run({
server: { endpoint: { url: server.url.toString() } }, server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) }, config: { get: async () => ({}), update: async () => ({}) },
packages: { resolve: async () => undefined },
args: { sessionID: "dummy" }, args: { sessionID: "dummy" },
log: () => {}, log: () => {},
pluginHost: {
async start(input) {
api = input.api
started()
},
async dispose() {},
},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node))), }).pipe(Effect.provide(AppNodeBuilder.build(Global.node))),
) )
await ready
await initialTitleSet await initialTitleSet
events.emit({ events.emit({
id: "evt_renamed", 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" }, data: { sessionID: "dummy", title: "Renamed session" },
}) })
await renamedTitleSet await renamedTitleSet
api?.keymap.dispatchCommand("app.exit") setup.renderer.destroy()
await task await task
expect(stdout).toContain("Renamed session") expect(stdout).toContain("Renamed session")
+6 -9
View File
@@ -36,7 +36,7 @@ test("legacy page key aliases compile as page keys", async () => {
}) })
const offKeymap = registerOpencodeKeymap(keymap, renderer, config) const offKeymap = registerOpencodeKeymap(keymap, renderer, config)
const offLayer = keymap.registerLayer({ 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({ const bindings = keymap.getCommandBindings({
visibility: "registered", visibility: "registered",
@@ -79,7 +79,7 @@ test("formats navigation keys as arrows", async () => {
const offKeymap = registerOpencodeKeymap(keymap, renderer, config) const offKeymap = registerOpencodeKeymap(keymap, renderer, config)
const commands = ["session.parent", "session.child.first", "session.child.previous", "session.child.next"] const commands = ["session.parent", "session.child.first", "session.child.previous", "session.child.next"]
const offLayer = keymap.registerLayer({ 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 }) const bindings = keymap.getCommandBindings({ visibility: "registered", commands })
commands.forEach((command) => { 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.page.up", run() {} },
{ name: "session.first", run() {} }, { name: "session.first", run() {} },
], ],
bindings: config.keybinds.gather("test.global", [ bindings: ["session.list", "session.new", "session.page.up", "session.first"].flatMap((command) =>
"session.list", config.keybinds.get(command),
"session.new", ),
"session.page.up",
"session.first",
]),
}) })
const offBase = keymap.registerLayer({ const offBase = keymap.registerLayer({
mode: OPENCODE_BASE_MODE, mode: OPENCODE_BASE_MODE,
commands: [{ name: "model.list", run() {} }], commands: [{ name: "model.list", run() {} }],
bindings: config.keybinds.gather("test.base", ["model.list"]), bindings: config.keybinds.get("model.list"),
}) })
const activeCounts = () => const activeCounts = () =>
Object.fromEntries( Object.fromEntries(