---
title: Question Widget (inline answer pills with stable position) (part 2)
---

# Question Widget (inline answer pills with stable position) (part 2)

## What it is

This is part 2 of the [Question Widget](question-widget.md) page. That page covers what the widget is, where its settings live, and the whole life of a question — how it renders, which message it may render on, how you answer it, the layout that holds the question still, and what is remembered while you fill it in. This half carries the machinery behind that: the Plain Speak rule that decides which part of a rewritten message becomes a widget, the format hint Omniscio quietly adds to your first message of a session, the parser's version history, the widget's saved state, and the file-level pointers for anyone touching the code.

## Where to find it

Nothing on this half has a surface of its own — every rule here runs behind the same inline widget the [main page](question-widget.md) describes, so there is no button to press to reach any of it. Two things are worth knowing about where it becomes visible: the Plain Speak rules apply only while you are looking at a Plain Speak rewrite of a message (the V hotkey or the header pill switches you back to the original, and the rule stops applying), and the format hint has a toggle plus a text preview of its own in **Settings → Lab**.

## How it behaves

### Canonical priority trigger — Plain Speak `## Questions` heading

When Omniscio's **Plain Speak** rewrite of an agent message contains a literal `## Questions` heading as the _last_ h2 in the rewrite, that heading anchors the Question Widget. Anything **above** the heading (including the heading itself) renders as plain markdown, and only the content **below** it is fed to the parser. This lets a Plain Speak rewrite include lead-in prose with a bold "Should we ship this?" question or A/B options without confusing the parser into rendering an extra widget — the parser only sees the canonical block.

If nothing parseable lives below the `## Questions` heading, the whole message falls back to plain markdown — no empty widget, no partial render.

**Scope.** Only Plain Speak rewrites. The original agent message keeps the unscoped parser behavior (any pattern can fire from anywhere in the message), so a hand-written agent question that doesn't include the canonical heading still gets a widget the same way it always did. The trigger is opt-in via the renderer prop `plainSpeakScope`, set by `PlainEnglishOverlay` whenever its view is `'plain'`. Toggling to "View original" with the V hotkey or the header pill turns the flag back off and the original message re-renders under the unscoped parser.

**Aggressive numbered-bullet treatment under `## Questions` (Pattern F).** When `plainSpeakScope === true`, a sixth pattern (**Pattern F**) fires FIRST in the try chain. Every numbered bullet (`1.` / `1)`, optionally bold-wrapped) under the LAST `## Questions` h2 becomes a question — **with or without lettered options, with or without a `?` terminator**. The contract: under Plain Speak, _everything emitted under the questions heading is intended as a question_, so the parser stops requiring per-question anchor tokens. Mixed shapes (Q1 has A/B, Q2 is open-ended) and pure open-ended shapes (3 questions, no options anywhere) both render as widgets. The Custom-answer textarea handles open-ended questions naturally.

**Bolded numbered headers with a trailing aside (fixed 2026-08-03).** Pattern F recognizes the same three bolded-number header shapes the regular Pattern B does, not just a full-line `**1. …**` wrap: (1) full-line wrap `**1. Question?**`, (2) the bold closing _before_ a trailing parenthetical — `**1. Which entity should own this?** (this becomes your public name)`, and (3) the number inside the bold with the `?` outside — `**1. the mobile app** (Omniscio or a different product)?`. Before this fix, Pattern F stripped only shape (1), so a question whose header ended in a parenthetical was **silently dropped** while a clean full-bold sibling still rendered — the reported "only question 2 showed in the widget" bug on a 2-question card. A bolded numbered _statement_ with a parenthetical but **no `?`** (`**1. Ship the redesign** (already approved)`) is still NOT promoted to a question, so the relaxation can't over-fire. This is the exact shape-stripping Pattern B has used since 2026-06-03, now shared between the two paths via one helper so they can never drift apart again. (Pattern B gained a FOURTH shape on 2026-08-09 — a mid-line bold-close with the `?` OUTSIDE the bold and no parenthetical, e.g. `**3. When X**, what happens?` — but that one lives in Pattern B's candidate collector, gated on lettered options directly below the header, and is deliberately NOT shared into Pattern F, whose aggressive numbered-item mode has no options-below signal to gate on.)

(The legacy v1/v2 parsers had no Pattern F — they routed the same heading-scoped slice through their own cascades, which still required per-question anchor tokens. Both were removed in audit F019; v3 is the only parser now, so Pattern F is the behavior in every install.)

**Match rules.**

- Heading text must be `## Questions` — whole-line, case-insensitive (`## QUESTIONS` and `## Questions` both match), no trailing text (`## Questions for you` does NOT match).
- Must be the **last** h2 in the rewrite. Earlier h2 headings are ignored when picking the anchor.
- Sub-headings (h3+) below `## Questions` are passed through to the parser unchanged.
- Code blocks above the heading are restored before the prose-above slice is handed back to the renderer.

**Why.** A Plain Speak rewrite is a Sonnet pass that re-shapes the agent's terse question dump into reader-friendly prose, often re-introducing emphatic "**Should we proceed?**" sentences in the lead-in that v1's permissive cascade anchors on as a false-positive question. The canonical heading gives the rewriter a stable, parser-recognized place to put the actual questions. v3 takes the contract one step further with Pattern F: since Sonnet was told to put _only questions_ under the heading, the parser trusts that signal and skips the per-question anchor requirement that the regular feed enforces.

### Format hint injection — teaching the agent to emit parseable widgets

The parser is strict: a question header missing the `?`, numbered options instead of lettered, a fenced code block around the block, or a multi-question round that numbers topic labels with one shared question repeated under each (instead of numbering the questions themselves) all silently fall back to plain markdown — that last case renders only the first question as a widget. To raise the hit rate without asking you to coach the model yourself, Omniscio **silently includes a short formatting hint with the FIRST message you send in each new session**. The hint mirrors the "QuestionWidget rendering" section users typically add to their global `CLAUDE.md` by hand — it ships with Omniscio out-of-box. The hint also asks the agent **not** to add an "Other / Something else / I'll explain" escape-hatch option, since the built-in Custom-answer field already covers that — the belt-and-suspenders partner to the automatic filter described under "Redundant 'Other' options are hidden automatically" on the [main page](question-widget.md).

**What you see**: your chat bubble shows exactly what you typed. The hint is **not visible** to you; it's stitched onto the outgoing prompt at send time so only the model sees it.

**When it fires**: on the FIRST operator message of a session only (`getOperatorMessageCount(sessionId) === 0`). Subsequent messages, replies, follow-ups — no hint. Blank input — no hint. If you disable the toggle — no hint. The check is `priorOperatorCount !== 0 → return displayText unchanged`, so the second message is always clean.

**Every path is covered**: the hint rides the in-session send path, the session-launch path (the first prompt at `/sessions/new` or recipe start), and every engine (Claude, Codex, Gemini, …). It's placed as a preamble **before** your message — so your actual message is always the last thing the model reads, never buried under a trailing hint — via `qwFormatHintForFirstMessage` + `withUserMessageLast` in [`/src/shared/qw-format-hint.ts`](/src/shared/qw-format-hint.ts). One source of truth, no inline copies.

**Preview the text**: Settings → Lab → "QW format hint injection" has a **Preview the hint text** collapsible that shows the exact string being appended, so you can decide whether to leave it on.

**Toggle off if**: you've put an equivalent hint in your own global `CLAUDE.md`, you want full control over the outgoing prompt, or you're debugging a model that misreads the hint as a literal user instruction. The toggle is in **Settings → Lab** because it's a Lab feature, not the Sessions tab where the rendering toggles live.

### Parser version

Every QuestionWidget extraction runs through **`v3`, the only parser.** The user-facing picker was removed 2026-05-31, and v1 + v2 were then **DELETED** (audit F019, behavior-preserving — every path already resolved to v3, so nothing user-visible changed). `resolveQwParser` is now an always-`v3` shim (see [qw-parser-contract.md](/.claude/memory/contracts/qw-parser-contract.md)). `v3` is what every user runs today.

- **v3** (default) — minimal high-precision parser at [`question-widget-parser-v3.ts`](/src/shared/question-widget-parser-v3.ts). Six named patterns, all-or-nothing within each pattern. Regular (non-overlay) try order is **B → A → D → C → E**: **A** (single question + `?` + lettered options; since 2026-07-04 the options may sit below the header across ONE short prose or bullet bridge, which attaches as the question's note — a two-paragraph gap or non-lettered options still fall back to plain markdown), **B** (multi-question numbered list, all-or-nothing letter sequence with `anySiblingHasOptions` allowing some open-ended slots; since 2026-07-13 a clean numbered list of STATEMENTS above a genuine question no longer suppresses it when the question's `?` is followed by a bold-close and/or a single trailing parenthetical aside — `**Want me to…?** (I'd back up first.)` — while a `?` sitting mid-sentence with prose after it still stays dark, so the relaxation can't open a false positive; and since 2026-08-09 a numbered header whose bold closes MID-LINE with the `?` OUTSIDE it and no parenthetical — `**3. When X**, what happens?` — is recognized as a question when lettered options sit DIRECTLY below it, so a partial-bold header no longer drops to plain text, while a rhetorical `**1. Context** why?` with no options below stays dark), **C** (decision verb + `:` + lettered options), **D** (multi-section h2 with decision-verb trailers), **E** (statement / options-announce lead-in — `your call`/`up to you`/`your choice`/`your options`/`your choices` as the line immediately above the options; all five may carry a trailing clause, e.g. "Your call on what's next" or "Your options for next steps", as long as the line still starts with the phrase; the phrase is also recognized inside a line-final trailing parenthetical (`Next steps (your call)`) or after a short prefix clause + separator, still closing the line (`Next step, your call:` — comma/semicolon/colon/em-dash/en-dash only, no bare "the timeline is your call" and no trailing content after the phrase); a trailing recommendation paragraph is allowed and renders as after-prose). Since 2026-08-07, Pattern E also fires on a whole-line-only announce header, `what I need from you` (an `## What I need from you` / bold / plain line directly above the options); because the Pattern B reject-rescue reuses the same whole-line matcher, this one phrase also un-suppresses a message whose preceding numbered-statement list would otherwise trip Pattern B's hard reject. Launch-hardening (2026-08-07) additionally recognizes the wider declarative decision-header set diverse agents emit — deferral siblings (your decision · the choice is yours · over to you · you decide · I'll leave it to you · …), more announce headers (here are your options · a couple of options), a distinctive phrase that CLOSES the line after a prefix ("Here's what I need from you:"), and curly or straight apostrophes alike — each back-tested for zero over-fires; generic words (options · next steps · summary) are deliberately excluded. When `plainSpeakScope === true` (Plain Speak overlay), **Pattern F** is prepended: every numbered bullet under the LAST `## Questions` h2 becomes a question with options optional — see "Canonical priority trigger" above. No partial fallback — if the message doesn't match a pattern cleanly, v3 returns `shouldRender: false` and the message renders as plain markdown. This is what real users see today.
- **v1 / v2** — **DELETED (audit F019).** The legacy 8-rule cascade (v1) and its experimental rewrite (v2) no longer exist; their source files are gone. Their history lives in [qw-parser-snapshots.md](qw-parser-snapshots.md) and the postmortem ledger. New work goes in v3.

**How routing works.** [`resolveQwParser()`](/src/shared/qw-parser-version.ts) is a compatibility shim that now ALWAYS returns `'v3'` (it ignores its argument). Every render site — and the main-process re-parse path in [`ndjson-text-analysis.ts`](/src/main/process/ndjson-text-analysis.ts) that sets the pendingAction "Question" dot — calls `parseQuestionWidgetV3(...)` directly. There is no fallback chain: v3 returning `shouldRender: false` renders the message as plain markdown.

**The "Question" dot is engine-agnostic.** That `ndjson-text-analysis.ts` path is Claude's. For **non-Claude** engines (Codex, Gemini, OpenCode, slim-ACP, …) the SAME session dot + chime are set at turn conclusion by [`external-question-pending.ts`](/src/main/services/waiting/external-question-pending.ts), which reuses these exact detectors (`isTextAskingQuestion` / `isTextTriggeringQuestionWidget`). So a question from any engine flips the session dot to the blue "Question" state and chimes "is asking you a question", exactly like Claude — not the generic amber "Needs You". (The interactive widget + the in-message "Question" chip were already engine-agnostic — they parse the message text client-side; this closes the session-dot + chime gap.) Contract: [`external-question-pending-contract.md`](/.claude/memory/contracts/external-question-pending-contract.md).

**Legacy settings.** The `qwParserVersion` and `useQwParserV2` AppSettings fields were **removed** (audit F019). A value persisted before the picker's removal is silently dropped by the settings schema's unknown-key strip and has no effect — every install runs `v3` via the single-source `QW_PARSER_DEFAULT` constant.

**Corpus testing.** The fixture corpus suite at [`tests/unit/components/question-widget-corpus.test.ts`](/tests/unit/components/question-widget-corpus.test.ts) runs every no-override fixture on the **product default (v3)** — re-baselined 2026-07-12 (parser-consolidation S1); before that the harness defaulted to v1. Fixtures whose v1-era expectations diverge under v3 are `pending: true` with a `humanNote` naming the gap (documented, not gated). A fixture may still carry `togglesOverride.qwParserVersion`, but it is accepted-and-ignored now (v3 is the only parser). In addition, [`qw-product-path-characterization.test.ts`](/tests/unit/components/qw-product-path-characterization.test.ts) locks the full product-path parse output of every fixture message against a committed baseline — the identity proof that consolidation work never changes what users see.

### Persistence

Your in-progress widget state is debounced-saved to `localStorage` every 300ms while you click around: the active index, picked options per question, custom-answer text, and the multi-select mode flag. So if you accidentally collapse the message, navigate away, or refresh the renderer, your half-filled answers reload exactly as you left them. The state is cleared on successful submit.

Persistence keys look like `qw:<sessionId>:<messageId>` and only run when the widget has a `messageId` — agent-streamed widgets without a stable message id (rare) skip persistence entirely.

The saved-state format carries a schema version (currently **v3**). A half-filled answer saved under an _older_ version is discarded once on reload (you'd just re-pick) — this is deliberate: an option-shape change can leave an old saved pick pointing at something no longer on screen, which would silently submit the wrong answer, so discarding the stale blob makes that impossible. (**v2** landed with the redundant-option filter's re-lettering; **v3** landed when free-form questions dropped their synthetic Yes/No pills.)

## For agents

### Implementation pointers (for agents touching this code)

- Component: [/src/renderer/src/components/ui/QuestionWidget.tsx](/src/renderer/src/components/ui/QuestionWidget.tsx) — single file, `<Slot>` sub-component renders one question. The verbose multi-line send format is built by `composeVerboseResponse(questions, answers, otherTexts, { header, keepSkipped })` + `formatAnswerLines()` (top-level helpers in the same file; `formatAnswerLines` returns one answer "atom" per selected option plus any custom text, and `composeVerboseResponse` renders each atom as its own nested bullet — so multiple picks never collapse into a comma run-on); `composeResponse()` collapses to a single call that branches on `plainSpeakScope` to pass the provenance header when the QW rendered out of the Plain Speak overlay (and `null` otherwise). Both call sites share the same selection/skipped-question Maps — the body shape is identical; only the leading header differs.
- Parser: [/src/shared/question-widget-parser-v3.ts](/src/shared/question-widget-parser-v3.ts) — `parseQuestionWidgetV3(content, opts)`. The six-pattern cascade lives here. Pass `opts.plainSpeakScope: true` to enable the canonical-priority pre-pass described above. Plumbing chain: `PlainEnglishOverlay` → `AgentMarkdown` (`plainSpeakScope` prop, threaded through every `renderSegments(segments, opts)` call site) → [`agent-markdown-segments.ts`](/src/renderer/src/components/ui/agent-markdown-segments.ts) `parseContentSegments` (`PreprocessConfig.plainSpeakScope`) → `parseQuestionWidgetV3(opts)` for parsing, AND → `<QuestionWidget plainSpeakScope={...} />` for the send-format header branch. Every layer in the chain defaults the flag to `false`; both branches emit the same verbose body, the flag only controls whether the provenance header is prepended.
- Grid CSS: `.qw-stable .qw-slot { grid-area: 1 / 1 }` in [/src/renderer/src/styles/globals.css](/src/renderer/src/styles/globals.css). Active-slot slide classes: `qw-slot-fwd`, `qw-slot-bck`.
- Raised-chrome CSS: the `.qw-glass.raised …` block in [/src/renderer/src/styles/globals.css](/src/renderer/src/styles/globals.css) (just above the mobile `@media` section). **Not uniform gloss** — the look is tiered: the **big option boxes** (`.qw-pill` / `.qw-other-btn`) and the **selected box** (`.qw-pill.sel`) are **lift-only** (drop shadow + a faint `inset 0 1px 0` top edge, **no** face gradient — the wide stacked boxes read too strong with the sheen); the **letter badges** (`.qw-pill-badge`) keep their glossy chip; the **Send** key carries the full chrome in BOTH states — idle (`.qw-glass.raised .qw-send`) is a tinted accent key with the sheen, and the picked **ready** state (`.qw-glass.raised .qw-send.ready`) is the MAX gloss. `QuestionWidget.tsx` adds the `raised` class to `.qw-glass` when `qwRaisedChromeEnabled` (default `true`, via `?? true`). **Trap:** the ready Send's animated conic-gradient border lives in `background`, so its gloss is layered as a **multi-layer `background`** (sheen `padding-box` + accent fill `padding-box` + conic `border-box`) — **never** via `background-image`, which would erase the conic. The card rule re-states the accent focus ring so `:focus-within` isn't clobbered. Setting field: [/src/shared/types/settings/chat-ui-settings.ts](/src/shared/types/settings/chat-ui-settings.ts) `qwRaisedChromeEnabled?: boolean` (default in `CHAT_UI_DEFAULTS`) + Zod in [/src/shared/ipc-schemas/settings/chat-ui-settings.ts](/src/shared/ipc-schemas/settings/chat-ui-settings.ts) + toggle in [/src/renderer/src/features/settings/QuestionsSettings-definitions.tsx](/src/renderer/src/features/settings/QuestionsSettings-definitions.tsx). Locked by the `raised chrome` describe block in [/tests/unit/question-widget.test.tsx](/tests/unit/question-widget.test.tsx).
- Question-area weight: `.qw-question` is `font-weight: 700`, and a scoped `.qw-question strong { font-weight: inherit }` rule (same file, just below `.qw-question`) caps inline `**bold**` inside a question to the question's own weight. Without it, a question with a **partial** bold span — e.g. `**Approve this?** then a trailing clause…`, where the parser keeps the `**` in `question.text` — rendered the bold clause as `<strong>` at the UA-default `bolder` = weight **900**, visibly heavier than the rest of the already-bold question ("extra bolding"). Fully-wrapped headers (`**Q?**`) are unaffected — the parser strips those markers, so no `<strong>` is produced. Locked by [/tests/unit/lint/qw-question-no-extra-bold-css-contract.test.ts](/tests/unit/lint/qw-question-no-extra-bold-css-contract.test.ts).
- Setting field: [/src/shared/types.ts](/src/shared/types.ts) `AppSettings.stableQuestionPosition: boolean` (default `true`); Zod field in `updateSettingsSchema`.
- Tests: [/tests/unit/question-widget.test.tsx](/tests/unit/question-widget.test.tsx) — see the `stable position` describe block for slot rendering, slide direction, persistence hydration, and the `stableQuestionPosition=false` escape-hatch coverage.
- Canonical-priority fixtures: [/tests/fixtures/question-widget/\_archived/qw-canonical-priority/](/tests/fixtures/question-widget/_archived/qw-canonical-priority/) — 3 fixtures covering the trigger (false-positive lead-in suppression, scope-off symmetry, streaming partial). Drive the corpus via `npm run test:qw-corpus`. The pivotal RED case is `02-false-positive-suppressed/` — without the pre-pass the parser returns 3 questions (lead-in bold + 2 canonical); with the pre-pass it returns the canonical 2.
- NAV_COOLDOWN: 300ms between Previous/Next clicks. Tests must `vi.advanceTimersByTime(350)` between sequential nav clicks.
- Undo / redo hook: [/src/renderer/src/hooks/useTextUndoRedo.ts](/src/renderer/src/hooks/useTextUndoRedo.ts) — generic textarea undo/redo with debounced snapshot coalescing; `resetKey` is composed as `${sessionId}:${messageId}:${slotIndex}` so the stack is per-slot per-message.

#### Performance invariant — inactive Slots don't re-render on typing

Inactive question Slots do not re-render when the user types into the active Custom-answer textarea. This is locked in by the regression tests in [/tests/unit/question-widget.test.tsx](/tests/unit/question-widget.test.tsx) under `describe('typing perf regression', ...)`. If you add a prop to `<Slot>`, ensure it is referentially stable across parent renders (no inline objects/arrays/closures from the parent) — otherwise the `React.memo` wrapper silently un-binds and per-keystroke lag returns. The same invariant applies to the `parseContentSegments` call in [/src/renderer/src/components/ui/AgentMarkdown.tsx](/src/renderer/src/components/ui/AgentMarkdown.tsx) and [/src/renderer/src/components/ui/MessageBubble/MessageBubble.tsx](/src/renderer/src/components/ui/MessageBubble/MessageBubble.tsx): both wrap the call in `useMemo` keyed on the inputs the parser actually reads, so unrelated parent re-renders do not re-tokenize the message.

## Related

[Question Widget (inline answer pills with stable position)](question-widget.md) is the half a user reads — what the widget is, where its settings live, and how to answer a question. The parser contract and the other files listed above hold the invariants behind what this half describes.
