From 6663a5e7db026bd802e9a4e08ac564f20d28ad0a Mon Sep 17 00:00:00 2001 From: Erik Kotsiuba Date: Fri, 17 Apr 2026 13:04:02 +0200 Subject: [PATCH] feat(ask): refine submission and custom answer flow --- README.md | 4 +-- src/ask-component.ts | 75 +++++++++++++++++++++----------------------- src/schema.ts | 2 +- src/state.ts | 53 ++++++++++++++++--------------- tests/state.test.ts | 70 +++++++++++++++++++++++++++++++++++++++-- 5 files changed, 134 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index 86101e7..c9e40f7 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ Use it when the agent needs structured clarification before proceeding. prompt: string, type?: "single" | "multi" | "code-review", allowOther?: boolean, - required?: boolean, + required?: boolean, // metadata only; submit is never blocked options: [ { value: string, @@ -150,7 +150,7 @@ The current prototype supports: - tabbed multi-question flow - single-select questions - multi-select questions -- fixed `Type something.` fallback option +- fixed `Type your own` fallback option that preserves typed text when dismissed - number-key quick selection - final submit/review page - transcript rendering for call/result rows diff --git a/src/ask-component.ts b/src/ask-component.ts index 4920423..b6fac70 100644 --- a/src/ask-component.ts +++ b/src/ask-component.ts @@ -8,13 +8,11 @@ import { visibleWidth, } from "@mariozechner/pi-tui"; import { - allRequiredAnswered, applyNumberShortcut, cancelFlow, confirmCurrentSelection, createInitialState, enterInputMode, - exitInputMode, getAnswer, getCurrentQuestion, getRenderableOptions, @@ -22,6 +20,7 @@ import { isSubmitTab, moveOption, moveTab, + saveCustomAnswer, submitCustomAnswer, toAskResult, toggleCurrentMultiOption, @@ -35,7 +34,6 @@ export async function runAskFlow( return ctx.ui.custom((tui, theme, keybindings, done) => { let state: AskState = createInitialState(params); let cachedLines: string[] | undefined; - let inputEscapePending = false; let suppressAutoInputForSelection = false; const newLineHint = formatKeybindingLabel( keybindings.getKeys("tui.input.newLine")[0] ?? "shift+enter", @@ -54,7 +52,6 @@ export async function runAskFlow( const editor = new Editor(tui, editorTheme); editor.onSubmit = (value) => { - inputEscapePending = false; suppressAutoInputForSelection = false; state = submitCustomAnswer(state, value); editor.setText(""); @@ -102,7 +99,6 @@ export async function runAskFlow( } state = enterInputMode(state, question.id); - inputEscapePending = false; hydrateEditorForInputMode(); } @@ -111,36 +107,27 @@ export async function runAskFlow( function handleInput(data: string) { if (state.mode === "input") { if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) { - state = exitInputMode(state); + state = saveCustomAnswer(state, editor.getText()); suppressAutoInputForSelection = false; - inputEscapePending = false; state = moveTab(state, 1); syncInputModeWithSelection(); refresh(); return; } if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) { - state = exitInputMode(state); + state = saveCustomAnswer(state, editor.getText()); suppressAutoInputForSelection = false; - inputEscapePending = false; state = moveTab(state, -1); syncInputModeWithSelection(); refresh(); return; } if (matchesKey(data, Key.escape)) { - if (editor.getText().trim().length === 0 || inputEscapePending) { - state = exitInputMode(state); - suppressAutoInputForSelection = true; - inputEscapePending = false; - refresh(); - return; - } - inputEscapePending = true; + state = saveCustomAnswer(state, editor.getText()); + suppressAutoInputForSelection = true; refresh(); return; } - inputEscapePending = false; editor.handleInput(data); refresh(); return; @@ -148,7 +135,6 @@ export async function runAskFlow( if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) { suppressAutoInputForSelection = false; - inputEscapePending = false; state = moveTab(state, 1); syncInputModeWithSelection(); refresh(); @@ -156,7 +142,6 @@ export async function runAskFlow( } if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) { suppressAutoInputForSelection = false; - inputEscapePending = false; state = moveTab(state, -1); syncInputModeWithSelection(); refresh(); @@ -164,7 +149,6 @@ export async function runAskFlow( } if (matchesKey(data, Key.up)) { suppressAutoInputForSelection = false; - inputEscapePending = false; state = moveOption(state, -1); syncInputModeWithSelection(); refresh(); @@ -172,7 +156,6 @@ export async function runAskFlow( } if (matchesKey(data, Key.down)) { suppressAutoInputForSelection = false; - inputEscapePending = false; state = moveOption(state, 1); syncInputModeWithSelection(); refresh(); @@ -182,7 +165,6 @@ export async function runAskFlow( const question = getCurrentQuestion(state); if (question?.type === "multi") { suppressAutoInputForSelection = false; - inputEscapePending = false; state = toggleCurrentMultiOption(state); if (state.mode === "input") { hydrateEditorForInputMode(); @@ -193,7 +175,6 @@ export async function runAskFlow( } if (matchesKey(data, Key.enter)) { suppressAutoInputForSelection = false; - inputEscapePending = false; state = confirmCurrentSelection(state); if (state.mode === "input") { hydrateEditorForInputMode(); @@ -212,7 +193,6 @@ export async function runAskFlow( const digit = parseDigit(data); if (digit !== null) { suppressAutoInputForSelection = false; - inputEscapePending = false; state = applyNumberShortcut(state, digit); if (state.mode === "input") { hydrateEditorForInputMode(); @@ -247,16 +227,23 @@ export async function runAskFlow( add(); const isMulti = question.type === "multi"; + const answer = getAnswer(state, question.id); for (let i = 0; i < options.length; i++) { const option = options[i]; const selected = i === state.optionIndex; - const checked = !!getAnswer(state, question.id)?.values.includes( - option.value, - ); + const isAnsweredOption = option.isOther + ? !!answer?.customText + : !!answer?.values.includes(option.value); const pointer = selected ? theme.fg("accent", "❯ ") : " "; const prefix = - isMulti && !option.isOther ? `[${checked ? "x" : " "}] ` : ""; - const optionColor = selected ? "accent" : "text"; + isMulti && !option.isOther + ? `[${isAnsweredOption ? "x" : " "}] ` + : ""; + const optionColor = isAnsweredOption + ? "success" + : selected + ? "accent" + : "text"; add( `${pointer}${theme.fg(optionColor, `${i + 1}. ${prefix}${option.label}`)}`, ); @@ -275,9 +262,17 @@ export async function runAskFlow( add(` ${renderInputLine(editorLine, width - 5, theme)}`); } add( - ` ${theme.fg("dim", inputEscapePending ? `${newLineHint} newline · Enter submit · Esc again to go back` : `${newLineHint} newline · Enter submit · Esc to go back if empty`)}`, + ` ${theme.fg("dim", `${newLineHint} newline · Enter submit · Esc save and close`)}`, ); } + if (option.isOther && state.mode !== "input") { + const customText = answer?.customText; + if (customText) { + for (const customLine of customText.split("\n")) { + add(` ${theme.fg("muted", customLine)}`); + } + } + } } } @@ -326,7 +321,7 @@ function renderTabs( segments.push( submitActive ? theme.bg("selectedBg", theme.fg("text", submitText)) - : theme.fg(allRequiredAnswered(state) ? "success" : "dim", submitText), + : theme.fg("success", submitText), ); segments.push(theme.fg("dim", " →")); @@ -346,15 +341,18 @@ function renderSubmit( for (const question of state.questions) { const answer = getAnswer(state, question.id); add(` ${theme.fg("muted", `● ${question.prompt}`)}`); + if (!answer) { + add(` ${theme.fg("warning", "→ (unanswered)")}`); + continue; + } + const answerText = answer.customText ?? answer.labels.join(", "); add( - ` ${theme.fg("text", `→ ${answer ? answer.labels.join(", ") : "(unanswered)"}`)}`, + ` ${theme.fg(answer.customText ? "text" : "success", `→ ${answerText}`)}`, ); } add(); - add( - ` ${allRequiredAnswered(state) ? theme.fg("success", "Ready to submit your answers?") : theme.fg("warning", "Answer all required questions before submitting.")}`, - ); + add(` ${theme.fg("success", "Ready to submit your answers?")}`); add(); const options = ["Submit answers", "Cancel"]; @@ -385,10 +383,7 @@ function renderFooter( " ⇆ tab · ↑↓ select · space toggle · enter continue · esc dismiss · 1-9 quick toggle", ); } - return theme.fg( - "dim", - " ⇆ tab · ↑↓ select · enter confirm · esc dismiss · 1-9 quick select", - ); + return theme.fg("dim", " ⇆ tab · ↑↓ select · enter confirm · esc dismiss"); } function parseDigit(data: string): number | null { diff --git a/src/schema.ts b/src/schema.ts index 77db26d..ec20646 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -36,7 +36,7 @@ export const AskQuestionSchema = Type.Object({ ), required: Type.Optional( Type.Boolean({ - description: "Whether an answer is required before submit", + description: "Optional metadata flag; submit is never blocked", }), ), options: Type.Array(AskOptionSchema, { diff --git a/src/state.ts b/src/state.ts index ca993f8..6dd1246 100644 --- a/src/state.ts +++ b/src/state.ts @@ -82,16 +82,7 @@ export function isQuestionAnswered( return !!answer && answer.labels.length > 0; } -export function allRequiredAnswered(state: AskState): boolean { - return state.questions.every( - (question) => !question.required || isQuestionAnswered(state, question.id), - ); -} - export function moveTab(state: AskState, delta: number): AskState { - if (state.questions.length <= 1 && delta !== 0) { - return state; - } const totalTabs = state.questions.length + 1; const currentTab = (state.currentTab + delta + totalTabs) % totalTabs; return { @@ -157,10 +148,7 @@ export function confirmCurrentSelection(state: AskState): AskState { if (state.submitIndex === 1) { return { ...state, cancelled: true, completed: true }; } - if (allRequiredAnswered(state)) { - return { ...state, completed: true }; - } - return state; + return { ...state, completed: true }; } const question = getCurrentQuestion(state); @@ -210,33 +198,48 @@ export function applyNumberShortcut(state: AskState, digit: number): AskState { return advanceToNextTab(nextState); } -export function submitCustomAnswer( - state: AskState, - rawValue: string, -): AskState { +export function saveCustomAnswer(state: AskState, rawValue: string): AskState { const question = state.questions.find( (item) => item.id === state.inputQuestionId, ); if (!question) return exitInputMode(state); const trimmed = rawValue.trim(); + const nextState = exitInputMode(state); if (!trimmed) { - return exitInputMode(state); + const answers = { ...nextState.answers }; + delete answers[question.id]; + return { + ...nextState, + answers, + }; } const answer: AskAnswer = { - values: [trimmed], - labels: [trimmed], + values: [rawValue], + labels: [rawValue], indices: [], - customText: trimmed, + customText: rawValue, }; - return advanceToNextTab({ - ...exitInputMode(state), + return { + ...nextState, answers: { - ...state.answers, + ...nextState.answers, [question.id]: answer, }, - }); + }; +} + +export function submitCustomAnswer( + state: AskState, + rawValue: string, +): AskState { + const questionId = state.inputQuestionId; + const nextState = saveCustomAnswer(state, rawValue); + if (!questionId || !getAnswer(nextState, questionId)) { + return nextState; + } + return advanceToNextTab(nextState); } export function cancelFlow(state: AskState): AskState { diff --git a/tests/state.test.ts b/tests/state.test.ts index a4e5913..507a424 100644 --- a/tests/state.test.ts +++ b/tests/state.test.ts @@ -1,11 +1,11 @@ import assert from "node:assert/strict"; import test from "node:test"; import { - allRequiredAnswered, applyNumberShortcut, cancelFlow, confirmCurrentSelection, createInitialState, + saveCustomAnswer, getRenderableOptions, moveOption, moveTab, @@ -84,7 +84,17 @@ test("multi-select space toggles current option and enter advances to submit", ( state = confirmCurrentSelection(state); assert.equal(state.mode, "submit"); assert.equal(state.currentTab, 2); - assert.equal(allRequiredAnswered(state), true); + assert.equal(state.completed, false); +}); + +test("submit can complete even when some questions are unanswered", () => { + let state = createInitialState(sampleParams()); + state = moveTab(state, -1); + assert.equal(state.mode, "submit"); + state = confirmCurrentSelection(state); + + assert.equal(state.completed, true); + assert.equal(state.cancelled, false); }); test("selecting other enters input mode and custom submit stores typed answer", () => { @@ -106,6 +116,46 @@ test("selecting other enters input mode and custom submit stores typed answer", assert.equal(state.mode, "submit"); }); +test("escape saves typed custom answer without advancing", () => { + let state = createInitialState({ + questions: [ + { + id: "q1", + prompt: "Other?", + options: [{ value: "a", label: "A" }], + }, + ], + }); + + state = applyNumberShortcut(state, 2); + assert.equal(state.mode, "input"); + state = saveCustomAnswer(state, "my draft answer"); + + assert.equal(state.mode, "navigate"); + assert.equal(state.currentTab, 0); + assert.equal(state.answers.q1.customText, "my draft answer"); +}); + +test("empty custom draft clears the stored answer", () => { + let state = createInitialState({ + questions: [ + { + id: "q1", + prompt: "Other?", + options: [{ value: "a", label: "A" }], + }, + ], + }); + + 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.mode, "navigate"); +}); + test("tab navigation wraps including submit tab", () => { let state = createInitialState(sampleParams()); state = moveTab(state, -1); @@ -117,6 +167,22 @@ test("tab navigation wraps including submit tab", () => { assert.equal(state.mode, "navigate"); }); +test("tab navigation works with a single question", () => { + let state = createInitialState({ + questions: [ + { id: "q", prompt: "Q?", options: [{ value: "a", label: "A" }] }, + ], + }); + + state = moveTab(state, -1); + assert.equal(state.currentTab, 1); + assert.equal(state.mode, "submit"); + + state = moveTab(state, 1); + assert.equal(state.currentTab, 0); + assert.equal(state.mode, "navigate"); +}); + test("escape cancels from main flow but only exits input mode when typing", () => { let state = createInitialState(sampleParams()); state = cancelFlow(state);