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
+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 };