From f153745c4f4775e0bc697aac24eb0623c31d9033 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Sat, 4 Apr 2026 11:12:17 -0500 Subject: [PATCH 1/3] fix: break infinite notes loop when selecting "None of the above" goNextOrSubmit() unconditionally reopened the notes field whenever the cursor sat on the "None of the above" slot, even after the user had already typed a note and pressed Enter. This trapped users in an endless loop where Enter always bounced back to notes mode. Add a `!states[currentIdx].notes` guard so the auto-open only fires when notes are still empty. Fixes #3502 --- src/resources/extensions/shared/interview-ui.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/resources/extensions/shared/interview-ui.ts b/src/resources/extensions/shared/interview-ui.ts index 99a3501b8..f07112597 100644 --- a/src/resources/extensions/shared/interview-ui.ts +++ b/src/resources/extensions/shared/interview-ui.ts @@ -298,7 +298,9 @@ export async function showInterviewRound( // Auto-open the notes field when "None of the above" is selected // so the user can immediately provide a free-text explanation // instead of being trapped in a re-asking loop (bug #2715). - if (!isMultiSelect(currentIdx) && states[currentIdx].cursorIndex === noneOrDoneIdx(currentIdx)) { + // Only auto-open if the user hasn't already provided notes — + // otherwise Enter from notes mode loops back here endlessly. + if (!isMultiSelect(currentIdx) && states[currentIdx].cursorIndex === noneOrDoneIdx(currentIdx) && !states[currentIdx].notes) { states[currentIdx].notesVisible = true; focusNotes = true; loadStateToEditor(); From e0884375e6f39f3e70f12e3ea14f64b2d5a1caa3 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Sat, 4 Apr 2026 11:22:15 -0500 Subject: [PATCH 2/3] test: add regression test for interview-ui notes loop (#3502) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exercises the goNextOrSubmit → notes auto-open path to ensure: - Enter after typing a note advances instead of looping - Empty notes still trigger the auto-open - Normal option selection is unaffected Fixes #3502 --- .../shared/tests/interview-notes-loop.test.ts | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 src/resources/extensions/shared/tests/interview-notes-loop.test.ts diff --git a/src/resources/extensions/shared/tests/interview-notes-loop.test.ts b/src/resources/extensions/shared/tests/interview-notes-loop.test.ts new file mode 100644 index 000000000..d138b4b38 --- /dev/null +++ b/src/resources/extensions/shared/tests/interview-notes-loop.test.ts @@ -0,0 +1,144 @@ +// GSD2 — Regression test for interview-ui "None of the above" notes loop +// Copyright (c) 2026 Jeremy McSpadden + +/** + * Regression test for bug #3502: + * + * Selecting "None of the above" opens the notes field, but pressing Enter + * after typing a note called goNextOrSubmit() which saw the cursor still + * on the "None of the above" slot and re-opened notes — trapping the user + * in an infinite loop. + * + * The fix adds a `!states[currentIdx].notes` guard so auto-open only fires + * when notes are still empty. + */ + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { showInterviewRound, type Question, type RoundResult } from "../interview-ui.js"; + +// Raw terminal sequences that matchesKey() recognises +const ENTER = "\r"; +const DOWN = "\x1b[B"; +const TAB = "\t"; + +/** + * Drive showInterviewRound with a scripted sequence of key inputs. + * We mock ctx.ui.custom() to capture the widget, feed it inputs, and + * resolve when done() is called. + */ +function runWithInputs( + questions: Question[], + inputs: string[], +): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error("Timed out — likely stuck in infinite loop")), 3000); + + const mockCtx = { + ui: { + custom: (factory: any) => { + const mockTui = { + requestRender: () => {}, + }; + const mockTheme = { + // Minimal theme stubs — render output is not asserted + fg: (_c: string, t: string) => t, + bold: (t: string) => t, + dim: (t: string) => t, + italic: (t: string) => t, + strikethrough: (t: string) => t, + accent: (t: string) => t, + success: (t: string) => t, + warning: (t: string) => t, + error: (t: string) => t, + info: (t: string) => t, + muted: (t: string) => t, + dimmed: (t: string) => t, + }; + const mockKb = {}; + + const widget = factory(mockTui, mockTheme, mockKb, (result: RoundResult) => { + clearTimeout(timeout); + resolve(result); + }); + + // Feed each input sequentially + for (const input of inputs) { + widget.handleInput(input); + } + }, + }, + }; + + showInterviewRound(questions, {}, mockCtx as any).catch(reject); + }); +} + +describe("interview-ui notes loop regression (#3502)", () => { + const questions: Question[] = [ + { + id: "q1", + header: "Project Type", + question: "What type of project?", + options: [ + { label: "Web App", description: "Frontend or full-stack" }, + { label: "CLI Tool", description: "Command-line utility" }, + ], + }, + ]; + + it("does not loop when Enter is pressed after typing a note on 'None of the above'", async () => { + // With 2 options, "None of the above" is index 2 (0-based) + // Cursor starts at 0, so press Down twice to reach it + const result = await runWithInputs(questions, [ + DOWN, // cursor → index 1 (CLI Tool) + DOWN, // cursor → index 2 (None of the above) + ENTER, // commit → auto-opens notes field + "u", "n", "s", "u", "r", "e", // type "unsure" + ENTER, // should advance to review, NOT reopen notes + ENTER, // submit from review screen + ]); + + // If we get here, the loop did not occur (timeout would have fired) + assert.ok(result, "should return a result"); + assert.equal(result.endInterview, false); + + const answer = result.answers.q1; + assert.ok(answer, "answer for q1 should exist"); + assert.equal(answer.notes, "unsure", "notes should contain typed text"); + assert.equal(answer.selected, "None of the above"); + }); + + it("still auto-opens notes when selecting 'None of the above' with no prior notes", async () => { + // Press Down twice to "None of the above", Enter to select + // Then immediately Enter again (empty notes) — this should re-open notes + // because the guard only skips when notes are non-empty. + // Type something on second open, then Enter to proceed. + const result = await runWithInputs(questions, [ + DOWN, // cursor → 1 + DOWN, // cursor → 2 (None of the above) + ENTER, // commit → auto-opens notes + ENTER, // empty notes → goNextOrSubmit → should re-open notes (empty guard) + "o", "k", // type "ok" + ENTER, // now notes = "ok" → should advance to review + ENTER, // submit + ]); + + assert.ok(result, "should return a result"); + const answer = result.answers.q1; + assert.ok(answer, "answer for q1 should exist"); + assert.equal(answer.notes, "ok"); + }); + + it("normal option selection is unaffected", async () => { + const result = await runWithInputs(questions, [ + ENTER, // select first option (Web App) and advance to review + ENTER, // submit from review screen + ]); + + assert.ok(result, "should return a result"); + const answer = result.answers.q1; + assert.ok(answer, "answer for q1 should exist"); + assert.equal(answer.selected, "Web App"); + }); +}); From 1e31ca4b29a3d84975a351fe4db331a49c7ae00a Mon Sep 17 00:00:00 2001 From: Jeremy Date: Sat, 4 Apr 2026 11:23:59 -0500 Subject: [PATCH 3/3] ci: trigger CI re-run