Skip to content

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

@malhashemi

Description

@malhashemi

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    discussionUsed for feature requests, proposals, ideas, etc. Open discussion

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions