Choice widget — author cookbook
How to write choice: elements: when to reach for each mode, the patterns that recur across stories, and the gotchas that bite.
Companion documents:
kitsoki docs app-schema§choice:— the authoritative field reference and TUI keymap tables.docs/stories/story-style.md§3.6 — prescriptive prompt / label / hint conventions.testdata/apps/choice_smoke/— the canonical 23-spoke fixture; every snippet below has a matching spoke that runs end-to-end.
This guide is a how-to, not a schema reference. For "what fields does form mode accept?" head to the embedded schema doc.
Where the widget runs in production (live examples)
Every shipped story uses choice: somewhere; these are the best reading order for new authors:
| Pattern | Live in |
|---|---|
| Verdict picker (accept/refine/quit) | stories/robbery/app.yaml (canary) |
| Phase navigation (restart_from/jump_to) | stories/bugfix/rooms/proposing.yaml (et al.) |
| Single + optional refine param | every *_awaiting_reply state in cypilot / pr-refinement / implementation |
| Pre-bound enum fan-out | stories/oregon-trail/rooms/intro.yaml |
| Form mode (multi-field purchase) | stories/oregon-trail/rooms/general_store.yaml |
| Form mode (enum + int) | stories/oregon-trail/rooms/river_crossing.yaml |
| Dynamic-single-item (templated slot) | stories/oregon-trail/rooms/inbox.yaml |
| Dynamic-id capture (param + free text) | stories/code-review/rooms/list_pending.yaml |
| Free-text verb coexistence | stories/oregon-trail/rooms/general_store.yaml |
1. Picking a mode
flowchart TD
need{"Need user input?"}
options["One of N options"]
free["Free text<br/>no enumerable set"]
shape{"Input shape?"}
single["pick ONE from list<br/>single mode"]
multi["pick MANY from list<br/>multi mode"]
form["structured submission<br/>2+ fields<br/>form mode"]
param{"Need one free-form<br/>parameter alongside the pick?"}
singleParam["single + param"]
prose["Use prose hint<br/>+ semantic router"]
need --> options
need --> free --> prose
options --> shape
shape --> single --> param --> singleParam
shape --> multi
shape --> form
Rules of thumb:
- Use
choice:whenever the room'son:arc set is enumerable and stable. If you can list the available intents in a header comment, you can list them in achoice:. - Use
singlefor verdict picks (accept / refine / quit), action pickers (pay / fight / flee), and "pick an entry from this short list" patterns. Addparam:when one item needs a free-form arg (a name, a theme, a comment). - Use
multiwhen the dispatched slot is naturally a list: symptoms, traits, files to keep. The widget submits ONE intent with ONE list-valued slot. It is NOT a shorthand for "fire one intent per selected item." - Use
formwhen the user is composing a structured submission with ≥2 fields — purchase proposals, parameter sheets, anything where you'd otherwise write apropose_purchase items=… total_cost=…template synonym.
When NOT to reach for choice::
- Open-ended chat or composition rooms (Agent Room, free-form message bodies) — the user is meant to write prose, not pick.
- Background-job execution states where the user has no decision to make while a job runs.
- Hub rooms where the actions are orthogonal navigation rather than a ranked choice (case-by-case — some hubs do convert).
2. Patterns
Every snippet is runnable as-is against an app that declares the named intent and its slots. The fixture column links to a spoke in testdata/apps/choice_smoke/ that exercises the same shape end-to-end.
2.1 Confirm / cancel split
A two-item single-mode picker for accept / quit verdicts. The intent name is the label.
view:
- prose: "Apply the proposed fix?"
- choice:
items:
- { label: "accept", intent: accept, hint: "apply and continue" }
- { label: "quit", intent: quit, hint: "discard the proposal" }
Fixture: single_no_slots (testdata/apps/choice_smoke/app.yaml:357).
2.2 Verdict picker with a disabled row
Show the user why an option is unavailable without hiding it. Use two rows on the same intent with mutually-exclusive when: guards.
view:
- choice:
prompt: "How do you respond?"
items:
- label: "pay"
hint: "buy them off (${{ world.threat_level * 50 }})"
intent: pay
when: "world.party_money >= world.threat_level * 50"
- label: "✗ pay — not enough money (${{ world.threat_level * 50 }} needed)"
intent: pay
when: "world.party_money < world.threat_level * 50"
- { label: "fight", intent: fight }
- { label: "flee", intent: flee }
Fixture: single_per_item_when (line 394) and its _blocked variant.
2.3 Single pick with a free-form parameter
When one item needs a free-form arg — a theme, a name, a comment — attach a param: block. Picking the item with param: and an empty buffer enters param mode; the user types, Enter commits.
view:
- choice:
items:
- { label: "use defaults", intent: use_defaults }
- label: "set a custom theme"
intent: generate_names
param:
slot: theme
type: string
placeholder: "e.g. norse mythology"
required: true
Fixtures: single_param_string (line 529), single_param_int (line 568), single_param_enum (line 605).
Optional param — the "refine with optional comment" pattern. Set required: false when the slot is nice-to-have but not required. Common for refine rooms where an LLM judge has already filled in a reason and the user can either accept it or override with their own:
- label: "refine"
hint: "ask for changes (Enter to accept the judge's reason, or type your own)"
intent: refine
param:
slot: feedback
type: string
placeholder: "(optional — leave blank to use the judge's reason)"
required: false
Live in stories/bugfix/rooms/*.yaml and every *_awaiting_reply state in stories/cypilot/, stories/pr-refinement/, stories/implementation/.
2.4 Multi-pick survey
The user selects ≥0 of a fixed list; the widget submits one intent with one list-valued slot. min: / max: enforced by the widget.
view:
- choice:
mode: multi
prompt: "Select symptoms"
intent: report_symptoms
slot: symptoms
min: 1
max: 5
items:
- { value: fever, label: "Fever", hint: ">100.4°F" }
- { value: cough, label: "Cough" }
- { value: fatigue, label: "Fatigue" }
- { value: rash, label: "Rash", when: "world.day > 3" }
Fixtures: multi_basic (line 647), multi_min_zero (line 685), multi_no_max (line 722), multi_per_item_when (line 762).
2.5 Structured submission form
For composing a ≥2-field submission. Tab cycles fields; Enter submits one intent with one slot per editable field. Readonly fields with expr: are computed displays.
view:
- choice:
mode: form
prompt: "Compose your purchase"
intent: propose_purchase
template: "Buy {items} for ${total_cost}, leaving ${remaining}."
fields:
items:
type: string
placeholder: "oxen=4, food=1500"
required: true
total_cost:
type: int
min: 1
max: "{{ world.money }}"
default: 0
remaining:
type: int
expr: "world.money - world.total_cost"
readonly: true
Fixtures: form_basic (line 850), form_enum (line 970), form_required (line 1037), form_readonly_expr (line 1146).
Keep template: short. The static renderer (used by Bitbucket / Jira transports where viewport width is fixed) prints the template with {field} blanks substituted as authored — it does NOT reflow or re-wrap when the line exceeds the column. Long templates horizontally overflow on narrow surfaces. Split into multiple sentences or use shorter field labels if you're approaching ~80 columns.
2.6 Enum field with cycle-on-Space
Inside a form, type: enum renders as an inline [ford ▾] picker; Space cycles through values:.
fields:
method:
type: enum
values: [ford, caulk, ferry, wait]
default: ford
Fixture: form_enum (line 970).
2.7 Per-field guard (hide when unavailable)
A form field hidden by when: is not rendered AND not submitted. Use this when a slot becomes meaningful only after a prior choice / world bind.
fields:
secret:
type: string
when: "world.show_secret_field"
placeholder: "(only visible when the flag is set)"
Fixture: form_per_field_when (line 1107), with both _shown and _hidden flow variants.
2.8 Phase-navigation picker
When a room offers "restart from <earlier phase>" or "jump to <later phase>", make one row per target phase. The intent is the same on every row; only the slots: literal differs. Use when: to hide phases that aren't reachable from here.
view:
- choice:
prompt: "Verdict"
items:
- { label: "accept", intent: accept }
- label: "refine"
intent: refine
param: { slot: feedback, type: string, required: false }
- { label: "restart from reproducing", intent: restart_from, slots: { stage: reproducing } }
- { label: "restart from proposing", intent: restart_from, slots: { stage: proposing } }
- { label: "restart from implementing", intent: restart_from, slots: { stage: implementing },
when: "world.reached_implementing" }
- { label: "jump to validating", intent: jump_to, slots: { stage: validating },
when: "world.reached_implementing" }
- { label: "quit", intent: quit }
Why one widget instead of nested submenus: every verdict — accept, refine, restart, jump, quit — is dispatched the same way (one intent, one slot literal), so flattening keeps the dispatch path uniform across humans, the LLM judge, and flow tests.
Live in stories/bugfix/rooms/*.yaml (canonical) and several stories/cypilot/rooms/*.yaml rooms.
2.9 Pre-bound enum fan-out (one row per literal value)
When an intent declares an enum slot and the user picks from a small fixed set, render one row per literal. The slot is pre-bound; the user picks, the intent dispatches with a known-valid value. More legible than asking the user to remember the enum members.
view:
- choice:
prompt: "Profession"
items:
- { label: "Banker — $1600", intent: pick_profession, slots: { profession: banker } }
- { label: "Carpenter — $800", intent: pick_profession, slots: { profession: carpenter } }
- { label: "Farmer — $400", intent: pick_profession, slots: { profession: farmer } }
Compared to single + param: { type: enum }: the fan-out makes every option visible at once; the enum-param compresses to one row with a cycle-on-Space picker. Fan-out is right when you want side-by-side comparison (hints differ per option); the enum-param is right when the options are interchangeable noise (e.g. "pick a colour").
Live in stories/oregon-trail/rooms/intro.yaml (the canonical multi-pattern test: profession × 3, month × 5).
2.10 Coexisting with a free-text verb (one choice per view)
A view may declare exactly one choice:. When a room has two meaningful verbs — e.g. compose a purchase (form-heavy) and leave the store (one bare intent) — pick the dominant one for the widget and keep the other as a prose: hint that names the intent. Semantic routing still catches the free-typed verb.
view:
- choice:
mode: form
prompt: "Compose your purchase"
intent: propose_purchase
template: "Buy {items} for ${total_cost}."
fields: { ... }
- prose: "Or say `leave the store` to exit empty-handed."
The prose hint should name the verb (leave the store, cancel crossing) — the semantic router scores it more reliably than vague phrasing like "type to exit."
Live in stories/oregon-trail/rooms/general_store.yaml, stories/oregon-trail/rooms/fort.yaml, stories/oregon-trail/rooms/river_crossing.yaml (substates).
2.11a Multi-phase wizard (split a dump-everything room)
When a room would otherwise put more than ~8 actions on one screen, or when several decisions are sequential (each depends on the previous), split the room into a wizard. One state per decision. Use back to return one step, continue to advance, and edit_step (with an enum slot for which step) to jump around from a summary screen.
Skeleton:
states:
intro: # welcome
view:
- prose: "..."
- choice:
items:
- { label: "begin", intent: begin_setup }
- { label: "look", intent: look }
on:
begin_setup: [{ target: intro_step1 }]
look: [{ target: intro }]
intro_step1: # first decision
view:
- choice:
items:
- { label: "Option A", intent: pick_x, slots: { x: a } }
- { label: "Option B", intent: pick_x, slots: { x: b } }
- { label: "back", intent: back }
on:
pick_x: [{ target: intro_step2, effects: [ { set: { x: "{{ slots.x }}" } } ] }]
back: [{ target: intro }]
# ... intro_step2, intro_step3 ...
intro_summary: # final review + start
view:
- kv: { Choice X: "{{ world.x }}", ... }
- choice:
items:
- { label: "Start", intent: start }
- { label: "Change X", intent: edit_step, slots: { step: x } }
- { label: "Change Y", intent: edit_step, slots: { step: y } }
on:
start: [{ target: next_room }]
edit_step:
- when: "slots.step == 'x'"
target: intro_step1
- when: "slots.step == 'y'"
target: intro_step2
Live in: stories/oregon-trail/rooms/intro.yaml (5-state wizard: welcome → profession → month → naming → summary).
2.11b Multi-phase shop (method picker + dedicated form room)
Variant for rooms that would otherwise drop the user straight into an 8-field form. The first room is a single-mode picker that branches to a focused sub-room per method: browse / set-budget / compose form. Each branch is itself one focused decision.
idle:
view:
- choice:
items:
- { label: "Browse what's available", intent: browse }
- label: "Set a budget"
intent: propose_by_budget
param: { slot: total, type: int, placeholder: "e.g. 200", required: true }
- { label: "Compose item-by-item", intent: open_compose }
- { label: "Leave", intent: leave_store }
compose:
view:
- choice:
mode: form
intent: propose_kit
fields:
oxen: { type: int, default: 0, unit: "head ($40/yoke)" }
food: { type: int, default: 0, unit: "lbs ($0.20/lb)" }
# ... no `items` string field, no `total_cost` field —
# both are computed in the `on:` effect from the slot
# values × local prices.
Live in: stories/oregon-trail/rooms/general_store.yaml and stories/oregon-trail/rooms/fort.yaml.
2.11c Unit suffixes on numeric form fields
A bare 5 on an int field reads as a dangling number. Annotate each numeric field with a unit: so the value renders as 5 head / 1500 lbs / 20 boxes. The suffix renders in the muted hint style both in the live widget and in static (Jira/Bitbucket) output.
fields:
oxen:
type: int
default: 0
unit: "head ($40/yoke)"
food:
type: int
default: 0
unit: "lbs ($0.20/lb)"
duration:
type: int
default: 0
unit: "days"
The suffix is suppressed when the buffer is empty AND a placeholder: is showing (so you don't get awkward «0» head rendering).
Compute fields like total_cost server-side in the intent's on: effect rather than asking the user to enter them — the form should have only fields where the user's intent is non-derivable.
2.11 Dynamic single-item via templated pre-bound slot
choice: items are a static YAML list, but the slot values inside each item can be templated. When the "dynamic" list is in practice "one open job at a time" (an inbox with one pending review, a ticket-search with the most-recently-picked id), build a single row whose slots: references the world key and guard the row with when::
view:
- choice:
items:
- label: "answer clarification ({{ world.last_job_id }})"
intent: answer_clarification
slots: { job_id: "{{ world.last_job_id }}" }
param: { slot: answer, type: string, required: true }
when: "world.last_job_id != ''"
- { label: "back", intent: go_main }
For N-of-many dynamic picks, the v1 workaround is single + param: to capture the id at pick time. See §2.3's fixture single_param_string — the user types the id, the intent dispatches with that as the slot. Use this for pick_ticket id=<> / pick_review pr_id=<> patterns.
Live in stories/oregon-trail/rooms/inbox.yaml, stories/dev-story/rooms/inbox.yaml, stories/dev-story/rooms/ticket_search.yaml, stories/code-review/rooms/list_pending.yaml.
3. Gotchas
3.1 No choice inside blocks: (extends: views)
The typed view metadata is lost through the inheritance pipeline — the loader rejects the combination. Workaround: put the extends: at the wrapping view and the choice: as a sibling element in the same view:, not inside a block.
# WRONG — loader rejects.
view:
extends: "base"
blocks:
choices:
- choice: { ... } # ✗ choice cannot live inside a block
# RIGHT — choice is a sibling.
view:
- choice: { ... }
# (extends/elements coexistence is not implemented yet — put the
# extends: on the wrapping view and the choice: as a sibling element.)
3.2 Readonly form fields ARE submitted when the intent declares them
A readonly form field's value is computed via expr: at widget-open time. If the intent declares a matching slot, the computed value flows into the dispatch. If the intent does NOT declare the slot, the field is presentation-only. Read the flow fixture for form_readonly_expr (line 1146) to see the expected shape — it declares total as a required slot so the readonly value participates in the dispatch.
3.3 form.<other_field> is NOT live in expr: today
The readonly field's expr: is evaluated at widget open with world.* and slots.* only; sibling form-buffer changes do NOT re-trigger the eval. This means the readonly value freezes at widget open — typing in another form field will NOT update a readonly display.
GOTCHA — this looks plausible but does NOT live-update:
# WRONG — author expects `remaining` to track `total_cost` as the
# user types. It doesn't. `form.total_cost` isn't plumbed; even if
# it were, `expr:` only fires at open.
fields:
total_cost: { type: int, default: 0 }
remaining:
type: int
expr: "world.money - form.total_cost" # ✗ never re-evals
readonly: true
The §2.5 example happens to work because world.total_cost is already in world state (bound by an earlier room / effect) — the expr: reads world, not the live form buffer, and the snapshot taken at open is what the user sees for the whole session of that widget.
Workarounds today:
- Precompute into a world key. Bind the derived value via an effect before the widget opens (
on_enter:on the room, or the effect chain of whatever transition led here), then referenceworld.remainingin theexpr:. - Drop the readonly field. If it's purely cosmetic, surface the budget in a sibling
prose:element and let the user do the subtraction. - Re-open the widget. Submitting and re-entering the room re-evaluates
expr:against fresh world state — fine for multi-step composers, not for one-shot forms.
The proposal's example referenced form.total_cost; that key is not yet plumbed. Track in the proposal §8 if you need live cross- field eval.
3.4 when: on items is renderer-side, not behavioural
A guard-false item is hidden from the widget, but the underlying on: arc still fires if someone dispatches the intent another way (flow fixture, right-pane menu pick, kitsoki turn --intent ...). For true behavioural gating, also guard the transition's when::
view:
- choice:
items:
- label: "drink"
intent: drink
when: "world.canteen > 0" # hide row
on:
drink:
- target: drinking
when: "world.canteen > 0" # ALSO block the arc
- default: true
effects: [ { say: "Canteen is empty." } ]
3.5 Type matching: bool world keys from templated slot values
A world key with type: bool set from "{{ slots.x }}" will fail because template substitution always produces strings. Either declare the world key as type: string, or use an effect that coerces:
# WRONG — fails when slots.flag is the string "true"
world:
active: { type: bool, default: false }
effects:
- set: { active: "{{ slots.flag }}" }
# RIGHT — coerce via expr-lang
effects:
- set: { active: "{{ slots.flag == 'true' }}" }
The form_bool spoke (testdata/apps/choice_smoke/app.yaml:901) sidesteps this by storing the captured value as a string.
3.6 Multi-mode items[].value must be a literal
Templated values are rejected at load time — materialising a list of templated strings would defeat downstream type checks. If you need a world-derived option list, precompute it into world and declare the literal items separately.
3.7 One choice per view
The loader enforces exactly one choice: element per view. To offer two distinct picks, use two states — or keep one as a prose hint that the semantic router can resolve (cookbook §2.10).
3.8 param: in conversational rooms causes duplicate text inputs
The trap. A choice: item with param: renders as a visible text-input form in the web UI. Separately, any global intent that declares a string slot (required: true or required: false) is also exposed as a "text-slot" intent, which the web UI renders as a free-text textarea below the choice buttons. When these overlap in a mode: conversational room the author gets two text inputs for the same intent — one from the param form, one from the textarea — which is confusing and looks like a bug.
This bit the design pipeline twice:
change_existing(intent withtarget: string, required: true) + "amend existing…" choice item withparam: { slot: target }→ two "Amend Existing" inputs indesign_search.- Adding
intent: discuss, param: { slot: message }todesign_refine(a conversational room) + the existingdiscusstextarea → two message inputs.
Rules.
Never use
param:in a conversational room. Thediscusstextarea already captures free text. For a "re-run with no feedback" button, use a plain button that fires the intent with a pre-filled empty slot instead:# RIGHT — plain button; discuss textarea still handles typed feedback. - label: "refine" intent: discuss slots: { message: "" } # WRONG — adds a second text input alongside the discuss textarea. - label: "refine" intent: discuss param: { slot: message, type: string, required: false }param:is safe in non-conversational rooms, provided no global intent with the same name also declares a matching string slot. The web engine now suppresses the text-slot textarea for any intent already covered by a plain button in the choice list (InputBar.vuecoveredByChoiceItem), but the design intent — not the suppression — is what matters. If you rely on the suppression your story will be brittle.required: falseon a string slot still creates a textarea. Therequiredflag tells the semantic router whether to force LLM slot extraction; it does not suppress the web input. If you don't want a textarea, don't declare the string slot at all, or ensure the intent is covered by a choice button.
3.9 Form-mode default: discipline
default: on a numeric field renders the buffer non-empty at widget-open. That's right for fields where the operator's job is to adjust an authored recommendation (confidence: 80 for a river crossing) and wrong for fields where empty means "I didn't pick this" (per-item counts in a propose-purchase form, where blank ≠ 0 in the user's head).
- Use
default:when there is a sensible starting value (a recommendation, the last-used value, world-derived). - Omit
default:when 0 / "" should look distinct from "the user typed 0 / ""."
required: true forces an explicit user touch even with a default. Combine both to mean "we suggest X, but you must confirm or change it."
Live: stories/oregon-trail/rooms/river_crossing.yaml (depth-aware defaults: ford/80, caulk/70, ferry/90) vs. stories/oregon-trail/rooms/general_store.yaml (per-item counts left blank).
4. Where to go next
- Schema details —
kitsoki docs app-schema§choice:. - Runnable examples —
./kitsoki run testdata/apps/choice_smoke/app.yamldrops you on the walkthrough, or pick a specific spoke from the feature menu. - Style guidance —
docs/stories/story-style.md§3.6. - Schema reference —
docs/embedded/app-schema.md.