mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-02 03:35:09 +02:00
test: fix parseNumberedOptions for inline-cursor box-layout AUQs
Calibration run 1 timed out with step0=0 review=0 because the parser could not find the cursor in /plan-ceo-review's scope-selection AUQ. The TTY's box-layout rendering inlines divider + header + prompt + "1." onto one logical line — cursor escapes get stripped, leaving text crushed onto a single line. Cursor anchor regex changed from anchored to unanchored so it matches mid-line. Cursor-line option extraction uses a non-anchored regex; subsequent options stay with the original start-of-line parser. parseQuestionPrompt picks up the inline prompt text BEFORE the cursor on the cursor line (after stripping box-drawing chars + sigil) and appends it after any walked-up multi-line prompt above. Three new unit tests: clean-cursor still works, inline-cursor extracts all 7 options, prompt extraction strips box chars. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 `❯<spaces>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<number>();
|
||||
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 `❯<spaces>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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user