diff --git a/README.md b/README.md index 00666f6..c47c48f 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,11 @@ Once installed, this package gives the agent a native way to ask for clarificati - 👀 Review tab with `Submit`, `Elaborate`, and `Cancel` - 💬 Elaboration flow to capture note-based clarification before final submission - ⌨️ Fast keyboard-first interaction with customizable ask keymaps (also mobile-friendly in remote sessions) -- ⚙️ Ask settings with persisted behaviour and keymap customization via config +- ⚙️ Ask settings with persisted behaviour, keymap customization, and `/answer` extraction config +- 🔁 Slash commands for fallback/replay: + - `/answer` extracts questions from the latest assistant message into an ask flow + - `/answer:again` reopens the latest `/answer` form on the current branch + - `/ask:replay` replays the latest real `ask_user` form on the current branch - 🗣️ You can talk to your agent to configure pi-ask; it will read the bundled configuration guide and tailor the config for you ## Feature walkthrough @@ -129,7 +133,16 @@ You can edit this file yourself, ask pi to edit it for you, or use `/ask-setting ```json { - "schemaVersion": 1, + "schemaVersion": 2, + "answer": { + "extractionModels": [ + { "provider": "openai-codex", "id": "gpt-5.4-mini" }, + { "provider": "github-copilot", "id": "gpt-5.4-mini" }, + { "provider": "anthropic", "id": "claude-haiku-4-5" } + ], + "extractionTimeoutMs": 30000, + "extractionRetries": 1 + }, "behaviour": { "autoSubmitWhenAnsweredWithoutNotes": false, "confirmDismissWhenDirty": true, @@ -151,10 +164,21 @@ Accepted notation follows pi-tui key ids. Common aliases are normalized, for exa ## Use -After installation, the extension registers the `ask_user` tool and `/ask-settings` command. +After installation, the extension registers the `ask_user` tool plus `/ask-settings`, `/answer`, `/answer:again`, and `/ask:replay` commands. Agents can auto-discover and call `ask_user` when they need clarification instead of guessing. In interactive sessions, it opens a terminal UI flow for structured answers, supports native pi-style `@` file references while typing answers or notes, and returns normalized answers back to the agent. Ask settings are available both from `?` in the ask flow and from the `/ask-settings` command. Behaviour settings are binary `on`/`off` toggles that save immediately; customizable ask keymaps are changed by editing the shown config file path. +### Answer and replay commands + +`/answer` is useful when the agent asked questions in plain text instead of using `ask_user`. It extracts questions from the latest completed assistant message and opens the same ask UI. + +Replay commands are branch-aware. They read persisted entries from the current pi session branch, so they work naturally with `/resume`, `/tree`, and conversation branching: + +- `/answer:again` reopens the latest form created by `/answer` on this branch +- `/ask:replay` reopens the latest real `ask_user` form on this branch + +Cancellation is local to the UI: closing a replayed form does not start a new agent turn. Submitted answers are sent back as a normal user follow-up message. + You can also talk to pi to configure this extension. When asked to customize pi-ask settings or keymaps, the agent is instructed to read the bundled `docs/configuration.md` guide first and then edit the config file accordingly. This package also bundles the `ask-user` skill profile from `skills/ask-user/SKILL.md`. It reinforces when to use the tool, is enabled by default when installed, and can be disabled via `pi config`. The skill was inspired by https://github.com/edlsh/pi-ask-user. diff --git a/docs/README.md b/docs/README.md index 4871da5..f255a7a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,7 +4,7 @@ This folder keeps only the documentation needed to understand and maintain the e ## Files -- `configuration.md` — source of truth for configuring pi-ask keymaps and behaviour +- `configuration.md` — source of truth for configuring pi-ask keymaps, behaviour, and `/answer` extraction - `contract.md` — external behavior, full tool payload/result details, and UX guarantees - `architecture.md` — module boundaries and invariants - `../skills/ask-user/SKILL.md` — auto-bundled agent-side decision-gate guidance; enabled by default, but can be disabled via `pi config`; inspired by https://github.com/edlsh/pi-ask-user diff --git a/docs/architecture.md b/docs/architecture.md index c21ab91..1be8617 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -14,7 +14,10 @@ The codebase is split so the implementation reads through file boundaries and na ### Tool surface - `src/index.ts` — extension entrypoint -- `src/ask-tool.ts` — tool registration, non-interactive fallback, transcript rendering +- `src/ask-tool.ts` — tool registration, non-interactive fallback, transcript rendering, ask payload capture +- `src/answer-commands.ts` — `/answer`, `/answer:again`, and `/ask:replay` command wiring +- `src/answer-extraction.ts` — configured extraction model selection and raw-JSON extraction retries +- `src/ask-payload-store.ts` — branch-aware persisted ask payload lookup - `src/schema.ts` — TypeBox schema - `src/types.ts` — shared types @@ -72,6 +75,7 @@ The codebase is split so the implementation reads through file boundaries and na - editor lifecycle stays in the controller, not in the reducers - persisted ask settings are migrated to the current schema version, validated, and normalized before use - config schema migrations preserve user-provided values and add new defaults only when fields are absent +- replay payload lookup scans only the current session branch and revalidates payloads before use - invalid persisted keymaps fall back to default keymaps for the current session without discarding valid behaviour settings - ask settings behaviour changes persist immediately from the settings list - when the ask config file is missing, the first ask use writes a default persisted config snapshot under `~/.pi/agent/extensions/` diff --git a/docs/configuration.md b/docs/configuration.md index 68981d6..a6e1895 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -28,7 +28,16 @@ Unsupported future versions or invalid files are backed up and defaults are load ```json { - "schemaVersion": 1, + "schemaVersion": 2, + "answer": { + "extractionModels": [ + { "provider": "openai-codex", "id": "gpt-5.4-mini" }, + { "provider": "github-copilot", "id": "gpt-5.4-mini" }, + { "provider": "anthropic", "id": "claude-haiku-4-5" } + ], + "extractionTimeoutMs": 30000, + "extractionRetries": 1 + }, "behaviour": { "autoSubmitWhenAnsweredWithoutNotes": false, "confirmDismissWhenDirty": true, @@ -46,6 +55,29 @@ Unsupported future versions or invalid files are backed up and defaults are load } ``` +## Answer extraction + +These settings affect only the `/answer` command. Normal `ask_user` tool calls do not use an extraction model. + +### `answer.extractionModels` + +- type: array of `{ "provider": string, "id": string }` +- default: `openai-codex/gpt-5.4-mini`, `github-copilot/gpt-5.4-mini`, then `anthropic/claude-haiku-4-5` +- effect: `/answer` tries configured models in order and uses the first model with available auth +- fallback: if no configured model is usable, `/answer` tries the current chat model after validating its auth + +### `answer.extractionTimeoutMs` + +- type: positive number +- default: `30000` +- effect: per-attempt extraction timeout in milliseconds + +### `answer.extractionRetries` + +- type: integer from `0` to `3` +- default: `1` +- effect: number of retry attempts after raw JSON parsing fails; retries include the parse error and previous response as feedback + ## Behaviour ### `behaviour.autoSubmitWhenAnsweredWithoutNotes` @@ -184,7 +216,14 @@ Invalid keymaps include: ```json { - "schemaVersion": 1, + "schemaVersion": 2, + "answer": { + "extractionRetries": 1, + "extractionTimeoutMs": 30000, + "extractionModels": [ + { "provider": "openai-codex", "id": "gpt-5.4-mini" } + ] + }, "behaviour": { "autoSubmitWhenAnsweredWithoutNotes": true, "confirmDismissWhenDirty": true, @@ -207,7 +246,9 @@ Invalid keymaps include: When editing this config for a user: - preserve unrelated fields -- keep `schemaVersion` at `1` +- keep `schemaVersion` at `2` +- preserve `answer.extractionModels` as explicit provider/id pairs +- keep `answer.extractionRetries` between `0` and `3` - do not assign reserved bindings to configurable actions - do not create duplicate configurable bindings - after changing the file, tell the user to run `/reload` or restart pi diff --git a/docs/contract.md b/docs/contract.md index c8891f3..0446c60 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -200,6 +200,10 @@ This document defines the stable external behavior. It does not explain internal - on the review tab, `Submit` and `Cancel` preview notes only for answered questions - on the review tab, `Elaborate` preview expands to all question notes and all option notes, including notes on unselected options - transcript-friendly call and result rendering +- `/answer` command to extract a raw-JSON `AskParams` form from the latest completed assistant message and open the ask UI +- `/answer` extraction may use an internal `freeform: true` option for open-ended questions with no explicit choices; these render as user-input-only questions with the label `Type your answer:`, no numbered option row, and no selection caret; this marker is not part of the public `ask_user` tool contract +- `/answer:again` command to replay the latest `/answer`-extracted form on the current branch +- `/ask:replay` command to replay the latest real `ask_user` form on the current branch - ask settings list with binary behaviour toggles - `?` in the ask flow and `/ask-settings` in pi open the same lightweight ask settings overlay - behaviour settings persist immediately when changed: `Auto-submit when answered without notes`, `Confirm dismiss when dirty`, `Double-press review shortcuts`, and `Show footer hints` @@ -241,6 +245,16 @@ Validation is handled inside the tool so malformed calls produce the same struct The ask flow subscribes to runtime settings updates while open. In practice, this means changing `Auto-submit when answered without notes`, `Confirm dismiss when dirty`, `Double-press review shortcuts`, `Show footer hints`, or reloading config-backed keymaps can affect the in-progress ask flow immediately instead of only future asks. +## Slash command replay/extraction + +- valid `ask_user` payloads are persisted as branch custom entries before the UI opens, so `/ask:replay` can reopen them after cancel, `/resume`, or `/tree` +- `/answer` scans the current branch for the latest assistant message; if that message did not finish with `stop`, extraction is refused +- `/answer` expects the extractor to return raw JSON only; JSON parse failures are retried according to `answer.extractionRetries`, then reported to the user without opening the ask UI +- `{ "questions": [] }` from extraction means no questions were found and is not treated as an invalid ask payload +- command-flow cancellation closes with a notification and does not send a message to the agent +- submitted or elaborated command-flow results are sent back with user-message semantics +- replay commands scan only `ctx.sessionManager.getBranch()`, ignore sibling/future branch payloads, and revalidate stored payloads before opening the UI + The fallback message includes normalized pending questions and options so the caller can re-ask them manually. `details.questions` still contains normalized question metadata, while `details.answers` stays empty until a user responds. ## Skill alignment (advisory) diff --git a/package.json b/package.json index e93052c..1295205 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,8 @@ "peerDependencies": { "@mariozechner/pi-coding-agent": "*", "@mariozechner/pi-tui": "*", - "typebox": "*" + "typebox": "*", + "@mariozechner/pi-ai": "*" }, "devDependencies": { "@biomejs/biome": "2.4.12", @@ -105,7 +106,8 @@ "lefthook": "^2.1.6", "semantic-release": "^25.0.3", "typescript": "6.0.2", - "ultracite": "7.6.0" + "ultracite": "7.6.0", + "@mariozechner/pi-ai": "0.69.0" }, "config": { "commitizen": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 22ce35c..e6d0b57 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@commitlint/config-conventional': specifier: ^19.8.1 version: 19.8.1 + '@mariozechner/pi-ai': + specifier: 0.69.0 + version: 0.69.0(ws@8.20.0)(zod@4.3.6) '@mariozechner/pi-coding-agent': specifier: 0.69.0 version: 0.69.0(ws@8.20.0)(zod@4.3.6) diff --git a/skills/ask-user/SKILL.md b/skills/ask-user/SKILL.md index b2b9903..d938eae 100644 --- a/skills/ask-user/SKILL.md +++ b/skills/ask-user/SKILL.md @@ -87,8 +87,9 @@ After attempt 2: ## ask_user payload quality - Ask one concrete decision at a time. -- Prefer 2-4 clear options (single-select by default). -- Use `multi` only when selections are truly independent. +- Provide clear, distinct options. Do not add filler options. +- Choose question type from semantics: `single` means one answer is expected, `multi` means multiple answers could reasonably be selected, and `preview` means options need preview-pane detail with non-empty preview text. +- Avoid defaulting mechanically; infer from whether options are mutually exclusive, can coexist, or need preview-pane detail. - Keep option labels short and outcome-oriented. - Include trade-off descriptions when non-obvious. - For research/planning, ask about goals, constraints, evaluation criteria, audience, budget, timeline, risk tolerance, and desired output only when they materially affect the result. diff --git a/src/answer-commands.ts b/src/answer-commands.ts new file mode 100644 index 0000000..011811f --- /dev/null +++ b/src/answer-commands.ts @@ -0,0 +1,321 @@ +import { + BorderedLoader, + type ExtensionAPI, + type ExtensionCommandContext, + type ExtensionContext, +} from "@mariozechner/pi-coding-agent"; +import { + extractAskParams, + selectExtractionModel, +} from "./answer-extraction.ts"; +import { + type AskPayloadSource, + appendAskPayload, + findLatestPayloadInCurrentBranch, +} from "./ask-payload-store.ts"; +import { + invalidPayloadResponse, + successfulResponse, + validateParams, +} from "./ask-tool-helpers.ts"; +import { getAskConfigStore } from "./config/store.ts"; +import type { AskParams, AskResult } from "./types.ts"; +import { runAskFlow } from "./ui/controller.ts"; + +interface AssistantTextSource { + entryId: string; + text: string; +} + +type ExtractionSelection = Exclude< + Awaited>, + { error: string } +>; + +type ExtractionUiResult = + | { cancelled: true } + | { error: string } + | { params: AskParams }; + +export function registerAnswerCommands(pi: ExtensionAPI): void { + pi.registerCommand("answer", { + description: + "Extract questions from the latest assistant message into an ask form", + handler: async (_args, ctx) => runAnswerCommand(pi, ctx), + }); + + pi.registerCommand("answer:again", { + description: "Reopen the previous /answer form on this branch", + handler: async (_args, ctx) => + runReplayCommand(pi, ctx, { + missingMessage: + "No previous /answer form found on this branch; use /answer first.", + noticePrefix: "Reopening previous /answer form on this branch", + source: "answer-extraction", + }), + }); + + pi.registerCommand("ask:replay", { + description: "Replay the previous ask_user form on this branch", + handler: async (_args, ctx) => + runReplayCommand(pi, ctx, { + missingMessage: "No previous ask_user form found on this branch.", + noticePrefix: "Replaying previous ask_user form on this branch", + source: "tool", + }), + }); +} + +async function runAnswerCommand( + pi: ExtensionAPI, + ctx: ExtensionCommandContext +): Promise { + if (!ctx.hasUI) { + ctx.ui.notify("/answer requires interactive UI.", "error"); + return; + } + + const assistant = findLatestAssistantText(ctx); + if ("error" in assistant) { + ctx.ui.notify(assistant.error, "error"); + return; + } + + const config = await getAskConfigStore().getConfig(); + const selected = await selectExtractionModel( + ctx, + config.answer.extractionModels + ); + if ("error" in selected) { + ctx.ui.notify(selected.error, "error"); + return; + } + if (selected.usedFallback) { + ctx.ui.notify( + `Configured extraction models unavailable; using current chat model ${selected.model.provider}/${selected.model.id}.`, + "info" + ); + } + + const params = await extractAndValidateAnswerParams( + ctx, + assistant, + selected, + config + ); + if (!params) { + return; + } + + appendAskPayload(pi, { + params, + source: "answer-extraction", + sourceEntryId: assistant.entryId, + }); + + await runAskAndSendSubmittedResult(pi, ctx, params, { + allowFreeform: true, + }); +} + +async function extractAndValidateAnswerParams( + ctx: ExtensionCommandContext, + assistant: AssistantTextSource, + selected: ExtractionSelection, + config: Awaited["getConfig"]>> +): Promise { + const extraction = await runExtractionUi(ctx, assistant, selected, config); + if ("cancelled" in extraction) { + ctx.ui.notify("Question extraction cancelled.", "info"); + return; + } + if ("error" in extraction) { + ctx.ui.notify(formatExtractionError(extraction.error), "error"); + return; + } + if (!isAskParamsCandidate(extraction.params)) { + ctx.ui.notify("Question extraction returned an invalid ask form.", "error"); + return; + } + if (extraction.params.questions.length === 0) { + ctx.ui.notify( + "No questions found in the latest assistant message.", + "info" + ); + return; + } + const validation = validateParams(extraction.params, { allowFreeform: true }); + if (!validation.ok) { + const response = invalidPayloadResponse( + extraction.params, + validation.issues + ); + ctx.ui.notify(response.content[0].text, "error"); + return; + } + return extraction.params; +} + +function runExtractionUi( + ctx: ExtensionCommandContext, + assistant: AssistantTextSource, + selected: ExtractionSelection, + config: Awaited["getConfig"]>> +): Promise { + return ctx.ui.custom((tui, theme, _keybindings, done) => { + let completed = false; + const doneOnce = (result: ExtractionUiResult) => { + if (completed) { + return; + } + completed = true; + done(result); + }; + const loader = new BorderedLoader( + tui, + theme, + `Extracting questions using ${selected.model.provider}/${selected.model.id}...` + ); + loader.onAbort = () => doneOnce({ cancelled: true }); + extractAskParams({ + assistantText: assistant.text, + auth: selected.auth, + model: selected.model, + onRetry: (attempt, maxRetries) => { + ctx.ui.notify( + `Retrying extraction JSON repair (${attempt}/${maxRetries})...`, + "info" + ); + }, + retries: config.answer.extractionRetries, + signal: loader.signal, + timeoutMs: config.answer.extractionTimeoutMs, + }) + .then((params) => doneOnce({ params })) + .catch((error) => doneOnce({ error: formatErrorMessage(error) })); + return loader; + }); +} + +function formatErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.name ? `${error.name}: ${error.message}` : error.message; + } + return String(error); +} + +function isAskParamsCandidate(value: unknown): value is AskParams { + return ( + !!value && + typeof value === "object" && + Array.isArray((value as { questions?: unknown }).questions) + ); +} + +function formatExtractionError(error: string): string { + if (error.includes("valid JSON")) { + return error; + } + if (error.includes("cancelled")) { + return "Question extraction cancelled."; + } + if ( + error.includes("aborted") || + error.includes("AbortError") || + error.includes("timed out") + ) { + return "Question extraction timed out. Try again or configure a faster extraction model."; + } + return error; +} + +async function runReplayCommand( + pi: ExtensionAPI, + ctx: ExtensionCommandContext, + options: { + missingMessage: string; + noticePrefix: string; + source: AskPayloadSource; + } +): Promise { + if (!ctx.hasUI) { + ctx.ui.notify("Ask replay requires interactive UI.", "error"); + return; + } + const lookup = findLatestPayloadInCurrentBranch(ctx, options.source); + if (!lookup.data) { + ctx.ui.notify( + lookup.invalidMatchFound + ? "Previous form exists on this branch but is no longer compatible." + : options.missingMessage, + "info" + ); + return; + } + ctx.ui.notify( + `${options.noticePrefix}: ${lookup.data.params.questions.length} question(s).`, + "info" + ); + await runAskAndSendSubmittedResult(pi, ctx, lookup.data.params, { + allowFreeform: options.source === "answer-extraction", + }); +} + +async function runAskAndSendSubmittedResult( + pi: Pick, + ctx: ExtensionContext, + params: AskParams, + options: { allowFreeform: boolean } +): Promise { + const result = await runAskFlow(ctx, params, options); + if (result.cancelled) { + ctx.ui.notify("Ask form cancelled.", "info"); + return; + } + sendAskResult(pi, result, ctx); +} + +function sendAskResult( + pi: Pick, + result: AskResult, + ctx: Pick +): void { + const response = successfulResponse(result); + const text = response.content[0]; + pi.sendUserMessage( + text.text, + ctx.isIdle() ? undefined : { deliverAs: "followUp" } + ); +} + +function findLatestAssistantText( + ctx: Pick +): AssistantTextSource | { error: string } { + const branch = ctx.sessionManager.getBranch(); + for (let index = branch.length - 1; index >= 0; index--) { + const entry = branch[index]; + if (entry.type !== "message") { + continue; + } + const message = entry.message; + if (!("role" in message) || message.role !== "assistant") { + continue; + } + if (message.stopReason !== "stop") { + return { + error: `Latest assistant message is incomplete (${message.stopReason}); wait for it to finish, then run /answer again.`, + }; + } + const text = message.content + .filter( + (part): part is { text: string; type: "text" } => part.type === "text" + ) + .map((part) => part.text) + .join("\n") + .trim(); + if (text) { + return { entryId: entry.id, text }; + } + } + return { error: "No assistant message found to extract questions from." }; +} diff --git a/src/answer-extraction.ts b/src/answer-extraction.ts new file mode 100644 index 0000000..c4accc2 --- /dev/null +++ b/src/answer-extraction.ts @@ -0,0 +1,354 @@ +import { + type Api, + complete, + type Model, + type UserMessage, +} from "@mariozechner/pi-ai"; +import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; +import type { AskConfig } from "./config/schema.ts"; +import type { AskParams } from "./types.ts"; + +export const ANSWER_EXTRACTION_SYSTEM_PROMPT = `You extract user-input questions from an assistant message and return ONLY raw JSON. + +Return JSON matching this TypeScript shape: +{ + "title"?: string, + "questions": [ + { + "id": string, + "label"?: string, + "prompt": string, + "type"?: "single" | "multi" | "preview", + "required"?: boolean, + "options": [ + { + "value": string, + "label": string, + "description"?: string, + "preview"?: string, + "freeform"?: boolean + } + ] + } + ] +} + +Rules: +- Output raw JSON only. No Markdown. No prose. No code fences. +- Extract questions that require user input. +- Ignore generic conversational or clarification prompts such as "How can I help?", "Could you clarify?", or "Let me know what you need" unless they include concrete choices. +- Extract a question when it has explicit choices or when the user should type their own answer. +- Preserve question order. +- Generate stable snake_case ids. +- Choose question type from the question semantics. +- Use type "single" when one answer is expected. +- Use type "multi" when multiple answers could reasonably be selected. +- Use type "preview" when options need preview-pane detail and every option has non-empty preview text. +- Avoid defaulting mechanically; infer from whether the options are mutually exclusive, can coexist, or need preview-pane detail. +- Each extracted question must have at least one option. +- Options rule: + - Extract options only from choices explicitly offered by the assistant. + - If the assistant gives concrete choices, use those choices as normal options. + - If the assistant gives examples only, do not treat examples as choices unless they are presented as selectable answers. + - If no concrete choices are given and the user should type their own answer, create exactly one freeform option: {"value":"freeform","label":"Type answer","freeform":true}. + - Never invent options. + - Never mix a freeform option with normal options. +- Provide clear, distinct options. Do not add filler options. +- Do not create an option that merely restates the question. +- Include descriptions only when helpful. +- Return {"questions":[]} if no questions are found.`; + +interface SelectedExtractionModel { + auth: { apiKey?: string; headers?: Record }; + model: Model; + usedFallback: boolean; +} + +export async function selectExtractionModel( + ctx: Pick, + preferences: AskConfig["answer"]["extractionModels"] +): Promise { + for (const preference of preferences) { + const model = ctx.modelRegistry.find(preference.provider, preference.id); + if (!model) { + continue; + } + const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model); + if (auth.ok) { + return { model, auth, usedFallback: false }; + } + } + + if (!ctx.model) { + return { + error: + "No available extraction model. Configure answer.extractionModels or select a chat model.", + }; + } + + const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model); + if (!auth.ok) { + return { + error: `No auth for fallback chat model: ${ctx.model.provider}/${ctx.model.id}.`, + }; + } + + return { model: ctx.model, auth, usedFallback: true }; +} + +export async function extractAskParams(options: { + assistantText: string; + model: Model; + auth: { apiKey?: string; headers?: Record }; + retries: number; + signal?: AbortSignal; + timeoutMs: number; + onRetry?: (attempt: number, maxRetries: number) => void; +}): Promise { + let lastCandidate: AskParams | undefined; + let lastResponse = ""; + let lastError = ""; + for (let attempt = 0; attempt <= options.retries; attempt++) { + if (attempt > 0) { + options.onRetry?.(attempt, options.retries); + } + const responseText = await runExtractionAttempt({ + ...options, + attempt, + lastError, + lastResponse, + }); + lastResponse = responseText; + const parsed = parseExtractionCandidate(responseText); + if (!parsed.ok) { + lastError = parsed.error; + continue; + } + lastCandidate = parsed.params; + if (parsed.issues.length === 0) { + return parsed.params; + } + lastError = parsed.issues.join("\n"); + } + if (lastCandidate) { + return repairExtractionParams(lastCandidate); + } + throw new Error( + "Question extraction did not return valid JSON after retries." + ); +} + +async function runExtractionAttempt(options: { + assistantText: string; + attempt: number; + auth: { apiKey?: string; headers?: Record }; + lastError: string; + lastResponse: string; + model: Model; + signal?: AbortSignal; + timeoutMs: number; +}): Promise { + const controller = new AbortController(); + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + controller.abort(); + }, options.timeoutMs); + const abortFromParent = () => controller.abort(); + options.signal?.addEventListener("abort", abortFromParent, { once: true }); + try { + const userMessage: UserMessage = { + role: "user", + content: [ + { + type: "text", + text: + options.attempt === 0 + ? options.assistantText + : formatRetryPrompt(options), + }, + ], + timestamp: Date.now(), + }; + const response = await complete( + options.model, + { + systemPrompt: ANSWER_EXTRACTION_SYSTEM_PROMPT, + messages: [userMessage], + }, + { + apiKey: options.auth.apiKey, + headers: options.auth.headers, + signal: controller.signal, + } + ); + if (response.stopReason === "aborted") { + throw new Error( + timedOut + ? "Question extraction timed out. Try again or configure a faster extraction model." + : "Question extraction cancelled." + ); + } + return response.content + .filter( + (part): part is { text: string; type: "text" } => part.type === "text" + ) + .map((part) => part.text) + .join("\n"); + } finally { + clearTimeout(timeout); + options.signal?.removeEventListener("abort", abortFromParent); + } +} + +const MAX_EXTRACTED_OPTIONS_PER_QUESTION = 4; + +function parseExtractionCandidate( + responseText: string +): + | { ok: true; params: AskParams; issues: string[] } + | { ok: false; error: string } { + try { + const parsed = JSON.parse(responseText.trim()) as unknown; + if (!isAskParamsLike(parsed)) { + return { ok: false, error: "root.questions must be an array" }; + } + return { + ok: true, + params: parsed, + issues: collectExtractionBusinessIssues(parsed), + }; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +function isAskParamsLike(value: unknown): value is AskParams { + return ( + !!value && + typeof value === "object" && + Array.isArray((value as { questions?: unknown }).questions) + ); +} + +export function collectExtractionBusinessIssues(params: AskParams): string[] { + const issues: string[] = []; + params.questions.forEach((question, questionIndex) => { + if (isGenericConversationalPrompt(question.prompt)) { + issues.push( + `questions[${questionIndex}].prompt is generic conversational text; omit this question unless it includes concrete choices` + ); + } + if (question.options.length > MAX_EXTRACTED_OPTIONS_PER_QUESTION) { + issues.push( + `questions[${questionIndex}].options has ${question.options.length} items; max is ${MAX_EXTRACTED_OPTIONS_PER_QUESTION}` + ); + } + if ( + question.options.length === 1 && + optionRestatesQuestion(question.options[0]?.label, question.prompt) + ) { + issues.push( + `questions[${questionIndex}].options[0] merely restates the question; omit this question or provide meaningful distinct options` + ); + } + }); + return issues; +} + +export function repairExtractionParams(params: AskParams): AskParams { + return { + ...params, + questions: params.questions + .filter((question) => !isGenericConversationalPrompt(question.prompt)) + .filter( + (question) => + question.options.length !== 1 || + !optionRestatesQuestion(question.options[0]?.label, question.prompt) + ) + .map((question) => ({ + ...question, + options: capOptionsPreservingOther(question.options), + })), + }; +} + +function capOptionsPreservingOther( + options: T[] +): T[] { + if (options.length <= MAX_EXTRACTED_OPTIONS_PER_QUESTION) { + return options; + } + const other = options.find((option) => isOtherOption(option)); + if (!other) { + return options.slice(0, MAX_EXTRACTED_OPTIONS_PER_QUESTION); + } + const head = options + .filter((option) => option !== other) + .slice(0, MAX_EXTRACTED_OPTIONS_PER_QUESTION - 1); + return [...head, other]; +} + +function isOtherOption(option: { label: string; value: string }): boolean { + const label = normalizeText(option.label); + const value = normalizeText(option.value); + return ( + label === "other" || label.includes("something else") || value === "other" + ); +} + +function optionRestatesQuestion( + label: string | undefined, + prompt: string +): boolean { + if (!label) { + return false; + } + const normalizedLabel = normalizeText(label); + const normalizedPrompt = normalizeText(prompt); + return ( + normalizedLabel.length > 8 && + (normalizedPrompt.includes(normalizedLabel) || + normalizedLabel.includes(normalizedPrompt)) + ); +} + +function isGenericConversationalPrompt(prompt: string): boolean { + const normalized = normalizeText(prompt); + return [ + "how can i help", + "could you clarify", + "let me know what you need", + "what else is on your mind", + "anything on your mind i can help with", + ].some((phrase) => normalized.includes(phrase)); +} + +function normalizeText(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .trim(); +} + +function formatRetryPrompt(options: { + assistantText: string; + lastError: string; + lastResponse: string; +}): string { + return `Your previous response was not valid raw JSON. + +JSON.parse error: +${options.lastError} + +Previous response: +${options.lastResponse} + +Original assistant message: +${options.assistantText} + +Return ONLY valid raw JSON matching the schema and fix the reported issues. No Markdown. No prose.`; +} diff --git a/src/ask-payload-store.ts b/src/ask-payload-store.ts new file mode 100644 index 0000000..46bb661 --- /dev/null +++ b/src/ask-payload-store.ts @@ -0,0 +1,86 @@ +import type { + ExtensionAPI, + ExtensionContext, +} from "@mariozechner/pi-coding-agent"; +import { validateParams } from "./ask-tool-helpers.ts"; +import type { AskParams } from "./types.ts"; + +export const ASK_PAYLOAD_ENTRY_TYPE = "ask:payload"; +export const ASK_PAYLOAD_ENTRY_VERSION = 1; + +export type AskPayloadSource = "answer-extraction" | "tool"; + +export interface AskPayloadEntryData { + params: AskParams; + source: AskPayloadSource; + sourceEntryId?: string; + timestamp: number; + version: typeof ASK_PAYLOAD_ENTRY_VERSION; +} + +export function appendAskPayload( + pi: Pick, + data: Omit +): void { + pi.appendEntry(ASK_PAYLOAD_ENTRY_TYPE, { + version: ASK_PAYLOAD_ENTRY_VERSION, + timestamp: Date.now(), + ...data, + }); +} + +export function findLatestPayloadInCurrentBranch( + ctx: Pick, + source: AskPayloadSource +): { data?: AskPayloadEntryData; invalidMatchFound: boolean } { + let invalidMatchFound = false; + for (const entry of [...ctx.sessionManager.getBranch()].reverse()) { + if (!isAskPayloadEntry(entry)) { + continue; + } + const data = entry.data; + if (data?.source !== source) { + continue; + } + if (isValidAskPayloadData(data)) { + return { data, invalidMatchFound }; + } + invalidMatchFound = true; + } + return { invalidMatchFound }; +} + +function isAskPayloadEntry(entry: unknown): entry is { + customType: string; + data?: Partial; + type: "custom"; +} { + return ( + !!entry && + typeof entry === "object" && + (entry as { type?: unknown }).type === "custom" && + (entry as { customType?: unknown }).customType === ASK_PAYLOAD_ENTRY_TYPE + ); +} + +function isValidAskPayloadData(data: unknown): data is AskPayloadEntryData { + if (!(data && typeof data === "object")) { + return false; + } + const payload = data as Partial; + if (payload.version !== ASK_PAYLOAD_ENTRY_VERSION) { + return false; + } + if (payload.source !== "tool" && payload.source !== "answer-extraction") { + return false; + } + if ( + !payload.params || + validateParams(payload.params, { + allowFreeform: payload.source === "answer-extraction", + }).ok === false + ) { + return false; + } + return true; +} diff --git a/src/ask-tool-helpers.ts b/src/ask-tool-helpers.ts index 78dc0c1..569747a 100644 --- a/src/ask-tool-helpers.ts +++ b/src/ask-tool-helpers.ts @@ -18,28 +18,34 @@ export const ASK_TOOL_DESCRIPTION = export const ASK_TOOL_PROMPT_GUIDELINES = [ "Use this tool before making preference-sensitive decisions about scope, tone, UX, naming, architecture, docs, or implementation direction.", "When multiple valid directions exist, ask 1-3 concise questions instead of committing to one path on your own.", - "Prefer one focused decision per question and use short labels with 2-4 clear options.", + "Prefer one focused decision per question. Use short labels. Provide clear, distinct options. Do not add filler options.", "Always include a non-empty `value` for every option.", - 'Use `type: "single"` by default and `type: "multi"` only when several answers can genuinely apply.', - 'Use `type: "preview"` only when every option includes non-empty `preview` text for the dedicated preview pane. Option descriptions do not satisfy this requirement.', + "Choose question `type` from the question semantics: `single` means one answer is expected, `multi` means multiple answers could reasonably be selected, and `preview` means options need preview-pane detail and every option includes non-empty `preview` text.", + "Avoid defaulting mechanically; infer from whether the options are mutually exclusive, can coexist, or need preview-pane detail.", + 'Use `type: "preview"` only when every option includes non-empty `preview` text. Option descriptions do not satisfy this requirement.', "After clarifying a note or follow-up question, prefer another structured ask_user follow-up if a choice is still needed instead of switching to plain-text multiple choice in chat.", "When prior answers already narrow the branch, bundle the next 2-3 related unresolved decisions into one follow-up ask instead of issuing a long sequence of single-question asks.", "Use one-at-a-time follow-up asks only when the next question materially depends on the previous answer.", ] as const; +interface ValidateParamsOptions { + allowFreeform?: boolean; +} + export function validateParams( - params: AskParams + params: AskParams, + options: ValidateParamsOptions = {} ): | { ok: true; state: ReturnType } | { ok: false; issues: AskValidationIssue[] } { - const issues = collectValidationIssues(params); + const issues = collectValidationIssues(params, options); if (issues.length > 0) { return { ok: false, issues }; } return { ok: true, - state: createInitialState(params), + state: createInitialState(params, options), }; } diff --git a/src/ask-tool.ts b/src/ask-tool.ts index db2bb6a..b89afff 100644 --- a/src/ask-tool.ts +++ b/src/ask-tool.ts @@ -2,6 +2,7 @@ import type { ExtensionAPI, ExtensionContext, } from "@mariozechner/pi-coding-agent"; +import { appendAskPayload } from "./ask-payload-store.ts"; import { ASK_TOOL_DESCRIPTION, ASK_TOOL_PROMPT_GUIDELINES, @@ -25,16 +26,25 @@ export function registerAskTool(pi: ExtensionAPI) { "Clarify ambiguous or preference-sensitive decisions with a short interactive interview before proceeding", promptGuidelines: [...ASK_TOOL_PROMPT_GUIDELINES], parameters: AskParamsSchema, - execute: executeAskTool, + execute: (toolCallId, params, signal, onUpdate, ctx) => + executeAskTool( + pi, + toolCallId, + params as AskParams, + signal, + onUpdate, + ctx + ), renderCall: renderAskToolCall, renderResult: renderAskToolResult, }); } -interface ExecuteContext extends Pick {} +interface ExecuteContext extends ExtensionContext {} async function executeAskTool( - _toolCallId: string, + pi: Pick, + toolCallId: string, params: AskParams, _signal: AbortSignal | undefined, _onUpdate: unknown, @@ -44,6 +54,11 @@ async function executeAskTool( if (!validation.ok) { return invalidPayloadResponse(params, validation.issues); } + appendAskPayload(pi, { + params, + source: "tool", + sourceEntryId: toolCallId, + }); if (!ctx.hasUI) { return nonInteractiveResponse(validation.state); } diff --git a/src/config/defaults.ts b/src/config/defaults.ts index b2c14cc..c6a80d0 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -1,7 +1,20 @@ import { DEFAULT_ASK_KEYMAPS } from "../constants/keymaps.ts"; -import type { AskConfig, AskConfigFileV1 } from "./schema.ts"; +import type { AskConfig, AskConfigFileV2 } from "./schema.ts"; + +const DEFAULT_EXTRACTION_RETRIES = 1; +const MAX_EXTRACTION_RETRIES = 3; +const DEFAULT_EXTRACTION_TIMEOUT_MS = 30_000; export const DEFAULT_ASK_CONFIG: AskConfig = { + answer: { + extractionModels: [ + { provider: "openai-codex", id: "gpt-5.4-mini" }, + { provider: "github-copilot", id: "gpt-5.4-mini" }, + { provider: "anthropic", id: "claude-haiku-4-5" }, + ], + extractionRetries: DEFAULT_EXTRACTION_RETRIES, + extractionTimeoutMs: DEFAULT_EXTRACTION_TIMEOUT_MS, + }, behaviour: { autoSubmitWhenAnsweredWithoutNotes: false, confirmDismissWhenDirty: true, @@ -14,9 +27,24 @@ export const DEFAULT_ASK_CONFIG: AskConfig = { }; export function normalizeAskConfig( - config?: Partial | AskConfig + config?: Partial | AskConfig ): AskConfig { return { + answer: { + extractionModels: + config?.answer?.extractionModels?.filter(isValidModelPreference) ?? + DEFAULT_ASK_CONFIG.answer.extractionModels, + extractionRetries: clampInteger( + config?.answer?.extractionRetries, + 0, + MAX_EXTRACTION_RETRIES, + DEFAULT_EXTRACTION_RETRIES + ), + extractionTimeoutMs: positiveNumberOrDefault( + config?.answer?.extractionTimeoutMs, + DEFAULT_EXTRACTION_TIMEOUT_MS + ), + }, behaviour: { autoSubmitWhenAnsweredWithoutNotes: config?.behaviour?.autoSubmitWhenAnsweredWithoutNotes ?? @@ -38,10 +66,15 @@ export function normalizeAskConfig( }; } -export function toAskConfigFileV1(config: AskConfig): AskConfigFileV1 { +export function toAskConfigFileV2(config: AskConfig): AskConfigFileV2 { const normalized = normalizeAskConfig(config); return { - schemaVersion: 1, + schemaVersion: 2, + answer: { + extractionModels: normalized.answer.extractionModels, + extractionRetries: normalized.answer.extractionRetries, + extractionTimeoutMs: normalized.answer.extractionTimeoutMs, + }, behaviour: { autoSubmitWhenAnsweredWithoutNotes: normalized.behaviour.autoSubmitWhenAnsweredWithoutNotes, @@ -60,3 +93,35 @@ export function toAskConfigFileV1(config: AskConfig): AskConfigFileV1 { }, }; } + +function isValidModelPreference(value: unknown): value is { + id: string; + provider: string; +} { + return ( + !!value && + typeof value === "object" && + typeof (value as { id?: unknown }).id === "string" && + (value as { id: string }).id.trim().length > 0 && + typeof (value as { provider?: unknown }).provider === "string" && + (value as { provider: string }).provider.trim().length > 0 + ); +} + +function clampInteger( + value: unknown, + min: number, + max: number, + fallback: number +): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + return fallback; + } + return Math.max(min, Math.min(max, Math.trunc(value))); +} + +function positiveNumberOrDefault(value: unknown, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) && value > 0 + ? value + : fallback; +} diff --git a/src/config/migrate.ts b/src/config/migrate.ts index bad5ab6..8fc24d1 100644 --- a/src/config/migrate.ts +++ b/src/config/migrate.ts @@ -8,8 +8,8 @@ import { type AskConfigVersionMigrationResult, migrateAskConfigFileToCurrent, } from "./migrations/index.ts"; -import type { AskConfig, AskConfigFileV1 } from "./schema.ts"; -import { validateAskConfigFileV1 } from "./schema.ts"; +import type { AskConfig, AskConfigFileV2 } from "./schema.ts"; +import { validateAskConfigFileV2 } from "./schema.ts"; export class AskConfigMigrationError extends Error { readonly reason: "invalid_or_unsupported" | "migration_failed"; @@ -42,14 +42,14 @@ export function migrateAskConfig(raw: unknown): AskConfigMigrationResult { ); } - if (!validateAskConfigFileV1.Check(migratedFile.config)) { + if (!validateAskConfigFileV2.Check(migratedFile.config)) { throw new AskConfigMigrationError( "Config was invalid or unsupported.", "invalid_or_unsupported" ); } - const currentFile = migratedFile.config as AskConfigFileV1; + const currentFile = migratedFile.config as AskConfigFileV2; const config = normalizeAskConfig(currentFile); const keymapsResult = normalizeConfiguredKeymaps( currentFile.keymaps as diff --git a/src/config/migrations/index.ts b/src/config/migrations/index.ts index 2a40991..9fc931c 100644 --- a/src/config/migrations/index.ts +++ b/src/config/migrations/index.ts @@ -1,8 +1,17 @@ import type { AskConfigMigration, VersionedAskConfigFile } from "./types.ts"; -export const CURRENT_ASK_CONFIG_SCHEMA_VERSION = 1; +export const CURRENT_ASK_CONFIG_SCHEMA_VERSION = 2; -const ASK_CONFIG_MIGRATIONS: AskConfigMigration[] = []; +const ASK_CONFIG_MIGRATIONS: AskConfigMigration[] = [ + { + from: 1, + to: 2, + migrate: (config) => ({ + ...config, + schemaVersion: 2, + }), + }, +]; export class AskConfigVersionMigrationError extends Error { readonly reason: "invalid_or_unsupported" | "migration_failed"; diff --git a/src/config/schema.ts b/src/config/schema.ts index 823ff23..aceeb46 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -1,8 +1,22 @@ import { type Static, Type } from "typebox"; import { Compile } from "typebox/compile"; -export const AskConfigFileV1Schema = Type.Object({ - schemaVersion: Type.Literal(1), +const AskAnswerModelPreferenceSchema = Type.Object({ + id: Type.String(), + provider: Type.String(), +}); + +export const AskConfigFileV2Schema = Type.Object({ + schemaVersion: Type.Literal(2), + answer: Type.Optional( + Type.Object({ + extractionModels: Type.Optional( + Type.Array(AskAnswerModelPreferenceSchema) + ), + extractionRetries: Type.Optional(Type.Number()), + extractionTimeoutMs: Type.Optional(Type.Number()), + }) + ), behaviour: Type.Optional( Type.Object({ autoSubmitWhenAnsweredWithoutNotes: Type.Optional(Type.Boolean()), @@ -14,16 +28,27 @@ export const AskConfigFileV1Schema = Type.Object({ keymaps: Type.Optional( Type.Object({ cancel: Type.Optional(Type.String()), - dismiss: Type.Optional(Type.String()), - toggle: Type.Optional(Type.String()), confirm: Type.Optional(Type.String()), + dismiss: Type.Optional(Type.String()), optionNote: Type.Optional(Type.String()), questionNote: Type.Optional(Type.String()), + toggle: Type.Optional(Type.String()), }) ), }); -export type AskConfigFileV1 = Static; +export type AskConfigFileV2 = Static; +export type AskConfigFileV1 = Omit< + AskConfigFileV2, + "answer" | "schemaVersion" +> & { + schemaVersion: 1; +}; + +export interface AskAnswerModelPreference { + id: string; + provider: string; +} export interface AskConfigKeymaps { cancel: string; @@ -35,6 +60,11 @@ export interface AskConfigKeymaps { } export interface AskConfig { + answer: { + extractionModels: AskAnswerModelPreference[]; + extractionRetries: number; + extractionTimeoutMs: number; + }; behaviour: { autoSubmitWhenAnsweredWithoutNotes: boolean; confirmDismissWhenDirty: boolean; @@ -44,4 +74,4 @@ export interface AskConfig { keymaps: AskConfigKeymaps; } -export const validateAskConfigFileV1 = Compile(AskConfigFileV1Schema); +export const validateAskConfigFileV2 = Compile(AskConfigFileV2Schema); diff --git a/src/config/store.ts b/src/config/store.ts index 38fdf0c..5f84bd6 100644 --- a/src/config/store.ts +++ b/src/config/store.ts @@ -7,7 +7,7 @@ import { getAgentDir } from "@mariozechner/pi-coding-agent"; import { DEFAULT_ASK_CONFIG, normalizeAskConfig, - toAskConfigFileV1, + toAskConfigFileV2, } from "./defaults.ts"; import { AskConfigMigrationError, migrateAskConfig } from "./migrate.ts"; import { migrateAskConfigPathIfNeeded } from "./path-migrations.ts"; @@ -63,10 +63,10 @@ export class AskConfigStore { return (await this.ensureLoaded()).config; } - async save(config: AskConfig): Promise { + async save(config: AskConfig | Partial): Promise { const normalized = normalizeAskConfig(config); const content = JSON.stringify( - toAskConfigFileV1(normalized), + toAskConfigFileV2(normalized), null, 2 ).concat("\n"); diff --git a/src/index.ts b/src/index.ts index 27febd0..5bb0fa6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { registerAnswerCommands } from "./answer-commands.ts"; import { registerAskSettingsCommand } from "./ask-settings-command.ts"; import { registerAskTool } from "./ask-tool.ts"; @@ -18,4 +19,5 @@ export default function askExtension(pi: ExtensionAPI) { })); registerAskTool(pi); registerAskSettingsCommand(pi); + registerAnswerCommands(pi); } diff --git a/src/schema.ts b/src/schema.ts index 7e63dec..e7389c5 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -46,7 +46,7 @@ export const AskQuestionSchema = Type.Object({ type: Type.Optional( Type.String({ description: - "Question type: use `single` by default. Use `preview` only when every option includes `preview` text for the dedicated preview pane; descriptions alone are not enough.", + "Question type: `single` means one answer is expected, `multi` means multiple answers could reasonably be selected, and `preview` means options need preview-pane detail. Use `preview` only when every option includes `preview` text; descriptions alone are not enough.", }) ), required: Type.Optional( @@ -56,7 +56,8 @@ export const AskQuestionSchema = Type.Object({ }) ), options: Type.Array(AskOptionSchema, { - description: "Answer options; prefer 2-4 clear choices when possible", + description: + "Answer options; provide clear, distinct choices and do not add filler options", }), }); diff --git a/src/state/create.ts b/src/state/create.ts index 3706f91..42e0a6c 100644 --- a/src/state/create.ts +++ b/src/state/create.ts @@ -2,9 +2,16 @@ import type { AskParams } from "../types.ts"; import { normalizeQuestions } from "./normalize.ts"; import { createInitialState as createBaseState } from "./transitions.ts"; -export function createInitialState(params: AskParams) { +interface CreateInitialStateOptions { + allowFreeform?: boolean; +} + +export function createInitialState( + params: AskParams, + options: CreateInitialStateOptions = {} +) { return createBaseState({ title: params.title, - questions: normalizeQuestions(params), + questions: normalizeQuestions(params, options), }); } diff --git a/src/state/editor.ts b/src/state/editor.ts index 45bd616..77e2d70 100644 --- a/src/state/editor.ts +++ b/src/state/editor.ts @@ -62,5 +62,9 @@ export function syncStateToSelection(state: AskState): AskState { return state; } + if (getAnswer(state, question.id)?.customText?.trim()) { + return state; + } + return enterInputMode(state, question.id); } diff --git a/src/state/normalize.ts b/src/state/normalize.ts index 7b133a7..c5aac45 100644 --- a/src/state/normalize.ts +++ b/src/state/normalize.ts @@ -11,8 +11,15 @@ interface IssueCollector { issues: AskValidationIssue[]; } -export function normalizeQuestions(params: AskParams): AskQuestion[] { - const issues = collectValidationIssues(params); +interface ValidationOptions { + allowFreeform?: boolean; +} + +export function normalizeQuestions( + params: AskParams, + options: ValidationOptions = {} +): AskQuestion[] { + const issues = collectValidationIssues(params, options); if (issues.length > 0) { throw new Error(issues[0]?.message ?? "Invalid ask_user payload"); } @@ -20,10 +27,11 @@ export function normalizeQuestions(params: AskParams): AskQuestion[] { } export function collectValidationIssues( - params: AskParams + params: AskParams, + options: ValidationOptions = {} ): AskValidationIssue[] { const collector = createIssueCollector(); - validateQuestions(params.questions, collector); + validateQuestions(params.questions, collector, options); return collector.issues; } @@ -47,12 +55,14 @@ function normalizeOption(option: AskOption): AskOption { label: option.label.trim(), description: option.description?.trim(), preview: option.preview?.trim(), + ...(option.freeform ? { freeform: true } : {}), }; } function validateQuestions( questions: AskParams["questions"], - collector: IssueCollector + collector: IssueCollector, + options: ValidationOptions ) { if (questions.length === 0) { collector.add("questions", "At least one question is required"); @@ -61,7 +71,7 @@ function validateQuestions( const questionIds = new Set(); for (const [questionIndex, question] of questions.entries()) { - validateQuestion(question, questionIndex, questionIds, collector); + validateQuestion(question, questionIndex, questionIds, collector, options); } } @@ -69,7 +79,8 @@ function validateQuestion( question: AskQuestionInput, questionIndex: number, questionIds: Set, - collector: IssueCollector + collector: IssueCollector, + options: ValidationOptions ) { const questionNumber = questionIndex + 1; const questionPath = `questions[${questionIndex}]`; @@ -114,6 +125,14 @@ function validateQuestion( `Question ${questionNumber}: at least one option is required` ); + validateFreeformOptions( + question.options, + questionNumber, + collector, + `${questionPath}.options`, + options + ); + const optionValues = new Set(); for (const [optionIndex, option] of question.options.entries()) { validateOption( @@ -128,6 +147,32 @@ function validateQuestion( } } +function validateFreeformOptions( + options: AskOption[], + questionNumber: number, + collector: IssueCollector, + path: string, + validationOptions: ValidationOptions +) { + const freeformCount = options.filter((option) => option.freeform).length; + if (freeformCount === 0) { + return; + } + if (!validationOptions.allowFreeform) { + collector.add( + path, + `Question ${questionNumber}: freeform options are only supported for /answer forms` + ); + return; + } + if (freeformCount > 1 || options.length > 1) { + collector.add( + path, + `Question ${questionNumber}: freeform options must be the only option` + ); + } +} + function validateOption( option: AskOption, optionIndex: number, diff --git a/src/state/selectors.ts b/src/state/selectors.ts index 6897290..31e3417 100644 --- a/src/state/selectors.ts +++ b/src/state/selectors.ts @@ -13,6 +13,8 @@ const CUSTOM_OPTION: AskDisplayOption = { isCustomOption: true, }; +const DEFAULT_FREEFORM_LABEL = "Type your answer:"; + export function getCurrentQuestion(state: AskState): AskQuestion | undefined { return state.questions[state.activeTabIndex]; } @@ -34,9 +36,30 @@ export function getRenderableOptions( if (!question) { return []; } + const freeformOption = getFreeformOption(question); + if (freeformOption) { + return [ + { + ...freeformOption, + label: DEFAULT_FREEFORM_LABEL, + isCustomOption: true, + isFreeformOnlyOption: true, + }, + ]; + } return [...question.options, CUSTOM_OPTION]; } +function getFreeformOption( + question: AskQuestion +): AskDisplayOption | undefined { + if (question.options.length !== 1) { + return; + } + const option = question.options[0]; + return option.freeform ? option : undefined; +} + export function getCurrentOption( state: AskState ): AskDisplayOption | undefined { diff --git a/src/types.ts b/src/types.ts index 8ff46de..b21500c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,6 +2,7 @@ export type AskQuestionType = "single" | "multi" | "preview"; export interface AskOption { description?: string; + freeform?: boolean; label: string; preview?: string; value: string; @@ -147,6 +148,7 @@ export interface AskState { export interface AskDisplayOption extends AskOption { isCustomOption?: boolean; + isFreeformOnlyOption?: boolean; } export type AskAction = diff --git a/src/ui/controller.ts b/src/ui/controller.ts index c469468..2983746 100644 --- a/src/ui/controller.ts +++ b/src/ui/controller.ts @@ -52,8 +52,16 @@ type Tui = CustomCallbackArgs[0]; type Theme = CustomCallbackArgs[1]; type Keybindings = CustomCallbackArgs[2]; type Done = (result: AskResult) => void; +interface AskFlowOptions { + allowFreeform?: boolean; +} + type AskFlowParams = AskParams & - Pick & { config: AskConfig; ctx: ExtensionContext }; + Pick & { + config: AskConfig; + ctx: ExtensionContext; + flowOptions: AskFlowOptions; + }; interface AskFlowController { config: AskConfig; @@ -72,12 +80,19 @@ interface AskFlowController { export async function runAskFlow( ctx: ExtensionContext, - params: AskParams + params: AskParams, + options: AskFlowOptions = {} ): Promise { const store = getAskConfigStore(); const config = await store.getConfig(); return ctx.ui.custom((...args) => - createAskFlowController(args, { ...params, config, cwd: ctx.cwd, ctx }) + createAskFlowController(args, { + ...params, + config, + cwd: ctx.cwd, + ctx, + flowOptions: options, + }) ); } @@ -97,7 +112,7 @@ function createAskFlowController( done, editor: createEditor(tui, theme, params.cwd), settingsOpen: false, - state: createInitialState(params), + state: createInitialState(params, params.flowOptions), suppressAutoInputForSelection: false, pendingReviewShortcutActionIndex: undefined, theme, @@ -321,8 +336,10 @@ function submitEditor(controller: AskFlowController, value: string) { if (nextState.activeTabIndex !== controller.state.activeTabIndex) { clearDismissNotice(controller); } - controller.state = maybeAutoSubmitState(nextState, controller.config); - controller.editor.setText(""); + controller.state = nextState; + syncSelection(controller); + controller.state = maybeAutoSubmitState(controller.state, controller.config); + hydrateEditor(controller); refresh(controller); maybeFinish(controller); } diff --git a/src/ui/render-question.ts b/src/ui/render-question.ts index baeac17..c47c4a4 100644 --- a/src/ui/render-question.ts +++ b/src/ui/render-question.ts @@ -76,7 +76,7 @@ function renderStandardOption( pushWrappedText( lines, - `${row.index + 1}. ${row.prefix}${row.label}`, + formatOptionLabel(row), context.width, context.theme, row.color, @@ -179,11 +179,16 @@ function renderOptionDetail( lines: string[], detail: OptionDetailModel | undefined, context: QuestionRenderContext, - options: { suppressLeadingGap?: boolean } = {} + options: { indent?: string; suppressLeadingGap?: boolean } = {} ) { if (!detail) { return; } + const indent = options.indent ?? " "; + const padding = + indent === " " + ? UI_DIMENSIONS.editorContentPadding + : UI_DIMENSIONS.editorIndentedPadding; if (detail.withGap && !options.suppressLeadingGap) { lines.push(""); } @@ -193,8 +198,8 @@ function renderOptionDetail( editor: context.editor, width: context.width, theme: context.theme, - indent: " ", - padding: UI_DIMENSIONS.editorIndentedPadding, + indent, + padding, placeholder: detail.placeholder, }); return; @@ -205,7 +210,7 @@ function renderOptionDetail( note: detail.text, width: context.width, theme: context.theme, - indent: " ", + indent, }); return; } @@ -215,8 +220,8 @@ function renderOptionDetail( context.width, context.theme, "muted", - " ", - " " + indent, + indent ); } @@ -244,29 +249,29 @@ function renderEditorWithIndent(args: { }); } +function formatOptionLabel(row: OptionRowModel): string { + return row.isFreeformOnly + ? row.label + : `${row.index + 1}. ${row.prefix}${row.label}`; +} + function renderInteractiveCustomOption( lines: string[], row: OptionRowModel, context: QuestionRenderContext ) { + const indent = row.isFreeformOnly ? " " : row.pointer; pushWrappedText( lines, - `${row.index + 1}. ${row.prefix}${row.label}`, + formatOptionLabel(row), context.width, context.theme, row.color, - row.pointer, - " ".repeat(visibleWidth(row.pointer)) + indent, + " ".repeat(visibleWidth(indent)) ); - renderEditorWithIndent({ - lines, - editor: context.editor, - width: context.width, - theme: context.theme, - indent: " ", - padding: UI_DIMENSIONS.editorIndentedPadding, - placeholder: - row.detail?.kind === "editor" ? row.detail.placeholder : "Type answer...", + renderOptionDetail(lines, row.detail, context, { + indent: row.isFreeformOnly ? " " : undefined, }); } diff --git a/src/ui/view-models/question.ts b/src/ui/view-models/question.ts index 8998e7f..cfd7cf4 100644 --- a/src/ui/view-models/question.ts +++ b/src/ui/view-models/question.ts @@ -26,6 +26,7 @@ export interface OptionRowModel { detail?: OptionDetailModel; index: number; isCustom: boolean; + isFreeformOnly: boolean; label: string; pointer: string; prefix: string; @@ -89,6 +90,7 @@ function buildOptionRowModel( const answered = option.isCustomOption ? !!answer?.customText : isOptionSelected(answer, option.value); + const pointer = getOptionPointer(option, selected); return { color: getOptionColor(answered, selected), description: option.description, @@ -98,13 +100,21 @@ function buildOptionRowModel( : buildOptionDetailModel(context, option, selected), index, isCustom: !!option.isCustomOption, + isFreeformOnly: !!option.isFreeformOnlyOption, label: option.label, - pointer: selected ? "❯ " : " ", + pointer, prefix: getOptionPrefix(question.type, option, answered), selected, }; } +function getOptionPointer(option: AskDisplayOption, selected: boolean): string { + if (option.isFreeformOnlyOption) { + return ""; + } + return selected ? "❯ " : " "; +} + function buildOptionDetailModel( context: QuestionRenderContext, option: AskDisplayOption | undefined, @@ -139,11 +149,15 @@ function buildOptionDetailModel( return { kind: "editor", placeholder: UI_TEXT.editorPlaceholderInput, - withGap: false, + withGap: !!option.isFreeformOnlyOption, }; } if (customText) { - return { kind: "custom-text", text: customText, withGap: false }; + return { + kind: "custom-text", + text: customText, + withGap: !!option.isFreeformOnlyOption, + }; } if (note) { return { kind: "saved-note", text: note, withGap: false }; diff --git a/tests/answer-extraction.test.ts b/tests/answer-extraction.test.ts new file mode 100644 index 0000000..0fa8927 --- /dev/null +++ b/tests/answer-extraction.test.ts @@ -0,0 +1,126 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + collectExtractionBusinessIssues, + repairExtractionParams, + selectExtractionModel, +} from "../src/answer-extraction.ts"; + +function model(provider: string, id: string) { + return { provider, id } as never; +} + +test("selectExtractionModel uses first configured model with auth", async () => { + const first = model("missing", "a"); + const second = model("ok", "b"); + const result = await selectExtractionModel( + { + model: model("fallback", "c"), + modelRegistry: { + find(provider: string, _id: string) { + if (provider === "missing") { + return first; + } + if (provider === "ok") { + return second; + } + }, + getApiKeyAndHeaders(candidate: { provider: string }) { + if (candidate.provider === "ok") { + return Promise.resolve({ ok: true, apiKey: "key" }); + } + return Promise.resolve({ ok: false, error: "no" }); + }, + }, + } as never, + [ + { provider: "missing", id: "a" }, + { provider: "ok", id: "b" }, + ] + ); + + assert.deepEqual(result, { + model: second, + auth: { ok: true, apiKey: "key" }, + usedFallback: false, + }); +}); + +test("extraction business rules flag option spam", () => { + const issues = collectExtractionBusinessIssues({ + questions: [ + { + id: "q", + prompt: "Pick one", + options: [ + { value: "a", label: "A" }, + { value: "b", label: "B" }, + { value: "c", label: "C" }, + { value: "d", label: "D" }, + { value: "e", label: "E" }, + ], + }, + ], + }); + + assert.deepEqual(issues, ["questions[0].options has 5 items; max is 4"]); +}); + +test("repairExtractionParams caps options while preserving other", () => { + const repaired = repairExtractionParams({ + questions: [ + { + id: "q", + prompt: "Pick one", + options: [ + { value: "a", label: "A" }, + { value: "b", label: "B" }, + { value: "c", label: "C" }, + { value: "d", label: "D" }, + { value: "other", label: "Other" }, + ], + }, + ], + }); + + assert.deepEqual( + repaired.questions[0]?.options.map((option) => option.value), + ["a", "b", "c", "other"] + ); +}); + +test("repairExtractionParams drops generic conversational prompts", () => { + const repaired = repairExtractionParams({ + questions: [ + { + id: "generic", + prompt: "How can I help you today?", + options: [{ value: "help", label: "Help me" }], + }, + ], + }); + + assert.deepEqual(repaired.questions, []); +}); + +test("selectExtractionModel validates fallback model auth", async () => { + const fallback = model("fallback", "c"); + const result = await selectExtractionModel( + { + model: fallback, + modelRegistry: { + find() { + return; + }, + getApiKeyAndHeaders() { + return Promise.resolve({ ok: false, error: "no auth" }); + }, + }, + } as never, + [] + ); + + assert.deepEqual(result, { + error: "No auth for fallback chat model: fallback/c.", + }); +}); diff --git a/tests/ask-payload-store.test.ts b/tests/ask-payload-store.test.ts new file mode 100644 index 0000000..c84c7d1 --- /dev/null +++ b/tests/ask-payload-store.test.ts @@ -0,0 +1,95 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { findLatestPayloadInCurrentBranch } from "../src/ask-payload-store.ts"; +import type { AskParams } from "../src/types.ts"; + +const params: AskParams = { + questions: [ + { + id: "goal", + prompt: "Goal?", + options: [{ value: "a", label: "A" }], + }, + ], +}; + +function custom(data: unknown) { + return { type: "custom", customType: "ask:payload", data }; +} + +function ctx(branch: unknown[]) { + return { sessionManager: { getBranch: () => branch } } as never; +} + +test("branch payload lookup returns the latest valid matching source", () => { + const older = { + version: 1, + source: "tool", + params, + timestamp: 1, + }; + const newer = { + version: 1, + source: "tool", + params: { ...params, title: "Newer" }, + timestamp: 2, + }; + + const result = findLatestPayloadInCurrentBranch( + ctx([custom(older), custom(newer)]), + "tool" + ); + + assert.equal(result.data, newer); + assert.equal(result.invalidMatchFound, false); +}); + +test("branch payload lookup allows freeform only for answer extraction payloads", () => { + const freeformParams: AskParams = { + questions: [ + { + id: "language", + prompt: "Which language?", + options: [{ value: "freeform", label: "Type answer", freeform: true }], + }, + ], + }; + + const answerPayload = { + version: 1, + source: "answer-extraction", + params: freeformParams, + timestamp: 1, + }; + const toolPayload = { + version: 1, + source: "tool", + params: freeformParams, + timestamp: 2, + }; + + assert.equal( + findLatestPayloadInCurrentBranch( + ctx([custom(answerPayload)]), + "answer-extraction" + ).data, + answerPayload + ); + assert.equal( + findLatestPayloadInCurrentBranch(ctx([custom(toolPayload)]), "tool").data, + undefined + ); +}); + +test("branch payload lookup ignores invalid stored payloads", () => { + const result = findLatestPayloadInCurrentBranch( + ctx([ + custom({ version: 1, source: "tool", params: { questions: [] } }), + custom({ version: 1, source: "answer-extraction", params }), + ]), + "tool" + ); + + assert.equal(result.data, undefined); + assert.equal(result.invalidMatchFound, true); +}); diff --git a/tests/ask-tool.test.ts b/tests/ask-tool.test.ts index 09c7fc2..e987403 100644 --- a/tests/ask-tool.test.ts +++ b/tests/ask-tool.test.ts @@ -24,19 +24,26 @@ const noop = () => { function registerMockTool() { const tools: Record[] = []; + const entries: Array<{ customType: string; data: unknown }> = []; registerAskTool({ + appendEntry(customType: string, data: unknown) { + entries.push({ customType, data }); + }, registerTool(tool: unknown) { tools.push(tool as Record); }, } as never); - return tools[0] as { - execute: (...args: any[]) => Promise; - renderCall: (args: unknown, theme: any) => { text: string }; - renderResult: ( - result: any, - options: unknown, - theme: any - ) => { text: string }; + return { + entries, + tool: tools[0] as { + execute: (...args: any[]) => Promise; + renderCall: (args: unknown, theme: any) => { text: string }; + renderResult: ( + result: any, + options: unknown, + theme: any + ) => { text: string }; + }, }; } @@ -61,8 +68,25 @@ function sampleParams(): AskParams { }; } +test("ask tool stores valid payloads as soon as they are called", async () => { + const { entries, tool } = registerMockTool(); + const params = sampleParams(); + + await tool.execute("call-1", params, undefined, noop, makeCtx(false)); + + assert.equal(entries.length, 1); + assert.equal(entries[0].customType, "ask:payload"); + assert.deepEqual(entries[0].data, { + version: 1, + source: "tool", + params, + sourceEntryId: "call-1", + timestamp: (entries[0].data as { timestamp: number }).timestamp, + }); +}); + test("ask tool returns pending questions in non-interactive mode", async () => { - const tool = registerMockTool(); + const { tool } = registerMockTool(); const result = await tool.execute( "call-1", @@ -90,7 +114,7 @@ test("ask tool returns pending questions in non-interactive mode", async () => { }); test("ask tool includes custom answer fallback for preview questions", async () => { - const tool = registerMockTool(); + const { tool } = registerMockTool(); const result = await tool.execute( "call-1", @@ -115,7 +139,7 @@ test("ask tool includes custom answer fallback for preview questions", async () }); test("ask tool rejects invalid payloads before UI opens with structured issues", async () => { - const tool = registerMockTool(); + const { tool } = registerMockTool(); const result = await tool.execute( "call-1", @@ -155,7 +179,7 @@ test("ask tool rejects invalid payloads before UI opens with structured issues", }); test("ask tool reports missing option values with structured issues", async () => { - const tool = registerMockTool(); + const { tool } = registerMockTool(); const result = await tool.execute( "call-1", @@ -188,7 +212,7 @@ test("ask tool reports missing option values with structured issues", async () = }); test("ask tool reports empty questions with structured issues", async () => { - const tool = registerMockTool(); + const { tool } = registerMockTool(); const result = await tool.execute( "call-1", @@ -215,7 +239,7 @@ test("ask tool reports empty questions with structured issues", async () => { }); test("ask tool reports invalid question types with structured issues", async () => { - const tool = registerMockTool(); + const { tool } = registerMockTool(); const result = await tool.execute( "call-1", @@ -250,7 +274,7 @@ test("ask tool reports invalid question types with structured issues", async () }); test("ask tool reports preview validation with structured issues", async () => { - const tool = registerMockTool(); + const { tool } = registerMockTool(); const result = await tool.execute( "call-1", @@ -285,7 +309,7 @@ test("ask tool reports preview validation with structured issues", async () => { }); test("ask tool transcript renderers summarize call and cancelled result", () => { - const tool = registerMockTool(); + const { tool } = registerMockTool(); const theme = { bold: (text: string) => text, fg: (_token: string, text: string) => text, diff --git a/tests/config-store.test.ts b/tests/config-store.test.ts index 4ed131b..6dab504 100644 --- a/tests/config-store.test.ts +++ b/tests/config-store.test.ts @@ -24,7 +24,8 @@ test("config store writes defaults when file is missing", async () => { assert.deepEqual(result.config, DEFAULT_ASK_CONFIG); assert.deepEqual(JSON.parse(await readFile(path, "utf-8")), { - schemaVersion: 1, + schemaVersion: 2, + answer: DEFAULT_ASK_CONFIG.answer, behaviour: DEFAULT_ASK_CONFIG.behaviour, keymaps: DEFAULT_ASK_CONFIG.keymaps, }); @@ -47,7 +48,8 @@ test("config store writes full normalized config on save", async () => { const content = await readFile(path, "utf-8"); assert.deepEqual(JSON.parse(content), { - schemaVersion: 1, + schemaVersion: 2, + answer: DEFAULT_ASK_CONFIG.answer, behaviour: { autoSubmitWhenAnsweredWithoutNotes: true, confirmDismissWhenDirty: true, @@ -143,7 +145,8 @@ test("config store migrates the legacy root config path into extensions", async assert.equal(result.config.behaviour.showFooterHints, false); await assert.rejects(readFile(legacyPath, "utf-8")); assert.deepEqual(JSON.parse(await readFile(path, "utf-8")), { - schemaVersion: 1, + schemaVersion: 2, + answer: DEFAULT_ASK_CONFIG.answer, behaviour: { autoSubmitWhenAnsweredWithoutNotes: true, confirmDismissWhenDirty: true, diff --git a/tests/editor-state.test.ts b/tests/editor-state.test.ts index 4824b76..8f39774 100644 --- a/tests/editor-state.test.ts +++ b/tests/editor-state.test.ts @@ -66,6 +66,91 @@ test("submitEditorDraft advances submitted custom answers", () => { assert.equal(next.view.kind, "submit"); }); +test("submitted answer can sync directly into next freeform input", () => { + let state = syncStateToSelection( + createInitialState( + { + questions: [ + { + id: "q1", + prompt: "First?", + options: [ + { value: "freeform", label: "Type answer", freeform: true }, + ], + }, + { + id: "q2", + prompt: "Second?", + options: [ + { value: "freeform", label: "Type answer", freeform: true }, + ], + }, + ], + }, + { allowFreeform: true } + ) + ); + + state = submitEditorDraft(state, "first answer"); + state = syncStateToSelection(state); + + assert.equal(state.activeTabIndex, 1); + assert.deepEqual(state.view, { kind: "input", questionId: "q2" }); +}); + +test("syncStateToSelection does not reopen answered freeform input", () => { + let state = syncStateToSelection( + createInitialState( + { + questions: [ + { + id: "q1", + prompt: "First?", + options: [ + { value: "freeform", label: "Type answer", freeform: true }, + ], + }, + ], + }, + { allowFreeform: true } + ) + ); + state = submitEditorDraft(state, "already answered"); + state = syncStateToSelection({ + ...state, + activeTabIndex: 0, + view: { kind: "navigate" }, + }); + + assert.equal(state.view.kind, "navigate"); + assert.equal(state.answers.q1.customText, "already answered"); +}); + +test("tab navigation can sync from review back into freeform input", () => { + let state = createInitialState( + { + questions: [ + { + id: "q1", + prompt: "First?", + options: [ + { value: "freeform", label: "Type answer", freeform: true }, + ], + }, + ], + }, + { allowFreeform: true } + ); + state = { ...state, activeTabIndex: 1, view: { kind: "submit" } }; + state = syncStateToSelection({ + ...state, + activeTabIndex: 0, + view: { kind: "navigate" }, + }); + + assert.deepEqual(state.view, { kind: "input", questionId: "q1" }); +}); + test("submitEditorDraft saves notes without completing the ask flow", () => { const state = enterQuestionNoteMode( createInitialState({ diff --git a/tests/render-question.test.ts b/tests/render-question.test.ts index 3609adf..1244fd6 100644 --- a/tests/render-question.test.ts +++ b/tests/render-question.test.ts @@ -189,6 +189,47 @@ test("selected multiline custom option renders full editor block", () => { assert(!lines.some((line) => line.includes("first line second line"))); }); +test("freeform-only question renders label without numbering or pointer and separates input", () => { + const state = createInitialState( + { + questions: [ + { + id: "q1", + prompt: "Type one", + options: [ + { value: "freeform", label: "Type answer", freeform: true }, + ], + }, + ], + }, + { allowFreeform: true } + ); + + const lines: string[] = []; + renderQuestionScreen({ + editor: mockEditor("", ["┌────┐", "", "└────┘"]), + lines, + options: getRenderableOptions(state.questions[0]), + question: state.questions[0], + state: { ...state, view: { kind: "input", questionId: "q1" } }, + theme: mockTheme(), + width: 80, + }); + + const labelIndex = lines.findIndex((line) => + line.includes("Type your answer:") + ); + const inputIndex = lines.findIndex((line) => line.includes("Type answer...")); + + assert.notEqual(labelIndex, -1); + assert.equal(inputIndex, labelIndex + 2); + assert(lines[labelIndex]?.startsWith(" ")); + assert(lines[inputIndex]?.startsWith(" ")); + assert(!lines[inputIndex]?.startsWith(" ")); + assert(!lines.some((line) => line.includes("1. Type your answer:"))); + assert(!lines.some((line) => line.includes("❯"))); +}); + test("preview questions also show the custom answer option", () => { const state = createInitialState({ questions: [ diff --git a/tests/settings-list.test.ts b/tests/settings-list.test.ts index ff2d172..d0236e0 100644 --- a/tests/settings-list.test.ts +++ b/tests/settings-list.test.ts @@ -1,10 +1,14 @@ import assert from "node:assert/strict"; import test from "node:test"; import { visibleWidth } from "@mariozechner/pi-tui"; +import { DEFAULT_ASK_CONFIG } from "../src/config/defaults.ts"; import type { AskConfig } from "../src/config/schema.ts"; import { AskSettingsList } from "../src/ui/settings-list.ts"; const savedConfig: AskConfig = { + answer: { + ...DEFAULT_ASK_CONFIG.answer, + }, behaviour: { autoSubmitWhenAnsweredWithoutNotes: false, confirmDismissWhenDirty: true, diff --git a/tests/state.test.ts b/tests/state.test.ts index e9315bb..7a55841 100644 --- a/tests/state.test.ts +++ b/tests/state.test.ts @@ -6,6 +6,9 @@ const DUPLICATE_QUESTION_ID_RE = /duplicate question id "scope"/; const DUPLICATE_OPTION_VALUE_RE = /duplicate option value "small"/; const PREVIEW_REQUIRED_RE = /preview questions require preview text for every option/; +const FREEFORM_UNSUPPORTED_RE = + /freeform options are only supported for \/answer forms/; +const FREEFORM_MIXED_RE = /freeform options must be the only option/; import { toAskResult } from "../src/state/result.ts"; import { getRenderableOptions } from "../src/state/selectors.ts"; @@ -76,6 +79,68 @@ test("normalize defaults via initial state", () => { ); }); +test("answer freeform questions render only the custom input option", () => { + const state = createInitialState( + { + questions: [ + { + id: "language", + prompt: "Which language do you want to learn?", + options: [ + { value: "freeform", label: "Type answer", freeform: true }, + ], + }, + ], + }, + { allowFreeform: true } + ); + + const options = getRenderableOptions(state.questions[0]); + assert.equal(options.length, 1); + assert.equal(options[0]?.label, "Type your answer:"); + assert.equal(options[0]?.isCustomOption, true); +}); + +test("freeform questions are rejected unless explicitly allowed", () => { + assert.throws( + () => + createInitialState({ + questions: [ + { + id: "language", + prompt: "Which language do you want to learn?", + options: [ + { value: "freeform", label: "Type answer", freeform: true }, + ], + }, + ], + }), + FREEFORM_UNSUPPORTED_RE + ); +}); + +test("freeform questions cannot mix normal options", () => { + assert.throws( + () => + createInitialState( + { + questions: [ + { + id: "language", + prompt: "Which language do you want to learn?", + options: [ + { value: "freeform", label: "Type answer", freeform: true }, + { value: "french", label: "French" }, + ], + }, + ], + }, + { allowFreeform: true } + ), + FREEFORM_MIXED_RE + ); +}); + test("preview questions keep their type and option previews", () => { const state = createInitialState({ questions: [