[FEATURE]: Plugin Hook for Instant TUI Commands #3437

Open
opened 2026-02-16 17:40:04 -05:00 by yindo · 16 comments
Owner

Originally created by @malhashemi on GitHub (Dec 9, 2025).

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:

  1. TUI Commands (instant) - Internal commands like session.new, session.share, agent.cycle that execute immediately via keyboard shortcuts or command palette
  2. 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:

  1. Be defined as a slash command
  2. Go through an agent that processes the template
  3. Agent calls a tool the plugin provides
  4. 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

  1. Plugin Loader (packages/opencode/src/plugin/index.ts):

    • Collect command hooks from all plugins
    • Expose via a new API endpoint or event
  2. 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(),
        })),
      }),
    )
    
  3. TUI Integration (packages/opencode/src/cli/cmd/tui/context/sync.tsx or similar):

    • Subscribe to plugin.commands.updated event
    • Register commands via useCommandDialog().register()
    • On command select, call back to server to execute plugin callback
  4. 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:

  1. Validate the use case - Is this something other plugin authors would benefit from?
  2. Get feedback on the API design - Does the proposed command hook interface make sense?
  3. 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
Originally created by @malhashemi on GitHub (Dec 9, 2025). ### 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: 1. **TUI Commands** (instant) - Internal commands like `session.new`, `session.share`, `agent.cycle` that execute immediately via keyboard shortcuts or command palette 2. **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: 1. Be defined as a slash command 2. Go through an agent that processes the template 3. Agent calls a tool the plugin provides 4. 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`) ```typescript // 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`) ```typescript 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: ```typescript // 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 ```typescript 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 1. **Plugin Loader** (`packages/opencode/src/plugin/index.ts`): - Collect `command` hooks from all plugins - Expose via a new API endpoint or event 2. **New Event** (`packages/opencode/src/cli/cmd/tui/event.ts`): ```typescript 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(), })), }), ) ``` 3. **TUI Integration** (`packages/opencode/src/cli/cmd/tui/context/sync.tsx` or similar): - Subscribe to `plugin.commands.updated` event - Register commands via `useCommandDialog().register()` - On command select, call back to server to execute plugin callback 4. **Server Endpoint** for command execution: ```typescript 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 ```markdown # /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: 1. **Validate the use case** - Is this something other plugin authors would benefit from? 2. **Get feedback on the API design** - Does the proposed `command` hook interface make sense? 3. **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`
yindo added the discussion label 2026-02-16 17:40:04 -05:00
Author
Owner

@github-actions[bot] commented on GitHub (Dec 9, 2025):

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.

@github-actions[bot] commented on GitHub (Dec 9, 2025): 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.
Author
Owner

@malhashemi commented on GitHub (Dec 9, 2025):

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

@malhashemi commented on GitHub (Dec 9, 2025): > 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
Author
Owner

@denisdifazio commented on GitHub (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 /usage command 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.

@denisdifazio commented on GitHub (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 `/usage` command 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.
Author
Owner

@malhashemi commented on GitHub (Dec 11, 2025):

@rekram1-node

Permission to make a PR once you are happy with the technical proposal

@malhashemi commented on GitHub (Dec 11, 2025): @rekram1-node Permission to make a PR once you are happy with the technical proposal
Author
Owner

@malhashemi commented on GitHub (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

@malhashemi commented on GitHub (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
Author
Owner

@rekram1-node commented on GitHub (Dec 18, 2025):

Sorry didn't see messages reading this now

@rekram1-node commented on GitHub (Dec 18, 2025): Sorry didn't see messages reading this now
Author
Owner

@rekram1-node commented on GitHub (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 commented on GitHub (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
Author
Owner

@rekram1-node commented on GitHub (Dec 18, 2025):

Lmk if im following or if i misunderstood

@rekram1-node commented on GitHub (Dec 18, 2025): Lmk if im following or if i misunderstood
Author
Owner

@malhashemi commented on GitHub (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 *.ts files to the command folder, 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.

@malhashemi commented on GitHub (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 `*.ts` files to the `command` folder, 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.
Author
Owner

@rekram1-node commented on GitHub (Dec 18, 2025):

Yeah I think as plugins is prolly easiest idk tho

@rekram1-node commented on GitHub (Dec 18, 2025): Yeah I think as plugins is prolly easiest idk tho
Author
Owner

@rekram1-node commented on GitHub (Dec 18, 2025):

I think someone actually started this work

@rekram1-node commented on GitHub (Dec 18, 2025): I think someone actually started this work
Author
Owner

@rekram1-node commented on GitHub (Dec 18, 2025):

@spoons-and-mirrors started some work here:
https://github.com/sst/opencode/pull/4411

@rekram1-node commented on GitHub (Dec 18, 2025): @spoons-and-mirrors started some work here: https://github.com/sst/opencode/pull/4411
Author
Owner

@malhashemi commented on GitHub (Dec 18, 2025):

Great! I will jump in on that draft one!

@malhashemi commented on GitHub (Dec 18, 2025): Great! I will jump in on that draft one!
Author
Owner

@spoons-and-mirrors commented on GitHub (Dec 18, 2025):

I'm on opencode's discord @spoon if you want to discuss that PR @malhashemi

@spoons-and-mirrors commented on GitHub (Dec 18, 2025): I'm on opencode's discord `@spoon` if you want to discuss that PR @malhashemi
Author
Owner

@PhilippPolterauer commented on GitHub (Jan 11, 2026):

i would really love seeing one of these plugin command PRs merged!

@PhilippPolterauer commented on GitHub (Jan 11, 2026): i would really love seeing one of these plugin command PRs merged!
Author
Owner

@rgodha24 commented on GitHub (Jan 14, 2026):

+1

I can def work on an implementation too based on the existing PRs

@rgodha24 commented on GitHub (Jan 14, 2026): +1 I can def work on an implementation too based on the existing PRs
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: anomalyco/opencode#3437