// SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: 2026 avtc import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { Box, TruncatedText } from "@earendil-works/pi-tui"; import { buildNotificationDetail, handleSubagentForwarding, isSubagentSession, registerAskQuestionBridge, } from "./ask-question-bridge.ts"; import { renderQuestionsViaUI } from "./component.ts"; import { formatResultSummary, InputSchema, type Question, type Result } from "./schema.ts"; import { subscribeToDialogCoordinator, withCoordinator } from "./snippets/vendored/subscribe-to-dialog-coordinator.ts"; import { getLastMessage, subscribeToNotificationApi, withAttention, } from "./snippets/vendored/subscribe-to-notifications.ts"; import { validateUniqueness } from "./validate.ts"; // Idempotent wiring guard. ask-user-question can be bundled into the avtc-pi umbrella // AND installed standalone — whichever copy loads first wires, the rest no-op. const WIRED_KEY = "__avtcPiAskUserQuestionWired"; type GlobalWithWired = typeof globalThis & { [WIRED_KEY]?: boolean }; export default function (pi: ExtensionAPI) { const g = globalThis as GlobalWithWired; if (g[WIRED_KEY]) return; g[WIRED_KEY] = true; registerAskQuestionBridge(pi); subscribeToNotificationApi(pi); subscribeToDialogCoordinator(pi); pi.registerTool({ name: "ask_user_question", label: "Ask User", description: `Ask the user 1–4 clarifying questions before proceeding. Use this tool to: 1. Clarify ambiguous instructions 2. Get the user's preference between valid approaches 3. Make decisions on implementation choices 4. Offer choices about what direction to take Each question must have 2–4 options. Users can always select "Other" to type a free-text answer, so do not include an "Other" option yourself. Option labels should be concise (1–5 words). Set multiSelect: true when more than one option can validly apply at the same time. The header field is a short label (max 12 characters) used in the tab bar when showing multiple questions. If you recommend a specific option, make it the first option in the list and add "(Recommended)" at the end of the label. Always use this tool instead of asking questions in plain text — it provides a structured, interactive UI.`, parameters: InputSchema, async execute(_toolCallId, params, _signal, _onUpdate, ctx) { // Reject duplicate question texts or duplicate option labels const validationError = validateUniqueness(params.questions); if (validationError) { return { content: [{ type: "text", text: `Error: ${validationError}` }], details: { questions: params.questions, answers: {}, cancelled: true, } satisfies Result, }; } // Try the subagent bridge FIRST (works in both json and rpc child modes). In json the // child has hasUI=false; in rpc the child has hasUI=true but must still forward to the // root session over the inherited socket. handleSubagentForwarding is a no-op (null) when // the bridge is unavailable, so calling it first is safe and lets an RPC child forward. const forwarded = await handleSubagentForwarding(params, _signal); if (forwarded) return forwarded; if (isSubagentSession(ctx)) { // Non-interactive session with no bridge — deregister so the LLM won't try again. pi.setActiveTools(pi.getActiveTools().filter((name) => name !== "ask_user_question")); return { content: [ { type: "text", text: "Error: ask_user_question requires an interactive session. The tool has been disabled for this session.", }, ], details: { questions: params.questions, answers: {}, cancelled: true, } satisfies Result, }; } // Build notification detail: question summary + last assistant message for context const notificationDetail = buildNotificationDetail(params.questions, getLastMessage()); const result = await withAttention("ask_user_question", notificationDetail, () => withCoordinator(() => renderQuestionsViaUI(params.questions, ctx)), ); if (!result) { return { content: [{ type: "text", text: "User cancelled" }], details: { questions: params.questions, answers: {}, cancelled: true, } satisfies Result, }; } return { content: [{ type: "text", text: formatResultSummary(result) }], details: result satisfies Result, }; }, renderCall(args, theme) { const questions = (args.questions ?? []) as Question[]; const topics = questions.map((q) => q.header).join(", "); return new TruncatedText(theme.fg("toolTitle", theme.bold("ask user ")) + theme.fg("muted", topics), 0, 0); }, renderResult(result, _options, theme) { const details = result.details as Result | undefined; if (!details) { const t = result.content[0]; return new TruncatedText(t?.type === "text" ? t.text : "", 0, 0); } if (details.cancelled) { return new TruncatedText(theme.fg("warning", "Cancelled"), 0, 0); } // One TruncatedText per question — each line item truncated independently const box = new Box(0, 0); for (const q of details.questions) { const answer = details.answers[q.question] ?? "(no answer)"; box.addChild( new TruncatedText( theme.fg("success", "✓ ") + theme.fg("accent", `${q.header}: `) + theme.fg("text", answer), 0, 0, ), ); } return box; }, }); // Reset the idempotency guard on session shutdown so /reload can re-wire fresh. // pi re-evaluates extension modules on /reload but globalThis persists, so without // this reset the guard would short-circuit re-wiring and leave the extension dead. pi.on("session_shutdown", () => { (globalThis as GlobalWithWired)[WIRED_KEY] = false; }); }