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 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
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
)) {
|
)) {
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user