Skip to content

Read two AskUserQuestion shapes that fall through to the raw mirror - #50

Closed
konpyl wants to merge 5 commits into
AltanS:mainfrom
konpyl:fix/preview-select-wrapped-labels
Closed

Read two AskUserQuestion shapes that fall through to the raw mirror#50
konpyl wants to merge 5 commits into
AltanS:mainfrom
konpyl:fix/preview-select-wrapped-labels

Conversation

@konpyl

@konpyl konpyl commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Two AskUserQuestion shapes currently fall through to the raw mirror. On a phone that means a wall of ~180-column terminal text with nothing to tap and no way to answer — both were hit in normal use today, in different repos.

Both come from the same root mistake: a grammar asserting a layout instead of reading it.

1. A wrapped option label kills the preview dialog

preview-select required its numbered rows to be on consecutive lines. The assumption is written down as fact:

// 3. Option rows: numbered rows 1..k in CONSECUTIVE lines (the left column
//    never wraps its 30-col labels)

That gutter is only ~30 columns, so Claude Code wraps any longer label onto continuation rows and the numbered rows stop being adjacent — the whole dialog bails to raw. Observed live: an option labelled Rename + keep old as hidden aliases (Recommended) occupied three rows, putting the options on lines 183/186/187.

Reproduced by running the real parseAnsi → splitLines → buildBlocks pipeline over the capture, and flipped by the single change of making those rows adjacent — nothing else about the dialog mattered.

Adjacency is replaced by a contiguity walk keyed on the label column (the width of the ❯ 1. prefix): left of it is a new option, at it is label text, right of it is not label text at all. That one rule also prevents a label ending …and 3. Backfill later from minting a phantom third button, and stops preview-pane box-drawing being folded onto a button in layouts where the Notes: column and the preview pane don't coincide.

2. A multiSelect question inside a multi-question wizard is owned by nobody

wizard refuses it correctly — a wizard digit selects and advances, a checkbox digit only toggles, so claiming it would mis-drive the dialog — and multi-select only ever handled the single-question form. Two gates encoded that: a stepper of exactly two chips, and an advance row spelled Submit.

multi-select now carries the wizard's chips (via the wizard's own parseStepperLine, so the chip grammar stays in one place) and navigates with the wizard's own Left/Right keys, following the pattern preview-select already established for this exact shape. The advance row's label is read, not assumed: it is Next on every step but the last.

The row is located by position — last non-blank line above the rule separating the menu from the escape row — never by scanning for the word. Pane text is model-authored; matching on text alone let an option description reading Next rename the button to an action it doesn't perform, satisfy the "row absent → fail closed" check on a pane with no advance row, and forge the pointer so Enter landed on whatever row the terminal's was really on (on Chat about this, that aborts the whole tool call).

The advance macro stops at the keystroke rather than predicting what follows — it verifies the pointer in the same iteration it presses Enter, then lets the next poll re-detect whatever appeared. The single-question review stays where it was; a multi-question dialog reviews once at the end, and that screen belongs to wizard.

Evidence

The claude--wizard-multiselect-* fixtures are sandbox captures of a real two-question dialog driven end to end, because nothing in the repo described this shape and guessing feeds a keystroke into a live terminal. They settled four things worth knowing:

  • the question chip flips ☐ → ☒ on the first tick — "answered" means touched, not complete;
  • digits toggle pointer-independently inside a wizard step, exactly as standalone;
  • the last step's row reads Submit, earlier ones Next;
  • the footer is not invariant — it gains ctrl+g to edit in Vim when the pointer reaches the advance row, which is why the signature already stops before it.

claude--wizard-preview-wrapped-label.txt is derived, not captured: its live specimen was real work and this repo is public, so the gutter of claude--wizard-preview-q1.txt was rewritten and everything from the Notes: column rightward carried over untouched. Flagged as derived in the fixtures README.

Existing single-question dialogs are unchanged (steps: null, review phase untouched), and every non-dialog fixture still yields zero detections.

Also in here

  • The race guard now compares the wizard steps. signature normalises ☒/☑ → ☐ across the whole chip line — it has to, since the current chip flips on the first tick — but that also erases which step you are on, so two questions of one wizard sharing a question text and option labels were byte-identical to the guard.
  • The stepper strip is one shared component; wizard, preview-select and multi-select all render it from the same place instead of three copies.
  • multi-select-action.ts carried a literal NUL byte as a map-key separator, which made git classify the file as binary and hide every change to the race guard from git diff. It's now a source escape, plus a .gitattributes rule so a future stray byte can't cost reviewability.

Notes for review

  • Versioning: the bump and CHANGELOG entry follow this fork's numbering lane, which doesn't correspond to yours. Happy to renumber or drop them entirely — say which you'd prefer.
  • Four new fixtures are pinned in prompt-binding-regions.json; the completeness gate and the bridge-side binding test both pass.
  • Gate: web 1321 tests, bridge 417, typecheck + build clean.

🤖 Generated with Claude Code

konpyl and others added 5 commits July 29, 2026 07:35
The deployment was installed from upstream, so the owner's 0.18.0 release
overwrote the checkout and the fork's features vanished from the running app.
Nothing in the repo said any of this was a fork, so the next reader would have
hit the same trap — and would reasonably have proposed a suffixed version
number, which this codebase's own comparator cannot parse.
Terminal output has no markup, so a URL is just characters. lib/links.ts
scans the visible mirror text for http(s) URLs and returns ranges in the
SAME coordinate space find.ts already uses, so one running offset serves
both splits; AnsiOutput wraps those ranges in anchors.

The two render paths (fast + find-highlight) collapse into one. Links
split outermost and find matches inside, so a search hit inside a URL
still highlights and a URL straddling an SGR change becomes one anchor
per slice, each with the same href. With no query and no links the
renderer still emits the segment's own string, so the polling path costs
what it did before.

Only http(s) is matched, which is the whole XSS story: javascript: and
data: are unmatchable rather than filtered, and a stray control byte
can't reach an href. Trailing prose punctuation is trimmed, with paren
balance respected so Wikipedia-shaped URLs survive.

Styling follows ADR 0002 — the anchor keeps the agent's own colour
(recolouring would lie about what the terminal printed) and is marked by
a literal dark-space blue underline, no theme token, no dark: variant.

py-[0.35em] is the tap target: vertical padding on an inline box doesn't
grow the line box, so the terminal grid is untouched (the <pre> measures
identical either way) while the target goes 14px -> 22.4px. Verified in
Chrome at 9/12/16px that the pad never reaches the neighbouring line's
centre, where a tap on ordinary output would silently open a link.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The switcher was the dashboard re-served as a sheet: 58 panes, no filter, 4.4
screens of scroll, and a "you are here" cue measuring 1.31:1 against its own
background. Opening it from a shell showed no current-pane marker at all — the
Shells group collapses past 12, so the row wasn't rendered to be marked.

Adds a filter (sticky header, clearable, Enter-commits on one match), a
"Here · <space>" section — the one thing this sheet knows that the dashboard
doesn't — status and age per row, and a frozen list with an "N changed —
refresh" affordance so a pane re-bucketing mid-browse can't swap the row under
your thumb.

Then three adversarial UX review passes against a live 61-pane herd, each
followed by fixes and a full re-proof. The ones worth naming:

- The age column contradicted the sort. Rows dated by lastActiveAt while Recent
  sorts by lastSeenAt, so ages ran out of order and disagreed with the dashboard
  by up to 13h about the same pane. ageBasisFor now owns the rule for both.
- Drag-to-dismiss was cancelled ~2x/second: onClose sat in the sheet's effect
  deps, so every poll-driven re-render tore down the touch listeners and reset
  the drag offset under the user's finger.
- Focus escaped both sheets despite aria-modal="true" (Tab #28 landed on the
  page behind), and a completed switch dropped focus on document.body.
- At 200% text zoom row titles clipped to 2-3 characters (WCAG 1.4.4).

Scroll anchoring is deliberately left alone: the browser already compensates
correctly for inserts above the viewport, so adjusting scrollTop by hand would
double-correct a working case. Freezing the list is the fix instead.

Evidence, harness and the seven still-open items: prp/2026-07-29-switch-pane-ux/

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rror

Both reported panes showed a question as a wall of 179-column terminal text with
nothing to tap. Different repos, different shapes, one root mistake: a grammar
asserting a layout instead of reading it.

WRAPPED OPTION LABELS (preview variant)

The grammar required its numbered rows to be on consecutive lines. The
assumption was written down as fact —

  // (the left column never wraps its 30-col labels)

— and this variant's gutter is only ~30 wide, so Claude Code wraps any longer
label onto continuation rows and the numbered rows stop being adjacent. The
whole dialog fell to raw. Observed live: option 1's label "Rename + keep old as
hidden aliases (Recommended)" spanned three rows.

Adjacency is replaced by a contiguity walk, with the LABEL COLUMN as the thing
that was missing: a wrapped line hangs under its label, so left of that column
is a new option, at it is label text, and right of it is not label text at all.
One rule, three cases. Without it, a label ending "…and 3. Backfill later" mints
a phantom third button that types a stray digit, and — where the Notes column
and preview pane don't coincide — box-drawing gets folded onto a button and
dragged into the core signature, making it pointer-DEPENDENT, the one property
the race guard needs it not to have. The column is taken only from a line that
genuinely parses as an option row: a bare "12." matches the prefix shape but
carries no option, and would widen the column that decides what an option is.

Also: `chosen` is tracked per row rather than tested against the joined label (a
revisited question paints its ✔ mid-string once the label wraps), and the
grammar bails past 9 options as the single-select one already did, since a
two-key digit is unsendable.

MULTISELECT INSIDE A WIZARD

A checkbox question that is one step of a multi-question dialog was owned by
nobody. wizard.ts refuses it correctly — a wizard digit selects AND advances
while a checkbox digit only toggles, so claiming it would mis-drive the dialog —
and multi-select only ever handled the single-question form. Two gates encoded
that: a stepper of exactly two chips, and an advance row spelled "Submit".

multi-select now carries the wizard's chips (parsed by the wizard's own
parseStepperLine — the chip grammar stays in one place) and navigates between
questions with the wizard's own Left/Right keys, following preview-select, which
already solved this same shape. The advance row's label is read rather than
assumed: it is "Next" on every step but the last. Hard-coding either word would
have broken half the steps of every multi-question dialog.

The advance row is located by POSITION — last non-blank line above the rule that
separates the menu from the escape row — never by scanning for text. Pane
content is model-authored and untrusted, and matching on the word alone let an
option DESCRIPTION reading "Next" rename the button to an action it doesn't
perform, satisfy the "row absent → fail closed" check on a pane that has no
advance row (leaving the macro to spray Down keys hunting for it), and, through
pointerAt, forge the pointer so Enter landed on whatever row the terminal's ❯
was really on — which on "Chat about this" aborts the whole tool call.

The macro stops at the keystroke instead of predicting what follows. It walks
the pointer onto the advance row, re-reads, verifies the pointer is there in
that same iteration, then presses Enter — and lets the next poll re-detect
whatever appeared, be it the next question or the wizard's review. Predicting
another grammar's screen is the assumption that made this brittle to begin with.
The single-question review stays anchored where it was; a multi-question dialog
reviews once at the end and that screen belongs to the wizard.

The race guard now compares the steps themselves. `signature` normalises ☒/☑ → ☐
across the whole chip line — it has to, since the current question's chip flips
on the first tick — but that also erases which step you are on, so two questions
of one wizard sharing a question text and option labels were byte-identical to
the guard, and a tap meant for the first could land on the second.

EVIDENCE

The wizard-multiselect fixtures are sandbox captures of a real two-question
dialog driven end to end, because nothing in the repo described this shape and
guessing feeds a keystroke into a live terminal. They settled four things: the
chip flips on the FIRST tick (answered means touched, not complete), digits
toggle pointer-independently inside a step exactly as they do standalone, the
last step reads "Submit", and the footer is not invariant — it gains "ctrl+g to
edit in Vim" when the pointer reaches the advance row, which is why the
signature already stops before it.

The wrapped-label fixture is DERIVED, not captured: its live specimen was real
work in a public-repo fixture corpus, so the gutter of claude--wizard-preview-q1
was rewritten and everything from the Notes column rightward carried over.

The stepper strip is extracted rather than hand-rolled a third time.

multi-select-action.ts carried a literal NUL byte as a map-key separator, which
made git classify the file as binary and hide every change to the race guard
from review. Same value, written as a source escape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@konpyl

konpyl commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

Closing in favour of #51, which is the same change scoped correctly.

This branch was cut from my fork's main, so it dragged in four unrelated commits of fork divergence (including a docs commit about the fork itself) and a version bump on a numbering lane that isn't yours. #51 is a single commit cherry-picked onto main, code and fixtures only — no version bump, no CHANGELOG entry. Sorry for the noise.

@konpyl konpyl closed this Jul 29, 2026
@konpyl
konpyl deleted the fix/preview-select-wrapped-labels branch July 31, 2026 10:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant