Skip to content

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

@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 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

References

Metadata

Metadata

Assignees

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