Question Widget (inline answer pills with stable position)
Omniscio's inline Question Widget: what happens when an agent asks a multiple-choice or yes/no question, where its settings live, how to answer with pills, keys or a typed reply, and how the layout, the option filter and the saved state behave while you work through it.
What it is
When an agent asks you a multiple-choice or yes/no question, Omniscio parses the question out of the agent's message and renders it as an interactive Question Widget inline — instead of forcing you to type the letter "A" back. The widget shows the question text in bold, then a row of clickable pills for each option (A, B, C, …). You click pills to pick answers, optionally type a custom answer in a textarea, then submit with Ctrl+Enter to send the formatted reply back to the agent.
If the agent asked multiple questions in one turn (e.g., a 5-question clarification dump), the widget shows them one at a time with a "1/5" step counter and Previous / Next arrow buttons. As of the stable-position update, navigating between questions of different lengths no longer makes the question text jump up and down on your screen — the question line stays at a stable Y while pills extend below the locked area, so you can read the next question without re-tracking.
Where to find it
There is nothing to open. The Question Widget is not a window, a panel or a page — it is drawn inside the agent's own message, in whichever session the question was asked in, so it has no menu entry and no keyboard shortcut of its own. It appears on the newest question the agent asked (agent messages that follow it and ask nothing of their own don't take it away), and it stops being interactive once you answer it or the agent asks something newer instead. The message carrying a waiting question is flagged with its own header chip, described first under How it behaves below, and a session with a question waiting shows the blue "Question" state on its row.
What you can open is the set of toggles that govern how the widget renders. They are listed at the end of How it behaves below; turn everything off and the agent's question simply renders as ordinary text.
How it behaves
What follows is the widget's behaviour in the order you meet it: the header cue that flags a waiting question, which message is allowed to be interactive, how throwaway "Other" options are cleaned up, how to answer and submit, the layout that stops the question jumping between steps, the undo stack in the custom-answer box, and the settings behind all of it.
Header cue — the "Question" chip
So a pending question is never scrolled past, the agent message that's asking one carries a small blue "Question" chip in its header (beside the "Agent" label and timestamp), with a tooltip reading "This message has a question waiting for your answer." It appears on exactly the message that hosts a live, unanswered question — the same scope as the interactive widget: it shows only while the question-widget feature is on, you haven't replied yet, and the agent hasn't gone on to ask a newer question (the same rule described under "Render scope" below). Because the chip and the widget are gated by one signal, the chip can never outlive the widget it points at. Because it lives in the header, it stays visible even when the message is long (the question sits below the fold) or the turn is collapsed. It's a label, not a button — the interactive pills are still the widget itself.
For agents working on this code: the chip renders in MessageBubble.tsx, gated on the memoized qwLikelyHostsWidget (!isOperator && questionWidgetEnabled && isLikelyQuestionWidget(message.content)) and reusing the shared Pill (tone="info", the themeable --status-question blue). It follows the same questionWidgetEnabled gate as everything else here, so turning Question widget — replace plain text off hides the chip too. Locked by message-bubble-question-indicator.test.tsx; strings messageBubble.questionWaiting / messageBubble.questionWaitingTooltip.
Render scope — only the newest question is interactive
The clickable widget appears on exactly one message: the newest question the agent asked. When the agent asks a newer question, the older one stops being a widget and renders as plain markdown (the bold question line followed by the lettered options as an ordinary list). Its pills, textarea, and Send button are gone; the text stays in the transcript so you can still read what was asked.
Only a newer question takes the widget away. An agent that carries on working — a status line, a tool run, the next pass of a routine job — leaves the question you haven't answered exactly where it was, still clickable. That matters because everything else on screen keeps saying the same thing: the header chip, the blue "Question" row in your inbox and (for a question a queued message interrupted) the note in the transcript all still point at that question until you answer it. Before the 2026-10-02 rule the widget was the odd one out: it vanished the moment the agent said anything newer, so the session advertised a question with nothing left to answer (owner report 2026-10-02: "there should not be a question widget icon if the qw isn't there, usually because another message has appeared in the thread").
Answering retires a question, however you answer it (owner rule, 2026-09-20: "a Question Widget should disappear as soon as the user responds, not just when the agent emits another message"). Send from the widget, type a reply, or tap a Quick Reply — the moment your response lands, the question closes and renders as plain text, without waiting for the agent to say something back. Before this rule a reply sent through the chat left the widget live for the whole of the agent's next turn, still inviting a second answer to a question you had already answered.
What does not count is Omniscio talking to itself: a mechanical nudge, an auto-resume, or another agent's hand-off is not you answering, so the question stays live.
Practically, this means:
- Scroll back through a long session and every past question is plain text. Only the newest one the agent actually asked is live.
- An unanswered question stays live while the agent keeps working — but the moment it asks a newer question, the older one becomes plain text.
- Replying without picking an option closes the widget too — your reply IS the answer. The question text stays in the transcript, so you can still read what was asked.
- If the agent asks a question and you dismiss it (the X on a desktop window, ⋯ → Dismiss on a phone — both revert it to plain text), then it's plain text by your choice; the newest-question rule is separate and applies even to questions you never touched.
- A direct, standalone widget that Omniscio mounts on its own (not parsed out of a chat message — e.g. a triage card) is unaffected by this rule; the scope gate only governs questions rendered through a chat message's markdown.
For agents working on this code: the gate is the row-level isFinalMessage prop threaded into renderSegments inside AgentMarkdown.tsx; a question segment mounts the interactive widget only when isFinalMessage === true, otherwise it falls back to raw question markdown. The full invariant, threading path, and locked-by tests live in the feature contract at .claude/memory/contracts/qw-parser-contract.md under "Render scope (consumer side)".
Redundant "Other" options are hidden automatically
The widget always renders its own Custom answer free-text field (the last lettered slot), so you can always type a reply that no pill covers. Because that escape hatch is built in, an agent that also writes its own throwaway choice — "Other (I'll explain)", "Something else", "None of the above", "Different — I'll describe it" — is just stacking a duplicate on top of it. Omniscio detects those and drops them before the widget renders, then re-letters what's left so the options stay a clean A, B, C, … The built-in Custom answer field is what covers "none of these."
Guardrails, so a real choice is never lost:
- An option the agent explicitly marked (recommended) is never hidden, even if its wording looks like a throwaway.
- If every option is a throwaway, none are removed — a question is never emptied.
- It's display-only: the message still counts as a question (it still lights the amber "needs you" dot), and nothing about parsing or your saved answers changes.
Omniscio also quietly asks the agent not to add such an option in the first place (see Format hint injection on the second page), so with a cooperative model you'll rarely see one at all — the filter is the belt-and-suspenders backstop for when the agent adds one anyway. Set the AMC_DISABLE_QW_OTHER_FILTER=1 environment variable to turn the filter off and show the agent's options verbatim. The matcher was calibrated against the live message history (924 real question rounds, zero false positives); the exact rules, guards, and the one deliberately-broad edge live in the feature contract at .claude/memory/contracts/qw-parser-contract.md under "Render-side redundant-option filter (consumer side)".
How to use it
Pick options. Click a letter pill or press the matching letter key (A/B/C/…). Pills can be single-select (tap one to lock it; tapping a different one swaps) or multi-select (toggle as many as you want) — the widget infers which based on the agent's wording, and you can flip the mode with the small mode segmented control above the pills. One-key shortcut: when the widget itself is focused (press Q or click the card), Space picks the agent's recommended option — the one with the soft green outline — or the first option if none is recommended, exactly like pressing that option's letter. (Space still types a normal space while your cursor is in the Custom-answer field.) On the last question, Space again sends: once you've picked an answer, pressing Space one more time submits — so a single-question widget is two taps (Space to pick, Space to send). With a recommended option (green-outlined), the first Space accepts it and the second sends; with no recommendation, the first Space selects the first option and the second still sends once you've answered — you no longer need to reach for Ctrl+Enter. (Turning the recommendation highlight off means nothing reads as recommended, so the same "second Space sends once answered" path applies.) The first Space alone never sends. Holding Shift while pressing Space never sends — it stays the "add this option" toggle.
Type a custom answer. Click "Custom answer" (small button next to the pills) — or press the letter past the last option — to reveal a textarea on the active question. In single-select mode, opening Custom or typing in it replaces any prior pill pick (and picking a pill closes Custom and discards the typed text), so the visual state always matches what gets sent. In multi-select mode, Custom answer text is sent in addition to the pill picks. Because it counts as one of your answers, the Custom-answer box's letter badge takes the same selected look as a picked pill the moment you type something non-blank — accent-coloured letter, plus a ticked checkbox in multi-select — so a typed answer never reads as ignored sitting next to your ticked pills. An empty or whitespace-only box stays neutral, matching the fact that blank text contributes nothing to the reply. To get the multi-select "compose" behavior in single-select mode for a single action, hold Shift while clicking the pill or pressing the letter — Shift always means "add, don't replace". Each question keeps its own custom-answer text, even when you navigate away and back.
Move between questions. Use the ◀ Previous and Next ▶ buttons (or Left/Right arrow keys when focus is in the widget). The "1/5" counter shows where you are. If you skip a question without answering, the Previous arrow's tooltip notes how many questions are skipped behind you (e.g. "Previous question (2 skipped)").
Send your answer. Press Ctrl+Enter to submit. If you have unanswered questions and the Confirm before submitting with unanswered questions setting is on (default), a confirmation dialog appears that names exactly which questions are still unanswered (e.g. "Questions 2 and 4 are still unanswered", or "Question 3 is still unanswered" for one) — click Submit to send the partial set, or Cancel (also Esc, or clicking outside) to jump straight to the first unanswered question so you can fill it in instead of being left on the last one. The Send button also warns you on hover when a confirm step is coming ("2 unanswered questions — you'll be asked to confirm"), so a partial submit is never a surprise.
- Default (Original view). The agent receives a verbose multi-line reply so the original question text is preserved alongside your answer in the conversation transcript (easier to scan when scrolling back). Each numbered question renders on its own line as
<n>. **<full question text>**; your answer renders on the next line as an indented sub-bullet beneath it (a nested Markdown list item — it visually sits indented under the question instead of running on the same line) — e.g.1. **Which color do you want?**\n - Red\n2. **Which size?**\n - Large. When you pick multiple options — or add a custom answer alongside a pick — each one renders as its own indented bullet, never joined on one line, so two choices can't blur into a single run-on (option texts routinely contain their own commas). Picking A and C gives1. **What next?**\n - Refresh the VM, then re-verify\n - Get it merge-ready; a pick plus a custom note gives1. **Should we ship?**\n - Yes, with caveats\n - run the regression suite first. Skipped questions are kept in the list as<n>. **<full question text>**\n - _skipped_rows so the agent can see which questions you intentionally didn't answer; the original<n>index is preserved. Free-form questions with no options have no pills at all — you answer them by typing in the Custom-answer field, and your text is sent verbatim (an earlier build stuck synthetic Yes/No pills on open-ended questions; that was removed). - Plain Speak view. When you submit while looking at the Plain Speak rewrite of the agent's message (the QW that rendered out of the rewrite, not the original), the same verbose body is prefixed with a one-paragraph provenance header explaining the questions came from the Plain Speak overlay and were paraphrased. The agent — which never saw the rewrite verbatim — needs the header to know the question text it sees was paraphrased; the regular path skips the header because the agent itself authored the question text. All-skipped falls back to the terse
1. skipped 2. skippedformat with no header (sending only a header would be meaningless). A single answered question still gets the header for predictable formatting.
- Default (Original view). The agent receives a verbose multi-line reply so the original question text is preserved alongside your answer in the conversation transcript (easier to scan when scrolling back). Each numbered question renders on its own line as
Dismiss without answering. Click the X to close the widget — the agent message reverts to plain text and you can type your own free-form reply.
Send button availability across session statuses
The widget's Send button mirrors the main composer's Send button: it is never disabled because of the session's status — the only thing that stops it is having nothing to send. Paused, ended, error, and archived sessions all let you click Send — the backend's sendResponse runs the auto-resume path (--resume <cliSessionId> plus auto-unarchive for archived) so the agent picks up the conversation. If you fill in answers on a paused session and click Send, the row flips back to running and your answers go through. A session that is still starting works too: the answers are folded into the launching turn, or they expedite a session still waiting in the restore queue. See pause-or-stop-a-session.md and send-a-message.md for the matching composer behavior.
Optimistic send. The moment you click Send (or press Ctrl+Enter), the widget collapses to its read-only submitted summary and — if your post-send-navigation setting is set to move you to the next session — Omniscio moves you there immediately, before the agent actually receives the answers. This matches how the regular composer behaves on every other send: the textarea clears the instant you press Enter, you don't wait for the round-trip. The IPC continues in the background; on success nothing else happens (you're already on the next session). On failure (the session ended, paused refused to resume, IPC error) the widget reappears with your answers intact, Omniscio navigates you back to the original session, and an error toast explains what happened so you can retry. A second click or Ctrl+Enter fired in the same instant is ignored so you can't accidentally double-submit. The one exception: if the session is in the starting state (CLI launch handshake) the click is blocked entirely — the widget does not collapse and no send is dispatched.
An answered question stays answered — even if you scroll away and come back. Once your answer goes out, that question reads as answered from then on. It used to come back as a live, clickable widget the moment its message scrolled off screen and back — with your answer already sent — because the only record that you had replied lived in the on-screen widget itself, and Omniscio unloads messages that scroll out of view to keep long conversations fast. That was most visible when the agent's next reply is one Omniscio folds away (a background check-in, an auto-wait notice, a no-op wake-up): the question stayed the newest thing you could see, so it never closed on its own either. Omniscio now records the fact that you answered — just the fact, never the answer text — so the question stays closed across scrolling, leaving the session and coming back, and a full app reload. Answers you have picked but not sent are untouched: a question you never submitted is still live and clickable, which is what keeps a nudge or a Quick Reply from stranding a question you still mean to answer. The same applies to a card deck — once the batch ships, every card closes together and cannot be sent a second time.
Stable position — what changed
In multi-question widgets, all questions render simultaneously as stacked grid slots in the same cell. Only the active slot is visible and interactive; the others are present in the DOM but hidden (aria-hidden="true", pointer-events: none, opacity: 0). The widget's body sizes itself to the tallest slot, so:
- Going Q1 (long pill labels, 3 lines wrapped) → Q2 (short pill labels, 1 line) → Q3 (one-liner) keeps the question text at the same vertical position.
- Pills can extend below the locked question position; the question itself never moves.
- Forward and backward navigation still slide softly (forward = right-to-left, back = left-to-right). With OS reduced-motion enabled (Windows: Settings → Accessibility → Visual effects → Animation effects = Off; macOS: System Settings → Accessibility → Display → Reduce motion), the slide is instant.
- The Custom answer pill is rendered on every slot (active and inactive) so the body's max-height calc stays symmetric across navigation. Inactive slots' Custom Answer buttons are present for layout only — clicking one (e.g., via assistive tech bypass) does not open a textarea or change state.
Pinned control bar — stays at the top while you scroll
The widget's top control bar — the ◀ ▶ navigation arrows, the single/multi mode toggle, and the help (?) and dismiss (×) buttons — stays glued to the top of the chat viewport while you scroll down past a tall widget, so those controls are always reachable even after the question's pills have scrolled off-screen. As soon as the whole widget scrolls out of view, the bar unpins and scrolls away with it.
On a phone the bar always stays on one line — it never wraps, and it fits by carrying fewer controls rather than by squeezing them. Three things give way there, in this order:
- The trailing actions collapse into a ⋯ menu. Ask the Council and the dismiss (×) are not drawn in the row at all on a phone — one three-dot button replaces both and opens them as menu items. This is the structural cap on the row: the next control somebody adds to this surface costs a menu row, not bar width.
- The help (?) button is hidden — there is no hover on a phone to open it.
- The remaining controls are drawn more compactly (narrower arrows, a tighter Send). The step counter is NOT among them — it stays at its full desktop size, because the ⋯ menu frees more width than the big counter costs.
On a screen narrow enough that it still wouldn't fit, the single/multi toggle is the one control that gives up space — it has a touch equivalent anyway, since long-pressing an option turns multi-select on — while Send and the ⋯ button keep their full size.
Two incidents shaped this, both on 2026-09-10. First, nothing gave way at all, so the dismiss (×), last in the row, was silently cut off outside the card and the widget could not be turned off. The fix for that bought width by shrinking the step counter to 14px, which was then too small to read on a phone — so the ⋯ menu replaced the pixel budget with a structural one and the counter went back to full size.
Hovering the ? opens a live shortcut cheat-sheet keyed to the current question — it lists the actual option-letter range (e.g. "A–C", not a fixed "A–F"), the Custom answer letter, Space (pick the suggested option), R (jump to the chat composer), Esc (release the widget's focus), and — on a multi-question widget — the ◀ ▶ arrow keys for Previous / Next.
This works on desktop and mobile alike. Under the hood the bar never actually moves in the document — Omniscio nudges it down visually as you scroll (a paint-only offset that stops at the bottom edge of the widget), so it can't fall out of step with the virtualized message list the way a plain CSS sticky header would. There is no setting; the pin is always on.
The question text is a touch larger on mobile (16px) for readability; pill labels stay at 14px.
Mobile — the next question scrolls to the top when you advance
On a phone, if a question has a long list of options and you've scrolled down to read them, tapping Next ▶ (or picking an answer that auto-advances) scrolls the page so the new question's heading lands at the top — you don't have to scroll back up to see what's being asked. It only nudges when you'd actually scrolled past the question, so a question already in view doesn't jump; and it never fires when you first open a session (opening a session never yanks you down to the widget — that was an old bug, deliberately ruled out here). Desktop is unchanged: questions there usually fit on screen, so no auto-scroll happens. There is no setting; like the pinned control bar, it's always on (in the default stable-position mode).
Mobile — "Custom answer" opens as a bottom sheet
On a phone (narrow viewport, < 768px), tapping Custom answer doesn't just reveal a textarea in place — it lifts the whole widget (question text, pills, and the typing box) up into a bottom sheet that slides in from the bottom of the screen and stays pinned just above the on-screen keyboard, so the text you're typing is never hidden behind the keyboard. The chat behind it dims slightly so the input stands out, and the sheet stays up while you move between questions until you Send, Dismiss, or the widget is superseded.
A tap on the dimmed area closes the sheet, like any pop-up (added 2026-09-24 — before that the only ways out were Send, ⋯ → Dismiss, or the phone's Back button, which read as a locked screen when the sheet was opened by mistake). The widget goes back to its spot in the chat, still unanswered, and the chat behind it is exactly where you left it: while the sheet is up, an invisible placeholder keeps the widget's space in the chat, so nothing shifts. If you moved to another question inside the sheet, the chat scrolls that question's heading into view as it returns — the same nudge Next ▶ gives. A typed answer is never thrown away: while any question holds text you typed into its Custom answer box, a tap outside only hides the keyboard and the sheet stays, one tap from Send. While the sheet is up the chat behind it does not respond to taps, as with any pop-up. The ⋯ menu (where Dismiss lives on a phone) opens on top of the sheet.
Opening Custom answer does not change the widget's background — the inline widget and the bottom sheet are the same surface, so the only thing that visibly happens on tap is the slide-up and the dim, not a recolor. They cannot differ: the widget's colour and blur are described once and every state reads that one description, rather than each state carrying its own copy that somebody has to keep in step. (The phone gets a solid fill rather than the translucent "glass" look because a translucent fixed sheet renders incorrectly on some Android browsers.) Desktop keeps the in-place reveal, the glass look, and no bottom sheet.
The typing box renders at 16px on a phone, which is deliberate and is the one size it may never drop below. Mobile Safari silently zooms the whole page in whenever you focus a text field smaller than 16px, and it does not zoom back out when you finish — so the page just stays magnified until you pinch it back yourself. The box used to be 14px, which is why tapping Custom answer on an iPhone zoomed in and never zoomed out, while the normal message box (already 16px) behaved fine. The surrounding pills and the collapsed "Custom answer" button stay at 14px on purpose: iOS only auto-zooms real text fields, never buttons. See mobile-font-size-contract.md §11 for the cascade detail and the guard test that keeps it from regressing.
Edge case — small viewports + very tall questions
The widget locks to the height of the tallest question. On a small screen with one very tall question (say, a 40-line dump of options), that locked height can push controls above the visible chat — natural scroll recovers them. This is by design; the alternative would be reverting to the legacy behavior where every question owns its own height.
Custom-answer textarea — undo / redo
The "Custom answer" textarea (revealed by clicking Custom answer next to the pills) supports the usual editor undo / redo against what you have typed:
- Ctrl+Z undoes the last typing burst.
- Ctrl+Shift+Z or Ctrl+Y redo it.
The undo stack is per-slot per-message — moving to a different question (Previous / Next) or to a different agent message clears the stack. So Ctrl+Z always undoes inside the Custom-answer textarea you are currently typing in, never across questions.
Snapshots coalesce on a 500ms debounce: rapid typing produces one undo step (a word or burst), not one per keystroke. Pause for half a second, keep typing, and a fresh snapshot is recorded — so a single Ctrl+Z rolls back the last burst rather than the last character.
Ctrl+Z is handled inside the textarea so it does not bubble up to any window-level binding while you are editing. Ctrl+Enter still bubbles normally and submits the answer.
Settings
Open Settings → Sessions and look for these toggles:
- Question widget — replace plain text (
questionWidgetEnabled, default on as of 2026-06-02): turns the parser on/off. When off, the agent's question is rendered as plain markdown without pills. Installs that still carried the old default-off were force-flipped on once by a one-shot migration (migrateQuestionWidgetDefault); turning it back off here sticks — the migration never re-flips a deliberate opt-out. - Confirm before submitting with unanswered questions (
confirmUnansweredQuestions, default on): the safety dialog described above. - Keep question position stable (
stableQuestionPosition, default on): turn this OFF to revert to legacy single-slot rendering where each question's height grows/shrinks the widget. Useful if the locked-height behavior feels wrong on your screen size, or if a buggy parse renders one question ridiculously tall. - Raised 3D buttons (
qwRaisedChromeEnabled, default on): gives the QuestionWidget a raised 3D look. The big option boxes and the card float on a drop shadow with no face sheen (the wide stacked boxes read calmer that way); the letter badges (A/B/C/D) keep their glossy chip, and the Send key carries the full gloss — a tinted, sheened key when idle, going to the glossiest "ready" look (sheen layered over its spinning accent border) the moment you pick an option. Turn this off for flat buttons. It's pure styling; nothing about how questions are parsed, answered, or submitted changes, and it reads correctly in every theme (white-on-top / shadow-on-bottom, the same trick the app's buttons use). Parser version— the v1/v2/v3 segmented picker was removed 2026-05-31; there is no parser-version control in Settings anymore. Every install runs thev3default — see "Parser version" on the second page.
In Settings → Lab:
- QW format hint injection (
qwFormatHintInjectionEnabled, default on): see "Format hint injection" on the second page.
The settings are findable via Ctrl+K (settings search): try "question", "stable", "unanswered", "parser", or "hint".
For agents
The parser itself — the six-pattern v3 cascade with its Plain Speak canonical-priority pre-pass, the format hint Omniscio injects into the first message of a session, the parser version history, the widget's saved-state persistence, and the file-level pointers for anyone touching this code — is in Question Widget (inline answer pills with stable position) (part 2).
Related
- use-quick-responses.md — AI suggestion chips appear in the same input toolbar; both surfaces handle the "agent asked a question" moment.
- notifications-and-silence.md —
postSendNavigationcontrols what happens after you submit an answer (stay, advance, etc.).
Last verified 2026-10-06