-
Notifications
You must be signed in to change notification settings - Fork 5.7k
Description
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:
- Plugins cannot emit events to clients — they can only receive events via the
eventhook - Each UI-driving feature requires per-client implementation — there's no shared primitive set
- 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.updatedevent) - 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:
- TUI-only — Desktop/Web clients hang
- Not extensible — each new UI need requires new protocol additions
- 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
- feat(new tool):Adding a new tool to opencode -> askquestion tool #5958 — AskQuestion wizard implementation (TUI-only)
- Ask Tool using Dialog TUI #5563 — Ask tool dialog implementation (TUI-only)
- [FEATURE]: Plan mode questions like claude code #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