Skip to content

[FEATURE]:Allow plugins to intercept slash commands and return results directly (skip LLM), plus register custom dialogs #28292

Closed

Description

@graoke

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:

  1. Save the user message to the session (so it appears in chat history)
  2. Skip the LLM loop entirely
  3. 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

  1. Instant feedback commands: /f-goal (set a focus goal) → plugin saves to file, shows "✅ Goal: old → new" in the UI immediately, no LLM wait
  2. Selection dialogs: /branch → plugin lists git branches, shows a selectable list, user picks one, plugin checks it out
  3. Status queries: /status → plugin reads working tree state, renders a compact summary in a dialog
  4. Toggle switches: /debug on → plugin toggles a config flag, shows confirmation toast
  5. 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:

Activity

  1. github-actions commented on May 19, 2026

    @github-actions
    Contributor

    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.

  2. graoke commented on May 19, 2026

    @graoke
    Author

    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.

    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:

    • ✅ Plugin can intercept /command and replace output.parts
    • ❌ No way to skip the LLM call after the hook — output only has { parts: Part[] }, no noReply field
    • ❌ No API for external plugins to register palette commands with custom dialogs
      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.
  3. graoke commented on May 19, 2026

    @graoke
    Author

    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.

    What it does:

    • Adds an optional noReply flag to the command schema
    • When set, the command dispatch forwards noReply: true to prompt(), which already has a short-circuit guard at if (input.noReply === true) return message
      This directly addresses Feature 1 of our issue — allowing command.execute.before to skip the LLM round-trip.
      What it does NOT cover:
    • Registering custom dialogs for external plugins (Feature 2) — no existing PR or issue for this
    • Any changes to the plugin hook interface itself — it's a command schema change, not a hook API change
      So the situation is:
    • Feature 1 (noReply) ✅ has an open PR (feat(command): add noReply flag to skip LLM dispatch for display-only commands #27521), needs attention to get merged.
    • Feature 1 (show information in tui)❌ The PR stops at "don't call the LLM," but the parts the hook produces still disappear into the session as a user message rather than surfacing as visible command output.
    • Feature 2 (dialog API for plugins) ❌ still no implementation anywhere.
  4. draxxris commented on May 19, 2026

    @draxxris

    How does Opencode DCP handle this? It has many local slash commands

  5. graoke commented on May 19, 2026

    @graoke
    Author

    How does Opencode DCP handle this? It has many local slash commands

    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

    'command.execute.before': async (input, output) => {
    
    }
    

    The hook runs after the command template is resolved but before the message is sent to the LLM.

  6. Svtter commented on May 19, 2026

    @Svtter

    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:auto slash 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:

    User types: /review:auto off
      → command.execute.before hook (can't cancel)
      → LLM agent receives a prompt: "if args contain 'off', call toggle_auto_review(enabled: false)"
      → LLM calls the toggle tool
      → Tool sets autoEnabled = false
    

    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.before hook to:

    1. Parse the argument (on/off)
    2. Set the state directly
    3. Return a response to the user
    4. Skip the LLM call entirely

    Workaround I'm considering: Use command.execute.before to do the actual toggle work, then replace output.parts with 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 / cancelled flag merged. Happy to help test if needed.

  7. graoke commented on May 20, 2026

    @graoke
    Author

    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:auto slash 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:

    User types: /review:auto off
      → command.execute.before hook (can't cancel)
      → LLM agent receives a prompt: "if args contain 'off', call toggle_auto_review(enabled: false)"
      → LLM calls the toggle tool
      → Tool sets autoEnabled = false
    

    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.before hook to:

    1. Parse the argument (on/off)
    2. Set the state directly
    3. Return a response to the user
    4. Skip the LLM call entirely

    Workaround I'm considering: Use command.execute.before to do the actual toggle work, then replace output.parts with 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 / cancelled flag merged. Happy to help test if needed.

    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:

    1. hook
    2. do something (in your case: set the state directly)
    3. then throws an exception. -- That works. You could try it.

    And how do you respond – opencode seems do not provide ways for a plugin to show messages to tui

  8. mirsella commented on Jul 5, 2026

    @mirsella
    Contributor

    Adding a v2 plugin use case here instead of opening another issue.

    I am porting a queue plugin that handles /queue commands directly. Some inputs are real queued work, but others are control commands like list, clear, start, and stop.

    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, or noReply outcome would solve this cleanly.

  9. metux commented on Aug 10, 2026

    @metux

    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)

  10. renaudcerrato commented on Aug 30, 2026

    @renaudcerrato

    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:

    const result = yield* prompt({
      sessionID: input.sessionID,
      messageID: input.messageID,
      model: userModel,
      agent: userAgent,
      parts,
      variant: input.variant,
    })

    prompt() already has the short-circuit:

    if (input.noReply === true) return message
    return yield* loop({ sessionID: input.sessionID })

    But CommandInput has no noReply field (only PromptInput does), and the command.execute.before hook output is { parts } only — so neither the client nor a plugin can ever set it. Plumbing noReply through command() (or letting the hook set it on the output object, forwarded to prompt()) is a two-line change, not new machinery.

    Also worth checking: #9306 was closed as completed, but noReply is unreachable from CommandInput and hooks today — it looks like the mechanism exists in prompt() 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 with slashName runs entirely client-side via keymap.dispatchCommand — no session.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 replacing output.parts with the command result as an ignored: true text part (visible in chat, filtered from the model by MessageV2.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: false and asking the model not to respond is not a workaround either:

    • the turn still fires and still costs tokens,
    • the command output becomes permanent conversation context,
    • suppression is probabilistic — models acknowledge ("Understood, I won't respond to that."), comment, or emit empty messages inconsistently.

    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) noReply on CommandInput for declarative definitions plus (2) a hook-output flag for plugin-driven flows — they compose cleanly and cover both static and dynamic cases.

  11. chrishalltu commented on Sep 8, 2026

    @chrishalltu

    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.prompt honours it — from the shipped binary:

    if (t.noReply === !0) return U;
    return yield* jo({ sessionID: t.sessionID });

    But the command payload schema has no such field:

    n.Struct({
      messageID: n.optional(Oe), sessionID: Pe,
      agent: n.optional(n.String), model: n.optional(n.String),
      arguments: n.String, command: n.String,
      variant: n.optional(n.String), parts: n.optional(...)
    })

    And Session.command calls prompt() unconditionally on the line after the hook returns:

    yield* d.trigger("command.execute.before", { command, sessionID, arguments }, { parts: We });
    let B = yield* we({ sessionID, messageID, model: P, agent: ee, parts: We, variant });

    So there is no path from the hook to the noReply that already exists one call below it.

    The documented workarounds, tested against a live server

    All three fail, and each fails differently:

    Attempt Result
    Throw from chat.message Command answers HTTP 500 and the original template still reaches the model
    client.session.abort() Returns OK; the turn runs regardless
    Empty output.parts An empty prompt is sent to the model

    The 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, replace output.parts with 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.parts must be mutated in place. prompt() already holds that array by reference, so output.parts = [...] is silently discarded.
    • synthetic: true on 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.
    • The turn still costs a real model round-trip. For a command with exactly one correct outcome (ours sets a billing ticket) that is pure waste, and on a metered provider it is waste the user pays for.

    output.noReply as proposed here would remove all of this. The plumbing on the prompt() side already exists.

  12. slkiser commented on Sep 26, 2026

    @slkiser

    Another use case: opencode-quota's /quota on Web/Desktop. A plugin command can post a synthetic message with resume: 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.

  13. added a commit that references this issue on Oct 17, 2026
  14. blockspacer commented on Oct 18, 2026

    @blockspacer

    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.

  15. opencode-agent commented on Oct 18, 2026

    @opencode-agent
    Contributor

    In V2 both parts of this are supported. Server plugins register commands through ctx.command.transform, and the command's execute decides whether to prompt the model at all, so control commands can complete without a model turn. TUI plugins can add palette and slash commands with context.keymap.layer and show select, confirm, prompt or custom dialogs through context.ui.dialog (see the plugin docs).

    Closing as completed. If something is still missing on V2, please open a new issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions