diff --git a/test/helpers/claude-pty-runner.ts b/test/helpers/claude-pty-runner.ts index 9c8e93bf..a85ba863 100644 --- a/test/helpers/claude-pty-runner.ts +++ b/test/helpers/claude-pty-runner.ts @@ -234,12 +234,14 @@ export function parseNumberedOptions( // this, parseNumberedOptions returns stale options after the dialog is // dismissed. const lines = tail.split('\n'); - // Anchor on the LAST `❯ 1.` line (cursor is on option 1 of the active - // AskUserQuestion). Greedy character classes don't help here — we need a literal - // `❯` after optional leading whitespace. + // Anchor on the LAST line containing `❯1.` ANYWHERE on the line. + // The /plan-*-review skill's box-layout AUQ uses TTY cursor-positioning + // escapes that stripAnsi removes — leaving the cursor `❯1.` mid-line, + // after dividers + header + prompt text on the same logical line. The + // earlier `^\s*❯` anchor missed those entirely. let cursorLineIdx = -1; for (let i = lines.length - 1; i >= 0; i--) { - if (/^\s*❯\s*1\./.test(lines[i] ?? '')) { + if (/❯\s*1\./.test(lines[i] ?? '')) { cursorLineIdx = i; break; } @@ -259,7 +261,37 @@ export function parseNumberedOptions( if (cursorLineIdx < 0) return []; const found: Array<{ index: number; label: string }> = []; const seenIndices = new Set(); - for (let i = cursorLineIdx; i < lines.length; i++) { + + // Cursor line: option 1 may be inline after box dividers + prompt header + // (`...divider...header...❯1. label`). Use a non-anchored regex that + // captures `❯N. label` from anywhere on the line through end-of-line. + // Only used for the cursor line — subsequent options are parsed with the + // start-of-line `optionRe`. + const cursorLine = lines[cursorLineIdx] ?? ''; + const cursorInlineRe = /❯\s*([1-9])\.\s*(\S.*?)\s*$/; + const inlineMatch = cursorInlineRe.exec(cursorLine); + if (inlineMatch) { + const idx = Number(inlineMatch[1]); + const label = (inlineMatch[2] ?? '').trim(); + if (label.length > 0 && !seenIndices.has(idx)) { + seenIndices.add(idx); + found.push({ index: idx, label }); + } + } else { + // No inline cursor match — fall back to start-of-line regex. + const startMatch = optionRe.exec(cursorLine); + if (startMatch) { + const idx = Number(startMatch[1]); + const label = (startMatch[2] ?? '').trim(); + if (label.length > 0 && !seenIndices.has(idx)) { + seenIndices.add(idx); + found.push({ index: idx, label }); + } + } + } + + // Subsequent lines: standard start-of-line option parsing. + for (let i = cursorLineIdx + 1; i < lines.length; i++) { const m = optionRe.exec(lines[i] ?? ''); if (!m) continue; const idx = Number(m[1]); @@ -442,16 +474,33 @@ export function parseQuestionPrompt(visible: string): string { const tail = visible.length > 4096 ? visible.slice(-4096) : visible; const lines = tail.split('\n'); - // Find the latest `❯ 1.` cursor line (matching parseNumberedOptions). + // Find the latest line containing `❯1.` (matching parseNumberedOptions — + // unanchored to handle the box-layout case where cursor is mid-line after + // divider + header + prompt text on the same logical line). let cursorLineIdx = -1; for (let i = lines.length - 1; i >= 0; i--) { - if (/^\s*❯\s*1\./.test(lines[i] ?? '')) { + if (/❯\s*1\./.test(lines[i] ?? '')) { cursorLineIdx = i; break; } } if (cursorLineIdx < 0) return ''; + // Box-layout case: prompt text may be ON the cursor line, BEFORE `❯1.`. + // Extract that prefix (after stripping leading box-drawing characters and + // dividers) as the last piece of the prompt — appended after any prior + // multi-line prompt text we walk up to find. + const cursorLine = lines[cursorLineIdx] ?? ''; + let inlinePrompt = ''; + const cursorPos = cursorLine.search(/❯\s*1\./); + if (cursorPos > 0) { + inlinePrompt = cursorLine + .slice(0, cursorPos) + // Strip box-drawing chars + dividers + leading checkbox sigil. + .replace(/^[─━┄┅┈┉─┌┐└┘├┤┬┴┼│┃☐□■\s]+/, '') + .trim(); + } + // Walk up at most 6 lines collecting prompt text. Stop at: // - a blank line preceded by another blank line (paragraph break) // - top of buffer @@ -472,7 +521,8 @@ export function parseQuestionPrompt(visible: string): string { promptLines.unshift(trimmed); } - const joined = promptLines.join(' ').replace(/\s+/g, ' ').trim(); + const all = inlinePrompt.length > 0 ? [...promptLines, inlinePrompt] : promptLines; + const joined = all.join(' ').replace(/\s+/g, ' ').trim(); return joined.slice(0, 240); } diff --git a/test/helpers/claude-pty-runner.unit.test.ts b/test/helpers/claude-pty-runner.unit.test.ts index 28b96b83..3960f322 100644 --- a/test/helpers/claude-pty-runner.unit.test.ts +++ b/test/helpers/claude-pty-runner.unit.test.ts @@ -312,6 +312,57 @@ describe('parseNumberedOptions', () => { test('returns empty array on prose-with-numbers (no cursor)', () => { expect(parseNumberedOptions('text 1. one 2. two')).toEqual([]); }); + + test('extracts options when the cursor is INLINE with prompt header (box-layout)', () => { + // Real /plan-ceo-review rendering: the TTY's cursor-positioning escapes + // collapse divider + header + prompt + cursor onto one logical line. + // Subsequent options (2..7) still start their own lines. + const visible = [ + '────────────────────────────────────────', + '☐ Review scope What scope do you want me to CEO-review? ❯ 1. The branch\'s diff vs main', + ' Review the full branch: ~10K LOC.', + '2. A specific plan file or design doc', + ' You point me at a file (path) and I review that.', + '3. An idea you\'ll describe inline', + '4. Cancel — wrong skill', + '5. Type something.', + '────────────────────────────────────────', + '6. Chat about this', + '7. Skip interview and plan immediately', + ].join('\n'); + const opts = parseNumberedOptions(visible); + expect(opts).toHaveLength(7); + expect(opts[0]).toEqual({ index: 1, label: "The branch's diff vs main" }); + expect(opts[1]?.index).toBe(2); + expect(opts[6]?.index).toBe(7); + expect(opts[6]?.label).toBe('Skip interview and plan immediately'); + }); + + test('inline-cursor and start-of-line cursor both produce 7 options for the box-layout case', () => { + // The inline path captures option 1 from the cursor line itself; the + // subsequent-lines path captures 2..7 with the existing optionRe. + const inlineLayout = [ + 'header text ❯ 1. first option', + '2. second', + '3. third', + ].join('\n'); + expect(parseNumberedOptions(inlineLayout)).toEqual([ + { index: 1, label: 'first option' }, + { index: 2, label: 'second' }, + { index: 3, label: 'third' }, + ]); + + const cleanLayout = [ + ' ❯ 1. first option', + ' 2. second', + ' 3. third', + ].join('\n'); + expect(parseNumberedOptions(cleanLayout)).toEqual([ + { index: 1, label: 'first option' }, + { index: 2, label: 'second' }, + { index: 3, label: 'third' }, + ]); + }); }); describe('runPlanSkillObservation env passthrough surface', () => { @@ -428,6 +479,24 @@ describe('parseQuestionPrompt', () => { 2. no`; expect(parseQuestionPrompt(visible)).toBe('D1 — Spaced out'); }); + + test('inline-cursor box-layout: extracts prompt text BEFORE ❯1. on the cursor line', () => { + // Real /plan-ceo-review rendering: divider + ☐ header + prompt text + + // cursor are all on one logical line because TTY cursor-positioning + // escapes collapse the box layout under stripAnsi. + const visible = [ + '──────────────────', + '☐ Review scope What scope do you want me to CEO-review? ❯ 1. The branch\'s diff vs main', + '2. A specific plan file', + '3. An idea inline', + ].join('\n'); + const prompt = parseQuestionPrompt(visible); + // Should extract "Review scope" and the prompt text, dropping the ☐ box-drawing sigil. + expect(prompt).toContain('Review scope'); + expect(prompt).toContain('What scope do you want me to CEO-review?'); + expect(prompt).not.toContain('❯'); + expect(prompt).not.toMatch(/^☐/); + }); }); describe('auqFingerprint', () => {