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 && (
-
+ 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) => (
-
- {step.answered ? : null}
- {step.label}
-
- ))}
-
- Submit
-
-
-
-
+ 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 (
+
+ ))}
+ {/* 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. */}
+
+ Submit
+
+
+
+
+ );
+}
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 @@
+[0m[38;2;175;175;175m[48;2;240;240;240m❯ [0m[38;2;0;0;0m[48;2;240;240;240mUse 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. [0m[48;2;240;240;240m [0m
+[0m[48;2;240;240;240m [0m[38;2;0;0;0m[48;2;240;240;240mQuestion 2: header Crust, multiSelect false, two options. Do not read or write files, do not run commands, do nothing else.[0m[48;2;240;240;240m [0m
+[0m[38;2;102;102;102m───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[0m
+[0m[38;2;102;102;102m← [0m[38;2;255;255;255m[48;2;87;105;247m ☒ Toppings [0m ☐ Crust ✔ Submit →
+
+[0m[1m[38;2;0;0;0mWhich toppings would you like on your pizza?[0m
+
+[0m[38;2;87;105;247m❯[0m [0m[38;2;102;102;102m1. [0m[38;2;44;122;57m[✔] [0m[38;2;87;105;247mPepperoni[0m
+ [0m[38;2;102;102;102mClassic cured pork-and-beef slices that crisp at the edges.[0m
+ [0m[38;2;102;102;102m2.[0m [ ] Mushrooms
+ [0m[38;2;102;102;102mEarthy sliced creminis that soften into the cheese.[0m
+ [0m[38;2;102;102;102m3. [0m[38;2;44;122;57m[✔] [0mBell peppers
+ [0m[38;2;102;102;102mSweet, slightly crunchy strips of red and green pepper.[0m
+ [0m[38;2;102;102;102m4. [0m[ ] Extra cheese
+ [0m[38;2;102;102;102mA double layer of mozzarella for a richer, stretchier bite.[0m
+ [0m[38;2;102;102;102m5. [0m[ ] [0m[38;2;102;102;102mType something[0m
+ [0m[1mNext[0m
+[0m[38;2;102;102;102m───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[0m
+ 6. Chat about this
+
+[0m[38;2;102;102;102mEnter to select · Tab/Arrow keys to navigate · Esc to cancel[0m
\ 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 @@
+[0m[38;2;175;175;175m[48;2;240;240;240m❯ [0m[38;2;0;0;0m[48;2;240;240;240mNow 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, [0m[48;2;240;240;240m [0m
+[0m[48;2;240;240;240m [0m[38;2;0;0;0m[48;2;240;240;240mtwo options. Question 2: header Extras, multiSelect true, three options each with a one-line description. Nothing else.[0m[48;2;240;240;240m [0m
+[0m[38;2;102;102;102m───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[0m
+← ☒ Size [0m[38;2;255;255;255m[48;2;87;105;247m ☐ Extras [0m ✔ Submit →
+
+[0m[1m[38;2;0;0;0mWhich extras should we add on the side?[0m
+
+[0m[38;2;87;105;247m❯[0m [0m[38;2;102;102;102m1.[0m [ ] [0m[38;2;87;105;247mGarlic knots[0m
+ [0m[38;2;102;102;102mWarm dough twists brushed with garlic butter and parsley.[0m
+ [0m[38;2;102;102;102m2.[0m [ ] Caesar salad
+ [0m[38;2;102;102;102mCrisp romaine, shaved parmesan, and croutons in a creamy dressing.[0m
+ [0m[38;2;102;102;102m3.[0m [ ] Dipping sauces
+ [0m[38;2;102;102;102mA trio of marinara, ranch, and garlic aioli for the crusts.[0m
+ [0m[38;2;102;102;102m4. [0m[ ] [0m[38;2;102;102;102mType something[0m
+ [0m[1mSubmit[0m
+[0m[38;2;102;102;102m───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[0m
+ 5. Chat about this
+
+[0m[38;2;102;102;102mEnter to select · Tab/Arrow keys to navigate · Esc to cancel[0m
\ 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 @@
+[0m[38;2;175;175;175m[48;2;240;240;240m❯ [0m[38;2;0;0;0m[48;2;240;240;240mUse 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. [0m[48;2;240;240;240m [0m
+[0m[48;2;240;240;240m [0m[38;2;0;0;0m[48;2;240;240;240mQuestion 2: header Crust, multiSelect false, two options. Do not read or write files, do not run commands, do nothing else.[0m[48;2;240;240;240m [0m
+[0m[38;2;102;102;102m───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[0m
+[0m[38;2;102;102;102m← [0m[38;2;255;255;255m[48;2;87;105;247m ☒ Toppings [0m ☐ Crust ✔ Submit →
+
+[0m[1m[38;2;0;0;0mWhich toppings would you like on your pizza?[0m
+
+ [0m[38;2;102;102;102m1. [0m[38;2;44;122;57m[✔] [0mPepperoni
+ [0m[38;2;102;102;102mClassic cured pork-and-beef slices that crisp at the edges.[0m
+ [0m[38;2;102;102;102m2.[0m [ ] Mushrooms
+ [0m[38;2;102;102;102mEarthy sliced creminis that soften into the cheese.[0m
+ [0m[38;2;102;102;102m3. [0m[38;2;44;122;57m[✔] [0mBell peppers
+ [0m[38;2;102;102;102mSweet, slightly crunchy strips of red and green pepper.[0m
+ [0m[38;2;102;102;102m4. [0m[ ] Extra cheese
+ [0m[38;2;102;102;102mA double layer of mozzarella for a richer, stretchier bite.[0m
+ [0m[38;2;102;102;102m5. [0m[ ] [0m[38;2;102;102;102mType something[0m
+[0m[38;2;87;105;247m❯ [0m[1m[38;2;87;105;247mNext[0m
+[0m[38;2;102;102;102m───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[0m
+ 6. Chat about this
+
+[0m[38;2;102;102;102mEnter to select · Tab/Arrow keys to navigate · ctrl+g to edit in Vim · Esc to cancel[0m
\ 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 @@
+[0m[38;2;175;175;175m[48;2;240;240;240m❯ [0m[38;2;0;0;0m[48;2;240;240;240mUse 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. [0m[48;2;240;240;240m [0m
+[0m[48;2;240;240;240m [0m[38;2;0;0;0m[48;2;240;240;240mQuestion 2: header Crust, multiSelect false, two options. Do not read or write files, do not run commands, do nothing else.[0m[48;2;240;240;240m [0m
+[0m[38;2;102;102;102m───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[0m
+[0m[38;2;102;102;102m← [0m[38;2;255;255;255m[48;2;87;105;247m ☐ Toppings [0m ☐ Crust ✔ Submit →
+
+[0m[1m[38;2;0;0;0mWhich toppings would you like on your pizza?[0m
+
+[0m[38;2;87;105;247m❯[0m [0m[38;2;102;102;102m1. [0m[ ] [0m[38;2;87;105;247mPepperoni[0m
+ [0m[38;2;102;102;102mClassic cured pork-and-beef slices that crisp at the edges.[0m
+ [0m[38;2;102;102;102m2.[0m [ ] Mushrooms
+ [0m[38;2;102;102;102mEarthy sliced creminis that soften into the cheese.[0m
+ [0m[38;2;102;102;102m3. [0m[ ] Bell peppers
+ [0m[38;2;102;102;102mSweet, slightly crunchy strips of red and green pepper.[0m
+ [0m[38;2;102;102;102m4. [0m[ ] Extra cheese
+ [0m[38;2;102;102;102mA double layer of mozzarella for a richer, stretchier bite.[0m
+ [0m[38;2;102;102;102m5. [0m[ ] [0m[38;2;102;102;102mType something[0m
+ [0m[1mNext[0m
+[0m[38;2;102;102;102m───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[0m
+ 6. Chat about this
+
+[0m[38;2;102;102;102mEnter to select · Tab/Arrow keys to navigate · Esc to cancel[0m
\ 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 @@
+[0m[38;2;153;153;153m ⎿ · Which widget design should we use? → (notes only)[0m
+
+[0m[38;2;255;255;255m● [0mYou 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.
+
+[0m[38;2;153;153;153m✻ Cooked for 13s[0m
+
+[0m[38;2;80;80;80m[48;2;55;55;55m❯ [0m[38;2;255;255;255m[48;2;55;55;55mAsk me the exact same AskUserQuestion again (same question, options, descriptions, previews). Do nothing else afterward.[0m[48;2;55;55;55m [0m
+
+[0m[38;2;255;255;255m● [0mUser answered Claude's questions:
+[0m[38;2;153;153;153m ⎿ · Which widget design should we use? → Minimal[0m
+
+[0m[38;2;255;255;255m● [0mYou selected Minimal this time, with a note: "prefer subtle shadows." Stopping here as requested.
+
+[0m[38;2;153;153;153m✻ Churned for 11s[0m
+
+[0m[38;2;80;80;80m[48;2;55;55;55m❯ [0m[38;2;255;255;255m[48;2;55;55;55mNow 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 [0m[48;2;55;55;55m [0m
+[0m[48;2;55;55;55m [0m[38;2;255;255;255m[48;2;55;55;55mmockup). Q2 "Should we add dark mode?" with options Yes, No (short descriptions, NO previews). Not multiSelect. Do nothing else afterward.[0m[48;2;55;55;55m [0m
+
+[0m[38;2;153;153;153m───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[0m
+[0m[38;2;153;153;153m← [0m[38;2;0;0;0m[48;2;177;185;249m ☐ Card layout [0m☐ Dark mode ✔ Submit →
+
+[0m[1m[38;2;255;255;255mWhich card layout should we use?[0m
+
+[0m[38;2;177;185;249m❯[0m[38;2;153;153;153m 1.[0m[1m[38;2;177;185;249m Grid of equal-width cards [0m[0m[38;2;153;153;153m┌──────────────────────────────────────────┐[0m
+[0m[1m[38;2;177;185;249m with a fixed gutter [0m[0m[38;2;153;153;153m│ [0m+--------+ +--------+[0m[38;2;153;153;153m │[0m
+[0m[1m[38;2;177;185;249m (Recommended) [0m[0m[38;2;153;153;153m│ [0m| Card 1 | | Card 2 |[0m[38;2;153;153;153m │[0m
+ [0m[38;2;153;153;153m 2.[0m List [0m[0m[38;2;153;153;153m│ [0m| .... | | .... |[0m[38;2;153;153;153m │[0m
+ [0m[38;2;153;153;153m│ [0m+--------+ +--------+[0m[38;2;153;153;153m │[0m
+ [0m[38;2;153;153;153m│ [0m+--------+ +--------+[0m[38;2;153;153;153m │[0m
+ [0m[38;2;153;153;153m│ [0m| Card 3 | | Card 4 |[0m[38;2;153;153;153m │[0m
+ [0m[38;2;153;153;153m│ [0m| .... | | .... |[0m[38;2;153;153;153m │[0m
+ [0m[38;2;153;153;153m│ [0m+--------+ +--------+[0m[38;2;153;153;153m │[0m
+ [0m[38;2;153;153;153m└──────────────────────────────────────────┘[0m
+
+ [0m[38;2;177;185;249mNotes: [0m[3m[38;2;153;153;153mpress n to add notes[0m
+
+[0m[38;2;153;153;153m───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[0m
+ Chat about this
+
+[0m[38;2;153;153;153mEnter to select · ↑/↓ to navigate · n to add notes · Tab to switch questions · Esc to cancel[0m
\ 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 a0fa013..64b9243 100644
Binary files a/web/src/lib/multi-select-action.ts and b/web/src/lib/multi-select-action.ts differ