From 1a1604493a7cfed0e288c6434d84ed97bfa6179e Mon Sep 17 00:00:00 2001 From: Luke Norland <217919286+Luke-Norland@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:55:09 +0000 Subject: [PATCH] =?UTF-8?q?fix(claude):=20read=20ticked=20[=E2=9C=94]=20bo?= =?UTF-8?q?xes=20in=20multi-select=20questions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code draws a ticked multi-select option as `[✔]` (U+2714). The card parser only recognised `[x]`, `[X]` and `[✓]`, so a box ticked in the terminal read as unticked and its label kept the `[✔]` prefix. When the user then answered from the chat card, the key plan never toggled that box off. With Apple ticked in the pane, answering Banana + Date from the card returned 200 and Claude received "Apple, Banana, Date". The leftover prefix also changed the prompt id whenever a box was ticked or unticked. Accept `✔` wherever `[x]`, `[X]` and `[✓]` are accepted on this path: the checked test, the label strip and the multi-select detection. `[ ]` stays unchecked. Co-Authored-By: Claude Opus 5.5 --- .../tmux-interactive-prompt.service.ts | 7 +- .../tests/claude-multiselect.test.ts | 101 ++++++++++++++++++ .../claude-multiselect/pretick.joined.ansi | 34 ++++++ 3 files changed, 139 insertions(+), 3 deletions(-) create mode 100644 server/modules/providers/tests/claude-multiselect.test.ts create mode 100644 server/modules/providers/tests/fixtures/claude-multiselect/pretick.joined.ansi diff --git a/server/modules/providers/services/tmux-interactive-prompt.service.ts b/server/modules/providers/services/tmux-interactive-prompt.service.ts index af4a58c..c26c9c9 100644 --- a/server/modules/providers/services/tmux-interactive-prompt.service.ts +++ b/server/modules/providers/services/tmux-interactive-prompt.service.ts @@ -310,8 +310,9 @@ function parseNumberedRows(lines: string[], start: number, end: number): Numbere const match = lines[index].replace(ANSI_RE, '').trim().match(NUMBERED_OPTION_RE); if (!match) continue; let label = match[3].trim(); - const checked = /^\[[xX✓]\]/.test(label); - label = label.replace(/^\[[ xX✓]\]\s*/, '').trim(); + // Claude Code draws a ticked box as `[✔]` (U+2714). + const checked = /^\[[xX✓✔]\]/.test(label); + label = label.replace(/^\[[ xX✓✔]\]\s*/, '').trim(); rows.push({ number: Number.parseInt(match[2], 10), label, @@ -456,7 +457,7 @@ function parseClaudeQuestion(screen: string): ParsedPrompt | null { if (!question) return null; const optionRows = rows.slice(0, customIndex); const multiSelect = optionRows.some((row) => - /^\s*(?:[›>❯]\s*)?\d+\.\s+\[[ xX✓]\]/.test(lines[row.lineIndex])); + /^\s*(?:[›>❯]\s*)?\d+\.\s+\[[ xX✓✔]\]/.test(lines[row.lineIndex])); // Public options exclude the trailing "Type something." / "Chat about this" // rows (mirroring the codex parser): validateChoices caps selections at // customOptionNumber, so exposing those rows as numbered options would diff --git a/server/modules/providers/tests/claude-multiselect.test.ts b/server/modules/providers/tests/claude-multiselect.test.ts new file mode 100644 index 0000000..6e483dd --- /dev/null +++ b/server/modules/providers/tests/claude-multiselect.test.ts @@ -0,0 +1,101 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +import type { TmuxRunner } from '@/modules/providers/services/builtin-relay.service.js'; +import { createVerifiedTmuxActionTarget } from '@/modules/providers/services/tmux-fresh-verifier.service.js'; +import { + answerTmuxInteractivePrompt, + getTmuxInteractivePrompt, + parseTmuxInteractivePrompt, +} from '@/modules/providers/services/tmux-interactive-prompt.service.js'; + +// Captured from Claude Code 2.1.291 (default renderer, 64x31) with +// `capture-pane -p -e -N -S -80`: a multiSelect AskUserQuestion with Apple, +// Banana, Cherry and Date, after Apple was ticked in the terminal. Claude Code +// draws a ticked option as `[✔]` (U+2714). +const pretick = readFileSync(new URL('./fixtures/claude-multiselect/pretick.joined.ansi', import.meta.url), 'utf8'); +const TICKED = '[✔]'; + +const target = () => createVerifiedTmuxActionTarget( + { socketPath: '/tmp/chatmux-multiselect-test.sock', sessionId: '$7', windowId: '@8', paneId: '%9' }, + { pid: 42, startedAtMs: 1234 }, + 'claude', + null, +); + +function paneShowing(screen: string, keys: string[] = []): TmuxRunner { + return async (args) => { + if (args.includes('send-keys')) keys.push(String(args.at(-1))); + if (args.includes('capture-pane')) return { code: 0, output: screen }; + if (args.includes('display-message')) return { code: 0, output: '$7\t@8\t%9\n' }; + return { code: 0, output: '' }; + }; +} + +/** Replays keys on the Claude multi-select: Up/Down move the focus, Enter toggles, Right leaves. */ +function tickedAfter(keys: readonly string[], ticked: number[], focus = 1): number[] { + const state = new Set(ticked); + for (const key of keys) { + if (key === 'Right') break; + if (key === 'Down') focus += 1; + if (key === 'Up') focus -= 1; + if (key === 'Enter') { + if (state.has(focus)) state.delete(focus); + else state.add(focus); + } + } + return [...state].sort(); +} + +test('a [✔] box from Claude Code reads as checked, with a clean label', async () => { + assert.equal(pretick.split(TICKED).length, 2, 'fixture assumption broken: expected one [✔] row'); + const prompt = await getTmuxInteractivePrompt(target(), paneShowing(pretick)); + assert.ok(prompt); + assert.equal(prompt.multiSelect, true); + assert.deepEqual(prompt.checkedChoiceNumbers, [1]); + assert.deepEqual(prompt.options.map((option) => option.label), ['Apple', 'Banana', 'Cherry', 'Date']); +}); + +test('answering Banana+Date with Apple ticked in the pane unticks Apple', async () => { + const prompt = parseTmuxInteractivePrompt('claude', pretick); + assert.ok(prompt); + const keys: string[] = []; + await answerTmuxInteractivePrompt(target(), prompt.id, [2, 4], paneShowing(pretick, keys)); + assert.deepEqual(keys, ['Enter', 'Down', 'Enter', 'Down', 'Down', 'Enter', 'Right', 'Enter']); + assert.deepEqual(tickedAfter(keys, [1]), [2, 4], 'the submitted set is exactly Banana, Date'); +}); + +test('[x], [X] and [✓] still read as checked on the same capture', () => { + const ticked = parseTmuxInteractivePrompt('claude', pretick); + assert.ok(ticked); + for (const box of ['[x]', '[X]', '[✓]']) { + const screen = pretick.replace(TICKED, box); + assert.notEqual(screen, pretick, 'fixture assumption broken: the box was not replaced'); + const prompt = parseTmuxInteractivePrompt('claude', screen); + assert.ok(prompt, box); + assert.deepEqual(prompt.checkedOptionIndices, [0], box); + assert.deepEqual(prompt.options.map((option) => option.label), ['Apple', 'Banana', 'Cherry', 'Date'], box); + assert.equal(prompt.id, ticked.id, box); + } +}); + +test('prompt id is stable when a box is ticked', () => { + const unticked = pretick.replace(TICKED, '[ ]'); + assert.notEqual(unticked, pretick, 'fixture assumption broken: the box was not replaced'); + const before = parseTmuxInteractivePrompt('claude', unticked); + const after = parseTmuxInteractivePrompt('claude', pretick); + assert.ok(before && after); + assert.deepEqual(before.checkedOptionIndices, []); + assert.deepEqual(after.checkedOptionIndices, [0]); + assert.equal(after.id, before.id); +}); + +test('every option ticked with [✔] is still a multi-select', () => { + const allTicked = pretick.replace(/\[ \] (Banana|Cherry|Date)/g, `${TICKED} $1`); + assert.equal(allTicked.split(TICKED).length, 5, 'fixture assumption broken: not all four rows ticked'); + const prompt = parseTmuxInteractivePrompt('claude', allTicked); + assert.ok(prompt); + assert.equal(prompt.multiSelect, true); + assert.deepEqual(prompt.checkedOptionIndices, [0, 1, 2, 3]); +}); diff --git a/server/modules/providers/tests/fixtures/claude-multiselect/pretick.joined.ansi b/server/modules/providers/tests/fixtures/claude-multiselect/pretick.joined.ansi new file mode 100644 index 0000000..126105f --- /dev/null +++ b/server/modules/providers/tests/fixtures/claude-multiselect/pretick.joined.ansi @@ -0,0 +1,34 @@ + ▐▛███▛█ Claude Code v2.1.291 +▝▜██████▀ Haiku 4.5 · Claude API + ▝▝ ▝▝ /tmp/fruit-project + + Get to finished work sooner with Opus 5.5. Switch anytime with + /model. + +❯ /clear + +❯ Call the AskUserQuestion tool exactly once, with one question + "Which fruits do you want?" header "Fruit", multiSelect  + true, and exactly 4 options labelled Apple, Banana, Cherry,  + Date, each with a short description of a few words. Do  + nothing else. After I answer, reply with only: GOT:  +──────────────────────────────────────────────────────────────── +←  ☒ Fruit  ✔ Submit → + +Which fruits do you want? + +❯ 1. [✔] Apple + Crisp red fruit + 2. [ ] Banana + Yellow tropical fruit + 3. [ ] Cherry + Small round berry + 4. [ ] Date + Sweet brown fruit + 5. [ ] Type something + Submit +──────────────────────────────────────────────────────────────── + 6. Chat about this + +Enter to select · ↑/↓ to navigate · Esc to cancel +