[FEATURE]: Plugin Hook for Instant TUI Commands
Feature hasn't been suggested before.
- [x] I have verified this feature I'm about to request hasn't been suggested before.
Describe the enhancement you want to request
Summary
Add a new plugin hook that allows plugins to register instant TUI commands that execute without agent involvement. This enables plugins to provide quick-action commands (like toggling modes, showing status, etc.) that respond immediately rather than going through the LLM.
Problem Statement
Current Behavior
OpenCode has two types of commands:
-
TUI Commands (instant) - Internal commands like
session.new,session.share,agent.cyclethat execute immediately via keyboard shortcuts or command palette -
Slash Commands (agent-driven) - Custom commands defined in
.opencode/command/that become prompts sent to an LLM agent
Plugins can only create slash commands (via command markdown files), which means every plugin command must go through an agent, adding:
- 1-3 seconds of latency
- Context window usage
- Unnecessary LLM inference for simple operations
Use Case: AFK Mode Toggle
I'm building an opencode-afk plugin that monitors sessions and sends notifications to the user's phone when agents need input. The plugin needs a simple toggle command:
/afk → Toggle AFK mode on/off
This is a boolean state toggle that should:
- Execute instantly (< 100ms)
- Show a toast confirmation
- Not involve any agent reasoning
But with current architecture, /afk must:
- Be defined as a slash command
- Go through an agent that processes the template
- Agent calls a tool the plugin provides
- Tool toggles the state
This adds unnecessary latency and complexity for what should be an instant operation.
Current Architecture
Based on my research of the codebase:
TUI Command Registration (packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx)
// TUI commands are registered via React hooks in components
const command = useCommandDialog()
command.register(() => [
{
title: "Share session",
value: "session.share",
keybind: "session_share",
category: "Session",
onSelect: async (dialog) => {
// Executes immediately - no agent involved
await sdk.client.session.share({ path: { id: sessionID } })
dialog.clear()
},
},
])
Plugin Hooks (packages/plugin/src/index.ts)
export interface Hooks {
event?: (input: { event: Event }) => Promise<void>
config?: (input: Config) => Promise<void>
tool?: { [key: string]: ToolDefinition }
auth?: AuthHook
"chat.message"?: (...) => Promise<void>
"chat.params"?: (...) => Promise<void>
"permission.ask"?: (...) => Promise<void>
"tool.execute.before"?: (...) => Promise<void>
"tool.execute.after"?: (...) => Promise<void>
// No hook for TUI commands!
}
The Gap
- TUI commands are registered in the React component tree (client-side)
- Plugins run on the server and communicate via events/SDK
- There's no bridge for plugins to register instant TUI commands
Proposed Solution
New Plugin Hook: command
Add a new hook that allows plugins to register instant commands:
// In packages/plugin/src/index.ts
export interface Hooks {
// ... existing hooks ...
/**
* Register instant TUI commands that execute without agent involvement
*/
command?: {
[name: string]: {
/** Display title in command palette */
title: string
/** Optional description */
description?: string
/** Category for grouping in palette */
category?: string
/** Keyboard shortcut key (references keybinds config) */
keybind?: string
/** Callback when command is executed */
execute: (context: CommandContext) => Promise<CommandResult>
}
}
}
export interface CommandContext {
/** Current session ID if in a session */
sessionID?: string
/** SDK client for API calls */
client: ReturnType<typeof createOpencodeClient>
/** Show a toast notification */
toast: (options: { message: string; variant: "info" | "success" | "warning" | "error" }) => void
}
export interface CommandResult {
/** Optional message to display */
message?: string
/** Prevent default command palette close */
keepOpen?: boolean
}
Example Plugin Usage
import type { Plugin } from "@opencode-ai/plugin"
export const AFKPlugin: Plugin = async (ctx) => {
let afkEnabled = false
return {
command: {
afk: {
title: "Toggle AFK Mode",
description: "Enable/disable away-from-keyboard notifications",
category: "AFK",
keybind: "afk_toggle", // User can configure in keybinds
async execute({ toast }) {
afkEnabled = !afkEnabled
toast({
message: afkEnabled
? "AFK mode enabled - notifications will go to your phone"
: "AFK mode disabled - welcome back!",
variant: afkEnabled ? "success" : "info",
})
return {}
},
},
"afk-status": {
title: "AFK Status",
description: "Show current AFK mode status",
category: "AFK",
async execute({ toast }) {
toast({
message: afkEnabled ? "AFK mode is ON" : "AFK mode is OFF",
variant: "info",
})
return {}
},
},
},
// ... other hooks
}
}
Implementation Approach
-
Plugin Loader (
packages/opencode/src/plugin/index.ts):- Collect
commandhooks from all plugins - Expose via a new API endpoint or event
- Collect
-
New Event (
packages/opencode/src/cli/cmd/tui/event.ts):PluginCommandsUpdated: Bus.event( "plugin.commands.updated", z.object({ commands: z.array(z.object({ name: z.string(), title: z.string(), description: z.string().optional(), category: z.string().optional(), keybind: z.string().optional(), })), }), ) -
TUI Integration (
packages/opencode/src/cli/cmd/tui/context/sync.tsxor similar):- Subscribe to
plugin.commands.updatedevent - Register commands via
useCommandDialog().register() - On command select, call back to server to execute plugin callback
- Subscribe to
-
Server Endpoint for command execution:
POST /plugin/command/:name // Triggers the plugin's execute callback // Returns result for toast/feedback
Alternatives Considered
Alternative 1: Use existing tools + minimal slash command
# /afk command
Call the afk-toggle tool.
Pros: Works today Cons: Still goes through agent (1-3s latency), wastes context
Alternative 2: Shell escape with external binary
User types !afk-toggle which runs a shell script.
Pros: Instant execution Cons: Requires separate binary installation, fragile, no toast feedback
Alternative 3: Keybind-only (no command)
Plugin could potentially hook into a keybind directly.
Pros: Truly instant Cons: Not discoverable, no command palette integration, limited UX
Request
I'd love to work on implementing this feature if the maintainers are open to the approach. Before I start a PR, I wanted to:
- Validate the use case - Is this something other plugin authors would benefit from?
-
Get feedback on the API design - Does the proposed
commandhook interface make sense? - Understand any constraints - Are there architectural concerns I'm missing?
Happy to iterate on the design or explore alternatives. Thanks for considering!
References
- Plugin hook types:
packages/plugin/src/index.ts - TUI command registration:
packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx - Plugin loader:
packages/opencode/src/plugin/index.ts - TUI events:
packages/opencode/src/cli/cmd/tui/event.ts
This issue might be a duplicate of existing issues. Please check:
- #2185: Hooks for commands (Plugin Commands) - Originally requested hooks to capture commands and allow plugins to define commands instead of requiring markdown files
- #5148: Comprehensive Plugin Pipeline - Middleware-Style Data Flow Control - A broader feature request for plugin system extensibility that could encompass plugin commands as part of a larger pipeline architecture
Feel free to ignore if none of these address your specific case.
This issue might be a duplicate of existing issues. Please check:
* [Hooks for commands (Plugin Commands) #2185](https://github.com/sst/opencode/issues/2185): Hooks for commands (Plugin Commands) - Originally requested hooks to capture commands and allow plugins to define commands instead of requiring markdown files
This one talks about adding commands which is something we can already do now, I believe could be closed, this issue is about TUI commands, not meant to be passed to LLMs at all