feat: add answer extraction replay commands
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
import {
|
||||
BorderedLoader,
|
||||
type ExtensionAPI,
|
||||
type ExtensionCommandContext,
|
||||
type ExtensionContext,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import {
|
||||
extractAskParams,
|
||||
selectExtractionModel,
|
||||
} from "./answer-extraction.ts";
|
||||
import {
|
||||
type AskPayloadSource,
|
||||
appendAskPayload,
|
||||
findLatestPayloadInCurrentBranch,
|
||||
} from "./ask-payload-store.ts";
|
||||
import {
|
||||
invalidPayloadResponse,
|
||||
successfulResponse,
|
||||
validateParams,
|
||||
} from "./ask-tool-helpers.ts";
|
||||
import { getAskConfigStore } from "./config/store.ts";
|
||||
import type { AskParams, AskResult } from "./types.ts";
|
||||
import { runAskFlow } from "./ui/controller.ts";
|
||||
|
||||
interface AssistantTextSource {
|
||||
entryId: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
type ExtractionSelection = Exclude<
|
||||
Awaited<ReturnType<typeof selectExtractionModel>>,
|
||||
{ error: string }
|
||||
>;
|
||||
|
||||
type ExtractionUiResult =
|
||||
| { cancelled: true }
|
||||
| { error: string }
|
||||
| { params: AskParams };
|
||||
|
||||
export function registerAnswerCommands(pi: ExtensionAPI): void {
|
||||
pi.registerCommand("answer", {
|
||||
description:
|
||||
"Extract questions from the latest assistant message into an ask form",
|
||||
handler: async (_args, ctx) => runAnswerCommand(pi, ctx),
|
||||
});
|
||||
|
||||
pi.registerCommand("answer:again", {
|
||||
description: "Reopen the previous /answer form on this branch",
|
||||
handler: async (_args, ctx) =>
|
||||
runReplayCommand(pi, ctx, {
|
||||
missingMessage:
|
||||
"No previous /answer form found on this branch; use /answer first.",
|
||||
noticePrefix: "Reopening previous /answer form on this branch",
|
||||
source: "answer-extraction",
|
||||
}),
|
||||
});
|
||||
|
||||
pi.registerCommand("ask:replay", {
|
||||
description: "Replay the previous ask_user form on this branch",
|
||||
handler: async (_args, ctx) =>
|
||||
runReplayCommand(pi, ctx, {
|
||||
missingMessage: "No previous ask_user form found on this branch.",
|
||||
noticePrefix: "Replaying previous ask_user form on this branch",
|
||||
source: "tool",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async function runAnswerCommand(
|
||||
pi: ExtensionAPI,
|
||||
ctx: ExtensionCommandContext
|
||||
): Promise<void> {
|
||||
if (!ctx.hasUI) {
|
||||
ctx.ui.notify("/answer requires interactive UI.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const assistant = findLatestAssistantText(ctx);
|
||||
if ("error" in assistant) {
|
||||
ctx.ui.notify(assistant.error, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const config = await getAskConfigStore().getConfig();
|
||||
const selected = await selectExtractionModel(
|
||||
ctx,
|
||||
config.answer.extractionModels
|
||||
);
|
||||
if ("error" in selected) {
|
||||
ctx.ui.notify(selected.error, "error");
|
||||
return;
|
||||
}
|
||||
if (selected.usedFallback) {
|
||||
ctx.ui.notify(
|
||||
`Configured extraction models unavailable; using current chat model ${selected.model.provider}/${selected.model.id}.`,
|
||||
"info"
|
||||
);
|
||||
}
|
||||
|
||||
const params = await extractAndValidateAnswerParams(
|
||||
ctx,
|
||||
assistant,
|
||||
selected,
|
||||
config
|
||||
);
|
||||
if (!params) {
|
||||
return;
|
||||
}
|
||||
|
||||
appendAskPayload(pi, {
|
||||
params,
|
||||
source: "answer-extraction",
|
||||
sourceEntryId: assistant.entryId,
|
||||
});
|
||||
|
||||
await runAskAndSendSubmittedResult(pi, ctx, params, {
|
||||
allowFreeform: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function extractAndValidateAnswerParams(
|
||||
ctx: ExtensionCommandContext,
|
||||
assistant: AssistantTextSource,
|
||||
selected: ExtractionSelection,
|
||||
config: Awaited<ReturnType<ReturnType<typeof getAskConfigStore>["getConfig"]>>
|
||||
): Promise<AskParams | undefined> {
|
||||
const extraction = await runExtractionUi(ctx, assistant, selected, config);
|
||||
if ("cancelled" in extraction) {
|
||||
ctx.ui.notify("Question extraction cancelled.", "info");
|
||||
return;
|
||||
}
|
||||
if ("error" in extraction) {
|
||||
ctx.ui.notify(formatExtractionError(extraction.error), "error");
|
||||
return;
|
||||
}
|
||||
if (!isAskParamsCandidate(extraction.params)) {
|
||||
ctx.ui.notify("Question extraction returned an invalid ask form.", "error");
|
||||
return;
|
||||
}
|
||||
if (extraction.params.questions.length === 0) {
|
||||
ctx.ui.notify(
|
||||
"No questions found in the latest assistant message.",
|
||||
"info"
|
||||
);
|
||||
return;
|
||||
}
|
||||
const validation = validateParams(extraction.params, { allowFreeform: true });
|
||||
if (!validation.ok) {
|
||||
const response = invalidPayloadResponse(
|
||||
extraction.params,
|
||||
validation.issues
|
||||
);
|
||||
ctx.ui.notify(response.content[0].text, "error");
|
||||
return;
|
||||
}
|
||||
return extraction.params;
|
||||
}
|
||||
|
||||
function runExtractionUi(
|
||||
ctx: ExtensionCommandContext,
|
||||
assistant: AssistantTextSource,
|
||||
selected: ExtractionSelection,
|
||||
config: Awaited<ReturnType<ReturnType<typeof getAskConfigStore>["getConfig"]>>
|
||||
): Promise<ExtractionUiResult> {
|
||||
return ctx.ui.custom<ExtractionUiResult>((tui, theme, _keybindings, done) => {
|
||||
let completed = false;
|
||||
const doneOnce = (result: ExtractionUiResult) => {
|
||||
if (completed) {
|
||||
return;
|
||||
}
|
||||
completed = true;
|
||||
done(result);
|
||||
};
|
||||
const loader = new BorderedLoader(
|
||||
tui,
|
||||
theme,
|
||||
`Extracting questions using ${selected.model.provider}/${selected.model.id}...`
|
||||
);
|
||||
loader.onAbort = () => doneOnce({ cancelled: true });
|
||||
extractAskParams({
|
||||
assistantText: assistant.text,
|
||||
auth: selected.auth,
|
||||
model: selected.model,
|
||||
onRetry: (attempt, maxRetries) => {
|
||||
ctx.ui.notify(
|
||||
`Retrying extraction JSON repair (${attempt}/${maxRetries})...`,
|
||||
"info"
|
||||
);
|
||||
},
|
||||
retries: config.answer.extractionRetries,
|
||||
signal: loader.signal,
|
||||
timeoutMs: config.answer.extractionTimeoutMs,
|
||||
})
|
||||
.then((params) => doneOnce({ params }))
|
||||
.catch((error) => doneOnce({ error: formatErrorMessage(error) }));
|
||||
return loader;
|
||||
});
|
||||
}
|
||||
|
||||
function formatErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.name ? `${error.name}: ${error.message}` : error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function isAskParamsCandidate(value: unknown): value is AskParams {
|
||||
return (
|
||||
!!value &&
|
||||
typeof value === "object" &&
|
||||
Array.isArray((value as { questions?: unknown }).questions)
|
||||
);
|
||||
}
|
||||
|
||||
function formatExtractionError(error: string): string {
|
||||
if (error.includes("valid JSON")) {
|
||||
return error;
|
||||
}
|
||||
if (error.includes("cancelled")) {
|
||||
return "Question extraction cancelled.";
|
||||
}
|
||||
if (
|
||||
error.includes("aborted") ||
|
||||
error.includes("AbortError") ||
|
||||
error.includes("timed out")
|
||||
) {
|
||||
return "Question extraction timed out. Try again or configure a faster extraction model.";
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
async function runReplayCommand(
|
||||
pi: ExtensionAPI,
|
||||
ctx: ExtensionCommandContext,
|
||||
options: {
|
||||
missingMessage: string;
|
||||
noticePrefix: string;
|
||||
source: AskPayloadSource;
|
||||
}
|
||||
): Promise<void> {
|
||||
if (!ctx.hasUI) {
|
||||
ctx.ui.notify("Ask replay requires interactive UI.", "error");
|
||||
return;
|
||||
}
|
||||
const lookup = findLatestPayloadInCurrentBranch(ctx, options.source);
|
||||
if (!lookup.data) {
|
||||
ctx.ui.notify(
|
||||
lookup.invalidMatchFound
|
||||
? "Previous form exists on this branch but is no longer compatible."
|
||||
: options.missingMessage,
|
||||
"info"
|
||||
);
|
||||
return;
|
||||
}
|
||||
ctx.ui.notify(
|
||||
`${options.noticePrefix}: ${lookup.data.params.questions.length} question(s).`,
|
||||
"info"
|
||||
);
|
||||
await runAskAndSendSubmittedResult(pi, ctx, lookup.data.params, {
|
||||
allowFreeform: options.source === "answer-extraction",
|
||||
});
|
||||
}
|
||||
|
||||
async function runAskAndSendSubmittedResult(
|
||||
pi: Pick<ExtensionAPI, "sendUserMessage">,
|
||||
ctx: ExtensionContext,
|
||||
params: AskParams,
|
||||
options: { allowFreeform: boolean }
|
||||
): Promise<void> {
|
||||
const result = await runAskFlow(ctx, params, options);
|
||||
if (result.cancelled) {
|
||||
ctx.ui.notify("Ask form cancelled.", "info");
|
||||
return;
|
||||
}
|
||||
sendAskResult(pi, result, ctx);
|
||||
}
|
||||
|
||||
function sendAskResult(
|
||||
pi: Pick<ExtensionAPI, "sendUserMessage">,
|
||||
result: AskResult,
|
||||
ctx: Pick<ExtensionContext, "isIdle">
|
||||
): void {
|
||||
const response = successfulResponse(result);
|
||||
const text = response.content[0];
|
||||
pi.sendUserMessage(
|
||||
text.text,
|
||||
ctx.isIdle() ? undefined : { deliverAs: "followUp" }
|
||||
);
|
||||
}
|
||||
|
||||
function findLatestAssistantText(
|
||||
ctx: Pick<ExtensionContext, "sessionManager">
|
||||
): AssistantTextSource | { error: string } {
|
||||
const branch = ctx.sessionManager.getBranch();
|
||||
for (let index = branch.length - 1; index >= 0; index--) {
|
||||
const entry = branch[index];
|
||||
if (entry.type !== "message") {
|
||||
continue;
|
||||
}
|
||||
const message = entry.message;
|
||||
if (!("role" in message) || message.role !== "assistant") {
|
||||
continue;
|
||||
}
|
||||
if (message.stopReason !== "stop") {
|
||||
return {
|
||||
error: `Latest assistant message is incomplete (${message.stopReason}); wait for it to finish, then run /answer again.`,
|
||||
};
|
||||
}
|
||||
const text = message.content
|
||||
.filter(
|
||||
(part): part is { text: string; type: "text" } => part.type === "text"
|
||||
)
|
||||
.map((part) => part.text)
|
||||
.join("\n")
|
||||
.trim();
|
||||
if (text) {
|
||||
return { entryId: entry.id, text };
|
||||
}
|
||||
}
|
||||
return { error: "No assistant message found to extract questions from." };
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
import {
|
||||
type Api,
|
||||
complete,
|
||||
type Model,
|
||||
type UserMessage,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||
import type { AskConfig } from "./config/schema.ts";
|
||||
import type { AskParams } from "./types.ts";
|
||||
|
||||
export const ANSWER_EXTRACTION_SYSTEM_PROMPT = `You extract user-input questions from an assistant message and return ONLY raw JSON.
|
||||
|
||||
Return JSON matching this TypeScript shape:
|
||||
{
|
||||
"title"?: string,
|
||||
"questions": [
|
||||
{
|
||||
"id": string,
|
||||
"label"?: string,
|
||||
"prompt": string,
|
||||
"type"?: "single" | "multi" | "preview",
|
||||
"required"?: boolean,
|
||||
"options": [
|
||||
{
|
||||
"value": string,
|
||||
"label": string,
|
||||
"description"?: string,
|
||||
"preview"?: string,
|
||||
"freeform"?: boolean
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Output raw JSON only. No Markdown. No prose. No code fences.
|
||||
- Extract questions that require user input.
|
||||
- Ignore generic conversational or clarification prompts such as "How can I help?", "Could you clarify?", or "Let me know what you need" unless they include concrete choices.
|
||||
- Extract a question when it has explicit choices or when the user should type their own answer.
|
||||
- Preserve question order.
|
||||
- Generate stable snake_case ids.
|
||||
- Choose question type from the question semantics.
|
||||
- Use type "single" when one answer is expected.
|
||||
- Use type "multi" when multiple answers could reasonably be selected.
|
||||
- Use type "preview" when options need preview-pane detail and every option has non-empty preview text.
|
||||
- Avoid defaulting mechanically; infer from whether the options are mutually exclusive, can coexist, or need preview-pane detail.
|
||||
- Each extracted question must have at least one option.
|
||||
- Options rule:
|
||||
- Extract options only from choices explicitly offered by the assistant.
|
||||
- If the assistant gives concrete choices, use those choices as normal options.
|
||||
- If the assistant gives examples only, do not treat examples as choices unless they are presented as selectable answers.
|
||||
- If no concrete choices are given and the user should type their own answer, create exactly one freeform option: {"value":"freeform","label":"Type answer","freeform":true}.
|
||||
- Never invent options.
|
||||
- Never mix a freeform option with normal options.
|
||||
- Provide clear, distinct options. Do not add filler options.
|
||||
- Do not create an option that merely restates the question.
|
||||
- Include descriptions only when helpful.
|
||||
- Return {"questions":[]} if no questions are found.`;
|
||||
|
||||
interface SelectedExtractionModel {
|
||||
auth: { apiKey?: string; headers?: Record<string, string> };
|
||||
model: Model<Api>;
|
||||
usedFallback: boolean;
|
||||
}
|
||||
|
||||
export async function selectExtractionModel(
|
||||
ctx: Pick<ExtensionContext, "model" | "modelRegistry">,
|
||||
preferences: AskConfig["answer"]["extractionModels"]
|
||||
): Promise<SelectedExtractionModel | { error: string }> {
|
||||
for (const preference of preferences) {
|
||||
const model = ctx.modelRegistry.find(preference.provider, preference.id);
|
||||
if (!model) {
|
||||
continue;
|
||||
}
|
||||
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
||||
if (auth.ok) {
|
||||
return { model, auth, usedFallback: false };
|
||||
}
|
||||
}
|
||||
|
||||
if (!ctx.model) {
|
||||
return {
|
||||
error:
|
||||
"No available extraction model. Configure answer.extractionModels or select a chat model.",
|
||||
};
|
||||
}
|
||||
|
||||
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model);
|
||||
if (!auth.ok) {
|
||||
return {
|
||||
error: `No auth for fallback chat model: ${ctx.model.provider}/${ctx.model.id}.`,
|
||||
};
|
||||
}
|
||||
|
||||
return { model: ctx.model, auth, usedFallback: true };
|
||||
}
|
||||
|
||||
export async function extractAskParams(options: {
|
||||
assistantText: string;
|
||||
model: Model<Api>;
|
||||
auth: { apiKey?: string; headers?: Record<string, string> };
|
||||
retries: number;
|
||||
signal?: AbortSignal;
|
||||
timeoutMs: number;
|
||||
onRetry?: (attempt: number, maxRetries: number) => void;
|
||||
}): Promise<AskParams> {
|
||||
let lastCandidate: AskParams | undefined;
|
||||
let lastResponse = "";
|
||||
let lastError = "";
|
||||
for (let attempt = 0; attempt <= options.retries; attempt++) {
|
||||
if (attempt > 0) {
|
||||
options.onRetry?.(attempt, options.retries);
|
||||
}
|
||||
const responseText = await runExtractionAttempt({
|
||||
...options,
|
||||
attempt,
|
||||
lastError,
|
||||
lastResponse,
|
||||
});
|
||||
lastResponse = responseText;
|
||||
const parsed = parseExtractionCandidate(responseText);
|
||||
if (!parsed.ok) {
|
||||
lastError = parsed.error;
|
||||
continue;
|
||||
}
|
||||
lastCandidate = parsed.params;
|
||||
if (parsed.issues.length === 0) {
|
||||
return parsed.params;
|
||||
}
|
||||
lastError = parsed.issues.join("\n");
|
||||
}
|
||||
if (lastCandidate) {
|
||||
return repairExtractionParams(lastCandidate);
|
||||
}
|
||||
throw new Error(
|
||||
"Question extraction did not return valid JSON after retries."
|
||||
);
|
||||
}
|
||||
|
||||
async function runExtractionAttempt(options: {
|
||||
assistantText: string;
|
||||
attempt: number;
|
||||
auth: { apiKey?: string; headers?: Record<string, string> };
|
||||
lastError: string;
|
||||
lastResponse: string;
|
||||
model: Model<Api>;
|
||||
signal?: AbortSignal;
|
||||
timeoutMs: number;
|
||||
}): Promise<string> {
|
||||
const controller = new AbortController();
|
||||
let timedOut = false;
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort();
|
||||
}, options.timeoutMs);
|
||||
const abortFromParent = () => controller.abort();
|
||||
options.signal?.addEventListener("abort", abortFromParent, { once: true });
|
||||
try {
|
||||
const userMessage: UserMessage = {
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
options.attempt === 0
|
||||
? options.assistantText
|
||||
: formatRetryPrompt(options),
|
||||
},
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
const response = await complete(
|
||||
options.model,
|
||||
{
|
||||
systemPrompt: ANSWER_EXTRACTION_SYSTEM_PROMPT,
|
||||
messages: [userMessage],
|
||||
},
|
||||
{
|
||||
apiKey: options.auth.apiKey,
|
||||
headers: options.auth.headers,
|
||||
signal: controller.signal,
|
||||
}
|
||||
);
|
||||
if (response.stopReason === "aborted") {
|
||||
throw new Error(
|
||||
timedOut
|
||||
? "Question extraction timed out. Try again or configure a faster extraction model."
|
||||
: "Question extraction cancelled."
|
||||
);
|
||||
}
|
||||
return response.content
|
||||
.filter(
|
||||
(part): part is { text: string; type: "text" } => part.type === "text"
|
||||
)
|
||||
.map((part) => part.text)
|
||||
.join("\n");
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
options.signal?.removeEventListener("abort", abortFromParent);
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_EXTRACTED_OPTIONS_PER_QUESTION = 4;
|
||||
|
||||
function parseExtractionCandidate(
|
||||
responseText: string
|
||||
):
|
||||
| { ok: true; params: AskParams; issues: string[] }
|
||||
| { ok: false; error: string } {
|
||||
try {
|
||||
const parsed = JSON.parse(responseText.trim()) as unknown;
|
||||
if (!isAskParamsLike(parsed)) {
|
||||
return { ok: false, error: "root.questions must be an array" };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
params: parsed,
|
||||
issues: collectExtractionBusinessIssues(parsed),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function isAskParamsLike(value: unknown): value is AskParams {
|
||||
return (
|
||||
!!value &&
|
||||
typeof value === "object" &&
|
||||
Array.isArray((value as { questions?: unknown }).questions)
|
||||
);
|
||||
}
|
||||
|
||||
export function collectExtractionBusinessIssues(params: AskParams): string[] {
|
||||
const issues: string[] = [];
|
||||
params.questions.forEach((question, questionIndex) => {
|
||||
if (isGenericConversationalPrompt(question.prompt)) {
|
||||
issues.push(
|
||||
`questions[${questionIndex}].prompt is generic conversational text; omit this question unless it includes concrete choices`
|
||||
);
|
||||
}
|
||||
if (question.options.length > MAX_EXTRACTED_OPTIONS_PER_QUESTION) {
|
||||
issues.push(
|
||||
`questions[${questionIndex}].options has ${question.options.length} items; max is ${MAX_EXTRACTED_OPTIONS_PER_QUESTION}`
|
||||
);
|
||||
}
|
||||
if (
|
||||
question.options.length === 1 &&
|
||||
optionRestatesQuestion(question.options[0]?.label, question.prompt)
|
||||
) {
|
||||
issues.push(
|
||||
`questions[${questionIndex}].options[0] merely restates the question; omit this question or provide meaningful distinct options`
|
||||
);
|
||||
}
|
||||
});
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function repairExtractionParams(params: AskParams): AskParams {
|
||||
return {
|
||||
...params,
|
||||
questions: params.questions
|
||||
.filter((question) => !isGenericConversationalPrompt(question.prompt))
|
||||
.filter(
|
||||
(question) =>
|
||||
question.options.length !== 1 ||
|
||||
!optionRestatesQuestion(question.options[0]?.label, question.prompt)
|
||||
)
|
||||
.map((question) => ({
|
||||
...question,
|
||||
options: capOptionsPreservingOther(question.options),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function capOptionsPreservingOther<T extends { label: string; value: string }>(
|
||||
options: T[]
|
||||
): T[] {
|
||||
if (options.length <= MAX_EXTRACTED_OPTIONS_PER_QUESTION) {
|
||||
return options;
|
||||
}
|
||||
const other = options.find((option) => isOtherOption(option));
|
||||
if (!other) {
|
||||
return options.slice(0, MAX_EXTRACTED_OPTIONS_PER_QUESTION);
|
||||
}
|
||||
const head = options
|
||||
.filter((option) => option !== other)
|
||||
.slice(0, MAX_EXTRACTED_OPTIONS_PER_QUESTION - 1);
|
||||
return [...head, other];
|
||||
}
|
||||
|
||||
function isOtherOption(option: { label: string; value: string }): boolean {
|
||||
const label = normalizeText(option.label);
|
||||
const value = normalizeText(option.value);
|
||||
return (
|
||||
label === "other" || label.includes("something else") || value === "other"
|
||||
);
|
||||
}
|
||||
|
||||
function optionRestatesQuestion(
|
||||
label: string | undefined,
|
||||
prompt: string
|
||||
): boolean {
|
||||
if (!label) {
|
||||
return false;
|
||||
}
|
||||
const normalizedLabel = normalizeText(label);
|
||||
const normalizedPrompt = normalizeText(prompt);
|
||||
return (
|
||||
normalizedLabel.length > 8 &&
|
||||
(normalizedPrompt.includes(normalizedLabel) ||
|
||||
normalizedLabel.includes(normalizedPrompt))
|
||||
);
|
||||
}
|
||||
|
||||
function isGenericConversationalPrompt(prompt: string): boolean {
|
||||
const normalized = normalizeText(prompt);
|
||||
return [
|
||||
"how can i help",
|
||||
"could you clarify",
|
||||
"let me know what you need",
|
||||
"what else is on your mind",
|
||||
"anything on your mind i can help with",
|
||||
].some((phrase) => normalized.includes(phrase));
|
||||
}
|
||||
|
||||
function normalizeText(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function formatRetryPrompt(options: {
|
||||
assistantText: string;
|
||||
lastError: string;
|
||||
lastResponse: string;
|
||||
}): string {
|
||||
return `Your previous response was not valid raw JSON.
|
||||
|
||||
JSON.parse error:
|
||||
${options.lastError}
|
||||
|
||||
Previous response:
|
||||
${options.lastResponse}
|
||||
|
||||
Original assistant message:
|
||||
${options.assistantText}
|
||||
|
||||
Return ONLY valid raw JSON matching the schema and fix the reported issues. No Markdown. No prose.`;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type {
|
||||
ExtensionAPI,
|
||||
ExtensionContext,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import { validateParams } from "./ask-tool-helpers.ts";
|
||||
import type { AskParams } from "./types.ts";
|
||||
|
||||
export const ASK_PAYLOAD_ENTRY_TYPE = "ask:payload";
|
||||
export const ASK_PAYLOAD_ENTRY_VERSION = 1;
|
||||
|
||||
export type AskPayloadSource = "answer-extraction" | "tool";
|
||||
|
||||
export interface AskPayloadEntryData {
|
||||
params: AskParams;
|
||||
source: AskPayloadSource;
|
||||
sourceEntryId?: string;
|
||||
timestamp: number;
|
||||
version: typeof ASK_PAYLOAD_ENTRY_VERSION;
|
||||
}
|
||||
|
||||
export function appendAskPayload(
|
||||
pi: Pick<ExtensionAPI, "appendEntry">,
|
||||
data: Omit<AskPayloadEntryData, "timestamp" | "version">
|
||||
): void {
|
||||
pi.appendEntry(ASK_PAYLOAD_ENTRY_TYPE, {
|
||||
version: ASK_PAYLOAD_ENTRY_VERSION,
|
||||
timestamp: Date.now(),
|
||||
...data,
|
||||
});
|
||||
}
|
||||
|
||||
export function findLatestPayloadInCurrentBranch(
|
||||
ctx: Pick<ExtensionContext, "sessionManager">,
|
||||
source: AskPayloadSource
|
||||
): { data?: AskPayloadEntryData; invalidMatchFound: boolean } {
|
||||
let invalidMatchFound = false;
|
||||
for (const entry of [...ctx.sessionManager.getBranch()].reverse()) {
|
||||
if (!isAskPayloadEntry(entry)) {
|
||||
continue;
|
||||
}
|
||||
const data = entry.data;
|
||||
if (data?.source !== source) {
|
||||
continue;
|
||||
}
|
||||
if (isValidAskPayloadData(data)) {
|
||||
return { data, invalidMatchFound };
|
||||
}
|
||||
invalidMatchFound = true;
|
||||
}
|
||||
return { invalidMatchFound };
|
||||
}
|
||||
|
||||
function isAskPayloadEntry(entry: unknown): entry is {
|
||||
customType: string;
|
||||
data?: Partial<AskPayloadEntryData>;
|
||||
type: "custom";
|
||||
} {
|
||||
return (
|
||||
!!entry &&
|
||||
typeof entry === "object" &&
|
||||
(entry as { type?: unknown }).type === "custom" &&
|
||||
(entry as { customType?: unknown }).customType === ASK_PAYLOAD_ENTRY_TYPE
|
||||
);
|
||||
}
|
||||
|
||||
function isValidAskPayloadData(data: unknown): data is AskPayloadEntryData {
|
||||
if (!(data && typeof data === "object")) {
|
||||
return false;
|
||||
}
|
||||
const payload = data as Partial<AskPayloadEntryData>;
|
||||
if (payload.version !== ASK_PAYLOAD_ENTRY_VERSION) {
|
||||
return false;
|
||||
}
|
||||
if (payload.source !== "tool" && payload.source !== "answer-extraction") {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!payload.params ||
|
||||
validateParams(payload.params, {
|
||||
allowFreeform: payload.source === "answer-extraction",
|
||||
}).ok === false
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
+12
-6
@@ -18,28 +18,34 @@ export const ASK_TOOL_DESCRIPTION =
|
||||
export const ASK_TOOL_PROMPT_GUIDELINES = [
|
||||
"Use this tool before making preference-sensitive decisions about scope, tone, UX, naming, architecture, docs, or implementation direction.",
|
||||
"When multiple valid directions exist, ask 1-3 concise questions instead of committing to one path on your own.",
|
||||
"Prefer one focused decision per question and use short labels with 2-4 clear options.",
|
||||
"Prefer one focused decision per question. Use short labels. Provide clear, distinct options. Do not add filler options.",
|
||||
"Always include a non-empty `value` for every option.",
|
||||
'Use `type: "single"` by default and `type: "multi"` only when several answers can genuinely apply.',
|
||||
'Use `type: "preview"` only when every option includes non-empty `preview` text for the dedicated preview pane. Option descriptions do not satisfy this requirement.',
|
||||
"Choose question `type` from the question semantics: `single` means one answer is expected, `multi` means multiple answers could reasonably be selected, and `preview` means options need preview-pane detail and every option includes non-empty `preview` text.",
|
||||
"Avoid defaulting mechanically; infer from whether the options are mutually exclusive, can coexist, or need preview-pane detail.",
|
||||
'Use `type: "preview"` only when every option includes non-empty `preview` text. Option descriptions do not satisfy this requirement.',
|
||||
"After clarifying a note or follow-up question, prefer another structured ask_user follow-up if a choice is still needed instead of switching to plain-text multiple choice in chat.",
|
||||
"When prior answers already narrow the branch, bundle the next 2-3 related unresolved decisions into one follow-up ask instead of issuing a long sequence of single-question asks.",
|
||||
"Use one-at-a-time follow-up asks only when the next question materially depends on the previous answer.",
|
||||
] as const;
|
||||
|
||||
interface ValidateParamsOptions {
|
||||
allowFreeform?: boolean;
|
||||
}
|
||||
|
||||
export function validateParams(
|
||||
params: AskParams
|
||||
params: AskParams,
|
||||
options: ValidateParamsOptions = {}
|
||||
):
|
||||
| { ok: true; state: ReturnType<typeof createInitialState> }
|
||||
| { ok: false; issues: AskValidationIssue[] } {
|
||||
const issues = collectValidationIssues(params);
|
||||
const issues = collectValidationIssues(params, options);
|
||||
if (issues.length > 0) {
|
||||
return { ok: false, issues };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
state: createInitialState(params),
|
||||
state: createInitialState(params, options),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+18
-3
@@ -2,6 +2,7 @@ import type {
|
||||
ExtensionAPI,
|
||||
ExtensionContext,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import { appendAskPayload } from "./ask-payload-store.ts";
|
||||
import {
|
||||
ASK_TOOL_DESCRIPTION,
|
||||
ASK_TOOL_PROMPT_GUIDELINES,
|
||||
@@ -25,16 +26,25 @@ export function registerAskTool(pi: ExtensionAPI) {
|
||||
"Clarify ambiguous or preference-sensitive decisions with a short interactive interview before proceeding",
|
||||
promptGuidelines: [...ASK_TOOL_PROMPT_GUIDELINES],
|
||||
parameters: AskParamsSchema,
|
||||
execute: executeAskTool,
|
||||
execute: (toolCallId, params, signal, onUpdate, ctx) =>
|
||||
executeAskTool(
|
||||
pi,
|
||||
toolCallId,
|
||||
params as AskParams,
|
||||
signal,
|
||||
onUpdate,
|
||||
ctx
|
||||
),
|
||||
renderCall: renderAskToolCall,
|
||||
renderResult: renderAskToolResult,
|
||||
});
|
||||
}
|
||||
|
||||
interface ExecuteContext extends Pick<ExtensionContext, "cwd" | "hasUI"> {}
|
||||
interface ExecuteContext extends ExtensionContext {}
|
||||
|
||||
async function executeAskTool(
|
||||
_toolCallId: string,
|
||||
pi: Pick<ExtensionAPI, "appendEntry">,
|
||||
toolCallId: string,
|
||||
params: AskParams,
|
||||
_signal: AbortSignal | undefined,
|
||||
_onUpdate: unknown,
|
||||
@@ -44,6 +54,11 @@ async function executeAskTool(
|
||||
if (!validation.ok) {
|
||||
return invalidPayloadResponse(params, validation.issues);
|
||||
}
|
||||
appendAskPayload(pi, {
|
||||
params,
|
||||
source: "tool",
|
||||
sourceEntryId: toolCallId,
|
||||
});
|
||||
if (!ctx.hasUI) {
|
||||
return nonInteractiveResponse(validation.state);
|
||||
}
|
||||
|
||||
+69
-4
@@ -1,7 +1,20 @@
|
||||
import { DEFAULT_ASK_KEYMAPS } from "../constants/keymaps.ts";
|
||||
import type { AskConfig, AskConfigFileV1 } from "./schema.ts";
|
||||
import type { AskConfig, AskConfigFileV2 } from "./schema.ts";
|
||||
|
||||
const DEFAULT_EXTRACTION_RETRIES = 1;
|
||||
const MAX_EXTRACTION_RETRIES = 3;
|
||||
const DEFAULT_EXTRACTION_TIMEOUT_MS = 30_000;
|
||||
|
||||
export const DEFAULT_ASK_CONFIG: AskConfig = {
|
||||
answer: {
|
||||
extractionModels: [
|
||||
{ provider: "openai-codex", id: "gpt-5.4-mini" },
|
||||
{ provider: "github-copilot", id: "gpt-5.4-mini" },
|
||||
{ provider: "anthropic", id: "claude-haiku-4-5" },
|
||||
],
|
||||
extractionRetries: DEFAULT_EXTRACTION_RETRIES,
|
||||
extractionTimeoutMs: DEFAULT_EXTRACTION_TIMEOUT_MS,
|
||||
},
|
||||
behaviour: {
|
||||
autoSubmitWhenAnsweredWithoutNotes: false,
|
||||
confirmDismissWhenDirty: true,
|
||||
@@ -14,9 +27,24 @@ export const DEFAULT_ASK_CONFIG: AskConfig = {
|
||||
};
|
||||
|
||||
export function normalizeAskConfig(
|
||||
config?: Partial<AskConfigFileV1> | AskConfig
|
||||
config?: Partial<AskConfigFileV2> | AskConfig
|
||||
): AskConfig {
|
||||
return {
|
||||
answer: {
|
||||
extractionModels:
|
||||
config?.answer?.extractionModels?.filter(isValidModelPreference) ??
|
||||
DEFAULT_ASK_CONFIG.answer.extractionModels,
|
||||
extractionRetries: clampInteger(
|
||||
config?.answer?.extractionRetries,
|
||||
0,
|
||||
MAX_EXTRACTION_RETRIES,
|
||||
DEFAULT_EXTRACTION_RETRIES
|
||||
),
|
||||
extractionTimeoutMs: positiveNumberOrDefault(
|
||||
config?.answer?.extractionTimeoutMs,
|
||||
DEFAULT_EXTRACTION_TIMEOUT_MS
|
||||
),
|
||||
},
|
||||
behaviour: {
|
||||
autoSubmitWhenAnsweredWithoutNotes:
|
||||
config?.behaviour?.autoSubmitWhenAnsweredWithoutNotes ??
|
||||
@@ -38,10 +66,15 @@ export function normalizeAskConfig(
|
||||
};
|
||||
}
|
||||
|
||||
export function toAskConfigFileV1(config: AskConfig): AskConfigFileV1 {
|
||||
export function toAskConfigFileV2(config: AskConfig): AskConfigFileV2 {
|
||||
const normalized = normalizeAskConfig(config);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
schemaVersion: 2,
|
||||
answer: {
|
||||
extractionModels: normalized.answer.extractionModels,
|
||||
extractionRetries: normalized.answer.extractionRetries,
|
||||
extractionTimeoutMs: normalized.answer.extractionTimeoutMs,
|
||||
},
|
||||
behaviour: {
|
||||
autoSubmitWhenAnsweredWithoutNotes:
|
||||
normalized.behaviour.autoSubmitWhenAnsweredWithoutNotes,
|
||||
@@ -60,3 +93,35 @@ export function toAskConfigFileV1(config: AskConfig): AskConfigFileV1 {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isValidModelPreference(value: unknown): value is {
|
||||
id: string;
|
||||
provider: string;
|
||||
} {
|
||||
return (
|
||||
!!value &&
|
||||
typeof value === "object" &&
|
||||
typeof (value as { id?: unknown }).id === "string" &&
|
||||
(value as { id: string }).id.trim().length > 0 &&
|
||||
typeof (value as { provider?: unknown }).provider === "string" &&
|
||||
(value as { provider: string }).provider.trim().length > 0
|
||||
);
|
||||
}
|
||||
|
||||
function clampInteger(
|
||||
value: unknown,
|
||||
min: number,
|
||||
max: number,
|
||||
fallback: number
|
||||
): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.max(min, Math.min(max, Math.trunc(value)));
|
||||
}
|
||||
|
||||
function positiveNumberOrDefault(value: unknown, fallback: number): number {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0
|
||||
? value
|
||||
: fallback;
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
type AskConfigVersionMigrationResult,
|
||||
migrateAskConfigFileToCurrent,
|
||||
} from "./migrations/index.ts";
|
||||
import type { AskConfig, AskConfigFileV1 } from "./schema.ts";
|
||||
import { validateAskConfigFileV1 } from "./schema.ts";
|
||||
import type { AskConfig, AskConfigFileV2 } from "./schema.ts";
|
||||
import { validateAskConfigFileV2 } from "./schema.ts";
|
||||
|
||||
export class AskConfigMigrationError extends Error {
|
||||
readonly reason: "invalid_or_unsupported" | "migration_failed";
|
||||
@@ -42,14 +42,14 @@ export function migrateAskConfig(raw: unknown): AskConfigMigrationResult {
|
||||
);
|
||||
}
|
||||
|
||||
if (!validateAskConfigFileV1.Check(migratedFile.config)) {
|
||||
if (!validateAskConfigFileV2.Check(migratedFile.config)) {
|
||||
throw new AskConfigMigrationError(
|
||||
"Config was invalid or unsupported.",
|
||||
"invalid_or_unsupported"
|
||||
);
|
||||
}
|
||||
|
||||
const currentFile = migratedFile.config as AskConfigFileV1;
|
||||
const currentFile = migratedFile.config as AskConfigFileV2;
|
||||
const config = normalizeAskConfig(currentFile);
|
||||
const keymapsResult = normalizeConfiguredKeymaps(
|
||||
currentFile.keymaps as
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import type { AskConfigMigration, VersionedAskConfigFile } from "./types.ts";
|
||||
|
||||
export const CURRENT_ASK_CONFIG_SCHEMA_VERSION = 1;
|
||||
export const CURRENT_ASK_CONFIG_SCHEMA_VERSION = 2;
|
||||
|
||||
const ASK_CONFIG_MIGRATIONS: AskConfigMigration[] = [];
|
||||
const ASK_CONFIG_MIGRATIONS: AskConfigMigration[] = [
|
||||
{
|
||||
from: 1,
|
||||
to: 2,
|
||||
migrate: (config) => ({
|
||||
...config,
|
||||
schemaVersion: 2,
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
export class AskConfigVersionMigrationError extends Error {
|
||||
readonly reason: "invalid_or_unsupported" | "migration_failed";
|
||||
|
||||
+36
-6
@@ -1,8 +1,22 @@
|
||||
import { type Static, Type } from "typebox";
|
||||
import { Compile } from "typebox/compile";
|
||||
|
||||
export const AskConfigFileV1Schema = Type.Object({
|
||||
schemaVersion: Type.Literal(1),
|
||||
const AskAnswerModelPreferenceSchema = Type.Object({
|
||||
id: Type.String(),
|
||||
provider: Type.String(),
|
||||
});
|
||||
|
||||
export const AskConfigFileV2Schema = Type.Object({
|
||||
schemaVersion: Type.Literal(2),
|
||||
answer: Type.Optional(
|
||||
Type.Object({
|
||||
extractionModels: Type.Optional(
|
||||
Type.Array(AskAnswerModelPreferenceSchema)
|
||||
),
|
||||
extractionRetries: Type.Optional(Type.Number()),
|
||||
extractionTimeoutMs: Type.Optional(Type.Number()),
|
||||
})
|
||||
),
|
||||
behaviour: Type.Optional(
|
||||
Type.Object({
|
||||
autoSubmitWhenAnsweredWithoutNotes: Type.Optional(Type.Boolean()),
|
||||
@@ -14,16 +28,27 @@ export const AskConfigFileV1Schema = Type.Object({
|
||||
keymaps: Type.Optional(
|
||||
Type.Object({
|
||||
cancel: Type.Optional(Type.String()),
|
||||
dismiss: Type.Optional(Type.String()),
|
||||
toggle: Type.Optional(Type.String()),
|
||||
confirm: Type.Optional(Type.String()),
|
||||
dismiss: Type.Optional(Type.String()),
|
||||
optionNote: Type.Optional(Type.String()),
|
||||
questionNote: Type.Optional(Type.String()),
|
||||
toggle: Type.Optional(Type.String()),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
export type AskConfigFileV1 = Static<typeof AskConfigFileV1Schema>;
|
||||
export type AskConfigFileV2 = Static<typeof AskConfigFileV2Schema>;
|
||||
export type AskConfigFileV1 = Omit<
|
||||
AskConfigFileV2,
|
||||
"answer" | "schemaVersion"
|
||||
> & {
|
||||
schemaVersion: 1;
|
||||
};
|
||||
|
||||
export interface AskAnswerModelPreference {
|
||||
id: string;
|
||||
provider: string;
|
||||
}
|
||||
|
||||
export interface AskConfigKeymaps {
|
||||
cancel: string;
|
||||
@@ -35,6 +60,11 @@ export interface AskConfigKeymaps {
|
||||
}
|
||||
|
||||
export interface AskConfig {
|
||||
answer: {
|
||||
extractionModels: AskAnswerModelPreference[];
|
||||
extractionRetries: number;
|
||||
extractionTimeoutMs: number;
|
||||
};
|
||||
behaviour: {
|
||||
autoSubmitWhenAnsweredWithoutNotes: boolean;
|
||||
confirmDismissWhenDirty: boolean;
|
||||
@@ -44,4 +74,4 @@ export interface AskConfig {
|
||||
keymaps: AskConfigKeymaps;
|
||||
}
|
||||
|
||||
export const validateAskConfigFileV1 = Compile(AskConfigFileV1Schema);
|
||||
export const validateAskConfigFileV2 = Compile(AskConfigFileV2Schema);
|
||||
|
||||
+3
-3
@@ -7,7 +7,7 @@ import { getAgentDir } from "@mariozechner/pi-coding-agent";
|
||||
import {
|
||||
DEFAULT_ASK_CONFIG,
|
||||
normalizeAskConfig,
|
||||
toAskConfigFileV1,
|
||||
toAskConfigFileV2,
|
||||
} from "./defaults.ts";
|
||||
import { AskConfigMigrationError, migrateAskConfig } from "./migrate.ts";
|
||||
import { migrateAskConfigPathIfNeeded } from "./path-migrations.ts";
|
||||
@@ -63,10 +63,10 @@ export class AskConfigStore {
|
||||
return (await this.ensureLoaded()).config;
|
||||
}
|
||||
|
||||
async save(config: AskConfig): Promise<AskConfig> {
|
||||
async save(config: AskConfig | Partial<AskConfig>): Promise<AskConfig> {
|
||||
const normalized = normalizeAskConfig(config);
|
||||
const content = JSON.stringify(
|
||||
toAskConfigFileV1(normalized),
|
||||
toAskConfigFileV2(normalized),
|
||||
null,
|
||||
2
|
||||
).concat("\n");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { registerAnswerCommands } from "./answer-commands.ts";
|
||||
import { registerAskSettingsCommand } from "./ask-settings-command.ts";
|
||||
import { registerAskTool } from "./ask-tool.ts";
|
||||
|
||||
@@ -18,4 +19,5 @@ export default function askExtension(pi: ExtensionAPI) {
|
||||
}));
|
||||
registerAskTool(pi);
|
||||
registerAskSettingsCommand(pi);
|
||||
registerAnswerCommands(pi);
|
||||
}
|
||||
|
||||
+3
-2
@@ -46,7 +46,7 @@ export const AskQuestionSchema = Type.Object({
|
||||
type: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"Question type: use `single` by default. Use `preview` only when every option includes `preview` text for the dedicated preview pane; descriptions alone are not enough.",
|
||||
"Question type: `single` means one answer is expected, `multi` means multiple answers could reasonably be selected, and `preview` means options need preview-pane detail. Use `preview` only when every option includes `preview` text; descriptions alone are not enough.",
|
||||
})
|
||||
),
|
||||
required: Type.Optional(
|
||||
@@ -56,7 +56,8 @@ export const AskQuestionSchema = Type.Object({
|
||||
})
|
||||
),
|
||||
options: Type.Array(AskOptionSchema, {
|
||||
description: "Answer options; prefer 2-4 clear choices when possible",
|
||||
description:
|
||||
"Answer options; provide clear, distinct choices and do not add filler options",
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
+9
-2
@@ -2,9 +2,16 @@ import type { AskParams } from "../types.ts";
|
||||
import { normalizeQuestions } from "./normalize.ts";
|
||||
import { createInitialState as createBaseState } from "./transitions.ts";
|
||||
|
||||
export function createInitialState(params: AskParams) {
|
||||
interface CreateInitialStateOptions {
|
||||
allowFreeform?: boolean;
|
||||
}
|
||||
|
||||
export function createInitialState(
|
||||
params: AskParams,
|
||||
options: CreateInitialStateOptions = {}
|
||||
) {
|
||||
return createBaseState({
|
||||
title: params.title,
|
||||
questions: normalizeQuestions(params),
|
||||
questions: normalizeQuestions(params, options),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -62,5 +62,9 @@ export function syncStateToSelection(state: AskState): AskState {
|
||||
return state;
|
||||
}
|
||||
|
||||
if (getAnswer(state, question.id)?.customText?.trim()) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return enterInputMode(state, question.id);
|
||||
}
|
||||
|
||||
+52
-7
@@ -11,8 +11,15 @@ interface IssueCollector {
|
||||
issues: AskValidationIssue[];
|
||||
}
|
||||
|
||||
export function normalizeQuestions(params: AskParams): AskQuestion[] {
|
||||
const issues = collectValidationIssues(params);
|
||||
interface ValidationOptions {
|
||||
allowFreeform?: boolean;
|
||||
}
|
||||
|
||||
export function normalizeQuestions(
|
||||
params: AskParams,
|
||||
options: ValidationOptions = {}
|
||||
): AskQuestion[] {
|
||||
const issues = collectValidationIssues(params, options);
|
||||
if (issues.length > 0) {
|
||||
throw new Error(issues[0]?.message ?? "Invalid ask_user payload");
|
||||
}
|
||||
@@ -20,10 +27,11 @@ export function normalizeQuestions(params: AskParams): AskQuestion[] {
|
||||
}
|
||||
|
||||
export function collectValidationIssues(
|
||||
params: AskParams
|
||||
params: AskParams,
|
||||
options: ValidationOptions = {}
|
||||
): AskValidationIssue[] {
|
||||
const collector = createIssueCollector();
|
||||
validateQuestions(params.questions, collector);
|
||||
validateQuestions(params.questions, collector, options);
|
||||
return collector.issues;
|
||||
}
|
||||
|
||||
@@ -47,12 +55,14 @@ function normalizeOption(option: AskOption): AskOption {
|
||||
label: option.label.trim(),
|
||||
description: option.description?.trim(),
|
||||
preview: option.preview?.trim(),
|
||||
...(option.freeform ? { freeform: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function validateQuestions(
|
||||
questions: AskParams["questions"],
|
||||
collector: IssueCollector
|
||||
collector: IssueCollector,
|
||||
options: ValidationOptions
|
||||
) {
|
||||
if (questions.length === 0) {
|
||||
collector.add("questions", "At least one question is required");
|
||||
@@ -61,7 +71,7 @@ function validateQuestions(
|
||||
|
||||
const questionIds = new Set<string>();
|
||||
for (const [questionIndex, question] of questions.entries()) {
|
||||
validateQuestion(question, questionIndex, questionIds, collector);
|
||||
validateQuestion(question, questionIndex, questionIds, collector, options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +79,8 @@ function validateQuestion(
|
||||
question: AskQuestionInput,
|
||||
questionIndex: number,
|
||||
questionIds: Set<string>,
|
||||
collector: IssueCollector
|
||||
collector: IssueCollector,
|
||||
options: ValidationOptions
|
||||
) {
|
||||
const questionNumber = questionIndex + 1;
|
||||
const questionPath = `questions[${questionIndex}]`;
|
||||
@@ -114,6 +125,14 @@ function validateQuestion(
|
||||
`Question ${questionNumber}: at least one option is required`
|
||||
);
|
||||
|
||||
validateFreeformOptions(
|
||||
question.options,
|
||||
questionNumber,
|
||||
collector,
|
||||
`${questionPath}.options`,
|
||||
options
|
||||
);
|
||||
|
||||
const optionValues = new Set<string>();
|
||||
for (const [optionIndex, option] of question.options.entries()) {
|
||||
validateOption(
|
||||
@@ -128,6 +147,32 @@ function validateQuestion(
|
||||
}
|
||||
}
|
||||
|
||||
function validateFreeformOptions(
|
||||
options: AskOption[],
|
||||
questionNumber: number,
|
||||
collector: IssueCollector,
|
||||
path: string,
|
||||
validationOptions: ValidationOptions
|
||||
) {
|
||||
const freeformCount = options.filter((option) => option.freeform).length;
|
||||
if (freeformCount === 0) {
|
||||
return;
|
||||
}
|
||||
if (!validationOptions.allowFreeform) {
|
||||
collector.add(
|
||||
path,
|
||||
`Question ${questionNumber}: freeform options are only supported for /answer forms`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (freeformCount > 1 || options.length > 1) {
|
||||
collector.add(
|
||||
path,
|
||||
`Question ${questionNumber}: freeform options must be the only option`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validateOption(
|
||||
option: AskOption,
|
||||
optionIndex: number,
|
||||
|
||||
@@ -13,6 +13,8 @@ const CUSTOM_OPTION: AskDisplayOption = {
|
||||
isCustomOption: true,
|
||||
};
|
||||
|
||||
const DEFAULT_FREEFORM_LABEL = "Type your answer:";
|
||||
|
||||
export function getCurrentQuestion(state: AskState): AskQuestion | undefined {
|
||||
return state.questions[state.activeTabIndex];
|
||||
}
|
||||
@@ -34,9 +36,30 @@ export function getRenderableOptions(
|
||||
if (!question) {
|
||||
return [];
|
||||
}
|
||||
const freeformOption = getFreeformOption(question);
|
||||
if (freeformOption) {
|
||||
return [
|
||||
{
|
||||
...freeformOption,
|
||||
label: DEFAULT_FREEFORM_LABEL,
|
||||
isCustomOption: true,
|
||||
isFreeformOnlyOption: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
return [...question.options, CUSTOM_OPTION];
|
||||
}
|
||||
|
||||
function getFreeformOption(
|
||||
question: AskQuestion
|
||||
): AskDisplayOption | undefined {
|
||||
if (question.options.length !== 1) {
|
||||
return;
|
||||
}
|
||||
const option = question.options[0];
|
||||
return option.freeform ? option : undefined;
|
||||
}
|
||||
|
||||
export function getCurrentOption(
|
||||
state: AskState
|
||||
): AskDisplayOption | undefined {
|
||||
|
||||
@@ -2,6 +2,7 @@ export type AskQuestionType = "single" | "multi" | "preview";
|
||||
|
||||
export interface AskOption {
|
||||
description?: string;
|
||||
freeform?: boolean;
|
||||
label: string;
|
||||
preview?: string;
|
||||
value: string;
|
||||
@@ -147,6 +148,7 @@ export interface AskState {
|
||||
|
||||
export interface AskDisplayOption extends AskOption {
|
||||
isCustomOption?: boolean;
|
||||
isFreeformOnlyOption?: boolean;
|
||||
}
|
||||
|
||||
export type AskAction =
|
||||
|
||||
+23
-6
@@ -52,8 +52,16 @@ type Tui = CustomCallbackArgs[0];
|
||||
type Theme = CustomCallbackArgs[1];
|
||||
type Keybindings = CustomCallbackArgs[2];
|
||||
type Done = (result: AskResult) => void;
|
||||
interface AskFlowOptions {
|
||||
allowFreeform?: boolean;
|
||||
}
|
||||
|
||||
type AskFlowParams = AskParams &
|
||||
Pick<ExtensionContext, "cwd"> & { config: AskConfig; ctx: ExtensionContext };
|
||||
Pick<ExtensionContext, "cwd"> & {
|
||||
config: AskConfig;
|
||||
ctx: ExtensionContext;
|
||||
flowOptions: AskFlowOptions;
|
||||
};
|
||||
|
||||
interface AskFlowController {
|
||||
config: AskConfig;
|
||||
@@ -72,12 +80,19 @@ interface AskFlowController {
|
||||
|
||||
export async function runAskFlow(
|
||||
ctx: ExtensionContext,
|
||||
params: AskParams
|
||||
params: AskParams,
|
||||
options: AskFlowOptions = {}
|
||||
): Promise<AskResult> {
|
||||
const store = getAskConfigStore();
|
||||
const config = await store.getConfig();
|
||||
return ctx.ui.custom<AskResult>((...args) =>
|
||||
createAskFlowController(args, { ...params, config, cwd: ctx.cwd, ctx })
|
||||
createAskFlowController(args, {
|
||||
...params,
|
||||
config,
|
||||
cwd: ctx.cwd,
|
||||
ctx,
|
||||
flowOptions: options,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -97,7 +112,7 @@ function createAskFlowController(
|
||||
done,
|
||||
editor: createEditor(tui, theme, params.cwd),
|
||||
settingsOpen: false,
|
||||
state: createInitialState(params),
|
||||
state: createInitialState(params, params.flowOptions),
|
||||
suppressAutoInputForSelection: false,
|
||||
pendingReviewShortcutActionIndex: undefined,
|
||||
theme,
|
||||
@@ -321,8 +336,10 @@ function submitEditor(controller: AskFlowController, value: string) {
|
||||
if (nextState.activeTabIndex !== controller.state.activeTabIndex) {
|
||||
clearDismissNotice(controller);
|
||||
}
|
||||
controller.state = maybeAutoSubmitState(nextState, controller.config);
|
||||
controller.editor.setText("");
|
||||
controller.state = nextState;
|
||||
syncSelection(controller);
|
||||
controller.state = maybeAutoSubmitState(controller.state, controller.config);
|
||||
hydrateEditor(controller);
|
||||
refresh(controller);
|
||||
maybeFinish(controller);
|
||||
}
|
||||
|
||||
+24
-19
@@ -76,7 +76,7 @@ function renderStandardOption(
|
||||
|
||||
pushWrappedText(
|
||||
lines,
|
||||
`${row.index + 1}. ${row.prefix}${row.label}`,
|
||||
formatOptionLabel(row),
|
||||
context.width,
|
||||
context.theme,
|
||||
row.color,
|
||||
@@ -179,11 +179,16 @@ function renderOptionDetail(
|
||||
lines: string[],
|
||||
detail: OptionDetailModel | undefined,
|
||||
context: QuestionRenderContext,
|
||||
options: { suppressLeadingGap?: boolean } = {}
|
||||
options: { indent?: string; suppressLeadingGap?: boolean } = {}
|
||||
) {
|
||||
if (!detail) {
|
||||
return;
|
||||
}
|
||||
const indent = options.indent ?? " ";
|
||||
const padding =
|
||||
indent === " "
|
||||
? UI_DIMENSIONS.editorContentPadding
|
||||
: UI_DIMENSIONS.editorIndentedPadding;
|
||||
if (detail.withGap && !options.suppressLeadingGap) {
|
||||
lines.push("");
|
||||
}
|
||||
@@ -193,8 +198,8 @@ function renderOptionDetail(
|
||||
editor: context.editor,
|
||||
width: context.width,
|
||||
theme: context.theme,
|
||||
indent: " ",
|
||||
padding: UI_DIMENSIONS.editorIndentedPadding,
|
||||
indent,
|
||||
padding,
|
||||
placeholder: detail.placeholder,
|
||||
});
|
||||
return;
|
||||
@@ -205,7 +210,7 @@ function renderOptionDetail(
|
||||
note: detail.text,
|
||||
width: context.width,
|
||||
theme: context.theme,
|
||||
indent: " ",
|
||||
indent,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -215,8 +220,8 @@ function renderOptionDetail(
|
||||
context.width,
|
||||
context.theme,
|
||||
"muted",
|
||||
" ",
|
||||
" "
|
||||
indent,
|
||||
indent
|
||||
);
|
||||
}
|
||||
|
||||
@@ -244,29 +249,29 @@ function renderEditorWithIndent(args: {
|
||||
});
|
||||
}
|
||||
|
||||
function formatOptionLabel(row: OptionRowModel): string {
|
||||
return row.isFreeformOnly
|
||||
? row.label
|
||||
: `${row.index + 1}. ${row.prefix}${row.label}`;
|
||||
}
|
||||
|
||||
function renderInteractiveCustomOption(
|
||||
lines: string[],
|
||||
row: OptionRowModel,
|
||||
context: QuestionRenderContext
|
||||
) {
|
||||
const indent = row.isFreeformOnly ? " " : row.pointer;
|
||||
pushWrappedText(
|
||||
lines,
|
||||
`${row.index + 1}. ${row.prefix}${row.label}`,
|
||||
formatOptionLabel(row),
|
||||
context.width,
|
||||
context.theme,
|
||||
row.color,
|
||||
row.pointer,
|
||||
" ".repeat(visibleWidth(row.pointer))
|
||||
indent,
|
||||
" ".repeat(visibleWidth(indent))
|
||||
);
|
||||
renderEditorWithIndent({
|
||||
lines,
|
||||
editor: context.editor,
|
||||
width: context.width,
|
||||
theme: context.theme,
|
||||
indent: " ",
|
||||
padding: UI_DIMENSIONS.editorIndentedPadding,
|
||||
placeholder:
|
||||
row.detail?.kind === "editor" ? row.detail.placeholder : "Type answer...",
|
||||
renderOptionDetail(lines, row.detail, context, {
|
||||
indent: row.isFreeformOnly ? " " : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface OptionRowModel {
|
||||
detail?: OptionDetailModel;
|
||||
index: number;
|
||||
isCustom: boolean;
|
||||
isFreeformOnly: boolean;
|
||||
label: string;
|
||||
pointer: string;
|
||||
prefix: string;
|
||||
@@ -89,6 +90,7 @@ function buildOptionRowModel(
|
||||
const answered = option.isCustomOption
|
||||
? !!answer?.customText
|
||||
: isOptionSelected(answer, option.value);
|
||||
const pointer = getOptionPointer(option, selected);
|
||||
return {
|
||||
color: getOptionColor(answered, selected),
|
||||
description: option.description,
|
||||
@@ -98,13 +100,21 @@ function buildOptionRowModel(
|
||||
: buildOptionDetailModel(context, option, selected),
|
||||
index,
|
||||
isCustom: !!option.isCustomOption,
|
||||
isFreeformOnly: !!option.isFreeformOnlyOption,
|
||||
label: option.label,
|
||||
pointer: selected ? "❯ " : " ",
|
||||
pointer,
|
||||
prefix: getOptionPrefix(question.type, option, answered),
|
||||
selected,
|
||||
};
|
||||
}
|
||||
|
||||
function getOptionPointer(option: AskDisplayOption, selected: boolean): string {
|
||||
if (option.isFreeformOnlyOption) {
|
||||
return "";
|
||||
}
|
||||
return selected ? "❯ " : " ";
|
||||
}
|
||||
|
||||
function buildOptionDetailModel(
|
||||
context: QuestionRenderContext,
|
||||
option: AskDisplayOption | undefined,
|
||||
@@ -139,11 +149,15 @@ function buildOptionDetailModel(
|
||||
return {
|
||||
kind: "editor",
|
||||
placeholder: UI_TEXT.editorPlaceholderInput,
|
||||
withGap: false,
|
||||
withGap: !!option.isFreeformOnlyOption,
|
||||
};
|
||||
}
|
||||
if (customText) {
|
||||
return { kind: "custom-text", text: customText, withGap: false };
|
||||
return {
|
||||
kind: "custom-text",
|
||||
text: customText,
|
||||
withGap: !!option.isFreeformOnlyOption,
|
||||
};
|
||||
}
|
||||
if (note) {
|
||||
return { kind: "saved-note", text: note, withGap: false };
|
||||
|
||||
Reference in New Issue
Block a user