diff --git a/biome.json b/biome.json index 6e383e4..195a1f0 100644 --- a/biome.json +++ b/biome.json @@ -15,7 +15,40 @@ "linter": { "enabled": true, "rules": { - "recommended": true + "recommended": true, + "complexity": { + "noExcessiveCognitiveComplexity": { + "level": "error", + "options": { + "maxAllowedComplexity": 12 + } + }, + "noExcessiveLinesPerFunction": { + "level": "warn", + "options": { + "maxLines": 70, + "skipBlankLines": true, + "skipIifes": true + } + } + }, + "correctness": { + "noUnusedFunctionParameters": "error" + }, + "performance": { + "noAccumulatingSpread": "error" + }, + "style": { + "noDefaultExport": "error", + "noNestedTernary": "error", + "noNonNullAssertion": "error", + "noParameterAssign": "error", + "useAsConstAssertion": "error", + "useConst": "error", + "useImportType": "error", + "useNamingConvention": "error", + "useNodejsImportProtocol": "error" + } } }, "javascript": { @@ -23,6 +56,28 @@ "quoteStyle": "double" } }, + "overrides": [ + { + "includes": ["tests/**/*.ts"], + "linter": { + "rules": { + "complexity": { + "noExcessiveLinesPerFunction": "off" + } + } + } + }, + { + "includes": ["src/index.ts"], + "linter": { + "rules": { + "style": { + "noDefaultExport": "off" + } + } + } + } + ], "assist": { "enabled": true, "actions": { diff --git a/src/ask-component.ts b/src/ask-component.ts index 8a3221e..61a081a 100644 --- a/src/ask-component.ts +++ b/src/ask-component.ts @@ -1,995 +1 @@ -import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; -import { - Editor, - type EditorTheme, - Key, - matchesKey, - truncateToWidth, - visibleWidth, -} from "@mariozechner/pi-tui"; -import { wrapText } from "./text.ts"; -import { - applyNumberShortcut, - cancelFlow, - confirmCurrentSelection, - createInitialState, - enterInputMode, - enterOptionNoteMode, - enterQuestionNoteMode, - getAnswer, - getCurrentQuestion, - getOptionNote, - getQuestionNote, - getRenderableOptions, - isQuestionAnswered, - isSubmitTab, - moveOption, - moveTab, - saveCustomAnswer, - saveNote, - submitCustomAnswer, - toAskResult, - toggleCurrentMultiOption, -} from "./state.ts"; -import type { AskParams, AskResult, AskState } from "./types.ts"; - -export async function runAskFlow( - ctx: ExtensionContext, - params: AskParams, -): Promise { - return ctx.ui.custom((tui, theme, keybindings, done) => { - let state: AskState = createInitialState(params); - let cachedLines: string[] | undefined; - let suppressAutoInputForSelection = false; - const newLineHint = formatKeybindingLabel( - keybindings.getKeys("tui.input.newLine")[0] ?? "shift+enter", - ); - - const editorTheme: EditorTheme = { - borderColor: (s) => theme.fg("accent", s), - selectList: { - selectedPrefix: (t) => theme.fg("accent", t), - selectedText: (t) => theme.fg("accent", t), - description: (t) => theme.fg("muted", t), - scrollInfo: (t) => theme.fg("dim", t), - noMatch: (t) => theme.fg("warning", t), - }, - }; - const editor = new Editor(tui, editorTheme); - - editor.onSubmit = (value) => { - suppressAutoInputForSelection = false; - if (state.mode === "input") { - state = submitCustomAnswer(state, value); - } else if (state.mode === "note") { - state = saveNote(state, value); - } - editor.setText(""); - refresh(); - maybeFinish(); - }; - - function refresh() { - cachedLines = undefined; - tui.requestRender(); - } - - function maybeFinish() { - if (state.completed) { - done(toAskResult(state)); - } - } - - function hydrateEditorForCurrentMode() { - if (state.mode === "input" && state.inputQuestionId) { - const existingText = - getAnswer(state, state.inputQuestionId)?.customText ?? ""; - editor.setText(existingText); - return; - } - if (state.mode !== "note" || !state.noteQuestionId) { - return; - } - const existingText = state.noteOptionValue - ? (getOptionNote(state, state.noteQuestionId, state.noteOptionValue) ?? - "") - : (getQuestionNote(state, state.noteQuestionId) ?? ""); - editor.setText(existingText); - } - - function syncInputModeWithSelection() { - if (state.mode === "note" || isSubmitTab(state)) { - return; - } - - const question = getCurrentQuestion(state); - if (!question) { - return; - } - - const option = getRenderableOptions(question)[state.optionIndex]; - if ( - !option?.isOther || - suppressAutoInputForSelection || - state.mode === "input" - ) { - return; - } - - state = enterInputMode(state, question.id); - hydrateEditorForCurrentMode(); - } - - syncInputModeWithSelection(); - - function handleInput(data: string) { - if (state.mode === "input" || state.mode === "note") { - if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) { - state = saveEditorAndMoveTab(1); - syncInputModeWithSelection(); - refresh(); - return; - } - if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) { - state = saveEditorAndMoveTab(-1); - syncInputModeWithSelection(); - refresh(); - return; - } - if (matchesKey(data, Key.escape)) { - state = saveEditorWithoutAdvancing(); - suppressAutoInputForSelection = state.mode !== "input"; - refresh(); - return; - } - editor.handleInput(data); - refresh(); - return; - } - - if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) { - suppressAutoInputForSelection = false; - state = moveTab(state, 1); - syncInputModeWithSelection(); - refresh(); - return; - } - if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) { - suppressAutoInputForSelection = false; - state = moveTab(state, -1); - syncInputModeWithSelection(); - refresh(); - return; - } - if (matchesKey(data, Key.up)) { - suppressAutoInputForSelection = false; - state = moveOption(state, -1); - syncInputModeWithSelection(); - refresh(); - return; - } - if (matchesKey(data, Key.down)) { - suppressAutoInputForSelection = false; - state = moveOption(state, 1); - syncInputModeWithSelection(); - refresh(); - return; - } - if (matchesKey(data, Key.space)) { - const question = getCurrentQuestion(state); - if (question?.type === "multi") { - suppressAutoInputForSelection = false; - state = toggleCurrentMultiOption(state); - if (state.mode === "input") { - hydrateEditorForCurrentMode(); - } - refresh(); - return; - } - } - if (isQuestionNoteShortcut(data)) { - const question = getCurrentQuestion(state); - if (question && !isSubmitTab(state)) { - state = enterQuestionNoteMode(state, question.id); - hydrateEditorForCurrentMode(); - refresh(); - } - return; - } - if (isOptionNoteShortcut(data)) { - const question = getCurrentQuestion(state); - const option = question - ? getRenderableOptions(question)[state.optionIndex] - : undefined; - if (question && option && !option.isOther && !isSubmitTab(state)) { - state = enterOptionNoteMode(state, question.id, option.value); - hydrateEditorForCurrentMode(); - refresh(); - } - return; - } - if (matchesKey(data, Key.enter)) { - suppressAutoInputForSelection = false; - state = confirmCurrentSelection(state); - if (state.mode === "input") { - hydrateEditorForCurrentMode(); - } - refresh(); - maybeFinish(); - return; - } - if (matchesKey(data, Key.escape)) { - state = cancelFlow(state); - refresh(); - maybeFinish(); - return; - } - - const digit = parseDigit(data); - if (digit !== null) { - suppressAutoInputForSelection = false; - state = applyNumberShortcut(state, digit); - if (state.mode === "input") { - hydrateEditorForCurrentMode(); - } - refresh(); - } - } - - function saveEditorWithoutAdvancing(): AskState { - const text = editor.getText(); - editor.setText(""); - if (state.mode === "input") { - suppressAutoInputForSelection = true; - return saveCustomAnswer(state, text); - } - return saveNote(state, text); - } - - function saveEditorAndMoveTab(delta: number): AskState { - const nextState = saveEditorWithoutAdvancing(); - suppressAutoInputForSelection = false; - return moveTab(nextState, delta); - } - - function render(width: number): string[] { - if (cachedLines) return cachedLines; - - const lines: string[] = []; - const add = (text = "") => lines.push(truncateToWidth(text, width)); - const question = getCurrentQuestion(state); - const options = getRenderableOptions(question); - - add(theme.fg("accent", "─".repeat(Math.max(1, width)))); - - if (state.title) { - add(` ${theme.fg("accent", theme.bold(state.title))}`); - add(); - } - - add(renderTabs(state, theme, width)); - add(); - - if (isSubmitTab(state)) { - renderSubmit(lines, state, theme, width); - } else if (question) { - renderQuestionContent({ - lines, - state, - question, - options, - theme, - width, - editor, - newLineHint, - }); - } - - const footer = renderFooter(state, theme); - if (footer) { - add(); - add(footer); - } - add(theme.fg("accent", "─".repeat(Math.max(1, width)))); - - cachedLines = lines; - return lines; - } - - return { - render, - invalidate() { - cachedLines = undefined; - }, - handleInput, - }; - }); -} - -function renderTabs( - state: AskState, - theme: ExtensionContext["ui"]["theme"], - width: number, -): string { - const segments: string[] = [theme.fg("dim", "← ")]; - for (let index = 0; index < state.questions.length; index++) { - const question = state.questions[index]; - const active = state.currentTab === index; - const answered = isQuestionAnswered(state, question.id); - const marker = answered ? "☒" : "☐"; - const text = ` ${marker} ${question.label} `; - segments.push( - active - ? theme.bg("selectedBg", theme.fg("text", text)) - : theme.fg(answered ? "success" : "muted", text), - ); - } - - const submitActive = isSubmitTab(state); - const submitText = " ✔ Submit "; - segments.push( - submitActive - ? theme.bg("selectedBg", theme.fg("text", submitText)) - : theme.fg("success", submitText), - ); - segments.push(theme.fg("dim", " →")); - - return truncateToWidth(` ${segments.join(" ")}`, width); -} - -function renderSubmit( - lines: string[], - state: AskState, - theme: ExtensionContext["ui"]["theme"], - width: number, -) { - pushWrappedText( - lines, - "Review your answers", - width, - theme, - "accent", - " ", - " ", - ); - lines.push(""); - - const submittedAnswers = toAskResult(state).answers; - for (const question of state.questions) { - const answer = submittedAnswers[question.id]; - pushWrappedText( - lines, - question.prompt, - width, - theme, - "muted", - " ● ", - " ", - ); - if (!answer) { - lines.push( - truncateToWidth(` ${theme.fg("warning", "→ (unanswered)")}`, width), - ); - continue; - } - const answerText = answer.customText ?? answer.labels.join(", "); - if (answerText) { - pushWrappedText( - lines, - `→ ${answerText}`, - width, - theme, - answer.customText ? "text" : "success", - " ", - " ", - ); - } - if (answer.note) { - pushWrappedText( - lines, - `Question note: ${answer.note}`, - width, - theme, - "muted", - " ", - " ", - ); - } - for (let index = 0; index < answer.values.length; index++) { - const value = answer.values[index]; - const label = answer.labels[index] ?? value; - const note = answer.optionNotes?.[value]; - if (!note) { - continue; - } - pushWrappedText( - lines, - `${label} note: ${note}`, - width, - theme, - "muted", - " ", - " ", - ); - } - } - - lines.push(""); - pushWrappedText( - lines, - "Ready to submit your answers?", - width, - theme, - "success", - " ", - " ", - ); - lines.push(""); - - const options = ["Submit answers", "Cancel"]; - for (let i = 0; i < options.length; i++) { - const selected = i === state.submitIndex; - const prefix = selected ? "❯ " : " "; - pushWrappedText( - lines, - `${i + 1}. ${options[i]}`, - width, - theme, - selected ? "accent" : "text", - prefix, - prefix, - ); - } -} - -function renderQuestionContent(args: { - lines: string[]; - state: AskState; - question: NonNullable>; - options: ReturnType; - theme: ExtensionContext["ui"]["theme"]; - width: number; - editor: Editor; - newLineHint: string; -}) { - const { lines, state, question, options, theme, width, editor, newLineHint } = - args; - - renderQuestionPrompt(lines, question.prompt, theme, width); - renderQuestionNote( - lines, - state, - question.id, - theme, - width, - editor, - newLineHint, - ); - - if (question.type === "preview") { - renderPreviewQuestion( - lines, - state, - question, - options, - theme, - width, - editor, - newLineHint, - ); - return; - } - - renderStandardQuestion( - lines, - state, - question, - options, - theme, - width, - editor, - newLineHint, - ); -} - -function renderQuestionPrompt( - lines: string[], - prompt: string, - theme: ExtensionContext["ui"]["theme"], - width: number, -) { - pushWrappedText(lines, prompt, width, theme, "text", " ", " "); - lines.push(""); -} - -function renderQuestionNote( - lines: string[], - state: AskState, - questionId: string, - theme: ExtensionContext["ui"]["theme"], - width: number, - editor: Editor, - newLineHint: string, -) { - const existingNote = getQuestionNote(state, questionId); - if ( - state.mode === "note" && - state.noteQuestionId === questionId && - !state.noteOptionValue - ) { - pushWrappedText(lines, "Question note", width, theme, "accent", " ", " "); - for (const editorLine of editor.render(Math.max(8, width - 5))) { - lines.push( - truncateToWidth( - ` ${renderInputLine(editorLine, width - 3, theme)}`, - width, - ), - ); - } - lines.push( - truncateToWidth( - ` ${theme.fg("dim", `${newLineHint} newline · Enter save · Esc save and close`)}`, - width, - ), - ); - lines.push(""); - return; - } - - if (!existingNote) { - return; - } - pushWrappedText( - lines, - `Question note: ${existingNote}`, - width, - theme, - "muted", - " ", - " ", - ); - lines.push(""); -} - -function renderStandardQuestion( - lines: string[], - state: AskState, - question: NonNullable>, - options: ReturnType, - theme: ExtensionContext["ui"]["theme"], - width: number, - editor: Editor, - newLineHint: string, -) { - const isMulti = question.type === "multi"; - const answer = getAnswer(state, question.id); - for (let i = 0; i < options.length; i++) { - const option = options[i]; - const selected = i === state.optionIndex; - const optionNote = option.isOther - ? undefined - : answer?.optionNotes?.[option.value]; - const isAnsweredOption = option.isOther - ? !!answer?.customText - : !!answer?.values.includes(option.value); - const pointer = selected ? "❯ " : " "; - const prefix = - isMulti && !option.isOther ? `[${isAnsweredOption ? "x" : " "}] ` : ""; - const optionColor = isAnsweredOption - ? "success" - : selected - ? "accent" - : "text"; - pushWrappedText( - lines, - `${i + 1}. ${prefix}${option.label}`, - width, - theme, - optionColor, - pointer, - " ".repeat(visibleWidth(pointer)), - ); - if (option.description) { - pushWrappedText( - lines, - option.description, - width, - theme, - "muted", - " ", - " ", - ); - } - if ( - !option.isOther && - state.mode === "note" && - state.noteQuestionId === question.id && - state.noteOptionValue === option.value - ) { - for (const editorLine of editor.render(Math.max(8, width - 7))) { - lines.push( - truncateToWidth( - ` ${renderInputLine(editorLine, width - 5, theme)}`, - width, - ), - ); - } - lines.push( - truncateToWidth( - ` ${theme.fg("dim", `${newLineHint} newline · Enter save · Esc save and close`)}`, - width, - ), - ); - } else if (option.isOther && selected && state.mode === "input") { - for (const editorLine of editor.render(Math.max(8, width - 7))) { - lines.push( - truncateToWidth( - ` ${renderInputLine(editorLine, width - 5, theme)}`, - width, - ), - ); - } - lines.push( - truncateToWidth( - ` ${theme.fg("dim", `${newLineHint} newline · Enter submit · Esc save and close`)}`, - width, - ), - ); - } - if (option.isOther && state.mode !== "input") { - const customText = answer?.customText; - if (customText) { - pushWrappedText( - lines, - customText, - width, - theme, - "muted", - " ", - " ", - ); - } - } - if ( - optionNote && - !(state.mode === "note" && state.noteOptionValue === option.value) - ) { - pushWrappedText( - lines, - `Note: ${optionNote}`, - width, - theme, - "muted", - " ", - " ", - ); - } - } -} - -function renderPreviewQuestion( - lines: string[], - state: AskState, - question: NonNullable>, - options: ReturnType, - theme: ExtensionContext["ui"]["theme"], - width: number, - editor: Editor, - newLineHint: string, -) { - const add = (text = "") => lines.push(truncateToWidth(text, width)); - const selectedOption = options[state.optionIndex]; - const wideLayout = width >= 90 && options.length > 0; - - const answer = getAnswer(state, question.id); - if (wideLayout) { - const leftWidth = measurePreviewLeftWidth(options, width); - const rightWidth = Math.max(24, width - leftWidth - 2); - const leftPane = renderPreviewOptionList( - state, - question, - options, - theme, - leftWidth, - ); - const rightPane = renderPreviewPane(selectedOption, theme, rightWidth); - for (const line of mergeColumns(leftPane, rightPane, leftWidth, width)) { - add(line); - } - } else { - renderPreviewOptionList(state, question, options, theme, width).forEach( - add, - ); - add(""); - renderPreviewPane(selectedOption, theme, width).forEach(add); - } - - if ( - selectedOption && - !selectedOption.isOther && - state.mode === "note" && - state.noteQuestionId === question.id && - state.noteOptionValue === selectedOption.value - ) { - add(""); - for (const editorLine of editor.render(Math.max(8, width - 7))) { - add(` ${renderInputLine(editorLine, width - 5, theme)}`); - } - add( - ` ${theme.fg("dim", `${newLineHint} newline · Enter save · Esc save and close`)}`, - ); - } else if (selectedOption && !selectedOption.isOther) { - const optionNote = answer?.optionNotes?.[selectedOption.value]; - if (optionNote) { - add(""); - for (const line of wrapText( - `Note: ${optionNote}`, - Math.max(1, width - 5), - )) { - add(` ${theme.fg("muted", line)}`); - } - } - } - - if (selectedOption?.isOther && state.mode === "input") { - add(""); - for (const editorLine of editor.render(Math.max(8, width - 7))) { - add(` ${renderInputLine(editorLine, width - 5, theme)}`); - } - add( - ` ${theme.fg("dim", `${newLineHint} newline · Enter submit · Esc save and close`)}`, - ); - } - - if (selectedOption?.isOther && state.mode !== "input" && answer?.customText) { - add(""); - for (const customLine of wrapText( - answer.customText, - Math.max(1, width - 5), - )) { - add(` ${theme.fg("muted", customLine)}`); - } - } -} - -function renderPreviewOptionList( - state: AskState, - question: NonNullable>, - options: ReturnType, - theme: ExtensionContext["ui"]["theme"], - width: number, -): string[] { - const lines: string[] = []; - const answer = getAnswer(state, question.id); - for (let i = 0; i < options.length; i++) { - const option = options[i]; - const selected = i === state.optionIndex; - const isAnsweredOption = option.isOther - ? !!answer?.customText - : !!answer?.values.includes(option.value); - const pointer = selected ? "❯ " : " "; - const optionColor = isAnsweredOption - ? "success" - : selected - ? "accent" - : "text"; - pushWrappedText( - lines, - `${i + 1}. ${option.label}`, - width, - theme, - optionColor, - pointer, - " ".repeat(visibleWidth(pointer)), - ); - if (option.description) { - pushWrappedText( - lines, - option.description, - width, - theme, - "muted", - " ", - " ", - ); - } - } - return lines; -} - -function renderPreviewPane( - selectedOption: ReturnType[number] | undefined, - theme: ExtensionContext["ui"]["theme"], - width: number, -): string[] { - const paneWidth = Math.max(10, width); - if (!selectedOption) { - return renderBox( - [{ text: "No preview available", color: "dim" }], - paneWidth, - theme, - ); - } - - const content: Array<{ - text: string; - color: "accent" | "muted" | "text" | "dim"; - }> = []; - content.push({ text: selectedOption.label, color: "accent" }); - if (selectedOption.description) { - content.push({ text: selectedOption.description, color: "muted" }); - } - if (selectedOption.preview) { - if (content.length) { - content.push({ text: "", color: "dim" }); - } - for (const previewLine of selectedOption.preview.split("\n")) { - content.push({ text: previewLine, color: "text" }); - } - } else { - if (content.length) { - content.push({ text: "", color: "dim" }); - } - content.push({ text: "No preview available", color: "dim" }); - } - - return renderBox(content, paneWidth, theme); -} - -function renderBox( - content: Array<{ text: string; color: "accent" | "muted" | "text" | "dim" }>, - width: number, - theme: ExtensionContext["ui"]["theme"], -): string[] { - const boxWidth = Math.max(10, width); - const innerWidth = Math.max(4, boxWidth - 2); - const top = theme.fg("accent", `┌${"─".repeat(innerWidth)}┐`); - const bottom = theme.fg("accent", `└${"─".repeat(innerWidth)}┘`); - const lines = [top]; - for (const item of content) { - for (const rawLine of wrapText(item.text, innerWidth)) { - const line = theme.fg(item.color, rawLine); - const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(line))); - lines.push( - theme.fg("accent", "│") + line + padding + theme.fg("accent", "│"), - ); - } - } - lines.push(bottom); - return lines; -} - -function mergeColumns( - left: string[], - right: string[], - leftWidth: number, - width: number, -): string[] { - const lines: string[] = []; - const rowCount = Math.max(left.length, right.length); - for (let index = 0; index < rowCount; index++) { - const leftLine = left[index] ?? ""; - const rightLine = right[index] ?? ""; - const paddedLeft = padToVisibleWidth(leftLine, leftWidth); - lines.push(truncateToWidth(`${paddedLeft} ${rightLine}`, width)); - } - return lines; -} - -function padToVisibleWidth(text: string, width: number): string { - return text + " ".repeat(Math.max(0, width - visibleWidth(text))); -} - -function measurePreviewLeftWidth( - options: ReturnType, - width: number, -): number { - let widest = 0; - for (let i = 0; i < options.length; i++) { - const option = options[i]; - const labelWidth = visibleWidth(`${i + 1}. ${option.label}`); - const descriptionWidth = option.description - ? visibleWidth(option.description) - : 0; - widest = Math.max(widest, labelWidth, descriptionWidth); - } - - const preferred = widest + 4; - const maxWidth = Math.min(34, Math.floor(width * 0.34)); - return clamp(preferred, 22, Math.max(22, maxWidth)); -} - -function clamp(value: number, min: number, max: number): number { - return Math.min(max, Math.max(min, value)); -} - -function pushWrappedText( - lines: string[], - text: string, - width: number, - theme: ExtensionContext["ui"]["theme"], - color: "accent" | "muted" | "text" | "dim" | "success" | "warning", - prefix = "", - continuationPrefix = prefix, -) { - const availableWidth = Math.max(1, width - visibleWidth(prefix)); - const wrapped = wrapText(text, availableWidth); - for (let index = 0; index < wrapped.length; index++) { - const line = wrapped[index]; - const currentPrefix = index === 0 ? prefix : continuationPrefix; - lines.push( - truncateToWidth(`${currentPrefix}${theme.fg(color, line)}`, width), - ); - } - if (wrapped.length === 0) { - lines.push(truncateToWidth(prefix, width)); - } -} - -function renderFooter( - state: AskState, - theme: ExtensionContext["ui"]["theme"], -): string { - if (state.mode === "input") { - return theme.fg( - "dim", - " Enter submit · Esc save and close · Tab/←→ save and navigate", - ); - } - if (state.mode === "note") { - return theme.fg( - "dim", - " Enter save · Esc save and close · Tab/←→ save and navigate", - ); - } - - const question = getCurrentQuestion(state); - if (isSubmitTab(state)) { - return theme.fg("dim", " ↑↓ select · Enter confirm · Esc cancel"); - } - if (question?.type === "multi") { - return theme.fg( - "dim", - " ⇆ tab · ↑↓ select · Space toggle · Enter continue · N option note · Ctrl+N question note · Esc dismiss · 1-9 quick toggle", - ); - } - return theme.fg( - "dim", - " ⇆ tab · ↑↓ select · Enter confirm · N option note · Ctrl+N question note · Esc dismiss", - ); -} - -function parseDigit(data: string): number | null { - return /^[1-9]$/.test(data) ? Number(data) : null; -} - -function renderInputLine( - line: string, - availableWidth: number, - theme: ExtensionContext["ui"]["theme"], -): string { - const innerWidth = Math.max(4, availableWidth - 2); - const truncated = truncateToWidth(line, innerWidth); - const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(truncated))); - return theme.bg("toolPendingBg", ` ${truncated}${padding} `); -} - -function formatKeybindingLabel(key: string): string { - return key - .split("+") - .map((part) => { - if (part.length <= 1) { - return part.toUpperCase(); - } - return part.charAt(0).toUpperCase() + part.slice(1); - }) - .join("+"); -} - -function isOptionNoteShortcut(data: string): boolean { - return data === "n" || data === "N"; -} - -function isQuestionNoteShortcut(data: string): boolean { - return matchesKey(data, Key.ctrl("n")); -} +export { runAskFlow } from "./ui/controller.ts"; diff --git a/src/ask-tool.ts b/src/ask-tool.ts index 894fc84..4953f21 100644 --- a/src/ask-tool.ts +++ b/src/ask-tool.ts @@ -5,6 +5,7 @@ import { renderResultText } from "./result.ts"; import { AskParamsSchema } from "./schema.ts"; import { createInitialState, summarizeResult, toAskResult } from "./state.ts"; import type { AskParams, AskQuestionInput, AskResult } from "./types.ts"; +import { UI_DIMENSIONS } from "./ui/constants.ts"; export function registerAskTool(pi: ExtensionAPI) { pi.registerTool({ @@ -63,7 +64,10 @@ export function registerAskTool(pi: ExtensionAPI) { 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, 50)})`); + text += theme.fg( + "dim", + ` (${truncateToWidth(labels, UI_DIMENSIONS.callLabelTruncateWidth)})`, + ); } return new Text(text, 0, 0); }, diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..cb69397 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,8 @@ +export const OTHER_OPTION_VALUE = "__other__"; +export const OTHER_OPTION_LABEL = "Type your own"; +export const SUBMIT_CHOICES = ["Submit answers", "Cancel"] as const; +export const NO_PREVIEW_TEXT = "No preview available"; +export const CANCELLED_SUMMARY = "User cancelled the ask flow"; +export const SUBMITTED_SUMMARY = "User submitted the ask flow"; +export const CANCELLED_RESULT_TEXT = "Cancelled"; +export const SUBMITTED_RESULT_TEXT = "Submitted"; diff --git a/src/result-format.ts b/src/result-format.ts new file mode 100644 index 0000000..a850fb8 --- /dev/null +++ b/src/result-format.ts @@ -0,0 +1,86 @@ +import type { AskResult } from "./types.ts"; + +export function formatResultLines( + result: AskResult, + options: { mode: "summary" | "render" }, +): string[] { + const lines: string[] = []; + + for (const question of result.questions) { + const answer = result.answers[question.id]; + if (!answer) { + continue; + } + + const answerLine = formatAnswerLine(question.label, answer, options.mode); + if (answerLine) { + lines.push(answerLine); + } + + const questionNoteLine = formatQuestionNoteLine( + question.label, + answer.note, + options.mode, + ); + if (questionNoteLine) { + lines.push(questionNoteLine); + } + + lines.push(...formatOptionNoteLines(question.label, answer, options.mode)); + } + + return lines; +} + +function formatAnswerLine( + questionLabel: string, + answer: AskResult["answers"][string], + mode: "summary" | "render", +): string | undefined { + const answerText = answer.customText ?? answer.labels.join(", "); + if (!answerText) { + return undefined; + } + if (mode === "summary") { + return `${questionLabel}: ${answerText}`; + } + if (answer.customText) { + return `✓ ${questionLabel}: (wrote) ${answerText}`; + } + return `✓ ${questionLabel}: ${answerText}`; +} + +function formatQuestionNoteLine( + questionLabel: string, + note: string | undefined, + mode: "summary" | "render", +): string | undefined { + if (!note) { + return undefined; + } + return mode === "summary" + ? `${questionLabel} note: ${note}` + : ` note: ${note}`; +} + +function formatOptionNoteLines( + questionLabel: string, + answer: AskResult["answers"][string], + mode: "summary" | "render", +): string[] { + const lines: string[] = []; + for (let index = 0; index < answer.values.length; index++) { + const value = answer.values[index]; + const label = answer.labels[index] ?? value; + const note = answer.optionNotes?.[value]; + if (!note) { + continue; + } + lines.push( + mode === "summary" + ? `${questionLabel} / ${label} note: ${note}` + : ` ${label} note: ${note}`, + ); + } + return lines; +} diff --git a/src/result.ts b/src/result.ts index 68d756e..ea775cb 100644 --- a/src/result.ts +++ b/src/result.ts @@ -1,29 +1,12 @@ +import { CANCELLED_RESULT_TEXT, SUBMITTED_RESULT_TEXT } from "./constants.ts"; +import { formatResultLines } from "./result-format.ts"; import type { AskResult } from "./types.ts"; export function renderResultText(result: AskResult): string { if (result.cancelled) { - return "Cancelled"; + return CANCELLED_RESULT_TEXT; } - const lines: string[] = []; - for (const question of result.questions) { - const answer = result.answers[question.id]; - if (!answer) continue; - if (answer.customText) { - lines.push(`✓ ${question.label}: (wrote) ${answer.customText}`); - } else if (answer.labels.length > 0) { - lines.push(`✓ ${question.label}: ${answer.labels.join(", ")}`); - } - if (answer.note) { - lines.push(` note: ${answer.note}`); - } - for (let index = 0; index < answer.values.length; index++) { - const value = answer.values[index]; - const label = answer.labels[index] ?? value; - const note = answer.optionNotes?.[value]; - if (!note) continue; - lines.push(` ${label} note: ${note}`); - } - } - return lines.join("\n") || "Submitted"; + const lines = formatResultLines(result, { mode: "render" }); + return lines.join("\n") || SUBMITTED_RESULT_TEXT; } diff --git a/src/state.ts b/src/state.ts index 3e5aa00..79f546d 100644 --- a/src/state.ts +++ b/src/state.ts @@ -1,547 +1,52 @@ -import type { - AskAnswer, - AskParams, - AskQuestion, - AskQuestionInput, - AskResult, - AskState, - RenderOption, -} from "./types.ts"; +import { normalizeQuestions } from "./state/normalize.ts"; +import { createInitialState as createBaseState } from "./state/transitions.ts"; -const OTHER_VALUE = "__other__"; +export { isOptionSelected } from "./state/answers.ts"; +export { normalizeQuestions } from "./state/normalize.ts"; +export { summarizeResult, toAskResult } from "./state/result.ts"; +export { + getAnswer, + getCurrentOption, + getCurrentQuestion, + getOptionNote, + getQuestionById, + getQuestionNote, + getRenderableOptions, + isInputOpenForQuestion, + isOptionNoteOpen, + isQuestionAnswered, + isQuestionNoteOpen, + isSubmitTab, +} from "./state/selectors.ts"; +export { + applyNumberShortcut, + cancelFlow, + confirmCurrentSelection, + enterInputMode, + enterOptionNoteMode, + enterQuestionNoteMode, + moveOption, + moveTab, + reduceAskState, + saveCustomAnswer, + saveNote, + submitCustomAnswer, + toggleCurrentMultiOption, +} from "./state/transitions.ts"; +export { + inputView, + isEditingView, + navigateView, + optionNoteView, + questionNoteView, + submitView, +} from "./state/view.ts"; -export function normalizeQuestions(params: AskParams): AskQuestion[] { - return params.questions.map((question, index) => - normalizeQuestion(question, index), - ); -} - -function normalizeQuestion( - question: AskQuestionInput, - index: number, -): AskQuestion { - return { - ...question, - label: question.label?.trim() || `Q${index + 1}`, - type: question.type ?? "single", - required: question.required !== false, - }; -} - -export function createInitialState(params: AskParams): AskState { - return { +export function createInitialState( + params: Parameters[0], +) { + return createBaseState({ title: params.title, questions: normalizeQuestions(params), - currentTab: 0, - optionIndex: 0, - submitIndex: 0, - mode: "navigate", - inputQuestionId: null, - noteQuestionId: null, - noteOptionValue: null, - answers: {}, - completed: false, - cancelled: false, - }; -} - -export function getCurrentQuestion(state: AskState): AskQuestion | undefined { - return state.questions[state.currentTab]; -} - -export function isSubmitTab(state: AskState): boolean { - return state.currentTab >= state.questions.length; -} - -export function getRenderableOptions(question?: AskQuestion): RenderOption[] { - if (!question) { - return []; - } - const options: RenderOption[] = [...question.options]; - if (question.type !== "preview") { - options.push({ - value: OTHER_VALUE, - label: "Type your own", - isOther: true, - }); - } - return options; -} - -export function getAnswer( - state: AskState, - questionId: string, -): AskAnswer | undefined { - return state.answers[questionId]; -} - -export function getQuestionNote( - state: AskState, - questionId: string, -): string | undefined { - return state.answers[questionId]?.note; -} - -export function getOptionNote( - state: AskState, - questionId: string, - optionValue: string, -): string | undefined { - return state.answers[questionId]?.optionNotes?.[optionValue]; -} - -export function isQuestionAnswered( - state: AskState, - questionId: string, -): boolean { - const answer = state.answers[questionId]; - return !!answer && answer.labels.length > 0; -} - -export function moveTab(state: AskState, delta: number): AskState { - const totalTabs = state.questions.length + 1; - const currentTab = (state.currentTab + delta + totalTabs) % totalTabs; - return { - ...state, - currentTab, - optionIndex: 0, - submitIndex: 0, - mode: isSubmitTab({ ...state, currentTab }) ? "submit" : "navigate", - inputQuestionId: null, - noteQuestionId: null, - noteOptionValue: null, - }; -} - -export function moveOption(state: AskState, delta: number): AskState { - if (isSubmitTab(state)) { - return { - ...state, - submitIndex: clamp(state.submitIndex + delta, 0, 1), - }; - } - - const options = getRenderableOptions(getCurrentQuestion(state)); - return { - ...state, - optionIndex: clamp( - state.optionIndex + delta, - 0, - Math.max(0, options.length - 1), - ), - }; -} - -export function enterInputMode(state: AskState, questionId: string): AskState { - return { - ...state, - mode: "input", - inputQuestionId: questionId, - noteQuestionId: null, - noteOptionValue: null, - }; -} - -export function exitInputMode(state: AskState): AskState { - return { - ...state, - mode: isSubmitTab(state) ? "submit" : "navigate", - inputQuestionId: null, - }; -} - -export function enterQuestionNoteMode( - state: AskState, - questionId: string, -): AskState { - return { - ...state, - mode: "note", - inputQuestionId: null, - noteQuestionId: questionId, - noteOptionValue: null, - }; -} - -export function enterOptionNoteMode( - state: AskState, - questionId: string, - optionValue: string, -): AskState { - return { - ...state, - mode: "note", - inputQuestionId: null, - noteQuestionId: questionId, - noteOptionValue: optionValue, - }; -} - -export function exitNoteMode(state: AskState): AskState { - return { - ...state, - mode: isSubmitTab(state) ? "submit" : "navigate", - noteQuestionId: null, - noteOptionValue: null, - }; -} - -export function toggleCurrentMultiOption(state: AskState): AskState { - const question = getCurrentQuestion(state); - if (!question || question.type === "single") return state; - - const options = getRenderableOptions(question); - const option = options[state.optionIndex]; - if (!option) return state; - if (option.isOther) { - return enterInputMode(state, question.id); - } - return toggleAnswer(state, question.id, option, state.optionIndex); -} - -export function confirmCurrentSelection(state: AskState): AskState { - if (isSubmitTab(state)) { - if (state.submitIndex === 1) { - return { ...state, cancelled: true, completed: true }; - } - return { ...state, completed: true }; - } - - const question = getCurrentQuestion(state); - if (!question) return state; - - if (question.type === "multi") { - const currentOption = getRenderableOptions(question)[state.optionIndex]; - if (currentOption?.isOther) { - return enterInputMode(state, question.id); - } - return advanceToNextTab(state); - } - - const option = getRenderableOptions(question)[state.optionIndex]; - if (!option) return state; - if (option.isOther) { - return enterInputMode(state, question.id); - } - const nextState = setSingleAnswer( - state, - question.id, - option, - state.optionIndex, - ); - return advanceToNextTab(nextState); -} - -export function applyNumberShortcut(state: AskState, digit: number): AskState { - if (digit <= 0 || isSubmitTab(state)) return state; - - const question = getCurrentQuestion(state); - if (!question) return state; - - const options = getRenderableOptions(question); - const index = digit - 1; - const option = options[index]; - if (!option) return state; - - const selectedState = { ...state, optionIndex: index }; - if (option.isOther) { - return enterInputMode(selectedState, question.id); - } - if (question.type === "multi") { - return toggleAnswer(selectedState, question.id, option, index); - } - const nextState = setSingleAnswer(selectedState, question.id, option, index); - return advanceToNextTab(nextState); -} - -export function saveCustomAnswer(state: AskState, rawValue: string): AskState { - const question = state.questions.find( - (item) => item.id === state.inputQuestionId, - ); - if (!question) return exitInputMode(state); - - const nextState = exitInputMode(state); - const trimmed = rawValue.trim(); - const existing = nextState.answers[question.id]; - if (!trimmed) { - return { - ...nextState, - answers: updateAnswer(nextState.answers, question.id, (answer) => { - delete answer.customText; - answer.values = []; - answer.labels = []; - answer.indices = []; - }), - }; - } - - return { - ...nextState, - answers: { - ...nextState.answers, - [question.id]: { - ...emptyAnswer(), - ...existing, - values: [rawValue], - labels: [rawValue], - indices: [], - customText: rawValue, - }, - }, - }; -} - -export function submitCustomAnswer( - state: AskState, - rawValue: string, -): AskState { - const questionId = state.inputQuestionId; - const nextState = saveCustomAnswer(state, rawValue); - if (!questionId || !isQuestionAnswered(nextState, questionId)) { - return nextState; - } - return advanceToNextTab(nextState); -} - -export function saveNote(state: AskState, rawValue: string): AskState { - const questionId = state.noteQuestionId; - if (!questionId) { - return exitNoteMode(state); - } - - const optionValue = state.noteOptionValue; - const trimmed = rawValue.trim(); - const nextState = exitNoteMode(state); - return { - ...nextState, - answers: updateAnswer(nextState.answers, questionId, (answer) => { - if (optionValue) { - const optionNotes = { ...(answer.optionNotes ?? {}) }; - if (trimmed) { - optionNotes[optionValue] = rawValue; - } else { - delete optionNotes[optionValue]; - } - answer.optionNotes = - Object.keys(optionNotes).length > 0 ? optionNotes : undefined; - return; - } - - if (trimmed) { - answer.note = rawValue; - } else { - delete answer.note; - } - }), - }; -} - -export function cancelFlow(state: AskState): AskState { - if (state.mode === "input") { - return exitInputMode(state); - } - if (state.mode === "note") { - return exitNoteMode(state); - } - return { - ...state, - cancelled: true, - completed: true, - }; -} - -export function toAskResult(state: AskState): AskResult { - return { - title: state.title, - cancelled: state.cancelled, - questions: state.questions.map((question) => ({ - id: question.id, - label: question.label, - prompt: question.prompt, - type: question.type, - })), - answers: filterAnswersForSubmission(state.answers), - }; -} - -export function summarizeResult(result: AskResult): string { - if (result.cancelled) { - return "User cancelled the ask flow"; - } - - const lines = result.questions.flatMap((question) => { - const answer = result.answers[question.id]; - if (!answer) return []; - - const questionLines: string[] = []; - if (answer.customText) { - questionLines.push(`${question.label}: ${answer.customText}`); - } else if (answer.labels.length > 0) { - questionLines.push(`${question.label}: ${answer.labels.join(", ")}`); - } - if (answer.note) { - questionLines.push(`${question.label} note: ${answer.note}`); - } - for (const label of answer.labels) { - const optionValue = answer.values[answer.labels.indexOf(label)]; - const note = optionValue ? answer.optionNotes?.[optionValue] : undefined; - if (note) { - questionLines.push(`${question.label} / ${label} note: ${note}`); - } - } - return questionLines; }); - - return lines.join("\n") || "User submitted the ask flow"; -} - -function toggleAnswer( - state: AskState, - questionId: string, - option: RenderOption, - index: number, -): AskState { - const existing = state.answers[questionId] ?? emptyAnswer(); - const next = cloneAnswer(existing); - delete next.customText; - const foundIndex = next.values.indexOf(option.value); - - if (foundIndex >= 0) { - next.values.splice(foundIndex, 1); - next.labels.splice(foundIndex, 1); - next.indices.splice(foundIndex, 1); - } else { - next.values.push(option.value); - next.labels.push(option.label); - next.indices.push(index + 1); - } - - return { - ...state, - answers: { - ...state.answers, - [questionId]: next, - }, - }; -} - -function setSingleAnswer( - state: AskState, - questionId: string, - option: RenderOption, - index: number, -): AskState { - const existing = state.answers[questionId] ?? emptyAnswer(); - return { - ...state, - answers: { - ...state.answers, - [questionId]: { - ...emptyAnswer(), - ...existing, - values: [option.value], - labels: [option.label], - indices: [index + 1], - customText: undefined, - }, - }, - }; -} - -function advanceToNextTab(state: AskState): AskState { - const totalQuestions = state.questions.length; - if (totalQuestions === 1) { - return { - ...state, - currentTab: totalQuestions, - mode: "submit", - optionIndex: 0, - submitIndex: 0, - inputQuestionId: null, - noteQuestionId: null, - noteOptionValue: null, - }; - } - - const nextTab = Math.min(state.currentTab + 1, totalQuestions); - return { - ...state, - currentTab: nextTab, - mode: nextTab === totalQuestions ? "submit" : "navigate", - optionIndex: 0, - submitIndex: 0, - inputQuestionId: null, - noteQuestionId: null, - noteOptionValue: null, - }; -} - -function emptyAnswer(): AskAnswer { - return { values: [], labels: [], indices: [] }; -} - -function cloneAnswer(answer: AskAnswer): AskAnswer { - return { - values: [...answer.values], - labels: [...answer.labels], - indices: [...answer.indices], - customText: answer.customText, - note: answer.note, - optionNotes: answer.optionNotes ? { ...answer.optionNotes } : undefined, - }; -} - -function clamp(value: number, min: number, max: number): number { - return Math.min(max, Math.max(min, value)); -} - -function updateAnswer( - answers: Record, - questionId: string, - mutate: (answer: AskAnswer) => void, -): Record { - const nextAnswer = cloneAnswer(answers[questionId] ?? emptyAnswer()); - mutate(nextAnswer); - if (isEmptyAnswer(nextAnswer)) { - const nextAnswers = { ...answers }; - delete nextAnswers[questionId]; - return nextAnswers; - } - return { - ...answers, - [questionId]: nextAnswer, - }; -} - -function isEmptyAnswer(answer: AskAnswer): 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 filterAnswersForSubmission( - answers: Record, -): Record { - const result: Record = {}; - for (const [questionId, answer] of Object.entries(answers)) { - const next = cloneAnswer(answer); - if (next.optionNotes) { - const selectedNotes = Object.fromEntries( - next.values - .map((value) => [value, next.optionNotes?.[value]]) - .filter((entry): entry is [string, string] => !!entry[1]), - ); - next.optionNotes = - Object.keys(selectedNotes).length > 0 ? selectedNotes : undefined; - } - if (!isEmptyAnswer(next)) { - result[questionId] = next; - } - } - return result; } diff --git a/src/state/answers.ts b/src/state/answers.ts new file mode 100644 index 0000000..3d39830 --- /dev/null +++ b/src/state/answers.ts @@ -0,0 +1,179 @@ +import type { + AskDisplayOption, + AskResultAnswer, + AskSelectedOption, + AskStateAnswer, +} from "../types.ts"; + +export function emptyAnswer(): AskStateAnswer { + return { selected: [] }; +} + +export function cloneAnswer(answer: AskStateAnswer): AskStateAnswer { + return { + selected: answer.selected.map(cloneSelection), + customText: answer.customText, + note: answer.note, + optionNotes: answer.optionNotes ? { ...answer.optionNotes } : undefined, + }; +} + +export function cloneResultAnswer(answer: AskResultAnswer): AskResultAnswer { + return { + values: [...answer.values], + labels: [...answer.labels], + indices: [...answer.indices], + customText: answer.customText, + note: answer.note, + optionNotes: answer.optionNotes ? { ...answer.optionNotes } : undefined, + }; +} + +export function toggleSelection( + answer: AskStateAnswer, + option: AskDisplayOption, + index: number, +): AskStateAnswer { + const next = cloneAnswer(answer); + delete next.customText; + const selectedIndex = next.selected.findIndex( + (selection) => selection.value === option.value, + ); + + if (selectedIndex >= 0) { + next.selected.splice(selectedIndex, 1); + return next; + } + + next.selected.push({ + value: option.value, + label: option.label, + index: index + 1, + }); + return next; +} + +export function setSingleSelection( + answer: AskStateAnswer, + option: AskDisplayOption, + index: number, +): AskStateAnswer { + return { + ...emptyAnswer(), + note: answer.note, + optionNotes: answer.optionNotes ? { ...answer.optionNotes } : undefined, + selected: [ + { + value: option.value, + label: option.label, + index: index + 1, + }, + ], + }; +} + +export function saveCustomText( + answer: AskStateAnswer, + rawValue: string, +): AskStateAnswer { + const trimmed = rawValue.trim(); + const next = cloneAnswer(answer); + next.selected = []; + if (!trimmed) { + delete next.customText; + return next; + } + next.customText = rawValue; + return next; +} + +export function saveQuestionNote( + answer: AskStateAnswer, + rawValue: string, +): AskStateAnswer { + const next = cloneAnswer(answer); + if (rawValue.trim()) { + next.note = rawValue; + return next; + } + delete next.note; + return next; +} + +export function saveOptionNote( + answer: AskStateAnswer, + optionValue: string, + rawValue: string, +): AskStateAnswer { + const next = cloneAnswer(answer); + const optionNotes = { ...(next.optionNotes ?? {}) }; + if (rawValue.trim()) { + optionNotes[optionValue] = rawValue; + } else { + delete optionNotes[optionValue]; + } + next.optionNotes = + Object.keys(optionNotes).length > 0 ? optionNotes : undefined; + return next; +} + +export function isAnswerEmpty(answer: AskStateAnswer): boolean { + return ( + answer.selected.length === 0 && + !answer.customText && + !answer.note && + (!answer.optionNotes || Object.keys(answer.optionNotes).length === 0) + ); +} + +export function isAnswerAnswered(answer?: AskStateAnswer): boolean { + if (!answer) { + return false; + } + return answer.selected.length > 0 || !!answer.customText?.trim(); +} + +export function isOptionSelected( + answer: AskStateAnswer | undefined, + optionValue: string, +): boolean { + return !!answer?.selected.some( + (selection) => selection.value === optionValue, + ); +} + +export function serializeAnswer(answer: AskStateAnswer): AskResultAnswer { + const values = answer.customText + ? [answer.customText] + : answer.selected.map((selection) => selection.value); + const labels = answer.customText + ? [answer.customText] + : answer.selected.map((selection) => selection.label); + const indices = answer.selected.map((selection) => selection.index); + const selectedNotes = answer.optionNotes + ? Object.fromEntries( + answer.selected + .map((selection) => [ + selection.value, + answer.optionNotes?.[selection.value], + ]) + .filter((entry): entry is [string, string] => !!entry[1]), + ) + : undefined; + + return { + values, + labels, + indices, + customText: answer.customText, + note: answer.note, + optionNotes: + selectedNotes && Object.keys(selectedNotes).length > 0 + ? selectedNotes + : undefined, + }; +} + +function cloneSelection(selection: AskSelectedOption): AskSelectedOption { + return { ...selection }; +} diff --git a/src/state/normalize.ts b/src/state/normalize.ts new file mode 100644 index 0000000..aeccce6 --- /dev/null +++ b/src/state/normalize.ts @@ -0,0 +1,19 @@ +import type { AskParams, AskQuestion, AskQuestionInput } from "../types.ts"; + +export function normalizeQuestions(params: AskParams): AskQuestion[] { + return params.questions.map((question, index) => + normalizeQuestion(question, index), + ); +} + +function normalizeQuestion( + question: AskQuestionInput, + index: number, +): AskQuestion { + return { + ...question, + label: question.label?.trim() || `Q${index + 1}`, + type: question.type ?? "single", + required: question.required !== false, + }; +} diff --git a/src/state/result.ts b/src/state/result.ts new file mode 100644 index 0000000..828438e --- /dev/null +++ b/src/state/result.ts @@ -0,0 +1,54 @@ +import { CANCELLED_SUMMARY, SUBMITTED_SUMMARY } from "../constants.ts"; +import { formatResultLines } from "../result-format.ts"; +import type { AskResult, AskState } from "../types.ts"; +import { isAnswerEmpty, serializeAnswer } from "./answers.ts"; + +export function toAskResult(state: AskState): AskResult { + const answers = Object.fromEntries( + Object.entries(state.answers) + .map( + ([questionId, answer]) => + [questionId, serializeAnswer(answer)] as const, + ) + .filter(([, answer]) => !isSerializedAnswerEmpty(answer)), + ); + + return { + title: state.title, + cancelled: state.cancelled, + questions: state.questions.map((question) => ({ + id: question.id, + label: question.label, + prompt: question.prompt, + type: question.type, + })), + answers, + }; +} + +export function summarizeResult(result: AskResult): string { + if (result.cancelled) { + return CANCELLED_SUMMARY; + } + + const lines = formatResultLines(result, { mode: "summary" }); + return lines.join("\n") || SUBMITTED_SUMMARY; +} + +export function hasAnswerContent(state: AskState, questionId: string): boolean { + const answer = state.answers[questionId]; + 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) + ); +} diff --git a/src/state/selectors.ts b/src/state/selectors.ts new file mode 100644 index 0000000..c03aeed --- /dev/null +++ b/src/state/selectors.ts @@ -0,0 +1,110 @@ +import { OTHER_OPTION_LABEL, OTHER_OPTION_VALUE } from "../constants.ts"; +import type { + AskDisplayOption, + AskQuestion, + AskState, + AskStateAnswer, +} from "../types.ts"; +import { isAnswerAnswered } from "./answers.ts"; + +export function getCurrentQuestion(state: AskState): AskQuestion | undefined { + return state.questions[state.activeTabIndex]; +} + +export function getQuestionById( + state: AskState, + questionId: string, +): AskQuestion | undefined { + return state.questions.find((question) => question.id === questionId); +} + +export function isSubmitTab(state: AskState): boolean { + return state.activeTabIndex >= state.questions.length; +} + +export function getRenderableOptions( + question?: AskQuestion, +): AskDisplayOption[] { + if (!question) { + return []; + } + + const options: AskDisplayOption[] = question.options.map((option) => ({ + ...option, + })); + if (question.type !== "preview") { + options.push({ + value: OTHER_OPTION_VALUE, + label: OTHER_OPTION_LABEL, + isCustomOption: true, + }); + } + return options; +} + +export function getCurrentOption( + state: AskState, +): AskDisplayOption | undefined { + return getRenderableOptions(getCurrentQuestion(state))[ + state.activeOptionIndex + ]; +} + +export function getAnswer( + state: AskState, + questionId: string, +): AskStateAnswer | undefined { + return state.answers[questionId]; +} + +export function getQuestionNote( + state: AskState, + questionId: string, +): string | undefined { + return getAnswer(state, questionId)?.note; +} + +export function getOptionNote( + state: AskState, + questionId: string, + optionValue: string, +): string | undefined { + return getAnswer(state, questionId)?.optionNotes?.[optionValue]; +} + +export function isQuestionAnswered( + state: AskState, + questionId: string, +): boolean { + return isAnswerAnswered(getAnswer(state, questionId)); +} + +export function isQuestionNoteOpen( + state: AskState, + questionId: string, +): boolean { + return ( + state.view.kind === "note" && + state.view.questionId === questionId && + state.view.optionValue === undefined + ); +} + +export function isOptionNoteOpen( + state: AskState, + questionId: string, + optionValue: string, +): boolean { + return ( + state.view.kind === "note" && + state.view.questionId === questionId && + state.view.optionValue === optionValue + ); +} + +export function isInputOpenForQuestion( + state: AskState, + questionId: string, +): boolean { + return state.view.kind === "input" && state.view.questionId === questionId; +} diff --git a/src/state/transitions.ts b/src/state/transitions.ts new file mode 100644 index 0000000..a5171c0 --- /dev/null +++ b/src/state/transitions.ts @@ -0,0 +1,351 @@ +import type { AskAction, AskState } from "../types.ts"; +import { + emptyAnswer, + isAnswerAnswered, + isAnswerEmpty, + saveCustomText, + saveOptionNote, + saveQuestionNote, + setSingleSelection, + toggleSelection, +} from "./answers.ts"; +import { + getAnswer, + getCurrentOption, + getCurrentQuestion, + getQuestionById, + getRenderableOptions, + isSubmitTab, +} from "./selectors.ts"; +import { + inputView, + isEditingView, + navigateView, + optionNoteView, + questionNoteView, + submitView, +} from "./view.ts"; + +const SUBMIT_ACTION_COUNT = 2; + +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, + }; +} + +export function reduceAskState(state: AskState, action: AskAction): AskState { + switch (action.type) { + case "MOVE_TAB": + return moveTabBy(state, action.delta); + case "MOVE_OPTION": + return moveOptionBy(state, action.delta); + case "OPEN_INPUT": + return setView(state, inputView(action.questionId)); + case "OPEN_QUESTION_NOTE": + return setView(state, questionNoteView(action.questionId)); + case "OPEN_OPTION_NOTE": + return setView( + state, + optionNoteView(action.questionId, action.optionValue), + ); + case "CONFIRM": + return confirmCurrentSelection(state); + case "TOGGLE_MULTI": + return toggleCurrentMultiOption(state); + case "NUMBER_SHORTCUT": + return applyNumberShortcut(state, action.digit); + case "SAVE_INPUT": + return saveInputValue(state, action.value, action.submit ?? false); + case "SAVE_NOTE": + return saveNoteValue(state, action.value); + case "CANCEL": + return cancelFlow(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 }); +} + +export function enterQuestionNoteMode( + state: AskState, + questionId: string, +): AskState { + return reduceAskState(state, { type: "OPEN_QUESTION_NOTE", questionId }); +} + +export function enterOptionNoteMode( + state: AskState, + questionId: string, + optionValue: string, +): AskState { + return reduceAskState(state, { + type: "OPEN_OPTION_NOTE", + questionId, + optionValue, + }); +} + +export function toggleCurrentMultiOption(state: AskState): AskState { + const question = getCurrentQuestion(state); + if (!question || question.type === "single") { + return state; + } + + const option = getCurrentOption(state); + if (!option) { + return state; + } + if (option.isCustomOption) { + return setView(state, inputView(question.id)); + } + + return updateAnswer(state, question.id, (answer) => + toggleSelection(answer, option, state.activeOptionIndex), + ); +} + +export function confirmCurrentSelection(state: AskState): AskState { + if (isSubmitTab(state)) { + return state.activeSubmitActionIndex === 1 + ? { ...state, cancelled: true, completed: true } + : { ...state, completed: true }; + } + + const question = getCurrentQuestion(state); + const option = getCurrentOption(state); + if (!question || !option) { + return state; + } + + if (question.type === "multi") { + if (option.isCustomOption) { + return setView(state, inputView(question.id)); + } + return advanceToNextTab(state); + } + + if (option.isCustomOption) { + return setView(state, inputView(question.id)); + } + + const nextState = updateAnswer(state, question.id, (answer) => + setSingleSelection(answer, option, state.activeOptionIndex), + ); + return advanceToNextTab(nextState); +} + +export function applyNumberShortcut(state: AskState, digit: number): AskState { + if (digit <= 0 || isSubmitTab(state)) { + return state; + } + + const question = getCurrentQuestion(state); + if (!question) { + return state; + } + + const index = digit - 1; + const option = getRenderableOptions(question)[index]; + if (!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); +} + +export function saveCustomAnswer(state: AskState, rawValue: string): AskState { + return saveInputValue(state, rawValue, false); +} + +export function submitCustomAnswer( + state: AskState, + rawValue: string, +): AskState { + return saveInputValue(state, rawValue, true); +} + +export function saveNote(state: AskState, rawValue: string): AskState { + return saveNoteValue(state, rawValue); +} + +export function cancelFlow(state: AskState): AskState { + if (isEditingView(state)) { + return exitEditingView(state); + } + return { + ...state, + cancelled: true, + completed: true, + }; +} + +function saveInputValue( + state: AskState, + rawValue: string, + submit: boolean, +): AskState { + if (state.view.kind !== "input") { + return state; + } + + const question = getQuestionById(state, state.view.questionId); + if (!question) { + return exitEditingView(state); + } + + const nextState = updateAnswer( + exitEditingView(state), + question.id, + (answer) => saveCustomText(answer, rawValue), + ); + if (!submit || !isAnswerAnswered(nextState.answers[question.id])) { + return nextState; + } + return advanceToNextTab(nextState); +} + +function saveNoteValue(state: AskState, rawValue: string): AskState { + if (state.view.kind !== "note") { + return exitEditingView(state); + } + + const { questionId, optionValue } = state.view; + const nextState = updateAnswer( + exitEditingView(state), + questionId, + (answer) => + optionValue + ? saveOptionNote(answer, optionValue, rawValue) + : saveQuestionNote(answer, rawValue), + ); + 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, + view, + }; +} + +function exitEditingView(state: AskState): AskState { + return { + ...state, + view: isSubmitTab(state) ? submitView() : navigateView(), + }; +} + +function advanceToNextTab(state: AskState): AskState { + const nextTab = Math.min(state.activeTabIndex + 1, state.questions.length); + return { + ...state, + activeTabIndex: nextTab, + activeOptionIndex: 0, + activeSubmitActionIndex: 0, + view: nextTab === state.questions.length ? submitView() : navigateView(), + }; +} + +function updateAnswer( + state: AskState, + questionId: string, + mutate: ( + answer: ReturnType, + ) => ReturnType, +): AskState { + const existing = getAnswer(state, questionId) ?? emptyAnswer(); + const nextAnswer = mutate(existing); + const answers = { ...state.answers }; + if (isAnswerEmpty(nextAnswer)) { + delete answers[questionId]; + } else { + answers[questionId] = nextAnswer; + } + return { + ...state, + answers, + }; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} diff --git a/src/state/view.ts b/src/state/view.ts new file mode 100644 index 0000000..4ef7d0a --- /dev/null +++ b/src/state/view.ts @@ -0,0 +1,28 @@ +import type { AskState, ViewState } from "../types.ts"; + +export function navigateView(): ViewState { + return { kind: "navigate" }; +} + +export function submitView(): ViewState { + return { kind: "submit" }; +} + +export function inputView(questionId: string): ViewState { + return { kind: "input", questionId }; +} + +export function questionNoteView(questionId: string): ViewState { + return { kind: "note", questionId }; +} + +export function optionNoteView( + questionId: string, + optionValue: string, +): ViewState { + return { kind: "note", questionId, optionValue }; +} + +export function isEditingView(state: AskState): boolean { + return state.view.kind === "input" || state.view.kind === "note"; +} diff --git a/src/text.ts b/src/text.ts index 34a2f60..832a33a 100644 --- a/src/text.ts +++ b/src/text.ts @@ -2,43 +2,37 @@ import { visibleWidth } from "@mariozechner/pi-tui"; export function wrapText(text: string, width: number): string[] { const effectiveWidth = Math.max(1, width); - const lines: string[] = []; - const paragraphs = text.split("\n"); - - for (const paragraph of paragraphs) { - if (paragraph.length === 0) { - lines.push(""); - continue; - } - - const leadingWhitespace = paragraph.match(/^\s*/)?.[0] ?? ""; - const body = paragraph.slice(leadingWhitespace.length).trim(); - if (!body) { - lines.push(leadingWhitespace); - continue; - } - - const wrapped = wrapParagraph( - body, - effectiveWidth - visibleWidth(leadingWhitespace), + const lines = text + .split("\n") + .flatMap((paragraph) => + wrapParagraphWithIndentation(paragraph, effectiveWidth), ); - if (wrapped.length === 0) { - lines.push(leadingWhitespace); - continue; - } - - for (const [index, part] of wrapped.entries()) { - lines.push( - index === 0 - ? `${leadingWhitespace}${part}` - : `${leadingWhitespace}${part}`, - ); - } - } return lines.length > 0 ? lines : [""]; } +function wrapParagraphWithIndentation( + paragraph: string, + width: number, +): string[] { + if (paragraph.length === 0) { + return [""]; + } + + const leadingWhitespace = paragraph.match(/^\s*/)?.[0] ?? ""; + const body = paragraph.slice(leadingWhitespace.length).trim(); + if (!body) { + return [leadingWhitespace]; + } + + const wrapped = wrapParagraph(body, width - visibleWidth(leadingWhitespace)); + if (wrapped.length === 0) { + return [leadingWhitespace]; + } + + return wrapped.map((part) => `${leadingWhitespace}${part}`); +} + function wrapParagraph(text: string, width: number): string[] { const effectiveWidth = Math.max(1, width); const words = text.split(/\s+/).filter(Boolean); diff --git a/src/types.ts b/src/types.ts index d3ab7a1..aa1b9f5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -28,7 +28,20 @@ export interface AskQuestion required: boolean; } -export interface AskAnswer { +export interface AskSelectedOption { + value: string; + label: string; + index: number; +} + +export interface AskStateAnswer { + selected: AskSelectedOption[]; + customText?: string; + note?: string; + optionNotes?: Record; +} + +export interface AskResultAnswer { values: string[]; labels: string[]; indices: number[]; @@ -46,26 +59,40 @@ export interface AskResult { prompt: string; type: AskQuestionType; }>; - answers: Record; + answers: Record; } -export type ViewMode = "navigate" | "input" | "note" | "submit"; +export type ViewState = + | { kind: "navigate" } + | { kind: "submit" } + | { kind: "input"; questionId: string } + | { kind: "note"; questionId: string; optionValue?: string }; export interface AskState { title?: string; questions: AskQuestion[]; - currentTab: number; - optionIndex: number; - submitIndex: number; - mode: ViewMode; - inputQuestionId: string | null; - noteQuestionId: string | null; - noteOptionValue: string | null; - answers: Record; + activeTabIndex: number; + activeOptionIndex: number; + activeSubmitActionIndex: number; + view: ViewState; + answers: Record; completed: boolean; cancelled: boolean; } -export interface RenderOption extends AskOption { - isOther?: boolean; +export interface AskDisplayOption extends AskOption { + isCustomOption?: boolean; } + +export type AskAction = + | { type: "MOVE_TAB"; delta: 1 | -1 } + | { type: "MOVE_OPTION"; delta: 1 | -1 } + | { type: "OPEN_INPUT"; questionId: string } + | { type: "OPEN_QUESTION_NOTE"; questionId: string } + | { type: "OPEN_OPTION_NOTE"; questionId: string; optionValue: string } + | { type: "CONFIRM" } + | { type: "TOGGLE_MULTI" } + | { type: "NUMBER_SHORTCUT"; digit: number } + | { type: "SAVE_INPUT"; value: string; submit?: boolean } + | { type: "SAVE_NOTE"; value: string } + | { type: "CANCEL" }; diff --git a/src/ui/constants.ts b/src/ui/constants.ts new file mode 100644 index 0000000..5d69596 --- /dev/null +++ b/src/ui/constants.ts @@ -0,0 +1,28 @@ +export const UI_DIMENSIONS = { + boxMinWidth: 10, + callLabelTruncateWidth: 50, + editorContentPadding: 5, + editorIndentedPadding: 7, + editorMinWidth: 8, + previewWideMinWidth: 90, + previewMinRightWidth: 24, + previewLeftMinWidth: 22, + previewLeftMaxWidth: 34, + previewLeftRatio: 0.34, +} as const; + +export const UI_TEXT = { + questionNoteTitle: "Question note", + reviewTitle: "Review your answers", + readyToSubmit: "Ready to submit your answers?", + unanswered: "→ (unanswered)", + footerInput: " Enter submit · Esc save and close · Tab/←→ save and navigate", + footerNote: " Enter save · Esc save and close · Tab/←→ save and navigate", + footerSubmit: " ↑↓ select · Enter confirm · Esc cancel", + footerMulti: + " ⇆ tab · ↑↓ select · Space toggle · Enter continue · N option note · Ctrl+N question note · Esc dismiss · 1-9 quick toggle", + footerDefault: + " ⇆ tab · ↑↓ select · Enter confirm · N option note · Ctrl+N question note · Esc dismiss", + hintSave: "{newLineHint} newline · Enter save · Esc save and close", + hintSubmit: "{newLineHint} newline · Enter submit · Esc save and close", +} as const; diff --git a/src/ui/controller.ts b/src/ui/controller.ts new file mode 100644 index 0000000..ba2c03c --- /dev/null +++ b/src/ui/controller.ts @@ -0,0 +1,350 @@ +import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; +import { Editor, type EditorTheme } from "@mariozechner/pi-tui"; +import { + applyNumberShortcut, + cancelFlow, + confirmCurrentSelection, + createInitialState, + enterInputMode, + enterOptionNoteMode, + enterQuestionNoteMode, + getAnswer, + getCurrentOption, + getCurrentQuestion, + getOptionNote, + getQuestionNote, + isEditingView, + isSubmitTab, + moveOption, + moveTab, + saveCustomAnswer, + saveNote, + submitCustomAnswer, + toAskResult, + toggleCurrentMultiOption, +} from "../state.ts"; +import type { AskParams, AskResult, AskState } from "../types.ts"; +import type { AskInputCommand } from "./input.ts"; +import { formatKeybindingLabel, getInputCommand } from "./input.ts"; +import { renderAskScreen } from "./render.ts"; + +type CustomCallback = Parameters[0]; +type CustomCallbackArgs = CustomCallback extends (...args: infer T) => unknown + ? T + : never; +type Tui = CustomCallbackArgs[0]; +type Theme = CustomCallbackArgs[1]; +type Keybindings = CustomCallbackArgs[2]; +type Done = (result: AskResult) => void; + +interface AskFlowController { + state: AskState; + cachedLines: string[] | undefined; + suppressAutoInputForSelection: boolean; + newLineHint: string; + tui: Tui; + theme: Theme; + editor: Editor; + done: Done; +} + +export async function runAskFlow( + ctx: ExtensionContext, + params: AskParams, +): Promise { + return ctx.ui.custom((...args) => + createAskFlowController(args, params), + ); +} + +function createAskFlowController( + [tui, theme, keybindings, done]: [ + Tui, + Theme, + Keybindings, + (result: AskResult) => void, + ], + params: AskParams, +) { + const controller: AskFlowController = { + state: createInitialState(params), + cachedLines: undefined, + suppressAutoInputForSelection: false, + newLineHint: formatKeybindingLabel( + keybindings.getKeys("tui.input.newLine")[0] ?? "shift+enter", + ), + tui, + theme, + editor: createEditor(tui, theme), + done, + }; + + controller.editor.onSubmit = (value) => submitEditor(controller, value); + syncInputModeWithSelection(controller); + + return { + render: (width: number) => renderController(controller, width), + invalidate() { + controller.cachedLines = undefined; + }, + handleInput(data: string) { + handleControllerInput(controller, data); + }, + }; +} + +function renderController( + controller: AskFlowController, + width: number, +): string[] { + if (!controller.cachedLines) { + controller.cachedLines = renderAskScreen({ + state: controller.state, + theme: controller.theme, + width, + editor: controller.editor, + newLineHint: controller.newLineHint, + }); + } + return controller.cachedLines; +} + +function handleControllerInput(controller: AskFlowController, data: string) { + const command = getInputCommand(controller.state, data); + if (isEditingView(controller.state)) { + handleEditingCommand(controller, command, data); + return; + } + handleNavigationCommand(controller, command); +} + +function handleEditingCommand( + controller: AskFlowController, + command: AskInputCommand, + data: string, +) { + if (command.kind === "editMoveTab") { + controller.state = saveEditorAndMoveTab(controller, command.delta); + syncInputModeWithSelection(controller); + refresh(controller); + return; + } + if (command.kind === "editClose") { + controller.state = saveEditorWithoutAdvancing(controller); + controller.suppressAutoInputForSelection = + controller.state.view.kind !== "input"; + refresh(controller); + return; + } + if (command.kind === "delegateToEditor") { + controller.editor.handleInput(data); + refresh(controller); + } +} + +function handleNavigationCommand( + controller: AskFlowController, + command: AskInputCommand, +) { + switch (command.kind) { + case "moveTab": + navigateTabs(controller, command.delta); + return; + case "moveOption": + navigateOptions(controller, command.delta); + return; + case "toggleMulti": + handleToggleMulti(controller); + return; + case "openQuestionNote": + openQuestionNote(controller); + return; + case "openOptionNote": + openOptionNote(controller); + return; + case "confirm": + commitNavigation(controller, confirmCurrentSelection(controller.state)); + maybeFinish(controller); + return; + case "cancel": + commitNavigation(controller, cancelFlow(controller.state)); + maybeFinish(controller); + return; + case "numberShortcut": + commitNavigation( + controller, + applyNumberShortcut(controller.state, command.digit), + ); + return; + case "ignore": + case "editMoveTab": + case "editClose": + case "delegateToEditor": + return; + default: + return; + } +} + +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 handleToggleMulti(controller: AskFlowController) { + const question = getCurrentQuestion(controller.state); + if (question?.type !== "multi") { + return; + } + controller.suppressAutoInputForSelection = false; + commitNavigation(controller, toggleCurrentMultiOption(controller.state)); +} + +function openQuestionNote(controller: AskFlowController) { + const question = getCurrentQuestion(controller.state); + if (!question || isSubmitTab(controller.state)) { + return; + } + controller.state = enterQuestionNoteMode(controller.state, question.id); + hydrateEditorForCurrentView(controller); + refresh(controller); +} + +function openOptionNote(controller: AskFlowController) { + const question = getCurrentQuestion(controller.state); + const option = getCurrentOption(controller.state); + if ( + !question || + !option || + option.isCustomOption || + isSubmitTab(controller.state) + ) { + return; + } + controller.state = enterOptionNoteMode( + controller.state, + question.id, + option.value, + ); + hydrateEditorForCurrentView(controller); + refresh(controller); +} + +function commitNavigation(controller: AskFlowController, nextState: AskState) { + controller.suppressAutoInputForSelection = false; + controller.state = nextState; + hydrateEditorForCurrentView(controller); + refresh(controller); +} + +function submitEditor(controller: AskFlowController, value: string) { + controller.suppressAutoInputForSelection = false; + controller.state = submitEditorValue(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 refresh(controller: AskFlowController) { + controller.cachedLines = undefined; + controller.tui.requestRender(); +} + +function maybeFinish(controller: AskFlowController) { + if (controller.state.completed) { + controller.done(toAskResult(controller.state)); + } +} + +function hydrateEditorForCurrentView(controller: AskFlowController) { + controller.editor.setText(getEditorTextForCurrentView(controller.state)); +} + +function syncInputModeWithSelection(controller: AskFlowController) { + if ( + isEditingView(controller.state) || + isSubmitTab(controller.state) || + 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); +} + +function saveEditorWithoutAdvancing(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 createEditor(tui: Tui, theme: Theme) { + const editorTheme: EditorTheme = { + borderColor: (text) => theme.fg("accent", text), + selectList: { + selectedPrefix: (text) => theme.fg("accent", text), + selectedText: (text) => theme.fg("accent", text), + description: (text) => theme.fg("muted", text), + scrollInfo: (text) => theme.fg("dim", text), + noMatch: (text) => theme.fg("warning", text), + }, + }; + return new Editor(tui, 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/input.ts b/src/ui/input.ts new file mode 100644 index 0000000..812bc72 --- /dev/null +++ b/src/ui/input.ts @@ -0,0 +1,99 @@ +import { Key, matchesKey } from "@mariozechner/pi-tui"; +import type { AskState } from "../types.ts"; + +export type AskInputCommand = + | { kind: "moveTab"; delta: 1 | -1 } + | { kind: "moveOption"; delta: 1 | -1 } + | { kind: "toggleMulti" } + | { kind: "openQuestionNote" } + | { kind: "openOptionNote" } + | { kind: "confirm" } + | { kind: "cancel" } + | { kind: "numberShortcut"; digit: number } + | { kind: "editMoveTab"; delta: 1 | -1 } + | { kind: "editClose" } + | { kind: "delegateToEditor" } + | { kind: "ignore" }; + +export function getInputCommand( + state: AskState, + data: string, +): AskInputCommand { + if (state.view.kind === "input" || state.view.kind === "note") { + return getEditingInputCommand(data); + } + + return getNavigationInputCommand(data); +} + +export function formatKeybindingLabel(key: string): string { + return key + .split("+") + .map((part) => { + if (part.length <= 1) { + return part.toUpperCase(); + } + return part.charAt(0).toUpperCase() + part.slice(1); + }) + .join("+"); +} + +function getEditingInputCommand(data: string): AskInputCommand { + if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) { + return { kind: "editMoveTab", delta: 1 }; + } + if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) { + return { kind: "editMoveTab", delta: -1 }; + } + if (matchesKey(data, Key.escape)) { + return { kind: "editClose" }; + } + return { kind: "delegateToEditor" }; +} + +function getNavigationInputCommand(data: string): AskInputCommand { + if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) { + return { kind: "moveTab", delta: 1 }; + } + if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) { + return { kind: "moveTab", delta: -1 }; + } + if (matchesKey(data, Key.up)) { + return { kind: "moveOption", delta: -1 }; + } + if (matchesKey(data, Key.down)) { + return { kind: "moveOption", delta: 1 }; + } + if (matchesKey(data, Key.space)) { + return { kind: "toggleMulti" }; + } + if (matchesKey(data, Key.enter)) { + return { kind: "confirm" }; + } + if (matchesKey(data, Key.escape)) { + return { kind: "cancel" }; + } + if (isQuestionNoteShortcut(data)) { + return { kind: "openQuestionNote" }; + } + if (isOptionNoteShortcut(data)) { + return { kind: "openOptionNote" }; + } + + const digit = parseDigit(data); + return digit === null + ? { kind: "ignore" } + : { kind: "numberShortcut", digit }; +} + +function isOptionNoteShortcut(data: string): boolean { + return data === "n" || data === "N"; +} + +function isQuestionNoteShortcut(data: string): boolean { + return matchesKey(data, Key.ctrl("n")); +} + +function parseDigit(data: string): number | null { + return /^[1-9]$/.test(data) ? Number(data) : null; +} diff --git a/src/ui/render-frame.ts b/src/ui/render-frame.ts new file mode 100644 index 0000000..ca240f9 --- /dev/null +++ b/src/ui/render-frame.ts @@ -0,0 +1,85 @@ +import { truncateToWidth } from "@mariozechner/pi-tui"; +import { + getCurrentQuestion, + isQuestionAnswered, + isSubmitTab, +} from "../state.ts"; +import type { AskState } from "../types.ts"; +import { renderFooterText } from "./render-helpers.ts"; +import type { Theme } from "./render-types.ts"; + +export function renderFrameHeader(args: { + lines: string[]; + state: AskState; + theme: Theme; + width: number; +}) { + const { lines, state, theme, width } = args; + const add = (text = "") => lines.push(truncateToWidth(text, width)); + + add(theme.fg("accent", "─".repeat(Math.max(1, width)))); + if (state.title) { + add(` ${theme.fg("accent", theme.bold(state.title))}`); + add(); + } + add(renderTabs(state, theme, width)); + add(); +} + +export function renderFrameFooter(args: { + lines: string[]; + state: AskState; + theme: Theme; + width: number; +}) { + const { lines, state, theme, width } = args; + const add = (text = "") => lines.push(truncateToWidth(text, width)); + const footer = renderFooter(state, theme); + if (footer) { + add(); + add(footer); + } + add(theme.fg("accent", "─".repeat(Math.max(1, width)))); +} + +function renderTabs(state: AskState, theme: Theme, width: number): string { + const segments: string[] = [theme.fg("dim", "← ")]; + for (let index = 0; index < state.questions.length; index++) { + const question = state.questions[index]; + const active = state.activeTabIndex === index; + const answered = isQuestionAnswered(state, question.id); + const marker = answered ? "☒" : "☐"; + const text = ` ${marker} ${question.label} `; + segments.push( + active + ? theme.bg("selectedBg", theme.fg("text", text)) + : theme.fg(answered ? "success" : "muted", text), + ); + } + + const submitText = " ✔ Submit "; + segments.push( + isSubmitTab(state) + ? theme.bg("selectedBg", theme.fg("text", submitText)) + : theme.fg("success", submitText), + ); + segments.push(theme.fg("dim", " →")); + return truncateToWidth(` ${segments.join(" ")}`, width); +} + +function renderFooter(state: AskState, theme: Theme): string { + if (state.view.kind === "input") { + return theme.fg("dim", renderFooterText("input")); + } + if (state.view.kind === "note") { + return theme.fg("dim", renderFooterText("note")); + } + if (isSubmitTab(state)) { + return theme.fg("dim", renderFooterText("submit")); + } + const question = getCurrentQuestion(state); + return theme.fg( + "dim", + renderFooterText(question?.type === "multi" ? "multi" : "default"), + ); +} diff --git a/src/ui/render-helpers.ts b/src/ui/render-helpers.ts new file mode 100644 index 0000000..e56b7d9 --- /dev/null +++ b/src/ui/render-helpers.ts @@ -0,0 +1,188 @@ +import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; +import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; +import { NO_PREVIEW_TEXT } from "../constants.ts"; +import { wrapText } from "../text.ts"; +import { UI_DIMENSIONS, UI_TEXT } from "./constants.ts"; + +type Theme = ExtensionContext["ui"]["theme"]; +type ThemeColor = "accent" | "muted" | "text" | "dim" | "success" | "warning"; + +export function pushWrappedText( + lines: string[], + text: string, + width: number, + theme: Theme, + color: ThemeColor, + prefix = "", + continuationPrefix = prefix, +) { + const availableWidth = Math.max(1, width - visibleWidth(prefix)); + const wrapped = wrapText(text, availableWidth); + for (let index = 0; index < wrapped.length; index++) { + const line = wrapped[index]; + const currentPrefix = index === 0 ? prefix : continuationPrefix; + lines.push( + truncateToWidth(`${currentPrefix}${theme.fg(color, line)}`, width), + ); + } + if (wrapped.length === 0) { + lines.push(truncateToWidth(prefix, width)); + } +} + +export function renderInputLine( + line: string, + availableWidth: number, + theme: Theme, +): string { + const innerWidth = Math.max(4, availableWidth - 2); + const truncated = truncateToWidth(line, innerWidth); + const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(truncated))); + return theme.bg("toolPendingBg", ` ${truncated}${padding} `); +} + +export function renderEditorBlock(args: { + lines: string[]; + editorLines: string[]; + width: number; + theme: Theme; + indent: string; + availableWidth: number; + hint: string; +}) { + const { lines, editorLines, width, theme, indent, availableWidth, hint } = + args; + for (const editorLine of editorLines) { + lines.push( + truncateToWidth( + `${indent}${renderInputLine(editorLine, availableWidth, theme)}`, + width, + ), + ); + } + lines.push(truncateToWidth(`${indent}${theme.fg("dim", hint)}`, width)); +} + +export function renderHint(template: string, newLineHint: string): string { + return template.replace("{newLineHint}", newLineHint); +} + +export function renderBox( + content: Array<{ text: string; color: ThemeColor }>, + width: number, + theme: Theme, +): string[] { + const boxWidth = Math.max(UI_DIMENSIONS.boxMinWidth, width); + const innerWidth = Math.max(4, boxWidth - 2); + const top = theme.fg("accent", `┌${"─".repeat(innerWidth)}┐`); + const bottom = theme.fg("accent", `└${"─".repeat(innerWidth)}┘`); + const lines = [top]; + for (const item of content) { + for (const rawLine of wrapText(item.text, innerWidth)) { + const line = theme.fg(item.color, rawLine); + const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(line))); + lines.push( + theme.fg("accent", "│") + line + padding + theme.fg("accent", "│"), + ); + } + } + lines.push(bottom); + return lines; +} + +export function renderPreviewPaneContent( + selectedOption: + | { label: string; description?: string; preview?: string } + | undefined, + theme: Theme, + width: number, +): string[] { + if (!selectedOption) { + return renderBox([{ text: NO_PREVIEW_TEXT, color: "dim" }], width, theme); + } + + const content: Array<{ text: string; color: ThemeColor }> = [ + { text: selectedOption.label, color: "accent" }, + ]; + if (selectedOption.description) { + content.push({ text: selectedOption.description, color: "muted" }); + } + content.push({ text: "", color: "dim" }); + for (const previewLine of (selectedOption.preview ?? NO_PREVIEW_TEXT).split( + "\n", + )) { + content.push({ + text: previewLine, + color: selectedOption.preview ? "text" : "dim", + }); + } + return renderBox(content, width, theme); +} + +export function mergeColumns( + left: string[], + right: string[], + leftWidth: number, + width: number, +): string[] { + const lines: string[] = []; + const rowCount = Math.max(left.length, right.length); + for (let index = 0; index < rowCount; index++) { + const leftLine = left[index] ?? ""; + const rightLine = right[index] ?? ""; + const paddedLeft = padToVisibleWidth(leftLine, leftWidth); + lines.push(truncateToWidth(`${paddedLeft} ${rightLine}`, width)); + } + return lines; +} + +export function measurePreviewLeftWidth( + options: Array<{ label: string; description?: string }>, + width: number, +): number { + let widest = 0; + for (let index = 0; index < options.length; index++) { + const option = options[index]; + widest = Math.max( + widest, + visibleWidth(`${index + 1}. ${option.label}`), + option.description ? visibleWidth(option.description) : 0, + ); + } + + const preferred = widest + 4; + const maxWidth = Math.min( + UI_DIMENSIONS.previewLeftMaxWidth, + Math.floor(width * UI_DIMENSIONS.previewLeftRatio), + ); + return clamp( + preferred, + UI_DIMENSIONS.previewLeftMinWidth, + Math.max(UI_DIMENSIONS.previewLeftMinWidth, maxWidth), + ); +} + +export function renderFooterText( + mode: "input" | "note" | "submit" | "multi" | "default", +) { + switch (mode) { + case "input": + return UI_TEXT.footerInput; + case "note": + return UI_TEXT.footerNote; + case "submit": + return UI_TEXT.footerSubmit; + case "multi": + return UI_TEXT.footerMulti; + default: + return UI_TEXT.footerDefault; + } +} + +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 new file mode 100644 index 0000000..db717f5 --- /dev/null +++ b/src/ui/render-question.ts @@ -0,0 +1,429 @@ +import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; +import { + getAnswer, + getOptionNote, + getQuestionNote, + isInputOpenForQuestion, + isOptionNoteOpen, + isOptionSelected, + isQuestionNoteOpen, +} from "../state.ts"; +import type { AskDisplayOption } from "../types.ts"; +import { UI_DIMENSIONS, UI_TEXT } from "./constants.ts"; +import { + measurePreviewLeftWidth, + mergeColumns, + pushWrappedText, + renderEditorBlock, + renderHint, + renderPreviewPaneContent, +} from "./render-helpers.ts"; +import type { + OptionDetailRenderContext, + QuestionRenderContext, + Theme, +} from "./render-types.ts"; + +export function renderQuestionScreen(context: QuestionRenderContext) { + const { lines, question, theme, width } = context; + pushWrappedText(lines, question.prompt, width, theme, "text", " ", " "); + lines.push(""); + renderQuestionNote(context); + + if (question.type === "preview") { + renderPreviewQuestion(context); + return; + } + + renderStandardQuestion(context); +} + +function renderQuestionNote(context: QuestionRenderContext) { + const { lines, state, question, theme, width, editor, newLineHint } = context; + if (isQuestionNoteOpen(state, question.id)) { + pushWrappedText( + lines, + UI_TEXT.questionNoteTitle, + width, + theme, + "accent", + " ", + " ", + ); + renderEditorBlock({ + lines, + editorLines: editor.render( + Math.max( + UI_DIMENSIONS.editorMinWidth, + width - UI_DIMENSIONS.editorContentPadding, + ), + ), + width, + theme, + indent: " ", + availableWidth: width - 3, + hint: renderHint(UI_TEXT.hintSave, newLineHint), + }); + lines.push(""); + return; + } + + const existingNote = getQuestionNote(state, question.id); + if (!existingNote) { + return; + } + pushWrappedText( + lines, + `Question note: ${existingNote}`, + width, + theme, + "muted", + " ", + " ", + ); + lines.push(""); +} + +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, +) { + 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); + + pushWrappedText( + lines, + `${index + 1}. ${prefix}${option.label}`, + width, + theme, + optionColor, + pointer, + " ".repeat(visibleWidth(pointer)), + ); + renderOptionMeta({ + ...context, + questionId: question.id, + answer, + option, + selected, + }); +} + +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); + + if (width >= UI_DIMENSIONS.previewWideMinWidth && options.length > 0) { + renderWidePreviewLayout( + add, + state, + options, + theme, + width, + answer, + selectedOption, + ); + } else { + renderStackedPreviewLayout( + add, + state, + options, + theme, + width, + answer, + selectedOption, + ); + } + + renderOptionEditorOrNote({ + ...context, + questionId: question.id, + answer, + option: selectedOption, + withGap: true, + }); +} + +function renderWidePreviewLayout( + add: (text?: string) => void, + state: QuestionRenderContext["state"], + options: QuestionRenderContext["options"], + theme: Theme, + width: number, + answer: ReturnType, + selectedOption: AskDisplayOption | undefined, +) { + const leftWidth = measurePreviewLeftWidth(options, width); + const rightWidth = Math.max( + UI_DIMENSIONS.previewMinRightWidth, + width - leftWidth - 2, + ); + const leftPane = renderPreviewOptionList( + state, + options, + theme, + leftWidth, + answer, + ); + const rightPane = renderPreviewPaneContent(selectedOption, theme, rightWidth); + for (const line of mergeColumns(leftPane, rightPane, leftWidth, width)) { + add(line); + } +} + +function renderStackedPreviewLayout( + add: (text?: string) => void, + state: QuestionRenderContext["state"], + options: QuestionRenderContext["options"], + theme: Theme, + width: number, + answer: ReturnType, + selectedOption: AskDisplayOption | undefined, +) { + renderPreviewOptionList(state, options, theme, width, answer).forEach(add); + add(""); + renderPreviewPaneContent(selectedOption, theme, width).forEach(add); +} + +function renderPreviewOptionList( + state: QuestionRenderContext["state"], + options: QuestionRenderContext["options"], + theme: Theme, + width: number, + answer: ReturnType, +): 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); + pushWrappedText( + lines, + `${index + 1}. ${option.label}`, + width, + theme, + getOptionColor(isAnsweredOption, selected), + selected ? "❯ " : " ", + " ", + ); + if (option.description) { + pushWrappedText( + lines, + option.description, + width, + theme, + "muted", + " ", + " ", + ); + } + } + return lines; +} + +function renderOptionMeta( + context: QuestionRenderContext & { + questionId: string; + answer: ReturnType; + option: AskDisplayOption; + selected: 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) { + return; + } + + const renderState = getOptionDetailRenderState(context); + if (withGap && hasOptionDetailContent(renderState)) { + lines.push(""); + } + if (renderOptionEditor(context, renderState)) { + return; + } + if (renderOptionContent(context, renderState)) { + 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, newLineHint } = context; + if (renderState.noteOpen) { + renderIndentedEditor( + lines, + editor, + width, + theme, + renderHint(UI_TEXT.hintSave, newLineHint), + ); + return true; + } + if (renderState.inputOpen) { + renderIndentedEditor( + lines, + editor, + width, + theme, + renderHint(UI_TEXT.hintSubmit, newLineHint), + ); + 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, + "muted", + " ", + " ", + ); + return true; +} + +function renderSavedOptionNote( + context: OptionDetailRenderContext, + optionNote: string | undefined, +) { + if (!optionNote) { + return; + } + const { lines, theme, width } = context; + pushWrappedText( + lines, + `Note: ${optionNote}`, + width, + theme, + "muted", + " ", + " ", + ); +} + +function renderIndentedEditor( + lines: string[], + editor: QuestionRenderContext["editor"], + width: number, + theme: Theme, + hint: string, +) { + renderEditorBlock({ + lines, + editorLines: editor.render( + Math.max( + UI_DIMENSIONS.editorMinWidth, + width - UI_DIMENSIONS.editorIndentedPadding, + ), + ), + width, + theme, + indent: " ", + availableWidth: width - 5, + hint, + }); +} + +function getOptionPrefix( + questionType: QuestionRenderContext["question"]["type"], + option: AskDisplayOption, + isAnsweredOption: boolean, +): string { + if (questionType !== "multi" || option.isCustomOption) { + return ""; + } + return `[${isAnsweredOption ? "x" : " "}] `; +} + +function getOptionColor(isAnsweredOption: boolean, selected: boolean) { + if (isAnsweredOption) { + return "success"; + } + if (selected) { + return "accent"; + } + return "text"; +} diff --git a/src/ui/render-submit.ts b/src/ui/render-submit.ts new file mode 100644 index 0000000..36c4d47 --- /dev/null +++ b/src/ui/render-submit.ts @@ -0,0 +1,128 @@ +import { truncateToWidth } from "@mariozechner/pi-tui"; +import { SUBMIT_CHOICES } from "../constants.ts"; +import { toAskResult } from "../state.ts"; +import type { AskResult, AskState } from "../types.ts"; +import { UI_TEXT } from "./constants.ts"; +import { pushWrappedText } from "./render-helpers.ts"; +import type { Theme } from "./render-types.ts"; + +export function renderSubmitScreen( + lines: string[], + state: AskState, + theme: Theme, + width: number, +) { + pushWrappedText(lines, UI_TEXT.reviewTitle, width, theme, "accent", " ", " "); + lines.push(""); + + const submittedAnswers = toAskResult(state).answers; + for (const question of state.questions) { + renderSubmittedQuestion( + lines, + question, + submittedAnswers[question.id], + theme, + width, + ); + } + + lines.push(""); + pushWrappedText( + lines, + UI_TEXT.readyToSubmit, + width, + theme, + "success", + " ", + " ", + ); + lines.push(""); + renderSubmitActions(lines, state, theme, width); +} + +function renderSubmittedQuestion( + lines: string[], + question: AskState["questions"][number], + answer: AskResult["answers"][string] | undefined, + theme: Theme, + width: number, +) { + pushWrappedText(lines, question.prompt, width, theme, "muted", " ● ", " "); + if (!answer) { + lines.push( + truncateToWidth(` ${theme.fg("warning", UI_TEXT.unanswered)}`, width), + ); + return; + } + + renderSubmittedAnswer(lines, answer, theme, width); +} + +function renderSubmittedAnswer( + lines: string[], + answer: AskResult["answers"][string], + theme: Theme, + width: number, +) { + const answerText = answer.customText ?? answer.labels.join(", "); + if (answerText) { + pushWrappedText( + lines, + `→ ${answerText}`, + width, + theme, + answer.customText ? "text" : "success", + " ", + " ", + ); + } + if (answer.note) { + pushWrappedText( + lines, + `Question note: ${answer.note}`, + width, + theme, + "muted", + " ", + " ", + ); + } + for (let index = 0; index < answer.values.length; index++) { + const value = answer.values[index]; + const label = answer.labels[index] ?? value; + const note = answer.optionNotes?.[value]; + if (!note) { + continue; + } + pushWrappedText( + lines, + `${label} note: ${note}`, + width, + theme, + "muted", + " ", + " ", + ); + } +} + +function renderSubmitActions( + lines: string[], + state: AskState, + theme: Theme, + width: number, +) { + for (let index = 0; index < SUBMIT_CHOICES.length; index++) { + const selected = index === state.activeSubmitActionIndex; + const prefix = selected ? "❯ " : " "; + pushWrappedText( + lines, + `${index + 1}. ${SUBMIT_CHOICES[index]}`, + width, + theme, + selected ? "accent" : "text", + prefix, + prefix, + ); + } +} diff --git a/src/ui/render-types.ts b/src/ui/render-types.ts new file mode 100644 index 0000000..b513b38 --- /dev/null +++ b/src/ui/render-types.ts @@ -0,0 +1,35 @@ +import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; +import type { Editor } from "@mariozechner/pi-tui"; +import type { + getAnswer, + getCurrentQuestion, + getRenderableOptions, +} from "../state.ts"; +import type { AskDisplayOption, AskState } from "../types.ts"; + +export type Theme = ExtensionContext["ui"]["theme"]; + +export interface QuestionRenderContext { + lines: string[]; + state: AskState; + question: NonNullable>; + options: ReturnType; + theme: Theme; + width: number; + editor: Editor; + newLineHint: string; +} + +export interface OptionDetailRenderContext { + lines: string[]; + state: AskState; + questionId: string; + answer: ReturnType; + option: AskDisplayOption | undefined; + theme: Theme; + width: number; + editor: Editor; + newLineHint: string; + selected?: boolean; + withGap?: boolean; +} diff --git a/src/ui/render.ts b/src/ui/render.ts new file mode 100644 index 0000000..2e986e3 --- /dev/null +++ b/src/ui/render.ts @@ -0,0 +1,43 @@ +import { + getCurrentQuestion, + getRenderableOptions, + isSubmitTab, +} from "../state.ts"; +import type { AskState } from "../types.ts"; +import { renderFrameFooter, renderFrameHeader } from "./render-frame.ts"; +import { renderQuestionScreen } from "./render-question.ts"; +import { renderSubmitScreen } from "./render-submit.ts"; +import type { QuestionRenderContext, Theme } from "./render-types.ts"; + +export function renderAskScreen(args: { + state: AskState; + theme: Theme; + width: number; + editor: QuestionRenderContext["editor"]; + newLineHint: string; +}): string[] { + const { state, theme, width, editor, newLineHint } = args; + const lines: string[] = []; + const question = getCurrentQuestion(state); + const options = getRenderableOptions(question); + + renderFrameHeader({ lines, state, theme, width }); + + if (isSubmitTab(state)) { + renderSubmitScreen(lines, state, theme, width); + } else if (question) { + renderQuestionScreen({ + lines, + state, + question, + options, + theme, + width, + editor, + newLineHint, + }); + } + + renderFrameFooter({ lines, state, theme, width }); + return lines; +} diff --git a/tests/state.test.ts b/tests/state.test.ts index cc7c99a..183b965 100644 --- a/tests/state.test.ts +++ b/tests/state.test.ts @@ -7,11 +7,11 @@ import { createInitialState, enterOptionNoteMode, enterQuestionNoteMode, - saveCustomAnswer, - saveNote, getRenderableOptions, moveOption, moveTab, + saveCustomAnswer, + saveNote, submitCustomAnswer, toAskResult, toggleCurrentMultiOption, @@ -63,7 +63,10 @@ test("normalize defaults via initial state", () => { getRenderableOptions(state.questions[0]).at(-1)?.label, "Type your own", ); - assert.equal(getRenderableOptions(state.questions[0]).at(-1)?.isOther, true); + assert.equal( + getRenderableOptions(state.questions[0]).at(-1)?.isCustomOption, + true, + ); }); test("preview questions keep their type and option previews", () => { @@ -82,7 +85,7 @@ test("preview questions keep their type and option previews", () => { assert.equal(state.questions[0].type, "preview"); assert.equal(state.questions[0].options[0].preview, "Example"); assert.equal( - getRenderableOptions(state.questions[0]).at(-1)?.isOther, + getRenderableOptions(state.questions[0]).at(-1)?.isCustomOption, undefined, ); assert.equal(getRenderableOptions(state.questions[0]).length, 1); @@ -92,31 +95,37 @@ test("single-select number shortcut stores answer and advances to next tab", () let state = createInitialState(sampleParams()); state = applyNumberShortcut(state, 2); - assert.equal(state.answers.lang.labels[0], "TypeScript"); - assert.equal(state.currentTab, 1); - assert.equal(state.mode, "navigate"); + assert.equal(state.answers.lang.selected[0].label, "TypeScript"); + assert.equal(state.activeTabIndex, 1); + assert.equal(state.view.kind, "navigate"); }); test("multi-select space toggles current option and enter advances to submit", () => { let state = createInitialState(sampleParams()); state = applyNumberShortcut(state, 1); state = toggleCurrentMultiOption(state); - assert.deepEqual(state.answers.fe.labels, ["React"]); + assert.deepEqual( + state.answers.fe.selected.map((selection) => selection.label), + ["React"], + ); state = moveOption(state, 1); state = toggleCurrentMultiOption(state); - assert.deepEqual(state.answers.fe.labels, ["React", "Vue"]); + assert.deepEqual( + state.answers.fe.selected.map((selection) => selection.label), + ["React", "Vue"], + ); state = confirmCurrentSelection(state); - assert.equal(state.mode, "submit"); - assert.equal(state.currentTab, 2); + assert.equal(state.view.kind, "submit"); + assert.equal(state.activeTabIndex, 2); assert.equal(state.completed, false); }); test("submit can complete even when some questions are unanswered", () => { let state = createInitialState(sampleParams()); state = moveTab(state, -1); - assert.equal(state.mode, "submit"); + assert.equal(state.view.kind, "submit"); state = confirmCurrentSelection(state); assert.equal(state.completed, true); @@ -135,11 +144,11 @@ test("selecting other enters input mode and custom submit stores typed answer", }); state = applyNumberShortcut(state, 2); - assert.equal(state.mode, "input"); + assert.equal(state.view.kind, "input"); state = submitCustomAnswer(state, "my custom answer"); assert.equal(state.answers.q1.customText, "my custom answer"); - assert.equal(state.mode, "submit"); + assert.equal(state.view.kind, "submit"); }); test("escape saves typed custom answer without advancing", () => { @@ -154,11 +163,11 @@ test("escape saves typed custom answer without advancing", () => { }); state = applyNumberShortcut(state, 2); - assert.equal(state.mode, "input"); + assert.equal(state.view.kind, "input"); state = saveCustomAnswer(state, "my draft answer"); - assert.equal(state.mode, "navigate"); - assert.equal(state.currentTab, 0); + assert.equal(state.view.kind, "navigate"); + assert.equal(state.activeTabIndex, 0); assert.equal(state.answers.q1.customText, "my draft answer"); }); @@ -182,7 +191,7 @@ test("empty custom draft clears the stored answer but preserves notes", () => { assert.equal(state.answers.q1.customText, undefined); assert.equal(state.answers.q1.note, "question note"); - assert.equal(state.mode, "navigate"); + assert.equal(state.view.kind, "navigate"); }); test("question note can be saved without selecting an answer and is submitted", () => { @@ -193,10 +202,10 @@ test("question note can be saved without selecting an answer and is submitted", }); state = enterQuestionNoteMode(state, "q1"); - assert.equal(state.mode, "note"); + assert.equal(state.view.kind, "note"); state = saveNote(state, "needs examples"); - assert.equal(state.mode, "navigate"); + assert.equal(state.view.kind, "navigate"); assert.equal(state.answers.q1.note, "needs examples"); assert.deepEqual(toAskResult(state).answers.q1, { values: [], @@ -229,10 +238,16 @@ test("option notes can exist before selection but only selected option notes are assert.equal(toAskResult(state).answers.q1, undefined); state = toggleCurrentMultiOption(state); - assert.deepEqual(state.answers.q1.values, ["react"]); + assert.deepEqual( + state.answers.q1.selected.map((selection) => selection.value), + ["react"], + ); state = moveOption(state, 1); state = toggleCurrentMultiOption(state); - assert.deepEqual(state.answers.q1.values, ["react", "vue"]); + assert.deepEqual( + state.answers.q1.selected.map((selection) => selection.value), + ["react", "vue"], + ); assert.deepEqual(toAskResult(state).answers.q1.optionNotes, { vue: "maybe later", }); @@ -309,12 +324,12 @@ test("preview questions can store selected option notes for submission", () => { test("tab navigation wraps including submit tab", () => { let state = createInitialState(sampleParams()); state = moveTab(state, -1); - assert.equal(state.currentTab, 2); - assert.equal(state.mode, "submit"); + assert.equal(state.activeTabIndex, 2); + assert.equal(state.view.kind, "submit"); state = moveTab(state, 1); - assert.equal(state.currentTab, 0); - assert.equal(state.mode, "navigate"); + assert.equal(state.activeTabIndex, 0); + assert.equal(state.view.kind, "navigate"); }); test("tab navigation works with a single question", () => { @@ -325,12 +340,12 @@ test("tab navigation works with a single question", () => { }); state = moveTab(state, -1); - assert.equal(state.currentTab, 1); - assert.equal(state.mode, "submit"); + assert.equal(state.activeTabIndex, 1); + assert.equal(state.view.kind, "submit"); state = moveTab(state, 1); - assert.equal(state.currentTab, 0); - assert.equal(state.mode, "navigate"); + assert.equal(state.activeTabIndex, 0); + assert.equal(state.view.kind, "navigate"); }); test("escape cancels from main flow but only exits input and note modes when editing", () => { @@ -345,14 +360,14 @@ test("escape cancels from main flow but only exits input and note modes when edi ], }); state = applyNumberShortcut(state, 2); - assert.equal(state.mode, "input"); + assert.equal(state.view.kind, "input"); state = cancelFlow(state); - assert.equal(state.mode, "navigate"); + assert.equal(state.view.kind, "navigate"); assert.equal(state.cancelled, false); state = enterQuestionNoteMode(state, "q"); - assert.equal(state.mode, "note"); + assert.equal(state.view.kind, "note"); state = cancelFlow(state); - assert.equal(state.mode, "navigate"); + assert.equal(state.view.kind, "navigate"); assert.equal(state.cancelled, false); });