From b297e3d69e5a3bb785620598b29b78a8473929e5 Mon Sep 17 00:00:00 2001 From: konpyl Date: Wed, 29 Jul 2026 23:02:05 +0400 Subject: [PATCH] feat(web): read the two dialog shapes that fell through to the raw mirror MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitattributes | 6 + .../components/multi-select-block.test.tsx | 61 +++++- web/src/components/multi-select-block.tsx | 26 ++- web/src/components/preview-select-block.tsx | 54 +----- web/src/components/wizard-block.tsx | 64 ++----- web/src/components/wizard-stepper.tsx | 108 +++++++++++ web/src/fixtures/panes/README.md | 5 + .../claude--wizard-multiselect-checked.txt | 21 +++ .../claude--wizard-multiselect-final.txt | 19 ++ ...laude--wizard-multiselect-pointer-next.txt | 21 +++ .../panes/claude--wizard-multiselect-q1.txt | 21 +++ .../claude--wizard-preview-wrapped-label.txt | 40 ++++ web/src/fixtures/prompt-binding-regions.json | 25 +++ .../lib/harness/claude/multi-select.test.ts | 125 ++++++++++++ web/src/lib/harness/claude/multi-select.ts | 101 ++++++++-- .../lib/harness/claude/preview-select.test.ts | 178 ++++++++++++++++++ web/src/lib/harness/claude/preview-select.ts | 91 ++++++++- web/src/lib/multi-select-action.test.ts | 70 +++++-- web/src/lib/multi-select-action.ts | Bin 13350 -> 15057 bytes 19 files changed, 899 insertions(+), 137 deletions(-) create mode 100644 .gitattributes create mode 100644 web/src/components/wizard-stepper.tsx create mode 100644 web/src/fixtures/panes/claude--wizard-multiselect-checked.txt create mode 100644 web/src/fixtures/panes/claude--wizard-multiselect-final.txt create mode 100644 web/src/fixtures/panes/claude--wizard-multiselect-pointer-next.txt create mode 100644 web/src/fixtures/panes/claude--wizard-multiselect-q1.txt create mode 100644 web/src/fixtures/panes/claude--wizard-preview-wrapped-label.txt diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..013e56b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# TypeScript sources are text, always. multi-select-action.ts uses a NUL as a map-key separator, and +# a single such byte makes git classify the whole file as binary — which silently hid every change to +# the race guard from `git show` and from PR review. The byte is now a source escape, but this keeps +# any future one from costing reviewability. +*.ts diff +*.tsx diff diff --git a/web/src/components/multi-select-block.test.tsx b/web/src/components/multi-select-block.test.tsx index 04a59e1..a2150e4 100644 --- a/web/src/components/multi-select-block.test.tsx +++ b/web/src/components/multi-select-block.test.tsx @@ -61,7 +61,7 @@ describe("MultiSelectBlock — checkbox screen", () => { // A tap locks every control until the (awaited) handler resolves; vi.fn() resolves synchronously, // so subsequent taps go through in order. await user.click(screen.getByRole("button", { name: /^Submit$/ })); - expect(onAction).toHaveBeenLastCalledWith({ kind: "submit" }); + expect(onAction).toHaveBeenLastCalledWith({ kind: "advance" }); await user.click(screen.getByRole("button", { name: /Chat about this/ })); expect(onAction).toHaveBeenLastCalledWith({ kind: "escape" }); @@ -91,3 +91,62 @@ describe("MultiSelectBlock — review screen", () => { expect(onAction).toHaveBeenLastCalledWith({ kind: "cancel" }); }); }); + +// A checkbox question that is one STEP of a wizard. Everything the parser lifts for this shape — +// the chips, the Left/Right navigation, and the advance row's literal label — has to reach the DOM, +// and none of it is exercised by the single-question fixtures (their `steps` is null). +describe("MultiSelectBlock — as a step of a wizard", () => { + it("renders the stepper, and names the advance control what the terminal calls it", () => { + const model = fixtureModel("claude--wizard-multiselect-q1.txt"); + render(); + + const steps = screen.getByRole("list", { name: "Questions" }); + expect(within(steps).getAllByRole("listitem").map((li) => li.textContent)).toEqual([ + "Toppings", + "Crust", + "Submit", + ]); + // Not "Submit": this is question 1 of 2, and the button must not claim to finish the dialog. + expect(screen.getByRole("button", { name: "Next" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Submit" })).not.toBeInTheDocument(); + }); + + it("says Submit on the last step", () => { + const model = fixtureModel("claude--wizard-multiselect-final.txt"); + render(); + expect(screen.getByRole("button", { name: "Submit" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Next" })).not.toBeInTheDocument(); + }); + + it("marks the current question and announces where you are", () => { + const model = fixtureModel("claude--wizard-multiselect-final.txt"); + render(); + // aria-current alone moves silently between list items, so the position is also spoken. + expect(screen.getByRole("status")).toHaveTextContent("Step 2 of 3, Extras"); + const current = within(screen.getByRole("list", { name: "Questions" })) + .getAllByRole("listitem") + .filter((li) => li.getAttribute("aria-current") === "step"); + expect(current.map((li) => li.textContent)).toEqual(["Extras"]); + }); + + it("navigates between questions with the wizard's own keys", async () => { + const user = userEvent.setup(); + const onAction = vi.fn(); + render(); + + await user.click(screen.getByRole("button", { name: "Previous step" })); + expect(onAction).toHaveBeenLastCalledWith({ kind: "nav", keys: ["Left"] }); + await user.click(screen.getByRole("button", { name: "Next step" })); + expect(onAction).toHaveBeenLastCalledWith({ kind: "nav", keys: ["Right"] }); + }); + + it("disables Back on the first question — there is nothing to its left", () => { + render(); + expect(screen.getByRole("button", { name: "Previous step" })).toBeDisabled(); + }); + + it("shows no stepper for a standalone single-question dialog", () => { + render(); + expect(screen.queryByRole("list", { name: "Questions" })).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/components/multi-select-block.tsx b/web/src/components/multi-select-block.tsx index dd97003..296cea1 100644 --- a/web/src/components/multi-select-block.tsx +++ b/web/src/components/multi-select-block.tsx @@ -5,6 +5,8 @@ import { cn } from "@/lib/utils"; import type { MultiSelectModel } from "@/lib/blocks"; import type { MultiSelectIntent } from "@/lib/multi-select-action"; import { KeyBadge, optionSurface, PromptPanel, QuestionHeading } from "@/components/option-button"; +import { WizardStepper } from "@/components/wizard-stepper"; +import { WIZARD_BACK_KEYS, WIZARD_NEXT_KEYS } from "@/lib/harness/claude/wizard"; export interface MultiSelectBlockProps { /** The detected multi-select dialog (checkbox screen or review screen). */ @@ -66,6 +68,17 @@ function CheckboxPhase({ }) { return ( + {multi.steps && ( + onPress("nav-back", { kind: "nav", keys: WIZARD_BACK_KEYS })} + onNext={() => onPress("nav-next", { kind: "nav", keys: WIZARD_NEXT_KEYS })} + /> + )} {multi.question}
{multi.options.map((option) => { @@ -111,15 +124,20 @@ function CheckboxPhase({ })}
- {/* Submit advances to the review screen — the handler drives the closed-loop macro. */} + {/* The advance row — "Submit" on the last question, "Next" before it. The handler drives the + closed-loop macro that walks the pointer onto it and verifies before pressing Enter. */} {/* "Chat about this" ABORTS the tool — de-emphasised, apart from the answers (like the wizard). */} diff --git a/web/src/components/preview-select-block.tsx b/web/src/components/preview-select-block.tsx index d5eacb1..8c1ecee 100644 --- a/web/src/components/preview-select-block.tsx +++ b/web/src/components/preview-select-block.tsx @@ -1,7 +1,6 @@ import { useState } from "react"; import { Check, - ChevronLeft, ChevronRight, Loader2, Pencil, @@ -9,6 +8,7 @@ import { Trash2, } from "lucide-react"; +import { WizardStepper } from "@/components/wizard-stepper"; import { cn } from "@/lib/utils"; import type { PreviewOption, PreviewSelectModel } from "@/lib/blocks"; import { @@ -75,7 +75,6 @@ export function PreviewSelectBlock({ preview, onAction, disabled }: PreviewSelec } const wizard = preview.steps !== null; - const atFirstQuestion = wizard && (preview.steps![0]?.current ?? false); const pointedLabel = preview.options.find((o) => o.pointed)?.label; const busyIcon = ( @@ -87,48 +86,15 @@ export function PreviewSelectBlock({ preview, onAction, disabled }: PreviewSelec a preview question is just one step of the same dialog. Single-question dialogs keep their question/chip line in the raw mirror above, so neither renders here. */} {wizard && ( -
- -
    - {preview.steps!.map((step, i) => ( -
  1. - {step.answered ? ( - - ) : null} - {step.label} -
  2. - ))} -
  3. - Submit -
  4. -
- -
+ press("nav-back", { kind: "nav", keys: WIZARD_BACK_KEYS })} + onNext={() => press("nav-next", { kind: "nav", keys: WIZARD_NEXT_KEYS })} + /> )} {wizard && {preview.question}} {!wizard && Choose an option} diff --git a/web/src/components/wizard-block.tsx b/web/src/components/wizard-block.tsx index 24b5cd7..8052ad7 100644 --- a/web/src/components/wizard-block.tsx +++ b/web/src/components/wizard-block.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; -import { AlertTriangle, Check, ChevronLeft, ChevronRight, Loader2 } from "lucide-react"; +import { AlertTriangle, Check, Loader2 } from "lucide-react"; -import { cn } from "@/lib/utils"; +import { WizardStepper } from "@/components/wizard-stepper"; import type { WizardModel, WizardOption } from "@/lib/blocks"; import { OptionButton, PromptPanel, QuestionHeading } from "@/components/option-button"; import { @@ -49,61 +49,23 @@ export function WizardBlock({ wizard, onAction, disabled }: WizardBlockProps) { // review step are no-ops, so disable those arrows rather than send a keystroke that does nothing. // When no chip reads as current (an unknown theme's highlight), both stay enabled — the TUI still // clamps, and keeping nav available is the safer degradation. - const atFirstQuestion = !review && (wizard.steps[0]?.current ?? false); const busyIcon = ; return ( {/* Stepper: one chip per question plus the fixed Submit step, flanked by the same back/next navigation the TUI drives with ←/→ (each tap sends exactly that one key). */} -
- -
    - {wizard.steps.map((step, i) => ( -
  1. - {step.answered ? : null} - {step.label} -
  2. - ))} -
  3. - Submit -
  4. -
- -
+ press("back", WIZARD_BACK_KEYS)} + onNext={() => press("next", WIZARD_NEXT_KEYS)} + /> {wizard.phase === "question" ? ( void; + onNext: () => void; +}) { + // The first question has nothing to its left; the TUI clamps there anyway, but a disabled control + // says so rather than sending a key that does nothing. + const atFirstQuestion = !submitCurrent && (steps[0]?.current ?? false); + const currentIndex = steps.findIndex((s) => s.current); + // Which step you are on is carried only by `aria-current` moving between list items, and by the + // advance button's name changing — neither of which is reliably announced. Say it out loud, once, + // on change. Politely: it is context, not an interruption. + const position = submitCurrent + ? `Step ${steps.length + 1} of ${steps.length + 1}, Submit` + : currentIndex >= 0 + ? `Step ${currentIndex + 1} of ${steps.length + 1}, ${steps[currentIndex]!.label}` + : ""; + return ( +
+ + {position} + + +
    + {steps.map((step, i) => ( +
  1. + {step.answered ? ( + + ) : null} + {step.label} +
  2. + ))} + {/* The trailing Submit pill is the dialog's last stop, and on the wizard's review step it is + where you actually are — hence `submitCurrent` rather than a hardcoded never. */} +
  3. + Submit +
  4. +
+ +
+ ); +} diff --git a/web/src/fixtures/panes/README.md b/web/src/fixtures/panes/README.md index d0b90c5..0612b99 100644 --- a/web/src/fixtures/panes/README.md +++ b/web/src/fixtures/panes/README.md @@ -87,6 +87,11 @@ deliberately NOT matched by prompt-select or the wizard grammar. | `claude--select-preview-note-attached.txt` | Committed note (`Notes: prefer subtle shadows`), input blurred | | `claude--wizard-preview-q1.txt` | 2-question wizard whose Q1 is a preview step: stepper header above the preview layout | | `claude--wizard-preview-note-attached.txt` | Same wizard step with a note attached | +| `claude--wizard-multiselect-q1.txt` | **A multiSelect question as one STEP of a wizard** — the shape no grammar owned. Stepper `← ☐ Toppings ☐ Crust ✔ Submit →`, checkbox rows with description sub-lines, and a navigable **`Next`** row (not `Submit`) because this isn't the last question | +| `claude--wizard-multiselect-checked.txt` | Same step with boxes 1 and 3 ticked; the question chip flips `☐`→`☒` on the FIRST tick — "answered" means touched, not complete | +| `claude--wizard-multiselect-pointer-next.txt` | Same step with the `❯` pointer on the `Next` row — the state the advance macro walks to and verifies before pressing Enter. Note the footer gains `ctrl+g to edit in Vim` here, which is why the signature stops before it | +| `claude--wizard-multiselect-final.txt` | A multiSelect as the **LAST** step: the row reads `Submit`, and the earlier chip shows `☒ Size` | +| `claude--wizard-preview-wrapped-label.txt` | Same wizard step whose **option 1 label wraps** onto two continuation rows, so the numbered rows are no longer adjacent — the shape that used to defeat detection entirely. **Derived**, not captured: the left gutter of `claude--wizard-preview-q1.txt` was rewritten and every byte from the Notes column rightward carried over untouched (the observed live shape came from a real pane whose content can't go in a public repo) | All sandbox-generated (a scratch pane driven through the bridge) except `claude--working.txt`, which is a real pane working on this repo. Every `blocked` fixture's menu sits at the **buffer diff --git a/web/src/fixtures/panes/claude--wizard-multiselect-checked.txt b/web/src/fixtures/panes/claude--wizard-multiselect-checked.txt new file mode 100644 index 0000000..d232486 --- /dev/null +++ b/web/src/fixtures/panes/claude--wizard-multiselect-checked.txt @@ -0,0 +1,21 @@ +❯ Use the AskUserQuestion tool exactly once, with TWO questions in the one call. Question 1: header Toppings, multiSelect true, four options each with a one-line description.   + Question 2: header Crust, multiSelect false, two options. Do not read or write files, do not run commands, do nothing else.  +─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +←  ☒ Toppings  ☐ Crust ✔ Submit → + +Which toppings would you like on your pizza? + +❯ 1. [✔] Pepperoni + Classic cured pork-and-beef slices that crisp at the edges. + 2. [ ] Mushrooms + Earthy sliced creminis that soften into the cheese. + 3. [✔] Bell peppers + Sweet, slightly crunchy strips of red and green pepper. + 4. [ ] Extra cheese + A double layer of mozzarella for a richer, stretchier bite. + 5. [ ] Type something + Next +─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + 6. Chat about this + +Enter to select · Tab/Arrow keys to navigate · Esc to cancel \ No newline at end of file diff --git a/web/src/fixtures/panes/claude--wizard-multiselect-final.txt b/web/src/fixtures/panes/claude--wizard-multiselect-final.txt new file mode 100644 index 0000000..61e165c --- /dev/null +++ b/web/src/fixtures/panes/claude--wizard-multiselect-final.txt @@ -0,0 +1,19 @@ +❯ Now use the AskUserQuestion tool exactly once with TWO questions again, but this time make the SECOND question the multiSelect one. Question 1: header Size, multiSelect false,   + two options. Question 2: header Extras, multiSelect true, three options each with a one-line description. Nothing else.  +─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +← ☒ Size  ☐ Extras  ✔ Submit → + +Which extras should we add on the side? + +❯ 1. [ ] Garlic knots + Warm dough twists brushed with garlic butter and parsley. + 2. [ ] Caesar salad + Crisp romaine, shaved parmesan, and croutons in a creamy dressing. + 3. [ ] Dipping sauces + A trio of marinara, ranch, and garlic aioli for the crusts. + 4. [ ] Type something + Submit +─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + 5. Chat about this + +Enter to select · Tab/Arrow keys to navigate · Esc to cancel \ No newline at end of file diff --git a/web/src/fixtures/panes/claude--wizard-multiselect-pointer-next.txt b/web/src/fixtures/panes/claude--wizard-multiselect-pointer-next.txt new file mode 100644 index 0000000..422b0ea --- /dev/null +++ b/web/src/fixtures/panes/claude--wizard-multiselect-pointer-next.txt @@ -0,0 +1,21 @@ +❯ Use the AskUserQuestion tool exactly once, with TWO questions in the one call. Question 1: header Toppings, multiSelect true, four options each with a one-line description.   + Question 2: header Crust, multiSelect false, two options. Do not read or write files, do not run commands, do nothing else.  +─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +←  ☒ Toppings  ☐ Crust ✔ Submit → + +Which toppings would you like on your pizza? + + 1. [✔] Pepperoni + Classic cured pork-and-beef slices that crisp at the edges. + 2. [ ] Mushrooms + Earthy sliced creminis that soften into the cheese. + 3. [✔] Bell peppers + Sweet, slightly crunchy strips of red and green pepper. + 4. [ ] Extra cheese + A double layer of mozzarella for a richer, stretchier bite. + 5. [ ] Type something +❯ Next +─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + 6. Chat about this + +Enter to select · Tab/Arrow keys to navigate · ctrl+g to edit in Vim · Esc to cancel \ No newline at end of file diff --git a/web/src/fixtures/panes/claude--wizard-multiselect-q1.txt b/web/src/fixtures/panes/claude--wizard-multiselect-q1.txt new file mode 100644 index 0000000..f0e0f3c --- /dev/null +++ b/web/src/fixtures/panes/claude--wizard-multiselect-q1.txt @@ -0,0 +1,21 @@ +❯ Use the AskUserQuestion tool exactly once, with TWO questions in the one call. Question 1: header Toppings, multiSelect true, four options each with a one-line description.   + Question 2: header Crust, multiSelect false, two options. Do not read or write files, do not run commands, do nothing else.  +─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +←  ☐ Toppings  ☐ Crust ✔ Submit → + +Which toppings would you like on your pizza? + +❯ 1. [ ] Pepperoni + Classic cured pork-and-beef slices that crisp at the edges. + 2. [ ] Mushrooms + Earthy sliced creminis that soften into the cheese. + 3. [ ] Bell peppers + Sweet, slightly crunchy strips of red and green pepper. + 4. [ ] Extra cheese + A double layer of mozzarella for a richer, stretchier bite. + 5. [ ] Type something + Next +─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + 6. Chat about this + +Enter to select · Tab/Arrow keys to navigate · Esc to cancel \ No newline at end of file diff --git a/web/src/fixtures/panes/claude--wizard-preview-wrapped-label.txt b/web/src/fixtures/panes/claude--wizard-preview-wrapped-label.txt new file mode 100644 index 0000000..76304fd --- /dev/null +++ b/web/src/fixtures/panes/claude--wizard-preview-wrapped-label.txt @@ -0,0 +1,40 @@ + ⎿  · Which widget design should we use? → (notes only) + +● You didn't select any of the three options, but left a note: "prefer thicker borders." Stopping here as requested — let me know if you'd like me to mock up a thick-bordered variant. + +✻ Cooked for 13s + +❯ Ask me the exact same AskUserQuestion again (same question, options, descriptions, previews). Do nothing else afterward.  + +● User answered Claude's questions: + ⎿  · Which widget design should we use? → Minimal + +● You selected Minimal this time, with a note: "prefer subtle shadows." Stopping here as requested. + +✻ Churned for 11s + +❯ Now use AskUserQuestion ONCE with TWO questions in the same tool call: Q1 "Which card layout should we use?" with options Grid, List (short descriptions, and each option MUST have a preview field with a small ASCII   + mockup). Q2 "Should we add dark mode?" with options Yes, No (short descriptions, NO previews). Not multiSelect. Do nothing else afterward.  + +─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +←  ☐ Card layout ☐ Dark mode ✔ Submit → + +Which card layout should we use? + +❯ 1. Grid of equal-width cards ┌──────────────────────────────────────────┐ + with a fixed gutter │ +--------+ +--------+ │ + (Recommended) │ | Card 1 | | Card 2 | │ +  2. List │ | .... | | .... | │ + │ +--------+ +--------+ │ + │ +--------+ +--------+ │ + │ | Card 3 | | Card 4 | │ + │ | .... | | .... | │ + │ +--------+ +--------+ │ + └──────────────────────────────────────────┘ + + Notes: press n to add notes + +─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Chat about this + +Enter to select · ↑/↓ to navigate · n to add notes · Tab to switch questions · Esc to cancel \ No newline at end of file diff --git a/web/src/fixtures/prompt-binding-regions.json b/web/src/fixtures/prompt-binding-regions.json index eb824f1..1ddcfc6 100644 --- a/web/src/fixtures/prompt-binding-regions.json +++ b/web/src/fixtures/prompt-binding-regions.json @@ -74,6 +74,11 @@ "detector": "preview-select", "region": " ⎿  · Which widget design should we use? → (notes only)\n\n● You didn't select any of the three options, but left a note: \"prefer thicker borders.\" Stopping here as requested — let me know if you'd like me to mock up a thick-bordered variant.\n\n✻ Cooked for 13s\n\n❯ Ask me the exact same AskUserQuestion again (same question, options, descriptions, previews). Do nothing else afterward. \n\n● User answered Claude's questions:\n ⎿  · Which widget design should we use? → Minimal\n\n● You selected Minimal this time, with a note: \"prefer subtle shadows.\" Stopping here as requested.\n\n✻ Churned for 11s\n\n❯ Now use AskUserQuestion ONCE with TWO questions in the same tool call: Q1 \"Which card layout should we use?\" with options Grid, List (short descriptions, and each option MUST have a preview field with a small ASCII \n mockup). Q2 \"Should we add dark mode?\" with options Yes, No (short descriptions, NO previews). Not multiSelect. Do nothing else afterward. \n\n───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n← ☐ Card layout ☐ Dark mode ✔ Submit →\n\nWhich card layout should we use?\n\n❯ 1. Grid ┌──────────────────────────────────────────┐\n 2. List │ +--------+ +--------+ │\n │ | Card 1 | | Card 2 | │\n │ | .... | | .... | │\n │ +--------+ +--------+ │\n │ +--------+ +--------+ │\n │ | Card 3 | | Card 4 | │\n │ | .... | | .... | │\n │ +--------+ +--------+ │\n └──────────────────────────────────────────┘\n\n Notes: press n to add notes\n\n───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n Chat about this\n\nEnter to select · ↑/↓ to navigate · n to add notes · Tab to switch questions · Esc to cancel" }, + { + "fixture": "claude--wizard-preview-wrapped-label.txt", + "detector": "preview-select", + "region": " ⎿  · Which widget design should we use? → (notes only)\n\n● You didn't select any of the three options, but left a note: \"prefer thicker borders.\" Stopping here as requested — let me know if you'd like me to mock up a thick-bordered variant.\n\n✻ Cooked for 13s\n\n❯ Ask me the exact same AskUserQuestion again (same question, options, descriptions, previews). Do nothing else afterward. \n\n● User answered Claude's questions:\n ⎿  · Which widget design should we use? → Minimal\n\n● You selected Minimal this time, with a note: \"prefer subtle shadows.\" Stopping here as requested.\n\n✻ Churned for 11s\n\n❯ Now use AskUserQuestion ONCE with TWO questions in the same tool call: Q1 \"Which card layout should we use?\" with options Grid, List (short descriptions, and each option MUST have a preview field with a small ASCII \n mockup). Q2 \"Should we add dark mode?\" with options Yes, No (short descriptions, NO previews). Not multiSelect. Do nothing else afterward. \n\n───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n← ☐ Card layout ☐ Dark mode ✔ Submit →\n\nWhich card layout should we use?\n\n❯ 1. Grid of equal-width cards ┌──────────────────────────────────────────┐\n with a fixed gutter │ +--------+ +--------+ │\n (Recommended) │ | Card 1 | | Card 2 | │\n 2. List │ | .... | | .... | │\n │ +--------+ +--------+ │\n │ +--------+ +--------+ │\n │ | Card 3 | | Card 4 | │\n │ | .... | | .... | │\n │ +--------+ +--------+ │\n └──────────────────────────────────────────┘\n\n Notes: press n to add notes\n\n───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n Chat about this\n\nEnter to select · ↑/↓ to navigate · n to add notes · Tab to switch questions · Esc to cancel" + }, { "fixture": "claude--wizard-q1-revisit.txt", "detector": "wizard", @@ -98,5 +103,25 @@ "fixture": "claude--wizard-submit.txt", "detector": "wizard", "region": "← ☒ Focus area ☒ Scope ☒ Workflow ✔ Submit →\n\nReview your answers\n\n ● Which focus area should we work on?\n → UI\n ● What scope should this work have?\n → Medium\n ● How should we approach the work?\n → Plan first\n \nReady to submit your answers?\n\n❯ 1. Submit answers\n 2. Cancel" + }, + { + "fixture": "claude--wizard-multiselect-q1.txt", + "detector": "multi-select", + "region": "← ☐ Toppings ☐ Crust ✔ Submit →\n\nWhich toppings would you like on your pizza?\n\n❯ 1. [ ] Pepperoni\n Classic cured pork-and-beef slices that crisp at the edges.\n 2. [ ] Mushrooms\n Earthy sliced creminis that soften into the cheese.\n 3. [ ] Bell peppers\n Sweet, slightly crunchy strips of red and green pepper.\n 4. [ ] Extra cheese\n A double layer of mozzarella for a richer, stretchier bite.\n 5. [ ] Type something\n Next\n───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n 6. Chat about this" + }, + { + "fixture": "claude--wizard-multiselect-checked.txt", + "detector": "multi-select", + "region": "← ☒ Toppings ☐ Crust ✔ Submit →\n\nWhich toppings would you like on your pizza?\n\n❯ 1. [✔] Pepperoni\n Classic cured pork-and-beef slices that crisp at the edges.\n 2. [ ] Mushrooms\n Earthy sliced creminis that soften into the cheese.\n 3. [✔] Bell peppers\n Sweet, slightly crunchy strips of red and green pepper.\n 4. [ ] Extra cheese\n A double layer of mozzarella for a richer, stretchier bite.\n 5. [ ] Type something\n Next\n───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n 6. Chat about this" + }, + { + "fixture": "claude--wizard-multiselect-pointer-next.txt", + "detector": "multi-select", + "region": "← ☒ Toppings ☐ Crust ✔ Submit →\n\nWhich toppings would you like on your pizza?\n\n 1. [✔] Pepperoni\n Classic cured pork-and-beef slices that crisp at the edges.\n 2. [ ] Mushrooms\n Earthy sliced creminis that soften into the cheese.\n 3. [✔] Bell peppers\n Sweet, slightly crunchy strips of red and green pepper.\n 4. [ ] Extra cheese\n A double layer of mozzarella for a richer, stretchier bite.\n 5. [ ] Type something\n❯ Next\n───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n 6. Chat about this" + }, + { + "fixture": "claude--wizard-multiselect-final.txt", + "detector": "multi-select", + "region": "← ☒ Size ☐ Extras ✔ Submit →\n \nWhich extras should we add on the side?\n\n❯ 1. [ ] Garlic knots\n Warm dough twists brushed with garlic butter and parsley.\n 2. [ ] Caesar salad\n Crisp romaine, shaved parmesan, and croutons in a creamy dressing.\n 3. [ ] Dipping sauces\n A trio of marinara, ranch, and garlic aioli for the crusts.\n 4. [ ] Type something\n Submit\n───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n 5. Chat about this" } ] diff --git a/web/src/lib/harness/claude/multi-select.test.ts b/web/src/lib/harness/claude/multi-select.test.ts index c549570..d912167 100644 --- a/web/src/lib/harness/claude/multi-select.test.ts +++ b/web/src/lib/harness/claude/multi-select.test.ts @@ -234,3 +234,128 @@ describe("wizard bails on a multiSelect step inside a multi-question wizard (v1 expect(detectMultiSelect(lines)).toBeNull(); }); }); + +// A multiSelect question can also be ONE STEP of a multi-question wizard. That shape differs from the +// standalone dialog in two ways the grammar has to know about: the stepper carries N question chips +// instead of one, and the navigable row reads "Next" until the last question, where it reads +// "Submit". Fixtures are sandbox captures of a real two-question dialog driven end to end. +describe("detectMultiSelect — a checkbox question inside a wizard", () => { + const checkbox = (name: string) => { + const m = detectMultiSelect(fixtureLines(name)); + expect(m).not.toBeNull(); + if (m!.phase !== "checkbox") throw new Error("expected checkbox phase"); + return m!; + }; + + it("lifts a non-final step, with the wizard's chips and a Next advance row", () => { + const m = checkbox("claude--wizard-multiselect-q1.txt"); + expect(m.steps).toEqual([ + { label: "Toppings", answered: false, current: true }, + { label: "Crust", answered: false, current: false }, + ]); + expect(m.advanceLabel).toBe("Next"); + expect(m.question).toBe("Which toppings would you like on your pizza?"); + expect(m.options.map((o) => o.n)).toEqual([1, 2, 3, 4]); + expect(m.options.map((o) => o.label)).toEqual([ + "Pepperoni", + "Mushrooms", + "Bell peppers", + "Extra cheese", + ]); + expect(m.options.every((o) => !o.checked)).toBe(true); + // The free-text "Type something" row stays with the composer; "Chat about this" is the escape. + expect(m.escape?.label).toMatch(/chat about this/i); + expect(m.options[0]!.description).toMatch(/crisp at the edges/); + }); + + it("reads the ticked boxes, and the chip answered once ANY box is ticked", () => { + const m = checkbox("claude--wizard-multiselect-checked.txt"); + expect(m.options.map((o) => o.checked)).toEqual([true, false, true, false]); + // "Answered" means touched, not complete — two of four boxes is enough to flip ☐ → ☒. + expect(m.steps![0]).toEqual({ label: "Toppings", answered: true, current: true }); + }); + + it("recognises the pointer sitting on the advance row", () => { + // This is the state the closed-loop macro walks to and verifies before it ever presses Enter. + const m = checkbox("claude--wizard-multiselect-pointer-next.txt"); + expect(m.pointer).toBe("advance"); + }); + + it("reads Submit on the LAST step, not Next", () => { + const m = checkbox("claude--wizard-multiselect-final.txt"); + expect(m.advanceLabel).toBe("Submit"); + expect(m.steps).toEqual([ + { label: "Size", answered: true, current: false }, + { label: "Extras", answered: false, current: true }, + ]); + expect(m.question).toBe("Which extras should we add on the side?"); + }); + + it("leaves the standalone single-question dialog with no steps", () => { + // The generalisation must not turn every multiSelect into a wizard: with one question there is + // nowhere to navigate, so the block renders no stepper. + const m = checkbox("claude--select-multiselect-single.txt"); + expect(m.steps).toBeNull(); + expect(m.advanceLabel).toBe("Submit"); + }); + + it("does not claim the wizard's own review screen", () => { + // A multi-question dialog reviews ONCE at the end, and that screen belongs to the wizard grammar. + // Two grammars claiming one screen is how a tap comes to mean two different things. + const lines = fixtureLines("claude--wizard-submit.txt"); + expect(detectMultiSelect(lines)).toBeNull(); + expect(detectWizard(lines)).not.toBeNull(); + }); +}); + +// Pane text is model-authored and untrusted, so 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 +// the first line that happens to read "Submit"/"Next". These three cases are what text-matching cost. +describe("detectMultiSelect — the advance row is a place, not a word", () => { + function pane(rows: string[], advance: string | null): string { + return [ + "← ☐ Toppings ✔ Submit →", + "", + "Which toppings would you like?", + "", + ...rows, + ...(advance === null ? [] : [` ${advance}`]), + "─".repeat(60), + // The escape continues the menu's 1..m numbering — trailingMenuRows requires the run. + ` ${rows.filter((r) => /^\s*❯?\s*\d+\./.test(r)).length + 1}. Chat about this`, + "", + "Enter to select · ↑/↓ to navigate · Esc to cancel", + ].join("\n"); + } + const model = (s: string) => detectMultiSelect(splitLines(parseAnsi(s))); + + it("a description reading 'Next' does not rename the button", () => { + // Otherwise the control advertises an action it does not perform: it would say "Next" while the + // tap walks onto the real Submit and ends the whole dialog. + const m = model(pane(["❯ 1. [ ] Garlic knots", " Next", " 2. [ ] Caesar salad"], "Submit")); + expect(m).not.toBeNull(); + if (m!.phase !== "checkbox") throw new Error("expected checkbox phase"); + expect(m!.advanceLabel).toBe("Submit"); + // …and the decoy stays visible as the description it actually is. + expect(m!.options[0]!.description).toBe("Next"); + }); + + it("bails when there is no advance row, however much the text looks like one", () => { + // The fail-closed guard exists for the garbled render. A description satisfying it would leave + // the macro spraying Down keys into a live pane, hunting a row that isn't there. + expect(model(pane(["❯ 1. [ ] Garlic knots", " Submit", " 2. [ ] Caesar salad"], null))).toBeNull(); + }); + + it("a decoy '❯ Submit' line does not forge the pointer", () => { + // pointerAt returns on the FIRST ❯ it sees. If a decoy above the real pointer could claim + // "advance", the macro would press Enter on whatever row the terminal's ❯ is really on — and on + // "Chat about this" that aborts the entire tool call. + const m = model(pane([" 1. [ ] Garlic knots", " ❯ Submit", "❯ 2. [ ] Caesar salad"], "Submit")); + expect(m).not.toBeNull(); + if (m!.phase !== "checkbox") throw new Error("expected checkbox phase"); + // A decoy still shadows the real ❯ (pointerAt takes the first one), so this reads "other" rather + // than "option" — which is fine: every non-"advance" pointer costs the macro a Down, never an + // Enter. What must never happen is "advance". + expect(m!.pointer).not.toBe("advance"); + }); +}); diff --git a/web/src/lib/harness/claude/multi-select.ts b/web/src/lib/harness/claude/multi-select.ts index 5068c23..cded1f3 100644 --- a/web/src/lib/harness/claude/multi-select.ts +++ b/web/src/lib/harness/claude/multi-select.ts @@ -18,6 +18,7 @@ import type { StyledLine } from "../../blocks"; import { classifyFooter, isBlank, isHorizontalRule, lineText } from "./markers"; import { checkboxState, isFreeTextLabel, parseOptionRow, trailingMenuRows } from "./prompt-select"; +import { parseStepperLine, type WizardStepChip } from "./wizard"; /** One checkable option of the current checkbox question. */ export interface MultiSelectOption { @@ -38,10 +39,10 @@ export interface MultiSelectEscape { label: string; } -/** Which KIND of row the `❯` pointer sits on — the Submit macro drives it to `submit` before Enter. +/** Which KIND of row the `❯` pointer sits on — the advance macro drives it to `advance` before Enter. * Parsed SEPARATELY from the signature (which normalises the pointer out), so the macro's own * Down/Up moves don't perturb the race-guard identity. */ -export type MultiPointer = "submit" | "chat" | "option" | "other" | null; +export type MultiPointer = "advance" | "chat" | "option" | "other" | null; /** * The detected multi-select dialog, a union on `phase`: @@ -63,6 +64,17 @@ export type MultiSelectModel = options: MultiSelectOption[]; escape: MultiSelectEscape | null; pointer: MultiPointer; + /** + * The wizard stepper's chips when this checkbox question is one STEP of a multi-question + * dialog; null when it's a standalone single-question multiSelect. Same distinction (and same + * Left/Right navigation) as `PreviewSelectModel.steps`. + */ + steps: WizardStepChip[] | null; + /** + * The advance row's literal label — `Submit` on the last question, `Next` on every earlier one. + * Captured rather than assumed so the button says what the terminal says. + */ + advanceLabel: string; signature: string; /** * Literal contiguous text over the same stepper-to-last-menu-row span as `signature`. It ends @@ -102,8 +114,14 @@ const STEPPER_SCAN_LIMIT = 10; const REVIEW_SCAN_WINDOW = 48; const CHAT_ESCAPE = /^chat about this\b/i; -// The navigable Submit row (no digit — Enter activates it), possibly carrying the pointer. -const SUBMIT_ROW = /^❯?\s*Submit$/; +// The navigable ADVANCE row (no digit — Enter activates it), possibly carrying the pointer. Its label +// is `Submit` on the LAST question and `Next` on every earlier one, so the word is captured rather +// than assumed — hard-coding either would break half the steps of a multi-question dialog. +// +// Note this row exists ONLY on a checkbox question. A plain wizard question has none: its digits +// select AND advance in one press, whereas a checkbox digit only toggles, so the advance needs its +// own control. That is what makes the row a reliable part of this grammar rather than incidental. +const ADVANCE_ROW = /^❯?\s*(Submit|Next)$/; const READY_PROMPT = /^ready to submit your answers\?/i; // --------------------------------------------------------------------------------------------- @@ -131,6 +149,29 @@ function isSingleQuestionStepper(line: StyledLine): boolean { return SUBMIT_GLYPHS.includes(submit.glyph) && /^submit$/i.test(submit.label); } +/** + * Scan upward for the stepper above a CHECKBOX question, in either of its two shapes: the + * single-question stepper (`☐ Toppings ✔ Submit`) or the multi-question wizard's own + * (`☐ Types ☐ Anchoring … ✔ Submit`), parsed by the wizard's `parseStepperLine` so the chip + * grammar lives in exactly one place. `steps` is null for the single-question form — the same + * distinction `preview-select` draws, and for the same reason: a wizard step gains Left/Right + * navigation between questions, a standalone dialog has nowhere to navigate to. + */ +function findCheckboxStepper( + lines: StyledLine[], + texts: string[], + start: number, + floor: number, +): { index: number; steps: WizardStepChip[] | null } | null { + for (let i = start; i >= 0 && i >= floor; i--) { + if (isHorizontalRule(texts[i]!)) return null; + if (isSingleQuestionStepper(lines[i]!)) return { index: i, steps: null }; + const wizardStepper = parseStepperLine(lines[i]!); + if (wizardStepper) return { index: i, steps: wizardStepper.chips }; + } + return null; +} + /** Scan upward from `start` down to `floor` for the single-question stepper, stopping at a horizontal * rule (the dialog border — the stepper always sits below it). Returns its line index, or -1. */ function findSingleStepper( @@ -166,11 +207,12 @@ function coreSignature(texts: string[], from: number, to: number): string { /** Classify which row the `❯` pointer sits on across [`from` … `to`] (raw line text — the pointer is * a leading glyph). `null` when no row carries it. */ -function pointerAt(texts: string[], from: number, to: number): MultiPointer { +function pointerAt(texts: string[], from: number, to: number, advanceIdx: number): MultiPointer { for (let i = from; i <= to; i++) { if (!/^\s*❯/.test(texts[i]!)) continue; const trimmed = texts[i]!.trim(); - if (SUBMIT_ROW.test(trimmed)) return "submit"; + // By index: a line that merely READS "Submit"/"Next" is not the advance row (see its location above). + if (i === advanceIdx) return "advance"; const parsed = parseOptionRow(trimmed); if (!parsed) return "other"; if (CHAT_ESCAPE.test(parsed.label)) return "chat"; @@ -230,15 +272,40 @@ function detectCheckboxPhase( const firstOpt = menu[0]!.index; if (fi - menu[menu.length - 1]!.index > MAX_FOOTER_GAP) return null; - // The screen MUST carry a navigable "Submit" row (Enter on it advances to review). The Submit macro - // walks the pointer ONTO that row before it ever Enters; if the row is absent (a partial / garbled - // render), fail closed to the raw mirror rather than lift a dialog whose Submit the macro would hunt - // for by typing nav keys into the live pane. (The `✔ Submit` stepper CHIP is not this row — - // SUBMIT_ROW anchors the whole line, so the chip never satisfies it.) - if (!texts.slice(firstOpt, fi).some((t) => SUBMIT_ROW.test(t.trim()))) return null; + // The screen MUST carry the navigable advance row, and we locate it by POSITION, not by scanning + // for the first line that reads "Submit"/"Next". Claude Code draws it in exactly one place: last + // non-blank line above the rule that separates the menu from the "Chat about this" escape. + // + // Position matters because the text is model-authored and untrusted. Matching on text alone, an + // option DESCRIPTION reading "Next" would (a) rename the button, so it advertises an action it + // doesn't perform, (b) satisfy the "row is absent → fail closed" check on a pane that has no + // advance row at all, leaving the macro to spray Down keys into a live pane hunting for it, and + // (c) via pointerAt, be reported as the pointer — making the macro press Enter on whatever row the + // terminal's ❯ is really on, which on "Chat about this" aborts the whole tool call. + // + // No rule below the options means a layout we don't know: bail rather than guess. + let ruleIdx = -1; + for (let i = fi - 1; i > firstOpt; i--) { + if (isHorizontalRule(texts[i]!)) { + ruleIdx = i; + break; + } + } + if (ruleIdx < 0) return null; + let advanceIdx = -1; + for (let i = ruleIdx - 1; i > firstOpt; i--) { + if (isBlank(texts[i]!)) continue; + advanceIdx = i; + break; + } + if (advanceIdx < 0) return null; + const advanceMatch = ADVANCE_ROW.exec(texts[advanceIdx]!.trim()); + if (!advanceMatch) return null; + const advanceLabel = advanceMatch[1]!; - const stepperIdx = findSingleStepper(lines, texts, firstOpt - 1, firstOpt - STEPPER_SCAN_LIMIT); - if (stepperIdx < 0) return null; + const stepper = findCheckboxStepper(lines, texts, firstOpt - 1, firstOpt - STEPPER_SCAN_LIMIT); + if (!stepper) return null; + const stepperIdx = stepper.index; // The question: every non-blank line between the stepper and the first option, joined. const questionLines: string[] = []; @@ -266,7 +333,7 @@ function detectCheckboxPhase( const desc: string[] = []; for (let i = row.index + 1; i < nextIdx; i++) { const t = texts[i]!; - if (isBlank(t) || isHorizontalRule(t) || parseOptionRow(t) || SUBMIT_ROW.test(t.trim())) continue; + if (isBlank(t) || isHorizontalRule(t) || parseOptionRow(t) || i === advanceIdx) continue; desc.push(t.trim()); } options.push({ @@ -284,7 +351,9 @@ function detectCheckboxPhase( question, options, escape, - pointer: pointerAt(texts, firstOpt, fi), + steps: stepper.steps, + advanceLabel, + pointer: pointerAt(texts, firstOpt, fi, advanceIdx), // Signature ends at the LAST menu row, NOT the footer: Claude's footer gains/loses a // "· ctrl+g to edit in nano" hint depending on which row the ❯ sits on (present on the // free-text/Submit/chat rows, absent on the checkbox rows). Since the Submit macro walks the diff --git a/web/src/lib/harness/claude/preview-select.test.ts b/web/src/lib/harness/claude/preview-select.test.ts index e852470..3f747aa 100644 --- a/web/src/lib/harness/claude/preview-select.test.ts +++ b/web/src/lib/harness/claude/preview-select.test.ts @@ -200,3 +200,181 @@ describe("detectPreviewSelectRegion + buildBlocks — render boundary and gating } }); }); + +describe("detectPreviewSelect — wrapped option labels", () => { + // The left column is only ~30 wide, so Claude Code wraps a longer option label onto unnumbered + // continuation rows. That makes the NUMBERED rows non-adjacent — which the detector used to + // treat as an unknown layout and bail on, dropping a real dialog to the raw mirror. + it("joins a wrapped label and still reads the rest of the dialog", () => { + const model = detectPreviewSelect(fixtureLines("claude--wizard-preview-wrapped-label.txt")); + expect(model).not.toBeNull(); + expect(model!.options.map((o) => o.label)).toEqual([ + "Grid of equal-width cards with a fixed gutter (Recommended)", + "List", + ]); + expect(model!.options.map((o) => o.n)).toEqual([1, 2]); + expect(model!.options.map((o) => o.pointed)).toEqual([true, false]); + expect(model!.question).toBe("Which card layout should we use?"); + expect(model!.steps!.map((s) => s.label)).toEqual(["Card layout", "Dark mode"]); + // The wrapped rows are label text, not preview content — they must not leak across the column. + expect(model!.preview.join("\n")).not.toContain("Recommended"); + expect(model!.preview.join("\n")).toContain("Card 1"); + }); + + it("lifts to a preview-select block instead of falling through to raw", () => { + const blocks = buildBlocks(fixtureLines("claude--wizard-preview-wrapped-label.txt"), { + agent: "claude", + }); + expect(blocks.map((b) => b.kind)).toContain("preview-select"); + }); + + // A wrapped label on the LAST option puts continuation rows BELOW the final numbered row, where + // the detector previously demanded a blank left column. + function synthWrapped(opts: { tail?: string; pointer?: number } = {}): string { + const col = 30; + const pane = ["┌────┐", "│ MK │", "│ MK │", "└────┘"]; + const pointer = opts.pointer ?? 1; + const rows = [ + `${pointer === 1 ? "❯" : " "} 1. Boxy`.padEnd(col) + pane[0], + `${pointer === 2 ? "❯" : " "} 2. Rounded corners on every`.padEnd(col) + pane[1], + ` ${opts.tail ?? "edge of the card"}`.padEnd(col) + pane[2], + " ".repeat(col) + pane[3], + ]; + return [ + " ☐ Design", + "", + "Which widget design should we use?", + "", + ...rows, + "", + " ".repeat(col) + "Notes: press n to add notes", + "", + "─".repeat(50), + " Chat about this", + "", + "Enter to select · n to add notes · Esc to cancel", + ].join("\n"); + } + const model = (s: string) => detectPreviewSelect(splitLines(parseAnsi(s))); + + it("handles a wrapped label on the FINAL option", () => { + const m = model(synthWrapped()); + expect(m).not.toBeNull(); + expect(m!.options.map((o) => o.label)).toEqual(["Boxy", "Rounded corners on every edge of the card"]); + }); + + it("folds the wrapped tail into the core signature", () => { + // The tail is part of the dialog's identity: two dialogs differing only in a wrapped + // continuation row must not compare equal, or the race guard would let a keystroke land on a + // dialog the user never saw. + const base = model(synthWrapped())!.coreSignature; + expect(model(synthWrapped({ tail: "side of the card" }))!.coreSignature).not.toBe(base); + // …while our own pointer move still leaves it untouched. + expect(model(synthWrapped({ pointer: 2 }))!.coreSignature).toBe(base); + }); +}); + +describe("detectPreviewSelect — the label column is the discriminator", () => { + // A wrapped line hangs UNDER its label. That column is the only thing separating "more of the + // option above" from "a new option" / "not label text at all", and both directions can bite. + function pane(gutterRows: string[], col: number, pane_: string[]): string { + const rows = gutterRows.map((g, i) => g.padEnd(col) + (pane_[i] ?? "")); + return [ + " ☐ Design", + "", + "Which widget design should we use?", + "", + ...rows, + "", + " ".repeat(col) + "Notes: press n to add notes", + "", + "─".repeat(50), + " Chat about this", + "", + "Enter to select · n to add notes · Esc to cancel", + ].join("\n"); + } + const model = (s: string) => detectPreviewSelect(splitLines(parseAnsi(s))); + + it("does not mint a phantom option from a number inside a wrapped label", () => { + // "…and 3. Backfill later" wraps so the continuation row parses as a numbered row, and its + // number is exactly k+1 — so the 1..k check would pass and render a third button that types a + // stray "3" into the terminal. + const m = model( + pane( + ["❯ 1. Boxy", " 2. Ship the migration in", " the second phase and", " 3. Backfill later"], + 30, + ["┌────┐", "│ MK │", "│ MK │", "└────┘"], + ), + ); + expect(m).not.toBeNull(); + expect(m!.options).toHaveLength(2); + expect(m!.options[1]!.label).toBe("Ship the migration in the second phase and 3. Backfill later"); + }); + + it("bails when left-column content below the list isn't label text", () => { + // A layout where the Notes column and the preview pane DON'T coincide: the box is drawn 2 + // columns left of `Notes:`, so preview rows land in the left slice. Folding those into the last + // option would put box-drawing garbage on a button that still types a digit. + const rows = ["❯ 1. Boxy".padEnd(32) + "┌──────┐", " 2. List".padEnd(32) + "│ AAAA │", + " ".repeat(32) + "│ AAAA │", " ".repeat(32) + "└──────┘"]; + const text = [ + " ☐ Design", "", "Which widget design should we use?", "", ...rows, "", + " ".repeat(34) + "Notes: press n to add notes", "", + "─".repeat(50), " Chat about this", "", + "Enter to select · n to add notes · Esc to cancel", + ].join("\n"); + expect(model(text)).toBeNull(); + }); + + it("keeps the chosen tick when it lands on the wrapped label's first row", () => { + // A revisited question paints ✔ at the end of the row it marks — the middle of the joined + // string. An end-anchored test would miss it AND leave the tick sitting inside the button text. + const m = model( + pane(["❯ 1. Grid of equal-width ✔", " cards with a gutter", " 2. List"], 30, + ["┌────┐", "│ MK │", "└────┘"]), + ); + expect(m).not.toBeNull(); + expect(m!.options[0]!.label).toBe("Grid of equal-width cards with a gutter"); + expect(m!.options[0]!.chosen).toBe(true); + }); + + it("bails past 9 options — a two-key digit is unsendable", () => { + const labels = Array.from({ length: 10 }, (_, i) => `${i === 0 ? "❯" : " "} ${i + 1}. Option`); + expect(model(pane(labels, 30, ["┌────┐"]))).toBeNull(); + }); +}); + +describe("detectPreviewSelect — the label column comes from a real option row", () => { + it("a bare 'N.' with no label cannot shift the column that discriminates", () => { + // OPTION_LABEL_START matches " 12." but parseOptionRow does not (no label). Deriving labelCol + // from such a line widens it, so a wrapped continuation then reads as a new option — a phantom + // button that types a digit no option in the terminal carries. + const col = 30; + const pane = ["┌────┐", "│ MK │", "│ MK │", "└────┘"]; + const rows = [ + " 12.".padEnd(col) + pane[0], + "❯ 1. Alpha".padEnd(col) + pane[1], + " 2. Rename now and".padEnd(col) + pane[2], + " 3. Backfill later".padEnd(col) + pane[3], + ]; + const text = [ + " ☐ Design", + "", + "Which widget design should we use?", + "", + ...rows, + "", + " ".repeat(col) + "Notes: press n to add notes", + "", + "─".repeat(50), + " Chat about this", + "", + "Enter to select · n to add notes · Esc to cancel", + ].join("\n"); + const m = detectPreviewSelect(splitLines(parseAnsi(text))); + expect(m).not.toBeNull(); + expect(m!.options).toHaveLength(2); + expect(m!.options[1]!.label).toBe("Rename now and 3. Backfill later"); + }); +}); diff --git a/web/src/lib/harness/claude/preview-select.ts b/web/src/lib/harness/claude/preview-select.ts index 302f8dc..ec1c802 100644 --- a/web/src/lib/harness/claude/preview-select.ts +++ b/web/src/lib/harness/claude/preview-select.ts @@ -103,6 +103,16 @@ const ESCAPE_ROW = /^❯?\s*Chat about this$/; /** The chosen-row marker a revisited answered question shows: "1. Grid ✔". */ const CHOSEN_SUFFIX = /\s*✔\s*$/; +// An option row's prefix — indentation, the optional ❯ pointer, the number, the dot and the space +// after it. Its LENGTH is the column the label starts at, which is the anchor a wrapped continuation +// row hangs under. Matched on the UNtrimmed left slice (parseOptionRow trims, losing the column). +const OPTION_LABEL_START = /^\s*(?:❯\s*)?\d+\.\s+/; + +/** Leading-space count — how far into the gutter a row's text starts. */ +function indentOf(text: string): number { + return text.length - text.trimStart().length; +} + // Bounded windows, same philosophy as the sibling grammars: the Notes line sits within a few lines // of the footer (rule + escape row between); options+preview sit within a screenful above it. const NOTES_SCAN_LIMIT = 8; @@ -151,24 +161,85 @@ export function detectPreviewSelectRegion(lines: StyledLine[]): PreviewSelectReg const noteCol = texts[notesIdx]!.indexOf("Notes:"); const note = parseNote(texts[notesIdx]!.slice(noteCol + "Notes:".length), editing); - // 3. Option rows: numbered rows 1..k in CONSECUTIVE lines (the left column never wraps its - // 30-col labels), matched on the text LEFT of the preview column. + // 3. Option rows: numbered rows 1..k, matched on the text LEFT of the preview column. Unlike the + // full-width grammars, this variant's left column is only ~30 wide, so a longer label WRAPS + // onto unnumbered continuation rows beneath it — the numbered rows are therefore NOT + // necessarily adjacent. What must hold is that the option list is CONTIGUOUS once those + // continuation rows are counted: from the first numbered row down, every line is either the + // next numbered row or a continuation of the label above it, until the left column goes blank + // (the preview pane continuing alone below a short list). const from = Math.max(0, notesIdx - OPTION_SCAN_WINDOW); + + // The LABEL COLUMN — where an option's text begins, past its "❯ 1. " prefix — is what separates a + // new option from more of the one above it. A wrapped line hangs UNDER its label, so anything + // starting at or right of that column is label text, and anything left of it is a fresh option. + // Without this the grammar has no way to tell the two apart, and both directions bite: + // - a label ending "…and 3. Backfill later" would wrap to a row parsing as a phantom option 3; + // - in a layout where `Notes:` and the preview pane DON'T share a column, preview content would + // sit in the left slice and get folded into the last option's label as box-drawing garbage. + let labelCol = -1; + for (let i = from; i < notesIdx; i++) { + const left = texts[i]!.slice(0, noteCol); + const m = OPTION_LABEL_START.exec(left); + // Must be a REAL option row, not merely something shaped like a number and a dot: a bare "12." + // with no label matches the prefix but carries no option, and would shift the very column that + // decides what counts as a new option. + if (m && parseOptionRow(left)) { + labelCol = m[0]!.length; + break; + } + } + if (labelCol < 0) return null; + const rows: { index: number; n: number; label: string; pointed: boolean }[] = []; for (let i = from; i < notesIdx; i++) { const left = texts[i]!.slice(0, noteCol); + if (indentOf(left) >= labelCol) continue; // a number inside a wrapped label, not an option const parsed = parseOptionRow(left); if (parsed) rows.push({ index: i, pointed: /^\s*❯/.test(left), ...parsed }); } if (rows.length < 2) return null; - if (rows.some((r, k) => r.n !== k + 1 || r.index !== rows[0]!.index + k)) return null; + if (rows.some((r, k) => r.n !== k + 1)) return null; + // Past 9 the digit is two keys ("10"), which Herdr's send_keys rejects — so the button would render + // and then fail on tap. Bail to the raw mirror + keys pad instead, as prompt-select does. + if (rows.length > 9) return null; const firstOpt = rows[0]!.index; - const lastOpt = rows[rows.length - 1]!.index; - // Below the last row and above the Notes line only preview continuation (blank left column) may + // Walk the list, folding each wrapped row into its option's label. `lastOpt` ends up on the last + // line still owned by the list (a continuation row when the FINAL label wraps) — which is what + // the core signature must span, so a wrapped tail can't fall out of the dialog's identity. + // + // `chosen` is tracked per PIECE rather than read off the joined label: a revisited question paints + // its ✔ at the end of the row it marks, which for a wrapped label is the numbered row — the middle + // of the joined string, where an end-anchored test would miss it and leave the tick in the text. + const labels = rows.map((r) => r.label.replace(CHOSEN_SUFFIX, "")); + const chosen = rows.map((r) => CHOSEN_SUFFIX.test(r.label)); + let cursor = 0; + let lastOpt = firstOpt; + let scan = firstOpt; + for (; scan < notesIdx; scan++) { + const left = texts[scan]!.slice(0, noteCol); + if (isBlank(left)) break; + if (cursor + 1 < rows.length && rows[cursor + 1]!.index === scan) { + cursor++; + } else if (scan !== firstOpt) { + // Right of the label column this isn't label text — it's a layout we don't know. Fold only + // what hangs under the label; bail on anything else rather than put it on a button. + if (indentOf(left) > labelCol) return null; + const piece = left.trim(); + if (CHOSEN_SUFFIX.test(piece)) chosen[cursor] = true; + labels[cursor] += ` ${piece.replace(CHOSEN_SUFFIX, "")}`; + } + lastOpt = scan; + } + // Every numbered row must have been reached before the left column went blank — a stray "N." row + // below the gap is a different layout, not a wrapped label. + if (cursor !== rows.length - 1) return null; + + // Below the list and above the Notes line only preview continuation (blank left column) may // appear — a non-blank left cell there is a layout we don't know. - for (let i = lastOpt + 1; i < notesIdx; i++) { - if (!isBlank(texts[i]!.slice(0, noteCol))) return null; + for (; scan < notesIdx; scan++) { + if (!isBlank(texts[scan]!.slice(0, noteCol))) return null; } // 4. The preview pane: the right column of the region's lines, verbatim (borders included). @@ -213,11 +284,11 @@ export function detectPreviewSelectRegion(lines: StyledLine[]): PreviewSelectReg } } - const options: PreviewOption[] = rows.map((r) => ({ - label: r.label.replace(CHOSEN_SUFFIX, ""), + const options: PreviewOption[] = rows.map((r, k) => ({ + label: labels[k]!.trim(), n: r.n, pointed: r.pointed, - chosen: CHOSEN_SUFFIX.test(r.label), + chosen: chosen[k]!, })); return { diff --git a/web/src/lib/multi-select-action.test.ts b/web/src/lib/multi-select-action.test.ts index dd7ab8f..5b6f135 100644 --- a/web/src/lib/multi-select-action.test.ts +++ b/web/src/lib/multi-select-action.test.ts @@ -222,7 +222,7 @@ describe("submit macro — walk the pointer down onto Submit, then Enter", () => checkboxBuffer({ pointer: "opt2" }), // read: still an option row → Down checkboxBuffer({ pointer: "submit" }), // read: on Submit → Enter (stops here, no overshoot) ); - const res = await submitMultiSelectIntent({ ...base, multi: m, intent: { kind: "submit" } }); + const res = await submitMultiSelectIntent({ ...base, multi: m, intent: { kind: "advance" } }); expect(res).toEqual({ status: "sent" }); expect(mockSendKeys.mock.calls).toEqual([ ["w1:p1", ["Down"], undefined, m.regionSignature], @@ -242,7 +242,7 @@ describe("submit macro — walk the pointer down onto Submit, then Enter", () => error: "Prompt changed before keys were sent", code: "prompt_changed", }); - const res = await submitMultiSelectIntent({ ...base, multi: m, intent: { kind: "submit" } }); + const res = await submitMultiSelectIntent({ ...base, multi: m, intent: { kind: "advance" } }); expect(res).toEqual({ status: "changed" }); expect(mockSendKeys.mock.calls).toEqual([ ["w1:p1", ["Down"], undefined, m.regionSignature], @@ -257,7 +257,7 @@ describe("submit macro — walk the pointer down onto Submit, then Enter", () => checkboxBuffer({ pointer: "opt2" }), // read: STILL option (Down swallowed) → Down again checkboxBuffer({ pointer: "submit" }), // read: Submit → Enter ); - const res = await submitMultiSelectIntent({ ...base, multi: m, intent: { kind: "submit" } }); + const res = await submitMultiSelectIntent({ ...base, multi: m, intent: { kind: "advance" } }); expect(res).toEqual({ status: "sent" }); expect(keysSent()).toEqual([["Down"], ["Down"], ["Enter"]]); }); @@ -269,7 +269,7 @@ describe("submit macro — walk the pointer down onto Submit, then Enter", () => checkboxBuffer({ pointer: "chat" }), // read: on the bottom row → Up checkboxBuffer({ pointer: "submit" }), // read: Submit → Enter ); - const res = await submitMultiSelectIntent({ ...base, multi: m, intent: { kind: "submit" } }); + const res = await submitMultiSelectIntent({ ...base, multi: m, intent: { kind: "advance" } }); expect(res).toEqual({ status: "sent" }); expect(keysSent()).toEqual([["Up"], ["Enter"]]); }); @@ -279,7 +279,7 @@ describe("submit macro — walk the pointer down onto Submit, then Enter", () => // Every read shows an option row — the pointer never converges on Submit, so the bounded walk // exhausts and refreshes rather than blind-sending an Enter at an unverified row. mockFetchPane.mockResolvedValue(paneWith(checkboxBuffer({ pointer: "opt2" }))); - const res = await submitMultiSelectIntent({ ...base, multi: m, intent: { kind: "submit" } }); + const res = await submitMultiSelectIntent({ ...base, multi: m, intent: { kind: "advance" } }); expect(res).toEqual({ status: "changed" }); expect(keysSent()).not.toContainEqual(["Enter"]); expect(keysSent().every((k) => k[0] === "Down")).toBe(true); // only ever nudged downward @@ -290,7 +290,7 @@ describe("submit macro — walk the pointer down onto Submit, then Enter", () => // The ❯ parked on "5. [ ] Type something" (the composer row) reads as a non-Submit row, so the // walk only ever nudges Down — it must never mistake it for Submit and blind-Enter. mockFetchPane.mockResolvedValue(paneWith(checkboxBuffer({ pointer: "free" }))); - const res = await submitMultiSelectIntent({ ...base, multi: m, intent: { kind: "submit" } }); + const res = await submitMultiSelectIntent({ ...base, multi: m, intent: { kind: "advance" } }); expect(res).toEqual({ status: "changed" }); expect(keysSent()).not.toContainEqual(["Enter"]); expect(keysSent().every((k) => k[0] === "Down")).toBe(true); @@ -301,7 +301,7 @@ describe("submit macro — walk the pointer down onto Submit, then Enter", () => // A redraw with the ❯ absent → pointer null → still not Submit, so the walk nudges Down and never // blind-Enters at an unverified row. mockFetchPane.mockResolvedValue(paneWith(checkboxBuffer({ pointer: "none" }))); - const res = await submitMultiSelectIntent({ ...base, multi: m, intent: { kind: "submit" } }); + const res = await submitMultiSelectIntent({ ...base, multi: m, intent: { kind: "advance" } }); expect(res).toEqual({ status: "changed" }); expect(keysSent()).not.toContainEqual(["Enter"]); expect(keysSent().every((k) => k[0] === "Down")).toBe(true); @@ -316,7 +316,7 @@ describe("submit macro — walk the pointer down onto Submit, then Enter", () => // check must reject it so NO Enter follows. checkboxBuffer({ pointer: "submit", question: "A different question?" }), ); - const res = await submitMultiSelectIntent({ ...base, multi: m, intent: { kind: "submit" } }); + const res = await submitMultiSelectIntent({ ...base, multi: m, intent: { kind: "advance" } }); expect(res).toEqual({ status: "changed" }); expect(keysSent()).toEqual([["Down"]]); // drift detected on the read, before any further key expect(keysSent()).not.toContainEqual(["Enter"]); @@ -325,7 +325,7 @@ describe("submit macro — walk the pointer down onto Submit, then Enter", () => it("rejects at the entry guard (no keys at all) when the dialog already changed", async () => { const m = model(checkboxBuffer({ checked: [] })); mockFetchPane.mockResolvedValue(paneWith(checkboxBuffer({ question: "Different?" }))); - const res = await submitMultiSelectIntent({ ...base, multi: m, intent: { kind: "submit" } }); + const res = await submitMultiSelectIntent({ ...base, multi: m, intent: { kind: "advance" } }); expect(res).toEqual({ status: "changed" }); expect(mockSendKeys).not.toHaveBeenCalled(); }); @@ -345,9 +345,9 @@ describe("per-pane serialization — overlapping actions can't both fire", () => return paneWith(checkboxBuffer({ pointer: "submit" })); }); - const first = submitMultiSelectIntent({ ...base, multi: m, intent: { kind: "submit" } }); + const first = submitMultiSelectIntent({ ...base, multi: m, intent: { kind: "advance" } }); // The second tap lands while the first is parked mid-flight → rejected before any read/send. - const second = await submitMultiSelectIntent({ ...base, multi: m, intent: { kind: "submit" } }); + const second = await submitMultiSelectIntent({ ...base, multi: m, intent: { kind: "advance" } }); expect(second).toEqual({ status: "changed" }); releaseFirst(); @@ -370,3 +370,51 @@ describe("per-pane serialization — overlapping actions can't both fire", () => expect(keysSent()).toEqual([["2"], ["3"]]); }); }); + +// The `signature` normalises ☒/☑ → ☐ across the whole chip line, because the current question's chip +// flips on the first tick. That also erases WHICH step you are on — so the comparators have to carry +// the steps themselves, or a tap meant for one question lands on another. +describe("multiSelectEquals / multiSelectIdentity — wizard step identity", () => { + const step = ( + chips: { label: string; answered: boolean; current: boolean }[], + ): MultiSelectModel => ({ + phase: "checkbox", + question: "Which changes should I keep?", + options: [ + { n: 1, label: "Formatting", checked: false }, + { n: 2, label: "Renames", checked: false }, + ], + escape: { n: 3, label: "Chat about this" }, + pointer: "option", + steps: chips, + advanceLabel: "Next", + // Deliberately IDENTICAL: this is what the normalisation leaves behind for two steps of one + // wizard whose questions and options happen to read the same. + signature: "same", + regionSignature: "region", + }); + + const q1 = step([ + { label: "Backend", answered: false, current: true }, + { label: "Frontend", answered: false, current: false }, + ]); + const q2 = step([ + { label: "Backend", answered: true, current: false }, + { label: "Frontend", answered: false, current: true }, + ]); + + it("does not treat two steps of one wizard as the same screen", () => { + expect(multiSelectEquals(q1, q2)).toBe(false); + expect(multiSelectIdentity(q1, q2)).toBe(false); + }); + + it("still treats the same step as itself", () => { + expect(multiSelectEquals(q1, step(q1.phase === "checkbox" ? q1.steps! : []))).toBe(true); + expect(multiSelectIdentity(q1, step(q1.phase === "checkbox" ? q1.steps! : []))).toBe(true); + }); + + it("separates a Next step from the Submit step even if everything else matches", () => { + const onSubmit = { ...q1, advanceLabel: "Submit" } as MultiSelectModel; + expect(multiSelectEquals(q1, onSubmit)).toBe(false); + }); +}); diff --git a/web/src/lib/multi-select-action.ts b/web/src/lib/multi-select-action.ts index a0fa013e9fe109fd529825d90e95a3d733a5e5e5..64b9243098f2aac6baf00adcd43833c89d16cabf 100644 GIT binary patch delta 1750 zcmbtV&u<%55Ec=Qg(H;$2dcJ;Cl(5JE zZ@n>1YEB%019Um$gb)XAASB?UgY|VI-Ik>u}y{?tk{J!~`W%Xf^i|Z@^8H zFf0N@OdI$hL2bAc9cbRcBRcf3ClvxWZ3=M=58;rDU=x~x4Vznc;1EZ86SPrW^zT0? za;-8XGsYl$z#!!xGDX*Rv1i=(ct12ic{3p?u$l#(JgI%MFm~70V4FhHkVN{b84(z9LRx!c#$T+VqWMfMuQC4*V4E2Gi3P8rTGt?&!BZ?rCh-{X? zlnSRt9EE{lF$|DVh1A;oxG1JT>_>U5VkR{DQUngX zB?mF(ALJqMD2}+#O*8@ujvc@w=G!kKF;+O_n68t-<{C)U4y?KLULp#M?d2ZLh0bnW z*~xYqm!I;olh*Rss06oY#CQ){%(292=ytmxk|=`XWAHB6+MtknFW{_m>2v|diBR-S z2rC3vdX%OaNN-Hd%j2I%b#RDk)S|p-PIL!&8@g}gUjW+4JCSEu!mixs4^~R^>gvpo z37ryA!GB$6C$FAyujbE|e-5}j=n!ywtA0|?u3x#4eN+FlJ0rElI^eZo2F9Fn<#+AS zl=07*R#zvFmcF|5{~)qQOTSJ2dG5!>#rKm9dM4jpeg5g|m4eM!R*BdyRBiI>jh%(i zYDgO%<~)(c-4%9*szu-1zx__WmrRUOJ7;#5=?3pJW8b&PRU4XB7TEp-4449$3QeIaEy`oY6w8?-?}r35u+j$s zM%*J^GD!T|jU>4=c2fgRWK|-peYJ!rNbGdTV$4ol_Uwhav%lRpFIU|v{x!1KHr}pR T)Nq}By7B$RTa)dZe=YnA_*6;1 delta 309 zcmcaux-4VE0(Q3I(xlwXlF5PWQrrrU+9oL!rIux;mQOywZnSwnM;FuNbt2M}Kkz-? zEG{seb+Wj?rOijg_OUTCZ0?X1Vo`x;R?-CODk&|>Qz$CU3kI9#o0wdbuaQ`kUaUE} zQBiX9O+^)^&Fre;oNN&J&8m90*;3)!tGN`wKwn=WEVU>zEi*MmAwN$+38GlZN+B`1 zB(p5BBvm1^1nkO^j8v#g6^fIKQd9F3G(bY7#i>OK$@zI{nMJw93P6#fRL#kXW{Dgy ylO|6zbK(OT4U_}hsjolzp_z*y#3F^vVxSz*