[FEATURE]: Plugin Hook for Instant TUI Commands #5305
Description
Feature hasn't been suggested before.
- 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
Activity
github-actions commentedon Dec 10, 2025
This issue might be a duplicate of existing issues. Please check:
Feel free to ignore if none of these address your specific case.
malhashemi commentedon Dec 10, 2025
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
denisdifazio commentedon Dec 11, 2025
+1
I want to create custom slash commands that don’t trigger any interaction with the LLM. For example, I built a
/usagecommand to fetch my current Claude plan usage, but the LLM attempts to interpret and respond to the output. In this case, I want the command to run and display its result without the LLM engaging with it.malhashemi commentedon Dec 11, 2025
@rekram1-node
Permission to make a PR once you are happy with the technical proposal
malhashemi commentedon Dec 18, 2025
@rekram1-node really appreciate any feedback on this one, I believe this would be a nice addition to the plugin eco system, unlocking new capabilities
rekram1-node commentedon Dec 18, 2025
Sorry didn't see messages reading this now
rekram1-node commentedon Dec 18, 2025
So to me this sounds like:
Slash commands should optionally have the ability to run actual code as part of their execution
Would that be correct?
Good example here is like:
/status (doesn't fire a prompt triggers action)
But you could create arbitrary ones like (this is totally hypothetical ignore the absurdity):
/send-reminder and it could literally just send an email to someone (no prompt triggered)
And maybe even the ability to run typescript code and inject it into a prompt so you could do ncie shortcuts like normal commands but with more ease of use
rekram1-node commentedon Dec 18, 2025
Lmk if im following or if i misunderstood
malhashemi commentedon Dec 18, 2025
Correct, exactly as you described, however these slash commands have to be registered as part of plugins. But now that you are framing it like this, maybe they can be registered by just adding
*.tsfiles to thecommandfolder, similar to how custom tools work. That would be infinitely cooler honestly, but I have not thought about where to start to implement this. If you like the proposal let me know so I can start planning something that include that as well. Could be too core of an implementation so I would ask Dax about his opinion here.rekram1-node commentedon Dec 18, 2025
Yeah I think as plugins is prolly easiest idk tho
rekram1-node commentedon Dec 18, 2025
I think someone actually started this work
rekram1-node commentedon Dec 18, 2025
@spoons-and-mirrors started some work here:
#4411
malhashemi commentedon Dec 18, 2025
Great! I will jump in on that draft one!
spoons-and-mirrors commentedon Dec 18, 2025
I'm on opencode's discord
@spoonif you want to discuss that PR @malhashemiPhilippPolterauer commentedon Jan 11, 2026
i would really love seeing one of these plugin command PRs merged!
rgodha24 commentedon Jan 14, 2026
+1
I can def work on an implementation too based on the existing PRs
m10rten commentedon Mar 20, 2026
+1
Much wanted feature, together with plugin loaded agents, skills and already possible: tools, this makes the suite complete.
docs(release): README + CHANGELOG + LICENSE — Task 14
docs(spec): Task 0 findings — restore runtime command install
docs(release): README + CHANGELOG + LICENSE — Task 14
Ar9av commentedon Jun 14, 2026
For reference on how other agents draw this line: agent-manual (https://github.com/Ar9av/agent-manual) cross-documents slash commands vs. hooks across 17 agents.
The clearest separation is Claude Code's model: slash commands (
.claude/commands/<name>.md) are agent-facing — they inject a prompt into the conversation. Hooks (settings.jsonunderhooks) are programmatic — they fire on lifecycle events and can block, transform, or observe without the agent knowing. The two systems never overlap.Gemini CLI follows the same split: Agent Skills (on-demand Markdown procedures) vs. Hooks (event-driven scripts). Codex doesn't have a slash command system yet, so hooks handle both roles.
If OpenCode goes the same route, the key design question from the almanac data is whether slash commands can trigger hooks or vice versa — Claude Code keeps them strictly separate, which makes both simpler to reason about individually.
rusak47 commentedon Jul 17, 2026
My 5 cents here, if allowed. Recently, I found a powerful plugin that provides a command similar to Claude Code's /context — and maybe @denisdifazio. However, I noticed that the plugin's tool output isn't shown at all. For example, the grep tool produces no visible output. While its output may not always be interesting, having the option to see it would be handy.
After some research, I found a working hack: the experimental.text.complete hook allows prepending tool output to the agent's response. However, this isn't an ideal solution, as longer outputs would add unnecessary noise. A tool output preview (#5358)— without even passing it to the agent for interpretation — could be really useful.
github-actions commentedon Sep 16, 2026
To stay organized issues are automatically closed after 60 days of no activity. If the issue is still relevant please open a new one.