Question Widget (inline answer pills with stable position) (part 2)
The second half of the Question Widget page: the Plain Speak canonical-priority trigger and its aggressive numbered-bullet mode, the format hint Omniscio adds to the first message of a session, the v3 parser's history, how widget state is saved, and the file-level pointers an engineer touching this code needs.
What it is
This is part 2 of the Question Widget 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 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 (## QUESTIONSand## Questionsboth match), no trailing text (## Questions for youdoes NOT match). - Must be the last h2 in the rewrite. Earlier h2 headings are ignored when picking the anchor.
- Sub-headings (h3+) below
## Questionsare 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.
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. 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). v3 is what every user runs today.
- v3 (default) — minimal high-precision parser at
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 withanySiblingHasOptionsallowing 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; and since 2026-09-28 a mixed round whose first question is written as prose with lettered options flush above a numbered header starting at N≥2 is admitted as ONE whole widget — the prose header becomes question N−1 — so the numbered questions no longer drop to plain text, while a leading list marker on a displayed question (- **1. …**) is now stripped from its text), 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 choicesas 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. WhenplainSpeakScope === true(Plain Speak overlay), Pattern F is prepended: every numbered bullet under the LAST## Questionsh2 becomes a question with options optional — see "Canonical priority trigger" above. No partial fallback — if the message doesn't match a pattern cleanly, v3 returnsshouldRender: falseand 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 and the postmortem ledger. New work goes in v3.
How routing works. resolveQwParser() 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 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, 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.
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 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 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 — single file,
<Slot>sub-component renders one question. The verbose multi-line send format is built bycomposeVerboseResponse(questions, answers, otherTexts, { header, keepSkipped })+formatAnswerLines()(top-level helpers in the same file;formatAnswerLinesreturns one answer "atom" per selected option plus any custom text, andcomposeVerboseResponserenders 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 onplainSpeakScopeto pass the provenance header when the QW rendered out of the Plain Speak overlay (andnullotherwise). 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 —
parseQuestionWidgetV3(content, opts). The six-pattern cascade lives here. Passopts.plainSpeakScope: trueto enable the canonical-priority pre-pass described above. Plumbing chain:PlainEnglishOverlay→AgentMarkdown(plainSpeakScopeprop, threaded through everyrenderSegments(segments, opts)call site) →agent-markdown-segments.tsparseContentSegments(PreprocessConfig.plainSpeakScope) →parseQuestionWidgetV3(opts)for parsing, AND →<QuestionWidget plainSpeakScope={...} />for the send-format header branch. Every layer in the chain defaults the flag tofalse; 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. 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 (just above the mobile@mediasection). 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 faintinset 0 1px 0top 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.tsxadds theraisedclass to.qw-glasswhenqwRaisedChromeEnabled(defaulttrue, via?? true). Trap: the ready Send's animated conic-gradient border lives inbackground, so its gloss is layered as a multi-layerbackground(sheenpadding-box+ accent fillpadding-box+ conicborder-box) — never viabackground-image, which would erase the conic. The card rule re-states the accent focus ring so:focus-withinisn't clobbered. Setting field: /src/shared/types/settings/chat-ui-settings.tsqwRaisedChromeEnabled?: boolean(default inCHAT_UI_DEFAULTS) + Zod in /src/shared/ipc-schemas/settings/chat-ui-settings.ts + toggle in /src/renderer/src/features/settings/QuestionsSettings-definitions.tsx. Locked by theraised chromedescribe block in /tests/unit/question-widget.test.tsx. - Question-area weight:
.qw-questionisfont-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**inquestion.text— rendered the bold clause as<strong>at the UA-defaultbolder= 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. - Setting field: /src/shared/types.ts
AppSettings.stableQuestionPosition: boolean(defaulttrue); Zod field inupdateSettingsSchema. - Tests: /tests/unit/question-widget.test.tsx — see the
stable positiondescribe block for slot rendering, slide direction, persistence hydration, and thestableQuestionPosition=falseescape-hatch coverage. - Canonical-priority fixtures: /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 is02-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 — generic textarea undo/redo with debounced snapshot coalescing;
resetKeyis 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 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 and /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) 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.
Last verified 2026-10-06