From b20db81d036c1d18ce512a02575578a1e4a6a7ea Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Wed, 22 Jul 2026 11:57:23 -0400 Subject: [PATCH 1/5] docs: update phase 3 plan --- ...action-refactor-phase-3-single-question.md | 184 +++++++++--------- ...7-21-questionnaire-interaction-refactor.md | 6 +- 2 files changed, 100 insertions(+), 90 deletions(-) diff --git a/docs/superpowers/plans/2026-07-21-questionnaire-interaction-refactor-phase-3-single-question.md b/docs/superpowers/plans/2026-07-21-questionnaire-interaction-refactor-phase-3-single-question.md index c8bfeb8..02ffe97 100644 --- a/docs/superpowers/plans/2026-07-21-questionnaire-interaction-refactor-phase-3-single-question.md +++ b/docs/superpowers/plans/2026-07-21-questionnaire-interaction-refactor-phase-3-single-question.md @@ -2,23 +2,25 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** Resolve a one-question questionnaire when its answer is committed, with no tabs or Review screen. +**Goal:** Resolve a one-question questionnaire when its answer is committed, without tabs or a Review detour. -**Architecture:** `input.ts` and `render.ts` treat tabs/review as multi-question-only presentation. `questionnaire-ui.ts` becomes the single completion boundary through a local `applyAction` helper that finalizes only valid one-question commits. +**Architecture:** Derive single-question mode from `questions.length === 1`. `input.ts` disables tab navigation, `render.ts` removes tab/review chrome while retaining the question header, and `questionnaire-ui.ts` owns one completion boundary after reducer transitions. Tests drive the real Pi `Editor` through `ui.custom`, matching the reference package’s public component harness. **Tech Stack:** TypeScript, Vitest, `@earendil-works/pi-tui`. --- -## File map +## Scope and file map -- `src/tui/input.ts` — disables tab switching for one question. -- `src/tui/render.ts` — hides tab/review chrome and inaccurate key hints. -- `src/tui/questionnaire-ui.ts` — finalizes valid commits once. -- `tests/tui/input.test.ts`, `tests/tui/render.test.ts` — pure behavior. -- `tests/tui/questionnaire-ui.test.ts` — create an adapter harness if absent. +- `src/tui/input.ts` — disables Left/Right tab effects for one question. +- `src/tui/render.ts` — hides tabs, keeps the single-question header, and removes inaccurate tab hints. +- `src/tui/questionnaire-ui.ts` — centralizes state application and one-question completion. +- `tests/tui/input.test.ts`, `tests/tui/render.test.ts` — pure navigation and presentation regressions. +- `tests/tui/questionnaire-ui.test.ts` — real `ui.custom`/`Editor` interaction tests. -### Task 1: Remove one-question tab and review presentation +No public API, result shape, reducer action, or multi-question behavior changes. Recommendation-to-answer synchronization remains phase 5. + +### Task 1: Make navigation and rendering single-question-aware **Files:** @@ -27,38 +29,45 @@ - Modify: `tests/tui/input.test.ts` - Modify: `tests/tui/render.test.ts` -- [ ] **Step 1: Add no-tab interpreter regressions.** +- [ ] **Step 1: Add failing pure regressions.** Use `const one = questions.slice(0, 1)` so the tests add no non-null assertions. ```ts it("does not create tab-navigation effects for one question", () => { - const one = [questions[0]!]; + const one = questions.slice(0, 1); expect(interpret("\x1b[C", ctx(one))).toEqual([]); expect(interpret("\x1b[D", ctx(one))).toEqual([]); }); -``` -- [ ] **Step 2: Add rendering regression.** - -```ts -it("hides tabs and review chrome for one question", () => { - const one = [questions[0]!]; - const text = renderQuestionnaire(initState(one), one, [], [], noopTheme, 80).join("\n"); +it("hides tabs and review chrome but keeps the single-question header", () => { + const one = questions.slice(0, 1); + const text = renderQuestionnaire( + initState(one), + one, + [], + [], + noopTheme, + 80, + ).join("\n"); + expect(text).toContain("Scope"); expect(text).not.toContain("Review"); expect(text).not.toContain("Left/Right tabs"); }); ``` -- [ ] **Step 3: Run the focused tests and confirm they fail.** +- [ ] **Step 2: Run the focused tests and confirm the new expectations fail.** ```bash pnpm exec vitest run tests/tui/input.test.ts tests/tui/render.test.ts ``` -- [ ] **Step 4: In `src/tui/input.ts`, add and use the session-size guard.** +Expected: the one-question arrows still dispatch `switchTab`, and the tab bar/hint still render. + +- [ ] **Step 3: Guard Left/Right in `src/tui/input.ts`.** Derive `const isMultiQuestion = questions.length > 1`; return `[]` for Left/Right when false, and retain the existing `questions.length + 1` tab cycle for multi-question sessions. ```ts const isMultiQuestion = questions.length > 1; -const totalTabs = isMultiQuestion ? questions.length + 1 : questions.length; +const reviewTabIndex = questions.length; +const totalTabs = questions.length + 1; if (matchesKey(data, Key.right)) { return isMultiQuestion @@ -67,79 +76,57 @@ if (matchesKey(data, Key.right)) { } if (matchesKey(data, Key.left)) { return isMultiQuestion - ? [dispatch({ type: "switchTab", tab: (state.activeTab - 1 + totalTabs) % totalTabs })] + ? [ + dispatch({ + type: "switchTab", + tab: (state.activeTab - 1 + totalTabs) % totalTabs, + }), + ] : []; } ``` -Leave the existing review branch intact: it is unreachable for a one-question session once no action can switch there. +- [ ] **Step 4: Make the renderer single-question-aware.** Call `renderTabBar` only when `questions.length > 1`. Before rendering the question body in single-question mode, retain the short header with the selected-tab styling: -- [ ] **Step 5: In `src/tui/render.ts`, render `renderTabBar` only for `questions.length > 1`.** For question hints, use `Up/Down select | ...`; retain the existing `Left/Right tabs | ...` text only for multi-question sessions. Do not change the review renderer. +```ts +if (!isMultiQuestion) { + lines.push(theme.bg("selectedBg", theme.fg("text", ` ${q.header} `)), ""); +} +``` -- [ ] **Step 6: Verify this task passes.** +Keep the Review renderer and Review hint unchanged for multi-question sessions. For question hints, include `Left/Right tabs | ` only when `isMultiQuestion` is true. + +- [ ] **Step 5: Verify and commit Task 1.** ```bash pnpm exec vitest run tests/tui/input.test.ts tests/tui/render.test.ts +git add src/tui/input.ts src/tui/render.ts tests/tui/input.test.ts tests/tui/render.test.ts +git commit -m "feat(tui): simplify single-question presentation" ``` Expected: PASS. -### Task 2: Finalize valid single-question commits in the UI adapter +### Task 2: Complete valid one-question commits at the UI adapter boundary **Files:** - Modify: `src/tui/questionnaire-ui.ts` - Create: `tests/tui/questionnaire-ui.test.ts` -- [ ] **Step 1: Create a focused `ui.custom` harness.** Mock `Editor` with `onSubmit`, `setText`, `getText`, `handleInput`, and `render`. Capture the component and `done` callback supplied by `ui.custom`; use a fake TUI with `requestRender` and a theme whose `fg`, `bg`, and `bold` return their input unchanged. - -Add these fixtures in the test file before `createHarness`: +- [ ] **Step 1: Add a real Pi component harness.** Follow the `driveCustom` pattern in `rpiv-ask-user-question/factory.test.ts`: invoke the `ui.custom` factory synchronously, instantiate the real `Editor`, supply an identity theme and `{ requestRender }`, capture the returned component and a wrapped `done`, and expose the UI promise for assertions. Do not mock `Editor` methods. -```ts -const singleQuestion: NormalizedQuestion = { - multiSelect: false, id: "scope", header: "Scope", prompt: "Pick scope", - options: [{ value: "small", label: "Small" }, { value: "large", label: "Large" }], - recommendation: null, allowOther: false, allowChat: false, -}; -const singleMultiQuestion: NormalizedQuestion = { - multiSelect: true, id: "features", header: "Features", prompt: "Pick features", - options: [{ value: "auth", label: "Auth" }, { value: "log", label: "Logging" }], - recommendation: "auth", allowOther: false, allowChat: false, -}; -``` - -- [ ] **Step 2: Add the immediate-completion tests.** - -```ts -it("resolves one single-select answer immediately", async () => { - const ui = createHarness([singleQuestion]); - ui.component.handleInput("\r"); - expect(ui.done).toHaveBeenCalledWith(expect.objectContaining({ - cancelled: false, - responses: [{ questionId: "scope", selection: { kind: "option", value: "small", label: "Small" } }], - })); -}); +The harness must use these real key sequences: Enter `"\r"`, Escape `"\x1b"`, Down `"\x1b[B"`, and Space `" "`. Its `done` wrapper resolves the promise and remains a Vitest spy. -it("does not resolve a whitespace custom submission", () => { - const ui = createHarness([{ ...singleQuestion, allowOther: true }]); - ui.component.handleInput("\x1b[B"); - ui.component.handleInput("\r"); - ui.editor.onSubmit(" "); - expect(ui.done).not.toHaveBeenCalled(); -}); +- [ ] **Step 2: Add failing adapter scenarios.** Use fixtures for a single-select question, a single-select question with `allowOther`, a single-select question with `allowChat`, and a single multi-select question with `recommendation: null`. -it("does not resolve one multi-select question until Next", () => { - const ui = createHarness([singleMultiQuestion]); - ui.component.handleInput(" "); - expect(ui.done).not.toHaveBeenCalled(); - ui.component.handleInput("\x1b[B"); - ui.component.handleInput("\x1b[B"); - ui.component.handleInput("\r"); - expect(ui.done).toHaveBeenCalledTimes(1); -}); -``` +Cover these exact flows: -Use the fixture's actual row count for the two Down inputs if it has more than one option. Do not assert a pending Promise: the captured `done` callback is deterministic. +1. Enter on the first option resolves one option response immediately. +2. Down twice then Enter on `Chat about this` resolves one chat response immediately. +3. Down twice, Enter, type `Custom` character-by-character, then Enter resolves one custom response. +4. Down twice, Enter, submit only whitespace, and assert `done` has not been called; press Escape to settle the harness as cancelled. +5. Space toggles a multi-select option without calling `done`; Down twice to `Next`, then Enter resolves one options response. +6. With two questions, answering the first does not call `done`; Escape settles the harness. - [ ] **Step 3: Run the adapter test and confirm it fails.** @@ -147,41 +134,62 @@ Use the fixture's actual row count for the two Down inputs if it has more than o pnpm exec vitest run tests/tui/questionnaire-ui.test.ts ``` -Expected: single-select confirmation does not call `done` because the current reducer advances to Review. +Expected: single-question option/chat/custom actions advance to the synthetic Review state without resolving the `ui.custom` promise. -- [ ] **Step 4: Import `Action` and add `applyAction` in `src/tui/questionnaire-ui.ts`.** +- [ ] **Step 4: Add the completion boundary in `src/tui/questionnaire-ui.ts`.** Import `Action` and `allAnswered`, then use one completion guard and one reducer entry point: ```ts +let completed = false; + +function finish(cancelled: boolean): void { + if (completed) return; + completed = true; + done(buildResult(state, questions, cancelled)); +} + function applyAction(action: Action): boolean { + if (completed) return true; state = reduce(state, action, questions); - const commits = - action.type === "selectOption" || - action.type === "selectChat" || - action.type === "confirmMulti" || - (action.type === "submitTyping" && action.value.trim().length > 0); - - if (questions.length === 1 && commits && state.answers.has(questions[0]!.id)) { - done(buildResult(state, questions, false)); + + if ( + questions.length === 1 && + state.activeTab === questions.length && + allAnswered(state, questions) + ) { + finish(false); return true; } return false; } ``` -- [ ] **Step 5: Route every action through `applyAction`.** In both editor `onSubmit` callbacks and the `dispatch` effect case, return from `handleInput` when `applyAction` returns true. On the non-completed path retain the existing editor clearing and `tui.requestRender()` calls. The trimmed-value condition prevents stale custom text from being resubmitted when whitespace is entered. +Route both editor submission callbacks and every `dispatch` effect through `applyAction`. If a submission completes, skip editor clearing and rendering. In `handleInput`, return immediately after a completing dispatch, use `finish(effect.cancelled)` for explicit finalize effects, and return after forwarding editor input when `completed` becomes true. Ignore later input once completed. -- [ ] **Step 6: Verify the phase and commit it.** +This predicate intentionally uses the reducer’s existing transition: option/chat/valid custom/`Next` advance to Review, while checkbox toggles and whitespace custom input do not. + +- [ ] **Step 5: Verify and commit Task 2.** ```bash pnpm exec vitest run tests/tui/input.test.ts tests/tui/render.test.ts tests/tui/questionnaire-ui.test.ts -git add src/tui/input.ts src/tui/render.ts src/tui/questionnaire-ui.ts tests/tui/input.test.ts tests/tui/render.test.ts tests/tui/questionnaire-ui.test.ts +git add src/tui/questionnaire-ui.ts tests/tui/questionnaire-ui.test.ts git commit -m "feat(tui): submit single-question responses immediately" ``` -Expected: Vitest passes before committing. +Expected: PASS, with exactly one completion callback per valid one-question commit. + +## Final verification + +Run: + +```bash +pnpm check +git diff --check +``` + +The existing Biome schema-version and phase-1 non-null-assertion diagnostics are baseline warnings and remain out of scope; this phase must not add new warnings. ## Plan self-review -- **Spec coverage:** Covers immediate option/chat/custom/multi-next commits and removes the one-question Review detour. -- **Placeholder scan:** Every test observes the captured completion callback, avoiding timing-dependent assertions. -- **Type consistency:** `Action`, `buildResult`, `reduce`, and UI `done` already exist; `applyAction` is local to the adapter. +- Covers option, chat, custom, multi-select `Next`, whitespace rejection, no-tab navigation, retained header, and multi-question non-regression. +- Uses no placeholders or new public interfaces. +- Leaves recommendation synchronization and multi-select custom-answer exclusivity to phase 5, where those changes are already planned. diff --git a/docs/superpowers/plans/2026-07-21-questionnaire-interaction-refactor.md b/docs/superpowers/plans/2026-07-21-questionnaire-interaction-refactor.md index 7c264b6..0f374a7 100644 --- a/docs/superpowers/plans/2026-07-21-questionnaire-interaction-refactor.md +++ b/docs/superpowers/plans/2026-07-21-questionnaire-interaction-refactor.md @@ -189,9 +189,11 @@ git commit -m "feat: publish questionnaire wait status" ## Phase 3 — Single-question fast path -This is independently usable: a one-question prompt completes as soon as the user commits a valid answer, without a Review detour. +This is independently usable: a one-question prompt completes as soon as the user commits a valid answer, without tabs or a Review detour. The detailed, executable plan is [phase 3: single-question fast path](2026-07-21-questionnaire-interaction-refactor-phase-3-single-question.md) and supersedes the legacy task breakdown below. -### Task 3: Make tabs and review multi-question-only +The revised plan retains the single-question header after removing the tab bar, uses the real Pi Editor/ui.custom harness from the reference package, centralizes completion after reducer transitions, and leaves recommendation synchronization to phase 5. + +### Legacy Task 3 (superseded; do not execute) **Files:** From 0be8906c0d8e78bc1093ecb0f26306550272489a Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Wed, 22 Jul 2026 11:59:05 -0400 Subject: [PATCH 2/5] feat(tui): simplify single-question presentation --- src/tui/input.ts | 9 +++++++-- src/tui/render.ts | 31 ++++++++++++++++++------------- tests/tui/input.test.ts | 6 ++++++ tests/tui/render.test.ts | 9 +++++++++ 4 files changed, 40 insertions(+), 15 deletions(-) diff --git a/src/tui/input.ts b/src/tui/input.ts index 3ad12a8..74ef765 100644 --- a/src/tui/input.ts +++ b/src/tui/input.ts @@ -28,6 +28,7 @@ function dispatch(a: Action): Effect { export function interpret(data: string, ctx: InputContext): Effect[] { const { state, questions } = ctx; + const isMultiQuestion = questions.length > 1; const reviewTabIndex = questions.length; const totalTabs = questions.length + 1; const q = currentQuestion(state, questions); @@ -80,10 +81,14 @@ export function interpret(data: string, ctx: InputContext): Effect[] { // Left/Right navigate tabs if (matchesKey(data, Key.right)) { - return [dispatch({ type: "switchTab", tab: (state.activeTab + 1) % totalTabs })]; + return isMultiQuestion + ? [dispatch({ type: "switchTab", tab: (state.activeTab + 1) % totalTabs })] + : []; } if (matchesKey(data, Key.left)) { - return [dispatch({ type: "switchTab", tab: (state.activeTab - 1 + totalTabs) % totalTabs })]; + return isMultiQuestion + ? [dispatch({ type: "switchTab", tab: (state.activeTab - 1 + totalTabs) % totalTabs })] + : []; } // Review tab diff --git a/src/tui/render.ts b/src/tui/render.ts index 7523b9f..15ddbe3 100644 --- a/src/tui/render.ts +++ b/src/tui/render.ts @@ -21,22 +21,24 @@ export function renderQuestionnaire( ): string[] { const lines: string[] = []; const renderWidth = Math.max(1, width); + const isMultiQuestion = questions.length > 1; const reviewTabIndex = questions.length; const q = currentQuestion(state, questions); lines.push(theme.fg("accent", "\u2500".repeat(renderWidth))); - // Tab bar - const notedIds = new Set(state.notes.keys()); - lines.push( - ...renderTabBar( - questions, - state.activeTab, - answeredIds(state), - notedIds, - theme, - ), - ); + if (isMultiQuestion) { + const notedIds = new Set(state.notes.keys()); + lines.push( + ...renderTabBar( + questions, + state.activeTab, + answeredIds(state), + notedIds, + theme, + ), + ); + } // Content if (state.activeTab === reviewTabIndex) { @@ -51,6 +53,9 @@ export function renderQuestionnaire( ), ); } else if (q) { + if (!isMultiQuestion) { + lines.push(theme.bg("selectedBg", theme.fg("text", ` ${q.header} `)), ""); + } lines.push( ...renderQuestion( { @@ -88,10 +93,10 @@ export function renderQuestionnaire( hint = "Left/Right tabs | Up/Down select | Enter submit | Esc cancel"; } else if (q?.multiSelect) { hint = - "Left/Right tabs | Up/Down select | Space toggle | Tab notes | Esc cancel"; + `${isMultiQuestion ? "Left/Right tabs | " : ""}Up/Down select | Space toggle | Tab notes | Esc cancel`; } else { hint = - "Left/Right tabs | Up/Down select | Enter confirm | Tab notes | Esc cancel"; + `${isMultiQuestion ? "Left/Right tabs | " : ""}Up/Down select | Enter confirm | Tab notes | Esc cancel`; } pushWrapped(lines, theme.fg("dim", hint), renderWidth); diff --git a/tests/tui/input.test.ts b/tests/tui/input.test.ts index 496f045..eecd842 100644 --- a/tests/tui/input.test.ts +++ b/tests/tui/input.test.ts @@ -96,6 +96,12 @@ function dispatched(effects: Effect[]) { } describe("interpret", () => { + it("does not create tab-navigation effects for one question", () => { + const one = questions.slice(0, 1); + expect(interpret("\x1b[C", ctx(one))).toEqual([]); + expect(interpret("\x1b[D", ctx(one))).toEqual([]); + }); + it("Esc returns finalize cancelled", () => { const effects = interpret("\x1b", ctx(questions)); expect(effects).toEqual([{ type: "finalize", cancelled: true }]); diff --git a/tests/tui/render.test.ts b/tests/tui/render.test.ts index 0b1aca9..c6ed381 100644 --- a/tests/tui/render.test.ts +++ b/tests/tui/render.test.ts @@ -34,6 +34,15 @@ const questions: NormalizedQuestion[] = [ ]; describe("renderQuestionnaire", () => { + it("hides tabs and review chrome but keeps the single-question header", () => { + const one = questions.slice(0, 1); + const text = renderQuestionnaire(initState(one), one, [], [], noopTheme, 80).join("\n"); + + expect(text).toContain("Scope"); + expect(text).not.toContain("Review"); + expect(text).not.toContain("Left/Right tabs"); + }); + it("renders tab bar and question content for single-choice", () => { const state = initState(questions); const lines = renderQuestionnaire(state, questions, [], [], noopTheme, 80); From 32f58a13c8d4fce927d7c725b36a5c3240197773 Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Wed, 22 Jul 2026 12:11:26 -0400 Subject: [PATCH 3/5] feat(tui): submit single-question responses immediately --- src/tui/questionnaire-ui.ts | 53 +++++++--- tests/tui/questionnaire-ui.test.ts | 149 +++++++++++++++++++++++++++++ 2 files changed, 187 insertions(+), 15 deletions(-) create mode 100644 tests/tui/questionnaire-ui.test.ts diff --git a/src/tui/questionnaire-ui.ts b/src/tui/questionnaire-ui.ts index 6244d2c..baa2867 100644 --- a/src/tui/questionnaire-ui.ts +++ b/src/tui/questionnaire-ui.ts @@ -1,7 +1,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import { Editor, type EditorTheme } from "@earendil-works/pi-tui"; import type { NormalizedQuestion, QuestionnaireResult } from "../core/types.ts"; -import { initState, reduce, buildResult } from "./state.ts"; +import { type Action, allAnswered, buildResult, initState, reduce } from "./state.ts"; import { interpret } from "./input.ts"; import { renderQuestionnaire } from "./render.ts"; @@ -11,6 +11,28 @@ export async function runQuestionnaireUI( ): Promise { return ctx.ui.custom((tui, theme, _kb, done) => { let state = initState(questions); + let completed = false; + + function finish(cancelled: boolean): void { + if (completed) return; + completed = true; + done(buildResult(state, questions, cancelled)); + } + + function applyAction(action: Action): boolean { + if (completed) return true; + state = reduce(state, action, questions); + + if ( + questions.length === 1 && + state.activeTab === questions.length && + allAnswered(state, questions) + ) { + finish(false); + return true; + } + return false; + } const editorTheme: EditorTheme = { borderColor: (s) => theme.fg("accent", s), @@ -27,15 +49,14 @@ export async function runQuestionnaireUI( editor.onSubmit = (value) => { if (state.inputMode === "typing" && state.editingQuestionId) { - state = reduce( - state, - { + if ( + applyAction({ type: "submitTyping", questionId: state.editingQuestionId, value: value.trim(), - }, - questions, - ); + }) + ) + return; editor.setText(""); tui.requestRender(); } @@ -43,21 +64,21 @@ export async function runQuestionnaireUI( notesEditor.onSubmit = (value) => { if (state.inputMode === "notes" && state.editingQuestionId) { - state = reduce( - state, - { + if ( + applyAction({ type: "submitNotes", questionId: state.editingQuestionId, value: value.trim(), - }, - questions, - ); + }) + ) + return; notesEditor.setText(""); tui.requestRender(); } }; function handleInput(data: string) { + if (completed) return; const effects = interpret(data, { state, questions, @@ -67,16 +88,18 @@ export async function runQuestionnaireUI( for (const effect of effects) { switch (effect.type) { case "dispatch": - state = reduce(state, effect.action, questions); + if (applyAction(effect.action)) return; break; case "finalize": - done(buildResult(state, questions, effect.cancelled)); + finish(effect.cancelled); return; case "forward-to-editor": editor.handleInput(data); + if (completed) return; break; case "forward-to-notes-editor": notesEditor.handleInput(data); + if (completed) return; break; case "set-editor-text": editor.setText(effect.text); diff --git a/tests/tui/questionnaire-ui.test.ts b/tests/tui/questionnaire-ui.test.ts new file mode 100644 index 0000000..b88bce5 --- /dev/null +++ b/tests/tui/questionnaire-ui.test.ts @@ -0,0 +1,149 @@ +import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; +import type { Component } from "@earendil-works/pi-tui"; +import { describe, expect, it, vi } from "vitest"; +import type { NormalizedQuestion, QuestionnaireResult } from "../../src/core/types.ts"; +import { runQuestionnaireUI } from "../../src/tui/questionnaire-ui.ts"; + +const single: NormalizedQuestion = { + multiSelect: false, + id: "scope", + header: "Scope", + prompt: "Pick scope", + options: [ + { value: "small", label: "Small" }, + { value: "large", label: "Large" }, + ], + recommendation: null, + allowOther: false, + allowChat: false, +}; + +const singleWithOther: NormalizedQuestion = { ...single, allowOther: true }; +const singleWithChat: NormalizedQuestion = { ...single, allowChat: true }; +const multi: NormalizedQuestion = { + ...single, + id: "features", + header: "Features", + multiSelect: true, +}; + +function driveCustom(questions: NormalizedQuestion[]) { + let component: Component; + let resolve!: (result: QuestionnaireResult) => void; + const result = new Promise((done) => { + resolve = done; + }); + const done = vi.fn((value: QuestionnaireResult) => resolve(value)); + const tui = { requestRender: vi.fn() }; + const theme = { + fg: (_color: string, text: string) => text, + bg: (_color: string, text: string) => text, + bold: (text: string) => text, + }; + const ctx = { + ui: { + custom: (factory: unknown) => { + component = ( + factory as ( + tui: object, + theme: object, + keybindings: object, + done: (value: QuestionnaireResult) => void, + ) => Component + )(tui, theme, {}, done); + return result; + }, + }, + } as Pick; + + return { + component: () => component, + done, + result: runQuestionnaireUI(ctx, questions), + }; +} + +function input(harness: ReturnType, data: string) { + harness.component().handleInput?.(data); +} + +describe("runQuestionnaireUI", () => { + it("resolves a one-question option response on Enter", async () => { + const harness = driveCustom([single]); + + input(harness, "\r"); + + await expect(harness.result).resolves.toMatchObject({ + cancelled: false, + responses: [{ questionId: "scope", selection: { kind: "option", value: "small" } }], + }); + expect(harness.done).toHaveBeenCalledTimes(1); + }); + + it("resolves a one-question chat response on Enter", async () => { + const harness = driveCustom([singleWithChat]); + + input(harness, "\x1b[B"); + input(harness, "\x1b[B"); + input(harness, "\r"); + + await expect(harness.result).resolves.toMatchObject({ + cancelled: false, + responses: [{ questionId: "scope", selection: { kind: "chat" } }], + }); + }); + + it("resolves a one-question custom response on Enter", async () => { + const harness = driveCustom([singleWithOther]); + + input(harness, "\x1b[B"); + input(harness, "\x1b[B"); + input(harness, "\r"); + for (const character of "Custom") input(harness, character); + input(harness, "\r"); + + await expect(harness.result).resolves.toMatchObject({ + cancelled: false, + responses: [{ questionId: "scope", selection: { kind: "custom", value: "Custom" } }], + }); + }); + + it("does not resolve whitespace-only custom input", async () => { + const harness = driveCustom([singleWithOther]); + + input(harness, "\x1b[B"); + input(harness, "\x1b[B"); + input(harness, "\r"); + input(harness, " "); + input(harness, "\r"); + + expect(harness.done).not.toHaveBeenCalled(); + input(harness, "\x1b"); + await expect(harness.result).resolves.toMatchObject({ cancelled: true }); + }); + + it("waits for Next before resolving a one-question multi-select response", async () => { + const harness = driveCustom([multi]); + + input(harness, " "); + expect(harness.done).not.toHaveBeenCalled(); + input(harness, "\x1b[B"); + input(harness, "\x1b[B"); + input(harness, "\r"); + + await expect(harness.result).resolves.toMatchObject({ + cancelled: false, + responses: [{ questionId: "features", selection: { kind: "options" } }], + }); + }); + + it("does not resolve after answering the first of two questions", async () => { + const harness = driveCustom([single, multi]); + + input(harness, "\r"); + + expect(harness.done).not.toHaveBeenCalled(); + input(harness, "\x1b"); + await expect(harness.result).resolves.toMatchObject({ cancelled: true }); + }); +}); From cb58f842c1ee42da2a11920fc839d0d4449ab66e Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Wed, 22 Jul 2026 12:57:05 -0400 Subject: [PATCH 4/5] test(tui): cover questionnaire completion guards --- tests/tui/questionnaire-ui.test.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/tui/questionnaire-ui.test.ts b/tests/tui/questionnaire-ui.test.ts index b88bce5..c77e132 100644 --- a/tests/tui/questionnaire-ui.test.ts +++ b/tests/tui/questionnaire-ui.test.ts @@ -59,6 +59,7 @@ function driveCustom(questions: NormalizedQuestion[]) { return { component: () => component, done, + requestRender: tui.requestRender, result: runQuestionnaireUI(ctx, questions), }; } @@ -77,7 +78,13 @@ describe("runQuestionnaireUI", () => { cancelled: false, responses: [{ questionId: "scope", selection: { kind: "option", value: "small" } }], }); + + input(harness, "\r"); + input(harness, "\x1b"); + input(harness, "ignored"); + expect(harness.done).toHaveBeenCalledTimes(1); + expect(harness.requestRender).not.toHaveBeenCalled(); }); it("resolves a one-question chat response on Enter", async () => { @@ -146,4 +153,25 @@ describe("runQuestionnaireUI", () => { input(harness, "\x1b"); await expect(harness.result).resolves.toMatchObject({ cancelled: true }); }); + + it("keeps Review submission for two questions", async () => { + const harness = driveCustom([single, multi]); + + input(harness, "\r"); + input(harness, " "); + input(harness, "\x1b[B"); + input(harness, "\x1b[B"); + input(harness, "\r"); + + expect(harness.done).not.toHaveBeenCalled(); + input(harness, "\r"); + await expect(harness.result).resolves.toMatchObject({ + cancelled: false, + responses: [ + { questionId: "scope", selection: { kind: "option", value: "small" } }, + { questionId: "features", selection: { kind: "options" } }, + ], + }); + expect(harness.done).toHaveBeenCalledTimes(1); + }); }); From 241aaa3e0273da05928121e064c01f98909f6fd0 Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Wed, 22 Jul 2026 13:18:08 -0400 Subject: [PATCH 5/5] refactor(tui): trim completion guards --- src/tui/questionnaire-ui.ts | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/src/tui/questionnaire-ui.ts b/src/tui/questionnaire-ui.ts index baa2867..eb3e05b 100644 --- a/src/tui/questionnaire-ui.ts +++ b/src/tui/questionnaire-ui.ts @@ -20,7 +20,6 @@ export async function runQuestionnaireUI( } function applyAction(action: Action): boolean { - if (completed) return true; state = reduce(state, action, questions); if ( @@ -29,9 +28,8 @@ export async function runQuestionnaireUI( allAnswered(state, questions) ) { finish(false); - return true; } - return false; + return completed; } const editorTheme: EditorTheme = { @@ -64,14 +62,11 @@ export async function runQuestionnaireUI( notesEditor.onSubmit = (value) => { if (state.inputMode === "notes" && state.editingQuestionId) { - if ( - applyAction({ - type: "submitNotes", - questionId: state.editingQuestionId, - value: value.trim(), - }) - ) - return; + applyAction({ + type: "submitNotes", + questionId: state.editingQuestionId, + value: value.trim(), + }); notesEditor.setText(""); tui.requestRender(); } @@ -99,7 +94,6 @@ export async function runQuestionnaireUI( break; case "forward-to-notes-editor": notesEditor.handleInput(data); - if (completed) return; break; case "set-editor-text": editor.setText(effect.text);