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 ../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 ### Install dependencies
+1 -1
View File
@@ -28,7 +28,7 @@
"CHANGELOG.md" "CHANGELOG.md"
], ],
"scripts": { "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", "test": "node --test tests/*.test.ts",
"typecheck": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json",
"format": "biome format --write .", "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 { import type {
AskParams, ExtensionAPI,
AskQuestionInput, ExtensionContext,
AskResult, } from "@mariozechner/pi-coding-agent";
AskValidationIssue, import {
} from "./types.ts"; ASK_TOOL_DESCRIPTION,
import { UI_DIMENSIONS } from "./ui/constants.ts"; 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"; import { runAskFlow } from "./ui/controller.ts";
export function registerAskTool(pi: ExtensionAPI) { export function registerAskTool(pi: ExtensionAPI) {
pi.registerTool({ pi.registerTool({
name: "ask_user", name: "ask_user",
label: "Ask User", label: "Ask User",
description: description: 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.",
promptSnippet: promptSnippet:
"Clarify ambiguous or preference-sensitive decisions with a short interactive interview before proceeding", "Clarify ambiguous or preference-sensitive decisions with a short interactive interview before proceeding",
promptGuidelines: [ promptGuidelines: [...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.",
],
parameters: AskParamsSchema, parameters: AskParamsSchema,
execute: executeAskTool,
async execute(_toolCallId, params: AskParams, _signal, _onUpdate, ctx) { renderCall: renderAskToolCall,
const validation = validateParams(params); renderResult: renderAskToolResult,
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);
},
}); });
} }
function errorResultDetails( interface ExecuteContext extends Pick<ExtensionContext, "cwd" | "hasUI"> {}
async function executeAskTool(
_toolCallId: string,
params: AskParams, params: AskParams,
issues: AskValidationIssue[] _signal: AbortSignal | undefined,
): AskResult { _onUpdate: unknown,
try { ctx: ExecuteContext
const state = createInitialState(params); ) {
return { const validation = validateParams(params);
...toAskResult(state), if (!validation.ok) {
cancelled: true, return invalidPayloadResponse(params, validation.issues);
error: {
kind: "invalid_input",
issues,
},
};
} catch {
return {
title: params.title,
cancelled: true,
mode: "submit",
questions: [],
answers: {},
error: {
kind: "invalid_input",
issues,
},
};
} }
} if (!ctx.hasUI) {
return nonInteractiveResponse(validation.state);
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,
};
} }
const result = await runAskFlow(ctx as never, params);
return { return successfulResponse(result);
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");
} }
+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 { ELABORATED_SUMMARY } from "./constants.ts";
import { isCustomOnlyAnswer } from "./state/answers.ts";
import type { AskResult } from "./types.ts"; import type { AskResult } from "./types.ts";
export function formatResultLines( export function formatResultLines(
@@ -51,10 +52,6 @@ function formatAnswerLine(
return `✓ ${questionLabel}: ${answerText}`; return `✓ ${questionLabel}: ${answerText}`;
} }
function isCustomOnlyAnswer(answer: AskResult["answers"][string]): boolean {
return answer.indices.length === 0 && !!answer.customText;
}
function formatQuestionNoteLine( function formatQuestionNoteLine(
questionLabel: string, questionLabel: string,
note: string | undefined, note: string | undefined,
+50
View File
@@ -5,6 +5,11 @@ import type {
AskStateAnswer, AskStateAnswer,
} from "../types.ts"; } from "../types.ts";
export interface ExtraOptionNote {
label: string;
note: string;
}
export function emptyAnswer(): AskStateAnswer { export function emptyAnswer(): AskStateAnswer {
return { selected: [] }; return { selected: [] };
} }
@@ -135,6 +140,34 @@ export function isAnswerAnswered(answer?: AskStateAnswer): boolean {
return answer.selected.length > 0 || !!answer.customText?.trim(); 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( export function isOptionSelected(
answer: AskStateAnswer | undefined, answer: AskStateAnswer | undefined,
optionValue: string 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 { function cloneSelection(selection: AskSelectedOption): AskSelectedOption {
return { ...selection }; 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, AskValidationIssue,
} from "../types.ts"; } from "../types.ts";
interface IssueCollector {
add: (path: string, message: string) => void;
issues: AskValidationIssue[];
}
export function normalizeQuestions(params: AskParams): AskQuestion[] { export function normalizeQuestions(params: AskParams): AskQuestion[] {
const issues = collectValidationIssues(params); const issues = collectValidationIssues(params);
if (issues.length > 0) { if (issues.length > 0) {
throw new Error(issues[0]?.message ?? "Invalid ask_user payload"); throw new Error(issues[0]?.message ?? "Invalid ask_user payload");
} }
return params.questions.map((question, index) => return params.questions.map(normalizeQuestion);
normalizeQuestion(question, index)
);
} }
export function collectValidationIssues( export function collectValidationIssues(
params: AskParams params: AskParams
): AskValidationIssue[] { ): AskValidationIssue[] {
const issues: AskValidationIssue[] = []; const collector = createIssueCollector();
validateQuestions(params.questions, issues); validateQuestions(params.questions, collector);
return issues; return collector.issues;
} }
function normalizeQuestion( function normalizeQuestion(
@@ -49,19 +52,16 @@ function normalizeOption(option: AskOption): AskOption {
function validateQuestions( function validateQuestions(
questions: AskParams["questions"], questions: AskParams["questions"],
issues: AskValidationIssue[] collector: IssueCollector
): void { ) {
if (questions.length === 0) { if (questions.length === 0) {
issues.push({ collector.add("questions", "At least one question is required");
path: "questions",
message: "At least one question is required",
});
return; return;
} }
const questionIds = new Set<string>(); const questionIds = new Set<string>();
for (const [questionIndex, question] of questions.entries()) { 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, question: AskQuestionInput,
questionIndex: number, questionIndex: number,
questionIds: Set<string>, questionIds: Set<string>,
issues: AskValidationIssue[] collector: IssueCollector
): void { ) {
const questionNumber = questionIndex + 1; const questionNumber = questionIndex + 1;
const questionPath = `questions[${questionIndex}]`; const questionPath = `questions[${questionIndex}]`;
const questionId = question.id?.trim(); const questionId = question.id?.trim();
const questionType = normalizeQuestionType(question.type); const questionType = normalizeQuestionType(question.type);
assertValidQuestionType(
validateQuestionType(
question.type, question.type,
`Question ${questionNumber}: invalid type ${JSON.stringify(question.type)}; expected "single", "multi", or "preview"`, questionNumber,
issues, collector,
`${questionPath}.type` `${questionPath}.type`
); );
assertRequired(
assertNonEmpty(
questionId, questionId,
`Question ${questionNumber}: id is required`, collector,
issues, `${questionPath}.id`,
`${questionPath}.id` `Question ${questionNumber}: id is required`
); );
assertUnique( assertUnique(
questionIds, questionIds,
questionId, questionId,
`Question ${questionNumber}: duplicate question id "${questionId}"`, collector,
issues, `${questionPath}.id`,
`${questionPath}.id` `Question ${questionNumber}: duplicate question id "${questionId}"`
); );
assertOptionalNonEmpty( assertOptionalText(
question.label, question.label,
`Question ${questionNumber}: label must not be empty`, collector,
issues, `${questionPath}.label`,
`${questionPath}.label` `Question ${questionNumber}: label must not be empty`
); );
assertNonEmpty( assertRequired(
question.prompt?.trim(), question.prompt?.trim(),
`Question ${questionNumber}: prompt is required`, collector,
issues, `${questionPath}.prompt`,
`${questionPath}.prompt` `Question ${questionNumber}: prompt is required`
); );
assertHasItems( assertHasItems(
question.options, question.options,
`Question ${questionNumber}: at least one option is required`, collector,
issues, `${questionPath}.options`,
`${questionPath}.options` `Question ${questionNumber}: at least one option is required`
); );
const optionValues = new Set<string>(); const optionValues = new Set<string>();
@@ -122,7 +122,7 @@ function validateQuestion(
optionValues, optionValues,
questionNumber, questionNumber,
questionType, questionType,
issues, collector,
`${questionPath}.options[${optionIndex}]` `${questionPath}.options[${optionIndex}]`
); );
} }
@@ -134,50 +134,51 @@ function validateOption(
optionValues: Set<string>, optionValues: Set<string>,
questionNumber: number, questionNumber: number,
questionType: AskQuestion["type"], questionType: AskQuestion["type"],
issues: AskValidationIssue[], collector: IssueCollector,
optionPath: string optionPath: string
): void { ) {
const optionNumber = optionIndex + 1; const optionNumber = optionIndex + 1;
const prefix = `Question ${questionNumber}, option ${optionNumber}`; const prefix = `Question ${questionNumber}, option ${optionNumber}`;
const optionValue = option.value?.trim(); const optionValue = option.value?.trim();
const optionPreview = option.preview?.trim();
assertNonEmpty( assertRequired(
optionValue, optionValue,
`${prefix}: value is required`, collector,
issues, `${optionPath}.value`,
`${optionPath}.value` `${prefix}: value is required`
); );
assertUnique( assertUnique(
optionValues, optionValues,
optionValue, optionValue,
`${prefix}: duplicate option value "${optionValue}"`, collector,
issues, `${optionPath}.value`,
`${optionPath}.value` `${prefix}: duplicate option value "${optionValue}"`
); );
assertNonEmpty( assertRequired(
option.label?.trim(), option.label?.trim(),
`${prefix}: label is required`, collector,
issues, `${optionPath}.label`,
`${optionPath}.label` `${prefix}: label is required`
); );
assertOptionalNonEmpty( assertOptionalText(
option.description, option.description,
`${prefix}: description must not be empty`, collector,
issues, `${optionPath}.description`,
`${optionPath}.description` `${prefix}: description must not be empty`
); );
assertOptionalNonEmpty( assertOptionalText(
option.preview, option.preview,
`${prefix}: preview must not be empty`, collector,
issues, `${optionPath}.preview`,
`${optionPath}.preview` `${prefix}: preview must not be empty`
); );
if (questionType === "preview") { if (questionType === "preview") {
assertNonEmpty( assertRequired(
option.preview?.trim(), optionPreview,
`${prefix}: preview questions require preview text for every option; add preview text or use type "single" instead`, collector,
issues, `${optionPath}.preview`,
`${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"; return value === "multi" || value === "preview" ? value : "single";
} }
function assertValidQuestionType( function validateQuestionType(
value: AskQuestionInput["type"], value: AskQuestionInput["type"],
errorMessage: string, questionNumber: number,
issues: AskValidationIssue[], collector: IssueCollector,
path: string path: string
): void { ) {
if ( if (
value !== undefined && value !== undefined &&
value !== "single" && value !== "single" &&
value !== "multi" && value !== "multi" &&
value !== "preview" value !== "preview"
) { ) {
issues.push({ path, message: errorMessage }); collector.add(
path,
`Question ${questionNumber}: invalid type ${JSON.stringify(value)}; expected "single", "multi", or "preview"`
);
} }
} }
function assertHasItems( function assertHasItems(
items: unknown[], items: unknown[],
errorMessage: string, collector: IssueCollector,
issues: AskValidationIssue[], path: string,
path: string message: string
): void { ) {
if (items.length === 0) { if (items.length === 0) {
issues.push({ path, message: errorMessage }); collector.add(path, message);
} }
} }
function assertNonEmpty( function assertRequired(
value: string | undefined, value: string | undefined,
errorMessage: string, collector: IssueCollector,
issues: AskValidationIssue[], path: string,
path: string message: string
): void { ) {
if (!value) { if (!value) {
issues.push({ path, message: errorMessage }); collector.add(path, message);
} }
} }
function assertOptionalNonEmpty( function assertOptionalText(
value: string | undefined, value: string | undefined,
errorMessage: string, collector: IssueCollector,
issues: AskValidationIssue[], path: string,
path: string message: string
): void { ) {
if (value !== undefined && !value.trim()) { if (value !== undefined && !value.trim()) {
issues.push({ path, message: errorMessage }); collector.add(path, message);
} }
} }
function assertUnique( function assertUnique(
seen: Set<string>, seen: Set<string>,
value: string | undefined, value: string | undefined,
errorMessage: string, collector: IssueCollector,
issues: AskValidationIssue[], path: string,
path: string message: string
): void { ) {
if (!value) { if (!value) {
return; return;
} }
if (seen.has(value)) { if (seen.has(value)) {
issues.push({ path, message: errorMessage }); collector.add(path, message);
return; return;
} }
seen.add(value); 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"; } from "../types.ts";
import { import {
cloneResultAnswer, cloneResultAnswer,
getExtraOptionNotes,
hasAnswerNotes,
isAnswerAnswered, isAnswerAnswered,
isAnswerEmpty, isAnswerEmpty,
isOptionSelected, isOptionSelected,
isResultAnswerCommitted,
isResultAnswerEmpty,
serializeAnswer, serializeAnswer,
} from "./answers.ts"; } 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 { export function toAskResult(state: AskState): AskResult {
const answers = Object.fromEntries( const answers = Object.fromEntries(
@@ -29,8 +41,8 @@ export function toAskResult(state: AskState): AskResult {
) )
.filter(([, answer]) => .filter(([, answer]) =>
state.mode === "elaborate" state.mode === "elaborate"
? isSerializedAnswerCommitted(answer) ? isResultAnswerCommitted(answer)
: !isSerializedAnswerEmpty(answer) : !isResultAnswerEmpty(answer)
) )
); );
@@ -72,19 +84,6 @@ export function hasAnswerContent(state: AskState, questionId: string): boolean {
return !!answer && !isAnswerEmpty(answer); 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( function serializeContinuation(
state: AskState, state: AskState,
answers: AskResult["answers"] answers: AskResult["answers"]
@@ -95,7 +94,7 @@ function serializeContinuation(
for (const question of state.questions) { for (const question of state.questions) {
const answer = state.answers[question.id]; const answer = state.answers[question.id];
const hasClarificationNeed = !!(answer?.note || answer?.optionNotes); const hasClarificationNeed = hasAnswerNotes(answer);
const committedAnswer = answers[question.id]; const committedAnswer = answers[question.id];
const answered = !!committedAnswer; const answered = !!committedAnswer;
@@ -138,7 +137,7 @@ function serializeElaborationItemsForQuestion(
question: AskState["questions"][number], question: AskState["questions"][number],
answer: AskStateAnswer | undefined answer: AskStateAnswer | undefined
): AskElaborationPayload["items"] { ): AskElaborationPayload["items"] {
if (!(answer?.note || answer?.optionNotes)) { if (!(answer && hasAnswerNotes(answer))) {
return []; return [];
} }
@@ -158,9 +157,7 @@ function serializeElaborationItemsForQuestion(
} }
for (const [value, note] of Object.entries(answer.optionNotes ?? {})) { for (const [value, note] of Object.entries(answer.optionNotes ?? {})) {
const option = question.options.find( const option = getQuestionOptionByValue(question, value);
(candidate) => candidate.value === value
);
if (!(option && note)) { if (!(option && note)) {
continue; continue;
} }
@@ -212,13 +209,47 @@ function toCommittedResultAnswer(
return cloneResultAnswer(serializeAnswer(answer)); return cloneResultAnswer(serializeAnswer(answer));
} }
function isSerializedAnswerCommitted( export function toReviewAnswer(
answer: AskResult["answers"][string] question: AskState["questions"][number],
): boolean { 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 ( return (
answer.values.length > 0 || answer.labels.length > 1 ||
answer.labels.length > 0 || Boolean(answer.optionNotes && Object.keys(answer.optionNotes).length > 0)
answer.indices.length > 0 ||
!!answer.customText
); );
} }
+14 -10
View File
@@ -7,6 +7,12 @@ import type {
} from "../types.ts"; } from "../types.ts";
import { isAnswerAnswered } from "./answers.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 { export function getCurrentQuestion(state: AskState): AskQuestion | undefined {
return state.questions[state.activeTabIndex]; return state.questions[state.activeTabIndex];
} }
@@ -28,16 +34,7 @@ export function getRenderableOptions(
if (!question) { if (!question) {
return []; return [];
} }
return [...question.options, CUSTOM_OPTION];
const options: AskDisplayOption[] = question.options.map((option) => ({
...option,
}));
options.push({
value: OTHER_OPTION_VALUE,
label: OTHER_OPTION_LABEL,
isCustomOption: true,
});
return options;
} }
export function getCurrentOption( 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( export function getAnswer(
state: AskState, state: AskState,
questionId: string questionId: string
+76 -162
View File
@@ -9,6 +9,13 @@ import {
setSingleSelection, setSingleSelection,
toggleSelection, toggleSelection,
} from "./answers.ts"; } from "./answers.ts";
import {
cancelFlow as cancelFlowBase,
createInitialState as createInitialStateBase,
dismissFlow as dismissFlowBase,
moveOption as moveOptionBase,
moveTab as moveTabBase,
} from "./navigation.ts";
import { import {
getAnswer, getAnswer,
getCurrentOption, getCurrentOption,
@@ -19,7 +26,6 @@ import {
} from "./selectors.ts"; } from "./selectors.ts";
import { import {
inputView, inputView,
isEditingView,
navigateView, navigateView,
optionNoteView, optionNoteView,
questionNoteView, questionNoteView,
@@ -32,26 +38,31 @@ export function createInitialState(params: {
title?: string; title?: string;
questions: AskState["questions"]; questions: AskState["questions"];
}): AskState { }): AskState {
return { return createInitialStateBase(params);
title: params.title, }
questions: params.questions,
activeTabIndex: 0, export function moveTab(state: AskState, delta: number): AskState {
activeOptionIndex: 0, return moveTabBase(state, delta);
activeSubmitActionIndex: 0, }
view: navigateView(),
answers: {}, export function moveOption(state: AskState, delta: number): AskState {
completed: false, return moveOptionBase(state, delta);
cancelled: false, }
mode: "submit",
}; 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 { export function reduceAskState(state: AskState, action: AskAction): AskState {
switch (action.type) { switch (action.type) {
case "MOVE_TAB": case "MOVE_TAB":
return moveTabBy(state, action.delta); return moveTabBase(state, action.delta);
case "MOVE_OPTION": case "MOVE_OPTION":
return moveOptionBy(state, action.delta); return moveOptionBase(state, action.delta);
case "OPEN_INPUT": case "OPEN_INPUT":
return setView(state, inputView(action.questionId)); return setView(state, inputView(action.questionId));
case "OPEN_QUESTION_NOTE": case "OPEN_QUESTION_NOTE":
@@ -72,26 +83,12 @@ export function reduceAskState(state: AskState, action: AskAction): AskState {
case "SAVE_NOTE": case "SAVE_NOTE":
return saveNoteValue(state, action.value); return saveNoteValue(state, action.value);
case "CANCEL": case "CANCEL":
return cancelFlow(state); return cancelFlowBase(state);
default: default:
return state; 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 { export function enterInputMode(state: AskState, questionId: string): AskState {
return reduceAskState(state, { type: "OPEN_INPUT", questionId }); return reduceAskState(state, { type: "OPEN_INPUT", questionId });
} }
@@ -116,37 +113,7 @@ export function enterOptionNoteMode(
} }
export function toggleCurrentOption(state: AskState): AskState { export function toggleCurrentOption(state: AskState): AskState {
const question = getCurrentQuestion(state); return activateCurrentOption(state, "toggle");
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);
});
} }
export function toggleCurrentMultiOption(state: AskState): AskState { export function toggleCurrentMultiOption(state: AskState): AskState {
@@ -155,36 +122,9 @@ export function toggleCurrentMultiOption(state: AskState): AskState {
export function confirmCurrentSelection(state: AskState): AskState { export function confirmCurrentSelection(state: AskState): AskState {
if (isSubmitTab(state)) { if (isSubmitTab(state)) {
if (state.activeSubmitActionIndex === 2) { return completeSubmitAction(state);
return { ...state, cancelled: true, completed: true };
}
if (state.activeSubmitActionIndex === 1) {
return { ...state, mode: "elaborate", completed: true };
}
return { ...state, mode: "submit", completed: true };
} }
return activateCurrentOption(state, "confirm");
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);
} }
export function applyNumberShortcut(state: AskState, digit: number): AskState { export function applyNumberShortcut(state: AskState, digit: number): AskState {
@@ -203,31 +143,13 @@ export function applyNumberShortcut(state: AskState, digit: number): AskState {
} }
const question = getCurrentQuestion(state); const question = getCurrentQuestion(state);
if (!question) {
return state;
}
const index = digit - 1; const index = digit - 1;
const option = getRenderableOptions(question)[index]; const option = question ? getRenderableOptions(question)[index] : undefined;
if (!option) { if (!(question && option)) {
return state; return state;
} }
const selectedState = { ...state, activeOptionIndex: index }; return activateCurrentOption({ ...state, activeOptionIndex: index }, "digit");
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);
} }
export function saveCustomAnswer(state: AskState, rawValue: string): AskState { export function saveCustomAnswer(state: AskState, rawValue: string): AskState {
@@ -245,20 +167,52 @@ export function saveNote(state: AskState, rawValue: string): AskState {
return saveNoteValue(state, rawValue); return saveNoteValue(state, rawValue);
} }
export function cancelFlow(state: AskState): AskState { function completeSubmitAction(state: AskState): AskState {
if (isEditingView(state)) { if (state.activeSubmitActionIndex === 2) {
return exitEditingView(state); return { ...state, cancelled: true, completed: true };
} }
return { if (state.activeSubmitActionIndex === 1) {
...state, return { ...state, mode: "elaborate", completed: true };
cancelled: true, }
completed: true, return { ...state, mode: "submit", completed: true };
};
} }
export function dismissFlow(state: AskState): AskState { function activateCurrentOption(
const nextState = cancelFlow(state); state: AskState,
return nextState.completed ? nextState : cancelFlow(nextState); 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( function saveInputValue(
@@ -308,42 +262,6 @@ function saveNoteValue(state: AskState, rawValue: string): AskState {
return nextState; 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 { function setView(state: AskState, view: AskState["view"]): AskState {
return { return {
...state, ...state,
@@ -389,7 +307,3 @@ function updateAnswer(
answers, 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 type { ExtensionContext } from "@mariozechner/pi-coding-agent";
import { Editor, type EditorTheme } from "@mariozechner/pi-tui"; import { Editor, type EditorTheme } from "@mariozechner/pi-tui";
import { createInitialState } from "../state/create.ts"; import { createInitialState } from "../state/create.ts";
import {
getEditorDraft,
saveEditorDraft,
submitEditorDraft,
syncStateToSelection,
} from "../state/editor.ts";
import { toAskResult } from "../state/result.ts"; import { toAskResult } from "../state/result.ts";
import { import {
getAnswer,
getCurrentOption, getCurrentOption,
getCurrentQuestion, getCurrentQuestion,
getOptionNote,
getQuestionNote,
isSubmitTab, isSubmitTab,
} from "../state/selectors.ts"; } from "../state/selectors.ts";
import { import {
@@ -15,14 +18,10 @@ import {
cancelFlow, cancelFlow,
confirmCurrentSelection, confirmCurrentSelection,
dismissFlow, dismissFlow,
enterInputMode,
enterOptionNoteMode, enterOptionNoteMode,
enterQuestionNoteMode, enterQuestionNoteMode,
moveOption, moveOption,
moveTab, moveTab,
saveCustomAnswer,
saveNote,
submitCustomAnswer,
toggleCurrentMultiOption, toggleCurrentMultiOption,
} from "../state/transitions.ts"; } from "../state/transitions.ts";
import { isEditingView } from "../state/view.ts"; import { isEditingView } from "../state/view.ts";
@@ -79,7 +78,7 @@ function createAskFlowController(
}; };
controller.editor.onSubmit = (value) => submitEditor(controller, value); controller.editor.onSubmit = (value) => submitEditor(controller, value);
syncInputModeWithSelection(controller); syncSelection(controller);
return { return {
render: (width: number) => renderController(controller, width), render: (width: number) => renderController(controller, width),
@@ -123,27 +122,19 @@ function handleEditingCommand(
data: string data: string
) { ) {
if (command.kind === "dismiss") { if (command.kind === "dismiss") {
commitNavigation(controller, dismissFlow(controller.state)); commitState(controller, dismissFlow(controller.state), { finish: true });
maybeFinish(controller);
return; return;
} }
if (command.kind === "editMoveTab") { if (command.kind === "editMoveTab") {
controller.state = saveEditorAndMoveTab(controller, command.delta); commitSavedEditorNavigation(controller, moveTab, command.delta);
syncInputModeWithSelection(controller);
refresh(controller);
return; return;
} }
if (command.kind === "editMoveOption") { if (command.kind === "editMoveOption") {
controller.state = saveEditorAndMoveOption(controller, command.delta); commitSavedEditorNavigation(controller, moveOption, command.delta);
syncInputModeWithSelection(controller);
refresh(controller);
return; return;
} }
if (command.kind === "editClose") { if (command.kind === "editClose") {
controller.state = saveEditorWithoutAdvancing(controller); closeEditor(controller);
controller.suppressAutoInputForSelection =
controller.state.view.kind !== "input";
refresh(controller);
return; return;
} }
if (command.kind === "delegateToEditor") { if (command.kind === "delegateToEditor") {
@@ -158,10 +149,10 @@ function handleNavigationCommand(
) { ) {
switch (command.kind) { switch (command.kind) {
case "moveTab": case "moveTab":
navigateTabs(controller, command.delta); commitState(controller, moveTab(controller.state, command.delta));
return; return;
case "moveOption": case "moveOption":
navigateOptions(controller, command.delta); commitState(controller, moveOption(controller.state, command.delta));
return; return;
case "toggleMulti": case "toggleMulti":
handleToggleCurrentOption(controller); handleToggleCurrentOption(controller);
@@ -173,22 +164,21 @@ function handleNavigationCommand(
openOptionNote(controller); openOptionNote(controller);
return; return;
case "confirm": case "confirm":
commitNavigation(controller, confirmCurrentSelection(controller.state)); commitState(controller, confirmCurrentSelection(controller.state), {
maybeFinish(controller); finish: true,
});
return; return;
case "cancel": case "cancel":
commitNavigation(controller, cancelFlow(controller.state)); commitState(controller, cancelFlow(controller.state), { finish: true });
maybeFinish(controller);
return; return;
case "numberShortcut": case "numberShortcut":
commitNavigation( commitState(
controller, controller,
applyNumberShortcut(controller.state, command.digit) applyNumberShortcut(controller.state, command.digit)
); );
return; return;
case "dismiss": case "dismiss":
commitNavigation(controller, dismissFlow(controller.state)); commitState(controller, dismissFlow(controller.state), { finish: true });
maybeFinish(controller);
return; return;
case "ignore": case "ignore":
case "editMoveTab": 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) { function handleToggleCurrentOption(controller: AskFlowController) {
const question = getCurrentQuestion(controller.state); const question = getCurrentQuestion(controller.state);
if (!question) { if (!question) {
return; return;
} }
controller.suppressAutoInputForSelection = false; commitState(controller, toggleCurrentMultiOption(controller.state));
commitNavigation(controller, toggleCurrentMultiOption(controller.state));
} }
function openQuestionNote(controller: AskFlowController) { function openQuestionNote(controller: AskFlowController) {
@@ -229,9 +204,13 @@ function openQuestionNote(controller: AskFlowController) {
if (!question || isSubmitTab(controller.state)) { if (!question || isSubmitTab(controller.state)) {
return; return;
} }
controller.state = enterQuestionNoteMode(controller.state, question.id); commitState(
hydrateEditorForCurrentView(controller); controller,
refresh(controller); enterQuestionNoteMode(controller.state, question.id),
{
syncSelection: false,
}
);
} }
function openOptionNote(controller: AskFlowController) { function openOptionNote(controller: AskFlowController) {
@@ -244,38 +223,51 @@ function openOptionNote(controller: AskFlowController) {
) { ) {
return; return;
} }
controller.state = enterOptionNoteMode( commitState(
controller.state, controller,
question.id, enterOptionNoteMode(controller.state, question.id, option.value),
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.suppressAutoInputForSelection = false;
controller.state = nextState; controller.state = nextState;
hydrateEditorForCurrentView(controller); if (options.syncSelection !== false) {
syncSelection(controller);
}
hydrateEditor(controller);
refresh(controller); refresh(controller);
if (options.finish) {
maybeFinish(controller);
}
} }
function submitEditor(controller: AskFlowController, value: string) { function submitEditor(controller: AskFlowController, value: string) {
controller.suppressAutoInputForSelection = false; controller.suppressAutoInputForSelection = false;
controller.state = submitEditorValue(controller.state, value); controller.state = submitEditorDraft(controller.state, value);
controller.editor.setText(""); controller.editor.setText("");
refresh(controller); refresh(controller);
maybeFinish(controller); maybeFinish(controller);
} }
function submitEditorValue(state: AskState, value: string): AskState { function commitSavedEditorNavigation(
if (state.view.kind === "input") { controller: AskFlowController,
return submitCustomAnswer(state, value); navigate: (state: AskState, delta: 1 | -1) => AskState,
} delta: 1 | -1
if (state.view.kind === "note") { ) {
return saveNote(state, value); commitState(controller, navigate(saveEditorState(controller), delta));
} }
return state;
function closeEditor(controller: AskFlowController) {
const nextState = saveEditorState(controller);
controller.suppressAutoInputForSelection = nextState.view.kind !== "input";
controller.state = nextState;
refresh(controller);
} }
function refresh(controller: AskFlowController) { function refresh(controller: AskFlowController) {
@@ -288,57 +280,21 @@ function maybeFinish(controller: AskFlowController) {
} }
} }
function hydrateEditorForCurrentView(controller: AskFlowController) { function hydrateEditor(controller: AskFlowController) {
controller.editor.setText(getEditorTextForCurrentView(controller.state)); controller.editor.setText(getEditorDraft(controller.state));
} }
function syncInputModeWithSelection(controller: AskFlowController) { function syncSelection(controller: AskFlowController) {
if ( if (controller.suppressAutoInputForSelection) {
isEditingView(controller.state) ||
isSubmitTab(controller.state) ||
controller.suppressAutoInputForSelection
) {
return; return;
} }
controller.state = syncStateToSelection(controller.state);
const question = getCurrentQuestion(controller.state);
const option = getCurrentOption(controller.state);
if (!(question && option?.isCustomOption)) {
return;
}
controller.state = enterInputMode(controller.state, question.id);
hydrateEditorForCurrentView(controller);
} }
function saveEditorWithoutAdvancing(controller: AskFlowController): AskState { function saveEditorState(controller: AskFlowController): AskState {
const text = controller.editor.getText(); const text = controller.editor.getText();
controller.editor.setText(""); controller.editor.setText("");
if (controller.state.view.kind === "input") { return saveEditorDraft(controller.state, text);
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);
} }
function createEditor(tui: Tui, theme: Theme, cwd: string) { 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 type { ExtensionContext } from "@mariozechner/pi-coding-agent";
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
import { NO_PREVIEW_TEXT } from "../constants.ts"; import { NO_PREVIEW_TEXT } from "../constants.ts";
import { clamp } from "../math.ts";
import { wrapText } from "../text.ts"; import { wrapText } from "../text.ts";
import { UI_DIMENSIONS, UI_TEXT } from "./constants.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( export function renderFooterText(
mode: "input" | "note" | "submit" | "multi" | "default" mode: "input" | "note" | "submit" | "multi" | "default"
) { ) {
@@ -363,7 +388,3 @@ export function renderFooterText(
function padToVisibleWidth(text: string, width: number): string { function padToVisibleWidth(text: string, width: number): string {
return text + " ".repeat(Math.max(0, width - visibleWidth(text))); 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 { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
import { isOptionSelected } from "../state/answers.ts"; import { UI_DIMENSIONS } from "./constants.ts";
import { 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, measurePreviewLeftWidth,
mergeColumns, mergeColumns,
pushSavedNote,
pushWrappedText, pushWrappedText,
renderEditorBlock, renderEditorBlock,
renderPreviewPaneContent, renderPreviewPaneContent,
} from "./render-helpers.ts"; } from "./render-helpers.ts";
import type { import type { QuestionRenderContext, Theme } from "./render-types.ts";
OptionDetailRenderContext, import {
QuestionRenderContext, buildQuestionScreenModel,
Theme, type OptionDetailModel,
} from "./render-types.ts"; type OptionRowModel,
} from "./view-models/question.ts";
export function renderQuestionScreen(context: QuestionRenderContext) { export function renderQuestionScreen(context: QuestionRenderContext) {
const { lines, question, theme, width } = context; const { lines, question, theme, width } = context;
pushWrappedText(lines, question.prompt, width, theme, "text", " ", " "); const model = buildQuestionScreenModel(context);
const renderedQuestionNote = renderQuestionNote(context);
if (!renderedQuestionNote) {
lines.push("");
}
if (question.type === "preview") { pushWrappedText(lines, question.prompt, width, theme, "text", " ", " ");
renderPreviewQuestion(context); renderQuestionNote(lines, model.questionNote, context);
if (model.mode === "preview") {
renderPreviewQuestion(context, model);
return; return;
} }
renderStandardQuestion(context); for (const row of model.rows) {
renderStandardOption(lines, row, context);
}
} }
function renderQuestionNote(context: QuestionRenderContext): boolean { function renderQuestionNote(
const { lines, state, question, theme, width, editor } = context; lines: string[],
if (isQuestionNoteOpen(state, question.id)) { questionNote: ReturnType<typeof buildQuestionScreenModel>["questionNote"],
renderQuestionNoteEditor( context: QuestionRenderContext
lines, ) {
editor, if (!questionNote) {
width,
theme,
UI_TEXT.editorPlaceholderNote
);
lines.push(""); lines.push("");
return true; return;
} }
if (questionNote.kind === "editor") {
const existingNote = getQuestionNote(state, question.id); renderEditorWithIndent({
if (!existingNote) { lines,
return false; 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: " ", indent: " ",
}); });
pushWrappedText(
lines,
existingNote,
width,
theme,
"muted",
prefix,
continuationPrefix
);
lines.push(""); 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( function renderStandardOption(
context: QuestionRenderContext, lines: string[],
option: AskDisplayOption, row: OptionRowModel,
index: number context: QuestionRenderContext
) { ) {
const { lines, state, question, theme, width } = context; if (row.isCustom && row.detail?.kind === "editor") {
const answer = getAnswer(state, question.id); renderInteractiveCustomOption(lines, row, context);
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);
return; return;
} }
pushWrappedText( pushWrappedText(
lines, lines,
`${index + 1}. ${prefix}${option.label}`, `${row.index + 1}. ${row.prefix}${row.label}`,
width, context.width,
theme, context.theme,
optionColor, row.color,
pointer, row.pointer,
" ".repeat(visibleWidth(pointer)) " ".repeat(visibleWidth(row.pointer))
); );
renderOptionMeta({ renderOptionDescription(lines, row.description, context.width, context.theme);
...context, renderOptionDetail(lines, row.detail, context, {
questionId: question.id, suppressLeadingGap: !!row.description,
answer,
option,
selected,
}); });
} }
function renderPreviewQuestion(context: QuestionRenderContext) { function renderPreviewQuestion(
const { lines, state, question, options, theme, width } = context; context: QuestionRenderContext,
const add = (text = "") => lines.push(truncateToWidth(text, width)); model: ReturnType<typeof buildQuestionScreenModel>
const selectedOption = options[state.activeOptionIndex]; ) {
const answer = getAnswer(state, question.id); if (model.mode !== "preview") {
return;
}
if (selectedOption?.isCustomOption) { const { lines, width, theme } = context;
renderPreviewOptionList(state, options, theme, width, answer).forEach(add); const add = (text = "") => lines.push(truncateToWidth(text, width));
} else if (width >= UI_DIMENSIONS.previewWideMinWidth && options.length > 0) {
if (model.previewLayout === "custom") {
renderPreviewOptionList(model.rows, theme, width).forEach(add);
} else if (model.previewLayout === "wide") {
renderWidePreviewLayout( renderWidePreviewLayout(
add, add,
state, model.rows,
options,
theme, theme,
width, width,
answer, model.selectedOption
selectedOption
); );
} else { } else {
renderStackedPreviewLayout( renderStackedPreviewLayout(
add, add,
state, model.rows,
options,
theme, theme,
width, width,
answer, model.selectedOption
selectedOption
); );
} }
renderOptionEditorOrNote({ renderOptionDetail(lines, model.selectedOptionDetail, context);
...context,
questionId: question.id,
answer,
option: selectedOption,
selected: true,
// Custom preview options already render their inline editor/content above.
withGap: !selectedOption?.isCustomOption,
});
} }
function renderWidePreviewLayout( function renderWidePreviewLayout(
add: (text?: string) => void, add: (text?: string) => void,
state: QuestionRenderContext["state"], rows: OptionRowModel[],
options: QuestionRenderContext["options"],
theme: Theme, theme: Theme,
width: number, width: number,
answer: ReturnType<typeof getAnswer>, selectedOption: ReturnType<typeof buildQuestionScreenModel>["selectedOption"]
selectedOption: AskDisplayOption | undefined
) { ) {
const leftWidth = measurePreviewLeftWidth(options, width); const leftWidth = measurePreviewLeftWidth(rows, width);
const rightWidth = Math.max( const rightWidth = Math.max(
UI_DIMENSIONS.previewMinRightWidth, UI_DIMENSIONS.previewMinRightWidth,
width - leftWidth - 2 width - leftWidth - 2
); );
const leftPane = renderPreviewOptionList( const leftPane = renderPreviewOptionList(rows, theme, leftWidth);
state,
options,
theme,
leftWidth,
answer
);
const rightPane = renderPreviewPaneContent(selectedOption, theme, rightWidth); const rightPane = renderPreviewPaneContent(selectedOption, theme, rightWidth);
for (const line of mergeColumns(leftPane, rightPane, leftWidth, width)) { for (const line of mergeColumns(leftPane, rightPane, leftWidth, width)) {
add(line); add(line);
@@ -192,294 +144,140 @@ function renderWidePreviewLayout(
function renderStackedPreviewLayout( function renderStackedPreviewLayout(
add: (text?: string) => void, add: (text?: string) => void,
state: QuestionRenderContext["state"], rows: OptionRowModel[],
options: QuestionRenderContext["options"],
theme: Theme, theme: Theme,
width: number, width: number,
answer: ReturnType<typeof getAnswer>, selectedOption: ReturnType<typeof buildQuestionScreenModel>["selectedOption"]
selectedOption: AskDisplayOption | undefined
) { ) {
renderPreviewOptionList(state, options, theme, width, answer).forEach(add); renderPreviewOptionList(rows, theme, width).forEach(add);
add(""); add("");
renderPreviewPaneContent(selectedOption, theme, width).forEach(add); renderPreviewPaneContent(selectedOption, theme, width).forEach(add);
} }
function renderPreviewOptionList( function renderPreviewOptionList(
state: QuestionRenderContext["state"], rows: OptionRowModel[],
options: QuestionRenderContext["options"],
theme: Theme, theme: Theme,
width: number, width: number
answer: ReturnType<typeof getAnswer>
): string[] { ): string[] {
const lines: string[] = []; const lines: string[] = [];
for (let index = 0; index < options.length; index++) { for (const row of rows) {
const option = options[index];
const selected = index === state.activeOptionIndex;
const isAnsweredOption = option.isCustomOption
? !!answer?.customText
: isOptionSelected(answer, option.value);
pushWrappedText( pushWrappedText(
lines, lines,
`${index + 1}. ${option.label}`, `${row.index + 1}. ${row.label}`,
width, width,
theme, theme,
getOptionColor(isAnsweredOption, selected), row.color,
selected ? "❯ " : " ", row.pointer,
" " " "
); );
if (option.description) { renderOptionDescription(lines, row.description, width, theme);
pushWrappedText(
lines,
option.description,
width,
theme,
"muted",
" ",
" "
);
}
} }
return lines; return lines;
} }
function renderOptionMeta( function renderOptionDetail(
context: QuestionRenderContext & { lines: string[],
questionId: string; detail: OptionDetailModel | undefined,
answer: ReturnType<typeof getAnswer>; context: QuestionRenderContext,
option: AskDisplayOption; options: { suppressLeadingGap?: boolean } = {}
selected: boolean;
}
) { ) {
const { lines, option, theme, width } = context; if (!detail) {
if (option.description) {
pushWrappedText(
lines,
option.description,
width,
theme,
"muted",
" ",
" "
);
}
renderOptionEditorOrNote(context);
}
function renderOptionEditorOrNote(context: OptionDetailRenderContext) {
const { lines, option, withGap } = context;
if (!option) {
return; return;
} }
if (detail.withGap && !options.suppressLeadingGap) {
const renderState = getOptionDetailRenderState(context);
if (withGap && hasOptionDetailContent(renderState)) {
lines.push(""); 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; return;
} }
if (renderOptionContent(context, renderState)) { if (detail.kind === "saved-note") {
pushSavedNote({
lines,
note: detail.text,
width: context.width,
theme: context.theme,
indent: " ",
});
return; 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( pushWrappedText(
lines, lines,
renderState.customText, detail.text,
width, context.width,
theme, context.theme,
"muted", "muted",
" ", " ",
" " " "
); );
return true;
} }
function renderSavedOptionNote( function renderEditorWithIndent(args: {
context: OptionDetailRenderContext, lines: string[];
optionNote: string | undefined editor: QuestionRenderContext["editor"];
) { width: number;
if (!optionNote) { theme: Theme;
return; indent: string;
} padding: number;
const { lines, theme, width } = context; placeholder: string;
const { prefix, continuationPrefix } = getSavedNotePrefixes(theme, { }) {
indent: " ", const { lines, editor, width, theme, indent, padding, placeholder } = args;
});
pushWrappedText(
lines,
optionNote,
width,
theme,
"muted",
prefix,
continuationPrefix
);
}
function renderQuestionNoteEditor(
lines: string[],
editor: QuestionRenderContext["editor"],
width: number,
theme: Theme,
placeholder: string
) {
renderEditorBlock({ renderEditorBlock({
lines, lines,
editorLines: editor.render( editorLines: editor.render(
Math.max( Math.max(UI_DIMENSIONS.editorMinWidth, width - padding)
UI_DIMENSIONS.editorMinWidth,
width - UI_DIMENSIONS.editorContentPadding
)
), ),
width, width,
theme, theme,
indent: " ", indent,
availableWidth: width - 1, availableWidth: width - visibleWidth(indent),
placeholder, placeholder,
isEmpty: editor.getText().length === 0, 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( function renderInteractiveCustomOption(
context: QuestionRenderContext, lines: string[],
index: number, row: OptionRowModel,
pointer: string, context: QuestionRenderContext
prefix: string,
optionColor: ReturnType<typeof getOptionColor>
) { ) {
const { lines, editor, theme, width } = context;
pushWrappedText( pushWrappedText(
lines, lines,
`${index + 1}. ${prefix}Type your own`, `${row.index + 1}. ${row.prefix}${row.label}`,
width, context.width,
theme, context.theme,
optionColor, row.color,
pointer, row.pointer,
" ".repeat(visibleWidth(pointer)) " ".repeat(visibleWidth(row.pointer))
); );
renderIndentedEditor( renderEditorWithIndent({
lines, lines,
editor, editor: context.editor,
width, width: context.width,
theme, theme: context.theme,
UI_TEXT.editorPlaceholderInput indent: " ",
); padding: UI_DIMENSIONS.editorIndentedPadding,
placeholder:
row.detail?.kind === "editor" ? row.detail.placeholder : "Type answer...",
});
} }
function getOptionColor(isAnsweredOption: boolean, selected: boolean) { function renderOptionDescription(
if (isAnsweredOption) { lines: string[],
return "success"; description: string | undefined,
width: number,
theme: Theme
) {
if (!description) {
return;
} }
if (selected) { pushWrappedText(lines, description, width, theme, "muted", " ", " ");
return "accent";
}
return "text";
} }
+62 -222
View File
@@ -1,21 +1,13 @@
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; import { truncateToWidth } from "@mariozechner/pi-tui";
import { SUBMIT_CHOICES } from "../constants.ts"; import type { AskState } from "../types.ts";
import { serializeAnswer } from "../state/answers.ts"; import { UI_TEXT } from "./constants.ts";
import type { AskResult, AskState, AskStateAnswer } from "../types.ts";
import { UI_DIMENSIONS, UI_TEXT } from "./constants.ts";
import { import {
getSavedNotePrefixes,
mergeColumns, mergeColumns,
pushSavedNote,
pushWrappedText, pushWrappedText,
} from "./render-helpers.ts"; } from "./render-helpers.ts";
import type { Theme } from "./render-types.ts"; import type { Theme } from "./render-types.ts";
import { buildReviewScreenModel } from "./view-models/review.ts";
type ReviewAnswer = AskResult["answers"][string] & {
extraOptionNotes?: Array<{
label: string;
note: string;
}>;
};
export function renderSubmitScreen( export function renderSubmitScreen(
lines: string[], lines: string[],
@@ -23,22 +15,19 @@ export function renderSubmitScreen(
theme: Theme, theme: Theme,
width: number width: number
) { ) {
const showAllNotes = state.activeSubmitActionIndex === 1; const model = buildReviewScreenModel(state, width);
const actionColumnWidth = getSubmitActionColumnWidth(); if (model.layout === "wide") {
const rightWidth = Math.max(1, width - model.actionColumnWidth - 2);
if (shouldUseWideSubmitLayout(width, actionColumnWidth)) { const reviewLines = renderSubmitReviewLines(model, theme, rightWidth);
const rightWidth = Math.max(1, width - actionColumnWidth - 2); const actionLines = renderSubmitActions(
const reviewLines = renderSubmitReviewLines( model,
state,
theme, theme,
rightWidth, model.actionColumnWidth
showAllNotes
); );
const actionLines = renderSubmitActions(state, theme, actionColumnWidth);
for (const line of mergeColumns( for (const line of mergeColumns(
actionLines, actionLines,
reviewLines, reviewLines,
actionColumnWidth, model.actionColumnWidth,
width width
)) { )) {
lines.push(line); lines.push(line);
@@ -46,38 +35,25 @@ export function renderSubmitScreen(
return; return;
} }
const reviewLines = renderSubmitReviewLines( const reviewLines = renderSubmitReviewLines(model, theme, width);
state, const actionLines = renderSubmitActions(model, theme, width);
theme,
width,
showAllNotes
);
const actionLines = renderSubmitActions(state, theme, width);
lines.push(...reviewLines); lines.push(...reviewLines);
lines.push(""); lines.push("");
lines.push(...actionLines); lines.push(...actionLines);
} }
function renderSubmitReviewLines( function renderSubmitReviewLines(
state: AskState, model: ReturnType<typeof buildReviewScreenModel>,
theme: Theme, theme: Theme,
width: number, width: number
showAllNotes: boolean
): string[] { ): string[] {
const lines: string[] = []; const lines: string[] = [];
pushWrappedText(lines, UI_TEXT.reviewTitle, width, theme, "accent", " ", " "); pushWrappedText(lines, UI_TEXT.reviewTitle, width, theme, "accent", " ", " ");
lines.push(""); lines.push("");
for (let index = 0; index < state.questions.length; index++) { for (const [index, question] of model.questions.entries()) {
const question = state.questions[index]; renderReviewQuestion(lines, question, theme, width);
renderSubmittedQuestion( if (index < model.questions.length - 1) {
lines,
question,
toReviewAnswer(question, state.answers[question.id], showAllNotes),
theme,
width
);
if (index < state.questions.length - 1) {
lines.push(""); lines.push("");
} }
} }
@@ -85,225 +61,89 @@ function renderSubmitReviewLines(
return lines; return lines;
} }
function getSubmitActionColumnWidth(): number { function renderReviewQuestion(
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(
lines: string[], lines: string[],
question: AskState["questions"][number], question: ReturnType<typeof buildReviewScreenModel>["questions"][number],
answer: ReviewAnswer | undefined,
theme: Theme, theme: Theme,
width: number width: number
) { ) {
pushWrappedText(lines, question.label, width, theme, "text", " ", " "); pushWrappedText(lines, question.label, width, theme, "text", " ", " ");
if (!answer) { if (question.unanswered) {
lines.push( lines.push(
truncateToWidth(` ${theme.fg("dim", UI_TEXT.unanswered)}`, width) truncateToWidth(` ${theme.fg("dim", UI_TEXT.unanswered)}`, width)
); );
return; return;
} }
renderSubmittedAnswer(lines, answer, theme, width); if (question.note) {
} pushSavedNote({
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(
lines, lines,
`→ ${answerText}`, note: question.note,
width, width,
theme, theme,
isCustomOnlyAnswer(answer) ? "text" : "success", indent: " ",
" ", });
" "
);
} }
renderExtraOptionNotes(lines, answer, theme, width); for (const selection of question.selections ?? []) {
}
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;
pushWrappedText( pushWrappedText(
lines, lines,
`→ ${label}`, `→ ${selection.label}`,
width, width,
theme, theme,
"success", "success",
" ", " ",
" " " "
); );
if (selection.note) {
const note = answer.optionNotes?.[value]; pushSavedNote({
if (note) { lines,
renderSubmittedNote(lines, note, theme, width); note: selection.note,
width,
theme,
indent: " ",
});
} }
} }
}
function shouldRenderAnswersIndividually(answer: ReviewAnswer): boolean { if (question.answerText) {
if (!answer.labels.length) { pushWrappedText(
return false; lines,
} `→ ${question.answerText}`,
width,
return ( theme,
answer.labels.length > 1 || question.isCustomOnly ? "text" : "success",
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 (
!(hasSelectedAnswer || serialized.note) &&
extraOptionNotes.length === 0
) {
return;
} }
return { for (const optionNote of question.extraOptionNotes ?? []) {
...serialized, pushSavedNote({
extraOptionNotes: lines,
extraOptionNotes.length > 0 ? extraOptionNotes : undefined, note: optionNote.note,
}; width,
theme,
indent: " ",
label: optionNote.label,
});
}
} }
function renderSubmitActions( function renderSubmitActions(
state: AskState, model: ReturnType<typeof buildReviewScreenModel>,
theme: Theme, theme: Theme,
width: number width: number
): string[] { ): string[] {
const lines: string[] = []; const lines: string[] = [];
for (let index = 0; index < SUBMIT_CHOICES.length; index++) { for (const [index, action] of model.actions.entries()) {
const selected = index === state.activeSubmitActionIndex; const prefix = action.selected ? "❯ " : " ";
const prefix = selected ? "❯ " : " ";
pushWrappedText( pushWrappedText(
lines, lines,
`${index + 1}. ${SUBMIT_CHOICES[index]}`, `${index + 1}. ${action.label}`,
width, width,
theme, theme,
selected ? "accent" : "text", action.selected ? "accent" : "text",
prefix, prefix,
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");
});