import type { AskResponse, QuestionOption } from "./types"; export type AskOptionInput = QuestionOption | string; export type AskUIResult = AskResponse; export type AskPreviousContextInput = { /** One-sentence recap of the state the user already established. */ summary?: string; /** Decisions the user or agent has already made in this question sequence. */ decisions?: string[]; /** Relevant observations from files, docs, commands, or prior answers. */ findings?: string[]; /** Constraints that should bound the next answer. */ constraints?: string[]; /** Why this question is being asked now / what it unlocks. */ nextStep?: string; }; export type AskPromptSections = { question: string; previousContext?: string; description?: string; }; export const FREEFORM_SENTINEL = "\u270f\ufe0f Type custom response..."; function normalizePromptText(value: unknown): string | undefined { if (typeof value !== "string") return undefined; const trimmed = value.trim(); return trimmed ? trimmed : undefined; } function normalizePromptList(value: unknown): string[] | undefined { if (!Array.isArray(value)) return undefined; const normalized = value .map((item) => normalizePromptText(item)) .filter((item): item is string => Boolean(item)); return normalized.length > 0 ? normalized : undefined; } function formatPromptList(title: string, values: string[] | undefined): string | undefined { if (!values || values.length === 0) return undefined; return [`${title}:`, ...values.map((value) => `- ${value}`)].join("\n"); } export function normalizePreviousContext( previousContext: AskPreviousContextInput | string | null | undefined, legacyContext?: string | null, ): AskPreviousContextInput | undefined { if (typeof previousContext === "string") { const summary = normalizePromptText(previousContext); if (summary) return { summary }; } else if (previousContext && typeof previousContext === "object") { const normalized: AskPreviousContextInput = { summary: normalizePromptText((previousContext as AskPreviousContextInput).summary), decisions: normalizePromptList((previousContext as AskPreviousContextInput).decisions), findings: normalizePromptList((previousContext as AskPreviousContextInput).findings), constraints: normalizePromptList((previousContext as AskPreviousContextInput).constraints), nextStep: normalizePromptText((previousContext as AskPreviousContextInput).nextStep), }; if ( normalized.summary || normalized.decisions || normalized.findings || normalized.constraints || normalized.nextStep ) { return normalized; } } const summary = normalizePromptText(legacyContext); return summary ? { summary } : undefined; } export function formatPreviousContext(previousContext: AskPreviousContextInput | undefined): string | undefined { if (!previousContext) return undefined; const sections: string[] = []; const summary = normalizePromptText(previousContext.summary); if (summary) sections.push(summary); const decisions = formatPromptList("Decisions", normalizePromptList(previousContext.decisions)); if (decisions) sections.push(decisions); const findings = formatPromptList("Findings", normalizePromptList(previousContext.findings)); if (findings) sections.push(findings); const constraints = formatPromptList("Constraints", normalizePromptList(previousContext.constraints)); if (constraints) sections.push(constraints); const nextStep = normalizePromptText(previousContext.nextStep); if (nextStep) sections.push(`Next step: ${nextStep}`); return sections.length > 0 ? sections.join("\n\n") : undefined; } export function buildAskPromptSections(input: { question: string; previousContext?: AskPreviousContextInput | string | null; context?: string | null; description?: string | null; }): AskPromptSections { return { question: normalizePromptText(input.question) ?? input.question, previousContext: formatPreviousContext(normalizePreviousContext(input.previousContext, input.context)), description: normalizePromptText(input.description), }; } export function formatAskPromptForMessage(sections: AskPromptSections): string { const parts: string[] = []; if (sections.previousContext) { parts.push(`Previous context:\n${sections.previousContext}`); } parts.push(`Question:\n${sections.question}`); if (sections.description) { parts.push(`Details:\n${sections.description}`); } return parts.join("\n\n"); } export function formatAskPromptBody(sections: AskPromptSections): string | undefined { const parts: string[] = []; if (sections.previousContext) { parts.push(`Previous context:\n${sections.previousContext}`); } if (sections.description) { parts.push(`Details:\n${sections.description}`); } return parts.length > 0 ? parts.join("\n\n") : undefined; } function normalizeFlags(flags: unknown): string[] | undefined { if (!Array.isArray(flags)) return undefined; const normalized = flags .map((flag) => typeof flag === "string" ? flag.trim().toLowerCase() : "") .filter((flag) => /^[a-z][a-z0-9_]{0,39}$/.test(flag)); return normalized.length > 0 ? [...new Set(normalized)] : undefined; } export function normalizeOptions(options: AskOptionInput[]): QuestionOption[] { return options .map((option) => { if (typeof option === "string") { return { title: option }; } if (option && typeof option === "object" && typeof option.title === "string") { const flags = normalizeFlags((option as any).flags); return { title: option.title, description: option.description, ...(flags ? { flags } : {}) }; } return null; }) .filter((option): option is QuestionOption => option !== null); } export function formatOptionsForMessage(options: QuestionOption[]): string { return options .map((option, index) => { const desc = option.description ? ` — ${option.description}` : ""; return `${index + 1}. ${option.title}${desc}`; }) .join("\n"); } export function normalizeOptionalComment(text: string | null | undefined): string | undefined { const trimmed = text?.trim(); return trimmed ? trimmed : undefined; } export function createFreeformResponse(text: string | null | undefined): AskResponse | null { const trimmed = text?.trim(); return trimmed ? { kind: "freeform", text: trimmed } : null; } export function createSelectionResponse(selections: string[], comment?: string | null): AskResponse | null { const normalizedSelections = selections.map((selection) => selection.trim()).filter(Boolean); if (normalizedSelections.length === 0) return null; const normalizedComment = normalizeOptionalComment(comment); return normalizedComment ? { kind: "selection", selections: normalizedSelections, comment: normalizedComment } : { kind: "selection", selections: normalizedSelections }; } export function formatResponseSummary(response: AskResponse): string { if (response.kind === "freeform") return response.text; const selections = response.selections.join(", "); return response.comment ? `${selections} — ${response.comment}` : selections; } export function buildCommentPrompt(prompt: string, selections: string[]): string { const label = selections.length === 1 ? "Selected option" : "Selected options"; const lines = selections.map((selection) => `- ${selection}`).join("\n"); return `${prompt}\n\n${label}:\n${lines}`; } export function parseDialogSelections(input: string): string[] { return input .split(",") .map((selection) => selection.trim()) .filter(Boolean); } export function isCancelledInput(value: unknown): value is null | undefined { return value === null || value === undefined; } export function isSelectionResponse(response: AskResponse): response is Extract { return response.kind === "selection"; } /** * RPC/headless fallback: use dialog methods (select/input) instead of the rich TUI overlay. * ctx.ui.custom() returns undefined in RPC mode, so we degrade gracefully. */ export async function askViaDialogs( ui: { select: Function; input: Function }, question: string, context: string | undefined, options: QuestionOption[], allowMultiple: boolean, allowFreeform: boolean, allowComment: boolean, timeout?: number, description?: string, ): Promise { const dialogOpts = timeout ? { timeout } : undefined; const prompt = formatAskPromptForMessage({ question, previousContext: context, description }); if (allowMultiple) { const optionList = formatOptionsForMessage(options); const rawSelections = await ui.input( `${prompt}\n\nOptions (select one or more):\n${optionList}`, "Type your selection(s)...", dialogOpts, ) as string | undefined; if (isCancelledInput(rawSelections)) return null; const selections = parseDialogSelections(rawSelections); if (selections.length === 0) return null; if (!allowComment) { return createSelectionResponse(selections); } const comment = await ui.input( buildCommentPrompt(prompt, selections), "Optional comment (press Enter to skip)...", dialogOpts, ) as string | undefined; return createSelectionResponse(selections, comment); } const selectOptions = options.map((o) => o.title); if (allowFreeform) selectOptions.push(FREEFORM_SENTINEL); const selected = await ui.select(prompt, selectOptions, dialogOpts) as string | undefined; if (isCancelledInput(selected)) return null; if (selected === FREEFORM_SENTINEL) { const answer = await ui.input(prompt, "Type your answer...", dialogOpts) as string | undefined; if (isCancelledInput(answer)) return null; return createFreeformResponse(answer); } if (!allowComment) { return createSelectionResponse([selected]); } const comment = await ui.input( buildCommentPrompt(prompt, [selected]), "Optional comment (press Enter to skip)...", dialogOpts, ) as string | undefined; return createSelectionResponse([selected], comment); }