feat(ask): refine submission and custom answer flow

This commit is contained in:
Erik Kotsiuba
2026-04-17 13:04:02 +02:00
parent 49f25d77be
commit 6663a5e7db
5 changed files with 134 additions and 70 deletions
+2 -2
View File
@@ -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
+35 -40
View File
@@ -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<AskResult>((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 {
+1 -1
View File
@@ -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, {
+28 -25
View File
@@ -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 {
+68 -2
View File
@@ -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);