feat: add answer extraction replay commands
This commit is contained in:
@@ -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.
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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/`
|
||||
|
||||
+44
-3
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+4
-2
@@ -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": {
|
||||
|
||||
Generated
+3
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<ReturnType<typeof selectExtractionModel>>,
|
||||
{ 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<void> {
|
||||
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<ReturnType<ReturnType<typeof getAskConfigStore>["getConfig"]>>
|
||||
): Promise<AskParams | undefined> {
|
||||
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<ReturnType<ReturnType<typeof getAskConfigStore>["getConfig"]>>
|
||||
): Promise<ExtractionUiResult> {
|
||||
return ctx.ui.custom<ExtractionUiResult>((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<void> {
|
||||
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<ExtensionAPI, "sendUserMessage">,
|
||||
ctx: ExtensionContext,
|
||||
params: AskParams,
|
||||
options: { allowFreeform: boolean }
|
||||
): Promise<void> {
|
||||
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<ExtensionAPI, "sendUserMessage">,
|
||||
result: AskResult,
|
||||
ctx: Pick<ExtensionContext, "isIdle">
|
||||
): void {
|
||||
const response = successfulResponse(result);
|
||||
const text = response.content[0];
|
||||
pi.sendUserMessage(
|
||||
text.text,
|
||||
ctx.isIdle() ? undefined : { deliverAs: "followUp" }
|
||||
);
|
||||
}
|
||||
|
||||
function findLatestAssistantText(
|
||||
ctx: Pick<ExtensionContext, "sessionManager">
|
||||
): 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." };
|
||||
}
|
||||
@@ -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<string, string> };
|
||||
model: Model<Api>;
|
||||
usedFallback: boolean;
|
||||
}
|
||||
|
||||
export async function selectExtractionModel(
|
||||
ctx: Pick<ExtensionContext, "model" | "modelRegistry">,
|
||||
preferences: AskConfig["answer"]["extractionModels"]
|
||||
): Promise<SelectedExtractionModel | { error: string }> {
|
||||
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<Api>;
|
||||
auth: { apiKey?: string; headers?: Record<string, string> };
|
||||
retries: number;
|
||||
signal?: AbortSignal;
|
||||
timeoutMs: number;
|
||||
onRetry?: (attempt: number, maxRetries: number) => void;
|
||||
}): Promise<AskParams> {
|
||||
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<string, string> };
|
||||
lastError: string;
|
||||
lastResponse: string;
|
||||
model: Model<Api>;
|
||||
signal?: AbortSignal;
|
||||
timeoutMs: number;
|
||||
}): Promise<string> {
|
||||
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<T extends { label: string; value: string }>(
|
||||
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.`;
|
||||
}
|
||||
@@ -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<ExtensionAPI, "appendEntry">,
|
||||
data: Omit<AskPayloadEntryData, "timestamp" | "version">
|
||||
): void {
|
||||
pi.appendEntry(ASK_PAYLOAD_ENTRY_TYPE, {
|
||||
version: ASK_PAYLOAD_ENTRY_VERSION,
|
||||
timestamp: Date.now(),
|
||||
...data,
|
||||
});
|
||||
}
|
||||
|
||||
export function findLatestPayloadInCurrentBranch(
|
||||
ctx: Pick<ExtensionContext, "sessionManager">,
|
||||
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<AskPayloadEntryData>;
|
||||
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<AskPayloadEntryData>;
|
||||
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;
|
||||
}
|
||||
+12
-6
@@ -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<typeof createInitialState> }
|
||||
| { 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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+18
-3
@@ -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<ExtensionContext, "cwd" | "hasUI"> {}
|
||||
interface ExecuteContext extends ExtensionContext {}
|
||||
|
||||
async function executeAskTool(
|
||||
_toolCallId: string,
|
||||
pi: Pick<ExtensionAPI, "appendEntry">,
|
||||
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);
|
||||
}
|
||||
|
||||
+69
-4
@@ -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<AskConfigFileV1> | AskConfig
|
||||
config?: Partial<AskConfigFileV2> | 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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
+36
-6
@@ -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<typeof AskConfigFileV1Schema>;
|
||||
export type AskConfigFileV2 = Static<typeof AskConfigFileV2Schema>;
|
||||
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);
|
||||
|
||||
+3
-3
@@ -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<AskConfig> {
|
||||
async save(config: AskConfig | Partial<AskConfig>): Promise<AskConfig> {
|
||||
const normalized = normalizeAskConfig(config);
|
||||
const content = JSON.stringify(
|
||||
toAskConfigFileV1(normalized),
|
||||
toAskConfigFileV2(normalized),
|
||||
null,
|
||||
2
|
||||
).concat("\n");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+3
-2
@@ -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",
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
+9
-2
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+52
-7
@@ -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<string>();
|
||||
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<string>,
|
||||
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<string>();
|
||||
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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
+23
-6
@@ -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<ExtensionContext, "cwd"> & { config: AskConfig; ctx: ExtensionContext };
|
||||
Pick<ExtensionContext, "cwd"> & {
|
||||
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<AskResult> {
|
||||
const store = getAskConfigStore();
|
||||
const config = await store.getConfig();
|
||||
return ctx.ui.custom<AskResult>((...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);
|
||||
}
|
||||
|
||||
+24
-19
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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.",
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
+40
-16
@@ -24,19 +24,26 @@ const noop = () => {
|
||||
|
||||
function registerMockTool() {
|
||||
const tools: Record<string, unknown>[] = [];
|
||||
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<string, unknown>);
|
||||
},
|
||||
} as never);
|
||||
return tools[0] as {
|
||||
execute: (...args: any[]) => Promise<any>;
|
||||
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<any>;
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user