opencode icon indicating copy to clipboard operation
opencode copied to clipboard

[FEATURE]: Generic UI Intent Channel for cross-client plugin-driven UX

Open malhashemi opened this issue 3 weeks ago • 14 comments

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 generic "UI intent" event type to the server-client protocol that allows the server and plugins to emit declarative UI specs (forms, selects, confirms, progress indicators) that all clients (TUI, Desktop, Web) can render uniformly—without requiring new core features for each use case.

Motivation

PRs #5958 and #5563 both implement Claude Code's AskUserQuestion tool, but both are TUI-only. When the tool is triggered from Desktop or Web clients, the session hangs indefinitely because those clients don't have renderers for the new UI.

This highlights a broader architectural gap:

  1. Plugins cannot emit events to clients — they can only receive events via the event hook
  2. Each UI-driving feature requires per-client implementation — there's no shared primitive set
  3. Core accumulates one-off features — instead of providing extensible infrastructure

The Permission system (packages/opencode/src/permission/index.ts:100-153) already demonstrates the correct pattern:

  • Server emits a declarative intent (permission.updated event)
  • Execution suspends via Promise
  • Client renders appropriate UI (permission prompt)
  • User responds via API (POST /session/:id/permissions/:id)
  • Promise resolves, execution continues

A generic UI intent channel would apply this same pattern to arbitrary UI interactions.

Proposed Solution

1. Define UI Intent Event Schema

// packages/opencode/src/ui-intent/index.ts
import { z } from "zod"
import { BusEvent } from "../bus/bus-event"

export const FormField = z.discriminatedUnion("type", [
  z.object({
    type: z.literal("select"),
    id: z.string(),
    label: z.string(),
    description: z.string().optional(),
    options: z.array(
      z.object({
        value: z.string(),
        label: z.string(),
        description: z.string().optional(),
      }),
    ),
    default: z.string().optional(),
  }),
  z.object({
    type: z.literal("multiselect"),
    id: z.string(),
    label: z.string(),
    description: z.string().optional(),
    options: z.array(
      z.object({
        value: z.string(),
        label: z.string(),
        description: z.string().optional(),
      }),
    ),
    default: z.array(z.string()).optional(),
  }),
  z.object({
    type: z.literal("confirm"),
    id: z.string(),
    label: z.string(),
    description: z.string().optional(),
    default: z.boolean().optional(),
  }),
  z.object({
    type: z.literal("text"),
    id: z.string(),
    label: z.string(),
    description: z.string().optional(),
    placeholder: z.string().optional(),
    default: z.string().optional(),
  }),
])

export const UIIntent = z.object({
  id: z.string(),
  sessionID: z.string(),
  messageID: z.string(),
  callID: z.string().optional(),
  source: z.enum(["core", "plugin"]),
  plugin: z.string().optional(),

  intent: z.discriminatedUnion("type", [
    z.object({
      type: z.literal("form"),
      title: z.string(),
      description: z.string().optional(),
      fields: z.array(FormField).min(1).max(10),
      submitLabel: z.string().default("Submit"),
      cancelLabel: z.string().default("Cancel"),
    }),
    z.object({
      type: z.literal("confirm"),
      title: z.string(),
      message: z.string(),
      confirmLabel: z.string().default("Confirm"),
      cancelLabel: z.string().default("Cancel"),
      variant: z.enum(["info", "warning", "danger"]).default("info"),
    }),
    z.object({
      type: z.literal("progress"),
      title: z.string(),
      message: z.string().optional(),
      value: z.number().min(0).max(100).optional(),
      indeterminate: z.boolean().default(false),
      cancellable: z.boolean().default(false),
    }),
    z.object({
      type: z.literal("toast"),
      message: z.string(),
      variant: z.enum(["info", "success", "warning", "error"]),
      duration: z.number().default(5000),
    }),
  ]),
})

export const UIIntentResponse = z.object({
  intentID: z.string(),
  sessionID: z.string(),
  response: z.union([
    z.object({ type: z.literal("submit"), data: z.record(z.string(), z.any()) }),
    z.object({ type: z.literal("cancel") }),
  ]),
})

export const Event = {
  Request: BusEvent.define("ui.intent.request", UIIntent),
  Response: BusEvent.define("ui.intent.response", UIIntentResponse),
}

2. Add Server API Endpoint

// packages/opencode/src/server/server.ts
.post("/session/:sessionID/ui-intent/:intentID", async (c) => {
  const { sessionID, intentID } = c.req.param()
  const body = UIIntentResponse.parse(await c.req.json())
  await UIIntent.respond({ sessionID, intentID, response: body.response })
  return c.json({ success: true })
})

3. Expose to Plugins

// packages/plugin/src/index.ts
export type PluginInput = {
  // ... existing fields
  ui: {
    form: (input: FormIntent) => Promise<Record<string, any>>
    confirm: (input: ConfirmIntent) => Promise<boolean>
    toast: (input: ToastIntent) => void
  }
}

4. Implement Client Renderers

Each client (TUI, Desktop, Web) implements renderers for the primitive set:

Primitive TUI Desktop/Web
form Dialog with fields (port from #5958/#5563) Modal form
confirm Yes/No dialog Confirmation modal
select Selection list Dropdown/radio group
multiselect Checkbox list Checkbox group
progress Progress bar Progress indicator
toast Toast notification Toast notification

Use Cases Enabled

Use Case Intent Type Description
AskUserQuestion form Claude Code-style structured questions
Plugin setup form OAuth flows, API key entry, configuration
Destructive confirmation confirm "Delete 47 files?"
Choice selection form with select "Which database?"
Feature selection form with multiselect "Select features to enable"
Long operation progress Cancellable progress for migrations
Notifications toast Non-blocking status updates

Design Decisions

Why not just merge #5958 or #5563?

Both PRs solve the immediate problem but:

  1. TUI-only — Desktop/Web clients hang
  2. Not extensible — each new UI need requires new protocol additions
  3. Core-only — plugins can't drive UX

Why use the Permission pattern?

It already solves:

  • Execution suspension via Promise
  • Client notification via Bus events
  • Response routing via API + pending map
  • Multi-client safety (only one responds)

Why declarative schemas instead of arbitrary UI?

  • Security: Plugins can't inject arbitrary code/markup
  • Consistency: All clients render the same primitives
  • Validation: Zod schemas enforce constraints at runtime
  • Theming: Clients control presentation, intents control content

Related

  • #5958 — AskQuestion wizard implementation (TUI-only)
  • #5563 — Ask tool dialog implementation (TUI-only)
  • #3844 — Original request for AskUserQuestion

References

  • Claude Code AskUserQuestion schema: https://gist.github.com/bgauryy/0cdb9aa337d01ae5bd0c803943aa36bd
  • Permission system: packages/opencode/src/permission/index.ts:100-153
  • Bus events: packages/opencode/src/bus/index.ts:41-64
  • Plugin hooks: packages/plugin/src/index.ts:145-209

malhashemi avatar Dec 29 '25 02:12 malhashemi

This issue might be a duplicate of existing issues. Please check:

  • #5147: [FEATURE]: Plugin Support for Modal Human-in-the-loop Interactions — Requests similar plugin-driven UI capability with modal dialogs for human-in-the-loop interactions
  • #5148: [FEATURE]: Comprehensive Plugin Pipeline - Middleware-Style Data Flow Control — Addresses the broader architectural need for plugins to emit events and drive UX via middleware patterns

These issues share the same core goal: enabling plugins to drive UI interactions and get user responses back to the pipeline, similar to the Permission system pattern you've outlined.

Feel free to ignore if this feature request addresses a different architectural approach.

github-actions[bot] avatar Dec 29 '25 02:12 github-actions[bot]

@rekram1-node Any thoughts on this one? Would be willing to put in the work for a PR if you agree with the approach

malhashemi avatar Dec 30 '25 08:12 malhashemi

hey @malhashemi i took a stab at this because i wanted to get the askuserquestion tool in opencode. the changes in the fork are all related to exposing (ui) intents to plugins, and i developed the askuserquestion tool as a plugin with a separate companion skill for guidance on asking good questions depending on context.

heres the PR draft if you want to have a look. i have tested it locally with the plugin tool.

the-vampiire avatar Dec 31 '25 21:12 the-vampiire

For ui plugins the team wants to make some decisions before we start implementing these things. @thdxr knows best here

rekram1-node avatar Dec 31 '25 22:12 rekram1-node

@rekram1-node I have figured that out as well, that's why I haven't gone straight with a PR, wanted to get some input from @thdxr first on the direction you would like to take for this one.

malhashemi avatar Dec 31 '25 22:12 malhashemi

hey @malhashemi i took a stab at this because i wanted to get the askuserquestion tool in opencode. the changes in the fork are all related to exposing (ui) intents to plugins, and i developed the askuserquestion tool as a plugin with a separate companion skill for guidance on asking good questions depending on context.

heres the PR draft if you want to have a look. i have tested it locally with the plugin tool.

Thanks @the-vampiire !!!

Cool approach, however for such a core feature better to get a feedback from the devs first.

malhashemi avatar Dec 31 '25 22:12 malhashemi

understood, i got overzealous going down a rabbit hole to get the ask user question tool working.

i'm nowhere near dax's architectural ability, but i did put thought into the design and adapting to the existing opencode patterns so it feels native instead of bolted on.

@malhashemi your proposal was instrumental, really good thinking there.

hopefully it will help or give something tangible to feel. but no worries at all if you want to close it and go a different direction.

i'll keep the fork running locally until we get the official design. having that interactivity has already opened up a lot of new workflows. i'm stoked to see what you all decide on. if there's anything i can help with let me know.

happy new years guys, be safe.

the-vampiire avatar Dec 31 '25 23:12 the-vampiire

Thanks @the-vampiire ,

I agree that the ask questions tool is getting requested a lot by the community, and I really hope we get a similar result to what this issue is proposing regardless of the implementation.

OpenCode plugins are already very powerful but they would be way more useful if they are allowed to render their own UI somehow. I am sure @thdxr already thought about that and might have it on the agenda at a certain point.

Happy new year everyone!!! Can't imagine what 2026 holds for OpenCode!

malhashemi avatar Dec 31 '25 23:12 malhashemi

How does this allow for things like changing the form definition when I select something in the drop-down?

Would that be an ever growing list of things that need to be filled into the response schema?

How does this approach allow me to create my own UI paradigms?

List of things I'm keen on creating/using

Side drawers

Buttons with custom label divisions

Status bar

Tables with draggable cards

Maybe I don't know enough about how the internals already render and maybe we're already painting ourselves into a corner with a technical debit credit card so large it's getting harder to scale it of...

But I had hoped that my plugins would be able to just render their own react/solid components.

I imagined a registry service that a plugin would be required to give a component for each mode web/tui/text.

airtonix avatar Jan 01 '26 16:01 airtonix

Thank you @malhashemi for starting this discussion. I think it’s an important one for the plugin ecosystem in OpenCode.

I want to clarify what I see as two separate needs that are currently being conflated:

  1. HITL (Human-in-the-loop) primitives
    Plugins need a way to pause execution and solicit structured input from the user, such as confirm or deny, select from options, or provide a value. This is what the current proposal is addressing: a protocol-level intent that clients can render and then resolve back to the plugin.

  2. Custom UI rendering
    Plugins rendering their own persistent or complex UI, such as custom React or Solid components, drawers, tables, status bars, and similar surfaces.

These are very different scopes.

The first is fundamentally a protocol problem: emit a declarative intent, suspend execution, and receive a response. This mirrors how the existing permission prompt works and is compatible across clients.

The second is effectively a full component system with client-specific implementation complexity, lifecycle concerns, and significant architectural implications. It's supporting "the whole world" vs just a subsection of it.

I’m currently building an orchestration plugin that depends on the first capability. I need supervised agent checkpoints where execution must pause, show the user what is about to happen, and wait for approval/feedback before continuing. At the moment, plugins can return messages, but they cannot block and wait for structured user input. The primitives proposed here would solve that directly.

My preference would be to ship the HITL primitives first. Custom UI rendering is a much larger architectural decision and I would prefer that it not block the immediate need for structured suspend-and-resume interactions. These primitives also feel like the natural building blocks that a future custom UI system would build on top of.

That said, I’m open to @thdxr’s perspective on whether splitting these concerns introduces tech debt or whether these primitives are a solid enough foundation for future extension.

eXamadeus avatar Jan 03 '26 08:01 eXamadeus

@eXamadeus i agree on separating those scopes and how the getting the HITL / intent and UI primitives makes sense to have in place before the much broader task of generic UI extensions.

it also makes sense to narrow the scope to just the server foundation (client intents and two way communication) and TUI client UI primitives. the TUI is the original and primary client. it's simpler to develop and reason about compared to web that has more UI/UX nuance. getting out for the TUI can give time to iterate and inform the web design.

the-vampiire avatar Jan 04 '26 02:01 the-vampiire

hey guys, ask question is built in now!

after looking over the implementation, they chose to go in a different direction.

i still think this is a great idea and would open up a lot of possibilities for plugins and future enhancements. but i think it's unlikely this foundation will be added as it would take significant refactoring to integrate now so i've closed the PR.

the-vampiire avatar Jan 10 '26 16:01 the-vampiire

Now I guess the major hurdle is just the custom UI, since (I haven't fully tested it yet) the question tooling seems to fill in the HITL primitive niche nicely.

eXamadeus avatar Jan 12 '26 15:01 eXamadeus

I took a swing at exposing the question.ask capabilities via API/SDK here: #8404

The goal is to allow plugins to call into this new native question tooling once the SDK is upgraded to v2 #7641, #7147

eXamadeus avatar Jan 14 '26 08:01 eXamadeus

So, I have a few PRs up that will enable this functionality, but here is a teaser:

Image

That is an HITL blocking response from a plugin!

The PR I mentioned earlier #8404 exposes the question.ask functionality through the SDK and #8380 allows plugins to use the new (v2) SDK. I was able to get this working off my local fork 😄

eXamadeus avatar Jan 18 '26 02:01 eXamadeus