feat: improve ask flow elaboration and validation

This commit is contained in:
Erik Kotsiuba
2026-04-24 19:13:10 +02:00
parent 0ee2d11ec9
commit b7c7301f97
16 changed files with 1270 additions and 134 deletions
+53 -17
View File
@@ -3,8 +3,14 @@ import { Text, truncateToWidth } from "@mariozechner/pi-tui";
import { renderResultText } from "./result.ts";
import { AskParamsSchema } from "./schema.ts";
import { createInitialState } from "./state/create.ts";
import { collectValidationIssues } from "./state/normalize.ts";
import { summarizeResult, toAskResult } from "./state/result.ts";
import type { AskParams, AskQuestionInput, AskResult } from "./types.ts";
import type {
AskParams,
AskQuestionInput,
AskResult,
AskValidationIssue,
} from "./types.ts";
import { UI_DIMENSIONS } from "./ui/constants.ts";
import { runAskFlow } from "./ui/controller.ts";
@@ -13,14 +19,19 @@ export function registerAskTool(pi: ExtensionAPI) {
name: "ask_user",
label: "Ask User",
description:
"Interactive clarification tool for cases where the next step depends on user preferences, missing requirements, or choosing between multiple valid directions. Ask a short structured interview, collect normalized answers, and continue using those answers explicitly instead of guessing. Supports single-select, multi-select, and preview questions with a dedicated preview pane.",
"Interactive clarification tool for cases where the next step depends on user preferences, missing requirements, or choosing between multiple valid directions. Ask a short structured interview, collect normalized answers, and continue using those answers explicitly instead of guessing. Supports single-select, multi-select, and preview-pane questions. Always include a machine-readable `value` for every option. Use `preview` only when every option includes `preview` text; descriptions alone are not enough.",
promptSnippet:
"Clarify ambiguous or preference-sensitive decisions with a short interactive interview before proceeding",
promptGuidelines: [
"Use this tool before making preference-sensitive decisions about scope, tone, UX, naming, architecture, docs, or implementation direction.",
"When multiple valid directions exist, ask 1-3 concise questions instead of committing to one path on your own.",
"Prefer one focused decision per question and use short labels with 2-4 clear options.",
'Use `type: "single"` by default, `type: "multi"` only when several answers can genuinely apply, and `type: "preview"` when options need a dedicated preview pane.',
"Always include a non-empty `value` for every option.",
'Use `type: "single"` by default and `type: "multi"` only when several answers can genuinely apply.',
'Use `type: "preview"` only when every option includes non-empty `preview` text for the dedicated preview pane. Option descriptions do not satisfy this requirement.',
"After clarifying a note or follow-up question, prefer another structured ask_user follow-up if a choice is still needed instead of switching to plain-text multiple choice in chat.",
"When prior answers already narrow the branch, bundle the next 2-3 related unresolved decisions into one follow-up ask instead of issuing a long sequence of single-question asks.",
"Use one-at-a-time follow-up asks only when the next question materially depends on the previous answer.",
],
parameters: AskParamsSchema,
@@ -28,8 +39,10 @@ export function registerAskTool(pi: ExtensionAPI) {
const validation = validateParams(params);
if (!validation.ok) {
return {
content: [{ type: "text", text: `Error: ${validation.error}` }],
details: errorResultDetails(params, true),
content: [
{ type: "text", text: formatValidationError(validation.issues) },
],
details: errorResultDetails(params, validation.issues),
};
}
@@ -83,6 +96,9 @@ export function registerAskTool(pi: ExtensionAPI) {
return new Text(text?.type === "text" ? text.text : "", 0, 0);
}
if (details.error) {
return new Text(theme.fg("warning", "Invalid input"), 0, 0);
}
if (details.cancelled) {
return new Text(theme.fg("warning", "Cancelled"), 0, 0);
}
@@ -92,19 +108,31 @@ export function registerAskTool(pi: ExtensionAPI) {
});
}
function errorResultDetails(params: AskParams, cancelled: boolean): AskResult {
function errorResultDetails(
params: AskParams,
issues: AskValidationIssue[]
): AskResult {
try {
const state = createInitialState(params);
return {
...toAskResult(state),
cancelled,
cancelled: true,
error: {
kind: "invalid_input",
issues,
},
};
} catch {
return {
title: params.title,
cancelled,
cancelled: true,
mode: "submit",
questions: [],
answers: {},
error: {
kind: "invalid_input",
issues,
},
};
}
}
@@ -113,19 +141,27 @@ function validateParams(
params: AskParams
):
| { ok: true; state: ReturnType<typeof createInitialState> }
| { ok: false; error: string } {
try {
return {
ok: true,
state: createInitialState(params),
};
} catch (error) {
| { ok: false; issues: AskValidationIssue[] } {
const issues = collectValidationIssues(params);
if (issues.length > 0) {
return {
ok: false,
error:
error instanceof Error ? error.message : "Invalid ask_user payload",
issues,
};
}
return {
ok: true,
state: createInitialState(params),
};
}
function formatValidationError(issues: AskValidationIssue[]): string {
const lines = [
"Invalid ask_user payload:",
...issues.map((issue) => `- ${issue.path}: ${issue.message}`),
];
return lines.join("\n");
}
function formatNonInteractiveMessage(
+5 -1
View File
@@ -1,8 +1,12 @@
export const OTHER_OPTION_VALUE = "__other__";
export const OTHER_OPTION_LABEL = "Type your own";
export const SUBMIT_CHOICES = ["Submit", "Cancel"] as const;
export const SUBMIT_CHOICES = ["Submit", "Elaborate", "Cancel"] as const;
export const NO_PREVIEW_TEXT = "No preview available";
export const ELABORATION_INSTRUCTION =
"First answer the user's noted clarification directly and concisely using the provided question and option context. Do not treat these notes as final answers. Then re-ask only the affected questions if a choice is still needed afterward. Do not jump straight to a follow-up question unless the note is already resolved.";
export const CANCELLED_SUMMARY = "User cancelled the ask flow";
export const SUBMITTED_SUMMARY = "User submitted the ask flow";
export const ELABORATED_SUMMARY = "User asked for elaboration based on notes";
export const CANCELLED_RESULT_TEXT = "Cancelled";
export const SUBMITTED_RESULT_TEXT = "Submitted";
export const ELABORATED_RESULT_TEXT = "Elaboration requested";
+23
View File
@@ -1,3 +1,4 @@
import { ELABORATED_SUMMARY } from "./constants.ts";
import type { AskResult } from "./types.ts";
export function formatResultLines(
@@ -67,6 +68,28 @@ function formatQuestionNoteLine(
: ` note: ${note}`;
}
export function formatElaborationLines(
result: AskResult,
_options: { mode: "summary" | "render" }
): string[] {
const items = result.elaboration?.items ?? [];
const lines = items.map((item) => {
if (item.target.kind === "question") {
return `User asked to elaborate on question ${quote(item.question.prompt)} with note ${quote(item.note)}`;
}
if (!("option" in item)) {
return `User asked to elaborate on question ${quote(item.question.prompt)} with note ${quote(item.note)}`;
}
return `User asked to elaborate on question ${quote(item.question.prompt)} option ${quote(item.option.label)} with note ${quote(item.note)}`;
});
return lines.length > 0 ? lines : [ELABORATED_SUMMARY];
}
function quote(value: string): string {
return JSON.stringify(value);
}
function formatOptionNoteLines(
questionLabel: string,
answer: AskResult["answers"][string],
+13 -2
View File
@@ -1,11 +1,22 @@
import { CANCELLED_RESULT_TEXT, SUBMITTED_RESULT_TEXT } from "./constants.ts";
import { formatResultLines } from "./result-format.ts";
import {
CANCELLED_RESULT_TEXT,
ELABORATED_RESULT_TEXT,
SUBMITTED_RESULT_TEXT,
} from "./constants.ts";
import { formatElaborationLines, formatResultLines } from "./result-format.ts";
import type { AskResult } from "./types.ts";
export function renderResultText(result: AskResult): string {
if (result.error) {
return "Invalid input";
}
if (result.cancelled) {
return CANCELLED_RESULT_TEXT;
}
if (result.mode === "elaborate") {
const lines = formatElaborationLines(result, { mode: "render" });
return lines.join("\n") || ELABORATED_RESULT_TEXT;
}
const lines = formatResultLines(result, { mode: "render" });
return lines.join("\n") || SUBMITTED_RESULT_TEXT;
+27 -32
View File
@@ -1,24 +1,24 @@
import { Type } from "typebox";
export const AskOptionSchema = Type.Object({
value: Type.String({
minLength: 1,
description:
"Machine-readable value returned for this option in the result",
}),
label: Type.String({
minLength: 1,
description: "Short visible option label shown in the list",
}),
value: Type.Optional(
Type.String({
description:
"Required machine-readable value returned for this option in the result",
})
),
label: Type.Optional(
Type.String({
description: "Required short visible option label shown in the list",
})
),
description: Type.Optional(
Type.String({
minLength: 1,
description: "Optional one-line explanation to help the user choose",
})
),
preview: Type.Optional(
Type.String({
minLength: 1,
description:
"Optional preview content shown in the dedicated preview pane for preview questions",
})
@@ -26,30 +26,28 @@ export const AskOptionSchema = Type.Object({
});
export const AskQuestionSchema = Type.Object({
id: Type.String({
minLength: 1,
description:
"Stable question identifier used as the key in returned answers",
}),
id: Type.Optional(
Type.String({
description:
"Required stable question identifier used as the key in returned answers",
})
),
label: Type.Optional(
Type.String({
minLength: 1,
description: "Short tab label, e.g. Goal, Audience, Tone, Scope",
})
),
prompt: Type.String({
minLength: 1,
description:
"Direct question shown to the user; ask about one decision at a time",
}),
prompt: Type.Optional(
Type.String({
description:
"Required direct question shown to the user; ask about one decision at a time",
})
),
type: Type.Optional(
Type.Union(
[Type.Literal("single"), Type.Literal("multi"), Type.Literal("preview")],
{
description:
"Question type: use `single` by default, `multi` when several answers may apply, and `preview` when each option needs a preview pane",
}
)
Type.String({
description:
"Question type: use `single` by default. Use `preview` only when every option includes `preview` text for the dedicated preview pane; descriptions alone are not enough.",
})
),
required: Type.Optional(
Type.Boolean({
@@ -58,7 +56,6 @@ export const AskQuestionSchema = Type.Object({
})
),
options: Type.Array(AskOptionSchema, {
minItems: 1,
description: "Answer options; prefer 2-4 clear choices when possible",
}),
});
@@ -66,13 +63,11 @@ export const AskQuestionSchema = Type.Object({
export const AskParamsSchema = Type.Object({
title: Type.Optional(
Type.String({
minLength: 1,
description:
"Optional short title shown above the clarification flow, e.g. README direction",
})
),
questions: Type.Array(AskQuestionSchema, {
minItems: 1,
description: "Questions to ask in the interactive clarification flow",
}),
});
+135 -33
View File
@@ -3,15 +3,27 @@ import type {
AskParams,
AskQuestion,
AskQuestionInput,
AskValidationIssue,
} from "../types.ts";
export function normalizeQuestions(params: AskParams): AskQuestion[] {
validateQuestions(params.questions);
const issues = collectValidationIssues(params);
if (issues.length > 0) {
throw new Error(issues[0]?.message ?? "Invalid ask_user payload");
}
return params.questions.map((question, index) =>
normalizeQuestion(question, index)
);
}
export function collectValidationIssues(
params: AskParams
): AskValidationIssue[] {
const issues: AskValidationIssue[] = [];
validateQuestions(params.questions, issues);
return issues;
}
function normalizeQuestion(
question: AskQuestionInput,
index: number
@@ -20,7 +32,7 @@ function normalizeQuestion(
id: question.id.trim(),
label: question.label?.trim() || `Q${index + 1}`,
prompt: question.prompt.trim(),
type: question.type ?? "single",
type: normalizeQuestionType(question.type),
required: question.required ?? false,
options: question.options.map(normalizeOption),
};
@@ -35,43 +47,71 @@ function normalizeOption(option: AskOption): AskOption {
};
}
function validateQuestions(questions: AskParams["questions"]): void {
function validateQuestions(
questions: AskParams["questions"],
issues: AskValidationIssue[]
): void {
if (questions.length === 0) {
throw new Error("At least one question is required");
issues.push({
path: "questions",
message: "At least one question is required",
});
return;
}
const questionIds = new Set<string>();
for (const [questionIndex, question] of questions.entries()) {
validateQuestion(question, questionIndex, questionIds);
validateQuestion(question, questionIndex, questionIds, issues);
}
}
function validateQuestion(
question: AskQuestionInput,
questionIndex: number,
questionIds: Set<string>
questionIds: Set<string>,
issues: AskValidationIssue[]
): void {
const questionNumber = questionIndex + 1;
const questionId = question.id.trim();
const questionType = question.type ?? "single";
const questionPath = `questions[${questionIndex}]`;
const questionId = question.id?.trim();
const questionType = normalizeQuestionType(question.type);
assertValidQuestionType(
question.type,
`Question ${questionNumber}: invalid type ${JSON.stringify(question.type)}; expected "single", "multi", or "preview"`,
issues,
`${questionPath}.type`
);
assertNonEmpty(questionId, `Question ${questionNumber}: id is required`);
assertNonEmpty(
questionId,
`Question ${questionNumber}: id is required`,
issues,
`${questionPath}.id`
);
assertUnique(
questionIds,
questionId,
`Question ${questionNumber}: duplicate question id "${questionId}"`
`Question ${questionNumber}: duplicate question id "${questionId}"`,
issues,
`${questionPath}.id`
);
assertOptionalNonEmpty(
question.label,
`Question ${questionNumber}: label must not be empty`
`Question ${questionNumber}: label must not be empty`,
issues,
`${questionPath}.label`
);
assertNonEmpty(
question.prompt.trim(),
`Question ${questionNumber}: prompt is required`
question.prompt?.trim(),
`Question ${questionNumber}: prompt is required`,
issues,
`${questionPath}.prompt`
);
assertHasItems(
question.options,
`Question ${questionNumber}: at least one option is required`
`Question ${questionNumber}: at least one option is required`,
issues,
`${questionPath}.options`
);
const optionValues = new Set<string>();
@@ -81,7 +121,9 @@ function validateQuestion(
optionIndex,
optionValues,
questionNumber,
questionType
questionType,
issues,
`${questionPath}.options[${optionIndex}]`
);
}
}
@@ -91,63 +133,123 @@ function validateOption(
optionIndex: number,
optionValues: Set<string>,
questionNumber: number,
questionType: AskQuestion["type"]
questionType: AskQuestion["type"],
issues: AskValidationIssue[],
optionPath: string
): void {
const optionNumber = optionIndex + 1;
const prefix = `Question ${questionNumber}, option ${optionNumber}`;
const optionValue = option.value.trim();
const optionValue = option.value?.trim();
assertNonEmpty(optionValue, `${prefix}: value is required`);
assertNonEmpty(
optionValue,
`${prefix}: value is required`,
issues,
`${optionPath}.value`
);
assertUnique(
optionValues,
optionValue,
`${prefix}: duplicate option value "${optionValue}"`
`${prefix}: duplicate option value "${optionValue}"`,
issues,
`${optionPath}.value`
);
assertNonEmpty(
option.label?.trim(),
`${prefix}: label is required`,
issues,
`${optionPath}.label`
);
assertNonEmpty(option.label.trim(), `${prefix}: label is required`);
assertOptionalNonEmpty(
option.description,
`${prefix}: description must not be empty`
`${prefix}: description must not be empty`,
issues,
`${optionPath}.description`
);
assertOptionalNonEmpty(
option.preview,
`${prefix}: preview must not be empty`
`${prefix}: preview must not be empty`,
issues,
`${optionPath}.preview`
);
if (questionType === "preview") {
assertNonEmpty(
option.preview?.trim(),
`${prefix}: preview questions require preview text for every option`
`${prefix}: preview questions require preview text for every option; add preview text or use type "single" instead`,
issues,
`${optionPath}.preview`
);
}
}
function assertHasItems(items: unknown[], errorMessage: string): void {
if (items.length === 0) {
throw new Error(errorMessage);
function normalizeQuestionType(
value: AskQuestionInput["type"]
): AskQuestion["type"] {
return value === "multi" || value === "preview" ? value : "single";
}
function assertValidQuestionType(
value: AskQuestionInput["type"],
errorMessage: string,
issues: AskValidationIssue[],
path: string
): void {
if (
value !== undefined &&
value !== "single" &&
value !== "multi" &&
value !== "preview"
) {
issues.push({ path, message: errorMessage });
}
}
function assertNonEmpty(value: string | undefined, errorMessage: string): void {
function assertHasItems(
items: unknown[],
errorMessage: string,
issues: AskValidationIssue[],
path: string
): void {
if (items.length === 0) {
issues.push({ path, message: errorMessage });
}
}
function assertNonEmpty(
value: string | undefined,
errorMessage: string,
issues: AskValidationIssue[],
path: string
): void {
if (!value) {
throw new Error(errorMessage);
issues.push({ path, message: errorMessage });
}
}
function assertOptionalNonEmpty(
value: string | undefined,
errorMessage: string
errorMessage: string,
issues: AskValidationIssue[],
path: string
): void {
if (value !== undefined && !value.trim()) {
throw new Error(errorMessage);
issues.push({ path, message: errorMessage });
}
}
function assertUnique(
seen: Set<string>,
value: string,
errorMessage: string
value: string | undefined,
errorMessage: string,
issues: AskValidationIssue[],
path: string
): void {
if (!value) {
return;
}
if (seen.has(value)) {
throw new Error(errorMessage);
issues.push({ path, message: errorMessage });
return;
}
seen.add(value);
}
+176 -5
View File
@@ -1,7 +1,25 @@
import { CANCELLED_SUMMARY, SUBMITTED_SUMMARY } from "../constants.ts";
import { formatResultLines } from "../result-format.ts";
import type { AskResult, AskState } from "../types.ts";
import { isAnswerEmpty, serializeAnswer } from "./answers.ts";
import {
CANCELLED_SUMMARY,
ELABORATED_SUMMARY,
ELABORATION_INSTRUCTION,
SUBMITTED_SUMMARY,
} from "../constants.ts";
import { formatElaborationLines, formatResultLines } from "../result-format.ts";
import type {
AskContinuationPayload,
AskElaborationPayload,
AskResult,
AskResultAnswer,
AskState,
AskStateAnswer,
} from "../types.ts";
import {
cloneResultAnswer,
isAnswerAnswered,
isAnswerEmpty,
isOptionSelected,
serializeAnswer,
} from "./answers.ts";
export function toAskResult(state: AskState): AskResult {
const answers = Object.fromEntries(
@@ -9,12 +27,17 @@ export function toAskResult(state: AskState): AskResult {
.map(
([questionId, answer]) => [questionId, serializeAnswer(answer)] as const
)
.filter(([, answer]) => !isSerializedAnswerEmpty(answer))
.filter(([, answer]) =>
state.mode === "elaborate"
? isSerializedAnswerCommitted(answer)
: !isSerializedAnswerEmpty(answer)
)
);
return {
title: state.title,
cancelled: state.cancelled,
mode: state.mode,
questions: state.questions.map((question) => ({
id: question.id,
label: question.label,
@@ -22,6 +45,12 @@ export function toAskResult(state: AskState): AskResult {
type: question.type,
})),
answers,
continuation:
state.mode === "elaborate"
? serializeContinuation(state, answers)
: undefined,
elaboration:
state.mode === "elaborate" ? serializeElaboration(state) : undefined,
};
}
@@ -29,6 +58,10 @@ export function summarizeResult(result: AskResult): string {
if (result.cancelled) {
return CANCELLED_SUMMARY;
}
if (result.mode === "elaborate") {
const lines = formatElaborationLines(result, { mode: "summary" });
return lines.join("\n") || ELABORATED_SUMMARY;
}
const lines = formatResultLines(result, { mode: "summary" });
return lines.join("\n") || SUBMITTED_SUMMARY;
@@ -51,3 +84,141 @@ function isSerializedAnswerEmpty(
(!answer.optionNotes || Object.keys(answer.optionNotes).length === 0)
);
}
function serializeContinuation(
state: AskState,
answers: AskResult["answers"]
): AskContinuationPayload {
const affectedQuestionIds: string[] = [];
const preservedAnswers: AskContinuationPayload["preservedAnswers"] = {};
const questionStates: AskContinuationPayload["questionStates"] = {};
for (const question of state.questions) {
const answer = state.answers[question.id];
const hasClarificationNeed = !!(answer?.note || answer?.optionNotes);
const committedAnswer = answers[question.id];
const answered = !!committedAnswer;
if (hasClarificationNeed) {
affectedQuestionIds.push(question.id);
questionStates[question.id] = { status: "needs_clarification" };
continue;
}
if (answered) {
preservedAnswers[question.id] = cloneResultAnswer(committedAnswer);
questionStates[question.id] = { status: "answered" };
continue;
}
questionStates[question.id] = { status: "unanswered" };
}
return {
affectedQuestionIds,
preservedAnswers,
questionStates,
strategy: "refine_only",
};
}
function serializeElaboration(state: AskState): AskElaborationPayload {
const items = state.questions.flatMap((question) =>
serializeElaborationItemsForQuestion(question, state.answers[question.id])
);
return {
instruction: ELABORATION_INSTRUCTION,
nextAction: "clarify_then_reask",
items,
};
}
function serializeElaborationItemsForQuestion(
question: AskState["questions"][number],
answer: AskStateAnswer | undefined
): AskElaborationPayload["items"] {
if (!(answer?.note || answer?.optionNotes)) {
return [];
}
const questionContext = createElaborationQuestionContext(question);
const serializedAnswer = toCommittedResultAnswer(answer);
const answered = isAnswerAnswered(answer);
const items: AskElaborationPayload["items"] = [];
if (answer.note) {
items.push({
target: { kind: "question" },
question: questionContext,
answered,
answer: serializedAnswer,
note: answer.note,
});
}
for (const [value, note] of Object.entries(answer.optionNotes ?? {})) {
const option = question.options.find(
(candidate) => candidate.value === value
);
if (!(option && note)) {
continue;
}
items.push({
target: {
kind: "option",
optionValue: value,
},
question: questionContext,
option: cloneOption(option),
selected: isOptionSelected(answer, value),
answered,
answer: serializedAnswer,
note,
});
}
return items;
}
function createElaborationQuestionContext(
question: AskState["questions"][number]
) {
return {
id: question.id,
label: question.label,
prompt: question.prompt,
type: question.type,
options: question.options.map(cloneOption),
};
}
function cloneOption(option: AskState["questions"][number]["options"][number]) {
return {
value: option.value,
label: option.label,
...(option.description ? { description: option.description } : {}),
...(option.preview ? { preview: option.preview } : {}),
};
}
function toCommittedResultAnswer(
answer: AskStateAnswer | undefined
): AskResultAnswer | undefined {
if (!(answer && isAnswerAnswered(answer))) {
return;
}
return cloneResultAnswer(serializeAnswer(answer));
}
function isSerializedAnswerCommitted(
answer: AskResult["answers"][string]
): boolean {
return (
answer.values.length > 0 ||
answer.labels.length > 0 ||
answer.indices.length > 0 ||
!!answer.customText
);
}
+9 -4
View File
@@ -26,7 +26,7 @@ import {
submitView,
} from "./view.ts";
const SUBMIT_ACTION_COUNT = 2;
const SUBMIT_ACTION_COUNT = 3;
export function createInitialState(params: {
title?: string;
@@ -42,6 +42,7 @@ export function createInitialState(params: {
answers: {},
completed: false,
cancelled: false,
mode: "submit",
};
}
@@ -154,9 +155,13 @@ export function toggleCurrentMultiOption(state: AskState): AskState {
export function confirmCurrentSelection(state: AskState): AskState {
if (isSubmitTab(state)) {
return state.activeSubmitActionIndex === 1
? { ...state, cancelled: true, completed: true }
: { ...state, completed: true };
if (state.activeSubmitActionIndex === 2) {
return { ...state, cancelled: true, completed: true };
}
if (state.activeSubmitActionIndex === 1) {
return { ...state, mode: "elaborate", completed: true };
}
return { ...state, mode: "submit", completed: true };
}
const question = getCurrentQuestion(state);
+71 -6
View File
@@ -21,6 +21,16 @@ export interface AskParams {
title?: string;
}
export interface AskValidationIssue {
message: string;
path: string;
}
export interface AskValidationError {
issues: AskValidationIssue[];
kind: "invalid_input";
}
export interface AskQuestion
extends Omit<AskQuestionInput, "type" | "required" | "label"> {
label: string;
@@ -50,15 +60,69 @@ export interface AskResultAnswer {
values: string[];
}
export interface AskQuestionSummary {
id: string;
label: string;
prompt: string;
type: AskQuestionType;
}
export interface AskElaborationQuestionContext extends AskQuestionSummary {
options: AskOption[];
}
export interface AskElaborationQuestionItem {
answer?: AskResultAnswer;
answered: boolean;
note: string;
question: AskElaborationQuestionContext;
target: {
kind: "question";
};
}
export interface AskElaborationOptionItem {
answer?: AskResultAnswer;
answered: boolean;
note: string;
option: AskOption;
question: AskElaborationQuestionContext;
selected: boolean;
target: {
kind: "option";
optionValue: string;
};
}
export type AskElaborationItem =
| AskElaborationQuestionItem
| AskElaborationOptionItem;
export interface AskElaborationPayload {
instruction: string;
items: AskElaborationItem[];
nextAction: "clarify" | "clarify_then_reask";
}
export interface AskContinuationQuestionState {
status: "answered" | "needs_clarification" | "unanswered";
}
export interface AskContinuationPayload {
affectedQuestionIds: string[];
preservedAnswers: Record<string, AskResultAnswer>;
questionStates: Record<string, AskContinuationQuestionState>;
strategy: "refine_only" | "resume";
}
export interface AskResult {
answers: Record<string, AskResultAnswer>;
cancelled: boolean;
questions: Array<{
id: string;
label: string;
prompt: string;
type: AskQuestionType;
}>;
continuation?: AskContinuationPayload;
elaboration?: AskElaborationPayload;
error?: AskValidationError;
mode: "submit" | "elaborate";
questions: AskQuestionSummary[];
title?: string;
}
@@ -75,6 +139,7 @@ export interface AskState {
answers: Record<string, AskStateAnswer>;
cancelled: boolean;
completed: boolean;
mode: "submit" | "elaborate";
questions: AskQuestion[];
title?: string;
view: ViewState;
+102 -21
View File
@@ -1,11 +1,18 @@
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
import { SUBMIT_CHOICES } from "../constants.ts";
import { toAskResult } from "../state/result.ts";
import type { AskResult, AskState } from "../types.ts";
import { serializeAnswer } from "../state/answers.ts";
import type { AskResult, AskState, AskStateAnswer } from "../types.ts";
import { UI_TEXT } from "./constants.ts";
import { pushWrappedText } from "./render-helpers.ts";
import type { Theme } from "./render-types.ts";
type ReviewAnswer = AskResult["answers"][string] & {
extraOptionNotes?: Array<{
label: string;
note: string;
}>;
};
export function renderSubmitScreen(
lines: string[],
state: AskState,
@@ -15,13 +22,13 @@ export function renderSubmitScreen(
pushWrappedText(lines, UI_TEXT.reviewTitle, width, theme, "accent", " ", " ");
lines.push("");
const submittedAnswers = toAskResult(state).answers;
const showAllNotes = state.activeSubmitActionIndex === 1;
for (let index = 0; index < state.questions.length; index++) {
const question = state.questions[index];
renderSubmittedQuestion(
lines,
question,
submittedAnswers[question.id],
toReviewAnswer(question, state.answers[question.id], showAllNotes),
theme,
width
);
@@ -37,7 +44,7 @@ export function renderSubmitScreen(
function renderSubmittedQuestion(
lines: string[],
question: AskState["questions"][number],
answer: AskResult["answers"][string] | undefined,
answer: ReviewAnswer | undefined,
theme: Theme,
width: number
) {
@@ -54,7 +61,7 @@ function renderSubmittedQuestion(
function renderSubmittedAnswer(
lines: string[],
answer: AskResult["answers"][string],
answer: ReviewAnswer,
theme: Theme,
width: number
) {
@@ -64,28 +71,29 @@ function renderSubmittedAnswer(
if (shouldRenderAnswersIndividually(answer)) {
renderSubmittedSelections(lines, answer, theme, width);
renderExtraOptionNotes(lines, answer, theme, width);
return;
}
const answerText = answer.labels.join(", ");
if (!answerText) {
return;
if (answerText) {
pushWrappedText(
lines,
`→ ${answerText}`,
width,
theme,
isCustomOnlyAnswer(answer) ? "text" : "success",
" ",
" "
);
}
pushWrappedText(
lines,
`→ ${answerText}`,
width,
theme,
isCustomOnlyAnswer(answer) ? "text" : "success",
" ",
" "
);
renderExtraOptionNotes(lines, answer, theme, width);
}
function renderSubmittedSelections(
lines: string[],
answer: AskResult["answers"][string],
answer: ReviewAnswer,
theme: Theme,
width: number
) {
@@ -109,9 +117,7 @@ function renderSubmittedSelections(
}
}
function shouldRenderAnswersIndividually(
answer: AskResult["answers"][string]
): boolean {
function shouldRenderAnswersIndividually(answer: ReviewAnswer): boolean {
if (!answer.labels.length) {
return false;
}
@@ -142,10 +148,85 @@ function renderSubmittedNote(
);
}
function renderLabeledSubmittedNote(
lines: string[],
label: string,
note: string,
theme: Theme,
width: number
) {
const indent = " ";
const title = `${label} ${UI_TEXT.questionNoteTitle}`;
const notePrefix = `${indent}${theme.fg("syntaxString", title)} `;
const continuationPrefix = `${indent}${" ".repeat(visibleWidth(title) + 1)}`;
pushWrappedText(
lines,
note,
width,
theme,
"muted",
notePrefix,
continuationPrefix
);
}
function renderExtraOptionNotes(
lines: string[],
answer: ReviewAnswer,
theme: Theme,
width: number
) {
for (const option of answer.extraOptionNotes ?? []) {
renderLabeledSubmittedNote(lines, option.label, option.note, theme, width);
}
}
function isCustomOnlyAnswer(answer: AskResult["answers"][string]): boolean {
return answer.indices.length === 0 && !!answer.customText;
}
function toReviewAnswer(
question: AskState["questions"][number],
answer: AskStateAnswer | undefined,
showAllNotes: boolean
): ReviewAnswer | undefined {
if (!answer) {
return;
}
const serialized = serializeAnswer(answer);
const hasSelectedAnswer = serialized.labels.length > 0;
if (!showAllNotes) {
return hasSelectedAnswer ? serialized : undefined;
}
const selectedValues = new Set(serialized.values);
const extraOptionNotes = Object.entries(answer.optionNotes ?? {})
.filter(([value, note]) => !selectedValues.has(value) && Boolean(note))
.map(([value, note]) => {
const option = question.options.find(
(candidate) => candidate.value === value
);
return option ? { label: option.label, note } : undefined;
})
.filter((entry): entry is { label: string; note: string } =>
Boolean(entry)
);
if (
!(hasSelectedAnswer || serialized.note) &&
extraOptionNotes.length === 0
) {
return;
}
return {
...serialized,
extraOptionNotes:
extraOptionNotes.length > 0 ? extraOptionNotes : undefined,
};
}
function renderSubmitActions(
lines: string[],
state: AskState,