-
Notifications
You must be signed in to change notification settings - Fork 5.6k
Add Intent abstraction for two-way server (and plugin) → client UI interactions #6549
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
Documentation AddedAdded documentation for the Intent system to the website docs: New Files
Updated Files
Docs build successfully ✅ |
eXamadeus
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Overall this looks great. I'd love to see some version of this make it into the upstream!
- Add Intent namespace with form, confirm, select, multiselect, toast primitives - Add IntentInfo and IntentResponse schemas for request/response cycle - Add server endpoints: POST /session/:id/intent/:id, GET /intent - Add Bus events: intent.updated, intent.replied - Add convenience helpers: Intent.form(), Intent.confirm(), Intent.select(), etc. - Add timeout and cancellation support - Add unit tests covering all intent types and edge cases
- Add dialog-intent.tsx with confirm, select, multiselect, form renderers - Wire Intent.Event.Updated subscription in app.tsx - Toast intents use existing toast system - Fix Zod schema types to support optional fields with defaults - Add z.input<> types for function parameters
- Regenerate SDK with intent.respond() and intent.list() methods - Add UIHelpers type to PluginInput (form, confirm, select, multiselect, toast) - Wire up plugin loader to provide UI helpers via Intent module - Update dialog-intent component with improved field handling
… design documentation - Add evt.preventDefault() to all keyboard handlers to prevent event leaking - Change form submit from ctrl+enter to enter - Convert select/multiselect field renderers to vertical layout - Add keyboard navigation (up/down) to form field renderers - Add space to toggle for multiselect field renderer - Auto-focus text inputs when they become visible (conditional fields) - Support array values in condition checks for multiselect - Document design decision to keep dialog-intent separate from dialog-select
Co-locate type casts with Switch/Match guards following the pattern established in session/index.tsx (UserMessage/AssistantMessage). Child components now receive narrowed types directly, removing redundant inline type assertions.
Follow existing pattern (ProjectRoute, TuiRoute) by extracting intent endpoints to separate Hono instance. Fixes TypeScript type chain depth limit exceeded error. API change: /session/:sessionID/intent/:intentID -> /intent/:sessionID/:intentID
8fa8d96 to
93206b6
Compare
Critical fixes: - dialog-intent.tsx: Fix API URL from /session/x/intent/y to /intent/x/y (was calling wrong endpoint, would have failed at runtime) - intent.test.ts: Flatten test structure to match canonical next.test.ts (codebase uses flat test() calls, not nested describe blocks) Standard fixes: - server/intent.ts: Split hono-openapi imports per project.ts pattern (describeRoute, validator on line 2; resolver on line 3) Verified via systematic comparison against: - permission/index.ts, permission/next.ts (core module patterns) - server/project.ts (route patterns) - test/permission/next.test.ts (test patterns) All tests pass (8/8), typecheck clean, formatter produces no changes.
|
i spent some extra time with claude to develop an idiomatic contribution guide. we used it to do a thorough review and align the PR with the code base. then we revisited it based on the experience to refine it. its out of scope so i didnt commmit it, but if you think its helpful i can add it for other contributors (here or in another PR). idioatic contributor guide# Idiomatic Contribution Guide Mental ModelYou are a guest in someone else's home. The existing codebase represents years of decisions, trade-offs, and evolved conventions. Every pattern you encounter—whether you personally agree with it or not—is the canonical standard. Your contribution must blend seamlessly, as if a core maintainer wrote it themselves. Core Principles
ApproachPhase 1: Establish BaselineBefore writing any code:
Phase 2: Implement with ConformanceWhile writing code:
Phase 3: Validate ConformanceBefore submitting:
Pattern DiscoveryFinding Canonical ReferencesMinimum 2 references required — A single reference can be an exception to the codebase norm. Multiple references reveal the actual pattern. Why 2+ References MatterA single reference can mislead you into following an exception rather than the rule. Example from real PR:
After checking more references:
The first reference was the exception, not the rule. When References ConflictIf canonical references show different patterns:
Example: "NamedError.create() used in 17 files (34 instances) vs class extends Error in 3 files (5 instances) → followed dominant pattern" Pattern Categories to Match
Status ClassificationsWhen analyzing conformance, use only these statuses:
There is no "acceptable deviation" — Either validate via multiple references that your approach IS the pattern, or fix it. Ambiguity = unresolved work. If you're tempted to mark something "acceptable" or "minor," you haven't done enough research. Find more references until you can definitively say conformant or drift. Issue SeverityNot all drift is equal. Prioritize fixes by severity:
Fix all tiers, but prioritize critical issues first. A stylistic fix means nothing if the code doesn't work. When Deviation May Be JustifiedDeviation from pattern is acceptable ONLY when:
If you can't quantify why you're different, you're not different enough. Example justification:
ExamplesGood: Following Route Pattern// Canonical reference: src/server/project.ts
export const ProjectRoute = new Hono()
.get("/", describeRoute({...}), async (c) => {...})
.patch("/:id", describeRoute({...}), validator(...), async (c) => {...})
// Your contribution: src/server/intent.ts
export const IntentRoute = new Hono()
.get("/", describeRoute({...}), async (c) => {...})
.post("/:id", describeRoute({...}), validator(...), async (c) => {...})Bad: Inventing New Patterns// Your contribution introduces different style
const intentRouter = new Hono() // Wrong: different naming convention
intentRouter.get(...) // Wrong: not chained
export default intentRouter // Wrong: default export vs namedGood: Type Narrowing at Match Site// Canonical reference: src/cli/cmd/tui/routes/session/index.tsx
<Match when={message.role === "user"}>
<UserMessage message={message as UserMessage} />
</Match>
// Your contribution follows same pattern
<Match when={intent.type === "confirm"}>
<ConfirmDialog intent={intent as ConfirmIntent} />
</Match>Do
Don't
ChecklistBefore marking PR ready for review:
SummaryYour goal: Write code that a maintainer would write. Your measure of success: A reviewer cannot distinguish your contribution from existing code. Your mindset: Grateful guest, not opinionated visitor. Your method: Multiple references, quantified decisions, zero ambiguity. |
|
@the-vampiire I was hoping your PR would find its way into the code. Is there a reason you've closed this PR? |
|
@arsham based on latest release yesterday, they have the question tool added. @thdxr took a different direction with it: this PR was an abstract foundation for intents that question or any other tools / plugins could build on. it's been 2 weeks since the discussion and PR, i don't think this is how they want to handle it. less so because it would be a big refactor to integrate now. |
feat: Add Intent abstraction for server→client UI interactions
Summary
This PR introduces a generic Intent abstraction that enables the server (tools, plugins, core) to request user input through the TUI. It provides composable UI primitives (form, confirm, select, multiselect, toast) following the existing Permission system's event-based pattern.
Closes: #6330 (partial — progress intent deferred)
Related: #5147, #5148, #5958, #5563
UI
I have tested it out with an
askuserquestiontool plugin. I'm happy to PR this into upstream if the team wants, but this PR is focused on the foundational primitives that @malhashemi proposed in #6330:They work with keyboard (arrows, space for multi-select, esc to cancel) or mouse input.
Motivation
Currently, there's no standardized way for tools or plugins to request user input during execution. PRs #5958 and #5563 implemented
askuserquestionbut were TUI-only and tightly coupled. This abstraction:uihelpers onPluginInputintent.respond()andintent.list()methodsWhat's Included vs. Issue #6330
formconfirmselectmultiselecttoastprogressWhy
progressis DeferredThe issue proposed a progress intent with:
Deferred because the semantics are unclear:
update()repeatedly?This likely needs a separate design discussion. The current intent system is request/response, not streaming.
Naming Differences from Issue
ui-intentmoduleintentmodule/session/:id/ui-intent/:id/session/:id/intent/:idui.intent.requesteventintent.updatedeventui.intent.responseeventintent.repliedeventAdditions Beyond Issue
Standalone
selectandmultiselectintents — Issue only proposed these as form fields. Added as top-level intents for simpler use cases.Conditional fields —
condition: { field, equals }on TextField enables the "select with Other option" pattern mentioned in the proposal.cancelAll(sessionID)— Utility to cancel all pending intents when a session ends.Scope Limitation: TUI Only
This PR implements TUI renderers only. Desktop and Web clients would need their own implementations of the intent dialog component. The core module and SDK are client-agnostic.
Changes
Core Intent Module (
packages/opencode/src/intent/)types.ts— Zod schemas for 5 intent types:form— Multi-field input form with conditional field visibilityconfirm— Yes/no dialog with info/warning/danger variantsselect— Single-choice selection (2-8 options)multiselect— Multi-choice selection with optional min/maxtoast— Non-blocking notificationindex.ts— State management and public API:Intent.request()— Create pending intent, returns PromiseIntent.respond()— Submit user responseIntent.list()— List pending intentsIntent.cancelAll()— Cancel all intents for a sessionIntent.form(),Intent.confirm(),Intent.select(),Intent.multiselect(),Intent.toast()Intent.Event.Updated,Intent.Event.RepliedServer Endpoints (
packages/opencode/src/server/server.ts)POST /session/:sessionID/intent/:intentID— Submit response to pending intentGET /intent— List all pending intentsTUI Integration (
packages/opencode/src/cli/cmd/tui/)component/dialog-intent.tsx— Full dialog component with renderers for all intent types:ConfirmDialog— Left/right arrow navigation, enter to confirmSelectDialog— Up/down navigation, enter to selectMultiSelectDialog— Space to toggle, enter to submitFormDialog— Tab between fields, enter to submitapp.tsx— Event subscription:Intent.Event.UpdatedDialogIntentfor blocking intentsSDK (
packages/sdk/)Regenerated with:
client.intent.respond()— Submit intent responseclient.intent.list()— List pending intentsPlugin API (
packages/plugin/src/index.ts)Added
UIHelperstype anduiproperty toPluginInput:Plugin Wiring (
packages/opencode/src/plugin/index.ts)UIHelpers implementation that wraps Intent module:
Usage Examples
Plugin requesting user confirmation
Tool with form input
Testing
Unit Tests
8 unit tests covering:
bun test packages/opencode/test/intent/intent.test.tsManual Testing
See
MANUAL_TESTING.mdfor step-by-step TUI testing instructions.Design Decisions
Following Permission pattern — Uses the same event-based architecture with pending state and promise resolution
Composable primitives — Form fields use the same schemas as standalone intents, enabling composition
Non-blocking toast — Toast returns immediately and doesn't create pending state
Session-scoped cleanup —
cancelAll()clears all pending intents when session endsSchema limits — Options capped at 2-8 items, fields at 1-10 to prevent UI overload
Optional
uion PluginInput — Plugins must checkif (ui)before using, as it may not be available in all contexts (headless mode, non-TUI clients)Separate
dialog-intentvs reusingdialog-select— Intentionally kept as separate components despite functional overlap. Analysis below.Modal dialog vs inline input replacement — Chose modal overlay rather than replacing the input area (Claude Code style). Analysis below.
Why Modal Dialog Instead of Inline Input Replacement
Claude Code replaces the input textarea with the question UI, keeping the user "in flow" with the conversation. We considered this but chose a modal dialog instead.
Approach Comparison
Why Modal Fits OpenCode
Consistent with existing UI — OpenCode already uses modals for:
Ctrl+K)Ctrl+P)Adding another modal is expected behavior. Inline replacement would be the outlier.
Supports all intent types — Modal naturally handles:
confirm— Yes/No buttonsselect— Option list with keyboard navmultiselect— Checkboxes with space to toggleform— Multiple fields with conditionsInline replacement works well for simple text input but becomes awkward for structured selection UI.
Clear escape hatch —
Esccloses modal and cancels the intent. With inline replacement, the semantics are muddier—does Esc restore the previous input? Cancel the whole operation? Users would need to learn new behavior.Implementation isolation — Modal is a new component (
dialog-intent.tsx) with no coupling to the input system. Inline replacement would require:Future Consideration
Inline replacement could be offered as an optional UX mode in a future PR if users prefer the Claude Code style. This would be additive—the modal implementation provides a working baseline, and inline could be added as
intent.display: "inline" | "modal"in config.This keeps the current PR focused on the Intent API and plugin integration, deferring UX experimentation to a separate effort.
Why dialog-intent is a Separate Component
We considered three options for the TUI dialog implementation:
dialog-intent.tsxwith its own Select/MultiSelectdialog-intentusesDialogSelectinternallyOptionListcomponent both useWe chose Option 1. Here's why:
Functional Overlap
dialog-selectdialog-intentRisk Assessment
Test coverage for TUI components: None
DialogSelect consumers (14 files):
Option 3 would require:
Recommendation for Future
If abstracting shared primitives is desired:
OptionList)This keeps the Intent PR focused, reviewable, and low-risk.
Future Work (Not in this PR)
progressprimitive — Needs design for streaming updatesChecklist