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