refactor: split ask flow state and view models

This commit is contained in:
Erik Kotsiuba
2026-04-26 02:17:11 +02:00
parent 8ea067dcde
commit 2618d6ea57
21 changed files with 1455 additions and 1173 deletions
+1 -1
View File
@@ -140,7 +140,7 @@ pnpm dev
pnpm dev ../test
```
`pnpm dev [path]` runs pi with `--no-extensions --no-skills --no-prompt-templates --no-themes --no-context-files`, loads this repo’s extension and `skills/ask-user`, and sets the working directory to `[path]` (defaults to `.`).
`pnpm dev [path]` runs pi with `--no-extensions --no-skills --no-prompt-templates --no-themes --no-context-files`, loads this repo’s extension and `skills/ask-user`, and starts pi from `[path]` by changing directories before launch (defaults to `.`).
### Install dependencies
+1 -1
View File
@@ -28,7 +28,7 @@
"CHANGELOG.md"
],
"scripts": {
"dev": "sh -c 'pi --no-extensions --no-skills --no-prompt-templates --no-themes --no-context-files -e ./src/index.ts --skill ./skills/ask-user --cd \"${1:-.}\"' --",
"dev": "sh -c 'ROOT=\"$PWD\"; TARGET=\"${1:-.}\"; if [ \"$TARGET\" = \"--\" ]; then TARGET=\"${2:-.}\"; fi; cd \"$TARGET\" && pi --no-extensions --no-skills --no-prompt-templates --no-themes --no-context-files -e \"$ROOT/src/index.ts\" --skill \"$ROOT/skills/ask-user\"' --",
"test": "node --test tests/*.test.ts",
"typecheck": "tsc -p tsconfig.json",
"format": "biome format --write .",
+166
View File
@@ -0,0 +1,166 @@
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
import { Text, truncateToWidth } from "@mariozechner/pi-tui";
import { renderResultText } from "./result.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,
AskValidationIssue,
} from "./types.ts";
import { UI_DIMENSIONS } from "./ui/constants.ts";
export const ASK_TOOL_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-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.";
export const ASK_TOOL_PROMPT_GUIDELINES = [
"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.",
"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.",
] as const;
export function validateParams(
params: AskParams
):
| { ok: true; state: ReturnType<typeof createInitialState> }
| { ok: false; issues: AskValidationIssue[] } {
const issues = collectValidationIssues(params);
if (issues.length > 0) {
return { ok: false, issues };
}
return {
ok: true,
state: createInitialState(params),
};
}
export function invalidPayloadResponse(
params: AskParams,
issues: AskValidationIssue[]
) {
return {
content: [{ type: "text" as const, text: formatValidationError(issues) }],
details: errorResultDetails(params, issues),
};
}
export function nonInteractiveResponse(
state: ReturnType<typeof createInitialState>
) {
return {
content: [
{ type: "text" as const, text: formatNonInteractiveMessage(state) },
],
details: {
...toAskResult(state),
cancelled: true,
},
};
}
export function successfulResponse(result: AskResult) {
return {
content: [{ type: "text" as const, text: summarizeResult(result) }],
details: result,
};
}
type ToolTheme = ExtensionContext["ui"]["theme"];
export function renderAskToolCall(args: unknown, theme: ToolTheme) {
const params = args as AskParams;
const labels = Array.isArray(params.questions)
? params.questions
.map(
(question: AskQuestionInput, index) =>
question.label || `Q${index + 1}`
)
.join(", ")
: "";
let text = theme.fg("toolTitle", theme.bold("ask_user "));
text += theme.fg("muted", `${params.questions?.length ?? 0} question(s)`);
if (labels) {
text += theme.fg(
"dim",
` (${truncateToWidth(labels, UI_DIMENSIONS.callLabelTruncateWidth)})`
);
}
return new Text(text, 0, 0);
}
export function renderAskToolResult(
result: {
content: Array<{ type?: string; text?: string }>;
details?: AskResult;
},
_options: unknown,
theme: ToolTheme
) {
const details = result.details;
if (!details) {
const text = result.content[0];
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);
}
return new Text(renderResultText(details), 0, 0);
}
function errorResultDetails(
params: AskParams,
issues: AskValidationIssue[]
): AskResult {
return {
title: params.title,
cancelled: true,
mode: "submit",
questions: [],
answers: {},
error: {
kind: "invalid_input",
issues,
},
};
}
function formatValidationError(issues: AskValidationIssue[]): string {
return [
"Invalid ask_user payload:",
...issues.map((issue) => `- ${issue.path}: ${issue.message}`),
].join("\n");
}
function formatNonInteractiveMessage(
state: ReturnType<typeof createInitialState>
): string {
const lines = [
"Needs user input: ask_user requires interactive UI.",
"Run same tool call in interactive session, or ask user these questions manually:",
];
for (const [index, question] of state.questions.entries()) {
lines.push(`${index + 1}. ${question.label}: ${question.prompt}`);
for (const option of question.options) {
lines.push(` - ${option.label} [${option.value}]`);
}
lines.push(" - Type your own [custom]");
}
lines.push(
"details.questions contains normalized pending questions. details.answers stays empty until user responds."
);
return lines.join("\n");
}
+35 -170
View File
@@ -1,187 +1,52 @@
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
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,
AskValidationIssue,
} from "./types.ts";
import { UI_DIMENSIONS } from "./ui/constants.ts";
ExtensionAPI,
ExtensionContext,
} from "@mariozechner/pi-coding-agent";
import {
ASK_TOOL_DESCRIPTION,
ASK_TOOL_PROMPT_GUIDELINES,
invalidPayloadResponse,
nonInteractiveResponse,
renderAskToolCall,
renderAskToolResult,
successfulResponse,
validateParams,
} from "./ask-tool-helpers.ts";
import { AskParamsSchema } from "./schema.ts";
import type { AskParams } from "./types.ts";
import { runAskFlow } from "./ui/controller.ts";
export function registerAskTool(pi: ExtensionAPI) {
pi.registerTool({
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-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.",
description: ASK_TOOL_DESCRIPTION,
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.",
"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.",
],
promptGuidelines: [...ASK_TOOL_PROMPT_GUIDELINES],
parameters: AskParamsSchema,
async execute(_toolCallId, params: AskParams, _signal, _onUpdate, ctx) {
const validation = validateParams(params);
if (!validation.ok) {
return {
content: [
{ type: "text", text: formatValidationError(validation.issues) },
],
details: errorResultDetails(params, validation.issues),
};
}
if (!ctx.hasUI) {
return {
content: [
{
type: "text",
text: formatNonInteractiveMessage(validation.state),
},
],
details: {
...toAskResult(validation.state),
cancelled: true,
},
};
}
const result = await runAskFlow(ctx, params);
return {
content: [{ type: "text", text: summarizeResult(result) }],
details: result,
};
},
renderCall(args, theme) {
const params = args as AskParams;
const labels = Array.isArray(params.questions)
? params.questions
.map(
(question: AskQuestionInput, index) =>
question.label || `Q${index + 1}`
)
.join(", ")
: "";
let text = theme.fg("toolTitle", theme.bold("ask_user "));
text += theme.fg("muted", `${params.questions?.length ?? 0} question(s)`);
if (labels) {
text += theme.fg(
"dim",
` (${truncateToWidth(labels, UI_DIMENSIONS.callLabelTruncateWidth)})`
);
}
return new Text(text, 0, 0);
},
renderResult(result, _options, theme) {
const details = result.details as AskResult | undefined;
if (!details) {
const text = result.content[0];
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);
}
return new Text(renderResultText(details), 0, 0);
},
execute: executeAskTool,
renderCall: renderAskToolCall,
renderResult: renderAskToolResult,
});
}
function errorResultDetails(
interface ExecuteContext extends Pick<ExtensionContext, "cwd" | "hasUI"> {}
async function executeAskTool(
_toolCallId: string,
params: AskParams,
issues: AskValidationIssue[]
): AskResult {
try {
const state = createInitialState(params);
return {
...toAskResult(state),
cancelled: true,
error: {
kind: "invalid_input",
issues,
},
};
} catch {
return {
title: params.title,
cancelled: true,
mode: "submit",
questions: [],
answers: {},
error: {
kind: "invalid_input",
issues,
},
};
_signal: AbortSignal | undefined,
_onUpdate: unknown,
ctx: ExecuteContext
) {
const validation = validateParams(params);
if (!validation.ok) {
return invalidPayloadResponse(params, validation.issues);
}
}
function validateParams(
params: AskParams
):
| { ok: true; state: ReturnType<typeof createInitialState> }
| { ok: false; issues: AskValidationIssue[] } {
const issues = collectValidationIssues(params);
if (issues.length > 0) {
return {
ok: false,
issues,
};
if (!ctx.hasUI) {
return nonInteractiveResponse(validation.state);
}
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(
state: ReturnType<typeof createInitialState>
): string {
const lines = [
"Needs user input: ask_user requires interactive UI.",
"Run same tool call in interactive session, or ask user these questions manually:",
];
for (const [index, question] of state.questions.entries()) {
lines.push(`${index + 1}. ${question.label}: ${question.prompt}`);
for (const option of question.options) {
lines.push(` - ${option.label} [${option.value}]`);
}
lines.push(" - Type your own [custom]");
}
lines.push(
"details.questions contains normalized pending questions. details.answers stays empty until user responds."
);
return lines.join("\n");
const result = await runAskFlow(ctx as never, params);
return successfulResponse(result);
}
+3
View File
@@ -0,0 +1,3 @@
export function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
+1 -4
View File
@@ -1,4 +1,5 @@
import { ELABORATED_SUMMARY } from "./constants.ts";
import { isCustomOnlyAnswer } from "./state/answers.ts";
import type { AskResult } from "./types.ts";
export function formatResultLines(
@@ -51,10 +52,6 @@ function formatAnswerLine(
return `✓ ${questionLabel}: ${answerText}`;
}
function isCustomOnlyAnswer(answer: AskResult["answers"][string]): boolean {
return answer.indices.length === 0 && !!answer.customText;
}
function formatQuestionNoteLine(
questionLabel: string,
note: string | undefined,
+50
View File
@@ -5,6 +5,11 @@ import type {
AskStateAnswer,
} from "../types.ts";
export interface ExtraOptionNote {
label: string;
note: string;
}
export function emptyAnswer(): AskStateAnswer {
return { selected: [] };
}
@@ -135,6 +140,34 @@ export function isAnswerAnswered(answer?: AskStateAnswer): boolean {
return answer.selected.length > 0 || !!answer.customText?.trim();
}
export function hasAnswerNotes(answer?: AskStateAnswer): boolean {
return !!(answer?.note || answer?.optionNotes);
}
export function isResultAnswerEmpty(answer: AskResultAnswer): 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)
);
}
export function isResultAnswerCommitted(answer: AskResultAnswer): boolean {
return (
answer.values.length > 0 ||
answer.labels.length > 0 ||
answer.indices.length > 0 ||
!!answer.customText
);
}
export function isCustomOnlyAnswer(answer: AskResultAnswer): boolean {
return answer.indices.length === 0 && !!answer.customText;
}
export function isOptionSelected(
answer: AskStateAnswer | undefined,
optionValue: string
@@ -178,6 +211,23 @@ export function serializeAnswer(answer: AskStateAnswer): AskResultAnswer {
};
}
export function getExtraOptionNotes(args: {
answer: AskStateAnswer;
questionOptions: Array<{ value: string; label: string }>;
selectedValues?: Iterable<string>;
}): ExtraOptionNote[] {
const selectedValues = new Set(args.selectedValues ?? []);
return Object.entries(args.answer.optionNotes ?? {})
.filter(([value, note]) => !selectedValues.has(value) && Boolean(note))
.map(([value, note]) => {
const option = args.questionOptions.find(
(candidate) => candidate.value === value
);
return option ? { label: option.label, note } : undefined;
})
.filter((entry): entry is ExtraOptionNote => Boolean(entry));
}
function cloneSelection(selection: AskSelectedOption): AskSelectedOption {
return { ...selection };
}
+66
View File
@@ -0,0 +1,66 @@
import type { AskState } from "../types.ts";
import {
getAnswer,
getCurrentOption,
getCurrentQuestion,
getOptionNote,
getQuestionNote,
isSubmitTab,
} from "./selectors.ts";
import {
enterInputMode,
saveCustomAnswer,
saveNote,
submitCustomAnswer,
} from "./transitions.ts";
import { isEditingView } from "./view.ts";
export function getEditorDraft(state: AskState): string {
if (state.view.kind === "input") {
return getAnswer(state, state.view.questionId)?.customText ?? "";
}
if (state.view.kind === "note") {
if (state.view.optionValue) {
return (
getOptionNote(state, state.view.questionId, state.view.optionValue) ??
""
);
}
return getQuestionNote(state, state.view.questionId) ?? "";
}
return "";
}
export function saveEditorDraft(state: AskState, text: string): AskState {
if (state.view.kind === "input") {
return saveCustomAnswer(state, text);
}
if (state.view.kind === "note") {
return saveNote(state, text);
}
return state;
}
export function submitEditorDraft(state: AskState, text: string): AskState {
if (state.view.kind === "input") {
return submitCustomAnswer(state, text);
}
if (state.view.kind === "note") {
return saveNote(state, text);
}
return state;
}
export function syncStateToSelection(state: AskState): AskState {
if (isEditingView(state) || isSubmitTab(state)) {
return state;
}
const question = getCurrentQuestion(state);
const option = getCurrentOption(state);
if (!(question && option?.isCustomOption)) {
return state;
}
return enterInputMode(state, question.id);
}
+86
View File
@@ -0,0 +1,86 @@
import { clamp } from "../math.ts";
import type { AskState } from "../types.ts";
import {
getCurrentQuestion,
getRenderableOptions,
isSubmitTab,
} from "./selectors.ts";
import { isEditingView, navigateView, submitView } from "./view.ts";
const SUBMIT_ACTION_COUNT = 3;
export function createInitialState(params: {
title?: string;
questions: AskState["questions"];
}): AskState {
return {
title: params.title,
questions: params.questions,
activeTabIndex: 0,
activeOptionIndex: 0,
activeSubmitActionIndex: 0,
view: navigateView(),
answers: {},
completed: false,
cancelled: false,
mode: "submit",
};
}
export function moveTab(state: AskState, delta: number): AskState {
const normalizedDelta = delta < 0 ? -1 : 1;
const totalTabs = state.questions.length + 1;
const activeTabIndex =
(state.activeTabIndex + normalizedDelta + totalTabs) % totalTabs;
return {
...state,
activeTabIndex,
activeOptionIndex: 0,
activeSubmitActionIndex: 0,
view:
activeTabIndex >= state.questions.length ? submitView() : navigateView(),
};
}
export function moveOption(state: AskState, delta: number): AskState {
const normalizedDelta = delta < 0 ? -1 : 1;
if (isSubmitTab(state)) {
return {
...state,
activeSubmitActionIndex: clamp(
state.activeSubmitActionIndex + normalizedDelta,
0,
SUBMIT_ACTION_COUNT - 1
),
};
}
const options = getRenderableOptions(getCurrentQuestion(state));
return {
...state,
activeOptionIndex: clamp(
state.activeOptionIndex + normalizedDelta,
0,
Math.max(0, options.length - 1)
),
};
}
export function cancelFlow(state: AskState): AskState {
if (isEditingView(state)) {
return {
...state,
view: isSubmitTab(state) ? submitView() : navigateView(),
};
}
return {
...state,
cancelled: true,
completed: true,
};
}
export function dismissFlow(state: AskState): AskState {
const nextState = cancelFlow(state);
return nextState.completed ? nextState : cancelFlow(nextState);
}
+105 -91
View File
@@ -6,22 +6,25 @@ import type {
AskValidationIssue,
} from "../types.ts";
interface IssueCollector {
add: (path: string, message: string) => void;
issues: AskValidationIssue[];
}
export function normalizeQuestions(params: AskParams): AskQuestion[] {
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)
);
return params.questions.map(normalizeQuestion);
}
export function collectValidationIssues(
params: AskParams
): AskValidationIssue[] {
const issues: AskValidationIssue[] = [];
validateQuestions(params.questions, issues);
return issues;
const collector = createIssueCollector();
validateQuestions(params.questions, collector);
return collector.issues;
}
function normalizeQuestion(
@@ -49,19 +52,16 @@ function normalizeOption(option: AskOption): AskOption {
function validateQuestions(
questions: AskParams["questions"],
issues: AskValidationIssue[]
): void {
collector: IssueCollector
) {
if (questions.length === 0) {
issues.push({
path: "questions",
message: "At least one question is required",
});
collector.add("questions", "At least one question is required");
return;
}
const questionIds = new Set<string>();
for (const [questionIndex, question] of questions.entries()) {
validateQuestion(question, questionIndex, questionIds, issues);
validateQuestion(question, questionIndex, questionIds, collector);
}
}
@@ -69,49 +69,49 @@ function validateQuestion(
question: AskQuestionInput,
questionIndex: number,
questionIds: Set<string>,
issues: AskValidationIssue[]
): void {
collector: IssueCollector
) {
const questionNumber = questionIndex + 1;
const questionPath = `questions[${questionIndex}]`;
const questionId = question.id?.trim();
const questionType = normalizeQuestionType(question.type);
assertValidQuestionType(
validateQuestionType(
question.type,
`Question ${questionNumber}: invalid type ${JSON.stringify(question.type)}; expected "single", "multi", or "preview"`,
issues,
questionNumber,
collector,
`${questionPath}.type`
);
assertNonEmpty(
assertRequired(
questionId,
`Question ${questionNumber}: id is required`,
issues,
`${questionPath}.id`
collector,
`${questionPath}.id`,
`Question ${questionNumber}: id is required`
);
assertUnique(
questionIds,
questionId,
`Question ${questionNumber}: duplicate question id "${questionId}"`,
issues,
`${questionPath}.id`
collector,
`${questionPath}.id`,
`Question ${questionNumber}: duplicate question id "${questionId}"`
);
assertOptionalNonEmpty(
assertOptionalText(
question.label,
`Question ${questionNumber}: label must not be empty`,
issues,
`${questionPath}.label`
collector,
`${questionPath}.label`,
`Question ${questionNumber}: label must not be empty`
);
assertNonEmpty(
assertRequired(
question.prompt?.trim(),
`Question ${questionNumber}: prompt is required`,
issues,
`${questionPath}.prompt`
collector,
`${questionPath}.prompt`,
`Question ${questionNumber}: prompt is required`
);
assertHasItems(
question.options,
`Question ${questionNumber}: at least one option is required`,
issues,
`${questionPath}.options`
collector,
`${questionPath}.options`,
`Question ${questionNumber}: at least one option is required`
);
const optionValues = new Set<string>();
@@ -122,7 +122,7 @@ function validateQuestion(
optionValues,
questionNumber,
questionType,
issues,
collector,
`${questionPath}.options[${optionIndex}]`
);
}
@@ -134,50 +134,51 @@ function validateOption(
optionValues: Set<string>,
questionNumber: number,
questionType: AskQuestion["type"],
issues: AskValidationIssue[],
collector: IssueCollector,
optionPath: string
): void {
) {
const optionNumber = optionIndex + 1;
const prefix = `Question ${questionNumber}, option ${optionNumber}`;
const optionValue = option.value?.trim();
const optionPreview = option.preview?.trim();
assertNonEmpty(
assertRequired(
optionValue,
`${prefix}: value is required`,
issues,
`${optionPath}.value`
collector,
`${optionPath}.value`,
`${prefix}: value is required`
);
assertUnique(
optionValues,
optionValue,
`${prefix}: duplicate option value "${optionValue}"`,
issues,
`${optionPath}.value`
collector,
`${optionPath}.value`,
`${prefix}: duplicate option value "${optionValue}"`
);
assertNonEmpty(
assertRequired(
option.label?.trim(),
`${prefix}: label is required`,
issues,
`${optionPath}.label`
collector,
`${optionPath}.label`,
`${prefix}: label is required`
);
assertOptionalNonEmpty(
assertOptionalText(
option.description,
`${prefix}: description must not be empty`,
issues,
`${optionPath}.description`
collector,
`${optionPath}.description`,
`${prefix}: description must not be empty`
);
assertOptionalNonEmpty(
assertOptionalText(
option.preview,
`${prefix}: preview must not be empty`,
issues,
`${optionPath}.preview`
collector,
`${optionPath}.preview`,
`${prefix}: preview must not be empty`
);
if (questionType === "preview") {
assertNonEmpty(
option.preview?.trim(),
`${prefix}: preview questions require preview text for every option; add preview text or use type "single" instead`,
issues,
`${optionPath}.preview`
assertRequired(
optionPreview,
collector,
`${optionPath}.preview`,
`${prefix}: preview questions require preview text for every option; add preview text or use type "single" instead`
);
}
}
@@ -188,68 +189,81 @@ function normalizeQuestionType(
return value === "multi" || value === "preview" ? value : "single";
}
function assertValidQuestionType(
function validateQuestionType(
value: AskQuestionInput["type"],
errorMessage: string,
issues: AskValidationIssue[],
questionNumber: number,
collector: IssueCollector,
path: string
): void {
) {
if (
value !== undefined &&
value !== "single" &&
value !== "multi" &&
value !== "preview"
) {
issues.push({ path, message: errorMessage });
collector.add(
path,
`Question ${questionNumber}: invalid type ${JSON.stringify(value)}; expected "single", "multi", or "preview"`
);
}
}
function assertHasItems(
items: unknown[],
errorMessage: string,
issues: AskValidationIssue[],
path: string
): void {
collector: IssueCollector,
path: string,
message: string
) {
if (items.length === 0) {
issues.push({ path, message: errorMessage });
collector.add(path, message);
}
}
function assertNonEmpty(
function assertRequired(
value: string | undefined,
errorMessage: string,
issues: AskValidationIssue[],
path: string
): void {
collector: IssueCollector,
path: string,
message: string
) {
if (!value) {
issues.push({ path, message: errorMessage });
collector.add(path, message);
}
}
function assertOptionalNonEmpty(
function assertOptionalText(
value: string | undefined,
errorMessage: string,
issues: AskValidationIssue[],
path: string
): void {
collector: IssueCollector,
path: string,
message: string
) {
if (value !== undefined && !value.trim()) {
issues.push({ path, message: errorMessage });
collector.add(path, message);
}
}
function assertUnique(
seen: Set<string>,
value: string | undefined,
errorMessage: string,
issues: AskValidationIssue[],
path: string
): void {
collector: IssueCollector,
path: string,
message: string
) {
if (!value) {
return;
}
if (seen.has(value)) {
issues.push({ path, message: errorMessage });
collector.add(path, message);
return;
}
seen.add(value);
}
function createIssueCollector(): IssueCollector {
const issues: AskValidationIssue[] = [];
return {
issues,
add(path, message) {
issues.push({ path, message });
},
};
}
+58 -27
View File
@@ -15,11 +15,23 @@ import type {
} from "../types.ts";
import {
cloneResultAnswer,
getExtraOptionNotes,
hasAnswerNotes,
isAnswerAnswered,
isAnswerEmpty,
isOptionSelected,
isResultAnswerCommitted,
isResultAnswerEmpty,
serializeAnswer,
} from "./answers.ts";
import { getQuestionOptionByValue } from "./selectors.ts";
export type ReviewAnswer = AskResult["answers"][string] & {
extraOptionNotes?: Array<{
label: string;
note: string;
}>;
};
export function toAskResult(state: AskState): AskResult {
const answers = Object.fromEntries(
@@ -29,8 +41,8 @@ export function toAskResult(state: AskState): AskResult {
)
.filter(([, answer]) =>
state.mode === "elaborate"
? isSerializedAnswerCommitted(answer)
: !isSerializedAnswerEmpty(answer)
? isResultAnswerCommitted(answer)
: !isResultAnswerEmpty(answer)
)
);
@@ -72,19 +84,6 @@ export function hasAnswerContent(state: AskState, questionId: string): boolean {
return !!answer && !isAnswerEmpty(answer);
}
function isSerializedAnswerEmpty(
answer: AskResult["answers"][string]
): 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 serializeContinuation(
state: AskState,
answers: AskResult["answers"]
@@ -95,7 +94,7 @@ function serializeContinuation(
for (const question of state.questions) {
const answer = state.answers[question.id];
const hasClarificationNeed = !!(answer?.note || answer?.optionNotes);
const hasClarificationNeed = hasAnswerNotes(answer);
const committedAnswer = answers[question.id];
const answered = !!committedAnswer;
@@ -138,7 +137,7 @@ function serializeElaborationItemsForQuestion(
question: AskState["questions"][number],
answer: AskStateAnswer | undefined
): AskElaborationPayload["items"] {
if (!(answer?.note || answer?.optionNotes)) {
if (!(answer && hasAnswerNotes(answer))) {
return [];
}
@@ -158,9 +157,7 @@ function serializeElaborationItemsForQuestion(
}
for (const [value, note] of Object.entries(answer.optionNotes ?? {})) {
const option = question.options.find(
(candidate) => candidate.value === value
);
const option = getQuestionOptionByValue(question, value);
if (!(option && note)) {
continue;
}
@@ -212,13 +209,47 @@ function toCommittedResultAnswer(
return cloneResultAnswer(serializeAnswer(answer));
}
function isSerializedAnswerCommitted(
answer: AskResult["answers"][string]
): boolean {
export function toReviewAnswer(
question: AskState["questions"][number],
answer: AskStateAnswer | undefined,
showAllNotes: boolean
): ReviewAnswer | undefined {
if (!answer) {
return;
}
const serialized = serializeAnswer(answer);
const hasCommittedAnswer = isResultAnswerCommitted(serialized);
if (!showAllNotes) {
return hasCommittedAnswer ? serialized : undefined;
}
const extraOptionNotes = getExtraOptionNotes({
answer,
questionOptions: question.options,
selectedValues: serialized.values,
});
if (
!(hasCommittedAnswer || serialized.note) &&
extraOptionNotes.length === 0
) {
return;
}
return {
...serialized,
extraOptionNotes:
extraOptionNotes.length > 0 ? extraOptionNotes : undefined,
};
}
export function shouldRenderAnswersIndividually(answer: ReviewAnswer): boolean {
if (!answer.labels.length) {
return false;
}
return (
answer.values.length > 0 ||
answer.labels.length > 0 ||
answer.indices.length > 0 ||
!!answer.customText
answer.labels.length > 1 ||
Boolean(answer.optionNotes && Object.keys(answer.optionNotes).length > 0)
);
}
+14 -10
View File
@@ -7,6 +7,12 @@ import type {
} from "../types.ts";
import { isAnswerAnswered } from "./answers.ts";
const CUSTOM_OPTION: AskDisplayOption = {
value: OTHER_OPTION_VALUE,
label: OTHER_OPTION_LABEL,
isCustomOption: true,
};
export function getCurrentQuestion(state: AskState): AskQuestion | undefined {
return state.questions[state.activeTabIndex];
}
@@ -28,16 +34,7 @@ export function getRenderableOptions(
if (!question) {
return [];
}
const options: AskDisplayOption[] = question.options.map((option) => ({
...option,
}));
options.push({
value: OTHER_OPTION_VALUE,
label: OTHER_OPTION_LABEL,
isCustomOption: true,
});
return options;
return [...question.options, CUSTOM_OPTION];
}
export function getCurrentOption(
@@ -48,6 +45,13 @@ export function getCurrentOption(
];
}
export function getQuestionOptionByValue(
question: AskQuestion,
optionValue: string
) {
return question.options.find((option) => option.value === optionValue);
}
export function getAnswer(
state: AskState,
questionId: string
+76 -162
View File
@@ -9,6 +9,13 @@ import {
setSingleSelection,
toggleSelection,
} from "./answers.ts";
import {
cancelFlow as cancelFlowBase,
createInitialState as createInitialStateBase,
dismissFlow as dismissFlowBase,
moveOption as moveOptionBase,
moveTab as moveTabBase,
} from "./navigation.ts";
import {
getAnswer,
getCurrentOption,
@@ -19,7 +26,6 @@ import {
} from "./selectors.ts";
import {
inputView,
isEditingView,
navigateView,
optionNoteView,
questionNoteView,
@@ -32,26 +38,31 @@ export function createInitialState(params: {
title?: string;
questions: AskState["questions"];
}): AskState {
return {
title: params.title,
questions: params.questions,
activeTabIndex: 0,
activeOptionIndex: 0,
activeSubmitActionIndex: 0,
view: navigateView(),
answers: {},
completed: false,
cancelled: false,
mode: "submit",
};
return createInitialStateBase(params);
}
export function moveTab(state: AskState, delta: number): AskState {
return moveTabBase(state, delta);
}
export function moveOption(state: AskState, delta: number): AskState {
return moveOptionBase(state, delta);
}
export function cancelFlow(state: AskState): AskState {
return cancelFlowBase(state);
}
export function dismissFlow(state: AskState): AskState {
return dismissFlowBase(state);
}
export function reduceAskState(state: AskState, action: AskAction): AskState {
switch (action.type) {
case "MOVE_TAB":
return moveTabBy(state, action.delta);
return moveTabBase(state, action.delta);
case "MOVE_OPTION":
return moveOptionBy(state, action.delta);
return moveOptionBase(state, action.delta);
case "OPEN_INPUT":
return setView(state, inputView(action.questionId));
case "OPEN_QUESTION_NOTE":
@@ -72,26 +83,12 @@ export function reduceAskState(state: AskState, action: AskAction): AskState {
case "SAVE_NOTE":
return saveNoteValue(state, action.value);
case "CANCEL":
return cancelFlow(state);
return cancelFlowBase(state);
default:
return state;
}
}
export function moveTab(state: AskState, delta: number): AskState {
return reduceAskState(state, {
type: "MOVE_TAB",
delta: delta < 0 ? -1 : 1,
});
}
export function moveOption(state: AskState, delta: number): AskState {
return reduceAskState(state, {
type: "MOVE_OPTION",
delta: delta < 0 ? -1 : 1,
});
}
export function enterInputMode(state: AskState, questionId: string): AskState {
return reduceAskState(state, { type: "OPEN_INPUT", questionId });
}
@@ -116,37 +113,7 @@ export function enterOptionNoteMode(
}
export function toggleCurrentOption(state: AskState): AskState {
const question = getCurrentQuestion(state);
if (!question) {
return state;
}
const option = getCurrentOption(state);
if (!option) {
return state;
}
if (option.isCustomOption) {
return setView(state, inputView(question.id));
}
if (question.type === "multi") {
return updateAnswer(state, question.id, (answer) =>
toggleSelection(answer, option, state.activeOptionIndex)
);
}
return updateAnswer(state, question.id, (answer) => {
const isSelected = answer.selected.some(
(selection) => selection.value === option.value
);
if (isSelected) {
return {
...answer,
selected: [],
};
}
return setSingleSelection(answer, option, state.activeOptionIndex);
});
return activateCurrentOption(state, "toggle");
}
export function toggleCurrentMultiOption(state: AskState): AskState {
@@ -155,36 +122,9 @@ export function toggleCurrentMultiOption(state: AskState): AskState {
export function confirmCurrentSelection(state: AskState): AskState {
if (isSubmitTab(state)) {
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 };
return completeSubmitAction(state);
}
const question = getCurrentQuestion(state);
const option = getCurrentOption(state);
if (!(question && option)) {
return state;
}
if (question.type === "multi") {
if (option.isCustomOption) {
return setView(state, inputView(question.id));
}
return advanceToNextTab(state);
}
if (option.isCustomOption) {
return setView(state, inputView(question.id));
}
const nextState = updateAnswer(state, question.id, (answer) =>
setSingleSelection(answer, option, state.activeOptionIndex)
);
return advanceToNextTab(nextState);
return activateCurrentOption(state, "confirm");
}
export function applyNumberShortcut(state: AskState, digit: number): AskState {
@@ -203,31 +143,13 @@ export function applyNumberShortcut(state: AskState, digit: number): AskState {
}
const question = getCurrentQuestion(state);
if (!question) {
return state;
}
const index = digit - 1;
const option = getRenderableOptions(question)[index];
if (!option) {
const option = question ? getRenderableOptions(question)[index] : undefined;
if (!(question && option)) {
return state;
}
const selectedState = { ...state, activeOptionIndex: index };
if (option.isCustomOption) {
return setView(selectedState, inputView(question.id));
}
if (question.type === "multi") {
return updateAnswer(selectedState, question.id, (answer) =>
toggleSelection(answer, option, index)
);
}
const nextState = updateAnswer(selectedState, question.id, (answer) =>
setSingleSelection(answer, option, index)
);
return advanceToNextTab(nextState);
return activateCurrentOption({ ...state, activeOptionIndex: index }, "digit");
}
export function saveCustomAnswer(state: AskState, rawValue: string): AskState {
@@ -245,20 +167,52 @@ export function saveNote(state: AskState, rawValue: string): AskState {
return saveNoteValue(state, rawValue);
}
export function cancelFlow(state: AskState): AskState {
if (isEditingView(state)) {
return exitEditingView(state);
function completeSubmitAction(state: AskState): AskState {
if (state.activeSubmitActionIndex === 2) {
return { ...state, cancelled: true, completed: true };
}
return {
...state,
cancelled: true,
completed: true,
};
if (state.activeSubmitActionIndex === 1) {
return { ...state, mode: "elaborate", completed: true };
}
return { ...state, mode: "submit", completed: true };
}
export function dismissFlow(state: AskState): AskState {
const nextState = cancelFlow(state);
return nextState.completed ? nextState : cancelFlow(nextState);
function activateCurrentOption(
state: AskState,
trigger: "toggle" | "confirm" | "digit"
): AskState {
const question = getCurrentQuestion(state);
const option = getCurrentOption(state);
if (!(question && option)) {
return state;
}
if (option.isCustomOption) {
return setView(state, inputView(question.id));
}
if (question.type === "multi") {
if (trigger === "confirm") {
return advanceToNextTab(state);
}
return updateAnswer(state, question.id, (answer) =>
toggleSelection(answer, option, state.activeOptionIndex)
);
}
const nextState = updateAnswer(state, question.id, (answer) => {
if (trigger === "toggle") {
const isSelected = answer.selected.some(
(selection) => selection.value === option.value
);
if (isSelected) {
return {
...answer,
selected: [],
};
}
}
return setSingleSelection(answer, option, state.activeOptionIndex);
});
return trigger === "toggle" ? nextState : advanceToNextTab(nextState);
}
function saveInputValue(
@@ -308,42 +262,6 @@ function saveNoteValue(state: AskState, rawValue: string): AskState {
return nextState;
}
function moveTabBy(state: AskState, delta: 1 | -1): AskState {
const totalTabs = state.questions.length + 1;
const activeTabIndex = (state.activeTabIndex + delta + totalTabs) % totalTabs;
return {
...state,
activeTabIndex,
activeOptionIndex: 0,
activeSubmitActionIndex: 0,
view:
activeTabIndex >= state.questions.length ? submitView() : navigateView(),
};
}
function moveOptionBy(state: AskState, delta: 1 | -1): AskState {
if (isSubmitTab(state)) {
return {
...state,
activeSubmitActionIndex: clamp(
state.activeSubmitActionIndex + delta,
0,
SUBMIT_ACTION_COUNT - 1
),
};
}
const options = getRenderableOptions(getCurrentQuestion(state));
return {
...state,
activeOptionIndex: clamp(
state.activeOptionIndex + delta,
0,
Math.max(0, options.length - 1)
),
};
}
function setView(state: AskState, view: AskState["view"]): AskState {
return {
...state,
@@ -389,7 +307,3 @@ function updateAnswer(
answers,
};
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
+64 -124
View File
@@ -1,13 +1,16 @@
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
import { Editor, type EditorTheme } from "@mariozechner/pi-tui";
import { createInitialState } from "../state/create.ts";
import {
getEditorDraft,
saveEditorDraft,
submitEditorDraft,
syncStateToSelection,
} from "../state/editor.ts";
import { toAskResult } from "../state/result.ts";
import {
getAnswer,
getCurrentOption,
getCurrentQuestion,
getOptionNote,
getQuestionNote,
isSubmitTab,
} from "../state/selectors.ts";
import {
@@ -15,14 +18,10 @@ import {
cancelFlow,
confirmCurrentSelection,
dismissFlow,
enterInputMode,
enterOptionNoteMode,
enterQuestionNoteMode,
moveOption,
moveTab,
saveCustomAnswer,
saveNote,
submitCustomAnswer,
toggleCurrentMultiOption,
} from "../state/transitions.ts";
import { isEditingView } from "../state/view.ts";
@@ -79,7 +78,7 @@ function createAskFlowController(
};
controller.editor.onSubmit = (value) => submitEditor(controller, value);
syncInputModeWithSelection(controller);
syncSelection(controller);
return {
render: (width: number) => renderController(controller, width),
@@ -123,27 +122,19 @@ function handleEditingCommand(
data: string
) {
if (command.kind === "dismiss") {
commitNavigation(controller, dismissFlow(controller.state));
maybeFinish(controller);
commitState(controller, dismissFlow(controller.state), { finish: true });
return;
}
if (command.kind === "editMoveTab") {
controller.state = saveEditorAndMoveTab(controller, command.delta);
syncInputModeWithSelection(controller);
refresh(controller);
commitSavedEditorNavigation(controller, moveTab, command.delta);
return;
}
if (command.kind === "editMoveOption") {
controller.state = saveEditorAndMoveOption(controller, command.delta);
syncInputModeWithSelection(controller);
refresh(controller);
commitSavedEditorNavigation(controller, moveOption, command.delta);
return;
}
if (command.kind === "editClose") {
controller.state = saveEditorWithoutAdvancing(controller);
controller.suppressAutoInputForSelection =
controller.state.view.kind !== "input";
refresh(controller);
closeEditor(controller);
return;
}
if (command.kind === "delegateToEditor") {
@@ -158,10 +149,10 @@ function handleNavigationCommand(
) {
switch (command.kind) {
case "moveTab":
navigateTabs(controller, command.delta);
commitState(controller, moveTab(controller.state, command.delta));
return;
case "moveOption":
navigateOptions(controller, command.delta);
commitState(controller, moveOption(controller.state, command.delta));
return;
case "toggleMulti":
handleToggleCurrentOption(controller);
@@ -173,22 +164,21 @@ function handleNavigationCommand(
openOptionNote(controller);
return;
case "confirm":
commitNavigation(controller, confirmCurrentSelection(controller.state));
maybeFinish(controller);
commitState(controller, confirmCurrentSelection(controller.state), {
finish: true,
});
return;
case "cancel":
commitNavigation(controller, cancelFlow(controller.state));
maybeFinish(controller);
commitState(controller, cancelFlow(controller.state), { finish: true });
return;
case "numberShortcut":
commitNavigation(
commitState(
controller,
applyNumberShortcut(controller.state, command.digit)
);
return;
case "dismiss":
commitNavigation(controller, dismissFlow(controller.state));
maybeFinish(controller);
commitState(controller, dismissFlow(controller.state), { finish: true });
return;
case "ignore":
case "editMoveTab":
@@ -201,27 +191,12 @@ function handleNavigationCommand(
}
}
function navigateTabs(controller: AskFlowController, delta: 1 | -1) {
controller.suppressAutoInputForSelection = false;
controller.state = moveTab(controller.state, delta);
syncInputModeWithSelection(controller);
refresh(controller);
}
function navigateOptions(controller: AskFlowController, delta: 1 | -1) {
controller.suppressAutoInputForSelection = false;
controller.state = moveOption(controller.state, delta);
syncInputModeWithSelection(controller);
refresh(controller);
}
function handleToggleCurrentOption(controller: AskFlowController) {
const question = getCurrentQuestion(controller.state);
if (!question) {
return;
}
controller.suppressAutoInputForSelection = false;
commitNavigation(controller, toggleCurrentMultiOption(controller.state));
commitState(controller, toggleCurrentMultiOption(controller.state));
}
function openQuestionNote(controller: AskFlowController) {
@@ -229,9 +204,13 @@ function openQuestionNote(controller: AskFlowController) {
if (!question || isSubmitTab(controller.state)) {
return;
}
controller.state = enterQuestionNoteMode(controller.state, question.id);
hydrateEditorForCurrentView(controller);
refresh(controller);
commitState(
controller,
enterQuestionNoteMode(controller.state, question.id),
{
syncSelection: false,
}
);
}
function openOptionNote(controller: AskFlowController) {
@@ -244,38 +223,51 @@ function openOptionNote(controller: AskFlowController) {
) {
return;
}
controller.state = enterOptionNoteMode(
controller.state,
question.id,
option.value
commitState(
controller,
enterOptionNoteMode(controller.state, question.id, option.value),
{ syncSelection: false }
);
hydrateEditorForCurrentView(controller);
refresh(controller);
}
function commitNavigation(controller: AskFlowController, nextState: AskState) {
function commitState(
controller: AskFlowController,
nextState: AskState,
options: { finish?: boolean; syncSelection?: boolean } = {}
) {
controller.suppressAutoInputForSelection = false;
controller.state = nextState;
hydrateEditorForCurrentView(controller);
if (options.syncSelection !== false) {
syncSelection(controller);
}
hydrateEditor(controller);
refresh(controller);
if (options.finish) {
maybeFinish(controller);
}
}
function submitEditor(controller: AskFlowController, value: string) {
controller.suppressAutoInputForSelection = false;
controller.state = submitEditorValue(controller.state, value);
controller.state = submitEditorDraft(controller.state, value);
controller.editor.setText("");
refresh(controller);
maybeFinish(controller);
}
function submitEditorValue(state: AskState, value: string): AskState {
if (state.view.kind === "input") {
return submitCustomAnswer(state, value);
}
if (state.view.kind === "note") {
return saveNote(state, value);
}
return state;
function commitSavedEditorNavigation(
controller: AskFlowController,
navigate: (state: AskState, delta: 1 | -1) => AskState,
delta: 1 | -1
) {
commitState(controller, navigate(saveEditorState(controller), delta));
}
function closeEditor(controller: AskFlowController) {
const nextState = saveEditorState(controller);
controller.suppressAutoInputForSelection = nextState.view.kind !== "input";
controller.state = nextState;
refresh(controller);
}
function refresh(controller: AskFlowController) {
@@ -288,57 +280,21 @@ function maybeFinish(controller: AskFlowController) {
}
}
function hydrateEditorForCurrentView(controller: AskFlowController) {
controller.editor.setText(getEditorTextForCurrentView(controller.state));
function hydrateEditor(controller: AskFlowController) {
controller.editor.setText(getEditorDraft(controller.state));
}
function syncInputModeWithSelection(controller: AskFlowController) {
if (
isEditingView(controller.state) ||
isSubmitTab(controller.state) ||
controller.suppressAutoInputForSelection
) {
function syncSelection(controller: AskFlowController) {
if (controller.suppressAutoInputForSelection) {
return;
}
const question = getCurrentQuestion(controller.state);
const option = getCurrentOption(controller.state);
if (!(question && option?.isCustomOption)) {
return;
}
controller.state = enterInputMode(controller.state, question.id);
hydrateEditorForCurrentView(controller);
controller.state = syncStateToSelection(controller.state);
}
function saveEditorWithoutAdvancing(controller: AskFlowController): AskState {
function saveEditorState(controller: AskFlowController): AskState {
const text = controller.editor.getText();
controller.editor.setText("");
if (controller.state.view.kind === "input") {
return saveCustomAnswer(controller.state, text);
}
if (controller.state.view.kind === "note") {
return saveNote(controller.state, text);
}
return controller.state;
}
function saveEditorAndMoveTab(
controller: AskFlowController,
delta: 1 | -1
): AskState {
const nextState = saveEditorWithoutAdvancing(controller);
controller.suppressAutoInputForSelection = false;
return moveTab(nextState, delta);
}
function saveEditorAndMoveOption(
controller: AskFlowController,
delta: 1 | -1
): AskState {
const nextState = saveEditorWithoutAdvancing(controller);
controller.suppressAutoInputForSelection = false;
return moveOption(nextState, delta);
return saveEditorDraft(controller.state, text);
}
function createEditor(tui: Tui, theme: Theme, cwd: string) {
@@ -359,19 +315,3 @@ function createEditorTheme(theme: Theme): EditorTheme {
},
};
}
function getEditorTextForCurrentView(state: AskState): string {
if (state.view.kind === "input") {
return getAnswer(state, state.view.questionId)?.customText ?? "";
}
if (state.view.kind === "note") {
if (state.view.optionValue) {
return (
getOptionNote(state, state.view.questionId, state.view.optionValue) ??
""
);
}
return getQuestionNote(state, state.view.questionId) ?? "";
}
return "";
}
+25 -4
View File
@@ -1,6 +1,7 @@
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
import { NO_PREVIEW_TEXT } from "../constants.ts";
import { clamp } from "../math.ts";
import { wrapText } from "../text.ts";
import { UI_DIMENSIONS, UI_TEXT } from "./constants.ts";
@@ -343,6 +344,30 @@ export function getSavedNotePrefixes(
};
}
export function pushSavedNote(args: {
lines: string[];
note: string;
width: number;
theme: Theme;
indent: string;
label?: string;
}) {
const { lines, note, width, theme, indent, label } = args;
const { prefix, continuationPrefix } = getSavedNotePrefixes(theme, {
indent,
label,
});
pushWrappedText(
lines,
note,
width,
theme,
"muted",
prefix,
continuationPrefix
);
}
export function renderFooterText(
mode: "input" | "note" | "submit" | "multi" | "default"
) {
@@ -363,7 +388,3 @@ export function renderFooterText(
function padToVisibleWidth(text: string, width: number): string {
return text + " ".repeat(Math.max(0, width - visibleWidth(text)));
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
+155 -357
View File
@@ -1,189 +1,141 @@
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
import { isOptionSelected } from "../state/answers.ts";
import { UI_DIMENSIONS } from "./constants.ts";
import {
getAnswer,
getOptionNote,
getQuestionNote,
isInputOpenForQuestion,
isOptionNoteOpen,
isQuestionNoteOpen,
} from "../state/selectors.ts";
import type { AskDisplayOption } from "../types.ts";
import { UI_DIMENSIONS, UI_TEXT } from "./constants.ts";
import {
getSavedNotePrefixes,
measurePreviewLeftWidth,
mergeColumns,
pushSavedNote,
pushWrappedText,
renderEditorBlock,
renderPreviewPaneContent,
} from "./render-helpers.ts";
import type {
OptionDetailRenderContext,
QuestionRenderContext,
Theme,
} from "./render-types.ts";
import type { QuestionRenderContext, Theme } from "./render-types.ts";
import {
buildQuestionScreenModel,
type OptionDetailModel,
type OptionRowModel,
} from "./view-models/question.ts";
export function renderQuestionScreen(context: QuestionRenderContext) {
const { lines, question, theme, width } = context;
pushWrappedText(lines, question.prompt, width, theme, "text", " ", " ");
const renderedQuestionNote = renderQuestionNote(context);
if (!renderedQuestionNote) {
lines.push("");
}
const model = buildQuestionScreenModel(context);
if (question.type === "preview") {
renderPreviewQuestion(context);
pushWrappedText(lines, question.prompt, width, theme, "text", " ", " ");
renderQuestionNote(lines, model.questionNote, context);
if (model.mode === "preview") {
renderPreviewQuestion(context, model);
return;
}
renderStandardQuestion(context);
for (const row of model.rows) {
renderStandardOption(lines, row, context);
}
}
function renderQuestionNote(context: QuestionRenderContext): boolean {
const { lines, state, question, theme, width, editor } = context;
if (isQuestionNoteOpen(state, question.id)) {
renderQuestionNoteEditor(
lines,
editor,
width,
theme,
UI_TEXT.editorPlaceholderNote
);
function renderQuestionNote(
lines: string[],
questionNote: ReturnType<typeof buildQuestionScreenModel>["questionNote"],
context: QuestionRenderContext
) {
if (!questionNote) {
lines.push("");
return true;
return;
}
const existingNote = getQuestionNote(state, question.id);
if (!existingNote) {
return false;
if (questionNote.kind === "editor") {
renderEditorWithIndent({
lines,
editor: context.editor,
width: context.width,
theme: context.theme,
indent: " ",
padding: UI_DIMENSIONS.editorContentPadding,
placeholder: questionNote.placeholder,
});
lines.push("");
return;
}
const { prefix, continuationPrefix } = getSavedNotePrefixes(theme, {
pushSavedNote({
lines,
note: questionNote.text,
width: context.width,
theme: context.theme,
indent: " ",
});
pushWrappedText(
lines,
existingNote,
width,
theme,
"muted",
prefix,
continuationPrefix
);
lines.push("");
return true;
}
function renderStandardQuestion(context: QuestionRenderContext) {
const { options } = context;
for (let index = 0; index < options.length; index++) {
renderStandardOption(context, options[index], index);
}
}
function renderStandardOption(
context: QuestionRenderContext,
option: AskDisplayOption,
index: number
lines: string[],
row: OptionRowModel,
context: QuestionRenderContext
) {
const { lines, state, question, theme, width } = context;
const answer = getAnswer(state, question.id);
const selected = index === state.activeOptionIndex;
const isAnsweredOption = option.isCustomOption
? !!answer?.customText
: isOptionSelected(answer, option.value);
const pointer = selected ? "❯ " : " ";
const prefix = getOptionPrefix(question.type, option, isAnsweredOption);
const optionColor = getOptionColor(isAnsweredOption, selected);
if (
option.isCustomOption &&
selected &&
isInputOpenForQuestion(state, question.id)
) {
renderInteractiveCustomOption(context, index, pointer, prefix, optionColor);
if (row.isCustom && row.detail?.kind === "editor") {
renderInteractiveCustomOption(lines, row, context);
return;
}
pushWrappedText(
lines,
`${index + 1}. ${prefix}${option.label}`,
width,
theme,
optionColor,
pointer,
" ".repeat(visibleWidth(pointer))
`${row.index + 1}. ${row.prefix}${row.label}`,
context.width,
context.theme,
row.color,
row.pointer,
" ".repeat(visibleWidth(row.pointer))
);
renderOptionMeta({
...context,
questionId: question.id,
answer,
option,
selected,
renderOptionDescription(lines, row.description, context.width, context.theme);
renderOptionDetail(lines, row.detail, context, {
suppressLeadingGap: !!row.description,
});
}
function renderPreviewQuestion(context: QuestionRenderContext) {
const { lines, state, question, options, theme, width } = context;
const add = (text = "") => lines.push(truncateToWidth(text, width));
const selectedOption = options[state.activeOptionIndex];
const answer = getAnswer(state, question.id);
function renderPreviewQuestion(
context: QuestionRenderContext,
model: ReturnType<typeof buildQuestionScreenModel>
) {
if (model.mode !== "preview") {
return;
}
if (selectedOption?.isCustomOption) {
renderPreviewOptionList(state, options, theme, width, answer).forEach(add);
} else if (width >= UI_DIMENSIONS.previewWideMinWidth && options.length > 0) {
const { lines, width, theme } = context;
const add = (text = "") => lines.push(truncateToWidth(text, width));
if (model.previewLayout === "custom") {
renderPreviewOptionList(model.rows, theme, width).forEach(add);
} else if (model.previewLayout === "wide") {
renderWidePreviewLayout(
add,
state,
options,
model.rows,
theme,
width,
answer,
selectedOption
model.selectedOption
);
} else {
renderStackedPreviewLayout(
add,
state,
options,
model.rows,
theme,
width,
answer,
selectedOption
model.selectedOption
);
}
renderOptionEditorOrNote({
...context,
questionId: question.id,
answer,
option: selectedOption,
selected: true,
// Custom preview options already render their inline editor/content above.
withGap: !selectedOption?.isCustomOption,
});
renderOptionDetail(lines, model.selectedOptionDetail, context);
}
function renderWidePreviewLayout(
add: (text?: string) => void,
state: QuestionRenderContext["state"],
options: QuestionRenderContext["options"],
rows: OptionRowModel[],
theme: Theme,
width: number,
answer: ReturnType<typeof getAnswer>,
selectedOption: AskDisplayOption | undefined
selectedOption: ReturnType<typeof buildQuestionScreenModel>["selectedOption"]
) {
const leftWidth = measurePreviewLeftWidth(options, width);
const leftWidth = measurePreviewLeftWidth(rows, width);
const rightWidth = Math.max(
UI_DIMENSIONS.previewMinRightWidth,
width - leftWidth - 2
);
const leftPane = renderPreviewOptionList(
state,
options,
theme,
leftWidth,
answer
);
const leftPane = renderPreviewOptionList(rows, theme, leftWidth);
const rightPane = renderPreviewPaneContent(selectedOption, theme, rightWidth);
for (const line of mergeColumns(leftPane, rightPane, leftWidth, width)) {
add(line);
@@ -192,294 +144,140 @@ function renderWidePreviewLayout(
function renderStackedPreviewLayout(
add: (text?: string) => void,
state: QuestionRenderContext["state"],
options: QuestionRenderContext["options"],
rows: OptionRowModel[],
theme: Theme,
width: number,
answer: ReturnType<typeof getAnswer>,
selectedOption: AskDisplayOption | undefined
selectedOption: ReturnType<typeof buildQuestionScreenModel>["selectedOption"]
) {
renderPreviewOptionList(state, options, theme, width, answer).forEach(add);
renderPreviewOptionList(rows, theme, width).forEach(add);
add("");
renderPreviewPaneContent(selectedOption, theme, width).forEach(add);
}
function renderPreviewOptionList(
state: QuestionRenderContext["state"],
options: QuestionRenderContext["options"],
rows: OptionRowModel[],
theme: Theme,
width: number,
answer: ReturnType<typeof getAnswer>
width: number
): string[] {
const lines: string[] = [];
for (let index = 0; index < options.length; index++) {
const option = options[index];
const selected = index === state.activeOptionIndex;
const isAnsweredOption = option.isCustomOption
? !!answer?.customText
: isOptionSelected(answer, option.value);
for (const row of rows) {
pushWrappedText(
lines,
`${index + 1}. ${option.label}`,
`${row.index + 1}. ${row.label}`,
width,
theme,
getOptionColor(isAnsweredOption, selected),
selected ? "❯ " : " ",
row.color,
row.pointer,
" "
);
if (option.description) {
pushWrappedText(
lines,
option.description,
width,
theme,
"muted",
" ",
" "
);
}
renderOptionDescription(lines, row.description, width, theme);
}
return lines;
}
function renderOptionMeta(
context: QuestionRenderContext & {
questionId: string;
answer: ReturnType<typeof getAnswer>;
option: AskDisplayOption;
selected: boolean;
}
function renderOptionDetail(
lines: string[],
detail: OptionDetailModel | undefined,
context: QuestionRenderContext,
options: { suppressLeadingGap?: boolean } = {}
) {
const { lines, option, theme, width } = context;
if (option.description) {
pushWrappedText(
lines,
option.description,
width,
theme,
"muted",
" ",
" "
);
}
renderOptionEditorOrNote(context);
}
function renderOptionEditorOrNote(context: OptionDetailRenderContext) {
const { lines, option, withGap } = context;
if (!option) {
if (!detail) {
return;
}
const renderState = getOptionDetailRenderState(context);
if (withGap && hasOptionDetailContent(renderState)) {
if (detail.withGap && !options.suppressLeadingGap) {
lines.push("");
}
if (renderOptionEditor(context, renderState)) {
if (detail.kind === "editor") {
renderEditorWithIndent({
lines,
editor: context.editor,
width: context.width,
theme: context.theme,
indent: " ",
padding: UI_DIMENSIONS.editorIndentedPadding,
placeholder: detail.placeholder,
});
return;
}
if (renderOptionContent(context, renderState)) {
if (detail.kind === "saved-note") {
pushSavedNote({
lines,
note: detail.text,
width: context.width,
theme: context.theme,
indent: " ",
});
return;
}
renderSavedOptionNote(context, renderState.optionNote);
}
function getOptionDetailRenderState(context: OptionDetailRenderContext) {
const { state, questionId, answer, option, selected } = context;
const inputOpen =
option?.isCustomOption &&
selected &&
isInputOpenForQuestion(state, questionId);
const noteOpen =
!!option &&
!option.isCustomOption &&
isOptionNoteOpen(state, questionId, option.value);
const optionNote =
option && !option.isCustomOption
? getOptionNote(state, questionId, option.value)
: undefined;
const customText = option?.isCustomOption ? answer?.customText : undefined;
return {
inputOpen,
noteOpen,
optionNote,
customText,
};
}
function hasOptionDetailContent(
renderState: ReturnType<typeof getOptionDetailRenderState>
) {
return !!(
renderState.noteOpen ||
renderState.inputOpen ||
renderState.customText ||
renderState.optionNote
);
}
function renderOptionEditor(
context: OptionDetailRenderContext,
renderState: ReturnType<typeof getOptionDetailRenderState>
): boolean {
const { lines, editor, width, theme } = context;
if (renderState.noteOpen) {
renderIndentedEditor(
lines,
editor,
width,
theme,
UI_TEXT.editorPlaceholderNote
);
return true;
}
if (renderState.inputOpen) {
renderIndentedEditor(
lines,
editor,
width,
theme,
UI_TEXT.editorPlaceholderInput
);
return true;
}
return false;
}
function renderOptionContent(
context: OptionDetailRenderContext,
renderState: ReturnType<typeof getOptionDetailRenderState>
): boolean {
const { lines, option, theme, width } = context;
if (!(option?.isCustomOption && renderState.customText)) {
return false;
}
pushWrappedText(
lines,
renderState.customText,
width,
theme,
detail.text,
context.width,
context.theme,
"muted",
" ",
" "
);
return true;
}
function renderSavedOptionNote(
context: OptionDetailRenderContext,
optionNote: string | undefined
) {
if (!optionNote) {
return;
}
const { lines, theme, width } = context;
const { prefix, continuationPrefix } = getSavedNotePrefixes(theme, {
indent: " ",
});
pushWrappedText(
lines,
optionNote,
width,
theme,
"muted",
prefix,
continuationPrefix
);
}
function renderQuestionNoteEditor(
lines: string[],
editor: QuestionRenderContext["editor"],
width: number,
theme: Theme,
placeholder: string
) {
function renderEditorWithIndent(args: {
lines: string[];
editor: QuestionRenderContext["editor"];
width: number;
theme: Theme;
indent: string;
padding: number;
placeholder: string;
}) {
const { lines, editor, width, theme, indent, padding, placeholder } = args;
renderEditorBlock({
lines,
editorLines: editor.render(
Math.max(
UI_DIMENSIONS.editorMinWidth,
width - UI_DIMENSIONS.editorContentPadding
)
Math.max(UI_DIMENSIONS.editorMinWidth, width - padding)
),
width,
theme,
indent: " ",
availableWidth: width - 1,
indent,
availableWidth: width - visibleWidth(indent),
placeholder,
isEmpty: editor.getText().length === 0,
});
}
function renderIndentedEditor(
lines: string[],
editor: QuestionRenderContext["editor"],
width: number,
theme: Theme,
placeholder: string
) {
renderEditorBlock({
lines,
editorLines: editor.render(
Math.max(
UI_DIMENSIONS.editorMinWidth,
width - UI_DIMENSIONS.editorIndentedPadding
)
),
width,
theme,
indent: " ",
availableWidth: width - 5,
placeholder,
isEmpty: editor.getText().length === 0,
});
}
function getOptionPrefix(
questionType: QuestionRenderContext["question"]["type"],
option: AskDisplayOption,
isAnsweredOption: boolean
): string {
if (questionType !== "multi" || option.isCustomOption) {
return "";
}
return `[${isAnsweredOption ? "x" : " "}] `;
}
function renderInteractiveCustomOption(
context: QuestionRenderContext,
index: number,
pointer: string,
prefix: string,
optionColor: ReturnType<typeof getOptionColor>
lines: string[],
row: OptionRowModel,
context: QuestionRenderContext
) {
const { lines, editor, theme, width } = context;
pushWrappedText(
lines,
`${index + 1}. ${prefix}Type your own`,
width,
theme,
optionColor,
pointer,
" ".repeat(visibleWidth(pointer))
`${row.index + 1}. ${row.prefix}${row.label}`,
context.width,
context.theme,
row.color,
row.pointer,
" ".repeat(visibleWidth(row.pointer))
);
renderIndentedEditor(
renderEditorWithIndent({
lines,
editor,
width,
theme,
UI_TEXT.editorPlaceholderInput
);
editor: context.editor,
width: context.width,
theme: context.theme,
indent: " ",
padding: UI_DIMENSIONS.editorIndentedPadding,
placeholder:
row.detail?.kind === "editor" ? row.detail.placeholder : "Type answer...",
});
}
function getOptionColor(isAnsweredOption: boolean, selected: boolean) {
if (isAnsweredOption) {
return "success";
function renderOptionDescription(
lines: string[],
description: string | undefined,
width: number,
theme: Theme
) {
if (!description) {
return;
}
if (selected) {
return "accent";
}
return "text";
pushWrappedText(lines, description, width, theme, "muted", " ", " ");
}
+62 -222
View File
@@ -1,21 +1,13 @@
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
import { SUBMIT_CHOICES } from "../constants.ts";
import { serializeAnswer } from "../state/answers.ts";
import type { AskResult, AskState, AskStateAnswer } from "../types.ts";
import { UI_DIMENSIONS, UI_TEXT } from "./constants.ts";
import { truncateToWidth } from "@mariozechner/pi-tui";
import type { AskState } from "../types.ts";
import { UI_TEXT } from "./constants.ts";
import {
getSavedNotePrefixes,
mergeColumns,
pushSavedNote,
pushWrappedText,
} from "./render-helpers.ts";
import type { Theme } from "./render-types.ts";
type ReviewAnswer = AskResult["answers"][string] & {
extraOptionNotes?: Array<{
label: string;
note: string;
}>;
};
import { buildReviewScreenModel } from "./view-models/review.ts";
export function renderSubmitScreen(
lines: string[],
@@ -23,22 +15,19 @@ export function renderSubmitScreen(
theme: Theme,
width: number
) {
const showAllNotes = state.activeSubmitActionIndex === 1;
const actionColumnWidth = getSubmitActionColumnWidth();
if (shouldUseWideSubmitLayout(width, actionColumnWidth)) {
const rightWidth = Math.max(1, width - actionColumnWidth - 2);
const reviewLines = renderSubmitReviewLines(
state,
const model = buildReviewScreenModel(state, width);
if (model.layout === "wide") {
const rightWidth = Math.max(1, width - model.actionColumnWidth - 2);
const reviewLines = renderSubmitReviewLines(model, theme, rightWidth);
const actionLines = renderSubmitActions(
model,
theme,
rightWidth,
showAllNotes
model.actionColumnWidth
);
const actionLines = renderSubmitActions(state, theme, actionColumnWidth);
for (const line of mergeColumns(
actionLines,
reviewLines,
actionColumnWidth,
model.actionColumnWidth,
width
)) {
lines.push(line);
@@ -46,38 +35,25 @@ export function renderSubmitScreen(
return;
}
const reviewLines = renderSubmitReviewLines(
state,
theme,
width,
showAllNotes
);
const actionLines = renderSubmitActions(state, theme, width);
const reviewLines = renderSubmitReviewLines(model, theme, width);
const actionLines = renderSubmitActions(model, theme, width);
lines.push(...reviewLines);
lines.push("");
lines.push(...actionLines);
}
function renderSubmitReviewLines(
state: AskState,
model: ReturnType<typeof buildReviewScreenModel>,
theme: Theme,
width: number,
showAllNotes: boolean
width: number
): string[] {
const lines: string[] = [];
pushWrappedText(lines, UI_TEXT.reviewTitle, width, theme, "accent", " ", " ");
lines.push("");
for (let index = 0; index < state.questions.length; index++) {
const question = state.questions[index];
renderSubmittedQuestion(
lines,
question,
toReviewAnswer(question, state.answers[question.id], showAllNotes),
theme,
width
);
if (index < state.questions.length - 1) {
for (const [index, question] of model.questions.entries()) {
renderReviewQuestion(lines, question, theme, width);
if (index < model.questions.length - 1) {
lines.push("");
}
}
@@ -85,225 +61,89 @@ function renderSubmitReviewLines(
return lines;
}
function getSubmitActionColumnWidth(): number {
return Math.max(
...SUBMIT_CHOICES.map((choice, index) =>
visibleWidth(`❯ ${index + 1}. ${choice}`)
)
);
}
function shouldUseWideSubmitLayout(
width: number,
actionColumnWidth: number
): boolean {
return (
width >= UI_DIMENSIONS.submitWideMinWidth &&
width - actionColumnWidth - 2 >= UI_DIMENSIONS.submitMinReviewWidth
);
}
function renderSubmittedQuestion(
function renderReviewQuestion(
lines: string[],
question: AskState["questions"][number],
answer: ReviewAnswer | undefined,
question: ReturnType<typeof buildReviewScreenModel>["questions"][number],
theme: Theme,
width: number
) {
pushWrappedText(lines, question.label, width, theme, "text", " ", " ");
if (!answer) {
if (question.unanswered) {
lines.push(
truncateToWidth(` ${theme.fg("dim", UI_TEXT.unanswered)}`, width)
);
return;
}
renderSubmittedAnswer(lines, answer, theme, width);
}
function renderSubmittedAnswer(
lines: string[],
answer: ReviewAnswer,
theme: Theme,
width: number
) {
if (answer.note) {
renderSubmittedNote(lines, answer.note, theme, width);
}
if (shouldRenderAnswersIndividually(answer)) {
renderSubmittedSelections(lines, answer, theme, width);
renderExtraOptionNotes(lines, answer, theme, width);
return;
}
const answerText = answer.labels.join(", ");
if (answerText) {
pushWrappedText(
if (question.note) {
pushSavedNote({
lines,
`→ ${answerText}`,
note: question.note,
width,
theme,
isCustomOnlyAnswer(answer) ? "text" : "success",
" ",
" "
);
indent: " ",
});
}
renderExtraOptionNotes(lines, answer, theme, width);
}
function renderSubmittedSelections(
lines: string[],
answer: ReviewAnswer,
theme: Theme,
width: number
) {
for (let index = 0; index < answer.labels.length; index++) {
const label = answer.labels[index];
const value = answer.values[index] ?? label;
for (const selection of question.selections ?? []) {
pushWrappedText(
lines,
`→ ${label}`,
`→ ${selection.label}`,
width,
theme,
"success",
" ",
" "
);
const note = answer.optionNotes?.[value];
if (note) {
renderSubmittedNote(lines, note, theme, width);
if (selection.note) {
pushSavedNote({
lines,
note: selection.note,
width,
theme,
indent: " ",
});
}
}
}
function shouldRenderAnswersIndividually(answer: ReviewAnswer): boolean {
if (!answer.labels.length) {
return false;
}
return (
answer.labels.length > 1 ||
Boolean(answer.optionNotes && Object.keys(answer.optionNotes).length > 0)
);
}
function renderSubmittedNote(
lines: string[],
note: string,
theme: Theme,
width: number
) {
const { prefix, continuationPrefix } = getSavedNotePrefixes(theme, {
indent: " ",
});
pushWrappedText(
lines,
note,
width,
theme,
"muted",
prefix,
continuationPrefix
);
}
function renderLabeledSubmittedNote(
lines: string[],
label: string,
note: string,
theme: Theme,
width: number
) {
const { prefix, continuationPrefix } = getSavedNotePrefixes(theme, {
indent: " ",
label,
});
pushWrappedText(
lines,
note,
width,
theme,
"muted",
prefix,
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 (question.answerText) {
pushWrappedText(
lines,
`→ ${question.answerText}`,
width,
theme,
question.isCustomOnly ? "text" : "success",
" ",
" "
);
if (
!(hasSelectedAnswer || serialized.note) &&
extraOptionNotes.length === 0
) {
return;
}
return {
...serialized,
extraOptionNotes:
extraOptionNotes.length > 0 ? extraOptionNotes : undefined,
};
for (const optionNote of question.extraOptionNotes ?? []) {
pushSavedNote({
lines,
note: optionNote.note,
width,
theme,
indent: " ",
label: optionNote.label,
});
}
}
function renderSubmitActions(
state: AskState,
model: ReturnType<typeof buildReviewScreenModel>,
theme: Theme,
width: number
): string[] {
const lines: string[] = [];
for (let index = 0; index < SUBMIT_CHOICES.length; index++) {
const selected = index === state.activeSubmitActionIndex;
const prefix = selected ? "❯ " : " ";
for (const [index, action] of model.actions.entries()) {
const prefix = action.selected ? "❯ " : " ";
pushWrappedText(
lines,
`${index + 1}. ${SUBMIT_CHOICES[index]}`,
`${index + 1}. ${action.label}`,
width,
theme,
selected ? "accent" : "text",
action.selected ? "accent" : "text",
prefix,
prefix
);
+190
View File
@@ -0,0 +1,190 @@
import { isOptionSelected } from "../../state/answers.ts";
import {
getAnswer,
getOptionNote,
getQuestionNote,
isInputOpenForQuestion,
isOptionNoteOpen,
isQuestionNoteOpen,
} from "../../state/selectors.ts";
import type { AskDisplayOption } from "../../types.ts";
import { UI_DIMENSIONS, UI_TEXT } from "../constants.ts";
import type { QuestionRenderContext } from "../render-types.ts";
export type QuestionNoteModel =
| { kind: "editor"; placeholder: string }
| { kind: "saved"; text: string };
export type OptionDetailModel =
| { kind: "editor"; placeholder: string; withGap: boolean }
| { kind: "saved-note"; text: string; withGap: boolean }
| { kind: "custom-text"; text: string; withGap: boolean };
export interface OptionRowModel {
color: "accent" | "text" | "success";
description?: string;
detail?: OptionDetailModel;
index: number;
isCustom: boolean;
label: string;
pointer: string;
prefix: string;
selected: boolean;
}
export interface QuestionScreenModel {
mode: "preview" | "standard";
previewLayout?: "custom" | "stacked" | "wide";
questionNote?: QuestionNoteModel;
rows: OptionRowModel[];
selectedOption?: AskDisplayOption;
selectedOptionDetail?: OptionDetailModel;
}
export function buildQuestionScreenModel(
context: QuestionRenderContext
): QuestionScreenModel {
const rows = context.options.map((option, index) =>
buildOptionRowModel(context, option, index)
);
const questionNote = buildQuestionNoteModel(context);
if (context.question.type !== "preview") {
return {
mode: "standard",
questionNote,
rows,
};
}
const selectedOption = context.options[context.state.activeOptionIndex];
return {
mode: "preview",
previewLayout: getPreviewLayout(context, selectedOption),
questionNote,
rows,
selectedOption,
selectedOptionDetail: buildOptionDetailModel(context, selectedOption, true),
};
}
function buildQuestionNoteModel(
context: QuestionRenderContext
): QuestionNoteModel | undefined {
const { state, question } = context;
if (isQuestionNoteOpen(state, question.id)) {
return { kind: "editor", placeholder: UI_TEXT.editorPlaceholderNote };
}
const note = getQuestionNote(state, question.id);
return note ? { kind: "saved", text: note } : undefined;
}
function buildOptionRowModel(
context: QuestionRenderContext,
option: AskDisplayOption,
index: number
): OptionRowModel {
const { question, state } = context;
const answer = getAnswer(state, question.id);
const selected = index === state.activeOptionIndex;
const answered = option.isCustomOption
? !!answer?.customText
: isOptionSelected(answer, option.value);
return {
color: getOptionColor(answered, selected),
description: option.description,
detail:
context.question.type === "preview"
? undefined
: buildOptionDetailModel(context, option, selected),
index,
isCustom: !!option.isCustomOption,
label: option.label,
pointer: selected ? "❯ " : " ",
prefix: getOptionPrefix(question.type, option, answered),
selected,
};
}
function buildOptionDetailModel(
context: QuestionRenderContext,
option: AskDisplayOption | undefined,
selected: boolean
): OptionDetailModel | undefined {
if (!option) {
return;
}
const { state, question } = context;
const answer = getAnswer(state, question.id);
const inputOpen =
!!option.isCustomOption &&
selected &&
isInputOpenForQuestion(state, question.id);
const noteOpen =
!option.isCustomOption &&
isOptionNoteOpen(state, question.id, option.value);
const customText = option.isCustomOption ? answer?.customText : undefined;
const note = option.isCustomOption
? undefined
: getOptionNote(state, question.id, option.value);
const withGap = !option.isCustomOption;
if (noteOpen) {
return {
kind: "editor",
placeholder: UI_TEXT.editorPlaceholderNote,
withGap,
};
}
if (inputOpen) {
return {
kind: "editor",
placeholder: UI_TEXT.editorPlaceholderInput,
withGap,
};
}
if (customText) {
return { kind: "custom-text", text: customText, withGap };
}
if (note) {
return { kind: "saved-note", text: note, withGap };
}
return;
}
function getPreviewLayout(
context: QuestionRenderContext,
selectedOption: AskDisplayOption | undefined
): "custom" | "stacked" | "wide" {
if (selectedOption?.isCustomOption) {
return "custom";
}
return context.width >= UI_DIMENSIONS.previewWideMinWidth &&
context.options.length > 0
? "wide"
: "stacked";
}
function getOptionPrefix(
questionType: QuestionRenderContext["question"]["type"],
option: AskDisplayOption,
answered: boolean
): string {
if (questionType !== "multi" || option.isCustomOption) {
return "";
}
return `[${answered ? "x" : " "}] `;
}
function getOptionColor(
answered: boolean,
selected: boolean
): "accent" | "text" | "success" {
if (answered) {
return "success";
}
if (selected) {
return "accent";
}
return "text";
}
+100
View File
@@ -0,0 +1,100 @@
import { visibleWidth } from "@mariozechner/pi-tui";
import { SUBMIT_CHOICES } from "../../constants.ts";
import { isCustomOnlyAnswer } from "../../state/answers.ts";
import {
type ReviewAnswer,
shouldRenderAnswersIndividually,
toReviewAnswer,
} from "../../state/result.ts";
import type { AskState } from "../../types.ts";
import { UI_DIMENSIONS } from "../constants.ts";
export interface ReviewSelectionModel {
label: string;
note?: string;
}
export interface ReviewQuestionModel {
answerText?: string;
extraOptionNotes?: Array<{ label: string; note: string }>;
isCustomOnly?: boolean;
label: string;
note?: string;
selections?: ReviewSelectionModel[];
unanswered: boolean;
}
export interface ReviewScreenModel {
actionColumnWidth: number;
actions: Array<{ label: string; selected: boolean }>;
layout: "stacked" | "wide";
questions: ReviewQuestionModel[];
}
export function buildReviewScreenModel(
state: AskState,
width: number
): ReviewScreenModel {
const showAllNotes = state.activeSubmitActionIndex === 1;
const actionColumnWidth = getSubmitActionColumnWidth();
return {
actionColumnWidth,
actions: SUBMIT_CHOICES.map((label, index) => ({
label,
selected: index === state.activeSubmitActionIndex,
})),
layout: shouldUseWideSubmitLayout(width, actionColumnWidth)
? "wide"
: "stacked",
questions: state.questions.map((question) =>
toReviewQuestionModel(
question.label,
toReviewAnswer(question, state.answers[question.id], showAllNotes)
)
),
};
}
function toReviewQuestionModel(
label: string,
answer: ReviewAnswer | undefined
): ReviewQuestionModel {
if (!answer) {
return { label, unanswered: true };
}
return {
answerText: shouldRenderAnswersIndividually(answer)
? undefined
: answer.labels.join(", "),
extraOptionNotes: answer.extraOptionNotes,
isCustomOnly: isCustomOnlyAnswer(answer),
label,
note: answer.note,
selections: shouldRenderAnswersIndividually(answer)
? answer.labels.map((selectionLabel, index) => ({
label: selectionLabel,
note: answer.optionNotes?.[answer.values[index] ?? selectionLabel],
}))
: undefined,
unanswered: false,
};
}
function getSubmitActionColumnWidth(): number {
return Math.max(
...SUBMIT_CHOICES.map((choice, index) =>
visibleWidth(`❯ ${index + 1}. ${choice}`)
)
);
}
function shouldUseWideSubmitLayout(
width: number,
actionColumnWidth: number
): boolean {
return (
width >= UI_DIMENSIONS.submitWideMinWidth &&
width - actionColumnWidth - 2 >= UI_DIMENSIONS.submitMinReviewWidth
);
}
+92
View File
@@ -0,0 +1,92 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createInitialState } from "../src/state/create.ts";
import {
getEditorDraft,
saveEditorDraft,
submitEditorDraft,
syncStateToSelection,
} from "../src/state/editor.ts";
import { enterQuestionNoteMode } from "../src/state/transitions.ts";
test("syncStateToSelection opens input when custom option is active", () => {
const state = {
...createInitialState({
questions: [
{
id: "q1",
prompt: "Pick one",
options: [{ value: "a", label: "A" }],
},
],
}),
activeOptionIndex: 1,
};
const next = syncStateToSelection(state);
assert.equal(next.view.kind, "input");
assert.equal(next.view.questionId, "q1");
});
test("saveEditorDraft stores question note drafts without advancing", () => {
const state = enterQuestionNoteMode(
createInitialState({
questions: [
{
id: "q1",
prompt: "Question?",
options: [{ value: "a", label: "A" }],
},
],
}),
"q1"
);
const next = saveEditorDraft(state, "needs examples");
assert.equal(next.view.kind, "navigate");
assert.equal(next.answers.q1.note, "needs examples");
});
test("submitEditorDraft advances submitted custom answers", () => {
const state = syncStateToSelection({
...createInitialState({
questions: [
{
id: "q1",
prompt: "Pick one",
options: [{ value: "a", label: "A" }],
},
],
}),
activeOptionIndex: 1,
});
const next = submitEditorDraft(state, "custom answer");
assert.equal(next.answers.q1.customText, "custom answer");
assert.equal(next.view.kind, "submit");
});
test("getEditorDraft reads saved note and custom text from view", () => {
let state = syncStateToSelection({
...createInitialState({
questions: [
{
id: "q1",
prompt: "Pick one",
options: [{ value: "a", label: "A" }],
},
],
}),
activeOptionIndex: 1,
});
state = saveEditorDraft(state, "draft answer");
state = enterQuestionNoteMode(state, "q1");
state = saveEditorDraft(state, "saved note");
state = enterQuestionNoteMode(state, "q1");
assert.equal(getEditorDraft(state), "saved note");
assert.equal(
getEditorDraft({ ...state, view: { kind: "input", questionId: "q1" } }),
"draft answer"
);
});
+105
View File
@@ -0,0 +1,105 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createInitialState } from "../src/state/create.ts";
import { getRenderableOptions } from "../src/state/selectors.ts";
import {
applyNumberShortcut,
enterQuestionNoteMode,
} from "../src/state/transitions.ts";
import { buildQuestionScreenModel } from "../src/ui/view-models/question.ts";
function buildContext(state: ReturnType<typeof createInitialState>) {
return {
editor: {
getText() {
return "";
},
render() {
return [];
},
} as never,
lines: [],
options: getRenderableOptions(state.questions[0]),
question: state.questions[0],
state,
theme: {
fg(_color: string, text: string) {
return text;
},
bg(_color: string, text: string) {
return text;
},
bold(text: string) {
return text;
},
} as never,
width: 80,
};
}
test("question view model marks active custom option as inline editor", () => {
let state = createInitialState({
questions: [
{
id: "q1",
prompt: "Pick one",
options: [{ value: "a", label: "A" }],
},
],
});
state = applyNumberShortcut(state, 2);
const model = buildQuestionScreenModel(buildContext(state));
assert.equal(model.mode, "standard");
assert.equal(model.rows[1]?.detail?.kind, "editor");
assert.equal(model.rows[1]?.detail?.placeholder, "Type answer...");
});
test("question view model exposes saved question notes", () => {
let state = enterQuestionNoteMode(
createInitialState({
questions: [
{
id: "q1",
prompt: "Question?",
options: [{ value: "a", label: "A" }],
},
],
}),
"q1"
);
state = {
...state,
view: { kind: "navigate" },
answers: { q1: { selected: [], note: "saved" } },
};
const model = buildQuestionScreenModel(buildContext(state));
assert.deepEqual(model.questionNote, { kind: "saved", text: "saved" });
});
test("preview view model selects stacked vs custom layouts", () => {
const base = createInitialState({
questions: [
{
id: "q1",
prompt: "Pick one",
type: "preview",
options: [{ value: "a", label: "A", preview: "Preview A" }],
},
],
});
const stacked = buildQuestionScreenModel(buildContext(base));
assert.equal(stacked.mode, "preview");
assert.equal(stacked.previewLayout, "stacked");
const custom = buildQuestionScreenModel(
buildContext({
...base,
activeOptionIndex: 1,
view: { kind: "input", questionId: "q1" },
})
);
assert.equal(custom.previewLayout, "custom");
});