From 7cca5eb54d1964d19ce17ebb45507f1939960b8b Mon Sep 17 00:00:00 2001 From: Erik Kotsiuba Date: Sat, 25 Apr 2026 18:56:00 +0200 Subject: [PATCH] feat(ui): support custom answers in preview questions --- docs/architecture.md | 2 +- docs/contract.md | 6 ++-- src/ask-tool.ts | 4 +-- src/state/selectors.ts | 12 +++----- src/ui/render-helpers.ts | 7 ++++- src/ui/render-question.ts | 7 +++-- tests/ask-tool.test.ts | 25 +++++++++++++++ tests/render-question.test.ts | 58 +++++++++++++++++++++++++++++++++++ tests/state.test.ts | 33 ++++++++++++++++++-- 9 files changed, 135 insertions(+), 19 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index af23aa0..38641df 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -55,7 +55,7 @@ The codebase is split so the implementation reads through file boundaries and na - single-select answers serialize as arrays - single-select free-form answers replace selected options for that question - multi-select free-form answers augment selected options instead of clearing them -- preview questions do not get the synthetic custom-answer option +- preview questions keep their preview-pane behavior while also supporting the synthetic custom-answer option - deselected option notes stay in UI state - only selected option notes are emitted in the final result - editor lifecycle stays in the controller, not in the reducers diff --git a/docs/contract.md b/docs/contract.md index 93da92b..9de4617 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -37,8 +37,8 @@ This document defines the stable external behavior. It does not explain internal - `type` defaults to `single` - `required` defaults to `false` - `required` is metadata only; it never blocks submission -- preview questions require preview text for every option; option descriptions do not satisfy this requirement, and invalid preview payloads report a fix hint to add preview text or switch to `type: "single"` -- non-`preview` questions get an internal `Type your own` option +- preview questions require preview text for every declared option; option descriptions do not satisfy this requirement, and invalid preview payloads report a fix hint to add preview text or switch to `type: "single"` +- all questions get an internal `Type your own` option ## Output @@ -190,7 +190,7 @@ This document defines the stable external behavior. It does not explain internal - tabbed multi-question flow - single-select, multi-select, and preview questions -- inline free-form answers for non-preview questions +- inline free-form answers for all question types - pi-style `@` file path autocomplete inside free-form answer and note editors - question notes via `Shift+N` - option notes via `n` diff --git a/src/ask-tool.ts b/src/ask-tool.ts index bcf48c3..395d71c 100644 --- a/src/ask-tool.ts +++ b/src/ask-tool.ts @@ -177,9 +177,7 @@ function formatNonInteractiveMessage( for (const option of question.options) { lines.push(` - ${option.label} [${option.value}]`); } - if (question.type !== "preview") { - lines.push(" - Type your own [custom]"); - } + lines.push(" - Type your own [custom]"); } lines.push( diff --git a/src/state/selectors.ts b/src/state/selectors.ts index af25de2..22c2464 100644 --- a/src/state/selectors.ts +++ b/src/state/selectors.ts @@ -32,13 +32,11 @@ export function getRenderableOptions( const options: AskDisplayOption[] = question.options.map((option) => ({ ...option, })); - if (question.type !== "preview") { - options.push({ - value: OTHER_OPTION_VALUE, - label: OTHER_OPTION_LABEL, - isCustomOption: true, - }); - } + options.push({ + value: OTHER_OPTION_VALUE, + label: OTHER_OPTION_LABEL, + isCustomOption: true, + }); return options; } diff --git a/src/ui/render-helpers.ts b/src/ui/render-helpers.ts index cbc9ff6..abe8674 100644 --- a/src/ui/render-helpers.ts +++ b/src/ui/render-helpers.ts @@ -255,7 +255,11 @@ export function renderBox( export function renderPreviewPaneContent( selectedOption: - | { label: string; description?: string; preview?: string } + | { + label: string; + description?: string; + preview?: string; + } | undefined, theme: Theme, width: number @@ -271,6 +275,7 @@ export function renderPreviewPaneContent( content.push({ text: selectedOption.description, color: "muted" }); } content.push({ text: "", color: "dim" }); + for (const previewLine of (selectedOption.preview ?? NO_PREVIEW_TEXT).split( "\n" )) { diff --git a/src/ui/render-question.ts b/src/ui/render-question.ts index 661418b..8d381cf 100644 --- a/src/ui/render-question.ts +++ b/src/ui/render-question.ts @@ -124,7 +124,9 @@ function renderPreviewQuestion(context: QuestionRenderContext) { const selectedOption = options[state.activeOptionIndex]; const answer = getAnswer(state, question.id); - if (width >= UI_DIMENSIONS.previewWideMinWidth && options.length > 0) { + if (selectedOption?.isCustomOption) { + renderPreviewOptionList(state, options, theme, width, answer).forEach(add); + } else if (width >= UI_DIMENSIONS.previewWideMinWidth && options.length > 0) { renderWidePreviewLayout( add, state, @@ -151,7 +153,8 @@ function renderPreviewQuestion(context: QuestionRenderContext) { questionId: question.id, answer, option: selectedOption, - withGap: true, + selected: true, + withGap: !selectedOption?.isCustomOption, }); } diff --git a/tests/ask-tool.test.ts b/tests/ask-tool.test.ts index 32268df..09c7fc2 100644 --- a/tests/ask-tool.test.ts +++ b/tests/ask-tool.test.ts @@ -89,6 +89,31 @@ test("ask tool returns pending questions in non-interactive mode", async () => { assert.match(result.content[0].text, CUSTOM_OPTION_RE); }); +test("ask tool includes custom answer fallback for preview questions", async () => { + const tool = registerMockTool(); + + const result = await tool.execute( + "call-1", + { + title: "Clarify", + questions: [ + { + id: "layout", + label: "Layout", + prompt: "Which layout?", + type: "preview", + options: [{ value: "compact", label: "Compact", preview: "A" }], + }, + ], + }, + undefined, + noop, + makeCtx(false) + ); + + assert.match(result.content[0].text, CUSTOM_OPTION_RE); +}); + test("ask tool rejects invalid payloads before UI opens with structured issues", async () => { const tool = registerMockTool(); diff --git a/tests/render-question.test.ts b/tests/render-question.test.ts index 0c0a07a..66f1b45 100644 --- a/tests/render-question.test.ts +++ b/tests/render-question.test.ts @@ -154,3 +154,61 @@ test("selected multiline custom option renders full editor block", () => { assert(lines.some((line) => line.includes("second line"))); assert(!lines.some((line) => line.includes("first line second line"))); }); + +test("preview questions also show the custom answer option", () => { + const state = createInitialState({ + questions: [ + { + id: "q1", + prompt: "Pick one", + type: "preview", + options: [{ value: "a", label: "A", preview: "Preview A" }], + }, + ], + }); + + const lines: string[] = []; + renderQuestionScreen({ + editor: mockEditor(), + lines, + options: getRenderableOptions(state.questions[0]), + question: state.questions[0], + state, + theme: mockTheme(), + width: 80, + }); + + assert(lines.some((line) => line.includes("Type your own"))); +}); + +test("preview custom option reuses the normal inline editor", () => { + let state = createInitialState({ + questions: [ + { + id: "q1", + prompt: "Pick one", + type: "preview", + options: [{ value: "a", label: "A", preview: "Preview A" }], + }, + ], + }); + state = applyNumberShortcut(state, 2); + + const lines: string[] = []; + renderQuestionScreen({ + editor: mockEditor("", ["┌────┐", "", "└────┘"]), + lines, + options: getRenderableOptions(state.questions[0]), + question: state.questions[0], + state, + theme: mockTheme(), + width: 80, + }); + + assert(lines.some((line) => line.includes("Type your own"))); + assert(lines.some((line) => line.includes("Type answer..."))); + const optionIndex = lines.findIndex((line) => line.includes("Type your own")); + const inputIndex = lines.findIndex((line) => line.includes("Type answer...")); + assert.equal(inputIndex, optionIndex + 1); + assert(!lines.some((line) => line.includes("asdasddasdasdasdere"))); +}); diff --git a/tests/state.test.ts b/tests/state.test.ts index 018979a..e9315bb 100644 --- a/tests/state.test.ts +++ b/tests/state.test.ts @@ -93,9 +93,13 @@ test("preview questions keep their type and option previews", () => { assert.equal(state.questions[0].options[0].preview, "Example"); assert.equal( getRenderableOptions(state.questions[0]).at(-1)?.isCustomOption, - undefined + true ); - assert.equal(getRenderableOptions(state.questions[0]).length, 1); + assert.equal( + getRenderableOptions(state.questions[0]).at(-1)?.label, + "Type your own" + ); + assert.equal(getRenderableOptions(state.questions[0]).length, 2); }); test("createInitialState rejects duplicate question ids", () => { @@ -529,6 +533,31 @@ test("preview questions can store selected option notes for submission", () => { }); }); +test("preview questions support custom answers", () => { + let state = createInitialState({ + questions: [ + { + id: "preview", + prompt: "Pick layout", + type: "preview", + options: [{ value: "compact", label: "Compact", preview: "A" }], + }, + ], + }); + + state = applyNumberShortcut(state, 2); + state = submitCustomAnswer(state, "Something else"); + + assert.deepEqual(toAskResult(state).answers.preview, { + values: ["Something else"], + labels: ["Something else"], + indices: [], + customText: "Something else", + note: undefined, + optionNotes: undefined, + }); +}); + test("tab navigation wraps including submit tab", () => { let state = createInitialState(sampleParams()); state = moveTab(state, -1);