feat(ask): refine submission and custom answer flow
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user