import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; import { stripVTControlCharacters } from "node:util"; import { KeybindingsManager } from "@oh-my-pi/pi-coding-agent/config/keybindings"; import type { ExtensionAskDialogQuestion } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/types"; import { AskDialogComponent } from "@oh-my-pi/pi-coding-agent/modes/components/ask-dialog"; import { getThemeByName, setThemeInstance } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import { setKeybindings } from "@oh-my-pi/pi-tui"; const DOWN = "\x1b[B"; const UP = "\x1b[A"; const PAGE_DOWN = "\x1b[6~"; const PAGE_UP = "\x1b[5~"; const ENTER = "\n"; const CANCEL = "\x07"; const SPACE = " "; const TAB = "\t"; const SHIFT_TAB = "\x1b[Z"; let darkTheme = await getThemeByName("dark"); function render(component: AskDialogComponent): string { return stripVTControlCharacters(component.render(80).join("\n")); } describe("AskDialogComponent", () => { beforeAll(async () => { darkTheme = await getThemeByName("dark"); if (!darkTheme) throw new Error("Failed to load dark theme"); }); beforeEach(() => { setThemeInstance(darkTheme!); setKeybindings(KeybindingsManager.inMemory({ "tui.select.cancel": "ctrl+g" })); }); afterEach(() => { setKeybindings(KeybindingsManager.inMemory()); vi.useRealTimers(); vi.restoreAllMocks(); }); it("single-question, single-select: Enter on option submits immediately", () => { const onSubmit = vi.fn(); const onCancel = vi.fn(); const onPrompt = vi.fn(); const questions: ExtensionAskDialogQuestion[] = [ { id: "q1", question: "Choose one?", options: [{ label: "Option A" }, { label: "Option B" }], }, ]; const component = new AskDialogComponent(questions, { onSubmit, onCancel, onPrompt, }); component.handleInput(ENTER); expect(onSubmit).toHaveBeenCalledTimes(1); expect(onSubmit.mock.calls[0][0]).toEqual({ kind: "submit", results: [ { id: "q1", question: "Choose one?", options: ["Option A", "Option B"], multi: false, selectedOptions: ["Option A"], customInput: undefined, note: undefined, timedOut: undefined, }, ], }); }); it("single-question, single-select: Space does not submit the highlighted answer", () => { const onSubmit = vi.fn(); const questions: ExtensionAskDialogQuestion[] = [ { id: "q1", question: "Choose one?", options: [{ label: "Option A" }, { label: "Option B" }], }, ]; const component = new AskDialogComponent(questions, { onSubmit, onCancel: vi.fn(), onPrompt: vi.fn(), }); component.handleInput(SPACE); expect(onSubmit).not.toHaveBeenCalled(); component.handleInput(ENTER); expect(onSubmit).toHaveBeenCalledTimes(1); expect(onSubmit.mock.calls[0][0].results[0].selectedOptions).toEqual(["Option A"]); }); it("single-question, single-select: DOWN then Enter selects second option and submits", () => { const onSubmit = vi.fn(); const onCancel = vi.fn(); const onPrompt = vi.fn(); const questions: ExtensionAskDialogQuestion[] = [ { id: "q1", question: "Choose one?", options: [{ label: "Option A" }, { label: "Option B" }], }, ]; const component = new AskDialogComponent(questions, { onSubmit, onCancel, onPrompt, }); component.handleInput(DOWN); component.handleInput(ENTER); expect(onSubmit).toHaveBeenCalledTimes(1); expect(onSubmit.mock.calls[0][0].results[0].selectedOptions).toEqual(["Option B"]); }); it("multi-question, single-select: Enter on option advances tab, does not submit", () => { const onSubmit = vi.fn(); const onCancel = vi.fn(); const onPrompt = vi.fn(); const questions: ExtensionAskDialogQuestion[] = [ { id: "q1", question: "Q1?", options: [{ label: "A1" }, { label: "B1" }], }, { id: "q2", question: "Q2?", options: [{ label: "A2" }, { label: "B2" }], }, ]; const component = new AskDialogComponent(questions, { onSubmit, onCancel, onPrompt, }); // Press Enter on A1 - should advance tab to Q2 (tab 1), not submit component.handleInput(ENTER); expect(onSubmit).not.toHaveBeenCalled(); // On Q2: Down to B2 and Enter - should advance tab to Submit (tab 2), not submit component.handleInput(DOWN); component.handleInput(ENTER); expect(onSubmit).not.toHaveBeenCalled(); // On Submit tab: Enter on Submit row - should submit component.handleInput(ENTER); expect(onSubmit).toHaveBeenCalledTimes(1); expect(onSubmit.mock.calls[0][0].results).toEqual([ { id: "q1", question: "Q1?", options: ["A1", "B1"], multi: false, selectedOptions: ["A1"], customInput: undefined, note: undefined, timedOut: undefined, }, { id: "q2", question: "Q2?", options: ["A2", "B2"], multi: false, selectedOptions: ["B2"], customInput: undefined, note: undefined, timedOut: undefined, }, ]); }); it("multi-select: Space and Enter both toggle without advancing; Submit tab confirms", () => { const onSubmit = vi.fn(); const onCancel = vi.fn(); const onPrompt = vi.fn(); const questions: ExtensionAskDialogQuestion[] = [ { id: "q1", question: "Choose multiple?", options: [{ label: "Option A" }, { label: "Option B" }], multi: true, }, ]; const component = new AskDialogComponent(questions, { onSubmit, onCancel, onPrompt, }); // Space on Option A - toggles without advancing component.handleInput(SPACE); expect(onSubmit).not.toHaveBeenCalled(); // Down to Option B, Enter - toggles B, still no submit and no movement component.handleInput(DOWN); component.handleInput(ENTER); expect(onSubmit).not.toHaveBeenCalled(); // Tab to the Submit tab (present even for a single multi question), // Enter confirms the selection. component.handleInput(TAB); component.handleInput(ENTER); expect(onSubmit).toHaveBeenCalledTimes(1); expect(onSubmit.mock.calls[0][0].results[0].selectedOptions).toEqual(["Option A", "Option B"]); }); it("tab-state persistence: answer question 0, Tab forward, Tab back, answer still present", () => { const onSubmit = vi.fn(); const onCancel = vi.fn(); const onPrompt = vi.fn(); const questions: ExtensionAskDialogQuestion[] = [ { id: "q1", question: "Q1?", options: [{ label: "A1" }, { label: "B1" }], }, { id: "q2", question: "Q2?", options: [{ label: "A2" }, { label: "B2" }], }, ]; const component = new AskDialogComponent(questions, { onSubmit, onCancel, onPrompt, }); // Enter on A1 selects it and auto-advances to Q2 (tab 1) component.handleInput(ENTER); // Shift+Tab back to Q1 (tab 0) component.handleInput(SHIFT_TAB); // Enter again on Q1's currently selected option (which will re-select/keep it and auto-advance to Q2) component.handleInput(ENTER); // On Q2: select B2 and advance to Submit component.handleInput(DOWN); component.handleInput(ENTER); // On Submit: Enter to submit component.handleInput(ENTER); expect(onSubmit).toHaveBeenCalledTimes(1); expect(onSubmit.mock.calls[0][0].results[0].selectedOptions).toEqual(["A1"]); expect(onSubmit.mock.calls[0][0].results[1].selectedOptions).toEqual(["B2"]); }); it("Tab and Shift+Tab switches tabs", () => { const onSubmit = vi.fn(); const questions: ExtensionAskDialogQuestion[] = [ { id: "q1", question: "Q1?", options: [{ label: "A1" }, { label: "B1" }], }, { id: "q2", question: "Q2?", options: [{ label: "A2" }, { label: "B2" }], }, ]; const component = new AskDialogComponent(questions, { onSubmit, onCancel: vi.fn(), onPrompt: vi.fn(), }); // Tab from Q1 -> Q2 component.handleInput(TAB); // Tab from Q2 -> Submit component.handleInput(TAB); // Shift+Tab from Submit -> Q2 component.handleInput(SHIFT_TAB); // Down to B2, Enter -> Submit component.handleInput(DOWN); component.handleInput(ENTER); // Enter on Submit component.handleInput(ENTER); expect(onSubmit).toHaveBeenCalledTimes(1); expect(onSubmit.mock.calls[0][0].results[0].selectedOptions).toEqual([]); expect(onSubmit.mock.calls[0][0].results[1].selectedOptions).toEqual(["B2"]); }); it("Submit tab shows unanswered warning but Enter still submits", () => { const onSubmit = vi.fn(); const questions: ExtensionAskDialogQuestion[] = [ { id: "q1", question: "Q1?", options: [{ label: "A1" }, { label: "B1" }], }, { id: "q2", question: "Q2?", options: [{ label: "A2" }, { label: "B2" }], }, ]; const component = new AskDialogComponent(questions, { onSubmit, onCancel: vi.fn(), onPrompt: vi.fn(), }); // Tab to Submit component.handleInput(TAB); component.handleInput(TAB); const output = render(component); expect(output.toLowerCase()).toContain("unanswered"); // Enter on Submit component.handleInput(ENTER); expect(onSubmit).toHaveBeenCalledTimes(1); expect(onSubmit.mock.calls[0][0].results[0].selectedOptions).toEqual([]); expect(onSubmit.mock.calls[0][0].results[1].selectedOptions).toEqual([]); }); it("Esc/cancel fires onCancel", () => { const onCancel = vi.fn(); const questions: ExtensionAskDialogQuestion[] = [ { id: "q1", question: "Choose one?", options: [{ label: "Option A" }], }, ]; const component = new AskDialogComponent(questions, { onSubmit: vi.fn(), onCancel, onPrompt: vi.fn(), }); component.handleInput(CANCEL); expect(onCancel).toHaveBeenCalledTimes(1); }); it("n on an option calls onPrompt and stores note with marker", async () => { const onPrompt = vi.fn().mockReturnValue(Promise.resolve("My Custom Note")); const onSubmit = vi.fn(); const questions: ExtensionAskDialogQuestion[] = [ { id: "q1", question: "Choose one?", options: [{ label: "Option A" }], }, ]; const component = new AskDialogComponent(questions, { onSubmit, onCancel: vi.fn(), onPrompt, }); // Highlight is on Option A. Press 'n'. component.handleInput("n"); // Await microtasks so the async #promptForNote runs await Promise.resolve(); await Promise.resolve(); expect(onPrompt).toHaveBeenCalledTimes(1); expect(onPrompt.mock.calls[0][0]).toBe("Note for Option A: Choose one?"); // Verify note is saved by submitting component.handleInput(ENTER); expect(onSubmit).toHaveBeenCalledTimes(1); expect(onSubmit.mock.calls[0][0].results[0].note).toBe("My Custom Note"); }); it("note prefill is empty when editing a different row after noting another option", async () => { const onPrompt = vi.fn(); const onSubmit = vi.fn(); const questions: ExtensionAskDialogQuestion[] = [ { id: "q1", question: "Choose one?", options: [{ label: "Option A" }, { label: "Option B" }], }, ]; const component = new AskDialogComponent(questions, { onSubmit, onCancel: vi.fn(), onPrompt, }); // Cursor starts on Option A. Add a note for A. onPrompt.mockReturnValueOnce(Promise.resolve("Note for A")); component.handleInput("n"); await Promise.resolve(); await Promise.resolve(); expect(onPrompt).toHaveBeenCalledTimes(1); expect(onPrompt.mock.calls[0][0]).toBe("Note for Option A: Choose one?"); // No prior note → prefill is undefined. expect(onPrompt.mock.calls[0][1]).toBeUndefined(); // Move down to Option B and open its note. component.handleInput(DOWN); onPrompt.mockReturnValueOnce(Promise.resolve("Note for B")); component.handleInput("n"); await Promise.resolve(); await Promise.resolve(); expect(onPrompt).toHaveBeenCalledTimes(2); // Prefill for Option B must be undefined — not the note from Option A. expect(onPrompt.mock.calls[1][1]).toBeUndefined(); // Move back up to Option A and re-open its note. component.handleInput("\x1b[A"); // UP onPrompt.mockReturnValueOnce(Promise.resolve("Updated note")); component.handleInput("n"); await Promise.resolve(); await Promise.resolve(); expect(onPrompt).toHaveBeenCalledTimes(3); // Note now belongs to Option B, so re-editing Option A starts empty. expect(onPrompt.mock.calls[2][1]).toBeUndefined(); }); it("note prefill reuses the existing note when re-editing the same row", async () => { const onPrompt = vi.fn(); const questions: ExtensionAskDialogQuestion[] = [ { id: "q1", question: "Choose one?", options: [{ label: "Option A" }], }, ]; const component = new AskDialogComponent(questions, { onSubmit: vi.fn(), onCancel: vi.fn(), onPrompt, }); // Add a note on Option A. onPrompt.mockReturnValueOnce(Promise.resolve("My note")); component.handleInput("n"); await Promise.resolve(); await Promise.resolve(); // Re-open the note on the same row (cursor still on Option A). onPrompt.mockReturnValueOnce(Promise.resolve("Updated note")); component.handleInput("n"); await Promise.resolve(); await Promise.resolve(); expect(onPrompt).toHaveBeenCalledTimes(2); // Same row → prefill reuses the existing note. expect(onPrompt.mock.calls[1][1]).toBe("My note"); }); it("omits a note when a single-select answer changes to a different option", async () => { const onPrompt = vi.fn().mockReturnValue(Promise.resolve("Note for A")); const onSubmit = vi.fn(); const questions: ExtensionAskDialogQuestion[] = [ { id: "q1", question: "Choose one?", options: [{ label: "Option A" }, { label: "Option B" }], }, ]; const component = new AskDialogComponent(questions, { onSubmit, onCancel: vi.fn(), onPrompt, }); component.handleInput("n"); await Promise.resolve(); await Promise.resolve(); component.handleInput(DOWN); component.handleInput(ENTER); expect(onSubmit).toHaveBeenCalledTimes(1); expect(onSubmit.mock.calls[0][0].results[0].selectedOptions).toEqual(["Option B"]); expect(onSubmit.mock.calls[0][0].results[0].note).toBeUndefined(); }); it("clears the note when a noted multi-select option is toggled off", async () => { const onPrompt = vi.fn().mockReturnValue(Promise.resolve("Note for A")); const onSubmit = vi.fn(); const questions: ExtensionAskDialogQuestion[] = [ { id: "q1", question: "Choose multiple?", options: [{ label: "Option A" }, { label: "Option B" }], multi: true, }, ]; const component = new AskDialogComponent(questions, { onSubmit, onCancel: vi.fn(), onPrompt, }); component.handleInput("n"); await Promise.resolve(); await Promise.resolve(); component.handleInput(SPACE); component.handleInput(SPACE); expect(render(component)).not.toContain("✎ note"); // Select Option B and confirm from the Submit tab; the cleared note // must not resurface. component.handleInput(DOWN); component.handleInput(SPACE); component.handleInput(TAB); component.handleInput(ENTER); expect(onSubmit).toHaveBeenCalledTimes(1); expect(onSubmit.mock.calls[0][0].results[0].selectedOptions).toEqual(["Option B"]); expect(onSubmit.mock.calls[0][0].results[0].note).toBeUndefined(); }); it("shows selected multi-select options together with custom input on Submit", async () => { const onPrompt = vi.fn().mockReturnValue(Promise.resolve("custom detail")); const onSubmit = vi.fn(); const questions: ExtensionAskDialogQuestion[] = [ { id: "q1", question: "Choose multiple?", options: [{ label: "Option A" }, { label: "Option B" }], multi: true, }, { id: "q2", question: "Second question?", options: [{ label: "Option C" }], }, ]; const component = new AskDialogComponent(questions, { onSubmit, onCancel: vi.fn(), onPrompt, }); component.handleInput(SPACE); component.handleInput(DOWN); component.handleInput(DOWN); component.handleInput(ENTER); await Promise.resolve(); await Promise.resolve(); // Multi questions do not auto-advance after the Other prompt: still on // q1, so Tab twice (q2, then Submit) to reach the review. component.handleInput(TAB); component.handleInput(TAB); const review = render(component); expect(review).toContain("Option A"); expect(review).toContain("custom detail"); component.handleInput(ENTER); expect(onSubmit).toHaveBeenCalledTimes(1); expect(onSubmit.mock.calls[0][0].results[0].selectedOptions).toEqual(["Option A"]); expect(onSubmit.mock.calls[0][0].results[0].customInput).toBe("custom detail"); }); it("defers a timeout that fires during a pending prompt and honors the resolved custom input", async () => { vi.useFakeTimers(); const deferred = Promise.withResolvers(); const onPrompt = vi.fn().mockReturnValue(deferred.promise); const onSubmit = vi.fn(); const onTimeout = vi.fn(); const questions: ExtensionAskDialogQuestion[] = [ { id: "q1", question: "First?", options: [{ label: "Option A" }, { label: "Option B" }], }, { id: "q2", question: "Second?", options: [{ label: "Option C" }, { label: "Option D" }], recommended: 1, }, ]; const component = new AskDialogComponent( questions, { onSubmit, onCancel: vi.fn(), onPrompt }, { timeout: 1000, onTimeout }, ); // Open the "Other (type your own)" prompt on question 1. component.handleInput(DOWN); component.handleInput(DOWN); component.handleInput(ENTER); expect(onPrompt).toHaveBeenCalledTimes(1); // Timer expires while the prompt is pending: the timeout must be deferred, // not submit the recommended fallback out from under the user. vi.advanceTimersByTime(1000); expect(onTimeout).not.toHaveBeenCalled(); expect(onSubmit).not.toHaveBeenCalled(); // Resolving the prompt honors the typed answer, then runs the deferred // timeout handling exactly once. deferred.resolve("my answer"); await Promise.resolve(); await Promise.resolve(); expect(onTimeout).toHaveBeenCalledTimes(1); expect(onSubmit).toHaveBeenCalledTimes(1); const results = onSubmit.mock.calls[0][0].results; expect(results[0].customInput).toBe("my answer"); expect(results[0].selectedOptions).toEqual([]); expect(results[0].timedOut).toBeUndefined(); expect(results[1].selectedOptions).toEqual(["Option D"]); expect(results[1].timedOut).toBe(true); }); it("keeps a single-question custom prompt answer when timeout expires while the prompt is pending", async () => { vi.useFakeTimers(); const deferred = Promise.withResolvers(); const onPrompt = vi.fn().mockReturnValue(deferred.promise); const onSubmit = vi.fn(); const onTimeout = vi.fn(); const questions: ExtensionAskDialogQuestion[] = [ { id: "q1", question: "Only question?", options: [{ label: "Fallback" }], }, ]; const component = new AskDialogComponent( questions, { onSubmit, onCancel: vi.fn(), onPrompt }, { timeout: 1000, onTimeout }, ); component.handleInput(DOWN); component.handleInput(ENTER); expect(onPrompt).toHaveBeenCalledTimes(1); vi.advanceTimersByTime(1000); expect(onTimeout).not.toHaveBeenCalled(); expect(onSubmit).not.toHaveBeenCalled(); deferred.resolve("my answer"); await Promise.resolve(); await Promise.resolve(); expect(onSubmit).toHaveBeenCalledTimes(1); expect(onTimeout).not.toHaveBeenCalled(); const result = onSubmit.mock.calls[0][0].results[0]; expect(result.customInput).toBe("my answer"); expect(result.selectedOptions).toEqual([]); expect(result.timedOut).toBeUndefined(); }); it("uses a noted non-recommended option as the timeout fallback", async () => { vi.useFakeTimers(); const onPrompt = vi.fn().mockReturnValue(Promise.resolve("why B")); const onSubmit = vi.fn(); const onTimeout = vi.fn(); const questions: ExtensionAskDialogQuestion[] = [ { id: "q1", question: "Choose one?", options: [{ label: "Option A" }, { label: "Option B" }], recommended: 0, }, ]; const component = new AskDialogComponent( questions, { onSubmit, onCancel: vi.fn(), onPrompt }, { timeout: 1000, onTimeout }, ); component.handleInput(DOWN); component.handleInput("n"); await Promise.resolve(); await Promise.resolve(); vi.advanceTimersByTime(1000); expect(onTimeout).toHaveBeenCalledTimes(1); expect(onSubmit).toHaveBeenCalledTimes(1); const result = onSubmit.mock.calls[0][0].results[0]; expect(result.selectedOptions).toEqual(["Option B"]); expect(result.note).toBe("why B"); expect(result.timedOut).toBe(true); }); it("preserves a pending note on a non-recommended option when deferred timeout submits", async () => { vi.useFakeTimers(); const deferred = Promise.withResolvers(); const onPrompt = vi.fn().mockReturnValue(deferred.promise); const onSubmit = vi.fn(); const onTimeout = vi.fn(); const questions: ExtensionAskDialogQuestion[] = [ { id: "q1", question: "Choose one?", options: [{ label: "Option A" }, { label: "Option B" }], recommended: 0, }, ]; const component = new AskDialogComponent( questions, { onSubmit, onCancel: vi.fn(), onPrompt }, { timeout: 1000, onTimeout }, ); component.handleInput(DOWN); component.handleInput("n"); expect(onPrompt).toHaveBeenCalledTimes(1); vi.advanceTimersByTime(1000); expect(onTimeout).not.toHaveBeenCalled(); expect(onSubmit).not.toHaveBeenCalled(); deferred.resolve("why B"); await Promise.resolve(); await Promise.resolve(); expect(onTimeout).toHaveBeenCalledTimes(1); expect(onSubmit).toHaveBeenCalledTimes(1); const result = onSubmit.mock.calls[0][0].results[0]; expect(result.selectedOptions).toEqual(["Option B"]); expect(result.note).toBe("why B"); expect(result.timedOut).toBe(true); }); it("resets the inactivity countdown on user input after the closed/prompt guard", () => { vi.useFakeTimers(); const onTimeout = vi.fn(); const onSubmit = vi.fn(); const questions: ExtensionAskDialogQuestion[] = [ { id: "q1", question: "Choose one?", options: [{ label: "Option A" }, { label: "Option B" }], }, ]; const component = new AskDialogComponent( questions, { onSubmit, onCancel: vi.fn(), onPrompt: vi.fn() }, { timeout: 5000, onTimeout }, ); // Advance most of the timeout window. vi.advanceTimersByTime(4000); expect(onTimeout).not.toHaveBeenCalled(); // User input (DOWN) should reset the countdown. component.handleInput(DOWN); // Advancing past the *original* deadline must NOT fire the timeout — // the reset moved the deadline forward by the interaction. vi.advanceTimersByTime(2000); expect(onTimeout).not.toHaveBeenCalled(); // Advancing the remaining time after the reset DOES fire. vi.advanceTimersByTime(3000); expect(onTimeout).toHaveBeenCalledTimes(1); }); it("does not reset the countdown while a prompt is active", async () => { vi.useFakeTimers(); const deferred = Promise.withResolvers(); const onPrompt = vi.fn().mockReturnValue(deferred.promise); const onTimeout = vi.fn(); const questions: ExtensionAskDialogQuestion[] = [ { id: "q1", question: "Choose one?", options: [{ label: "Option A" }], }, ]; const component = new AskDialogComponent( questions, { onSubmit: vi.fn(), onCancel: vi.fn(), onPrompt }, { timeout: 5000, onTimeout }, ); // Open the custom-input prompt (DOWN to "Other", ENTER). component.handleInput(DOWN); component.handleInput(ENTER); expect(onPrompt).toHaveBeenCalledTimes(1); // While the prompt is pending, input is guarded — no reset. component.handleInput(DOWN); vi.advanceTimersByTime(5000); // Timeout is deferred during prompt, not fired. expect(onTimeout).not.toHaveBeenCalled(); deferred.resolve("answer"); await Promise.resolve(); await Promise.resolve(); }); it("bounds custom input prompt title for long multi-line questions", async () => { const onPrompt = vi.fn().mockReturnValue(Promise.resolve("custom")); const longQuestion = "This is a very long question ".repeat(20); const questions: ExtensionAskDialogQuestion[] = [ { id: "q1", question: longQuestion, options: [{ label: "Option A" }], }, ]; const component = new AskDialogComponent(questions, { onSubmit: vi.fn(), onCancel: vi.fn(), onPrompt, }); // Navigate to "Other" and press Enter to trigger the custom prompt. component.handleInput(DOWN); component.handleInput(ENTER); await Promise.resolve(); await Promise.resolve(); expect(onPrompt).toHaveBeenCalledTimes(1); const title = onPrompt.mock.calls[0][0] as string; const lines = title.split("\n"); // Title must be bounded to at most MAX_PROMPT_TITLE_ROWS lines. expect(lines.length).toBeLessThanOrEqual(3); // Each line must fit within the terminal content width. for (const line of lines) { expect(stripVTControlCharacters(line).length).toBeLessThanOrEqual((process.stdout.columns ?? 80) - 4); } // Must contain the prefix and a truncation indicator on the last line. expect(stripVTControlCharacters(title)).toContain("Custom answer:"); }); it("bounds note prompt title for long multi-line questions", async () => { const onPrompt = vi.fn().mockReturnValue(Promise.resolve("note")); const longQuestion = "Multi\nline\nquestion ".repeat(30); const questions: ExtensionAskDialogQuestion[] = [ { id: "q1", question: longQuestion, options: [{ label: "Option A" }], }, ]; const component = new AskDialogComponent(questions, { onSubmit: vi.fn(), onCancel: vi.fn(), onPrompt, }); // Press 'n' on the highlighted option to trigger the note prompt. component.handleInput("n"); await Promise.resolve(); await Promise.resolve(); expect(onPrompt).toHaveBeenCalledTimes(1); const title = onPrompt.mock.calls[0][0] as string; const lines = title.split("\n"); // Title must be bounded to at most MAX_PROMPT_TITLE_ROWS lines. expect(lines.length).toBeLessThanOrEqual(3); // The multi-line question must be flattened (no raw newlines expanding rows). expect(stripVTControlCharacters(title)).toContain("Note for Option A:"); }); it("scrolls question rows when cursor moves below the viewport", () => { const originalRows = Object.getOwnPropertyDescriptor(process.stdout, "rows"); Object.defineProperty(process.stdout, "rows", { configurable: true, value: 24 }); try { const options = Array.from({ length: 30 }, (_, i) => ({ label: `Option ${String(i + 1).padStart(2, "0")}`, })); const questions: ExtensionAskDialogQuestion[] = [{ id: "q1", question: "Pick one?", options }]; const component = new AskDialogComponent(questions, { onSubmit: vi.fn(), onCancel: vi.fn(), onPrompt: vi.fn(), }); const renderAt = (width: number): string => stripVTControlCharacters(component.render(width).join("\n")); const initial = renderAt(60); expect(initial).toContain("Option 01"); expect(initial).not.toContain("Option 30"); expect(initial).toContain("↓ scroll"); for (let i = 0; i < 28; i++) component.handleInput(DOWN); const scrolled = renderAt(60); expect(scrolled).not.toContain("Option 01"); expect(scrolled).toContain("Option 29"); } finally { if (originalRows) Object.defineProperty(process.stdout, "rows", originalRows); else Reflect.deleteProperty(process.stdout, "rows"); } }); it("single-question multi-select: Enter toggles instead of submitting", () => { const onSubmit = vi.fn(); const questions: ExtensionAskDialogQuestion[] = [ { id: "q1", question: "Choose multiple?", options: [{ label: "Option A" }, { label: "Option B" }], multi: true, }, ]; const component = new AskDialogComponent(questions, { onSubmit, onCancel: vi.fn(), onPrompt: vi.fn(), }); // Enter on Option B toggles it — no submit, no tab movement. component.handleInput(DOWN); component.handleInput(ENTER); expect(onSubmit).not.toHaveBeenCalled(); // The toggle registered: Submit tab confirms only Option B. component.handleInput(TAB); component.handleInput(ENTER); expect(onSubmit).toHaveBeenCalledTimes(1); expect(onSubmit.mock.calls[0][0].results[0].selectedOptions).toEqual(["Option B"]); }); it("multi-select: Enter on a checked option toggles it off; empty answer submits from Submit tab", () => { const onSubmit = vi.fn(); const questions: ExtensionAskDialogQuestion[] = [ { id: "q1", question: "Choose multiple?", options: [{ label: "Option A" }, { label: "Option B" }], multi: true, }, ]; const component = new AskDialogComponent(questions, { onSubmit, onCancel: vi.fn(), onPrompt: vi.fn(), }); // Space checks Option A, Enter on the same row unchecks it. component.handleInput(SPACE); component.handleInput(ENTER); // Submit tab warns about the unanswered question but still submits. component.handleInput(TAB); expect(render(component).toLowerCase()).toContain("unanswered"); component.handleInput(ENTER); expect(onSubmit).toHaveBeenCalledTimes(1); expect(onSubmit.mock.calls[0][0].results[0].selectedOptions).toEqual([]); }); it("renders every option's preview inline, not only the highlighted one", () => { const questions: ExtensionAskDialogQuestion[] = [ { id: "q1", question: "Pick one?", options: [ { label: "Alpha", preview: "PREVIEW-ALPHA" }, { label: "Bravo", preview: "PREVIEW-BRAVO" }, { label: "Charlie" }, ], }, ]; const component = new AskDialogComponent(questions, { onSubmit: vi.fn(), onCancel: vi.fn(), onPrompt: vi.fn(), }); // Cursor defaults to option 0; both previews must be visible without navigating. const out = stripVTControlCharacters(component.render(80).join("\n")); expect(out).toContain("PREVIEW-ALPHA"); expect(out).toContain("PREVIEW-BRAVO"); }); it("refreshes cached preview styling after theme invalidation", async () => { const createComponent = (): AskDialogComponent => new AskDialogComponent( [{ id: "q1", question: "Pick one?", options: [{ label: "Alpha", preview: "CACHE-PREVIEW" }] }], { onSubmit: vi.fn(), onCancel: vi.fn(), onPrompt: vi.fn() }, ); const previewLine = (component: AskDialogComponent): string => component.render(80).find(line => line.includes("CACHE-PREVIEW")) ?? ""; const originalTheme = darkTheme; if (!originalTheme) throw new Error("Failed to load dark theme"); const lightTheme = await getThemeByName("light"); if (!lightTheme) throw new Error("Failed to load light theme"); const cachedComponent = createComponent(); const before = previewLine(cachedComponent); expect(stripVTControlCharacters(before)).toContain("│ CACHE-PREVIEW"); try { setThemeInstance(lightTheme); const stale = previewLine(cachedComponent); const fresh = previewLine(createComponent()); expect(stripVTControlCharacters(stale)).toBe(stripVTControlCharacters(fresh)); expect(stale).not.toBe(fresh); cachedComponent.invalidate(); expect(previewLine(cachedComponent)).toBe(fresh); } finally { setThemeInstance(originalTheme); cachedComponent.invalidate(); } }); it("keeps the memoized overflowing render identical to the initial width-adjusted render", () => { const originalRows = Object.getOwnPropertyDescriptor(process.stdout, "rows"); Object.defineProperty(process.stdout, "rows", { configurable: true, value: 24 }); try { const edgeLine = `${"X".repeat(67)}Ω`; const filler = Array.from({ length: 30 }, (_, index) => `filler-${index}`).join("\n"); const component = new AskDialogComponent( [ { id: "q1", question: "Inspect?", options: [{ label: "Alpha", preview: `\`\`\`\n${edgeLine}\n${filler}\n\`\`\`` }], }, ], { onSubmit: vi.fn(), onCancel: vi.fn(), onPrompt: vi.fn() }, ); const initial = render(component); const cached = render(component); expect(initial).toContain("Ω"); expect(cached).toBe(initial); } finally { if (originalRows) Object.defineProperty(process.stdout, "rows", originalRows); else Reflect.deleteProperty(process.stdout, "rows"); } }); it("keeps the cancel hint visible with tabs and a tall preview", () => { const originalRows = Object.getOwnPropertyDescriptor(process.stdout, "rows"); Object.defineProperty(process.stdout, "rows", { configurable: true, value: 24 }); try { const preview = `\`\`\`\n${Array.from({ length: 40 }, (_, index) => `line-${index}`).join("\n")}\n\`\`\``; const component = new AskDialogComponent( [ { id: "q1", question: "Inspect?", options: [{ label: "Alpha", preview }], multi: true }, { id: "q2", question: "Continue?", options: [{ label: "Bravo" }] }, ], { onSubmit: vi.fn(), onCancel: vi.fn(), onPrompt: vi.fn() }, ); const out = render(component); expect(out).toContain("PgUp/PgDn"); expect(out).toContain("Tab/←/→"); expect(out).not.toContain(" tabs"); expect(out).toContain("Ctrl+G cancel"); setKeybindings( KeybindingsManager.inMemory({ "tui.select.cancel": "ctrl+g", "tui.select.pageUp": "ctrl+u", "tui.select.pageDown": "ctrl+d", }), ); const remapped = render(component); expect(remapped).toContain("Ctrl+U/Ctrl+D"); expect(remapped).toContain("Ctrl+G cancel"); } finally { if (originalRows) Object.defineProperty(process.stdout, "rows", originalRows); else Reflect.deleteProperty(process.stdout, "rows"); } }); it("pages through an inline preview taller than the question viewport", () => { const originalRows = Object.getOwnPropertyDescriptor(process.stdout, "rows"); Object.defineProperty(process.stdout, "rows", { configurable: true, value: 24 }); try { const previewLines = Array.from({ length: 80 }, (_, index) => { if (index === 0) return "PREVIEW-FIRST"; if (index === 40) return "PREVIEW-MIDDLE"; if (index === 79) return "PREVIEW-LAST"; return `preview-line-${index}`; }); const questions: ExtensionAskDialogQuestion[] = [ { id: "q1", question: "Inspect the preview?", options: [{ label: "Plain" }, { label: "Alpha", preview: `\`\`\`\n${previewLines.join("\n")}\n\`\`\`` }], }, ]; const component = new AskDialogComponent(questions, { onSubmit: vi.fn(), onCancel: vi.fn(), onPrompt: vi.fn(), }); let out = render(component); expect(out).toContain("PREVIEW-FIRST"); expect(out).not.toContain("PREVIEW-MIDDLE"); expect(out).not.toContain("PREVIEW-LAST"); expect(out).not.toContain("PgUp/PgDn"); expect(out).toContain("↓ scroll"); component.handleInput(DOWN); for (let page = 0; page < 4; page++) component.handleInput(PAGE_DOWN); out = render(component); expect(out).toContain("PgUp/PgDn"); expect(out).toContain("PREVIEW-MIDDLE"); component.handleInput(DOWN); out = render(component); expect(out).toContain("Other (type your own)"); expect(out).not.toContain("PREVIEW-MIDDLE"); expect(out).not.toContain("PgUp/PgDn"); component.handleInput(UP); out = render(component); expect(out).toContain("PREVIEW-FIRST"); expect(out).toContain("PgUp/PgDn"); for (let page = 0; page < 10; page++) { component.handleInput(PAGE_DOWN); out = render(component); } expect(out).toContain("PREVIEW-LAST"); expect(out).not.toContain("Other (type your own)"); component.handleInput(DOWN); out = render(component); expect(out).toContain("Other (type your own)"); component.handleInput(UP); out = render(component); for (let page = 0; page < 10; page++) { component.handleInput(PAGE_UP); out = render(component); } expect(out).toContain("PREVIEW-FIRST"); } finally { if (originalRows) Object.defineProperty(process.stdout, "rows", originalRows); else Reflect.deleteProperty(process.stdout, "rows"); } }); it("keeps a selected inline preview visible when its row fits the viewport", () => { const originalRows = Object.getOwnPropertyDescriptor(process.stdout, "rows"); Object.defineProperty(process.stdout, "rows", { configurable: true, value: 24 }); try { const options = [ ...Array.from({ length: 8 }, (_, index) => ({ label: `Plain ${index}` })), { label: "Target", preview: "```\nPREVIEW-SHORT-FIRST\npreview-short-middle\nPREVIEW-SHORT-LAST\n```" }, ...Array.from({ length: 8 }, (_, index) => ({ label: `After ${index}` })), ]; const component = new AskDialogComponent([{ id: "q1", question: "Pick one?", options }], { onSubmit: vi.fn(), onCancel: vi.fn(), onPrompt: vi.fn(), }); for (let index = 0; index < 8; index++) component.handleInput(DOWN); let out = render(component); expect(out).toContain("PREVIEW-SHORT-FIRST"); expect(out).toContain("PREVIEW-SHORT-LAST"); expect(out).not.toContain("PgUp/PgDn"); expect(out).toMatch(/[↓↑↕] scroll/); component.handleInput(PAGE_DOWN); out = render(component); expect(out).toContain("PREVIEW-SHORT-FIRST"); expect(out).toContain("PREVIEW-SHORT-LAST"); expect(out).not.toContain("PgUp/PgDn"); expect(out).toMatch(/[↓↑↕] scroll/); } finally { if (originalRows) Object.defineProperty(process.stdout, "rows", originalRows); else Reflect.deleteProperty(process.stdout, "rows"); } }); it("does not repeat the tab chip in the question line", () => { const questions: ExtensionAskDialogQuestion[] = [ { id: "q1", question: "First question?", header: "Alpha", options: [{ label: "A" }] }, { id: "q2", question: "Second question?", header: "Beta", options: [{ label: "B" }] }, ]; const component = new AskDialogComponent(questions, { onSubmit: vi.fn(), onCancel: vi.fn(), onPrompt: vi.fn(), }); const output = render(component); // Tab bar still shows the chip… expect(output).toContain("Alpha"); // …but the question line is just the question, not "[Alpha] First question?". expect(output).toContain("First question?"); expect(output).not.toContain("[Alpha]"); }); it("bounds in-body question header for long multi-line questions", () => { const onSubmit = vi.fn(); const longQuestion = "This is a very long question ".repeat(30); const questions: ExtensionAskDialogQuestion[] = [ { id: "q1", question: longQuestion, options: [{ label: "Option A" }, { label: "Option B" }], }, ]; const component = new AskDialogComponent(questions, { onSubmit, onCancel: vi.fn(), onPrompt: vi.fn(), }); // The rendered body must not blow out with the full 30-line question. // The header is capped to MAX_HEADER_ROWS lines. const output = render(component); // The question text should appear but be truncated — verify it does // not contain the full repeated text (30 copies would be ~870 chars). expect(output).toContain("This is a very long question"); // Count occurrences of the repeated phrase — should be far fewer than 30. const matches = output.match(/This is a very long question/g); expect(matches?.length ?? 0).toBeLessThan(10); }); it("Other editor cancel returns to the option list without submitting", async () => { const onPrompt = vi.fn().mockReturnValue(Promise.resolve(undefined)); const onSubmit = vi.fn(); const onCancel = vi.fn(); const questions: ExtensionAskDialogQuestion[] = [ { id: "q1", question: "Choose one?", options: [{ label: "Option A" }, { label: "Option B" }], }, ]; const component = new AskDialogComponent(questions, { onSubmit, onCancel, onPrompt, }); // Navigate to "Other" and press Enter to open the custom input prompt. component.handleInput(DOWN); component.handleInput(DOWN); component.handleInput(ENTER); await Promise.resolve(); await Promise.resolve(); // The prompt was cancelled (returns undefined). The dialog must stay // open — no submit, no cancel. expect(onPrompt).toHaveBeenCalledTimes(1); expect(onSubmit).not.toHaveBeenCalled(); expect(onCancel).not.toHaveBeenCalled(); // The dialog should still be usable: select Option A and submit. component.handleInput("\x1b[A"); // UP to Option B component.handleInput("\x1b[A"); // UP to Option A component.handleInput(ENTER); expect(onSubmit).toHaveBeenCalledTimes(1); expect(onSubmit.mock.calls[0][0].results[0].selectedOptions).toEqual(["Option A"]); }); it("keeps a fixed spawn-time height across tabs, clamped to 70% of the terminal", () => { const questions: ExtensionAskDialogQuestion[] = [ { id: "q1", question: "Pick one?", options: [{ label: "Option A" }, { label: "Option B" }], multi: true, }, ]; const component = new AskDialogComponent(questions, { onSubmit: vi.fn(), onCancel: vi.fn(), onPrompt: vi.fn(), }); const cap = Math.max(12, Math.floor((process.stdout.rows || 40) * 0.7)); const questionTab = component.render(80); expect(questionTab.length).toBeLessThanOrEqual(cap); // The submit tab renders at exactly the same height — the box is // sized once from the tallest tab, not per-tab content. component.handleInput(TAB); const submitTab = component.render(80); expect(submitTab.length).toBe(questionTab.length); // Toggling an option (which changes the review summary) does not // resize the box either. component.handleInput(SHIFT_TAB); component.handleInput(SPACE); expect(component.render(80).length).toBe(questionTab.length); }); it("clears the custom answer when the Other prompt is submitted empty", async () => { const onPrompt = vi.fn(); const onSubmit = vi.fn(); const questions: ExtensionAskDialogQuestion[] = [ { id: "q1", question: "Choose multiple?", options: [{ label: "Option A" }, { label: "Option B" }], multi: true, }, ]; const component = new AskDialogComponent(questions, { onSubmit, onCancel: vi.fn(), onPrompt, }); // Set a custom answer via Other. onPrompt.mockReturnValueOnce(Promise.resolve("my custom answer")); component.handleInput(DOWN); // Option B component.handleInput(DOWN); // Other component.handleInput(ENTER); await Promise.resolve(); await Promise.resolve(); expect(render(component)).toContain("my custom answer"); // Reopen Other (prefilled with the current answer) and submit an // empty value: the custom answer is unselected. onPrompt.mockReturnValueOnce(Promise.resolve("")); component.handleInput(ENTER); await Promise.resolve(); await Promise.resolve(); expect(onPrompt).toHaveBeenNthCalledWith(2, expect.any(String), "my custom answer"); expect(render(component)).not.toContain("my custom answer"); // Submitting confirms nothing was kept. component.handleInput(TAB); component.handleInput(ENTER); expect(onSubmit).toHaveBeenCalledTimes(1); expect(onSubmit.mock.calls[0][0].results[0].customInput).toBeUndefined(); }); it("normalizes malformed questions so render and submit do not crash", () => { const onSubmit = vi.fn(); // A question entry that reaches the live dialog without a string // `question` field (e.g. via the askDialog extension surface) used to // throw `replaceTabs(undefined)` and take down the whole TUI. const questions = [{ id: "q1", options: [{ label: "Option A" }] }] as unknown as ExtensionAskDialogQuestion[]; const component = new AskDialogComponent(questions, { onSubmit, onCancel: vi.fn(), onPrompt: vi.fn(), }); expect(() => render(component)).not.toThrow(); component.handleInput(ENTER); expect(onSubmit).toHaveBeenCalledTimes(1); const result = onSubmit.mock.calls[0][0].results[0]; expect(result.question).toBe(""); expect(result.selectedOptions).toEqual(["Option A"]); }); });