feat(ui): support custom answers in preview questions
This commit is contained in:
@@ -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
|
||||
|
||||
+3
-3
@@ -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`
|
||||
|
||||
+1
-3
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
)) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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")));
|
||||
});
|
||||
|
||||
+31
-2
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user