Skip to content

Render ACP elicitation forms - #797

Open
OSadovy wants to merge 4 commits into
xenodium:mainfrom
OSadovy:elicitation-forms
Open

Render ACP elicitation forms#797
OSadovy wants to merge 4 commits into
xenodium:mainfrom
OSadovy:elicitation-forms

Conversation

@OSadovy

@OSadovy OSadovy commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Implements #792.

The code is mostly AI-written. I've been using it daily since and have tested the behaviours below against a live agent, so I'll vouch for it working.

What it does

  • Advertises elicitation.form in initialize. url is not supported yet.
  • Renders elicitation/create as an in-buffer form covering every ElicitationPropertySchema variant, both spellings of each enum shape (oneOf/enum, items.anyOf/items.enum), and the forward-compat unknown type.
  • Validates required-presence on submit only; minLength, pattern, minimum, minItems and friends are not implemented yet - left for the agent to reject.
  • TAB walks the form as one more item kind beside prompts, blocks and permission buttons; RET acts. Named commands reading text properties rather than per-control closures (Expand/collapse group with TAB #759).
  • Reports blocked while a form is pending, so a queued prompt isn't steered into a turn that's waiting on an answer.
  • Cancels pending forms on interrupt, and arms the same idle timer an unanswered permission does.

Slightly opionated behaviour that isn't obvious

  • The free-text "Other" field folds into the select it belongs to. Agents bridging an ask-the-user tool send a select plus a companion text field, marked with an un-namespaced _meta key. Rendered as two independent fields, one question reads as two and can be answered twice in contradictory ways.
  • On a single-select the two replace each other; on a multi-select they combine into one string. Those agents read the companion first and return early, so a response carrying both silently loses the ticks - verified against the Claude adapter. AFAIK Zed is not doing it - we can do better here.
  • A checkbox answers only when ticked, required, or carrying a default. An unticked optional box is indistinguishable on screen from one nobody reached. Same rule Zed's form uses.
  • Integer fields reject a fraction rather than truncating it, and each retry is prefilled with the text that was rejected.
  • An elicitation with no tool call behind it - from an MCP server, or scoped to a request rather than a session - keeps its settled form, which is the only record of what was asked.
  • Option previews open from the option (?) or its disclosure glyph, and are offered as help-echo so C-h . reads them without opening. ? is bound only on options that actually carry a preview, so it stays free everywhere else - the viewport still opens its help menu with it. That _meta key is agent-namespaced, so previews are Claude-only and their absence changes nothing.

Incidental fixes

Happy to submit those as separate PRs.

  • agent-shell-ui--echo-action-hint takes a command and keymap, so chrome bound to its own map names its own key.
  • A fragment no longer erases the help-echo its body's content carried - it set the property to nil whenever debugging was off.

Not included

url mode; constraint validation; digit shortcuts; eldoc.

Where this differs from #792

I wrote the issue before building any of it, and a few things changed on the way.

  • Options aren't numbered and there are no digit shortcuts - the digits are already taken in the viewport map, and without them the numbering is just noise.
  • "Other" is a minibuffer read rather than an editable field inline in the buffer. An editable region inside otherwise read-only rendered text is a lot of machinery for one field, and the form re-renders on every interaction, which would fight it.
  • TAB stops on every control rather than jumping question to question.
  • A cancelled form shows Cancelled without preserving whatever had been typed into it.
  • No SVG radio/checkbox markers - text only for now.

One question

acp-make-initialize-request hardcodes clientCapabilities to the fs pair, so there's no way to advertise elicitation through it. I build the request locally instead (agent-shell--make-initialize-request), which does mean agent-shell no longer picks up future changes to that constructor. Would you rather acp.el grew a capabilities argument? Happy to send that first and keep using the helper here.

Checklist

  • I agree to communicate (PR description and comments) with the author myself (not AI-generated).
  • I've reviewed all code in PR myself and will vouch for its quality.
  • I've read and followed the Contributing guidelines.
  • I've filed a feature request/discussion for a new feature.
  • I'm making visual changes, so I'm including screenshots so you can view and discuss.
  • I've added tests where applicable.
  • I've updated documentation where necessary.
  • I've run M-x checkdoc and M-x byte-compile-file.

`agent-shell-ui--echo-action-hint' hardcoded `agent-shell-ui-toggle-fragment'
and `agent-shell-ui-fragment-map', so it could only ever describe folding.
Chrome bound to a different shared map wants the same "Press KEY to VERB"
hint pointing at its own binding.

Both arguments are optional and default to what was hardcoded, so existing
callers are unchanged.
Agents can ask a structured question mid-turn and wait for the answer.
agent-shell did not implement `elicitation/create', and because the
capability is opt-in per client, agents cannot tell it is merely absent:
the Claude adapter drops `AskUserQuestion' from the model's toolset
entirely, and auto-declines elicitations coming from MCP servers.

Advertising `elicitation.form' is a promise to render any form the schema
permits, so every `ElicitationPropertySchema' variant is covered: single-
and multi-select in both spellings, boolean, string, number, integer, and
the forward-compat unknown type.  What is deferred is constraint
validation, not coverage -- only a required-presence gate runs on submit,
without which we would send a response the schema forbids.  `url' mode is
a separate capability and is deliberately not advertised: an agent must
not send a mode the client did not ask for.

Form state lives in `agent-shell--state' under `:elicitations', keyed by
the JSON-RPC request id.  Rendered text carries identity only, read back
by named commands at point.  Fragment-local state would not do, since
`agent-shell--update-fragment' writes into both the shell buffer and the
viewport buffer, so a fragment's own state exists in two copies.

Agents bridging an ask-the-user tool onto elicitation send a select plus a
free-text "Other" field, marking the second as belonging to the first
through an un-namespaced `_meta' key.  Rendered as two independent fields,
one question reads as two and can be answered twice in contradictory ways,
so the companion folds into its select as one more option.  For a
single-select the two replace one another.  For a multi-select they
combine into one string, because those agents read the companion first
and return early: a response carrying both silently loses the ticks.

Such a bridge calls the tool with the questions as its input, which
agent-shell renders as a JSON dump for want of anything better, beside a
form saying the same thing.  A tool call carrying a questionnaire is now
recognised from that input: it reads as a question rather than the
catch-all "other" kind, says nothing while its form is open, and once
answered is left to speak for itself -- its own content already spells
the answers out in prose.  The form is removed at that point, so an
answer reads the same whether it was just given or replayed from a
restored session, which never brings the form back.  An elicitation with
no such tool call behind it, from an MCP server or scoped to a request,
keeps its settled form as the only record of what was asked.

Input is corrected rather than coerced: text that does not parse comes
back prefilled for editing, and an integer field refuses a fraction
rather than answering 1.9 with 1.  A pending form arms the same idle
timer an unanswered permission does, so subscribers hear about a question
left unattended.
A fragment tags itself with its qualified-id for debugging, and adds
nothing when that is off.  Nothing was what it failed to do: the tag
went on through `add-text-properties' either way, so with debugging off
it set `help-echo' to nil across the region rather than skipping it,
erasing any help the content there carried.

No body carried any until now, which is why it never showed.  A body
holds arbitrary rendered content, and `display-local-help' is the way
to read the help that content offers, so the property has to survive.

Debugging on still covers the body with the id, since surfacing
qualified-ids is what that mode is for.
An agent may attach a preview to an answer -- a mockup, a snippet, the
shape of what picking it would do -- so the user can see what they are
choosing before they choose it. Ignoring it left that unreachable.

The preview hangs off the option rather than becoming a control of its
own. A disclosure glyph, the same one every fold in the buffer uses,
marks an answer that has one; the glyph acts but is not navigable, so a
question offering a preview is still a single TAB stop rather than two.

Opening it is bound on the option as well as on the glyph, since
`end-of-line' lands after the glyph rather than on it, and RET past the
end of a line should go on meaning what it means everywhere else. The
preview is also offered as `help-echo', which `display-local-help' reads
out without opening anything, so it costs no keybinding of ours.

Which previews are open is kept in the elicitation beside the field
values, not in the rendered text, since the form is drawn into both the
shell buffer and the viewport and text-local state would exist there in
two copies that could disagree.

The `_meta' key carrying a preview is namespaced to the agent that
sends it, unlike the free-text companion marker which is deliberately
un-namespaced for use across agents. Previews are therefore Claude's
alone today; an option without one renders as it did before.
@OSadovy
OSadovy force-pushed the elicitation-forms branch from 4eb52fe to 587a0e8 Compare August 23, 2026 12:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant