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