feat(ask): add question and option notes

This commit is contained in:
Erik Kotsiuba
2026-04-17 16:50:53 +02:00
parent 52dfc4f9a6
commit 16ff8c42e2
7 changed files with 718 additions and 57 deletions
+26
View File
@@ -151,10 +151,36 @@ The current prototype supports:
- single-select questions
- multi-select questions
- preview questions with a dedicated preview pane
- free-form answers via `Type your own`
- per-question notes via `Ctrl+N`
- per-option notes via `N`
- full inline rendering of saved notes
- number-key quick selection
- final submit/review page
- transcript rendering for call/result rows
### Result shape notes
The returned `details.answers[questionId]` object may include:
```ts
{
values: string[]
labels: string[]
customText?: string
note?: string // question-level note
optionNotes?: Record<string, string> // selected options only
}
```
Behavior:
- question-level notes are submitted whenever authored
- option notes can be authored for any active option during the UI flow
- only notes for currently selected options are included in the submitted result
- deselecting an option keeps its note in UI state, so re-selecting it restores the note
- empty note text clears the note
## Example agent instruction
You can give the agent an instruction like this to encourage proper `ask_user` usage:
+256 -34
View File
@@ -14,14 +14,19 @@ import {
confirmCurrentSelection,
createInitialState,
enterInputMode,
enterOptionNoteMode,
enterQuestionNoteMode,
getAnswer,
getCurrentQuestion,
getOptionNote,
getQuestionNote,
getRenderableOptions,
isQuestionAnswered,
isSubmitTab,
moveOption,
moveTab,
saveCustomAnswer,
saveNote,
submitCustomAnswer,
toAskResult,
toggleCurrentMultiOption,
@@ -54,7 +59,11 @@ export async function runAskFlow(
editor.onSubmit = (value) => {
suppressAutoInputForSelection = false;
state = submitCustomAnswer(state, value);
if (state.mode === "input") {
state = submitCustomAnswer(state, value);
} else if (state.mode === "note") {
state = saveNote(state, value);
}
editor.setText("");
refresh();
maybeFinish();
@@ -71,17 +80,25 @@ export async function runAskFlow(
}
}
function hydrateEditorForInputMode() {
if (state.mode !== "input" || !state.inputQuestionId) {
function hydrateEditorForCurrentMode() {
if (state.mode === "input" && state.inputQuestionId) {
const existingText =
getAnswer(state, state.inputQuestionId)?.customText ?? "";
editor.setText(existingText);
return;
}
const existingText =
getAnswer(state, state.inputQuestionId)?.customText ?? "";
if (state.mode !== "note" || !state.noteQuestionId) {
return;
}
const existingText = state.noteOptionValue
? (getOptionNote(state, state.noteQuestionId, state.noteOptionValue) ??
"")
: (getQuestionNote(state, state.noteQuestionId) ?? "");
editor.setText(existingText);
}
function syncInputModeWithSelection() {
if (isSubmitTab(state)) {
if (state.mode === "note" || isSubmitTab(state)) {
return;
}
@@ -100,32 +117,28 @@ export async function runAskFlow(
}
state = enterInputMode(state, question.id);
hydrateEditorForInputMode();
hydrateEditorForCurrentMode();
}
syncInputModeWithSelection();
function handleInput(data: string) {
if (state.mode === "input") {
if (state.mode === "input" || state.mode === "note") {
if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
state = saveCustomAnswer(state, editor.getText());
suppressAutoInputForSelection = false;
state = moveTab(state, 1);
state = saveEditorAndMoveTab(1);
syncInputModeWithSelection();
refresh();
return;
}
if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
state = saveCustomAnswer(state, editor.getText());
suppressAutoInputForSelection = false;
state = moveTab(state, -1);
state = saveEditorAndMoveTab(-1);
syncInputModeWithSelection();
refresh();
return;
}
if (matchesKey(data, Key.escape)) {
state = saveCustomAnswer(state, editor.getText());
suppressAutoInputForSelection = true;
state = saveEditorWithoutAdvancing();
suppressAutoInputForSelection = state.mode !== "input";
refresh();
return;
}
@@ -168,17 +181,38 @@ export async function runAskFlow(
suppressAutoInputForSelection = false;
state = toggleCurrentMultiOption(state);
if (state.mode === "input") {
hydrateEditorForInputMode();
hydrateEditorForCurrentMode();
}
refresh();
return;
}
}
if (isQuestionNoteShortcut(data)) {
const question = getCurrentQuestion(state);
if (question && !isSubmitTab(state)) {
state = enterQuestionNoteMode(state, question.id);
hydrateEditorForCurrentMode();
refresh();
}
return;
}
if (isOptionNoteShortcut(data)) {
const question = getCurrentQuestion(state);
const option = question
? getRenderableOptions(question)[state.optionIndex]
: undefined;
if (question && option && !option.isOther && !isSubmitTab(state)) {
state = enterOptionNoteMode(state, question.id, option.value);
hydrateEditorForCurrentMode();
refresh();
}
return;
}
if (matchesKey(data, Key.enter)) {
suppressAutoInputForSelection = false;
state = confirmCurrentSelection(state);
if (state.mode === "input") {
hydrateEditorForInputMode();
hydrateEditorForCurrentMode();
}
refresh();
maybeFinish();
@@ -196,13 +230,28 @@ export async function runAskFlow(
suppressAutoInputForSelection = false;
state = applyNumberShortcut(state, digit);
if (state.mode === "input") {
hydrateEditorForInputMode();
hydrateEditorForCurrentMode();
}
refresh();
return;
}
}
function saveEditorWithoutAdvancing(): AskState {
const text = editor.getText();
editor.setText("");
if (state.mode === "input") {
suppressAutoInputForSelection = true;
return saveCustomAnswer(state, text);
}
return saveNote(state, text);
}
function saveEditorAndMoveTab(delta: number): AskState {
const nextState = saveEditorWithoutAdvancing();
suppressAutoInputForSelection = false;
return moveTab(nextState, delta);
}
function render(width: number): string[] {
if (cachedLines) return cachedLines;
@@ -305,8 +354,9 @@ function renderSubmit(
);
lines.push("");
const submittedAnswers = toAskResult(state).answers;
for (const question of state.questions) {
const answer = getAnswer(state, question.id);
const answer = submittedAnswers[question.id];
pushWrappedText(
lines,
question.prompt,
@@ -323,15 +373,45 @@ function renderSubmit(
continue;
}
const answerText = answer.customText ?? answer.labels.join(", ");
pushWrappedText(
lines,
`→ ${answerText}`,
width,
theme,
answer.customText ? "text" : "success",
" ",
" ",
);
if (answerText) {
pushWrappedText(
lines,
`→ ${answerText}`,
width,
theme,
answer.customText ? "text" : "success",
" ",
" ",
);
}
if (answer.note) {
pushWrappedText(
lines,
`Question note: ${answer.note}`,
width,
theme,
"muted",
" ",
" ",
);
}
for (let index = 0; index < answer.values.length; index++) {
const value = answer.values[index];
const label = answer.labels[index] ?? value;
const note = answer.optionNotes?.[value];
if (!note) {
continue;
}
pushWrappedText(
lines,
`${label} note: ${note}`,
width,
theme,
"muted",
" ",
" ",
);
}
}
lines.push("");
@@ -376,6 +456,15 @@ function renderQuestionContent(args: {
args;
renderQuestionPrompt(lines, question.prompt, theme, width);
renderQuestionNote(
lines,
state,
question.id,
theme,
width,
editor,
newLineHint,
);
if (question.type === "preview") {
renderPreviewQuestion(
@@ -413,6 +502,55 @@ function renderQuestionPrompt(
lines.push("");
}
function renderQuestionNote(
lines: string[],
state: AskState,
questionId: string,
theme: ExtensionContext["ui"]["theme"],
width: number,
editor: Editor,
newLineHint: string,
) {
const existingNote = getQuestionNote(state, questionId);
if (
state.mode === "note" &&
state.noteQuestionId === questionId &&
!state.noteOptionValue
) {
pushWrappedText(lines, "Question note", width, theme, "accent", " ", " ");
for (const editorLine of editor.render(Math.max(8, width - 5))) {
lines.push(
truncateToWidth(
` ${renderInputLine(editorLine, width - 3, theme)}`,
width,
),
);
}
lines.push(
truncateToWidth(
` ${theme.fg("dim", `${newLineHint} newline · Enter save · Esc save and close`)}`,
width,
),
);
lines.push("");
return;
}
if (!existingNote) {
return;
}
pushWrappedText(
lines,
`Question note: ${existingNote}`,
width,
theme,
"muted",
" ",
" ",
);
lines.push("");
}
function renderStandardQuestion(
lines: string[],
state: AskState,
@@ -428,6 +566,9 @@ function renderStandardQuestion(
for (let i = 0; i < options.length; i++) {
const option = options[i];
const selected = i === state.optionIndex;
const optionNote = option.isOther
? undefined
: answer?.optionNotes?.[option.value];
const isAnsweredOption = option.isOther
? !!answer?.customText
: !!answer?.values.includes(option.value);
@@ -459,7 +600,27 @@ function renderStandardQuestion(
" ",
);
}
if (option.isOther && selected && state.mode === "input") {
if (
!option.isOther &&
state.mode === "note" &&
state.noteQuestionId === question.id &&
state.noteOptionValue === option.value
) {
for (const editorLine of editor.render(Math.max(8, width - 7))) {
lines.push(
truncateToWidth(
` ${renderInputLine(editorLine, width - 5, theme)}`,
width,
),
);
}
lines.push(
truncateToWidth(
` ${theme.fg("dim", `${newLineHint} newline · Enter save · Esc save and close`)}`,
width,
),
);
} else if (option.isOther && selected && state.mode === "input") {
for (const editorLine of editor.render(Math.max(8, width - 7))) {
lines.push(
truncateToWidth(
@@ -489,6 +650,20 @@ function renderStandardQuestion(
);
}
}
if (
optionNote &&
!(state.mode === "note" && state.noteOptionValue === option.value)
) {
pushWrappedText(
lines,
`Note: ${optionNote}`,
width,
theme,
"muted",
" ",
" ",
);
}
}
}
@@ -529,6 +704,33 @@ function renderPreviewQuestion(
renderPreviewPane(selectedOption, theme, width).forEach(add);
}
if (
selectedOption &&
!selectedOption.isOther &&
state.mode === "note" &&
state.noteQuestionId === question.id &&
state.noteOptionValue === selectedOption.value
) {
add("");
for (const editorLine of editor.render(Math.max(8, width - 7))) {
add(` ${renderInputLine(editorLine, width - 5, theme)}`);
}
add(
` ${theme.fg("dim", `${newLineHint} newline · Enter save · Esc save and close`)}`,
);
} else if (selectedOption && !selectedOption.isOther) {
const optionNote = answer?.optionNotes?.[selectedOption.value];
if (optionNote) {
add("");
for (const line of wrapText(
`Note: ${optionNote}`,
Math.max(1, width - 5),
)) {
add(` ${theme.fg("muted", line)}`);
}
}
}
if (selectedOption?.isOther && state.mode === "input") {
add("");
for (const editorLine of editor.render(Math.max(8, width - 7))) {
@@ -729,7 +931,16 @@ function renderFooter(
theme: ExtensionContext["ui"]["theme"],
): string {
if (state.mode === "input") {
return "";
return theme.fg(
"dim",
" Enter submit · Esc save and close · Tab/←→ save and navigate",
);
}
if (state.mode === "note") {
return theme.fg(
"dim",
" Enter save · Esc save and close · Tab/←→ save and navigate",
);
}
const question = getCurrentQuestion(state);
@@ -739,10 +950,13 @@ function renderFooter(
if (question?.type === "multi") {
return theme.fg(
"dim",
" ⇆ tab · ↑↓ select · space toggle · enter continue · esc dismiss · 1-9 quick toggle",
" ⇆ tab · ↑↓ select · Space toggle · Enter continue · N option note · Ctrl+N question note · Esc dismiss · 1-9 quick toggle",
);
}
return theme.fg("dim", " ⇆ tab · ↑↓ select · enter confirm · esc dismiss");
return theme.fg(
"dim",
" ⇆ tab · ↑↓ select · Enter confirm · N option note · Ctrl+N question note · Esc dismiss",
);
}
function parseDigit(data: string): number | null {
@@ -771,3 +985,11 @@ function formatKeybindingLabel(key: string): string {
})
.join("+");
}
function isOptionNoteShortcut(data: string): boolean {
return data === "n" || data === "N";
}
function isQuestionNoteShortcut(data: string): boolean {
return matchesKey(data, Key.ctrl("n"));
}
+15 -4
View File
@@ -9,10 +9,21 @@ export function renderResultText(result: AskResult): string {
for (const question of result.questions) {
const answer = result.answers[question.id];
if (!answer) continue;
const value = answer.customText
? `(wrote) ${answer.customText}`
: answer.labels.join(", ");
lines.push(`✓ ${question.label}: ${value}`);
if (answer.customText) {
lines.push(`✓ ${question.label}: (wrote) ${answer.customText}`);
} else if (answer.labels.length > 0) {
lines.push(`✓ ${question.label}: ${answer.labels.join(", ")}`);
}
if (answer.note) {
lines.push(` note: ${answer.note}`);
}
for (let index = 0; index < answer.values.length; index++) {
const value = answer.values[index];
const label = answer.labels[index] ?? value;
const note = answer.optionNotes?.[value];
if (!note) continue;
lines.push(` ${label} note: ${note}`);
}
}
return lines.join("\n") || "Submitted";
}
+188 -15
View File
@@ -37,6 +37,8 @@ export function createInitialState(params: AskParams): AskState {
submitIndex: 0,
mode: "navigate",
inputQuestionId: null,
noteQuestionId: null,
noteOptionValue: null,
answers: {},
completed: false,
cancelled: false,
@@ -73,6 +75,21 @@ export function getAnswer(
return state.answers[questionId];
}
export function getQuestionNote(
state: AskState,
questionId: string,
): string | undefined {
return state.answers[questionId]?.note;
}
export function getOptionNote(
state: AskState,
questionId: string,
optionValue: string,
): string | undefined {
return state.answers[questionId]?.optionNotes?.[optionValue];
}
export function isQuestionAnswered(
state: AskState,
questionId: string,
@@ -91,6 +108,8 @@ export function moveTab(state: AskState, delta: number): AskState {
submitIndex: 0,
mode: isSubmitTab({ ...state, currentTab }) ? "submit" : "navigate",
inputQuestionId: null,
noteQuestionId: null,
noteOptionValue: null,
};
}
@@ -118,6 +137,8 @@ export function enterInputMode(state: AskState, questionId: string): AskState {
...state,
mode: "input",
inputQuestionId: questionId,
noteQuestionId: null,
noteOptionValue: null,
};
}
@@ -129,6 +150,42 @@ export function exitInputMode(state: AskState): AskState {
};
}
export function enterQuestionNoteMode(
state: AskState,
questionId: string,
): AskState {
return {
...state,
mode: "note",
inputQuestionId: null,
noteQuestionId: questionId,
noteOptionValue: null,
};
}
export function enterOptionNoteMode(
state: AskState,
questionId: string,
optionValue: string,
): AskState {
return {
...state,
mode: "note",
inputQuestionId: null,
noteQuestionId: questionId,
noteOptionValue: optionValue,
};
}
export function exitNoteMode(state: AskState): AskState {
return {
...state,
mode: isSubmitTab(state) ? "submit" : "navigate",
noteQuestionId: null,
noteOptionValue: null,
};
}
export function toggleCurrentMultiOption(state: AskState): AskState {
const question = getCurrentQuestion(state);
if (!question || question.type === "single") return state;
@@ -203,28 +260,33 @@ export function saveCustomAnswer(state: AskState, rawValue: string): AskState {
);
if (!question) return exitInputMode(state);
const trimmed = rawValue.trim();
const nextState = exitInputMode(state);
const trimmed = rawValue.trim();
const existing = nextState.answers[question.id];
if (!trimmed) {
const answers = { ...nextState.answers };
delete answers[question.id];
return {
...nextState,
answers,
answers: updateAnswer(nextState.answers, question.id, (answer) => {
delete answer.customText;
answer.values = [];
answer.labels = [];
answer.indices = [];
}),
};
}
const answer: AskAnswer = {
values: [rawValue],
labels: [rawValue],
indices: [],
customText: rawValue,
};
return {
...nextState,
answers: {
...nextState.answers,
[question.id]: answer,
[question.id]: {
...emptyAnswer(),
...existing,
values: [rawValue],
labels: [rawValue],
indices: [],
customText: rawValue,
},
},
};
}
@@ -235,16 +297,52 @@ export function submitCustomAnswer(
): AskState {
const questionId = state.inputQuestionId;
const nextState = saveCustomAnswer(state, rawValue);
if (!questionId || !getAnswer(nextState, questionId)) {
if (!questionId || !isQuestionAnswered(nextState, questionId)) {
return nextState;
}
return advanceToNextTab(nextState);
}
export function saveNote(state: AskState, rawValue: string): AskState {
const questionId = state.noteQuestionId;
if (!questionId) {
return exitNoteMode(state);
}
const optionValue = state.noteOptionValue;
const trimmed = rawValue.trim();
const nextState = exitNoteMode(state);
return {
...nextState,
answers: updateAnswer(nextState.answers, questionId, (answer) => {
if (optionValue) {
const optionNotes = { ...(answer.optionNotes ?? {}) };
if (trimmed) {
optionNotes[optionValue] = rawValue;
} else {
delete optionNotes[optionValue];
}
answer.optionNotes =
Object.keys(optionNotes).length > 0 ? optionNotes : undefined;
return;
}
if (trimmed) {
answer.note = rawValue;
} else {
delete answer.note;
}
}),
};
}
export function cancelFlow(state: AskState): AskState {
if (state.mode === "input") {
return exitInputMode(state);
}
if (state.mode === "note") {
return exitNoteMode(state);
}
return {
...state,
cancelled: true,
@@ -262,7 +360,7 @@ export function toAskResult(state: AskState): AskResult {
prompt: question.prompt,
type: question.type,
})),
answers: state.answers,
answers: filterAnswersForSubmission(state.answers),
};
}
@@ -274,10 +372,24 @@ export function summarizeResult(result: AskResult): string {
const lines = result.questions.flatMap((question) => {
const answer = result.answers[question.id];
if (!answer) return [];
const questionLines: string[] = [];
if (answer.customText) {
return [`${question.label}: ${answer.customText}`];
questionLines.push(`${question.label}: ${answer.customText}`);
} else if (answer.labels.length > 0) {
questionLines.push(`${question.label}: ${answer.labels.join(", ")}`);
}
return [`${question.label}: ${answer.labels.join(", ")}`];
if (answer.note) {
questionLines.push(`${question.label} note: ${answer.note}`);
}
for (const label of answer.labels) {
const optionValue = answer.values[answer.labels.indexOf(label)];
const note = optionValue ? answer.optionNotes?.[optionValue] : undefined;
if (note) {
questionLines.push(`${question.label} / ${label} note: ${note}`);
}
}
return questionLines;
});
return lines.join("\n") || "User submitted the ask flow";
@@ -291,6 +403,7 @@ function toggleAnswer(
): AskState {
const existing = state.answers[questionId] ?? emptyAnswer();
const next = cloneAnswer(existing);
delete next.customText;
const foundIndex = next.values.indexOf(option.value);
if (foundIndex >= 0) {
@@ -318,14 +431,18 @@ function setSingleAnswer(
option: RenderOption,
index: number,
): AskState {
const existing = state.answers[questionId] ?? emptyAnswer();
return {
...state,
answers: {
...state.answers,
[questionId]: {
...emptyAnswer(),
...existing,
values: [option.value],
labels: [option.label],
indices: [index + 1],
customText: undefined,
},
},
};
@@ -341,6 +458,8 @@ function advanceToNextTab(state: AskState): AskState {
optionIndex: 0,
submitIndex: 0,
inputQuestionId: null,
noteQuestionId: null,
noteOptionValue: null,
};
}
@@ -352,6 +471,8 @@ function advanceToNextTab(state: AskState): AskState {
optionIndex: 0,
submitIndex: 0,
inputQuestionId: null,
noteQuestionId: null,
noteOptionValue: null,
};
}
@@ -366,9 +487,61 @@ function cloneAnswer(answer: AskAnswer): AskAnswer {
indices: [...answer.indices],
customText: answer.customText,
note: answer.note,
optionNotes: answer.optionNotes ? { ...answer.optionNotes } : undefined,
};
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function updateAnswer(
answers: Record<string, AskAnswer>,
questionId: string,
mutate: (answer: AskAnswer) => void,
): Record<string, AskAnswer> {
const nextAnswer = cloneAnswer(answers[questionId] ?? emptyAnswer());
mutate(nextAnswer);
if (isEmptyAnswer(nextAnswer)) {
const nextAnswers = { ...answers };
delete nextAnswers[questionId];
return nextAnswers;
}
return {
...answers,
[questionId]: nextAnswer,
};
}
function isEmptyAnswer(answer: AskAnswer): boolean {
return (
answer.values.length === 0 &&
answer.labels.length === 0 &&
answer.indices.length === 0 &&
!answer.customText &&
!answer.note &&
(!answer.optionNotes || Object.keys(answer.optionNotes).length === 0)
);
}
function filterAnswersForSubmission(
answers: Record<string, AskAnswer>,
): Record<string, AskAnswer> {
const result: Record<string, AskAnswer> = {};
for (const [questionId, answer] of Object.entries(answers)) {
const next = cloneAnswer(answer);
if (next.optionNotes) {
const selectedNotes = Object.fromEntries(
next.values
.map((value) => [value, next.optionNotes?.[value]])
.filter((entry): entry is [string, string] => !!entry[1]),
);
next.optionNotes =
Object.keys(selectedNotes).length > 0 ? selectedNotes : undefined;
}
if (!isEmptyAnswer(next)) {
result[questionId] = next;
}
}
return result;
}
+4 -1
View File
@@ -34,6 +34,7 @@ export interface AskAnswer {
indices: number[];
customText?: string;
note?: string;
optionNotes?: Record<string, string>;
}
export interface AskResult {
@@ -48,7 +49,7 @@ export interface AskResult {
answers: Record<string, AskAnswer>;
}
export type ViewMode = "navigate" | "input" | "submit";
export type ViewMode = "navigate" | "input" | "note" | "submit";
export interface AskState {
title?: string;
@@ -58,6 +59,8 @@ export interface AskState {
submitIndex: number;
mode: ViewMode;
inputQuestionId: string | null;
noteQuestionId: string | null;
noteOptionValue: string | null;
answers: Record<string, AskAnswer>;
completed: boolean;
cancelled: boolean;
+92
View File
@@ -4,6 +4,9 @@ import { renderResultText } from "../src/result.ts";
import {
applyNumberShortcut,
createInitialState,
enterOptionNoteMode,
enterQuestionNoteMode,
saveNote,
submitCustomAnswer,
summarizeResult,
toAskResult,
@@ -54,6 +57,95 @@ test("summaries include custom text answers", () => {
);
});
test("summaries and result text include question and selected option notes", () => {
let state = createInitialState({
questions: [
{
id: "framework",
label: "Framework",
prompt: "Pick a framework",
options: [
{ value: "react", label: "React" },
{ value: "vue", label: "Vue" },
],
},
],
});
state = enterQuestionNoteMode(state, "framework");
state = saveNote(state, "Need SSR support");
state = enterOptionNoteMode(state, "framework", "vue");
state = saveNote(state, "Team has prior experience");
state = applyNumberShortcut(state, 2);
const result = toAskResult(state);
assert.equal(
summarizeResult(result),
[
"Framework: Vue",
"Framework note: Need SSR support",
"Framework / Vue note: Team has prior experience",
].join("\n"),
);
assert.equal(
renderResultText(result),
[
"✓ Framework: Vue",
" note: Need SSR support",
" Vue note: Team has prior experience",
].join("\n"),
);
});
test("unselected option notes are omitted from the submitted result", () => {
let state = createInitialState({
questions: [
{
id: "framework",
label: "Framework",
prompt: "Pick a framework",
options: [
{ value: "react", label: "React" },
{ value: "vue", label: "Vue" },
],
},
],
});
state = enterOptionNoteMode(state, "framework", "react");
state = saveNote(state, "Only for legacy projects");
state = applyNumberShortcut(state, 2);
const result = toAskResult(state);
assert.equal(result.answers.framework.optionNotes, undefined);
assert.equal(renderResultText(result), "✓ Framework: Vue");
});
test("question-note-only results still render note content", () => {
const result = {
title: "Interview",
cancelled: false,
questions: [
{
id: "scope",
label: "Scope",
prompt: "What should we focus on?",
type: "single" as const,
},
],
answers: {
scope: {
values: [],
labels: [],
indices: [],
note: "Prefer incremental changes",
},
},
};
assert.equal(renderResultText(result), " note: Prefer incremental changes");
});
test("cancelled result renders as cancelled", () => {
const result = {
title: "Interview",
+137 -3
View File
@@ -5,11 +5,15 @@ import {
cancelFlow,
confirmCurrentSelection,
createInitialState,
enterOptionNoteMode,
enterQuestionNoteMode,
saveCustomAnswer,
saveNote,
getRenderableOptions,
moveOption,
moveTab,
submitCustomAnswer,
toAskResult,
toggleCurrentMultiOption,
} from "../src/state.ts";
import type { AskParams } from "../src/types.ts";
@@ -158,7 +162,7 @@ test("escape saves typed custom answer without advancing", () => {
assert.equal(state.answers.q1.customText, "my draft answer");
});
test("empty custom draft clears the stored answer", () => {
test("empty custom draft clears the stored answer but preserves notes", () => {
let state = createInitialState({
questions: [
{
@@ -169,15 +173,139 @@ test("empty custom draft clears the stored answer", () => {
],
});
state = enterQuestionNoteMode(state, "q1");
state = saveNote(state, "question note");
state = applyNumberShortcut(state, 2);
state = saveCustomAnswer(state, "my answer");
state = applyNumberShortcut(state, 2);
state = saveCustomAnswer(state, " ");
assert.equal(state.answers.q1, undefined);
assert.equal(state.answers.q1.customText, undefined);
assert.equal(state.answers.q1.note, "question note");
assert.equal(state.mode, "navigate");
});
test("question note can be saved without selecting an answer and is submitted", () => {
let state = createInitialState({
questions: [
{ id: "q1", prompt: "Question?", options: [{ value: "a", label: "A" }] },
],
});
state = enterQuestionNoteMode(state, "q1");
assert.equal(state.mode, "note");
state = saveNote(state, "needs examples");
assert.equal(state.mode, "navigate");
assert.equal(state.answers.q1.note, "needs examples");
assert.deepEqual(toAskResult(state).answers.q1, {
values: [],
labels: [],
indices: [],
customText: undefined,
note: "needs examples",
optionNotes: undefined,
});
});
test("option notes can exist before selection but only selected option notes are submitted", () => {
let state = createInitialState({
questions: [
{
id: "q1",
prompt: "Pick frameworks",
type: "multi",
options: [
{ value: "react", label: "React" },
{ value: "vue", label: "Vue" },
],
},
],
});
state = enterOptionNoteMode(state, "q1", "vue");
state = saveNote(state, "maybe later");
assert.equal(state.answers.q1.optionNotes?.vue, "maybe later");
assert.equal(toAskResult(state).answers.q1, undefined);
state = toggleCurrentMultiOption(state);
assert.deepEqual(state.answers.q1.values, ["react"]);
state = moveOption(state, 1);
state = toggleCurrentMultiOption(state);
assert.deepEqual(state.answers.q1.values, ["react", "vue"]);
assert.deepEqual(toAskResult(state).answers.q1.optionNotes, {
vue: "maybe later",
});
});
test("deselecting an option keeps its note in state but omits it from submission", () => {
let state = createInitialState({
questions: [
{
id: "q1",
prompt: "Pick frameworks",
type: "multi",
options: [
{ value: "react", label: "React" },
{ value: "vue", label: "Vue" },
],
},
],
});
state = moveOption(state, 1);
state = toggleCurrentMultiOption(state);
state = enterOptionNoteMode(state, "q1", "vue");
state = saveNote(state, "migration risk");
state = toggleCurrentMultiOption(state);
assert.equal(state.answers.q1.optionNotes?.vue, "migration risk");
assert.equal(toAskResult(state).answers.q1, undefined);
state = toggleCurrentMultiOption(state);
assert.deepEqual(toAskResult(state).answers.q1, {
values: ["vue"],
labels: ["Vue"],
indices: [2],
customText: undefined,
note: undefined,
optionNotes: {
vue: "migration risk",
},
});
});
test("preview questions can store selected option notes for submission", () => {
let state = createInitialState({
questions: [
{
id: "preview",
prompt: "Pick layout",
type: "preview",
options: [
{ value: "compact", label: "Compact", preview: "A" },
{ value: "spacious", label: "Spacious", preview: "B" },
],
},
],
});
state = enterOptionNoteMode(state, "preview", "spacious");
state = saveNote(state, "better scanability");
state = applyNumberShortcut(state, 2);
assert.deepEqual(toAskResult(state).answers.preview, {
values: ["spacious"],
labels: ["Spacious"],
indices: [2],
customText: undefined,
note: undefined,
optionNotes: {
spacious: "better scanability",
},
});
});
test("tab navigation wraps including submit tab", () => {
let state = createInitialState(sampleParams());
state = moveTab(state, -1);
@@ -205,7 +333,7 @@ test("tab navigation works with a single question", () => {
assert.equal(state.mode, "navigate");
});
test("escape cancels from main flow but only exits input mode when typing", () => {
test("escape cancels from main flow but only exits input and note modes when editing", () => {
let state = createInitialState(sampleParams());
state = cancelFlow(state);
assert.equal(state.cancelled, true);
@@ -221,4 +349,10 @@ test("escape cancels from main flow but only exits input mode when typing", () =
state = cancelFlow(state);
assert.equal(state.mode, "navigate");
assert.equal(state.cancelled, false);
state = enterQuestionNoteMode(state, "q");
assert.equal(state.mode, "note");
state = cancelFlow(state);
assert.equal(state.mode, "navigate");
assert.equal(state.cancelled, false);
});