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 - single-select questions
- multi-select questions - multi-select questions
- preview questions with a dedicated preview pane - 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 - number-key quick selection
- final submit/review page - final submit/review page
- transcript rendering for call/result rows - 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 ## Example agent instruction
You can give the agent an instruction like this to encourage proper `ask_user` usage: 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, confirmCurrentSelection,
createInitialState, createInitialState,
enterInputMode, enterInputMode,
enterOptionNoteMode,
enterQuestionNoteMode,
getAnswer, getAnswer,
getCurrentQuestion, getCurrentQuestion,
getOptionNote,
getQuestionNote,
getRenderableOptions, getRenderableOptions,
isQuestionAnswered, isQuestionAnswered,
isSubmitTab, isSubmitTab,
moveOption, moveOption,
moveTab, moveTab,
saveCustomAnswer, saveCustomAnswer,
saveNote,
submitCustomAnswer, submitCustomAnswer,
toAskResult, toAskResult,
toggleCurrentMultiOption, toggleCurrentMultiOption,
@@ -54,7 +59,11 @@ export async function runAskFlow(
editor.onSubmit = (value) => { editor.onSubmit = (value) => {
suppressAutoInputForSelection = false; 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(""); editor.setText("");
refresh(); refresh();
maybeFinish(); maybeFinish();
@@ -71,17 +80,25 @@ export async function runAskFlow(
} }
} }
function hydrateEditorForInputMode() { function hydrateEditorForCurrentMode() {
if (state.mode !== "input" || !state.inputQuestionId) { if (state.mode === "input" && state.inputQuestionId) {
const existingText =
getAnswer(state, state.inputQuestionId)?.customText ?? "";
editor.setText(existingText);
return; return;
} }
const existingText = if (state.mode !== "note" || !state.noteQuestionId) {
getAnswer(state, state.inputQuestionId)?.customText ?? ""; return;
}
const existingText = state.noteOptionValue
? (getOptionNote(state, state.noteQuestionId, state.noteOptionValue) ??
"")
: (getQuestionNote(state, state.noteQuestionId) ?? "");
editor.setText(existingText); editor.setText(existingText);
} }
function syncInputModeWithSelection() { function syncInputModeWithSelection() {
if (isSubmitTab(state)) { if (state.mode === "note" || isSubmitTab(state)) {
return; return;
} }
@@ -100,32 +117,28 @@ export async function runAskFlow(
} }
state = enterInputMode(state, question.id); state = enterInputMode(state, question.id);
hydrateEditorForInputMode(); hydrateEditorForCurrentMode();
} }
syncInputModeWithSelection(); syncInputModeWithSelection();
function handleInput(data: string) { function handleInput(data: string) {
if (state.mode === "input") { if (state.mode === "input" || state.mode === "note") {
if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) { if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
state = saveCustomAnswer(state, editor.getText()); state = saveEditorAndMoveTab(1);
suppressAutoInputForSelection = false;
state = moveTab(state, 1);
syncInputModeWithSelection(); syncInputModeWithSelection();
refresh(); refresh();
return; return;
} }
if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) { if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
state = saveCustomAnswer(state, editor.getText()); state = saveEditorAndMoveTab(-1);
suppressAutoInputForSelection = false;
state = moveTab(state, -1);
syncInputModeWithSelection(); syncInputModeWithSelection();
refresh(); refresh();
return; return;
} }
if (matchesKey(data, Key.escape)) { if (matchesKey(data, Key.escape)) {
state = saveCustomAnswer(state, editor.getText()); state = saveEditorWithoutAdvancing();
suppressAutoInputForSelection = true; suppressAutoInputForSelection = state.mode !== "input";
refresh(); refresh();
return; return;
} }
@@ -168,17 +181,38 @@ export async function runAskFlow(
suppressAutoInputForSelection = false; suppressAutoInputForSelection = false;
state = toggleCurrentMultiOption(state); state = toggleCurrentMultiOption(state);
if (state.mode === "input") { if (state.mode === "input") {
hydrateEditorForInputMode(); hydrateEditorForCurrentMode();
} }
refresh(); refresh();
return; 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)) { if (matchesKey(data, Key.enter)) {
suppressAutoInputForSelection = false; suppressAutoInputForSelection = false;
state = confirmCurrentSelection(state); state = confirmCurrentSelection(state);
if (state.mode === "input") { if (state.mode === "input") {
hydrateEditorForInputMode(); hydrateEditorForCurrentMode();
} }
refresh(); refresh();
maybeFinish(); maybeFinish();
@@ -196,13 +230,28 @@ export async function runAskFlow(
suppressAutoInputForSelection = false; suppressAutoInputForSelection = false;
state = applyNumberShortcut(state, digit); state = applyNumberShortcut(state, digit);
if (state.mode === "input") { if (state.mode === "input") {
hydrateEditorForInputMode(); hydrateEditorForCurrentMode();
} }
refresh(); 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[] { function render(width: number): string[] {
if (cachedLines) return cachedLines; if (cachedLines) return cachedLines;
@@ -305,8 +354,9 @@ function renderSubmit(
); );
lines.push(""); lines.push("");
const submittedAnswers = toAskResult(state).answers;
for (const question of state.questions) { for (const question of state.questions) {
const answer = getAnswer(state, question.id); const answer = submittedAnswers[question.id];
pushWrappedText( pushWrappedText(
lines, lines,
question.prompt, question.prompt,
@@ -323,15 +373,45 @@ function renderSubmit(
continue; continue;
} }
const answerText = answer.customText ?? answer.labels.join(", "); const answerText = answer.customText ?? answer.labels.join(", ");
pushWrappedText( if (answerText) {
lines, pushWrappedText(
`→ ${answerText}`, lines,
width, `→ ${answerText}`,
theme, width,
answer.customText ? "text" : "success", 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(""); lines.push("");
@@ -376,6 +456,15 @@ function renderQuestionContent(args: {
args; args;
renderQuestionPrompt(lines, question.prompt, theme, width); renderQuestionPrompt(lines, question.prompt, theme, width);
renderQuestionNote(
lines,
state,
question.id,
theme,
width,
editor,
newLineHint,
);
if (question.type === "preview") { if (question.type === "preview") {
renderPreviewQuestion( renderPreviewQuestion(
@@ -413,6 +502,55 @@ function renderQuestionPrompt(
lines.push(""); 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( function renderStandardQuestion(
lines: string[], lines: string[],
state: AskState, state: AskState,
@@ -428,6 +566,9 @@ function renderStandardQuestion(
for (let i = 0; i < options.length; i++) { for (let i = 0; i < options.length; i++) {
const option = options[i]; const option = options[i];
const selected = i === state.optionIndex; const selected = i === state.optionIndex;
const optionNote = option.isOther
? undefined
: answer?.optionNotes?.[option.value];
const isAnsweredOption = option.isOther const isAnsweredOption = option.isOther
? !!answer?.customText ? !!answer?.customText
: !!answer?.values.includes(option.value); : !!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))) { for (const editorLine of editor.render(Math.max(8, width - 7))) {
lines.push( lines.push(
truncateToWidth( 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); 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") { if (selectedOption?.isOther && state.mode === "input") {
add(""); add("");
for (const editorLine of editor.render(Math.max(8, width - 7))) { for (const editorLine of editor.render(Math.max(8, width - 7))) {
@@ -729,7 +931,16 @@ function renderFooter(
theme: ExtensionContext["ui"]["theme"], theme: ExtensionContext["ui"]["theme"],
): string { ): string {
if (state.mode === "input") { 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); const question = getCurrentQuestion(state);
@@ -739,10 +950,13 @@ function renderFooter(
if (question?.type === "multi") { if (question?.type === "multi") {
return theme.fg( return theme.fg(
"dim", "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 { function parseDigit(data: string): number | null {
@@ -771,3 +985,11 @@ function formatKeybindingLabel(key: string): string {
}) })
.join("+"); .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) { for (const question of result.questions) {
const answer = result.answers[question.id]; const answer = result.answers[question.id];
if (!answer) continue; if (!answer) continue;
const value = answer.customText if (answer.customText) {
? `(wrote) ${answer.customText}` lines.push(`✓ ${question.label}: (wrote) ${answer.customText}`);
: answer.labels.join(", "); } else if (answer.labels.length > 0) {
lines.push(`✓ ${question.label}: ${value}`); 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"; return lines.join("\n") || "Submitted";
} }
+188 -15
View File
@@ -37,6 +37,8 @@ export function createInitialState(params: AskParams): AskState {
submitIndex: 0, submitIndex: 0,
mode: "navigate", mode: "navigate",
inputQuestionId: null, inputQuestionId: null,
noteQuestionId: null,
noteOptionValue: null,
answers: {}, answers: {},
completed: false, completed: false,
cancelled: false, cancelled: false,
@@ -73,6 +75,21 @@ export function getAnswer(
return state.answers[questionId]; 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( export function isQuestionAnswered(
state: AskState, state: AskState,
questionId: string, questionId: string,
@@ -91,6 +108,8 @@ export function moveTab(state: AskState, delta: number): AskState {
submitIndex: 0, submitIndex: 0,
mode: isSubmitTab({ ...state, currentTab }) ? "submit" : "navigate", mode: isSubmitTab({ ...state, currentTab }) ? "submit" : "navigate",
inputQuestionId: null, inputQuestionId: null,
noteQuestionId: null,
noteOptionValue: null,
}; };
} }
@@ -118,6 +137,8 @@ export function enterInputMode(state: AskState, questionId: string): AskState {
...state, ...state,
mode: "input", mode: "input",
inputQuestionId: questionId, 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 { export function toggleCurrentMultiOption(state: AskState): AskState {
const question = getCurrentQuestion(state); const question = getCurrentQuestion(state);
if (!question || question.type === "single") return 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); if (!question) return exitInputMode(state);
const trimmed = rawValue.trim();
const nextState = exitInputMode(state); const nextState = exitInputMode(state);
const trimmed = rawValue.trim();
const existing = nextState.answers[question.id];
if (!trimmed) { if (!trimmed) {
const answers = { ...nextState.answers };
delete answers[question.id];
return { return {
...nextState, ...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 { return {
...nextState, ...nextState,
answers: { answers: {
...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 { ): AskState {
const questionId = state.inputQuestionId; const questionId = state.inputQuestionId;
const nextState = saveCustomAnswer(state, rawValue); const nextState = saveCustomAnswer(state, rawValue);
if (!questionId || !getAnswer(nextState, questionId)) { if (!questionId || !isQuestionAnswered(nextState, questionId)) {
return nextState; return nextState;
} }
return advanceToNextTab(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 { export function cancelFlow(state: AskState): AskState {
if (state.mode === "input") { if (state.mode === "input") {
return exitInputMode(state); return exitInputMode(state);
} }
if (state.mode === "note") {
return exitNoteMode(state);
}
return { return {
...state, ...state,
cancelled: true, cancelled: true,
@@ -262,7 +360,7 @@ export function toAskResult(state: AskState): AskResult {
prompt: question.prompt, prompt: question.prompt,
type: question.type, 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 lines = result.questions.flatMap((question) => {
const answer = result.answers[question.id]; const answer = result.answers[question.id];
if (!answer) return []; if (!answer) return [];
const questionLines: string[] = [];
if (answer.customText) { 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"; return lines.join("\n") || "User submitted the ask flow";
@@ -291,6 +403,7 @@ function toggleAnswer(
): AskState { ): AskState {
const existing = state.answers[questionId] ?? emptyAnswer(); const existing = state.answers[questionId] ?? emptyAnswer();
const next = cloneAnswer(existing); const next = cloneAnswer(existing);
delete next.customText;
const foundIndex = next.values.indexOf(option.value); const foundIndex = next.values.indexOf(option.value);
if (foundIndex >= 0) { if (foundIndex >= 0) {
@@ -318,14 +431,18 @@ function setSingleAnswer(
option: RenderOption, option: RenderOption,
index: number, index: number,
): AskState { ): AskState {
const existing = state.answers[questionId] ?? emptyAnswer();
return { return {
...state, ...state,
answers: { answers: {
...state.answers, ...state.answers,
[questionId]: { [questionId]: {
...emptyAnswer(),
...existing,
values: [option.value], values: [option.value],
labels: [option.label], labels: [option.label],
indices: [index + 1], indices: [index + 1],
customText: undefined,
}, },
}, },
}; };
@@ -341,6 +458,8 @@ function advanceToNextTab(state: AskState): AskState {
optionIndex: 0, optionIndex: 0,
submitIndex: 0, submitIndex: 0,
inputQuestionId: null, inputQuestionId: null,
noteQuestionId: null,
noteOptionValue: null,
}; };
} }
@@ -352,6 +471,8 @@ function advanceToNextTab(state: AskState): AskState {
optionIndex: 0, optionIndex: 0,
submitIndex: 0, submitIndex: 0,
inputQuestionId: null, inputQuestionId: null,
noteQuestionId: null,
noteOptionValue: null,
}; };
} }
@@ -366,9 +487,61 @@ function cloneAnswer(answer: AskAnswer): AskAnswer {
indices: [...answer.indices], indices: [...answer.indices],
customText: answer.customText, customText: answer.customText,
note: answer.note, note: answer.note,
optionNotes: answer.optionNotes ? { ...answer.optionNotes } : undefined,
}; };
} }
function clamp(value: number, min: number, max: number): number { function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value)); 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[]; indices: number[];
customText?: string; customText?: string;
note?: string; note?: string;
optionNotes?: Record<string, string>;
} }
export interface AskResult { export interface AskResult {
@@ -48,7 +49,7 @@ export interface AskResult {
answers: Record<string, AskAnswer>; answers: Record<string, AskAnswer>;
} }
export type ViewMode = "navigate" | "input" | "submit"; export type ViewMode = "navigate" | "input" | "note" | "submit";
export interface AskState { export interface AskState {
title?: string; title?: string;
@@ -58,6 +59,8 @@ export interface AskState {
submitIndex: number; submitIndex: number;
mode: ViewMode; mode: ViewMode;
inputQuestionId: string | null; inputQuestionId: string | null;
noteQuestionId: string | null;
noteOptionValue: string | null;
answers: Record<string, AskAnswer>; answers: Record<string, AskAnswer>;
completed: boolean; completed: boolean;
cancelled: boolean; cancelled: boolean;
+92
View File
@@ -4,6 +4,9 @@ import { renderResultText } from "../src/result.ts";
import { import {
applyNumberShortcut, applyNumberShortcut,
createInitialState, createInitialState,
enterOptionNoteMode,
enterQuestionNoteMode,
saveNote,
submitCustomAnswer, submitCustomAnswer,
summarizeResult, summarizeResult,
toAskResult, 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", () => { test("cancelled result renders as cancelled", () => {
const result = { const result = {
title: "Interview", title: "Interview",
+137 -3
View File
@@ -5,11 +5,15 @@ import {
cancelFlow, cancelFlow,
confirmCurrentSelection, confirmCurrentSelection,
createInitialState, createInitialState,
enterOptionNoteMode,
enterQuestionNoteMode,
saveCustomAnswer, saveCustomAnswer,
saveNote,
getRenderableOptions, getRenderableOptions,
moveOption, moveOption,
moveTab, moveTab,
submitCustomAnswer, submitCustomAnswer,
toAskResult,
toggleCurrentMultiOption, toggleCurrentMultiOption,
} from "../src/state.ts"; } from "../src/state.ts";
import type { AskParams } from "../src/types.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"); 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({ let state = createInitialState({
questions: [ 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 = applyNumberShortcut(state, 2);
state = saveCustomAnswer(state, "my answer"); state = saveCustomAnswer(state, "my answer");
state = applyNumberShortcut(state, 2); state = applyNumberShortcut(state, 2);
state = saveCustomAnswer(state, " "); 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"); 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", () => { test("tab navigation wraps including submit tab", () => {
let state = createInitialState(sampleParams()); let state = createInitialState(sampleParams());
state = moveTab(state, -1); state = moveTab(state, -1);
@@ -205,7 +333,7 @@ test("tab navigation works with a single question", () => {
assert.equal(state.mode, "navigate"); 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()); let state = createInitialState(sampleParams());
state = cancelFlow(state); state = cancelFlow(state);
assert.equal(state.cancelled, true); 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); state = cancelFlow(state);
assert.equal(state.mode, "navigate"); assert.equal(state.mode, "navigate");
assert.equal(state.cancelled, false); 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);
}); });