Standard questions: options and written answers

Problem and boundary

An agent often needs a person’s answer to a few questions, each with a short list of suggestions, while leaving room for something the list did not anticipate. Requiring a Provider, an HTML page, and button bindings for that interaction makes a small question unnecessarily expensive. afui ask owns this generic interaction: background context, one to eight questions, one answer per question, and one terminal result.

This is an AFUI-owned page, not a renderer for arbitrary domain data. Custom Provider pages continue to use the existing Router/session APIs. The page does not execute commands from answers, render Markdown or HTML, or offer a general form schema.

Invocation

One question:

afui ask --question 'What should happen next?' \
  --options '[{"id":"retry","label":"Retry","description":"Run the operation again","recommended":true},{"id":"details","label":"Inspect details"},{"id":"stop","label":"Stop"}]'

Several questions on one page, with background context:

afui ask \
  --context '[{"type":"paragraph","text":"The review found two issues."},{"type":"list","items":["README lists feature details","The session subject is the whole question"]}]' \
  --questions '[{"id":"scope","question":"Which should be fixed?","multiple":true,"options":[{"id":"readme","label":"README"},{"id":"subject","label":"Subject"}]},{"id":"when","question":"When to release?","allow_custom":false,"options":[{"id":"now","label":"Now"},{"id":"later","label":"Later"}]}]'

An option has a stable id, a human label, an optional description, and an optional recommended flag. A question in --questions has an id, the question text, its options, optional multiple (allow several choices) and optional allow_custom (default true). The single-question form expresses the last two as --multiple and --no-custom, and its question’s ID is question. Context blocks are paragraph {text}, list {items}, and code {text}.

--mode window|link|session uses the existing delivery resolver, including AFUI_DELIVERY. Link delivery uses AFUI’s existing attention policy.

The request is validated before opening a window or issuing a credential:

ItemBound
questions1–8, unique IDs
question textnon-whitespace, at most 8 KiB
options per question1–32, unique IDs within the question, at most one recommended
IDs1–64 ASCII letters, digits, underscores, or hyphens
option label / descriptionnon-whitespace label up to 1 KiB; description up to 4 KiB
contextat most 32 blocks; paragraph and code up to 8 KiB; lists of 1–32 items up to 1 KiB
written answer or noteat most 16 KiB each

Unknown fields and unknown block types are rejected. A malformed JSON argument is reported with the flag, the line and column, and the field name when a field is unknown, missing, or duplicated; values are never echoed.

Interaction

The page shows the context, then each question with its options. Single-choice questions use radio buttons; multiple questions use checkboxes. A recommended option carries a badge but is never preselected: nothing is preselected and there is no default answer.

When a question allows the person’s own words, each kind of text sits inside the card it belongs to:

Nothing is open by default: a card’s text box appears only for a chosen card, so the page reads as a list of answers rather than a form to fill.

Submit stays disabled until every question has a choice or written answer, so selection is reviewable before submission. Cancel precedes Submit in source and focus order. The page uses native form controls, AFUI’s baseline stylesheet and theme variables, responsive layout, visible focus, and live connection/error status. All request strings are rendered as text, never HTML. English and Simplified Chinese page controls follow the browser language; supplied content is unchanged. When framed, the page participates in afui.frame header negotiation.

While disconnected or submitting, answering is disabled. Runtime validation failures remain on the page and allow correction; reconnect keeps the current draft. Reloading starts a new draft, never a fabricated answer.

Result contract

The readiness progress event includes phase: "ask_ready", the session ID and resolved delivery facts. It does not include the questions or a private access URL. Only Link delivery intentionally publishes its handoff URL using the existing readiness helper. Default finite output sends progress to stderr and the one terminal result to stdout.

An answered request returns one answer per question, in question order:

{"kind":"result","result":{"code":"ask_answered","answers":[
  {"question_id":"scope","selected":[
    {"option_id":"readme","label":"README","note":"Keep the link"},
    {"option_id":"subject","label":"Subject"}
  ],"text":"Also check the skill"},
  {"question_id":"when","selected":[],"text":"After the next gate run"}
]},"trace":{}}

selected lists the chosen options with server-resolved labels; note is the person’s note on that choice. text is a written answer of their own. A single-choice question has exactly one of a selection or text; a multiple-choice question may have both. Text is preserved exactly as submitted, and absent when the person wrote nothing or only whitespace.

Explicit Cancel returns ask_cancelled; closing a window or ending the session returns ask_closed; attention expiry returns ask_expired. These have no answers field. All are successful interactions (exit 0), but only ask_answered supplies answers. Setup, validation, and transport failures return structured errors and exit 1; CLI grammar errors retain exit 2.

Waiting without holding a command open

afui ask blocks until the question ends. A caller whose commands have a time limit uses --detach instead: the question runs in a background process, and the command returns as soon as it is ready:

{"kind":"result","result":{"code":"ask_started","mode":"session","session_id":"4181c256…"},"trace":{}}

afui ask wait <session_id> then returns the same terminal result afui ask would have printed. With --timeout-s N it stops after N seconds and reports that the question is still open:

{"kind":"result","result":{"code":"ask_pending","session_id":"4181c256…","waiting_s":312},"trace":{}}

waiting_s is how long the question has been open, so the caller can decide when to remind the person through its own channel. AFUI does not send reminders itself: it does not know where the person is.

A result is collected once: the wait that returns it removes it. A later wait, or a wait for a question whose process ended without recording a result, returns ask_not_found. The background process records its ending in an owner-only directory beside the session registry, so --detach is refused when AFUI_NO_REGISTRY keeps questions out of that registry. The background process leaves the caller’s process group, so a caller that kills its command on timeout does not take the question with it.

The registry subject of a question is the first line of its first question, shortened to 120 characters, so a session list stays one line per session.

Implementation

ask is a session-tier module with typed AskRequest, AskQuestion, AskOption, AskBlock, AskAction, AskReply, AskChoice, AskResponse, AskAnswer, and AskSelection values, plus a start/wait API. It composes UiSession, UiDeliveryPlan, nonce CSP, and the existing runtime. The opening retained state carries the validated request. The trusted page script uses afui.connect(), afui.connection() and afui.status(); no second transport or background server is introduced. The built-in page has its own small stylesheet; the shared baseline remains domain-agnostic. The page is fixed rather than a new frontend-override contract.

The wait loop handles runtime calls and delivery termination together, so there is no detached worker whose failure leaves a window waiting indefinitely. The server validates that every question is answered exactly once, each chosen ID belongs to its question, a single-choice question has one choice or one written answer, notes and written text are allowed and bounded, and that cancellation is explicit. Completion uses AFUI’s existing single-winner completion state. Before ending the session, the final call reply gets a bounded acknowledgement window (250 ms); this prevents normal teardown from overtaking that reply, without letting a disconnected surface hold the question open. Dropping or ending a delivery revokes access and removes its registry entry.

The same standard page is available to a Rust caller without invoking a CLI:

use agent_first_ui::{UiDeliveryPlan, ask::{AskOption, AskQuestion, AskRequest}};

# async fn example() -> agent_first_ui::Result<()> {
let active = AskRequest::single(AskQuestion {
    id: "next".into(),
    question: "What should happen next?".into(),
    options: vec![AskOption {
        id: "retry".into(),
        label: "Retry".into(),
        description: None,
        recommended: true,
    }],
    multiple: false,
    allow_custom: true,
})
.start(UiDeliveryPlan::resolve(None)?)
.await?;
// Report readiness using active.facts() before waiting if needed.
let outcome = active.wait().await?;
# let _ = outcome;
# Ok(())
# }

--detach and afui ask wait are CLI behaviour: a Rust caller already owns its process and can hold ActiveAsk for as long as it likes.

Verification

Use scripts/test.sh as the fixed entrypoint. Cover request validation, single and multiple choices, notes and written answers through the real runtime, several questions on one page, invalid/forged submissions and retry, cancellation, external close, detach/wait including a timeout and single collection, CLI output/routing, and registry cleanup. The real-browser smoke test exercises native selection, notes, written answers, context blocks, several questions, disabled states and narrow-screen layout.