refactor: split ask flow state and view models
This commit is contained in:
@@ -140,7 +140,7 @@ pnpm dev
|
||||
pnpm dev ../test
|
||||
```
|
||||
|
||||
`pnpm dev [path]` runs pi with `--no-extensions --no-skills --no-prompt-templates --no-themes --no-context-files`, loads this repo’s extension and `skills/ask-user`, and sets the working directory to `[path]` (defaults to `.`).
|
||||
`pnpm dev [path]` runs pi with `--no-extensions --no-skills --no-prompt-templates --no-themes --no-context-files`, loads this repo’s extension and `skills/ask-user`, and starts pi from `[path]` by changing directories before launch (defaults to `.`).
|
||||
|
||||
### Install dependencies
|
||||
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@
|
||||
"CHANGELOG.md"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "sh -c 'pi --no-extensions --no-skills --no-prompt-templates --no-themes --no-context-files -e ./src/index.ts --skill ./skills/ask-user --cd \"${1:-.}\"' --",
|
||||
"dev": "sh -c 'ROOT=\"$PWD\"; TARGET=\"${1:-.}\"; if [ \"$TARGET\" = \"--\" ]; then TARGET=\"${2:-.}\"; fi; cd \"$TARGET\" && pi --no-extensions --no-skills --no-prompt-templates --no-themes --no-context-files -e \"$ROOT/src/index.ts\" --skill \"$ROOT/skills/ask-user\"' --",
|
||||
"test": "node --test tests/*.test.ts",
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"format": "biome format --write .",
|
||||
|
||||
@@ -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
@@ -1,187 +1,52 @@
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { Text, truncateToWidth } from "@mariozechner/pi-tui";
|
||||
import { renderResultText } from "./result.ts";
|
||||
import { AskParamsSchema } from "./schema.ts";
|
||||
import { createInitialState } from "./state/create.ts";
|
||||
import { collectValidationIssues } from "./state/normalize.ts";
|
||||
import { summarizeResult, toAskResult } from "./state/result.ts";
|
||||
import type {
|
||||
AskParams,
|
||||
AskQuestionInput,
|
||||
AskResult,
|
||||
AskValidationIssue,
|
||||
} from "./types.ts";
|
||||
import { UI_DIMENSIONS } from "./ui/constants.ts";
|
||||
ExtensionAPI,
|
||||
ExtensionContext,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import {
|
||||
ASK_TOOL_DESCRIPTION,
|
||||
ASK_TOOL_PROMPT_GUIDELINES,
|
||||
invalidPayloadResponse,
|
||||
nonInteractiveResponse,
|
||||
renderAskToolCall,
|
||||
renderAskToolResult,
|
||||
successfulResponse,
|
||||
validateParams,
|
||||
} from "./ask-tool-helpers.ts";
|
||||
import { AskParamsSchema } from "./schema.ts";
|
||||
import type { AskParams } from "./types.ts";
|
||||
import { runAskFlow } from "./ui/controller.ts";
|
||||
|
||||
export function registerAskTool(pi: ExtensionAPI) {
|
||||
pi.registerTool({
|
||||
name: "ask_user",
|
||||
label: "Ask User",
|
||||
description:
|
||||
"Interactive clarification tool for cases where the next step depends on user preferences, missing requirements, or choosing between multiple valid directions. Ask a short structured interview, collect normalized answers, and continue using those answers explicitly instead of guessing. Supports single-select, multi-select, and preview-pane questions. Always include a machine-readable `value` for every option. Use `preview` only when every option includes `preview` text; descriptions alone are not enough.",
|
||||
description: ASK_TOOL_DESCRIPTION,
|
||||
promptSnippet:
|
||||
"Clarify ambiguous or preference-sensitive decisions with a short interactive interview before proceeding",
|
||||
promptGuidelines: [
|
||||
"Use this tool before making preference-sensitive decisions about scope, tone, UX, naming, architecture, docs, or implementation direction.",
|
||||
"When multiple valid directions exist, ask 1-3 concise questions instead of committing to one path on your own.",
|
||||
"Prefer one focused decision per question and use short labels with 2-4 clear options.",
|
||||
"Always include a non-empty `value` for every option.",
|
||||
'Use `type: "single"` by default and `type: "multi"` only when several answers can genuinely apply.',
|
||||
'Use `type: "preview"` only when every option includes non-empty `preview` text for the dedicated preview pane. Option descriptions do not satisfy this requirement.',
|
||||
"After clarifying a note or follow-up question, prefer another structured ask_user follow-up if a choice is still needed instead of switching to plain-text multiple choice in chat.",
|
||||
"When prior answers already narrow the branch, bundle the next 2-3 related unresolved decisions into one follow-up ask instead of issuing a long sequence of single-question asks.",
|
||||
"Use one-at-a-time follow-up asks only when the next question materially depends on the previous answer.",
|
||||
],
|
||||
promptGuidelines: [...ASK_TOOL_PROMPT_GUIDELINES],
|
||||
parameters: AskParamsSchema,
|
||||
|
||||
async execute(_toolCallId, params: AskParams, _signal, _onUpdate, ctx) {
|
||||
const validation = validateParams(params);
|
||||
if (!validation.ok) {
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: formatValidationError(validation.issues) },
|
||||
],
|
||||
details: errorResultDetails(params, validation.issues),
|
||||
};
|
||||
}
|
||||
|
||||
if (!ctx.hasUI) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: formatNonInteractiveMessage(validation.state),
|
||||
},
|
||||
],
|
||||
details: {
|
||||
...toAskResult(validation.state),
|
||||
cancelled: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const result = await runAskFlow(ctx, params);
|
||||
return {
|
||||
content: [{ type: "text", text: summarizeResult(result) }],
|
||||
details: result,
|
||||
};
|
||||
},
|
||||
|
||||
renderCall(args, theme) {
|
||||
const params = args as AskParams;
|
||||
const labels = Array.isArray(params.questions)
|
||||
? params.questions
|
||||
.map(
|
||||
(question: AskQuestionInput, index) =>
|
||||
question.label || `Q${index + 1}`
|
||||
)
|
||||
.join(", ")
|
||||
: "";
|
||||
let text = theme.fg("toolTitle", theme.bold("ask_user "));
|
||||
text += theme.fg("muted", `${params.questions?.length ?? 0} question(s)`);
|
||||
if (labels) {
|
||||
text += theme.fg(
|
||||
"dim",
|
||||
` (${truncateToWidth(labels, UI_DIMENSIONS.callLabelTruncateWidth)})`
|
||||
);
|
||||
}
|
||||
return new Text(text, 0, 0);
|
||||
},
|
||||
|
||||
renderResult(result, _options, theme) {
|
||||
const details = result.details as AskResult | undefined;
|
||||
if (!details) {
|
||||
const text = result.content[0];
|
||||
return new Text(text?.type === "text" ? text.text : "", 0, 0);
|
||||
}
|
||||
|
||||
if (details.error) {
|
||||
return new Text(theme.fg("warning", "Invalid input"), 0, 0);
|
||||
}
|
||||
if (details.cancelled) {
|
||||
return new Text(theme.fg("warning", "Cancelled"), 0, 0);
|
||||
}
|
||||
|
||||
return new Text(renderResultText(details), 0, 0);
|
||||
},
|
||||
execute: executeAskTool,
|
||||
renderCall: renderAskToolCall,
|
||||
renderResult: renderAskToolResult,
|
||||
});
|
||||
}
|
||||
|
||||
function errorResultDetails(
|
||||
interface ExecuteContext extends Pick<ExtensionContext, "cwd" | "hasUI"> {}
|
||||
|
||||
async function executeAskTool(
|
||||
_toolCallId: string,
|
||||
params: AskParams,
|
||||
issues: AskValidationIssue[]
|
||||
): AskResult {
|
||||
try {
|
||||
const state = createInitialState(params);
|
||||
return {
|
||||
...toAskResult(state),
|
||||
cancelled: true,
|
||||
error: {
|
||||
kind: "invalid_input",
|
||||
issues,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
title: params.title,
|
||||
cancelled: true,
|
||||
mode: "submit",
|
||||
questions: [],
|
||||
answers: {},
|
||||
error: {
|
||||
kind: "invalid_input",
|
||||
issues,
|
||||
},
|
||||
};
|
||||
_signal: AbortSignal | undefined,
|
||||
_onUpdate: unknown,
|
||||
ctx: ExecuteContext
|
||||
) {
|
||||
const validation = validateParams(params);
|
||||
if (!validation.ok) {
|
||||
return invalidPayloadResponse(params, validation.issues);
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
state: createInitialState(params),
|
||||
};
|
||||
}
|
||||
|
||||
function formatValidationError(issues: AskValidationIssue[]): string {
|
||||
const lines = [
|
||||
"Invalid ask_user payload:",
|
||||
...issues.map((issue) => `- ${issue.path}: ${issue.message}`),
|
||||
];
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function formatNonInteractiveMessage(
|
||||
state: ReturnType<typeof createInitialState>
|
||||
): string {
|
||||
const lines = [
|
||||
"Needs user input: ask_user requires interactive UI.",
|
||||
"Run same tool call in interactive session, or ask user these questions manually:",
|
||||
];
|
||||
|
||||
for (const [index, question] of state.questions.entries()) {
|
||||
lines.push(`${index + 1}. ${question.label}: ${question.prompt}`);
|
||||
for (const option of question.options) {
|
||||
lines.push(` - ${option.label} [${option.value}]`);
|
||||
}
|
||||
lines.push(" - Type your own [custom]");
|
||||
}
|
||||
|
||||
lines.push(
|
||||
"details.questions contains normalized pending questions. details.answers stays empty until user responds."
|
||||
);
|
||||
return lines.join("\n");
|
||||
const result = await runAskFlow(ctx as never, params);
|
||||
return successfulResponse(result);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ELABORATED_SUMMARY } from "./constants.ts";
|
||||
import { isCustomOnlyAnswer } from "./state/answers.ts";
|
||||
import type { AskResult } from "./types.ts";
|
||||
|
||||
export function formatResultLines(
|
||||
@@ -51,10 +52,6 @@ function formatAnswerLine(
|
||||
return `✓ ${questionLabel}: ${answerText}`;
|
||||
}
|
||||
|
||||
function isCustomOnlyAnswer(answer: AskResult["answers"][string]): boolean {
|
||||
return answer.indices.length === 0 && !!answer.customText;
|
||||
}
|
||||
|
||||
function formatQuestionNoteLine(
|
||||
questionLabel: string,
|
||||
note: string | undefined,
|
||||
|
||||
@@ -5,6 +5,11 @@ import type {
|
||||
AskStateAnswer,
|
||||
} from "../types.ts";
|
||||
|
||||
export interface ExtraOptionNote {
|
||||
label: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export function emptyAnswer(): AskStateAnswer {
|
||||
return { selected: [] };
|
||||
}
|
||||
@@ -135,6 +140,34 @@ export function isAnswerAnswered(answer?: AskStateAnswer): boolean {
|
||||
return answer.selected.length > 0 || !!answer.customText?.trim();
|
||||
}
|
||||
|
||||
export function hasAnswerNotes(answer?: AskStateAnswer): boolean {
|
||||
return !!(answer?.note || answer?.optionNotes);
|
||||
}
|
||||
|
||||
export function isResultAnswerEmpty(answer: AskResultAnswer): boolean {
|
||||
return (
|
||||
answer.values.length === 0 &&
|
||||
answer.labels.length === 0 &&
|
||||
answer.indices.length === 0 &&
|
||||
!answer.customText &&
|
||||
!answer.note &&
|
||||
(!answer.optionNotes || Object.keys(answer.optionNotes).length === 0)
|
||||
);
|
||||
}
|
||||
|
||||
export function isResultAnswerCommitted(answer: AskResultAnswer): boolean {
|
||||
return (
|
||||
answer.values.length > 0 ||
|
||||
answer.labels.length > 0 ||
|
||||
answer.indices.length > 0 ||
|
||||
!!answer.customText
|
||||
);
|
||||
}
|
||||
|
||||
export function isCustomOnlyAnswer(answer: AskResultAnswer): boolean {
|
||||
return answer.indices.length === 0 && !!answer.customText;
|
||||
}
|
||||
|
||||
export function isOptionSelected(
|
||||
answer: AskStateAnswer | undefined,
|
||||
optionValue: string
|
||||
@@ -178,6 +211,23 @@ export function serializeAnswer(answer: AskStateAnswer): AskResultAnswer {
|
||||
};
|
||||
}
|
||||
|
||||
export function getExtraOptionNotes(args: {
|
||||
answer: AskStateAnswer;
|
||||
questionOptions: Array<{ value: string; label: string }>;
|
||||
selectedValues?: Iterable<string>;
|
||||
}): ExtraOptionNote[] {
|
||||
const selectedValues = new Set(args.selectedValues ?? []);
|
||||
return Object.entries(args.answer.optionNotes ?? {})
|
||||
.filter(([value, note]) => !selectedValues.has(value) && Boolean(note))
|
||||
.map(([value, note]) => {
|
||||
const option = args.questionOptions.find(
|
||||
(candidate) => candidate.value === value
|
||||
);
|
||||
return option ? { label: option.label, note } : undefined;
|
||||
})
|
||||
.filter((entry): entry is ExtraOptionNote => Boolean(entry));
|
||||
}
|
||||
|
||||
function cloneSelection(selection: AskSelectedOption): AskSelectedOption {
|
||||
return { ...selection };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -6,22 +6,25 @@ import type {
|
||||
AskValidationIssue,
|
||||
} from "../types.ts";
|
||||
|
||||
interface IssueCollector {
|
||||
add: (path: string, message: string) => void;
|
||||
issues: AskValidationIssue[];
|
||||
}
|
||||
|
||||
export function normalizeQuestions(params: AskParams): AskQuestion[] {
|
||||
const issues = collectValidationIssues(params);
|
||||
if (issues.length > 0) {
|
||||
throw new Error(issues[0]?.message ?? "Invalid ask_user payload");
|
||||
}
|
||||
return params.questions.map((question, index) =>
|
||||
normalizeQuestion(question, index)
|
||||
);
|
||||
return params.questions.map(normalizeQuestion);
|
||||
}
|
||||
|
||||
export function collectValidationIssues(
|
||||
params: AskParams
|
||||
): AskValidationIssue[] {
|
||||
const issues: AskValidationIssue[] = [];
|
||||
validateQuestions(params.questions, issues);
|
||||
return issues;
|
||||
const collector = createIssueCollector();
|
||||
validateQuestions(params.questions, collector);
|
||||
return collector.issues;
|
||||
}
|
||||
|
||||
function normalizeQuestion(
|
||||
@@ -49,19 +52,16 @@ function normalizeOption(option: AskOption): AskOption {
|
||||
|
||||
function validateQuestions(
|
||||
questions: AskParams["questions"],
|
||||
issues: AskValidationIssue[]
|
||||
): void {
|
||||
collector: IssueCollector
|
||||
) {
|
||||
if (questions.length === 0) {
|
||||
issues.push({
|
||||
path: "questions",
|
||||
message: "At least one question is required",
|
||||
});
|
||||
collector.add("questions", "At least one question is required");
|
||||
return;
|
||||
}
|
||||
|
||||
const questionIds = new Set<string>();
|
||||
for (const [questionIndex, question] of questions.entries()) {
|
||||
validateQuestion(question, questionIndex, questionIds, issues);
|
||||
validateQuestion(question, questionIndex, questionIds, collector);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,49 +69,49 @@ function validateQuestion(
|
||||
question: AskQuestionInput,
|
||||
questionIndex: number,
|
||||
questionIds: Set<string>,
|
||||
issues: AskValidationIssue[]
|
||||
): void {
|
||||
collector: IssueCollector
|
||||
) {
|
||||
const questionNumber = questionIndex + 1;
|
||||
const questionPath = `questions[${questionIndex}]`;
|
||||
const questionId = question.id?.trim();
|
||||
const questionType = normalizeQuestionType(question.type);
|
||||
assertValidQuestionType(
|
||||
|
||||
validateQuestionType(
|
||||
question.type,
|
||||
`Question ${questionNumber}: invalid type ${JSON.stringify(question.type)}; expected "single", "multi", or "preview"`,
|
||||
issues,
|
||||
questionNumber,
|
||||
collector,
|
||||
`${questionPath}.type`
|
||||
);
|
||||
|
||||
assertNonEmpty(
|
||||
assertRequired(
|
||||
questionId,
|
||||
`Question ${questionNumber}: id is required`,
|
||||
issues,
|
||||
`${questionPath}.id`
|
||||
collector,
|
||||
`${questionPath}.id`,
|
||||
`Question ${questionNumber}: id is required`
|
||||
);
|
||||
assertUnique(
|
||||
questionIds,
|
||||
questionId,
|
||||
`Question ${questionNumber}: duplicate question id "${questionId}"`,
|
||||
issues,
|
||||
`${questionPath}.id`
|
||||
collector,
|
||||
`${questionPath}.id`,
|
||||
`Question ${questionNumber}: duplicate question id "${questionId}"`
|
||||
);
|
||||
assertOptionalNonEmpty(
|
||||
assertOptionalText(
|
||||
question.label,
|
||||
`Question ${questionNumber}: label must not be empty`,
|
||||
issues,
|
||||
`${questionPath}.label`
|
||||
collector,
|
||||
`${questionPath}.label`,
|
||||
`Question ${questionNumber}: label must not be empty`
|
||||
);
|
||||
assertNonEmpty(
|
||||
assertRequired(
|
||||
question.prompt?.trim(),
|
||||
`Question ${questionNumber}: prompt is required`,
|
||||
issues,
|
||||
`${questionPath}.prompt`
|
||||
collector,
|
||||
`${questionPath}.prompt`,
|
||||
`Question ${questionNumber}: prompt is required`
|
||||
);
|
||||
assertHasItems(
|
||||
question.options,
|
||||
`Question ${questionNumber}: at least one option is required`,
|
||||
issues,
|
||||
`${questionPath}.options`
|
||||
collector,
|
||||
`${questionPath}.options`,
|
||||
`Question ${questionNumber}: at least one option is required`
|
||||
);
|
||||
|
||||
const optionValues = new Set<string>();
|
||||
@@ -122,7 +122,7 @@ function validateQuestion(
|
||||
optionValues,
|
||||
questionNumber,
|
||||
questionType,
|
||||
issues,
|
||||
collector,
|
||||
`${questionPath}.options[${optionIndex}]`
|
||||
);
|
||||
}
|
||||
@@ -134,50 +134,51 @@ function validateOption(
|
||||
optionValues: Set<string>,
|
||||
questionNumber: number,
|
||||
questionType: AskQuestion["type"],
|
||||
issues: AskValidationIssue[],
|
||||
collector: IssueCollector,
|
||||
optionPath: string
|
||||
): void {
|
||||
) {
|
||||
const optionNumber = optionIndex + 1;
|
||||
const prefix = `Question ${questionNumber}, option ${optionNumber}`;
|
||||
const optionValue = option.value?.trim();
|
||||
const optionPreview = option.preview?.trim();
|
||||
|
||||
assertNonEmpty(
|
||||
assertRequired(
|
||||
optionValue,
|
||||
`${prefix}: value is required`,
|
||||
issues,
|
||||
`${optionPath}.value`
|
||||
collector,
|
||||
`${optionPath}.value`,
|
||||
`${prefix}: value is required`
|
||||
);
|
||||
assertUnique(
|
||||
optionValues,
|
||||
optionValue,
|
||||
`${prefix}: duplicate option value "${optionValue}"`,
|
||||
issues,
|
||||
`${optionPath}.value`
|
||||
collector,
|
||||
`${optionPath}.value`,
|
||||
`${prefix}: duplicate option value "${optionValue}"`
|
||||
);
|
||||
assertNonEmpty(
|
||||
assertRequired(
|
||||
option.label?.trim(),
|
||||
`${prefix}: label is required`,
|
||||
issues,
|
||||
`${optionPath}.label`
|
||||
collector,
|
||||
`${optionPath}.label`,
|
||||
`${prefix}: label is required`
|
||||
);
|
||||
assertOptionalNonEmpty(
|
||||
assertOptionalText(
|
||||
option.description,
|
||||
`${prefix}: description must not be empty`,
|
||||
issues,
|
||||
`${optionPath}.description`
|
||||
collector,
|
||||
`${optionPath}.description`,
|
||||
`${prefix}: description must not be empty`
|
||||
);
|
||||
assertOptionalNonEmpty(
|
||||
assertOptionalText(
|
||||
option.preview,
|
||||
`${prefix}: preview must not be empty`,
|
||||
issues,
|
||||
`${optionPath}.preview`
|
||||
collector,
|
||||
`${optionPath}.preview`,
|
||||
`${prefix}: preview must not be empty`
|
||||
);
|
||||
if (questionType === "preview") {
|
||||
assertNonEmpty(
|
||||
option.preview?.trim(),
|
||||
`${prefix}: preview questions require preview text for every option; add preview text or use type "single" instead`,
|
||||
issues,
|
||||
`${optionPath}.preview`
|
||||
assertRequired(
|
||||
optionPreview,
|
||||
collector,
|
||||
`${optionPath}.preview`,
|
||||
`${prefix}: preview questions require preview text for every option; add preview text or use type "single" instead`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -188,68 +189,81 @@ function normalizeQuestionType(
|
||||
return value === "multi" || value === "preview" ? value : "single";
|
||||
}
|
||||
|
||||
function assertValidQuestionType(
|
||||
function validateQuestionType(
|
||||
value: AskQuestionInput["type"],
|
||||
errorMessage: string,
|
||||
issues: AskValidationIssue[],
|
||||
questionNumber: number,
|
||||
collector: IssueCollector,
|
||||
path: string
|
||||
): void {
|
||||
) {
|
||||
if (
|
||||
value !== undefined &&
|
||||
value !== "single" &&
|
||||
value !== "multi" &&
|
||||
value !== "preview"
|
||||
) {
|
||||
issues.push({ path, message: errorMessage });
|
||||
collector.add(
|
||||
path,
|
||||
`Question ${questionNumber}: invalid type ${JSON.stringify(value)}; expected "single", "multi", or "preview"`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertHasItems(
|
||||
items: unknown[],
|
||||
errorMessage: string,
|
||||
issues: AskValidationIssue[],
|
||||
path: string
|
||||
): void {
|
||||
collector: IssueCollector,
|
||||
path: string,
|
||||
message: string
|
||||
) {
|
||||
if (items.length === 0) {
|
||||
issues.push({ path, message: errorMessage });
|
||||
collector.add(path, message);
|
||||
}
|
||||
}
|
||||
|
||||
function assertNonEmpty(
|
||||
function assertRequired(
|
||||
value: string | undefined,
|
||||
errorMessage: string,
|
||||
issues: AskValidationIssue[],
|
||||
path: string
|
||||
): void {
|
||||
collector: IssueCollector,
|
||||
path: string,
|
||||
message: string
|
||||
) {
|
||||
if (!value) {
|
||||
issues.push({ path, message: errorMessage });
|
||||
collector.add(path, message);
|
||||
}
|
||||
}
|
||||
|
||||
function assertOptionalNonEmpty(
|
||||
function assertOptionalText(
|
||||
value: string | undefined,
|
||||
errorMessage: string,
|
||||
issues: AskValidationIssue[],
|
||||
path: string
|
||||
): void {
|
||||
collector: IssueCollector,
|
||||
path: string,
|
||||
message: string
|
||||
) {
|
||||
if (value !== undefined && !value.trim()) {
|
||||
issues.push({ path, message: errorMessage });
|
||||
collector.add(path, message);
|
||||
}
|
||||
}
|
||||
|
||||
function assertUnique(
|
||||
seen: Set<string>,
|
||||
value: string | undefined,
|
||||
errorMessage: string,
|
||||
issues: AskValidationIssue[],
|
||||
path: string
|
||||
): void {
|
||||
collector: IssueCollector,
|
||||
path: string,
|
||||
message: string
|
||||
) {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
if (seen.has(value)) {
|
||||
issues.push({ path, message: errorMessage });
|
||||
collector.add(path, message);
|
||||
return;
|
||||
}
|
||||
seen.add(value);
|
||||
}
|
||||
|
||||
function createIssueCollector(): IssueCollector {
|
||||
const issues: AskValidationIssue[] = [];
|
||||
return {
|
||||
issues,
|
||||
add(path, message) {
|
||||
issues.push({ path, message });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+58
-27
@@ -15,11 +15,23 @@ import type {
|
||||
} from "../types.ts";
|
||||
import {
|
||||
cloneResultAnswer,
|
||||
getExtraOptionNotes,
|
||||
hasAnswerNotes,
|
||||
isAnswerAnswered,
|
||||
isAnswerEmpty,
|
||||
isOptionSelected,
|
||||
isResultAnswerCommitted,
|
||||
isResultAnswerEmpty,
|
||||
serializeAnswer,
|
||||
} from "./answers.ts";
|
||||
import { getQuestionOptionByValue } from "./selectors.ts";
|
||||
|
||||
export type ReviewAnswer = AskResult["answers"][string] & {
|
||||
extraOptionNotes?: Array<{
|
||||
label: string;
|
||||
note: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export function toAskResult(state: AskState): AskResult {
|
||||
const answers = Object.fromEntries(
|
||||
@@ -29,8 +41,8 @@ export function toAskResult(state: AskState): AskResult {
|
||||
)
|
||||
.filter(([, answer]) =>
|
||||
state.mode === "elaborate"
|
||||
? isSerializedAnswerCommitted(answer)
|
||||
: !isSerializedAnswerEmpty(answer)
|
||||
? isResultAnswerCommitted(answer)
|
||||
: !isResultAnswerEmpty(answer)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -72,19 +84,6 @@ export function hasAnswerContent(state: AskState, questionId: string): boolean {
|
||||
return !!answer && !isAnswerEmpty(answer);
|
||||
}
|
||||
|
||||
function isSerializedAnswerEmpty(
|
||||
answer: AskResult["answers"][string]
|
||||
): boolean {
|
||||
return (
|
||||
answer.values.length === 0 &&
|
||||
answer.labels.length === 0 &&
|
||||
answer.indices.length === 0 &&
|
||||
!answer.customText &&
|
||||
!answer.note &&
|
||||
(!answer.optionNotes || Object.keys(answer.optionNotes).length === 0)
|
||||
);
|
||||
}
|
||||
|
||||
function serializeContinuation(
|
||||
state: AskState,
|
||||
answers: AskResult["answers"]
|
||||
@@ -95,7 +94,7 @@ function serializeContinuation(
|
||||
|
||||
for (const question of state.questions) {
|
||||
const answer = state.answers[question.id];
|
||||
const hasClarificationNeed = !!(answer?.note || answer?.optionNotes);
|
||||
const hasClarificationNeed = hasAnswerNotes(answer);
|
||||
const committedAnswer = answers[question.id];
|
||||
const answered = !!committedAnswer;
|
||||
|
||||
@@ -138,7 +137,7 @@ function serializeElaborationItemsForQuestion(
|
||||
question: AskState["questions"][number],
|
||||
answer: AskStateAnswer | undefined
|
||||
): AskElaborationPayload["items"] {
|
||||
if (!(answer?.note || answer?.optionNotes)) {
|
||||
if (!(answer && hasAnswerNotes(answer))) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -158,9 +157,7 @@ function serializeElaborationItemsForQuestion(
|
||||
}
|
||||
|
||||
for (const [value, note] of Object.entries(answer.optionNotes ?? {})) {
|
||||
const option = question.options.find(
|
||||
(candidate) => candidate.value === value
|
||||
);
|
||||
const option = getQuestionOptionByValue(question, value);
|
||||
if (!(option && note)) {
|
||||
continue;
|
||||
}
|
||||
@@ -212,13 +209,47 @@ function toCommittedResultAnswer(
|
||||
return cloneResultAnswer(serializeAnswer(answer));
|
||||
}
|
||||
|
||||
function isSerializedAnswerCommitted(
|
||||
answer: AskResult["answers"][string]
|
||||
): boolean {
|
||||
export function toReviewAnswer(
|
||||
question: AskState["questions"][number],
|
||||
answer: AskStateAnswer | undefined,
|
||||
showAllNotes: boolean
|
||||
): ReviewAnswer | undefined {
|
||||
if (!answer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const serialized = serializeAnswer(answer);
|
||||
const hasCommittedAnswer = isResultAnswerCommitted(serialized);
|
||||
if (!showAllNotes) {
|
||||
return hasCommittedAnswer ? serialized : undefined;
|
||||
}
|
||||
|
||||
const extraOptionNotes = getExtraOptionNotes({
|
||||
answer,
|
||||
questionOptions: question.options,
|
||||
selectedValues: serialized.values,
|
||||
});
|
||||
if (
|
||||
!(hasCommittedAnswer || serialized.note) &&
|
||||
extraOptionNotes.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
...serialized,
|
||||
extraOptionNotes:
|
||||
extraOptionNotes.length > 0 ? extraOptionNotes : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldRenderAnswersIndividually(answer: ReviewAnswer): boolean {
|
||||
if (!answer.labels.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
answer.values.length > 0 ||
|
||||
answer.labels.length > 0 ||
|
||||
answer.indices.length > 0 ||
|
||||
!!answer.customText
|
||||
answer.labels.length > 1 ||
|
||||
Boolean(answer.optionNotes && Object.keys(answer.optionNotes).length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
+14
-10
@@ -7,6 +7,12 @@ import type {
|
||||
} from "../types.ts";
|
||||
import { isAnswerAnswered } from "./answers.ts";
|
||||
|
||||
const CUSTOM_OPTION: AskDisplayOption = {
|
||||
value: OTHER_OPTION_VALUE,
|
||||
label: OTHER_OPTION_LABEL,
|
||||
isCustomOption: true,
|
||||
};
|
||||
|
||||
export function getCurrentQuestion(state: AskState): AskQuestion | undefined {
|
||||
return state.questions[state.activeTabIndex];
|
||||
}
|
||||
@@ -28,16 +34,7 @@ export function getRenderableOptions(
|
||||
if (!question) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const options: AskDisplayOption[] = question.options.map((option) => ({
|
||||
...option,
|
||||
}));
|
||||
options.push({
|
||||
value: OTHER_OPTION_VALUE,
|
||||
label: OTHER_OPTION_LABEL,
|
||||
isCustomOption: true,
|
||||
});
|
||||
return options;
|
||||
return [...question.options, CUSTOM_OPTION];
|
||||
}
|
||||
|
||||
export function getCurrentOption(
|
||||
@@ -48,6 +45,13 @@ export function getCurrentOption(
|
||||
];
|
||||
}
|
||||
|
||||
export function getQuestionOptionByValue(
|
||||
question: AskQuestion,
|
||||
optionValue: string
|
||||
) {
|
||||
return question.options.find((option) => option.value === optionValue);
|
||||
}
|
||||
|
||||
export function getAnswer(
|
||||
state: AskState,
|
||||
questionId: string
|
||||
|
||||
+76
-162
@@ -9,6 +9,13 @@ import {
|
||||
setSingleSelection,
|
||||
toggleSelection,
|
||||
} from "./answers.ts";
|
||||
import {
|
||||
cancelFlow as cancelFlowBase,
|
||||
createInitialState as createInitialStateBase,
|
||||
dismissFlow as dismissFlowBase,
|
||||
moveOption as moveOptionBase,
|
||||
moveTab as moveTabBase,
|
||||
} from "./navigation.ts";
|
||||
import {
|
||||
getAnswer,
|
||||
getCurrentOption,
|
||||
@@ -19,7 +26,6 @@ import {
|
||||
} from "./selectors.ts";
|
||||
import {
|
||||
inputView,
|
||||
isEditingView,
|
||||
navigateView,
|
||||
optionNoteView,
|
||||
questionNoteView,
|
||||
@@ -32,26 +38,31 @@ export function createInitialState(params: {
|
||||
title?: string;
|
||||
questions: AskState["questions"];
|
||||
}): AskState {
|
||||
return {
|
||||
title: params.title,
|
||||
questions: params.questions,
|
||||
activeTabIndex: 0,
|
||||
activeOptionIndex: 0,
|
||||
activeSubmitActionIndex: 0,
|
||||
view: navigateView(),
|
||||
answers: {},
|
||||
completed: false,
|
||||
cancelled: false,
|
||||
mode: "submit",
|
||||
};
|
||||
return createInitialStateBase(params);
|
||||
}
|
||||
|
||||
export function moveTab(state: AskState, delta: number): AskState {
|
||||
return moveTabBase(state, delta);
|
||||
}
|
||||
|
||||
export function moveOption(state: AskState, delta: number): AskState {
|
||||
return moveOptionBase(state, delta);
|
||||
}
|
||||
|
||||
export function cancelFlow(state: AskState): AskState {
|
||||
return cancelFlowBase(state);
|
||||
}
|
||||
|
||||
export function dismissFlow(state: AskState): AskState {
|
||||
return dismissFlowBase(state);
|
||||
}
|
||||
|
||||
export function reduceAskState(state: AskState, action: AskAction): AskState {
|
||||
switch (action.type) {
|
||||
case "MOVE_TAB":
|
||||
return moveTabBy(state, action.delta);
|
||||
return moveTabBase(state, action.delta);
|
||||
case "MOVE_OPTION":
|
||||
return moveOptionBy(state, action.delta);
|
||||
return moveOptionBase(state, action.delta);
|
||||
case "OPEN_INPUT":
|
||||
return setView(state, inputView(action.questionId));
|
||||
case "OPEN_QUESTION_NOTE":
|
||||
@@ -72,26 +83,12 @@ export function reduceAskState(state: AskState, action: AskAction): AskState {
|
||||
case "SAVE_NOTE":
|
||||
return saveNoteValue(state, action.value);
|
||||
case "CANCEL":
|
||||
return cancelFlow(state);
|
||||
return cancelFlowBase(state);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export function moveTab(state: AskState, delta: number): AskState {
|
||||
return reduceAskState(state, {
|
||||
type: "MOVE_TAB",
|
||||
delta: delta < 0 ? -1 : 1,
|
||||
});
|
||||
}
|
||||
|
||||
export function moveOption(state: AskState, delta: number): AskState {
|
||||
return reduceAskState(state, {
|
||||
type: "MOVE_OPTION",
|
||||
delta: delta < 0 ? -1 : 1,
|
||||
});
|
||||
}
|
||||
|
||||
export function enterInputMode(state: AskState, questionId: string): AskState {
|
||||
return reduceAskState(state, { type: "OPEN_INPUT", questionId });
|
||||
}
|
||||
@@ -116,37 +113,7 @@ export function enterOptionNoteMode(
|
||||
}
|
||||
|
||||
export function toggleCurrentOption(state: AskState): AskState {
|
||||
const question = getCurrentQuestion(state);
|
||||
if (!question) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const option = getCurrentOption(state);
|
||||
if (!option) {
|
||||
return state;
|
||||
}
|
||||
if (option.isCustomOption) {
|
||||
return setView(state, inputView(question.id));
|
||||
}
|
||||
|
||||
if (question.type === "multi") {
|
||||
return updateAnswer(state, question.id, (answer) =>
|
||||
toggleSelection(answer, option, state.activeOptionIndex)
|
||||
);
|
||||
}
|
||||
|
||||
return updateAnswer(state, question.id, (answer) => {
|
||||
const isSelected = answer.selected.some(
|
||||
(selection) => selection.value === option.value
|
||||
);
|
||||
if (isSelected) {
|
||||
return {
|
||||
...answer,
|
||||
selected: [],
|
||||
};
|
||||
}
|
||||
return setSingleSelection(answer, option, state.activeOptionIndex);
|
||||
});
|
||||
return activateCurrentOption(state, "toggle");
|
||||
}
|
||||
|
||||
export function toggleCurrentMultiOption(state: AskState): AskState {
|
||||
@@ -155,36 +122,9 @@ export function toggleCurrentMultiOption(state: AskState): AskState {
|
||||
|
||||
export function confirmCurrentSelection(state: AskState): AskState {
|
||||
if (isSubmitTab(state)) {
|
||||
if (state.activeSubmitActionIndex === 2) {
|
||||
return { ...state, cancelled: true, completed: true };
|
||||
return completeSubmitAction(state);
|
||||
}
|
||||
if (state.activeSubmitActionIndex === 1) {
|
||||
return { ...state, mode: "elaborate", completed: true };
|
||||
}
|
||||
return { ...state, mode: "submit", completed: true };
|
||||
}
|
||||
|
||||
const question = getCurrentQuestion(state);
|
||||
const option = getCurrentOption(state);
|
||||
if (!(question && option)) {
|
||||
return state;
|
||||
}
|
||||
|
||||
if (question.type === "multi") {
|
||||
if (option.isCustomOption) {
|
||||
return setView(state, inputView(question.id));
|
||||
}
|
||||
return advanceToNextTab(state);
|
||||
}
|
||||
|
||||
if (option.isCustomOption) {
|
||||
return setView(state, inputView(question.id));
|
||||
}
|
||||
|
||||
const nextState = updateAnswer(state, question.id, (answer) =>
|
||||
setSingleSelection(answer, option, state.activeOptionIndex)
|
||||
);
|
||||
return advanceToNextTab(nextState);
|
||||
return activateCurrentOption(state, "confirm");
|
||||
}
|
||||
|
||||
export function applyNumberShortcut(state: AskState, digit: number): AskState {
|
||||
@@ -203,31 +143,13 @@ export function applyNumberShortcut(state: AskState, digit: number): AskState {
|
||||
}
|
||||
|
||||
const question = getCurrentQuestion(state);
|
||||
if (!question) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const index = digit - 1;
|
||||
const option = getRenderableOptions(question)[index];
|
||||
if (!option) {
|
||||
const option = question ? getRenderableOptions(question)[index] : undefined;
|
||||
if (!(question && option)) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const selectedState = { ...state, activeOptionIndex: index };
|
||||
if (option.isCustomOption) {
|
||||
return setView(selectedState, inputView(question.id));
|
||||
}
|
||||
|
||||
if (question.type === "multi") {
|
||||
return updateAnswer(selectedState, question.id, (answer) =>
|
||||
toggleSelection(answer, option, index)
|
||||
);
|
||||
}
|
||||
|
||||
const nextState = updateAnswer(selectedState, question.id, (answer) =>
|
||||
setSingleSelection(answer, option, index)
|
||||
);
|
||||
return advanceToNextTab(nextState);
|
||||
return activateCurrentOption({ ...state, activeOptionIndex: index }, "digit");
|
||||
}
|
||||
|
||||
export function saveCustomAnswer(state: AskState, rawValue: string): AskState {
|
||||
@@ -245,20 +167,52 @@ export function saveNote(state: AskState, rawValue: string): AskState {
|
||||
return saveNoteValue(state, rawValue);
|
||||
}
|
||||
|
||||
export function cancelFlow(state: AskState): AskState {
|
||||
if (isEditingView(state)) {
|
||||
return exitEditingView(state);
|
||||
function completeSubmitAction(state: AskState): AskState {
|
||||
if (state.activeSubmitActionIndex === 2) {
|
||||
return { ...state, cancelled: true, completed: true };
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
cancelled: true,
|
||||
completed: true,
|
||||
};
|
||||
if (state.activeSubmitActionIndex === 1) {
|
||||
return { ...state, mode: "elaborate", completed: true };
|
||||
}
|
||||
return { ...state, mode: "submit", completed: true };
|
||||
}
|
||||
|
||||
export function dismissFlow(state: AskState): AskState {
|
||||
const nextState = cancelFlow(state);
|
||||
return nextState.completed ? nextState : cancelFlow(nextState);
|
||||
function activateCurrentOption(
|
||||
state: AskState,
|
||||
trigger: "toggle" | "confirm" | "digit"
|
||||
): AskState {
|
||||
const question = getCurrentQuestion(state);
|
||||
const option = getCurrentOption(state);
|
||||
if (!(question && option)) {
|
||||
return state;
|
||||
}
|
||||
if (option.isCustomOption) {
|
||||
return setView(state, inputView(question.id));
|
||||
}
|
||||
if (question.type === "multi") {
|
||||
if (trigger === "confirm") {
|
||||
return advanceToNextTab(state);
|
||||
}
|
||||
return updateAnswer(state, question.id, (answer) =>
|
||||
toggleSelection(answer, option, state.activeOptionIndex)
|
||||
);
|
||||
}
|
||||
|
||||
const nextState = updateAnswer(state, question.id, (answer) => {
|
||||
if (trigger === "toggle") {
|
||||
const isSelected = answer.selected.some(
|
||||
(selection) => selection.value === option.value
|
||||
);
|
||||
if (isSelected) {
|
||||
return {
|
||||
...answer,
|
||||
selected: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
return setSingleSelection(answer, option, state.activeOptionIndex);
|
||||
});
|
||||
return trigger === "toggle" ? nextState : advanceToNextTab(nextState);
|
||||
}
|
||||
|
||||
function saveInputValue(
|
||||
@@ -308,42 +262,6 @@ function saveNoteValue(state: AskState, rawValue: string): AskState {
|
||||
return nextState;
|
||||
}
|
||||
|
||||
function moveTabBy(state: AskState, delta: 1 | -1): AskState {
|
||||
const totalTabs = state.questions.length + 1;
|
||||
const activeTabIndex = (state.activeTabIndex + delta + totalTabs) % totalTabs;
|
||||
return {
|
||||
...state,
|
||||
activeTabIndex,
|
||||
activeOptionIndex: 0,
|
||||
activeSubmitActionIndex: 0,
|
||||
view:
|
||||
activeTabIndex >= state.questions.length ? submitView() : navigateView(),
|
||||
};
|
||||
}
|
||||
|
||||
function moveOptionBy(state: AskState, delta: 1 | -1): AskState {
|
||||
if (isSubmitTab(state)) {
|
||||
return {
|
||||
...state,
|
||||
activeSubmitActionIndex: clamp(
|
||||
state.activeSubmitActionIndex + delta,
|
||||
0,
|
||||
SUBMIT_ACTION_COUNT - 1
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const options = getRenderableOptions(getCurrentQuestion(state));
|
||||
return {
|
||||
...state,
|
||||
activeOptionIndex: clamp(
|
||||
state.activeOptionIndex + delta,
|
||||
0,
|
||||
Math.max(0, options.length - 1)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function setView(state: AskState, view: AskState["view"]): AskState {
|
||||
return {
|
||||
...state,
|
||||
@@ -389,7 +307,3 @@ function updateAnswer(
|
||||
answers,
|
||||
};
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
+63
-123
@@ -1,13 +1,16 @@
|
||||
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||
import { Editor, type EditorTheme } from "@mariozechner/pi-tui";
|
||||
import { createInitialState } from "../state/create.ts";
|
||||
import {
|
||||
getEditorDraft,
|
||||
saveEditorDraft,
|
||||
submitEditorDraft,
|
||||
syncStateToSelection,
|
||||
} from "../state/editor.ts";
|
||||
import { toAskResult } from "../state/result.ts";
|
||||
import {
|
||||
getAnswer,
|
||||
getCurrentOption,
|
||||
getCurrentQuestion,
|
||||
getOptionNote,
|
||||
getQuestionNote,
|
||||
isSubmitTab,
|
||||
} from "../state/selectors.ts";
|
||||
import {
|
||||
@@ -15,14 +18,10 @@ import {
|
||||
cancelFlow,
|
||||
confirmCurrentSelection,
|
||||
dismissFlow,
|
||||
enterInputMode,
|
||||
enterOptionNoteMode,
|
||||
enterQuestionNoteMode,
|
||||
moveOption,
|
||||
moveTab,
|
||||
saveCustomAnswer,
|
||||
saveNote,
|
||||
submitCustomAnswer,
|
||||
toggleCurrentMultiOption,
|
||||
} from "../state/transitions.ts";
|
||||
import { isEditingView } from "../state/view.ts";
|
||||
@@ -79,7 +78,7 @@ function createAskFlowController(
|
||||
};
|
||||
|
||||
controller.editor.onSubmit = (value) => submitEditor(controller, value);
|
||||
syncInputModeWithSelection(controller);
|
||||
syncSelection(controller);
|
||||
|
||||
return {
|
||||
render: (width: number) => renderController(controller, width),
|
||||
@@ -123,27 +122,19 @@ function handleEditingCommand(
|
||||
data: string
|
||||
) {
|
||||
if (command.kind === "dismiss") {
|
||||
commitNavigation(controller, dismissFlow(controller.state));
|
||||
maybeFinish(controller);
|
||||
commitState(controller, dismissFlow(controller.state), { finish: true });
|
||||
return;
|
||||
}
|
||||
if (command.kind === "editMoveTab") {
|
||||
controller.state = saveEditorAndMoveTab(controller, command.delta);
|
||||
syncInputModeWithSelection(controller);
|
||||
refresh(controller);
|
||||
commitSavedEditorNavigation(controller, moveTab, command.delta);
|
||||
return;
|
||||
}
|
||||
if (command.kind === "editMoveOption") {
|
||||
controller.state = saveEditorAndMoveOption(controller, command.delta);
|
||||
syncInputModeWithSelection(controller);
|
||||
refresh(controller);
|
||||
commitSavedEditorNavigation(controller, moveOption, command.delta);
|
||||
return;
|
||||
}
|
||||
if (command.kind === "editClose") {
|
||||
controller.state = saveEditorWithoutAdvancing(controller);
|
||||
controller.suppressAutoInputForSelection =
|
||||
controller.state.view.kind !== "input";
|
||||
refresh(controller);
|
||||
closeEditor(controller);
|
||||
return;
|
||||
}
|
||||
if (command.kind === "delegateToEditor") {
|
||||
@@ -158,10 +149,10 @@ function handleNavigationCommand(
|
||||
) {
|
||||
switch (command.kind) {
|
||||
case "moveTab":
|
||||
navigateTabs(controller, command.delta);
|
||||
commitState(controller, moveTab(controller.state, command.delta));
|
||||
return;
|
||||
case "moveOption":
|
||||
navigateOptions(controller, command.delta);
|
||||
commitState(controller, moveOption(controller.state, command.delta));
|
||||
return;
|
||||
case "toggleMulti":
|
||||
handleToggleCurrentOption(controller);
|
||||
@@ -173,22 +164,21 @@ function handleNavigationCommand(
|
||||
openOptionNote(controller);
|
||||
return;
|
||||
case "confirm":
|
||||
commitNavigation(controller, confirmCurrentSelection(controller.state));
|
||||
maybeFinish(controller);
|
||||
commitState(controller, confirmCurrentSelection(controller.state), {
|
||||
finish: true,
|
||||
});
|
||||
return;
|
||||
case "cancel":
|
||||
commitNavigation(controller, cancelFlow(controller.state));
|
||||
maybeFinish(controller);
|
||||
commitState(controller, cancelFlow(controller.state), { finish: true });
|
||||
return;
|
||||
case "numberShortcut":
|
||||
commitNavigation(
|
||||
commitState(
|
||||
controller,
|
||||
applyNumberShortcut(controller.state, command.digit)
|
||||
);
|
||||
return;
|
||||
case "dismiss":
|
||||
commitNavigation(controller, dismissFlow(controller.state));
|
||||
maybeFinish(controller);
|
||||
commitState(controller, dismissFlow(controller.state), { finish: true });
|
||||
return;
|
||||
case "ignore":
|
||||
case "editMoveTab":
|
||||
@@ -201,27 +191,12 @@ function handleNavigationCommand(
|
||||
}
|
||||
}
|
||||
|
||||
function navigateTabs(controller: AskFlowController, delta: 1 | -1) {
|
||||
controller.suppressAutoInputForSelection = false;
|
||||
controller.state = moveTab(controller.state, delta);
|
||||
syncInputModeWithSelection(controller);
|
||||
refresh(controller);
|
||||
}
|
||||
|
||||
function navigateOptions(controller: AskFlowController, delta: 1 | -1) {
|
||||
controller.suppressAutoInputForSelection = false;
|
||||
controller.state = moveOption(controller.state, delta);
|
||||
syncInputModeWithSelection(controller);
|
||||
refresh(controller);
|
||||
}
|
||||
|
||||
function handleToggleCurrentOption(controller: AskFlowController) {
|
||||
const question = getCurrentQuestion(controller.state);
|
||||
if (!question) {
|
||||
return;
|
||||
}
|
||||
controller.suppressAutoInputForSelection = false;
|
||||
commitNavigation(controller, toggleCurrentMultiOption(controller.state));
|
||||
commitState(controller, toggleCurrentMultiOption(controller.state));
|
||||
}
|
||||
|
||||
function openQuestionNote(controller: AskFlowController) {
|
||||
@@ -229,9 +204,13 @@ function openQuestionNote(controller: AskFlowController) {
|
||||
if (!question || isSubmitTab(controller.state)) {
|
||||
return;
|
||||
}
|
||||
controller.state = enterQuestionNoteMode(controller.state, question.id);
|
||||
hydrateEditorForCurrentView(controller);
|
||||
refresh(controller);
|
||||
commitState(
|
||||
controller,
|
||||
enterQuestionNoteMode(controller.state, question.id),
|
||||
{
|
||||
syncSelection: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function openOptionNote(controller: AskFlowController) {
|
||||
@@ -244,38 +223,51 @@ function openOptionNote(controller: AskFlowController) {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
controller.state = enterOptionNoteMode(
|
||||
controller.state,
|
||||
question.id,
|
||||
option.value
|
||||
commitState(
|
||||
controller,
|
||||
enterOptionNoteMode(controller.state, question.id, option.value),
|
||||
{ syncSelection: false }
|
||||
);
|
||||
hydrateEditorForCurrentView(controller);
|
||||
refresh(controller);
|
||||
}
|
||||
|
||||
function commitNavigation(controller: AskFlowController, nextState: AskState) {
|
||||
function commitState(
|
||||
controller: AskFlowController,
|
||||
nextState: AskState,
|
||||
options: { finish?: boolean; syncSelection?: boolean } = {}
|
||||
) {
|
||||
controller.suppressAutoInputForSelection = false;
|
||||
controller.state = nextState;
|
||||
hydrateEditorForCurrentView(controller);
|
||||
if (options.syncSelection !== false) {
|
||||
syncSelection(controller);
|
||||
}
|
||||
hydrateEditor(controller);
|
||||
refresh(controller);
|
||||
if (options.finish) {
|
||||
maybeFinish(controller);
|
||||
}
|
||||
}
|
||||
|
||||
function submitEditor(controller: AskFlowController, value: string) {
|
||||
controller.suppressAutoInputForSelection = false;
|
||||
controller.state = submitEditorValue(controller.state, value);
|
||||
controller.state = submitEditorDraft(controller.state, value);
|
||||
controller.editor.setText("");
|
||||
refresh(controller);
|
||||
maybeFinish(controller);
|
||||
}
|
||||
|
||||
function submitEditorValue(state: AskState, value: string): AskState {
|
||||
if (state.view.kind === "input") {
|
||||
return submitCustomAnswer(state, value);
|
||||
function commitSavedEditorNavigation(
|
||||
controller: AskFlowController,
|
||||
navigate: (state: AskState, delta: 1 | -1) => AskState,
|
||||
delta: 1 | -1
|
||||
) {
|
||||
commitState(controller, navigate(saveEditorState(controller), delta));
|
||||
}
|
||||
if (state.view.kind === "note") {
|
||||
return saveNote(state, value);
|
||||
}
|
||||
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) {
|
||||
@@ -288,57 +280,21 @@ function maybeFinish(controller: AskFlowController) {
|
||||
}
|
||||
}
|
||||
|
||||
function hydrateEditorForCurrentView(controller: AskFlowController) {
|
||||
controller.editor.setText(getEditorTextForCurrentView(controller.state));
|
||||
function hydrateEditor(controller: AskFlowController) {
|
||||
controller.editor.setText(getEditorDraft(controller.state));
|
||||
}
|
||||
|
||||
function syncInputModeWithSelection(controller: AskFlowController) {
|
||||
if (
|
||||
isEditingView(controller.state) ||
|
||||
isSubmitTab(controller.state) ||
|
||||
controller.suppressAutoInputForSelection
|
||||
) {
|
||||
function syncSelection(controller: AskFlowController) {
|
||||
if (controller.suppressAutoInputForSelection) {
|
||||
return;
|
||||
}
|
||||
|
||||
const question = getCurrentQuestion(controller.state);
|
||||
const option = getCurrentOption(controller.state);
|
||||
if (!(question && option?.isCustomOption)) {
|
||||
return;
|
||||
controller.state = syncStateToSelection(controller.state);
|
||||
}
|
||||
|
||||
controller.state = enterInputMode(controller.state, question.id);
|
||||
hydrateEditorForCurrentView(controller);
|
||||
}
|
||||
|
||||
function saveEditorWithoutAdvancing(controller: AskFlowController): AskState {
|
||||
function saveEditorState(controller: AskFlowController): AskState {
|
||||
const text = controller.editor.getText();
|
||||
controller.editor.setText("");
|
||||
if (controller.state.view.kind === "input") {
|
||||
return saveCustomAnswer(controller.state, text);
|
||||
}
|
||||
if (controller.state.view.kind === "note") {
|
||||
return saveNote(controller.state, text);
|
||||
}
|
||||
return controller.state;
|
||||
}
|
||||
|
||||
function saveEditorAndMoveTab(
|
||||
controller: AskFlowController,
|
||||
delta: 1 | -1
|
||||
): AskState {
|
||||
const nextState = saveEditorWithoutAdvancing(controller);
|
||||
controller.suppressAutoInputForSelection = false;
|
||||
return moveTab(nextState, delta);
|
||||
}
|
||||
|
||||
function saveEditorAndMoveOption(
|
||||
controller: AskFlowController,
|
||||
delta: 1 | -1
|
||||
): AskState {
|
||||
const nextState = saveEditorWithoutAdvancing(controller);
|
||||
controller.suppressAutoInputForSelection = false;
|
||||
return moveOption(nextState, delta);
|
||||
return saveEditorDraft(controller.state, text);
|
||||
}
|
||||
|
||||
function createEditor(tui: Tui, theme: Theme, cwd: string) {
|
||||
@@ -359,19 +315,3 @@ function createEditorTheme(theme: Theme): EditorTheme {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getEditorTextForCurrentView(state: AskState): string {
|
||||
if (state.view.kind === "input") {
|
||||
return getAnswer(state, state.view.questionId)?.customText ?? "";
|
||||
}
|
||||
if (state.view.kind === "note") {
|
||||
if (state.view.optionValue) {
|
||||
return (
|
||||
getOptionNote(state, state.view.questionId, state.view.optionValue) ??
|
||||
""
|
||||
);
|
||||
}
|
||||
return getQuestionNote(state, state.view.questionId) ?? "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
||||
import { NO_PREVIEW_TEXT } from "../constants.ts";
|
||||
import { clamp } from "../math.ts";
|
||||
import { wrapText } from "../text.ts";
|
||||
import { UI_DIMENSIONS, UI_TEXT } from "./constants.ts";
|
||||
|
||||
@@ -343,6 +344,30 @@ export function getSavedNotePrefixes(
|
||||
};
|
||||
}
|
||||
|
||||
export function pushSavedNote(args: {
|
||||
lines: string[];
|
||||
note: string;
|
||||
width: number;
|
||||
theme: Theme;
|
||||
indent: string;
|
||||
label?: string;
|
||||
}) {
|
||||
const { lines, note, width, theme, indent, label } = args;
|
||||
const { prefix, continuationPrefix } = getSavedNotePrefixes(theme, {
|
||||
indent,
|
||||
label,
|
||||
});
|
||||
pushWrappedText(
|
||||
lines,
|
||||
note,
|
||||
width,
|
||||
theme,
|
||||
"muted",
|
||||
prefix,
|
||||
continuationPrefix
|
||||
);
|
||||
}
|
||||
|
||||
export function renderFooterText(
|
||||
mode: "input" | "note" | "submit" | "multi" | "default"
|
||||
) {
|
||||
@@ -363,7 +388,3 @@ export function renderFooterText(
|
||||
function padToVisibleWidth(text: string, width: number): string {
|
||||
return text + " ".repeat(Math.max(0, width - visibleWidth(text)));
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
+155
-357
@@ -1,189 +1,141 @@
|
||||
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
||||
import { isOptionSelected } from "../state/answers.ts";
|
||||
import { UI_DIMENSIONS } from "./constants.ts";
|
||||
import {
|
||||
getAnswer,
|
||||
getOptionNote,
|
||||
getQuestionNote,
|
||||
isInputOpenForQuestion,
|
||||
isOptionNoteOpen,
|
||||
isQuestionNoteOpen,
|
||||
} from "../state/selectors.ts";
|
||||
import type { AskDisplayOption } from "../types.ts";
|
||||
import { UI_DIMENSIONS, UI_TEXT } from "./constants.ts";
|
||||
import {
|
||||
getSavedNotePrefixes,
|
||||
measurePreviewLeftWidth,
|
||||
mergeColumns,
|
||||
pushSavedNote,
|
||||
pushWrappedText,
|
||||
renderEditorBlock,
|
||||
renderPreviewPaneContent,
|
||||
} from "./render-helpers.ts";
|
||||
import type {
|
||||
OptionDetailRenderContext,
|
||||
QuestionRenderContext,
|
||||
Theme,
|
||||
} from "./render-types.ts";
|
||||
import type { QuestionRenderContext, Theme } from "./render-types.ts";
|
||||
import {
|
||||
buildQuestionScreenModel,
|
||||
type OptionDetailModel,
|
||||
type OptionRowModel,
|
||||
} from "./view-models/question.ts";
|
||||
|
||||
export function renderQuestionScreen(context: QuestionRenderContext) {
|
||||
const { lines, question, theme, width } = context;
|
||||
pushWrappedText(lines, question.prompt, width, theme, "text", " ", " ");
|
||||
const renderedQuestionNote = renderQuestionNote(context);
|
||||
if (!renderedQuestionNote) {
|
||||
lines.push("");
|
||||
}
|
||||
const model = buildQuestionScreenModel(context);
|
||||
|
||||
if (question.type === "preview") {
|
||||
renderPreviewQuestion(context);
|
||||
pushWrappedText(lines, question.prompt, width, theme, "text", " ", " ");
|
||||
renderQuestionNote(lines, model.questionNote, context);
|
||||
|
||||
if (model.mode === "preview") {
|
||||
renderPreviewQuestion(context, model);
|
||||
return;
|
||||
}
|
||||
|
||||
renderStandardQuestion(context);
|
||||
for (const row of model.rows) {
|
||||
renderStandardOption(lines, row, context);
|
||||
}
|
||||
}
|
||||
|
||||
function renderQuestionNote(context: QuestionRenderContext): boolean {
|
||||
const { lines, state, question, theme, width, editor } = context;
|
||||
if (isQuestionNoteOpen(state, question.id)) {
|
||||
renderQuestionNoteEditor(
|
||||
lines,
|
||||
editor,
|
||||
width,
|
||||
theme,
|
||||
UI_TEXT.editorPlaceholderNote
|
||||
);
|
||||
function renderQuestionNote(
|
||||
lines: string[],
|
||||
questionNote: ReturnType<typeof buildQuestionScreenModel>["questionNote"],
|
||||
context: QuestionRenderContext
|
||||
) {
|
||||
if (!questionNote) {
|
||||
lines.push("");
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
|
||||
const existingNote = getQuestionNote(state, question.id);
|
||||
if (!existingNote) {
|
||||
return false;
|
||||
if (questionNote.kind === "editor") {
|
||||
renderEditorWithIndent({
|
||||
lines,
|
||||
editor: context.editor,
|
||||
width: context.width,
|
||||
theme: context.theme,
|
||||
indent: " ",
|
||||
padding: UI_DIMENSIONS.editorContentPadding,
|
||||
placeholder: questionNote.placeholder,
|
||||
});
|
||||
lines.push("");
|
||||
return;
|
||||
}
|
||||
const { prefix, continuationPrefix } = getSavedNotePrefixes(theme, {
|
||||
pushSavedNote({
|
||||
lines,
|
||||
note: questionNote.text,
|
||||
width: context.width,
|
||||
theme: context.theme,
|
||||
indent: " ",
|
||||
});
|
||||
pushWrappedText(
|
||||
lines,
|
||||
existingNote,
|
||||
width,
|
||||
theme,
|
||||
"muted",
|
||||
prefix,
|
||||
continuationPrefix
|
||||
);
|
||||
lines.push("");
|
||||
return true;
|
||||
}
|
||||
|
||||
function renderStandardQuestion(context: QuestionRenderContext) {
|
||||
const { options } = context;
|
||||
for (let index = 0; index < options.length; index++) {
|
||||
renderStandardOption(context, options[index], index);
|
||||
}
|
||||
}
|
||||
|
||||
function renderStandardOption(
|
||||
context: QuestionRenderContext,
|
||||
option: AskDisplayOption,
|
||||
index: number
|
||||
lines: string[],
|
||||
row: OptionRowModel,
|
||||
context: QuestionRenderContext
|
||||
) {
|
||||
const { lines, state, question, theme, width } = context;
|
||||
const answer = getAnswer(state, question.id);
|
||||
const selected = index === state.activeOptionIndex;
|
||||
const isAnsweredOption = option.isCustomOption
|
||||
? !!answer?.customText
|
||||
: isOptionSelected(answer, option.value);
|
||||
const pointer = selected ? "❯ " : " ";
|
||||
const prefix = getOptionPrefix(question.type, option, isAnsweredOption);
|
||||
const optionColor = getOptionColor(isAnsweredOption, selected);
|
||||
if (
|
||||
option.isCustomOption &&
|
||||
selected &&
|
||||
isInputOpenForQuestion(state, question.id)
|
||||
) {
|
||||
renderInteractiveCustomOption(context, index, pointer, prefix, optionColor);
|
||||
if (row.isCustom && row.detail?.kind === "editor") {
|
||||
renderInteractiveCustomOption(lines, row, context);
|
||||
return;
|
||||
}
|
||||
|
||||
pushWrappedText(
|
||||
lines,
|
||||
`${index + 1}. ${prefix}${option.label}`,
|
||||
width,
|
||||
theme,
|
||||
optionColor,
|
||||
pointer,
|
||||
" ".repeat(visibleWidth(pointer))
|
||||
`${row.index + 1}. ${row.prefix}${row.label}`,
|
||||
context.width,
|
||||
context.theme,
|
||||
row.color,
|
||||
row.pointer,
|
||||
" ".repeat(visibleWidth(row.pointer))
|
||||
);
|
||||
renderOptionMeta({
|
||||
...context,
|
||||
questionId: question.id,
|
||||
answer,
|
||||
option,
|
||||
selected,
|
||||
renderOptionDescription(lines, row.description, context.width, context.theme);
|
||||
renderOptionDetail(lines, row.detail, context, {
|
||||
suppressLeadingGap: !!row.description,
|
||||
});
|
||||
}
|
||||
|
||||
function renderPreviewQuestion(context: QuestionRenderContext) {
|
||||
const { lines, state, question, options, theme, width } = context;
|
||||
const add = (text = "") => lines.push(truncateToWidth(text, width));
|
||||
const selectedOption = options[state.activeOptionIndex];
|
||||
const answer = getAnswer(state, question.id);
|
||||
function renderPreviewQuestion(
|
||||
context: QuestionRenderContext,
|
||||
model: ReturnType<typeof buildQuestionScreenModel>
|
||||
) {
|
||||
if (model.mode !== "preview") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedOption?.isCustomOption) {
|
||||
renderPreviewOptionList(state, options, theme, width, answer).forEach(add);
|
||||
} else if (width >= UI_DIMENSIONS.previewWideMinWidth && options.length > 0) {
|
||||
const { lines, width, theme } = context;
|
||||
const add = (text = "") => lines.push(truncateToWidth(text, width));
|
||||
|
||||
if (model.previewLayout === "custom") {
|
||||
renderPreviewOptionList(model.rows, theme, width).forEach(add);
|
||||
} else if (model.previewLayout === "wide") {
|
||||
renderWidePreviewLayout(
|
||||
add,
|
||||
state,
|
||||
options,
|
||||
model.rows,
|
||||
theme,
|
||||
width,
|
||||
answer,
|
||||
selectedOption
|
||||
model.selectedOption
|
||||
);
|
||||
} else {
|
||||
renderStackedPreviewLayout(
|
||||
add,
|
||||
state,
|
||||
options,
|
||||
model.rows,
|
||||
theme,
|
||||
width,
|
||||
answer,
|
||||
selectedOption
|
||||
model.selectedOption
|
||||
);
|
||||
}
|
||||
|
||||
renderOptionEditorOrNote({
|
||||
...context,
|
||||
questionId: question.id,
|
||||
answer,
|
||||
option: selectedOption,
|
||||
selected: true,
|
||||
// Custom preview options already render their inline editor/content above.
|
||||
withGap: !selectedOption?.isCustomOption,
|
||||
});
|
||||
renderOptionDetail(lines, model.selectedOptionDetail, context);
|
||||
}
|
||||
|
||||
function renderWidePreviewLayout(
|
||||
add: (text?: string) => void,
|
||||
state: QuestionRenderContext["state"],
|
||||
options: QuestionRenderContext["options"],
|
||||
rows: OptionRowModel[],
|
||||
theme: Theme,
|
||||
width: number,
|
||||
answer: ReturnType<typeof getAnswer>,
|
||||
selectedOption: AskDisplayOption | undefined
|
||||
selectedOption: ReturnType<typeof buildQuestionScreenModel>["selectedOption"]
|
||||
) {
|
||||
const leftWidth = measurePreviewLeftWidth(options, width);
|
||||
const leftWidth = measurePreviewLeftWidth(rows, width);
|
||||
const rightWidth = Math.max(
|
||||
UI_DIMENSIONS.previewMinRightWidth,
|
||||
width - leftWidth - 2
|
||||
);
|
||||
const leftPane = renderPreviewOptionList(
|
||||
state,
|
||||
options,
|
||||
theme,
|
||||
leftWidth,
|
||||
answer
|
||||
);
|
||||
const leftPane = renderPreviewOptionList(rows, theme, leftWidth);
|
||||
const rightPane = renderPreviewPaneContent(selectedOption, theme, rightWidth);
|
||||
for (const line of mergeColumns(leftPane, rightPane, leftWidth, width)) {
|
||||
add(line);
|
||||
@@ -192,294 +144,140 @@ function renderWidePreviewLayout(
|
||||
|
||||
function renderStackedPreviewLayout(
|
||||
add: (text?: string) => void,
|
||||
state: QuestionRenderContext["state"],
|
||||
options: QuestionRenderContext["options"],
|
||||
rows: OptionRowModel[],
|
||||
theme: Theme,
|
||||
width: number,
|
||||
answer: ReturnType<typeof getAnswer>,
|
||||
selectedOption: AskDisplayOption | undefined
|
||||
selectedOption: ReturnType<typeof buildQuestionScreenModel>["selectedOption"]
|
||||
) {
|
||||
renderPreviewOptionList(state, options, theme, width, answer).forEach(add);
|
||||
renderPreviewOptionList(rows, theme, width).forEach(add);
|
||||
add("");
|
||||
renderPreviewPaneContent(selectedOption, theme, width).forEach(add);
|
||||
}
|
||||
|
||||
function renderPreviewOptionList(
|
||||
state: QuestionRenderContext["state"],
|
||||
options: QuestionRenderContext["options"],
|
||||
rows: OptionRowModel[],
|
||||
theme: Theme,
|
||||
width: number,
|
||||
answer: ReturnType<typeof getAnswer>
|
||||
width: number
|
||||
): string[] {
|
||||
const lines: string[] = [];
|
||||
for (let index = 0; index < options.length; index++) {
|
||||
const option = options[index];
|
||||
const selected = index === state.activeOptionIndex;
|
||||
const isAnsweredOption = option.isCustomOption
|
||||
? !!answer?.customText
|
||||
: isOptionSelected(answer, option.value);
|
||||
for (const row of rows) {
|
||||
pushWrappedText(
|
||||
lines,
|
||||
`${index + 1}. ${option.label}`,
|
||||
`${row.index + 1}. ${row.label}`,
|
||||
width,
|
||||
theme,
|
||||
getOptionColor(isAnsweredOption, selected),
|
||||
selected ? "❯ " : " ",
|
||||
row.color,
|
||||
row.pointer,
|
||||
" "
|
||||
);
|
||||
if (option.description) {
|
||||
pushWrappedText(
|
||||
lines,
|
||||
option.description,
|
||||
width,
|
||||
theme,
|
||||
"muted",
|
||||
" ",
|
||||
" "
|
||||
);
|
||||
}
|
||||
renderOptionDescription(lines, row.description, width, theme);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function renderOptionMeta(
|
||||
context: QuestionRenderContext & {
|
||||
questionId: string;
|
||||
answer: ReturnType<typeof getAnswer>;
|
||||
option: AskDisplayOption;
|
||||
selected: boolean;
|
||||
}
|
||||
function renderOptionDetail(
|
||||
lines: string[],
|
||||
detail: OptionDetailModel | undefined,
|
||||
context: QuestionRenderContext,
|
||||
options: { suppressLeadingGap?: boolean } = {}
|
||||
) {
|
||||
const { lines, option, theme, width } = context;
|
||||
if (option.description) {
|
||||
pushWrappedText(
|
||||
lines,
|
||||
option.description,
|
||||
width,
|
||||
theme,
|
||||
"muted",
|
||||
" ",
|
||||
" "
|
||||
);
|
||||
}
|
||||
renderOptionEditorOrNote(context);
|
||||
}
|
||||
|
||||
function renderOptionEditorOrNote(context: OptionDetailRenderContext) {
|
||||
const { lines, option, withGap } = context;
|
||||
if (!option) {
|
||||
if (!detail) {
|
||||
return;
|
||||
}
|
||||
|
||||
const renderState = getOptionDetailRenderState(context);
|
||||
if (withGap && hasOptionDetailContent(renderState)) {
|
||||
if (detail.withGap && !options.suppressLeadingGap) {
|
||||
lines.push("");
|
||||
}
|
||||
if (renderOptionEditor(context, renderState)) {
|
||||
if (detail.kind === "editor") {
|
||||
renderEditorWithIndent({
|
||||
lines,
|
||||
editor: context.editor,
|
||||
width: context.width,
|
||||
theme: context.theme,
|
||||
indent: " ",
|
||||
padding: UI_DIMENSIONS.editorIndentedPadding,
|
||||
placeholder: detail.placeholder,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (renderOptionContent(context, renderState)) {
|
||||
if (detail.kind === "saved-note") {
|
||||
pushSavedNote({
|
||||
lines,
|
||||
note: detail.text,
|
||||
width: context.width,
|
||||
theme: context.theme,
|
||||
indent: " ",
|
||||
});
|
||||
return;
|
||||
}
|
||||
renderSavedOptionNote(context, renderState.optionNote);
|
||||
}
|
||||
|
||||
function getOptionDetailRenderState(context: OptionDetailRenderContext) {
|
||||
const { state, questionId, answer, option, selected } = context;
|
||||
const inputOpen =
|
||||
option?.isCustomOption &&
|
||||
selected &&
|
||||
isInputOpenForQuestion(state, questionId);
|
||||
const noteOpen =
|
||||
!!option &&
|
||||
!option.isCustomOption &&
|
||||
isOptionNoteOpen(state, questionId, option.value);
|
||||
const optionNote =
|
||||
option && !option.isCustomOption
|
||||
? getOptionNote(state, questionId, option.value)
|
||||
: undefined;
|
||||
const customText = option?.isCustomOption ? answer?.customText : undefined;
|
||||
|
||||
return {
|
||||
inputOpen,
|
||||
noteOpen,
|
||||
optionNote,
|
||||
customText,
|
||||
};
|
||||
}
|
||||
|
||||
function hasOptionDetailContent(
|
||||
renderState: ReturnType<typeof getOptionDetailRenderState>
|
||||
) {
|
||||
return !!(
|
||||
renderState.noteOpen ||
|
||||
renderState.inputOpen ||
|
||||
renderState.customText ||
|
||||
renderState.optionNote
|
||||
);
|
||||
}
|
||||
|
||||
function renderOptionEditor(
|
||||
context: OptionDetailRenderContext,
|
||||
renderState: ReturnType<typeof getOptionDetailRenderState>
|
||||
): boolean {
|
||||
const { lines, editor, width, theme } = context;
|
||||
if (renderState.noteOpen) {
|
||||
renderIndentedEditor(
|
||||
lines,
|
||||
editor,
|
||||
width,
|
||||
theme,
|
||||
UI_TEXT.editorPlaceholderNote
|
||||
);
|
||||
return true;
|
||||
}
|
||||
if (renderState.inputOpen) {
|
||||
renderIndentedEditor(
|
||||
lines,
|
||||
editor,
|
||||
width,
|
||||
theme,
|
||||
UI_TEXT.editorPlaceholderInput
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function renderOptionContent(
|
||||
context: OptionDetailRenderContext,
|
||||
renderState: ReturnType<typeof getOptionDetailRenderState>
|
||||
): boolean {
|
||||
const { lines, option, theme, width } = context;
|
||||
if (!(option?.isCustomOption && renderState.customText)) {
|
||||
return false;
|
||||
}
|
||||
pushWrappedText(
|
||||
lines,
|
||||
renderState.customText,
|
||||
width,
|
||||
theme,
|
||||
detail.text,
|
||||
context.width,
|
||||
context.theme,
|
||||
"muted",
|
||||
" ",
|
||||
" "
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
function renderSavedOptionNote(
|
||||
context: OptionDetailRenderContext,
|
||||
optionNote: string | undefined
|
||||
) {
|
||||
if (!optionNote) {
|
||||
return;
|
||||
}
|
||||
const { lines, theme, width } = context;
|
||||
const { prefix, continuationPrefix } = getSavedNotePrefixes(theme, {
|
||||
indent: " ",
|
||||
});
|
||||
pushWrappedText(
|
||||
lines,
|
||||
optionNote,
|
||||
width,
|
||||
theme,
|
||||
"muted",
|
||||
prefix,
|
||||
continuationPrefix
|
||||
);
|
||||
}
|
||||
|
||||
function renderQuestionNoteEditor(
|
||||
lines: string[],
|
||||
editor: QuestionRenderContext["editor"],
|
||||
width: number,
|
||||
theme: Theme,
|
||||
placeholder: string
|
||||
) {
|
||||
function renderEditorWithIndent(args: {
|
||||
lines: string[];
|
||||
editor: QuestionRenderContext["editor"];
|
||||
width: number;
|
||||
theme: Theme;
|
||||
indent: string;
|
||||
padding: number;
|
||||
placeholder: string;
|
||||
}) {
|
||||
const { lines, editor, width, theme, indent, padding, placeholder } = args;
|
||||
renderEditorBlock({
|
||||
lines,
|
||||
editorLines: editor.render(
|
||||
Math.max(
|
||||
UI_DIMENSIONS.editorMinWidth,
|
||||
width - UI_DIMENSIONS.editorContentPadding
|
||||
)
|
||||
Math.max(UI_DIMENSIONS.editorMinWidth, width - padding)
|
||||
),
|
||||
width,
|
||||
theme,
|
||||
indent: " ",
|
||||
availableWidth: width - 1,
|
||||
indent,
|
||||
availableWidth: width - visibleWidth(indent),
|
||||
placeholder,
|
||||
isEmpty: editor.getText().length === 0,
|
||||
});
|
||||
}
|
||||
|
||||
function renderIndentedEditor(
|
||||
lines: string[],
|
||||
editor: QuestionRenderContext["editor"],
|
||||
width: number,
|
||||
theme: Theme,
|
||||
placeholder: string
|
||||
) {
|
||||
renderEditorBlock({
|
||||
lines,
|
||||
editorLines: editor.render(
|
||||
Math.max(
|
||||
UI_DIMENSIONS.editorMinWidth,
|
||||
width - UI_DIMENSIONS.editorIndentedPadding
|
||||
)
|
||||
),
|
||||
width,
|
||||
theme,
|
||||
indent: " ",
|
||||
availableWidth: width - 5,
|
||||
placeholder,
|
||||
isEmpty: editor.getText().length === 0,
|
||||
});
|
||||
}
|
||||
|
||||
function getOptionPrefix(
|
||||
questionType: QuestionRenderContext["question"]["type"],
|
||||
option: AskDisplayOption,
|
||||
isAnsweredOption: boolean
|
||||
): string {
|
||||
if (questionType !== "multi" || option.isCustomOption) {
|
||||
return "";
|
||||
}
|
||||
return `[${isAnsweredOption ? "x" : " "}] `;
|
||||
}
|
||||
|
||||
function renderInteractiveCustomOption(
|
||||
context: QuestionRenderContext,
|
||||
index: number,
|
||||
pointer: string,
|
||||
prefix: string,
|
||||
optionColor: ReturnType<typeof getOptionColor>
|
||||
lines: string[],
|
||||
row: OptionRowModel,
|
||||
context: QuestionRenderContext
|
||||
) {
|
||||
const { lines, editor, theme, width } = context;
|
||||
pushWrappedText(
|
||||
lines,
|
||||
`${index + 1}. ${prefix}Type your own`,
|
||||
width,
|
||||
theme,
|
||||
optionColor,
|
||||
pointer,
|
||||
" ".repeat(visibleWidth(pointer))
|
||||
`${row.index + 1}. ${row.prefix}${row.label}`,
|
||||
context.width,
|
||||
context.theme,
|
||||
row.color,
|
||||
row.pointer,
|
||||
" ".repeat(visibleWidth(row.pointer))
|
||||
);
|
||||
renderIndentedEditor(
|
||||
renderEditorWithIndent({
|
||||
lines,
|
||||
editor,
|
||||
width,
|
||||
theme,
|
||||
UI_TEXT.editorPlaceholderInput
|
||||
);
|
||||
editor: context.editor,
|
||||
width: context.width,
|
||||
theme: context.theme,
|
||||
indent: " ",
|
||||
padding: UI_DIMENSIONS.editorIndentedPadding,
|
||||
placeholder:
|
||||
row.detail?.kind === "editor" ? row.detail.placeholder : "Type answer...",
|
||||
});
|
||||
}
|
||||
|
||||
function getOptionColor(isAnsweredOption: boolean, selected: boolean) {
|
||||
if (isAnsweredOption) {
|
||||
return "success";
|
||||
function renderOptionDescription(
|
||||
lines: string[],
|
||||
description: string | undefined,
|
||||
width: number,
|
||||
theme: Theme
|
||||
) {
|
||||
if (!description) {
|
||||
return;
|
||||
}
|
||||
if (selected) {
|
||||
return "accent";
|
||||
}
|
||||
return "text";
|
||||
pushWrappedText(lines, description, width, theme, "muted", " ", " ");
|
||||
}
|
||||
|
||||
+55
-215
@@ -1,21 +1,13 @@
|
||||
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
||||
import { SUBMIT_CHOICES } from "../constants.ts";
|
||||
import { serializeAnswer } from "../state/answers.ts";
|
||||
import type { AskResult, AskState, AskStateAnswer } from "../types.ts";
|
||||
import { UI_DIMENSIONS, UI_TEXT } from "./constants.ts";
|
||||
import { truncateToWidth } from "@mariozechner/pi-tui";
|
||||
import type { AskState } from "../types.ts";
|
||||
import { UI_TEXT } from "./constants.ts";
|
||||
import {
|
||||
getSavedNotePrefixes,
|
||||
mergeColumns,
|
||||
pushSavedNote,
|
||||
pushWrappedText,
|
||||
} from "./render-helpers.ts";
|
||||
import type { Theme } from "./render-types.ts";
|
||||
|
||||
type ReviewAnswer = AskResult["answers"][string] & {
|
||||
extraOptionNotes?: Array<{
|
||||
label: string;
|
||||
note: string;
|
||||
}>;
|
||||
};
|
||||
import { buildReviewScreenModel } from "./view-models/review.ts";
|
||||
|
||||
export function renderSubmitScreen(
|
||||
lines: string[],
|
||||
@@ -23,22 +15,19 @@ export function renderSubmitScreen(
|
||||
theme: Theme,
|
||||
width: number
|
||||
) {
|
||||
const showAllNotes = state.activeSubmitActionIndex === 1;
|
||||
const actionColumnWidth = getSubmitActionColumnWidth();
|
||||
|
||||
if (shouldUseWideSubmitLayout(width, actionColumnWidth)) {
|
||||
const rightWidth = Math.max(1, width - actionColumnWidth - 2);
|
||||
const reviewLines = renderSubmitReviewLines(
|
||||
state,
|
||||
const model = buildReviewScreenModel(state, width);
|
||||
if (model.layout === "wide") {
|
||||
const rightWidth = Math.max(1, width - model.actionColumnWidth - 2);
|
||||
const reviewLines = renderSubmitReviewLines(model, theme, rightWidth);
|
||||
const actionLines = renderSubmitActions(
|
||||
model,
|
||||
theme,
|
||||
rightWidth,
|
||||
showAllNotes
|
||||
model.actionColumnWidth
|
||||
);
|
||||
const actionLines = renderSubmitActions(state, theme, actionColumnWidth);
|
||||
for (const line of mergeColumns(
|
||||
actionLines,
|
||||
reviewLines,
|
||||
actionColumnWidth,
|
||||
model.actionColumnWidth,
|
||||
width
|
||||
)) {
|
||||
lines.push(line);
|
||||
@@ -46,38 +35,25 @@ export function renderSubmitScreen(
|
||||
return;
|
||||
}
|
||||
|
||||
const reviewLines = renderSubmitReviewLines(
|
||||
state,
|
||||
theme,
|
||||
width,
|
||||
showAllNotes
|
||||
);
|
||||
const actionLines = renderSubmitActions(state, theme, width);
|
||||
const reviewLines = renderSubmitReviewLines(model, theme, width);
|
||||
const actionLines = renderSubmitActions(model, theme, width);
|
||||
lines.push(...reviewLines);
|
||||
lines.push("");
|
||||
lines.push(...actionLines);
|
||||
}
|
||||
|
||||
function renderSubmitReviewLines(
|
||||
state: AskState,
|
||||
model: ReturnType<typeof buildReviewScreenModel>,
|
||||
theme: Theme,
|
||||
width: number,
|
||||
showAllNotes: boolean
|
||||
width: number
|
||||
): string[] {
|
||||
const lines: string[] = [];
|
||||
pushWrappedText(lines, UI_TEXT.reviewTitle, width, theme, "accent", " ", " ");
|
||||
lines.push("");
|
||||
|
||||
for (let index = 0; index < state.questions.length; index++) {
|
||||
const question = state.questions[index];
|
||||
renderSubmittedQuestion(
|
||||
lines,
|
||||
question,
|
||||
toReviewAnswer(question, state.answers[question.id], showAllNotes),
|
||||
theme,
|
||||
width
|
||||
);
|
||||
if (index < state.questions.length - 1) {
|
||||
for (const [index, question] of model.questions.entries()) {
|
||||
renderReviewQuestion(lines, question, theme, width);
|
||||
if (index < model.questions.length - 1) {
|
||||
lines.push("");
|
||||
}
|
||||
}
|
||||
@@ -85,225 +61,89 @@ function renderSubmitReviewLines(
|
||||
return lines;
|
||||
}
|
||||
|
||||
function getSubmitActionColumnWidth(): number {
|
||||
return Math.max(
|
||||
...SUBMIT_CHOICES.map((choice, index) =>
|
||||
visibleWidth(`❯ ${index + 1}. ${choice}`)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function shouldUseWideSubmitLayout(
|
||||
width: number,
|
||||
actionColumnWidth: number
|
||||
): boolean {
|
||||
return (
|
||||
width >= UI_DIMENSIONS.submitWideMinWidth &&
|
||||
width - actionColumnWidth - 2 >= UI_DIMENSIONS.submitMinReviewWidth
|
||||
);
|
||||
}
|
||||
|
||||
function renderSubmittedQuestion(
|
||||
function renderReviewQuestion(
|
||||
lines: string[],
|
||||
question: AskState["questions"][number],
|
||||
answer: ReviewAnswer | undefined,
|
||||
question: ReturnType<typeof buildReviewScreenModel>["questions"][number],
|
||||
theme: Theme,
|
||||
width: number
|
||||
) {
|
||||
pushWrappedText(lines, question.label, width, theme, "text", " ", " ");
|
||||
if (!answer) {
|
||||
if (question.unanswered) {
|
||||
lines.push(
|
||||
truncateToWidth(` ${theme.fg("dim", UI_TEXT.unanswered)}`, width)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
renderSubmittedAnswer(lines, answer, theme, width);
|
||||
}
|
||||
|
||||
function renderSubmittedAnswer(
|
||||
lines: string[],
|
||||
answer: ReviewAnswer,
|
||||
theme: Theme,
|
||||
width: number
|
||||
) {
|
||||
if (answer.note) {
|
||||
renderSubmittedNote(lines, answer.note, theme, width);
|
||||
}
|
||||
|
||||
if (shouldRenderAnswersIndividually(answer)) {
|
||||
renderSubmittedSelections(lines, answer, theme, width);
|
||||
renderExtraOptionNotes(lines, answer, theme, width);
|
||||
return;
|
||||
}
|
||||
|
||||
const answerText = answer.labels.join(", ");
|
||||
if (answerText) {
|
||||
pushWrappedText(
|
||||
if (question.note) {
|
||||
pushSavedNote({
|
||||
lines,
|
||||
`→ ${answerText}`,
|
||||
note: question.note,
|
||||
width,
|
||||
theme,
|
||||
isCustomOnlyAnswer(answer) ? "text" : "success",
|
||||
" ",
|
||||
" "
|
||||
);
|
||||
indent: " ",
|
||||
});
|
||||
}
|
||||
|
||||
renderExtraOptionNotes(lines, answer, theme, width);
|
||||
}
|
||||
|
||||
function renderSubmittedSelections(
|
||||
lines: string[],
|
||||
answer: ReviewAnswer,
|
||||
theme: Theme,
|
||||
width: number
|
||||
) {
|
||||
for (let index = 0; index < answer.labels.length; index++) {
|
||||
const label = answer.labels[index];
|
||||
const value = answer.values[index] ?? label;
|
||||
for (const selection of question.selections ?? []) {
|
||||
pushWrappedText(
|
||||
lines,
|
||||
`→ ${label}`,
|
||||
`→ ${selection.label}`,
|
||||
width,
|
||||
theme,
|
||||
"success",
|
||||
" ",
|
||||
" "
|
||||
);
|
||||
|
||||
const note = answer.optionNotes?.[value];
|
||||
if (note) {
|
||||
renderSubmittedNote(lines, note, theme, width);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function shouldRenderAnswersIndividually(answer: ReviewAnswer): boolean {
|
||||
if (!answer.labels.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
answer.labels.length > 1 ||
|
||||
Boolean(answer.optionNotes && Object.keys(answer.optionNotes).length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
function renderSubmittedNote(
|
||||
lines: string[],
|
||||
note: string,
|
||||
theme: Theme,
|
||||
width: number
|
||||
) {
|
||||
const { prefix, continuationPrefix } = getSavedNotePrefixes(theme, {
|
||||
indent: " ",
|
||||
});
|
||||
pushWrappedText(
|
||||
if (selection.note) {
|
||||
pushSavedNote({
|
||||
lines,
|
||||
note,
|
||||
note: selection.note,
|
||||
width,
|
||||
theme,
|
||||
"muted",
|
||||
prefix,
|
||||
continuationPrefix
|
||||
);
|
||||
indent: " ",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderLabeledSubmittedNote(
|
||||
lines: string[],
|
||||
label: string,
|
||||
note: string,
|
||||
theme: Theme,
|
||||
width: number
|
||||
) {
|
||||
const { prefix, continuationPrefix } = getSavedNotePrefixes(theme, {
|
||||
indent: " ",
|
||||
label,
|
||||
});
|
||||
if (question.answerText) {
|
||||
pushWrappedText(
|
||||
lines,
|
||||
note,
|
||||
`→ ${question.answerText}`,
|
||||
width,
|
||||
theme,
|
||||
"muted",
|
||||
prefix,
|
||||
continuationPrefix
|
||||
question.isCustomOnly ? "text" : "success",
|
||||
" ",
|
||||
" "
|
||||
);
|
||||
}
|
||||
|
||||
function renderExtraOptionNotes(
|
||||
lines: string[],
|
||||
answer: ReviewAnswer,
|
||||
theme: Theme,
|
||||
width: number
|
||||
) {
|
||||
for (const option of answer.extraOptionNotes ?? []) {
|
||||
renderLabeledSubmittedNote(lines, option.label, option.note, theme, width);
|
||||
for (const optionNote of question.extraOptionNotes ?? []) {
|
||||
pushSavedNote({
|
||||
lines,
|
||||
note: optionNote.note,
|
||||
width,
|
||||
theme,
|
||||
indent: " ",
|
||||
label: optionNote.label,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function isCustomOnlyAnswer(answer: AskResult["answers"][string]): boolean {
|
||||
return answer.indices.length === 0 && !!answer.customText;
|
||||
}
|
||||
|
||||
function toReviewAnswer(
|
||||
question: AskState["questions"][number],
|
||||
answer: AskStateAnswer | undefined,
|
||||
showAllNotes: boolean
|
||||
): ReviewAnswer | undefined {
|
||||
if (!answer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const serialized = serializeAnswer(answer);
|
||||
const hasSelectedAnswer = serialized.labels.length > 0;
|
||||
if (!showAllNotes) {
|
||||
return hasSelectedAnswer ? serialized : undefined;
|
||||
}
|
||||
|
||||
const selectedValues = new Set(serialized.values);
|
||||
const extraOptionNotes = Object.entries(answer.optionNotes ?? {})
|
||||
.filter(([value, note]) => !selectedValues.has(value) && Boolean(note))
|
||||
.map(([value, note]) => {
|
||||
const option = question.options.find(
|
||||
(candidate) => candidate.value === value
|
||||
);
|
||||
return option ? { label: option.label, note } : undefined;
|
||||
})
|
||||
.filter((entry): entry is { label: string; note: string } =>
|
||||
Boolean(entry)
|
||||
);
|
||||
|
||||
if (
|
||||
!(hasSelectedAnswer || serialized.note) &&
|
||||
extraOptionNotes.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
...serialized,
|
||||
extraOptionNotes:
|
||||
extraOptionNotes.length > 0 ? extraOptionNotes : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function renderSubmitActions(
|
||||
state: AskState,
|
||||
model: ReturnType<typeof buildReviewScreenModel>,
|
||||
theme: Theme,
|
||||
width: number
|
||||
): string[] {
|
||||
const lines: string[] = [];
|
||||
for (let index = 0; index < SUBMIT_CHOICES.length; index++) {
|
||||
const selected = index === state.activeSubmitActionIndex;
|
||||
const prefix = selected ? "❯ " : " ";
|
||||
for (const [index, action] of model.actions.entries()) {
|
||||
const prefix = action.selected ? "❯ " : " ";
|
||||
pushWrappedText(
|
||||
lines,
|
||||
`${index + 1}. ${SUBMIT_CHOICES[index]}`,
|
||||
`${index + 1}. ${action.label}`,
|
||||
width,
|
||||
theme,
|
||||
selected ? "accent" : "text",
|
||||
action.selected ? "accent" : "text",
|
||||
prefix,
|
||||
prefix
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user