diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 627c3abab17..cd0691e13c2 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -211,10 +211,14 @@ export function Autocomplete(props: { const commands = createMemo((): AutocompleteOption[] => { const results: AutocompleteOption[] = [] const s = session() + for (const command of sync.data.command) { + if (command.sessionOnly && !s) continue + results.push({ display: "/" + command.name, description: command.description, + aliases: command.aliases?.map((a) => "/" + a), onSelect: () => { const newText = "/" + command.name + " " const cursor = props.input().logicalCursor diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 99a90ab46ac..53ff7c9ccda 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -513,14 +513,15 @@ export function Prompt(props: PromptProps) { inputText.startsWith("/") && iife(() => { const command = inputText.split(" ")[0].slice(1) - console.log(command) - return sync.data.command.some((x) => x.name === command) + return sync.data.command.some((x) => x.name === command || x.aliases?.includes(command)) }) ) { let [command, ...args] = inputText.split(" ") + const commandName = command.slice(1) + const resolved = sync.data.command.find((x) => x.name === commandName || x.aliases?.includes(commandName)) sdk.client.session.command({ sessionID, - command: command.slice(1), + command: resolved?.name ?? commandName, arguments: args.join(" "), agent: local.agent.current().name, model: `${selectedModel.providerID}/${selectedModel.modelID}`, diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 0a9bfc62030..1d6da266322 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -6,6 +6,7 @@ import { Instance } from "../project/instance" import { Identifier } from "../id/id" import PROMPT_INITIALIZE from "./template/initialize.txt" import PROMPT_REVIEW from "./template/review.txt" +import { Plugin } from "../plugin" export namespace Command { export const Event = { @@ -27,7 +28,10 @@ export namespace Command { agent: z.string().optional(), model: z.string().optional(), template: z.string(), + type: z.enum(["template", "plugin"]), subtask: z.boolean().optional(), + sessionOnly: z.boolean().optional(), + aliases: z.array(z.string()).optional(), }) .meta({ ref: "Command", @@ -45,11 +49,13 @@ export namespace Command { const result: Record = { [Default.INIT]: { name: Default.INIT, + type: "template", description: "create/update AGENTS.md", template: PROMPT_INITIALIZE.replace("${path}", Instance.worktree), }, [Default.REVIEW]: { name: Default.REVIEW, + type: "template", description: "review changes [commit|branch|pr], defaults to uncommitted", template: PROMPT_REVIEW.replace("${path}", Instance.worktree), subtask: true, @@ -59,6 +65,7 @@ export namespace Command { for (const [name, command] of Object.entries(cfg.command ?? {})) { result[name] = { name, + type: "template", agent: command.agent, model: command.model, description: command.description, @@ -67,11 +74,34 @@ export namespace Command { } } + const plugins = await Plugin.list() + for (const plugin of plugins) { + const commands = plugin["plugin.command"] + if (!commands) continue + for (const [name, cmd] of Object.entries(commands)) { + if (result[name]) continue + result[name] = { + name, + type: "plugin", + description: cmd.description, + template: "", + sessionOnly: cmd.sessionOnly, + aliases: cmd.aliases, + } + } + } + return result }) export async function get(name: string) { - return state().then((x) => x[name]) + const commands = await state() + if (commands[name]) return commands[name] + // Check aliases + for (const cmd of Object.values(commands)) { + if (cmd.aliases?.includes(name)) return cmd + } + return undefined } export async function list() { diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 9086f70ce62..5b2c28aabc0 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -295,7 +295,7 @@ export namespace Config { return result } - const PLUGIN_GLOB = new Bun.Glob("plugin/*.{ts,js}") + const PLUGIN_GLOB = new Bun.Glob("{plugin,command}/*.{ts,js}") async function loadPlugin(dir: string) { const plugins: string[] = [] diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index b492c7179e6..586d13e6bfd 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -41,6 +41,7 @@ export namespace Plugin { } const mod = await import(plugin) for (const [_name, fn] of Object.entries(mod)) { + if (typeof fn !== "function") continue const init = await fn(input) hooks.push(init) } @@ -53,7 +54,7 @@ export namespace Plugin { }) export async function trigger< - Name extends Exclude, "auth" | "event" | "tool">, + Name extends Exclude, "auth" | "event" | "tool" | "plugin.command">, Input = Parameters[Name]>[0], Output = Parameters[Name]>[1], >(name: Name, input: Input, output: Output): Promise { @@ -73,6 +74,10 @@ export namespace Plugin { return state().then((x) => x.hooks) } + export async function client() { + return state().then((x) => x.input.client) + } + export async function init() { const hooks = await state().then((x) => x.hooks) const config = await Config.get() diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index ff5194d5594..c7fdfb4ed42 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -43,6 +43,7 @@ import { SessionStatus } from "./status" import { LLM } from "./llm" import { iife } from "@/util/iife" import { Shell } from "@/shell/shell" +import { TuiEvent } from "@/cli/cmd/tui/event" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false @@ -1282,6 +1283,45 @@ export namespace SessionPrompt { export async function command(input: CommandInput) { log.info("command", input) const command = await Command.get(input.command) + if (!command) return + + // Plugin commands execute directly + if (command.type === "plugin") { + const plugins = await Plugin.list() + for (const plugin of plugins) { + const pluginCommands = plugin["plugin.command"] + const pluginCommand = pluginCommands?.[command.name] + if (!pluginCommand) continue + + const client = await Plugin.client() + try { + await pluginCommand.execute({ + sessionID: input.sessionID, + arguments: input.arguments, + client, + }) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + log.error("plugin command failed", { + command: command.name, + error: errorMessage, + }) + await Bus.publish(TuiEvent.ToastShow, { + title: `/${command.name} failed`, + message: errorMessage, + variant: "error", + }) + throw error + } + const last = await Session.messages({ + sessionID: input.sessionID, + limit: 1, + }) + return last.at(0) + } + return + } + const agentName = command.agent ?? input.agent ?? "build" const raw = input.arguments.match(argsRegex) ?? [] diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 487e6ed3ee2..384dabaa411 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -200,4 +200,19 @@ export interface Hooks { input: { sessionID: string; messageID: string; partID: string }, output: { text: string }, ) => Promise + /** + * Register custom plugin commands (accessible via /command in TUI) + */ + "plugin.command"?: { + [key: string]: { + description: string + aliases?: string[] + sessionOnly?: boolean + execute(input: { + sessionID?: string + arguments: string + client: ReturnType + }): Promise + } + } } diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 8d455052537..d5dababbc4f 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -1427,6 +1427,8 @@ export type Command = { model?: string template: string subtask?: boolean + sessionOnly?: boolean + aliases?: Array } export type Model = { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 00f209c6d88..ae186e597e0 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -557,6 +557,48 @@ export type EventCommandExecuted = { } } +export type EventTuiPromptAppend = { + type: "tui.prompt.append" + properties: { + text: string + } +} + +export type EventTuiCommandExecute = { + type: "tui.command.execute" + properties: { + command: + | "session.list" + | "session.new" + | "session.share" + | "session.interrupt" + | "session.compact" + | "session.page.up" + | "session.page.down" + | "session.half.page.up" + | "session.half.page.down" + | "session.first" + | "session.last" + | "prompt.clear" + | "prompt.submit" + | "agent.cycle" + | string + } +} + +export type EventTuiToastShow = { + type: "tui.toast.show" + properties: { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + /** + * Duration in milliseconds + */ + duration?: number + } +} + export type Session = { id: string projectID: string @@ -639,48 +681,6 @@ export type EventVcsBranchUpdated = { } } -export type EventTuiPromptAppend = { - type: "tui.prompt.append" - properties: { - text: string - } -} - -export type EventTuiCommandExecute = { - type: "tui.command.execute" - properties: { - command: - | "session.list" - | "session.new" - | "session.share" - | "session.interrupt" - | "session.compact" - | "session.page.up" - | "session.page.down" - | "session.half.page.up" - | "session.half.page.down" - | "session.first" - | "session.last" - | "prompt.clear" - | "prompt.submit" - | "agent.cycle" - | string - } -} - -export type EventTuiToastShow = { - type: "tui.toast.show" - properties: { - title?: string - message: string - variant: "info" | "success" | "warning" | "error" - /** - * Duration in milliseconds - */ - duration?: number - } -} - export type Pty = { id: string title: string @@ -753,6 +753,9 @@ export type Event = | EventSessionIdle | EventSessionCompacted | EventCommandExecuted + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow | EventSessionCreated | EventSessionUpdated | EventSessionDeleted @@ -760,9 +763,6 @@ export type Event = | EventSessionError | EventFileWatcherUpdated | EventVcsBranchUpdated - | EventTuiPromptAppend - | EventTuiCommandExecute - | EventTuiToastShow | EventPtyCreated | EventPtyUpdated | EventPtyExited @@ -1610,7 +1610,10 @@ export type Command = { agent?: string model?: string template: string + type: "template" | "plugin" subtask?: boolean + sessionOnly?: boolean + aliases?: Array } export type Model = { diff --git a/packages/web/src/content/docs/plugins.mdx b/packages/web/src/content/docs/plugins.mdx index 6982b4c932f..78733edd871 100644 --- a/packages/web/src/content/docs/plugins.mdx +++ b/packages/web/src/content/docs/plugins.mdx @@ -233,3 +233,94 @@ Include any state that should persist across compaction: ``` The `experimental.session.compacting` hook fires before the LLM generates a continuation summary. Use it to inject domain-specific context that the default compaction prompt would miss. + +--- + +### Slash commands + +Register custom slash commands that users can execute from the TUI. You can place `.ts` files directly in the `command/` folder: + +```ts title=".opencode/command/hello.ts" +import type { Plugin } from "@opencode-ai/plugin" + +const HelloCommand: Plugin = async () => ({ + "plugin.command": { + hello: { + description: "Say hello", + aliases: ["hi"], + sessionOnly: true, + async execute({ sessionID, arguments: args, client }) { + await client.session.prompt({ + path: { id: sessionID! }, + body: { + parts: [{ type: "text", text: `Hello from command! Args: ${args}` }], + }, + }) + }, + }, + }, +}) + +export default HelloCommand +``` + +You can also register multiple commands from a single plugin file in `.opencode/plugin/`: + +```ts title=".opencode/plugin/commands.ts" +import type { Plugin } from "@opencode-ai/plugin" + +export const CommandsPlugin: Plugin = async (ctx) => { + return { + "plugin.command": { + foo: { + description: "Do foo", + async execute({ sessionID, arguments: args, client }) { + // ... + }, + }, + bar: { + description: "Do bar", + async execute({ sessionID, arguments: args, client }) { + // ... + }, + }, + }, + } +} +``` + +The `plugin.command` hook lets you define commands that appear in command listings. Each command has: + +- `description`: Brief description shown in autocomplete +- `aliases`: Alternative names for the command (optional) +- `sessionOnly`: Whether command requires an active session (optional) +- `execute`: Function that runs when the command is invoked, receives `{ sessionID, arguments, client }` + +Commands are invoked by typing `/command-name` in the TUI. + +--- + +### Show toast notifications + +Commands can display toast notifications using the SDK client: + +```ts title=".opencode/command/notify.ts" +export default async () => ({ + "plugin.command": { + notify: { + description: "Show a notification", + async execute({ arguments: args, client }) { + await client.tui.showToast({ + body: { + title: "Notification", + message: args || "Hello from plugin!", + variant: "success", + }, + }) + }, + }, + }, +}) +``` + +Available toast variants: `success`, `error`, `warning`.