feat: add answer extraction replay commands

This commit is contained in:
Erik Kotsiuba
2026-05-02 22:47:55 +02:00
parent 288caf49de
commit 5b70eca306
36 changed files with 1636 additions and 98 deletions
+27 -3
View File
@@ -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
View File
@@ -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
+5 -1
View File
@@ -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
View File
@@ -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
+14
View File
@@ -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
View File
@@ -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": {
+3
View File
@@ -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)
+3 -2
View File
@@ -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.
+321
View File
@@ -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." };
}
+354
View File
@@ -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.`;
}
+86
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+4 -4
View File
@@ -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
+11 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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");
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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),
});
}
+4
View File
@@ -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
View File
@@ -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,
+23
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
});
}
+17 -3
View File
@@ -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 };
+126
View File
@@ -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.",
});
});
+95
View File
@@ -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);
});
+33 -9
View File
@@ -24,12 +24,18 @@ 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 {
return {
entries,
tool: tools[0] as {
execute: (...args: any[]) => Promise<any>;
renderCall: (args: unknown, theme: any) => { text: string };
renderResult: (
@@ -37,6 +43,7 @@ function registerMockTool() {
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,
+6 -3
View File
@@ -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,
+85
View File
@@ -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({
+41
View File
@@ -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: [
+4
View File
@@ -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,
+65
View File
@@ -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: [