feat(ui): support custom answers in preview questions

This commit is contained in:
Erik Kotsiuba
2026-04-25 20:01:43 +02:00
parent 1ce721094c
commit 7cca5eb54d
9 changed files with 135 additions and 19 deletions
+1 -1
View File
@@ -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 answers serialize as arrays
- single-select free-form answers replace selected options for that question - single-select free-form answers replace selected options for that question
- multi-select free-form answers augment selected options instead of clearing them - 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 - deselected option notes stay in UI state
- only selected option notes are emitted in the final result - only selected option notes are emitted in the final result
- editor lifecycle stays in the controller, not in the reducers - editor lifecycle stays in the controller, not in the reducers
+3 -3
View File
@@ -37,8 +37,8 @@ This document defines the stable external behavior. It does not explain internal
- `type` defaults to `single` - `type` defaults to `single`
- `required` defaults to `false` - `required` defaults to `false`
- `required` is metadata only; it never blocks submission - `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"` - 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"`
- non-`preview` questions get an internal `Type your own` option - all questions get an internal `Type your own` option
## Output ## Output
@@ -190,7 +190,7 @@ This document defines the stable external behavior. It does not explain internal
- tabbed multi-question flow - tabbed multi-question flow
- single-select, multi-select, and preview questions - 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 - pi-style `@` file path autocomplete inside free-form answer and note editors
- question notes via `Shift+N` - question notes via `Shift+N`
- option notes via `n` - option notes via `n`
+1 -3
View File
@@ -177,9 +177,7 @@ function formatNonInteractiveMessage(
for (const option of question.options) { for (const option of question.options) {
lines.push(` - ${option.label} [${option.value}]`); lines.push(` - ${option.label} [${option.value}]`);
} }
if (question.type !== "preview") { lines.push(" - Type your own [custom]");
lines.push(" - Type your own [custom]");
}
} }
lines.push( lines.push(
+5 -7
View File
@@ -32,13 +32,11 @@ export function getRenderableOptions(
const options: AskDisplayOption[] = question.options.map((option) => ({ const options: AskDisplayOption[] = question.options.map((option) => ({
...option, ...option,
})); }));
if (question.type !== "preview") { options.push({
options.push({ value: OTHER_OPTION_VALUE,
value: OTHER_OPTION_VALUE, label: OTHER_OPTION_LABEL,
label: OTHER_OPTION_LABEL, isCustomOption: true,
isCustomOption: true, });
});
}
return options; return options;
} }
+6 -1
View File
@@ -255,7 +255,11 @@ export function renderBox(
export function renderPreviewPaneContent( export function renderPreviewPaneContent(
selectedOption: selectedOption:
| { label: string; description?: string; preview?: string } | {
label: string;
description?: string;
preview?: string;
}
| undefined, | undefined,
theme: Theme, theme: Theme,
width: number width: number
@@ -271,6 +275,7 @@ export function renderPreviewPaneContent(
content.push({ text: selectedOption.description, color: "muted" }); content.push({ text: selectedOption.description, color: "muted" });
} }
content.push({ text: "", color: "dim" }); content.push({ text: "", color: "dim" });
for (const previewLine of (selectedOption.preview ?? NO_PREVIEW_TEXT).split( for (const previewLine of (selectedOption.preview ?? NO_PREVIEW_TEXT).split(
"\n" "\n"
)) { )) {
+5 -2
View File
@@ -124,7 +124,9 @@ function renderPreviewQuestion(context: QuestionRenderContext) {
const selectedOption = options[state.activeOptionIndex]; const selectedOption = options[state.activeOptionIndex];
const answer = getAnswer(state, question.id); 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( renderWidePreviewLayout(
add, add,
state, state,
@@ -151,7 +153,8 @@ function renderPreviewQuestion(context: QuestionRenderContext) {
questionId: question.id, questionId: question.id,
answer, answer,
option: selectedOption, option: selectedOption,
withGap: true, selected: true,
withGap: !selectedOption?.isCustomOption,
}); });
} }
+25
View File
@@ -89,6 +89,31 @@ test("ask tool returns pending questions in non-interactive mode", async () => {
assert.match(result.content[0].text, CUSTOM_OPTION_RE); 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 () => { test("ask tool rejects invalid payloads before UI opens with structured issues", async () => {
const tool = registerMockTool(); const tool = registerMockTool();
+58
View File
@@ -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("second line")));
assert(!lines.some((line) => line.includes("first line 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")));
});
+31 -2
View File
@@ -93,9 +93,13 @@ test("preview questions keep their type and option previews", () => {
assert.equal(state.questions[0].options[0].preview, "Example"); assert.equal(state.questions[0].options[0].preview, "Example");
assert.equal( assert.equal(
getRenderableOptions(state.questions[0]).at(-1)?.isCustomOption, 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", () => { 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", () => { test("tab navigation wraps including submit tab", () => {
let state = createInitialState(sampleParams()); let state = createInitialState(sampleParams());
state = moveTab(state, -1); state = moveTab(state, -1);