Repository navigation
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
Feature Request: Allow plugins to intercept slash commands and return results directly (skip LLM), plus register custom dialogs
Problem
Currently, plugins can hook into slash command execution via command.execute.before, but they have no way to prevent the command from being sent to the LLM after the hook runs. The only workaround is throwing an error in the hook, which is hacky and produces a poor UX.
Additionally, external plugins cannot register TUI-level palette commands with custom dialogs (like /models does with DialogModel). Dialog-based interactions — browsing, selecting, confirming — are exclusive to opencode's built-in commands.
These limitations make it unnecessarily hard to build responsive plugin experiences. For example:
- A /todo list plugin that queries a local file and displays results instantly
- A /branch switch plugin that shows a picker of git branches
- A /focus goal plugin that updates session state and shows a confirmation without waiting for an LLM round-trip
Feature 1: noReply field on command.execute.before output
Allow the command.execute.before hook to signal that the LLM call should be skipped and the hook's output parts shown directly to the user.
Current hook signature (packages/plugin/src/index.ts lines 261-264):
"command.execute.before"?: (
input: { command: string; sessionID: string; arguments: string },
output: { parts: Part[] },
) => Promise<void>
Proposed change — add an optional noReply field to output:
"command.execute.before"?: (
input: { command: string; sessionID: string; arguments: string },
output: { parts: Part[]; noReply?: boolean },
) => Promise<void>
When output.noReply = true is set by the hook, the command should:
- Save the user message to the session (so it appears in chat history)
- Skip the LLM loop entirely
- Display the output.parts directly as the assistant's response (or as a toast/status)
The noReply parameter already exists on the prompt() function (packages/opencode/src/session/prompt.ts line 1631), but there is currently no way for a plugin hook to set it.
Implementation sketch (packages/opencode/src/session/prompt.ts around line 1984):
const hookOutput = yield* plugin.trigger(
"command.execute.before",
{ command: input.command, sessionID: input.sessionID, arguments: input.arguments },
{ parts },
)
if (hookOutput?.noReply) {
const result = yield* prompt({
...input,
parts: hookOutput.parts,
noReply: true,
})
yield* bus.publish(Command.Event.Executed, { ... })
return result
}
Feature 2: Plugin API for registering palette commands with custom dialogs
Allow external plugins (loaded via opencode.json's plugin field) to register TUI-level palette commands that open custom dialogs — the same capability that built-in commands like /models (DialogModel) and /sessions (DialogSession) have.
Currently, api.keymap.registerLayer() is only accessible to internal system plugins. External plugins only have access to server-side hooks. There's no bridge for an external plugin to say "I want this slash command to open a dialog."
Possible approaches:
(a) Expose api.ui.dialog.replace() and api.keymap.registerLayer() to external plugins, so they can register commands with custom dialog components (even if limited to simple form/select/list components).
(b) Add a new hook (e.g., "command.dialog") that receives the command name and arguments, and returns a dialog descriptor that the TUI can render:
"command.dialog"?: (
input: { command: string; arguments: string },
output: { dialog: { type: "select" | "confirm" | "prompt"; options?: string[]; message: string } },
) => Promise<void>
(c) Allow plugins to provide pre-built dialog components via a plugin manifest, loaded and rendered by the TUI at runtime.
Use Cases
- Instant feedback commands: /f-goal (set a focus goal) → plugin saves to file, shows "✅ Goal: old → new" in the UI immediately, no LLM wait
- Selection dialogs: /branch → plugin lists git branches, shows a selectable list, user picks one, plugin checks it out
- Status queries: /status → plugin reads working tree state, renders a compact summary in a dialog
- Toggle switches: /debug on → plugin toggles a config flag, shows confirmation toast
- Composable workflows: /deploy staging → plugin runs pre-checks, shows progress, all without consuming LLM context window
Why this matters
These changes would unlock a new class of low-latency, utility-focused plugins that don't need LLM involvement for simple CRUD operations. This is especially valuable for:
- Power users who want fast, keyboard-driven workflows
- Plugin authors who want to build responsive tools
- Reducing token waste on trivial commands
Prior art - opencode-shell-commands plugin uses ! prefix to run shell commands and skip the LLM entirely, but requires users to write separate shell scripts alongside their command definitions, making it inconvenient for plugin distribution
- PR feat(plugin commands): allows for code execution on plugin registered slash commands #4411 requested plugin-registered executable commands (closed in favor of command.execute.before + subsequent hooks)
- Issue feat: Add noReply option to command.execute.before hook #9306 requested a noReply option (closed as completed, but the feature appears not to have been released or was only partially implemented)
- Issue [FEATURE]: Plugin Hook for Instant TUI Commands #5305 requested a plugin hook for instant TUI commands
Activity
github-actions commentedon May 19, 2026
This issue might be a duplicate of (or closely related to) existing issues. Please check:
If these cover the same ground, consider adding your details there or linking them. If your request is meaningfully different, a brief note explaining the distinction would help maintainers triage.
graoke commentedon May 19, 2026
PR #9267 (feat(hook): command execute before hook) is already merged. It introduces the command.execute.before hook that lets plugins intercept slash commands and modify output.parts. However, it only solves half the problem:
So the hook exists, but plugins still cannot return results directly to the UI without an LLM round-trip. The only workaround is throwing an error in the hook, which produces a bad UX. The noReply field on output (and optionally a dialog API) are what's still needed to make this work cleanly.
graoke commentedon May 19, 2026
What it does:
This directly addresses Feature 1 of our issue — allowing command.execute.before to skip the LLM round-trip.
What it does NOT cover:
So the situation is:
draxxris commentedon May 19, 2026
How does Opencode DCP handle this? It has many local slash commands
graoke commentedon May 19, 2026
Layer 1: Command registration — .md files in ~/.config/opencode/command/
Each .md file registers a slash command.
Layer 2: Interception — command.execute.before hook in the plugin
The hook runs after the command template is resolved but before the message is sent to the LLM.
Svtter commentedon May 19, 2026
Real-world use case: toggle auto-review without LLM round-trip
I'm building opencode-review, a plugin that runs automated code reviews when a session goes idle. It has a
/review:autoslash command to toggle auto-review on/off.Current problem: The command does literally one thing — flip a boolean — but it must spin up an LLM agent to do it. The flow is:
This wastes tokens and adds ~1-2s latency for what should be an instant operation. It also feels fragile — the LLM might misunderstand the instruction or do something unexpected.
What I need: A way for the
command.execute.beforehook to:on/off)Workaround I'm considering: Use
command.execute.beforeto do the actual toggle work, then replaceoutput.partswith a simple instruction so the LLM just echoes the result. Still wastes a call but at least the logic is deterministic.Would love to see the
noReply/cancelledflag merged. Happy to help test if needed.graoke commentedon May 20, 2026
I'd love to see the noReply / cancelled flag merged too. And it would be better if opencode can provide some tools that can be used in a hook – for example, showing a dialog, or showing a message in the TUI.
I currently use an unconventional approach to skip the LLM call entirely:
And how do you respond – opencode seems do not provide ways for a plugin to show messages to tui
mirsella commentedon Jul 5, 2026
Adding a v2 plugin use case here instead of opening another issue.
I am porting a queue plugin that handles
/queuecommands directly. Some inputs are real queued work, but others are control commands likelist,clear,start, andstop.For those control commands, the plugin needs to handle the command and stop it from becoming an LLM turn. Rewriting the prompt is not enough, and throwing gives a bad UX. A supported
handled,cancelled, ornoReplyoutcome would solve this cleanly.metux commentedon Aug 10, 2026
copy-pasting from #41086 (accidental duplicate):
Describe the enhancement you want to request
For a fleeting automation plugin (part of https://github.com/mpbt-hq/starfleetctl) I would like to be able to inject all / commands directly via plugin (the plugin recieves messages from a spool and is already able to inject messages and restart turn).
most important operations for me right now are:
a) model switch --> hard-terminate the current term, switch to the given target model and restart the last term (with previous prompt / context)
b) issue /reset --> clear context (except system prompt)
c) restart failed turn (just resubmit the same request)
renaudcerrato commentedon Aug 30, 2026
Adding our findings from building a plugin that hits exactly this — plus two concrete data points the thread doesn't have yet.
1. The precise code path (the fix is small)
SessionPrompt.command(packages/opencode/src/session/prompt.ts) always ends with:prompt()already has the short-circuit:But
CommandInputhas nonoReplyfield (onlyPromptInputdoes), and thecommand.execute.beforehook output is{ parts }only — so neither the client nor a plugin can ever set it. PlumbingnoReplythroughcommand()(or letting the hook set it on the output object, forwarded toprompt()) is a two-line change, not new machinery.Also worth checking: #9306 was closed as completed, but
noReplyis unreachable fromCommandInputand hooks today — it looks like the mechanism exists inprompt()but was never exposed on the command path.2. Proof the capability already exists internally
The TUI plugin surface (
@opencode-ai/plugin/tui) already solves this within the terminal UI: a palette command registered withslashNameruns entirely client-side viakeymap.dispatchCommand— nosession.command, no user message, no agent loop. We built a working dialog-driven slash command on it (menu → prompts → file write → toast) with zero session interaction.So the mechanism demonstrably works without an LLM turn — it's just not exposed to server commands or hooks. A web frontend using server commands has no equivalent.
3. Why the current workarounds fail
With
command.execute.before, the only option is replacingoutput.partswith the command result as anignored: truetext part (visible in chat, filtered from the model byMessageV2.toModelMessages). The loop still runs, and the model receives an effectively empty user turn, which produces a spurious reply ("Understood.", a comment on the prior context, etc.) — a full LLM call for zero value, on every invocation.Setting
ignored: falseand asking the model not to respond is not a workaround either:So today's choice is between a deterministic wart (spurious reply, no context pollution) and a worse one (guaranteed turn + polluted context + unreliable suppression). Neither is acceptable for text-only commands.
4. Use case
A config-management slash command (list/set/delete entries in a JSON file, validated against the provider list) — pure text I/O, no LLM involvement needed or wanted. Every invocation currently costs a wasted LLM turn, and the spurious model reply confuses users into thinking the command "did something" in the session.
Strongly support option (1)
noReplyonCommandInputfor declarative definitions plus (2) a hook-output flag for plugin-driven flows — they compose cleanly and cover both static and dynamic cases.chrishalltu commentedon Sep 8, 2026
Confirming this is still open against 1.18.29, with evidence on exactly which workarounds fail and how — since #9306 was closed as completed but the field is not in the shipped payload.
The field is absent from the command payload
session.prompthonours it — from the shipped binary:But the command payload schema has no such field:
And
Session.commandcallsprompt()unconditionally on the line after the hook returns:So there is no path from the hook to the
noReplythat already exists one call below it.The documented workarounds, tested against a live server
All three fail, and each fails differently:
chat.messageclient.session.abort()output.partsThe throw case is worth stressing: it is the workaround named in this issue and in #9306 as "hacky", but it is not merely ugly — it does not actually suppress the turn.
The non-obvious trap:
promptAsync({ noReply: true })Posting the result out-of-band does reach the transcript with no model call, so it looks like a clean workaround. It is not: it re-enters
chat.message. A plugin that suppresses "the next message" therefore swallows its own result, and then lets the original template through to the model anyway — strictly worse than doing nothing. This only shows up in a live trace; it reads as correct on paper.What we ended up shipping
Do the work in
command.execute.before, replaceoutput.partswith the finished result, and accept the trailing turn — giving it the answer plus an explicit instruction to reply with nothing.Three details that may be useful to anyone else working around this, and to whoever implements the fix:
output.partsmust be mutated in place.prompt()already holds that array by reference, sooutput.parts = [...]is silently discarded.synthetic: trueon the replacement part is what makes OpenCode render it as assistant output rather than as words the user typed. Without it the command's own answer appears as the user's message.output.noReplyas proposed here would remove all of this. The plumbing on theprompt()side already exists.slkiser commentedon Sep 26, 2026
Another use case: opencode-quota's
/quotaon Web/Desktop. A plugin command can post asyntheticmessage withresume: false, so the model doesn't reply, but that message is still sent to the model on the next turn and ends up in compaction. A display-only message or part that the UI shows but the prompt and compaction skip would solve this, and the status-message case in #6330.chore: track @opencode/plugin 2.0.32
blockspacer commentedon Oct 18, 2026
The ignored vs synthetic split is the inverse of intuition, and the two flags do different jobs (1.19.0 toModelMessages):
model context: user text parts are kept only when !ignored && text !== "" — ignored is filtered, synthetic still reaches the model;
web + TUI bubble renderers: skip only synthetic parts. So ignored: true alone = visible normal user bubble + model-invisible; synthetic: true = hidden from bubble + model-visible. session.prompt({noReply:true, parts:[{type:'text',text,ignored:true}]}) is a zero-cost status message (live probe: persisted in ~18 ms, no turn, no tokens).
Command path: the forced turn is confirmed unavoidable on 1.19.0. With control results as ignored parts the model never reads the command text (live probe: the turn continued the PRIOR context — it did pending verification work and asked a question, instead of answering the command output). So the workaround's residual is exactly one wasted context-continuation turn per control command, no context pollution — the "spurious reply" reports above, with the nuance that it's context continuation, not a response to the command text.
(notifications are zero-cost via the server-API noReply; control commands cost one turn each). We'd adopt output.noReply on the hook (per #46579, OR semantics) the day it lands.
opencode-agent commentedon Oct 18, 2026
In V2 both parts of this are supported. Server plugins register commands through
ctx.command.transform, and the command'sexecutedecides whether to prompt the model at all, so control commands can complete without a model turn. TUI plugins can add palette and slash commands withcontext.keymap.layerand show select, confirm, prompt or custom dialogs throughcontext.ui.dialog(see the plugin docs).Closing as completed. If something is still missing on V2, please open a new issue.