ai: add ask_user tool extension
Assisted-by: GLM-5.3 via pi
change qvvpkrorvykszvovkwuswkvxlqwnrxyr
commit 35dafaea37c82673ca0e564fa97c92be5d48e10a
author Alpha Chen <alpha@kejadlen.dev>
date
parent ptvxzwrq
diff --git a/ai/pi/extensions/README.md b/ai/pi/extensions/README.md
index 439f7063..dc08ef67 100644
--- a/ai/pi/extensions/README.md
+++ b/ai/pi/extensions/README.md
@@ -27,6 +27,24 @@ pi has no built-in permission prompt, so `permission-gate.ts` emits
 `agent-state:blocked` events on the shared extension event bus around its
 dialogs, and this extension records `blocked` for their duration.
 
+# Ask User
+
+Gives the agent an `ask_user` tool: a question shows as a dialog mid-turn,
+and the answer returns as the tool result, so the agent keeps going instead
+of ending its turn and waiting for a chat reply.
+
+## How it works
+
+Built on the `ctx.ui` dialogs rather than a custom component, so it works
+in the TUI and over RPC. With `options`, the user picks from a list with
+an "Other…" escape hatch that opens a text input; without, the user types
+a free-form answer. Dismissing either dialog cancels the question and the
+tool reports that the user didn't answer. In headless modes (JSON, print)
+the tool returns an error telling the agent to ask in its reply instead.
+
+`permission-gate.ts` allowlists `ask_user` — gating a question behind a
+confirmation dialog would just be a second question.
+
 # Permission Gate
 
 Requires explicit user confirmation before every tool call, with
@@ -40,7 +58,8 @@ directories, and pi documentation.
 **Bash tool** — auto-allowed only if the command matches the allowlist
 and contains no shell chaining (`|`, `&&`, `;`, `` ` ``, `$()`).
 
-Everything else prompts for confirmation.
+Everything else prompts for confirmation, except `ask_user` (dialog-only,
+no side effects).
 
 ## Base Allowlist
 
diff --git a/ai/pi/extensions/ask-user.ts b/ai/pi/extensions/ask-user.ts
new file mode 100644
index 00000000..f3e6971d
--- /dev/null
+++ b/ai/pi/extensions/ask-user.ts
@@ -0,0 +1,135 @@
+/**
+ * ask_user - let the agent ask the user a question mid-turn.
+ *
+ * The question appears as a dialog instead of ending the turn in chat, so
+ * the answer comes back as a tool result and the agent continues where it
+ * left off. With options, the user picks from a list (with an "Other…"
+ * escape hatch to type instead); without, the user types a free-form reply.
+ *
+ * Uses the built-in ctx.ui dialogs rather than a custom component, so it
+ * also works over RPC (headless modes get an error result instead).
+ */
+
+import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
+import { Text } from "@earendil-works/pi-tui";
+import { Type } from "typebox";
+
+interface AskUserDetails {
+  question: string;
+  options: string[];
+  answer: string | null;
+  typed?: boolean;
+}
+
+const OTHER = "Other…";
+
+export default function askUser(pi: ExtensionAPI) {
+  pi.registerTool({
+    name: "ask_user",
+    label: "Ask user",
+    description:
+      "Ask the user a question and wait for their answer. Use whenever you " +
+      "need information or a decision from the user before continuing; the " +
+      "answer returns as the tool result and you keep going in the same " +
+      "turn. Pass options to offer a choice (the user can still type a " +
+      "different answer); omit them for a free-text answer. Prefer this " +
+      "over ending your turn with a question in plain text.",
+    parameters: Type.Object({
+      question: Type.String({ description: "The question to ask" }),
+      options: Type.Optional(
+        Type.Array(Type.String(), {
+          description: "Choices to offer; omit for a free-text answer",
+        }),
+      ),
+    }),
+    executionMode: "sequential",
+    annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
+
+    async execute(_toolCallId, params, signal, _onUpdate, ctx) {
+      const details = (answer: string | null, typed = false): AskUserDetails => ({
+        question: params.question,
+        options: params.options ?? [],
+        answer,
+        typed,
+      });
+
+      if (!ctx.hasUI) {
+        return {
+          content: [
+            {
+              type: "text",
+              text: "Error: ask_user has no UI in this mode. Ask the question in your reply instead.",
+            },
+          ],
+          isError: true,
+          details: details(null),
+        };
+      }
+
+      // Free-form question: go straight to the input dialog.
+      if (!params.options || params.options.length === 0) {
+        const answer = await ctx.ui.input(params.question, "Your answer…", { signal });
+        if (answer === undefined) {
+          return {
+            content: [{ type: "text", text: "User dismissed the question without answering." }],
+            details: details(null, true),
+          };
+        }
+        return {
+          content: [{ type: "text", text: `User answered: ${answer}` }],
+          details: details(answer, true),
+        };
+      }
+
+      const labels = params.options.filter((o) => o !== OTHER);
+      const choice = await ctx.ui.select(params.question, [...labels, OTHER], { signal });
+      if (choice === undefined) {
+        return {
+          content: [{ type: "text", text: "User dismissed the question without answering." }],
+          details: details(null),
+        };
+      }
+
+      if (choice === OTHER) {
+        const answer = await ctx.ui.input(params.question, "Your answer…", { signal });
+        if (answer === undefined) {
+          return {
+            content: [{ type: "text", text: "User dismissed the question without answering." }],
+            details: details(null, true),
+          };
+        }
+        return {
+          content: [{ type: "text", text: `User answered: ${answer}` }],
+          details: details(answer, true),
+        };
+      }
+
+      return {
+        content: [{ type: "text", text: `User selected: ${choice}` }],
+        details: details(choice),
+      };
+    },
+
+    renderCall(args, theme) {
+      let text = theme.fg("toolTitle", theme.bold("ask_user ")) + theme.fg("muted", args.question);
+      if (args.options?.length) {
+        const numbered = [...args.options, OTHER].map((o, i) => `${i + 1}. ${o}`);
+        text += `\n${theme.fg("dim", `  Options: ${numbered.join(", ")}`)}`;
+      }
+      return new Text(text, 0, 0);
+    },
+
+    renderResult(result, _options, theme) {
+      const details = result.details as AskUserDetails | undefined;
+      if (!details) {
+        const text = result.content[0];
+        return new Text(text?.type === "text" ? text.text : "", 0, 0);
+      }
+      if (details.answer === null) {
+        return new Text(theme.fg("warning", "Dismissed"), 0, 0);
+      }
+      const prefix = details.typed ? "✓ (typed) " : "✓ ";
+      return new Text(theme.fg("success", prefix) + theme.fg("accent", details.answer), 0, 0);
+    },
+  });
+}
diff --git a/ai/pi/extensions/permission-gate.ts b/ai/pi/extensions/permission-gate.ts
index 3c350d7f..fbb7d0dc 100644
--- a/ai/pi/extensions/permission-gate.ts
+++ b/ai/pi/extensions/permission-gate.ts
@@ -280,6 +280,9 @@ function isTrackedFile(filePath: string, ctx: ExtensionContext): boolean {
 }
 
 function isAllowed(toolName: string, input: Record<string, unknown>, ctx: ExtensionContext): boolean {
+  // Dialog-only, no side effects; prompting to ask a prompt would be absurd.
+  if (toolName === "ask_user") return true;
+
   if (toolName === "read") {
     const filePath = path.resolve(ctx.cwd, String(input.path ?? ""));
     // Resolve symlinks so a tracked symlink pointing outside the repo