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