import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent"; import * as Type from "typebox"; import type { TUnsafe } from "typebox"; import { type KeybindingsManager, type OverlayHandle, Text, type TUI, } from "@earendil-works/pi-tui"; import { getAgentTickTimeoutMs, resolveAgentTickPresentation } from "./config"; import { AskComponent, askViaDialogs, buildAskPromptSections, buildCustomUIOptions, createFreeformResponse, DEFAULT_COMMENT_TOGGLE_KEY, DEFAULT_OVERLAY_TOGGLE_KEY, formatAskPromptForMessage, formatOptionsForMessage, formatResponseSummary, isSelectionResponse, normalizeOptions, normalizePreviousContext, resolveShortcut, type AskOptionInput, type AskPreviousContextInput, type AskPromptSections, type AskUIResult, type ResolvedAskShortcuts, } from "./piAskUser"; import { piSessionIdFromContext, type AgentTickSessionContext } from "./session"; import type { StatusReporter } from "./status"; import { startRemoteSteering } from "./steering"; import type { AgentTickClient } from "./agentTickClient"; import type { AskResponse, QuestionOption } from "./types"; /** * Emit a flat `{ type: "string", enum: [...] }` JSON Schema instead of the * `anyOf`/`oneOf` shape that `Type.Union([Type.Literal()])` produces. Google's * function-calling API rejects the union form. Local copy of pi-ai's StringEnum * to avoid a peer dependency for one helper. */ function StringEnum( values: T, options?: { description?: string; default?: T[number] }, ): TUnsafe { return Type.Unsafe({ type: "string", enum: [...values], ...(options?.description ? { description: options.description } : {}), ...(options?.default !== undefined ? { default: options.default } : {}), }); } type AskDisplayMode = "overlay" | "inline"; interface AskParams { question: string; context?: string; previousContext?: AskPreviousContextInput | string; description?: string; options?: AskOptionInput[]; allowMultiple?: boolean; allowFreeform?: boolean; allowComment?: boolean; displayMode?: AskDisplayMode; overlayToggleKey?: string | null; commentToggleKey?: string | null; timeout?: number; choiceInteractionMode?: "click-to-submit" | "select-then-submit"; optionPlacement?: "sticky-bottom" | "inline-after-content"; confirmBeforeSubmit?: boolean; } interface AskToolDetails { question: string; /** Backward-compatible plain-text rendering of previousContext. */ context?: string; previousContext?: AskPreviousContextInput; description?: string; options: QuestionOption[]; response: AskResponse | null; cancelled: boolean; } type ResolvedAskPrompt = { sections: AskPromptSections; previousContext?: AskPreviousContextInput; details: Pick; }; export type CreateAgentTickClient = (ctx?: { ui?: { notify?: (message: string, level?: string) => void } }) => AgentTickClient | null; function formatAgentTickError(error: unknown): string { const status = typeof error === "object" && error !== null && "status" in error && typeof (error as { status?: unknown }).status === "number" ? (error as { status: number }).status : undefined; const base = error instanceof Error ? error.message : String(error); return status !== undefined ? `${base} (HTTP ${status})` : base; } function cancellationStopReason(signal?: AbortSignal): "agent_cancelled" | "shutdown" { const reason = signal?.reason instanceof Error ? signal.reason.message : signal?.reason; return typeof reason === "string" && /shutdown/i.test(reason) ? "shutdown" : "agent_cancelled"; } function resolveAskPrompt(params: Pick): ResolvedAskPrompt { const previousContext = normalizePreviousContext(params.previousContext, params.context); const sections = buildAskPromptSections({ question: params.question, previousContext, description: params.description, }); return { sections, previousContext, details: { question: sections.question, context: sections.previousContext, previousContext, description: sections.description, }, }; } function mirroredPromptError(prompt: ResolvedAskPrompt, options: QuestionOption[], error: unknown) { const message = formatAgentTickError(error); return { content: [{ type: "text", text: `Agent Tick mirrored prompt failed: ${message}` }], isError: true, details: { ...prompt.details, options, response: null, cancelled: true, agentTickError: message }, }; } export function registerAskUserTools( pi: ExtensionAPI, input: { statusReporter: StatusReporter | null; createAgentTickClient: CreateAgentTickClient; sessionContext?: AgentTickSessionContext; }, ): void { const { statusReporter, createAgentTickClient, sessionContext } = input; const askUserTool = { name: "ask_user", label: "Ask User", description: "Ask the user a question with optional multiple-choice answers. Use this to gather information interactively. Ask exactly one focused question per call. Keep previous decisions in previousContext, the concise ask in question, and expanded explanation in description.", promptSnippet: "Ask the user one focused question with optional multiple-choice answers to gather information interactively", promptGuidelines: [ "Before calling ask_user, gather context with tools (read/web/ref) and pass decisions or findings already established via previousContext, not in the question text.", "Use ask_user when the user's intent is ambiguous, when a decision requires explicit user input, or when multiple valid options exist.", "Ask exactly one focused question per ask_user call; keep question short and put expanded explanation in description.", "When chaining questions, put the prior answer(s) in previousContext.decisions and the next dependency in previousContext.nextStep so the UI shows that context before the new question.", "When you have a recommended option, set that option's flags to [\"favorite\"] so Agent Tick can highlight it remotely.", "Do not combine multiple numbered, multipart, or unrelated questions into one ask_user prompt.", ], parameters: Type.Object({ question: Type.String({ description: "The short, focused question to ask the user. Do not include previous decisions or long context here." }), context: Type.Optional( Type.String({ description: "Legacy plain-text previous context. Prefer previousContext for new calls; rendered before the question.", }), ), previousContext: Type.Optional( Type.Object({ summary: Type.Optional(Type.String({ description: "One-sentence recap of state the user already established." })), decisions: Type.Optional(Type.Array(Type.String({ description: "A decision already made in this question sequence." }))), findings: Type.Optional(Type.Array(Type.String({ description: "A relevant observation from files, docs, commands, or prior answers." }))), constraints: Type.Optional(Type.Array(Type.String({ description: "A constraint that should bound the next answer." }))), nextStep: Type.Optional(Type.String({ description: "Why this question is being asked now / what it unlocks." })), }, { description: "Structured prior context rendered at the very beginning of the prompt, before the question.", }), ), description: Type.Optional( Type.String({ description: "Expanded details explaining the current question. Rendered after the question, separate from previousContext.", }), ), options: Type.Optional( Type.Array( Type.Union([ Type.String({ description: "Short title for this option" }), Type.Object({ title: Type.String({ description: "Short title for this option" }), description: Type.Optional( Type.String({ description: "Longer description explaining this option" }), ), flags: Type.Optional( Type.Array( Type.String({ description: "Mobile-visible Agent Tick choice flag, e.g. favorite for the recommended option. Use Agent Tick's supported flag names; do not include secrets or long context.", }), { description: "Agent Tick flags for highlighting this option remotely" }, ), ), }), ]), { description: "List of options for the user to choose from" }, ), ), allowMultiple: Type.Optional( Type.Boolean({ description: "Allow selecting multiple options. Default: false" }), ), allowFreeform: Type.Optional( Type.Boolean({ description: "Add a freeform text option. Default: true" }), ), allowComment: Type.Optional( Type.Boolean({ description: "Collect an optional comment after selecting one or more options. Default: false" }), ), displayMode: Type.Optional( StringEnum(["overlay", "inline"] as const, { description: "UI rendering mode. 'overlay' shows a centered modal, 'inline' renders in-place. Default: PI_ASK_USER_DISPLAY_MODE env var if set, otherwise 'overlay'. Omit to respect the user's configured preference.", }), ), overlayToggleKey: Type.Optional( Type.String({ description: "Shortcut for hiding/showing the overlay popup (overlay mode only), e.g. 'alt+o' or 'ctrl+shift+h'. Pass 'off' to disable. Default: PI_ASK_USER_OVERLAY_TOGGLE_KEY env var if set, otherwise 'alt+o'.", }), ), commentToggleKey: Type.Optional( Type.String({ description: "Shortcut for toggling the optional comment/extra-context row when allowComment is true, e.g. 'ctrl+g'. Pass 'off' to disable. Default: PI_ASK_USER_COMMENT_TOGGLE_KEY env var if set, otherwise 'ctrl+g'.", }), ), timeout: Type.Optional( Type.Number({ description: "Auto-dismiss after N milliseconds. Returns null (cancelled) when expired." }), ), choiceInteractionMode: Type.Optional( StringEnum(["click-to-submit", "select-then-submit"] as const, { description: "Agent Tick remote choice behavior. 'click-to-submit' makes each option submit directly; 'select-then-submit' renders radio/checkbox choices with a final Send decision button.", }), ), optionPlacement: Type.Optional( StringEnum(["sticky-bottom", "inline-after-content"] as const, { description: "Agent Tick remote option placement. 'inline-after-content' renders options after the body so the user can scroll through the request before acting.", }), ), confirmBeforeSubmit: Type.Optional( Type.Boolean({ description: "Ask Agent Tick to show a confirmation dialog before submitting a direct clickable option." }), ), }), async execute(_toolCallId, params, signal, onUpdate, ctx) { if (signal?.aborted) { statusReporter?.send("blocked", "ask_user was cancelled before it started"); return { content: [{ type: "text", text: "Cancelled" }], details: { question: params.question, options: [], response: null, cancelled: true } as AskToolDetails, }; } const mirroredRequired = Boolean((params as Record).__agentTickMirroredRequired); const { question, context, previousContext, description, options: rawOptions = [], allowMultiple = false, allowFreeform = true, allowComment = false, displayMode, overlayToggleKey, commentToggleKey, timeout, choiceInteractionMode, optionPlacement, confirmBeforeSubmit, } = params as AskParams; const envMode = process.env.PI_ASK_USER_DISPLAY_MODE; const envDisplayMode: AskDisplayMode | undefined = envMode === "overlay" || envMode === "inline" ? envMode : undefined; const effectiveDisplayMode: AskDisplayMode = displayMode ?? envDisplayMode ?? "overlay"; const shortcuts: ResolvedAskShortcuts = { overlayToggle: resolveShortcut( overlayToggleKey, process.env.PI_ASK_USER_OVERLAY_TOGGLE_KEY, DEFAULT_OVERLAY_TOGGLE_KEY, ), commentToggle: resolveShortcut( commentToggleKey, process.env.PI_ASK_USER_COMMENT_TOGGLE_KEY, DEFAULT_COMMENT_TOGGLE_KEY, ), }; const options = normalizeOptions(rawOptions); const prompt = resolveAskPrompt({ question, context, previousContext, description }); const normalizedContext = prompt.sections.previousContext; const promptQuestion = prompt.sections.question; const promptDetails = prompt.details; sessionContext?.usePiSessionId(piSessionIdFromContext(ctx)); const agentTickPresentation = resolveAgentTickPresentation({ choiceInteractionMode, optionPlacement, confirmBeforeSubmit, }); let agentTickError: string | undefined; const onAgentTickError = (error: unknown) => { agentTickError = formatAgentTickError(error); ctx.ui?.notify?.(`Agent Tick mirror failed: ${agentTickError}`, "error"); onUpdate?.({ content: [{ type: "text", text: `Agent Tick mirror failed: ${agentTickError}` }], details: { ...promptDetails, options, response: null, cancelled: false, agentTickError }, }); }; if (options.length === 0 && mirroredRequired) { return mirroredPromptError(prompt, options, "Agent Tick mirrored prompts require at least one option"); } if (!ctx.hasUI || !ctx.ui) { const agentTickClient = createAgentTickClient(ctx); if (agentTickClient && options.length > 0) { onUpdate?.({ content: [{ type: "text", text: "Waiting for Agent Tick response..." }], details: { ...promptDetails, options, response: null, cancelled: false }, }); const remoteSteering = startRemoteSteering({ client: agentTickClient, question: promptQuestion, context: normalizedContext, description: prompt.sections.description, options, allowMultiple, allowFreeform, timeoutMs: timeout ?? getAgentTickTimeoutMs(), presentation: agentTickPresentation, session: sessionContext?.current(), signal, onError: onAgentTickError, }); if (mirroredRequired) { try { await remoteSteering.requestPromise; } catch (error) { return mirroredPromptError(prompt, options, error); } } const resumeWorking = mirroredRequired ? statusReporter?.pauseWorkingStatus() : undefined; try { const response = await remoteSteering.resultPromise; if (mirroredRequired && agentTickError) return mirroredPromptError(prompt, options, agentTickError); if (response) { pi.events.emit("ask:answered", { ...promptDetails, response }); return { content: [{ type: "text", text: `User answered: ${formatResponseSummary(response)}` }], details: { ...promptDetails, options, response, cancelled: false } as AskToolDetails, }; } remoteSteering.abandon(cancellationStopReason(signal)); statusReporter?.send("blocked", "ask_user was cancelled or timed out"); return { content: [{ type: "text", text: "User cancelled the question" }], details: { ...promptDetails, options, response: null, cancelled: true } as AskToolDetails, }; } finally { resumeWorking?.(); } } if (mirroredRequired) { return mirroredPromptError(prompt, options, "Agent Tick client is not configured"); } const optionText = options.length > 0 ? `\n\nOptions:\n${formatOptionsForMessage(options)}` : ""; const freeformHint = allowFreeform ? "\n\nYou can also answer freely." : ""; const commentHint = allowComment ? "\n\nAfter choosing an option, you may add an optional comment." : ""; return { content: [{ type: "text", text: `Ask requires interactive mode. Please answer:\n\n${formatAskPromptForMessage(prompt.sections)}${optionText}${freeformHint}${commentHint}` }], isError: true, details: { ...promptDetails, options, response: null, cancelled: true } as AskToolDetails, }; } if (options.length === 0) { const inputPrompt = formatAskPromptForMessage(prompt.sections); const localResult = await (ctx.ui.input(inputPrompt, "Type your answer...", timeout ? { timeout } : undefined) as Promise); const response = createFreeformResponse(localResult); if (!response) { pi.events.emit("ask:cancelled", { ...promptDetails, options }); statusReporter?.send("blocked", "ask_user was cancelled or timed out"); return { content: [{ type: "text", text: "User cancelled the question" }], details: { ...promptDetails, options, response: null, cancelled: true } as AskToolDetails, }; } pi.events.emit("ask:answered", { ...promptDetails, response }); return { content: [{ type: "text", text: `User answered: ${formatResponseSummary(response)}` }], details: { ...promptDetails, options, response, cancelled: false } as AskToolDetails, }; } onUpdate?.({ content: [{ type: "text", text: "Waiting for user input..." }], details: { ...promptDetails, options, response: null, cancelled: false }, }); const agentTickClient = createAgentTickClient(ctx); if (!agentTickClient && mirroredRequired) { return mirroredPromptError(prompt, options, "Agent Tick client is not configured"); } let remoteWon = false; let remoteError: unknown; const remoteSteering = agentTickClient ? startRemoteSteering({ client: agentTickClient, question: promptQuestion, context: normalizedContext, description: prompt.sections.description, options, allowMultiple, allowFreeform, timeoutMs: timeout ?? getAgentTickTimeoutMs(), presentation: agentTickPresentation, session: sessionContext?.current(), signal, onError: onAgentTickError, }) : undefined; if (mirroredRequired && remoteSteering) { try { await remoteSteering.requestPromise; } catch (error) { return mirroredPromptError(prompt, options, error); } } const resumeWorking = mirroredRequired ? statusReporter?.pauseWorkingStatus() : undefined; let result: AskUIResult | null; let overlayHandle: OverlayHandle | undefined; let removeOverlayInputListener: (() => void) | undefined; let hasAnnouncedHide = false; try { const customFactory = (tui: TUI, theme: Theme, keybindings: KeybindingsManager, done: (result: AskUIResult | null) => void) => { let settled = false; const finish = (result: AskUIResult | null): boolean => { if (settled) return false; settled = true; done(result); return true; }; if (signal) { const onAbort = () => finish(null); signal.addEventListener("abort", onAbort, { once: true }); } if (timeout && timeout > 0) { setTimeout(() => finish(null), timeout); } remoteSteering?.resultPromise.then((remoteResult) => { if (remoteResult === undefined) return; remoteWon = finish(remoteResult); }); remoteSteering?.errorPromise.then((error) => { remoteError = error; finish(null); }); return new AskComponent( prompt.sections, options, allowMultiple, allowFreeform, allowComment, effectiveDisplayMode, tui, theme, keybindings, shortcuts, finish, ); }; // Register a raw terminal input listener for the overlay-toggle key so the // overlay can be toggled even while it is hidden (hidden overlays do not // receive input). Inline mode does not need this because the prompt is // already non-modal. Skipped entirely if the user disabled the shortcut. const overlayToggle = shortcuts.overlayToggle; if ( effectiveDisplayMode === "overlay" && !overlayToggle.disabled && typeof ctx.ui.onTerminalInput === "function" ) { removeOverlayInputListener = ctx.ui.onTerminalInput((data) => { if (!overlayToggle.matches(data) || !overlayHandle) return undefined; const nextHidden = !overlayHandle.isHidden(); overlayHandle.setHidden(nextHidden); if (nextHidden && !hasAnnouncedHide) { hasAnnouncedHide = true; ctx.ui.notify?.(`ask_user hidden — press ${overlayToggle.spec} to reopen`, "info"); } return { consume: true }; }); } const customResult = await ctx.ui.custom( customFactory, buildCustomUIOptions(effectiveDisplayMode, (handle) => { overlayHandle = handle; }), ); if (customResult !== undefined) { result = customResult; } else { // RPC/headless mode: degrade to select()/input() dialog protocol, // while still racing Agent Tick if it is available. const localDialogResult = askViaDialogs(ctx.ui, promptQuestion, normalizedContext, options, allowMultiple, allowFreeform, allowComment, timeout, prompt.sections.description) .then((response) => ({ source: "local" as const, response })); const remoteDialogResult = remoteSteering?.resultPromise.then((response) => { if (response === undefined) return new Promise(() => undefined); return { source: "remote" as const, response }; }); const remoteDialogError = remoteSteering?.errorPromise.then((error) => ({ source: "remote_error" as const, error })); const dialogResult = await Promise.race([ localDialogResult, ...(remoteDialogResult ? [remoteDialogResult] : []), ...(remoteDialogError ? [remoteDialogError] : []), ]); if (dialogResult.source === "remote_error") { remoteError = dialogResult.error; result = null; } else { remoteWon = dialogResult.source === "remote"; result = dialogResult.response; } } } catch (error) { const message = error instanceof Error ? `${error.message}\n${error.stack ?? ""}` : String(error); return { content: [{ type: "text", text: `Ask tool failed: ${message}` }], isError: true, details: { error: message }, }; } finally { removeOverlayInputListener?.(); resumeWorking?.(); } if (remoteError || (mirroredRequired && agentTickError)) { remoteSteering?.abandon(cancellationStopReason(signal)); return mirroredPromptError(prompt, options, remoteError ?? agentTickError); } if (remoteSteering && !remoteWon) { remoteSteering.abandon(result ? "local_answer" : cancellationStopReason(signal)); } if (result === null) { pi.events.emit("ask:cancelled", { ...promptDetails, options }); statusReporter?.send("blocked", "ask_user was cancelled or timed out"); return { content: [{ type: "text", text: "User cancelled the question" }], details: { ...promptDetails, options, response: null, cancelled: true } as AskToolDetails, }; } pi.events.emit("ask:answered", { ...promptDetails, response: result, }); return { content: [{ type: "text", text: `User answered: ${formatResponseSummary(result)}` }], details: { ...promptDetails, options, response: result, cancelled: false, } as AskToolDetails, }; }, renderCall(args, theme) { const question = (args.question as string) || ""; const rawOptions = Array.isArray(args.options) ? args.options : []; let text = theme.fg("toolTitle", theme.bold("ask_user ")); text += theme.fg("muted", question); if (rawOptions.length > 0) { const labels = rawOptions.map((o: unknown) => typeof o === "string" ? o : (o as QuestionOption)?.title ?? "", ); text += "\n" + theme.fg("dim", ` ${rawOptions.length} option(s): ${labels.join(", ")}`); } if (args.allowMultiple) { text += theme.fg("dim", " [multi-select]"); } if (args.allowComment) { text += theme.fg("dim", " [optional comment]"); } return new Text(text, 0, 0); }, renderResult(result, options, theme) { const details = result.details as (AskToolDetails & { error?: string }) | undefined; if (details?.error) { return new Text(theme.fg("error", `✗ ${details.error}`), 0, 0); } if (options.isPartial) { const waitingText = result.content ?.filter((part: { type?: string; text?: string }) => part?.type === "text") .map((part: { text?: string }) => part.text ?? "") .join("\n") .trim() || "Waiting for user input..."; return new Text(theme.fg("muted", waitingText), 0, 0); } if (!details || details.cancelled || !details.response) { return new Text(theme.fg("warning", "Cancelled"), 0, 0); } const response = details.response; let text = theme.fg("success", "✓ "); if (response.kind === "freeform") { text += theme.fg("muted", "(wrote) "); } text += theme.fg("accent", formatResponseSummary(response)); if (options.expanded) { text += "\n" + theme.fg("dim", `Q: ${details.question}`); if (details.context) { text += "\n" + theme.fg("dim", `Previous context:\n${details.context}`); } if (details.description) { text += "\n" + theme.fg("dim", `Details:\n${details.description}`); } if (isSelectionResponse(response) && details.options.length > 0) { const selectedTitles = new Set(response.selections); text += "\n" + theme.fg("dim", "Options:"); for (const opt of details.options) { const desc = opt.description ? ` — ${opt.description}` : ""; const marker = selectedTitles.has(opt.title) ? theme.fg("success", "●") : theme.fg("dim", "○"); text += `\n ${marker} ${theme.fg("dim", opt.title)}${theme.fg("dim", desc)}`; } if (response.comment) { text += `\n${theme.fg("dim", "Comment:")} ${theme.fg("dim", response.comment)}`; } } } return new Text(text, 0, 0); }, }; pi.registerTool({ ...askUserTool, name: "agent_tick_ask_user", label: "Agent Tick Ask User", description: "Ask the user a question through Agent Tick-backed local/remote UI with optional multiple-choice answers. Use this to gather information interactively. Ask exactly one focused question per call. Keep previous decisions in previousContext, the concise ask in question, and expanded explanation in description.", promptSnippet: "Ask the user one focused question through Agent Tick-backed local/remote UI", promptGuidelines: [ "Before calling agent_tick_ask_user, gather context with tools (read/web/ref) and pass decisions or findings already established via previousContext, not in the question text.", "Use agent_tick_ask_user when the user's intent is ambiguous, when a decision requires explicit user input, or when multiple valid options exist.", "Ask exactly one focused question per agent_tick_ask_user call; keep question short and put expanded explanation in description.", "When chaining questions, put the prior answer(s) in previousContext.decisions and the next dependency in previousContext.nextStep so the UI shows that context before the new question.", "When you have a recommended option, set that option's flags to [\"favorite\"] so Agent Tick can highlight it remotely.", "Do not combine multiple numbered, multipart, or unrelated questions into one agent_tick_ask_user prompt.", ], async execute(toolCallId, params, signal, onUpdate, ctx) { return askUserTool.execute(toolCallId, { ...(params as Record), __agentTickMirroredRequired: true }, signal, onUpdate, ctx); }, renderCall(args, theme) { const question = (args.question as string) || ""; const rawOptions = Array.isArray(args.options) ? args.options : []; let text = theme.fg("toolTitle", theme.bold("agent_tick_ask_user ")); text += theme.fg("muted", question); if (rawOptions.length > 0) { const labels = rawOptions.map((o: unknown) => typeof o === "string" ? o : (o as QuestionOption)?.title ?? "", ); text += "\n" + theme.fg("dim", ` ${rawOptions.length} option(s): ${labels.join(", ")}`); } if (args.allowMultiple) { text += theme.fg("dim", " [multi-select]"); } if (args.allowComment) { text += theme.fg("dim", " [optional comment]"); } return new Text(text, 0, 0); }, }); // Compatibility alias for existing Pi agents, skills, and prompts that know // the inherited pi-ask-user tool name. pi.registerTool(askUserTool); }