Compare equivalent Dashboard and terminal treatments before runtime wiring begins.
+
+
+ Shared contract
+ Options stay visible while typing
+ Free text is available by default
+ Multiple selections combine with custom text
+ Skip, Escape, timeout, and abort always continue safely
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+const prototypeStyles = `
+:root { color-scheme: dark; font-family: "IBM Plex Mono", "SFMono-Regular", Consolas, monospace; background: #0a0a0a; color: #f2f2f2; }
+* { box-sizing: border-box; }
+body { margin: 0; min-width: 320px; background: radial-gradient(circle at 50% -20%, #243226 0, #0a0a0a 38rem); }
+button, input, textarea { font: inherit; }
+button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px solid #8bd99b; outline-offset: 2px; }
+main { width: min(1240px, calc(100% - 32px)); margin: 0 auto; padding: 40px 0 64px; }
+.prototype-header { display: grid; grid-template-columns: minmax(0, 1fr) minmax(300px, 440px); gap: 32px; align-items: end; border-bottom: 1px solid #424242; padding-bottom: 26px; }
+.eyebrow { color: #8bd99b; text-transform: uppercase; letter-spacing: .12em; font-size: 12px; margin: 0 0 10px; }
+h1 { font-size: clamp(24px, 4vw, 42px); line-height: 1.08; margin: 0; max-width: 720px; }
+h1 code { color: #8bd99b; }
+.intro { color: #adadad; max-width: 620px; line-height: 1.55; }
+.contract { border: 1px solid #48594c; background: #111a13; padding: 16px; display: grid; gap: 7px; font-size: 12px; color: #c8d6ca; }
+.contract strong { color: #8bd99b; margin-bottom: 4px; }
+.contract span::before { content: "✓ "; color: #8bd99b; }
+.scenario-picker { display: flex; flex-wrap: wrap; gap: 8px; margin: 24px 0; }
+.scenario-picker button { color: #bdbdbd; background: #111; border: 1px solid #444; padding: 8px 12px; cursor: pointer; }
+.scenario-picker button:hover { border-color: #777; color: white; }
+.scenario-picker button.active { background: #d9f7df; border-color: #d9f7df; color: #101510; }
+.comparison { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; align-items: start; }
+.surface { min-width: 0; }
+.surface-heading { display: flex; justify-content: space-between; align-items: center; color: #aaa; font-size: 12px; text-transform: uppercase; letter-spacing: .1em; margin-bottom: 8px; }
+.attention { color: #f0be62; letter-spacing: 0; text-transform: none; }
+.question-frame { min-height: 510px; border: 1px solid #555; background: #151515; box-shadow: 0 20px 50px #0008; padding: 24px; }
+.surface-dashboard .question-frame { border-radius: 8px; background: #171817; }
+.surface-tui .question-frame { border: 1px solid #7b7b7b; box-shadow: inset 0 0 0 1px #202020, 0 20px 50px #0008; }
+.surface-tui .question-frame::before { content: "┌─ dreb question ─────────────────────────────┐"; display: block; color: #777; overflow: hidden; white-space: nowrap; margin: -19px -15px 16px; }
+.question-frame > header { display: flex; align-items: start; justify-content: space-between; gap: 16px; padding-bottom: 14px; border-bottom: 1px solid #414141; }
+.question-frame > header strong { color: #fff; }
+.surface-tui .question-frame > header strong { color: #8bd99b; }
+.timer { color: #91c79b; white-space: nowrap; font-size: 12px; }
+.timer.expired { color: #e69686; }
+.question { color: #ddd; line-height: 1.5; margin: 18px 0; }
+fieldset { border: 0; padding: 0; margin: 0 0 18px; display: grid; gap: 7px; }
+legend { color: #888; font-size: 11px; text-transform: uppercase; letter-spacing: .08em; margin-bottom: 8px; }
+.option { display: flex; gap: 10px; align-items: center; border: 1px solid #3d3d3d; padding: 10px 12px; cursor: pointer; color: #d0d0d0; }
+.surface-dashboard .option { border-radius: 5px; }
+.option:hover { border-color: #777; }
+.option.selected { border-color: #78c989; background: #18251b; color: white; }
+.surface-dashboard .option input { width: 16px; height: 16px; margin: 0 2px 0 0; accent-color: #78c989; flex: 0 0 auto; cursor: pointer; }
+.surface-tui .option input { position: absolute; opacity: 0; pointer-events: none; }
+.control-glyph { color: #777; min-width: 26px; }
+.option.selected .control-glyph { color: #8bd99b; }
+.custom-answer { display: grid; gap: 7px; }
+.custom-answer label { color: #aaa; font-size: 12px; }
+.custom-answer input, .custom-answer textarea { width: 100%; color: #f2f2f2; background: #0b0b0b; border: 1px solid #555; border-radius: 4px; padding: 10px 11px; resize: vertical; }
+.surface-tui .custom-answer input, .surface-tui .custom-answer textarea { border-radius: 0; border-color: #777; }
+footer { border-top: 1px solid #414141; margin-top: 22px; padding-top: 16px; display: flex; gap: 16px; justify-content: space-between; align-items: center; }
+.hint { color: #777; font-size: 11px; }
+.actions { display: flex; gap: 8px; }
+.button { border: 1px solid #666; padding: 8px 12px; cursor: pointer; }
+.button.secondary { color: #ccc; background: transparent; }
+.button.primary { color: #101510; background: #a9e6b5; border-color: #a9e6b5; }
+.button:disabled { opacity: .35; cursor: not-allowed; }
+.result, .terminal-state { display: block; width: 100%; margin-top: 22px; border: 1px solid #4f7657; background: #142019; color: #bce4c4; padding: 14px; line-height: 1.45; }
+.result-skipped, .terminal-state { border-color: #6e624b; background: #211d14; color: #e4d4b5; }
+.terminal-state { min-height: 150px; display: grid; place-content: center; text-align: center; gap: 10px; }
+.terminal-state span { color: #b8ab92; max-width: 400px; }
+@media (max-width: 850px) { .prototype-header, .comparison { grid-template-columns: 1fr; } .question-frame { min-height: 0; } }
+@media (max-width: 500px) { main { width: min(100% - 20px, 1240px); padding-top: 24px; } .question-frame { padding: 17px; } footer { align-items: stretch; flex-direction: column; } .actions { justify-content: flex-end; } }
+`;
+
+const root = document.querySelector("#app");
+if (root) render(() => , root);
diff --git a/packages/dashboard/test/client/ask-user.browser.test.ts b/packages/dashboard/test/client/ask-user.browser.test.ts
new file mode 100644
index 00000000..171b116c
--- /dev/null
+++ b/packages/dashboard/test/client/ask-user.browser.test.ts
@@ -0,0 +1,142 @@
+import { type AddressInfo, createServer, type Server } from "node:http";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { type Browser, chromium, type Page } from "playwright";
+import { build as viteBuild } from "vite";
+import solid from "vite-plugin-solid";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+
+let browser: Browser;
+let server: Server;
+let base: string;
+const root = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
+
+beforeAll(async () => {
+ const bundle = await viteBuild({
+ configFile: false,
+ logLevel: "error",
+ root,
+ plugins: [solid()],
+ build: {
+ minify: false,
+ write: false,
+ rollupOptions: { input: resolve(root, "test/client/ask-user.browser-harness.tsx") },
+ },
+ });
+ const outputs = Array.isArray(bundle) ? bundle.flatMap((item) => item.output) : bundle.output;
+ const entry = outputs.find((item) => item.type === "chunk" && item.isEntry);
+ if (!entry || entry.type !== "chunk") throw new Error("ask_user browser harness bundle has no entry chunk");
+ const script = entry.code.replaceAll(" {
+ response.setHeader("content-type", "text/html; charset=utf-8");
+ response.end(``);
+ });
+ await new Promise((resolveStarted) => server.listen(0, "127.0.0.1", resolveStarted));
+ base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
+ browser = await chromium.launch({ headless: true });
+}, 30_000);
+
+afterAll(async () => {
+ await browser?.close();
+ if (server) await new Promise((resolveClosed) => server.close(() => resolveClosed()));
+});
+
+async function openHarness(): Promise {
+ const page = await browser.newPage({ viewport: { width: 1400, height: 1000 } });
+ page.setDefaultTimeout(2_000);
+ await page.goto(base);
+ await page.getByRole("heading", { name: /ask_user cross-surface interaction/ }).waitFor();
+ return page;
+}
+
+function surface(page: Page, name: "dashboard" | "tui") {
+ return page.getByTestId(`${name}-surface`);
+}
+
+describe("ask_user UX prototype", () => {
+ it("shows equivalent single-choice, free-text, skip, and attention affordances", async () => {
+ const page = await openHarness();
+ for (const name of ["dashboard", "tui"] as const) {
+ const panel = surface(page, name);
+ expect(await panel.getByText("◆ needs attention").textContent()).toContain("needs attention");
+ expect(await panel.getByRole("radio").count()).toBe(3);
+ if (name === "dashboard") {
+ expect(await panel.locator(".control-glyph").count()).toBe(0);
+ expect(
+ await panel
+ .getByRole("radio")
+ .first()
+ .evaluate((input) => getComputedStyle(input).opacity),
+ ).toBe("1");
+ } else {
+ expect(await panel.locator(".control-glyph").count()).toBe(3);
+ }
+ await panel.getByText("SQLite", { exact: true }).click();
+ await panel.getByLabel("Or type your own answer").fill("with WAL enabled");
+ await panel.getByRole("button", { name: "Submit answer" }).click();
+ const result = panel.getByRole("status");
+ await result.waitFor();
+ expect(await result.textContent()).toContain("SQLite; with WAL enabled");
+ expect(await result.evaluate((output) => getComputedStyle(output).display)).toBe("block");
+ expect(
+ await result.evaluate((output) => {
+ const input = output.previousElementSibling?.querySelector("input");
+ return input ? output.getBoundingClientRect().top > input.getBoundingClientRect().bottom : false;
+ }),
+ ).toBe(true);
+ }
+ await page.close();
+ });
+
+ it("combines multiple selections with custom text on both surfaces", async () => {
+ const page = await openHarness();
+ await page.getByRole("button", { name: "multiple choice" }).click();
+ for (const name of ["dashboard", "tui"] as const) {
+ const panel = surface(page, name);
+ await panel.getByText("Unit tests", { exact: true }).click();
+ await panel.getByText("Type checking", { exact: true }).click();
+ await panel.getByLabel("Or type your own answer").fill("Run smoke tests too");
+ await panel.getByRole("button", { name: "Submit answer" }).click();
+ const result = await panel.getByRole("status").textContent();
+ expect(result).toContain("Unit tests; Type checking; Run smoke tests too");
+ }
+ await page.close();
+ });
+
+ it("covers free-text, multiline, skip, timeout, and abort states", async () => {
+ const page = await openHarness();
+
+ await page.getByRole("button", { name: "free text" }).click();
+ expect(await surface(page, "dashboard").getByLabel("Your answer").getAttribute("type")).toBe("text");
+
+ await page.getByRole("button", { name: "multiline" }).click();
+ expect(
+ await surface(page, "tui")
+ .getByLabel("Your answer")
+ .evaluate((element) => element.tagName),
+ ).toBe("TEXTAREA");
+
+ await surface(page, "dashboard").getByRole("button", { name: "Skip" }).click();
+ expect(await surface(page, "dashboard").getByRole("status").textContent()).toContain("Question skipped");
+
+ await page.getByRole("button", { name: "timeout" }).click();
+ for (const name of ["dashboard", "tui"] as const) {
+ expect(await surface(page, name).getByRole("status").textContent()).toContain("No answer received");
+ }
+
+ await page.getByRole("button", { name: "abort" }).click();
+ for (const name of ["dashboard", "tui"] as const) {
+ expect(await surface(page, name).getByRole("status").textContent()).toContain("Question cancelled");
+ }
+ await page.close();
+ });
+
+ it("maps Escape to the explicit safe skip route", async () => {
+ const page = await openHarness();
+ await page.keyboard.press("Escape");
+ for (const name of ["dashboard", "tui"] as const) {
+ expect(await surface(page, name).getByRole("status").textContent()).toContain("Question skipped");
+ }
+ await page.close();
+ });
+});
diff --git a/packages/dashboard/test/client/ask-user.vite.config.ts b/packages/dashboard/test/client/ask-user.vite.config.ts
new file mode 100644
index 00000000..084d8ea0
--- /dev/null
+++ b/packages/dashboard/test/client/ask-user.vite.config.ts
@@ -0,0 +1,21 @@
+import { resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { defineConfig } from "vite";
+import solid from "vite-plugin-solid";
+
+const root = fileURLToPath(new URL(".", import.meta.url));
+
+export default defineConfig({
+ plugins: [solid()],
+ root,
+ server: {
+ open: "/ask-user.browser-harness.html",
+ },
+ build: {
+ outDir: resolve(root, "../../.ask-user-prototype"),
+ emptyOutDir: true,
+ rollupOptions: {
+ input: resolve(root, "ask-user.browser-harness.html"),
+ },
+ },
+});
From 440ba36f0138c585ab63e5fcc1ff3d123a20da21 Mon Sep 17 00:00:00 2001
From: Maximilian Scheurer <11406414+maxscheurer@users.noreply.github.com>
Date: Wed, 29 Jul 2026 08:10:40 +0200
Subject: [PATCH 03/15] Wire up ask_user tool across TUI, Dashboard, and RPC
(Stage 2)
---
README.md | 2 +-
packages/coding-agent/README.md | 6 +-
packages/coding-agent/docs/extensions.md | 22 +-
packages/coding-agent/docs/rpc.md | 27 +-
.../coding-agent/src/core/agent-session.ts | 8 +-
.../coding-agent/src/core/extensions/index.ts | 2 +
.../src/core/extensions/runner.ts | 1 +
.../coding-agent/src/core/extensions/types.ts | 38 +++
.../coding-agent/src/core/tools/ask-user.ts | 233 +++++++++++++++
packages/coding-agent/src/core/tools/index.ts | 12 +
.../modes/interactive/components/ask-user.ts | 267 ++++++++++++++++++
.../src/modes/interactive/interactive-mode.ts | 55 ++++
.../coding-agent/src/modes/rpc/rpc-mode.ts | 22 ++
.../coding-agent/src/modes/rpc/rpc-types.ts | 13 +
.../test/ask-user-component.test.ts | 120 ++++++++
packages/coding-agent/test/tools.test.ts | 54 ++++
.../coding-agent/test/tools/ask-user.test.ts | 206 ++++++++++++++
.../dashboard/src/client/screens/session.tsx | 126 ++++++++-
.../dashboard/src/client/state/reducer.ts | 21 +-
packages/dashboard/src/client/styles/app.css | 56 ++++
packages/dashboard/src/server/runtime-pool.ts | 8 +-
packages/dashboard/test/client/pwa.test.tsx | 32 +++
.../dashboard/test/client/screens.test.tsx | 77 +++++
packages/dashboard/test/reducer.test.ts | 30 ++
24 files changed, 1418 insertions(+), 20 deletions(-)
create mode 100644 packages/coding-agent/src/core/tools/ask-user.ts
create mode 100644 packages/coding-agent/src/modes/interactive/components/ask-user.ts
create mode 100644 packages/coding-agent/test/ask-user-component.test.ts
create mode 100644 packages/coding-agent/test/tools/ask-user.test.ts
diff --git a/README.md b/README.md
index d3a2f404..32287359 100644
--- a/README.md
+++ b/README.md
@@ -74,7 +74,7 @@ bunx --force dreb
### Tools and interaction
-dreb ships with 12 built-in tools: `read`, `write`, `edit`, `bash`, `grep`, `find`, `ls`, `web_search`, `web_fetch`, `subagent`, `wait`, and `search` (semantic codebase search). Two more tools are always active: `skill` for loading workflows, and `tasks_update` for visible task tracking. `suggest_next` (ghost text command suggestions, Tab to accept) is active by default but excluded when `--tools` is specified.
+dreb ships with 13 built-in tools: `read`, `write`, `edit`, `bash`, `grep`, `find`, `ls`, `web_search`, `web_fetch`, `subagent`, `wait`, `search` (semantic codebase search), and `ask_user` (pause and ask the user a structured multiple-choice or free-text question, rendered natively in the TUI and Dashboard). Two more tools are always active: `skill` for loading workflows, and `tasks_update` for visible task tracking. `suggest_next` (ghost text command suggestions, Tab to accept) is active by default but excluded when `--tools` is specified.
Interactive mode adds slash commands such as `/model`, `/settings`, `/resume`, `/tree`, `/fork`, `/compact`, `/dream`, `/buddy`, `/export`, `/reload`, and `/hotkeys`. The message queue lets you steer a running agent or queue follow-up work without waiting for the current turn to finish.
diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md
index a5e610e9..3b0961c6 100644
--- a/packages/coding-agent/README.md
+++ b/packages/coding-agent/README.md
@@ -79,7 +79,7 @@ dreb
Or use a custom provider (corporate proxy, Bedrock, etc.) — see [Custom providers & models](#providers--models).
-Then just talk to dreb. All 11 built-in tools are enabled by default: `read`, `write`, `edit`, `bash`, `grep`, `find`, `ls`, `web_search`, `web_fetch`, `subagent`, and `wait`. Use `--tools` to restrict to a subset (e.g., `--tools read,grep,find,ls` for read-only). Three additional tools — `search`, `skill`, and `tasks_update` — are always active regardless of `--tools`. `suggest_next` is active by default but excluded when `--tools` is specified. The model uses these to fulfill your requests. Add capabilities via [skills](#skills), [prompt templates](#prompt-templates), [extensions](#extensions), or [packages](#packages).
+Then just talk to dreb. All 12 built-in tools are enabled by default: `read`, `write`, `edit`, `bash`, `grep`, `find`, `ls`, `web_search`, `web_fetch`, `subagent`, `wait`, and `ask_user`. Use `--tools` to restrict to a subset (e.g., `--tools read,grep,find,ls` for read-only). Three additional tools — `search`, `skill`, and `tasks_update` — are always active regardless of `--tools`. `suggest_next` is active by default but excluded when `--tools` is specified. The model uses these to fulfill your requests. Add capabilities via [skills](#skills), [prompt templates](#prompt-templates), [extensions](#extensions), or [packages](#packages).
**Also available:** [`@dreb/telegram`](https://www.npmjs.com/package/@dreb/telegram) — run dreb as a Telegram bot with live tool status and visible results for user-facing tools (`npm install -g @dreb/telegram`). [`@dreb/dashboard`](https://www.npmjs.com/package/@dreb/dashboard) — run `dreb dashboard` for a browser UI with fleet overview, full chat steering, inline provider/API failures with partial output preserved, sanitized raster tool images plus sent user uploads retained as bounded transcript previews by default, subagent observability, host file browser, curated appearance themes (per-browser light/dark), and Tailscale/rotating-code pairing (`npm install -g @dreb/dashboard`; see [docs/dashboard.md](docs/dashboard.md)). Tool images cross browser-facing transport as content-addressed references; browser-local Settings offers placeholders, bounded previews, or informed-opt-in originals, with size disclosure and confirmation above 1 MiB. Full-resolution HTML export remains self-contained. Compact SSE snapshots update live fleet cards without repeatedly fetching the cross-project inventory, and session drill-in hydrates state, messages, and background agents through one ordered snapshot request. Terminal provider failures show their reason on fleet cards, while transient failures clear terminal state when automatic retry begins and remain recorded inline on the failed attempt. Its top bar and persistent session header indicators report connecting, connected, retrying, resyncing, disconnected, or auth failed; bounded SSE replay plus an explicit snapshot barrier restores session state, tasks, and image references after a reload, restart, gap, backpressure disconnect, or stalled stream, while authenticated image routes recover bytes separately from authoritative transcripts.
@@ -639,7 +639,9 @@ cat README.md | dreb -p "Summarize this text"
| `--tools ` | Comma-separated list of tools to enable (default: all) |
| `--no-tools` | Disable all built-in tools (extension tools still work) |
-Available built-in tools: `read`, `bash`, `edit`, `write`, `grep`, `find`, `ls`, `web_search`, `web_fetch`, `subagent`, `wait`, `search`
+Available built-in tools: `read`, `bash`, `edit`, `write`, `grep`, `find`, `ls`, `web_search`, `web_fetch`, `subagent`, `wait`, `search`, `ask_user`
+
+`ask_user` pauses the turn and asks you a structured clarifying question — optional 2-4 multiple-choice options (single- or multi-select) plus a "type your own answer" field — rendered natively in the TUI and the Dashboard, and over RPC. Skipping, Escape, tool abort, timeout, and headless/no-UI modes all resolve gracefully, so the agent never deadlocks waiting on an absent user. Concurrent `ask_user` calls are shown strictly one at a time (FIFO). In the TUI: `↑`/`↓` move between options and the free-text field, `Space` toggles a checkbox (multi-select), `Enter` submits, and `Esc` skips.
Three additional tools are always active but don't appear in `--tools`:
- `skill` — invokes [skills](#skills) programmatically
diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md
index 1d984a02..6520c6c1 100644
--- a/packages/coding-agent/docs/extensions.md
+++ b/packages/coding-agent/docs/extensions.md
@@ -9,7 +9,7 @@ Extensions are TypeScript modules that extend dreb's behavior. They can subscrib
**Key capabilities:**
- **Custom tools** - Register tools the LLM can call via `dreb.registerTool()`
- **Event interception** - Block or modify tool calls, inject context, customize compaction
-- **User interaction** - Prompt users via `ctx.ui` (select, confirm, input, notify)
+- **User interaction** - Prompt users via `ctx.ui` (select, confirm, input, ask, notify)
- **Custom UI components** - Full TUI components with keyboard input via `ctx.ui.custom()` for complex interactions
- **Custom commands** - Register commands like `/mycommand` via `dreb.registerCommand()`
- **Session persistence** - Store state that survives restarts via `dreb.appendEntry()`
@@ -161,6 +161,26 @@ export default function (dreb: ExtensionAPI) {
ctx.ui.notify("Done!", "success");
ctx.ui.setStatus("my-ext", "Processing..."); // Footer status
ctx.ui.setWidget("my-ext", ["Line 1", "Line 2"]); // Widget above editor (default)
+
+ // ctx.ui.ask — a rich clarifying question with options + free text,
+ // rendered natively in the TUI and Dashboard (and over RPC). Resolves to
+ // { selected: string[], customText?: string }, or undefined when skipped,
+ // cancelled, or timed out. This is the same primitive that powers the
+ // built-in `ask_user` tool.
+ const answer = await ctx.ui.ask(
+ {
+ title: "Choose a database",
+ question: "Which persistence strategy should I use?",
+ options: ["SQLite", "PostgreSQL", "Keep the JSON file"],
+ allowFreeText: true, // default; offers a "type your own answer" field
+ multiSelect: false, // true → checkboxes, combined with any free text
+ multiline: false, // true → multi-line free-text area
+ },
+ { signal, timeout: 60000 }, // both optional; absent user never deadlocks
+ );
+ if (!answer) {
+ // user skipped / cancelled / timed out — continue gracefully
+ }
});
// Register tools, commands, shortcuts, flags
diff --git a/packages/coding-agent/docs/rpc.md b/packages/coding-agent/docs/rpc.md
index fdc24aad..2648fe53 100644
--- a/packages/coding-agent/docs/rpc.md
+++ b/packages/coding-agent/docs/rpc.md
@@ -1874,7 +1874,7 @@ Extensions can request user interaction via `ctx.ui.select()`, `ctx.ui.confirm()
There are two categories of extension UI methods:
-- **Dialog methods** (`select`, `confirm`, `input`, `editor`): emit an `extension_ui_request` on stdout and block until the client sends back an `extension_ui_response` on stdin with the matching `id`.
+- **Dialog methods** (`select`, `confirm`, `input`, `editor`, `ask`): emit an `extension_ui_request` on stdout and block until the client sends back an `extension_ui_response` on stdin with the matching `id`.
- **Fire-and-forget methods** (`notify`, `setStatus`, `setWidget`, `setTitle`, `set_editor_text`): emit an `extension_ui_request` on stdout but do not expect a response. The client can display the information or ignore it.
If a dialog method includes a `timeout` field, the agent-side will auto-resolve with a default value when the timeout expires. The client does not need to track timeouts.
@@ -1961,6 +1961,31 @@ Open a multi-line text editor with optional prefilled content.
Expected response: `extension_ui_response` with `value` (the edited text) or `cancelled: true`.
+#### ask
+
+Ask the user a rich clarifying question with optional single- or multi-select options and an optional free-text field. This powers the built-in `ask_user` tool. `options` (2-4) is optional; `allowFreeText` (default `true`), `multiSelect`, and `multiline` are optional booleans.
+
+```json
+{
+ "type": "extension_ui_request",
+ "id": "uuid-5",
+ "method": "ask",
+ "title": "Choose a database",
+ "question": "Which persistence strategy should I use?",
+ "options": ["SQLite", "PostgreSQL", "Keep the JSON file"],
+ "allowFreeText": true,
+ "multiSelect": false,
+ "multiline": false,
+ "timeout": 60000
+}
+```
+
+Expected response: `extension_ui_response` with `selected` (array of chosen option strings, possibly empty) and optional `customText` (the typed answer), or `cancelled: true` to skip. The client should treat an empty `selected` with no `customText` as a skip.
+
+```json
+{ "type": "extension_ui_response", "id": "uuid-5", "selected": ["SQLite"], "customText": "with WAL enabled" }
+```
+
#### notify
Display a notification. Fire-and-forget, no response expected.
diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts
index ae2d6311..9c69c554 100644
--- a/packages/coding-agent/src/core/agent-session.ts
+++ b/packages/coding-agent/src/core/agent-session.ts
@@ -2901,10 +2901,16 @@ export class AgentSession {
? wrapRegisteredTools(allCustomTools, this._extensionRunner)
: [];
+ // Give base tools a per-execution extension context so built-ins like
+ // ask_user can reach ctx.ui. createContext() snapshots hasUI at call time,
+ // so print/RPC-without-host modes degrade to the no-op UI (hasUI === false).
+ const baseToolCtxFactory = this._extensionRunner
+ ? () => (this._extensionRunner as ExtensionRunner).createContext()
+ : undefined;
const toolRegistry = new Map(
Array.from(this._baseToolDefinitions.values()).map((definition) => [
definition.name,
- wrapToolDefinition(definition),
+ wrapToolDefinition(definition, baseToolCtxFactory),
]),
);
for (const tool of wrappedExtensionTools as AgentTool[]) {
diff --git a/packages/coding-agent/src/core/extensions/index.ts b/packages/coding-agent/src/core/extensions/index.ts
index 793d24ba..3089fa4e 100644
--- a/packages/coding-agent/src/core/extensions/index.ts
+++ b/packages/coding-agent/src/core/extensions/index.ts
@@ -28,6 +28,8 @@ export type {
AppendEntryHandler,
// App keybindings (for custom editors)
AppKeybinding,
+ AskRequest,
+ AskResult,
// Events - Tool (ToolCallEvent types)
BashToolCallEvent,
BashToolResultEvent,
diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts
index 106f9184..d658bc25 100644
--- a/packages/coding-agent/src/core/extensions/runner.ts
+++ b/packages/coding-agent/src/core/extensions/runner.ts
@@ -174,6 +174,7 @@ const noOpUIContext: ExtensionUIContext = {
select: async () => undefined,
confirm: async () => false,
input: async () => undefined,
+ ask: async () => undefined,
notify: () => {},
onTerminalInput: () => () => {},
setStatus: () => {},
diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts
index 8c730bac..1ad6ca8d 100644
--- a/packages/coding-agent/src/core/extensions/types.ts
+++ b/packages/coding-agent/src/core/extensions/types.ts
@@ -84,6 +84,37 @@ export interface ExtensionUIDialogOptions {
timeout?: number;
}
+/**
+ * A rich question for {@link ExtensionUIContext.ask}. Presents optional
+ * suggested options (single- or multi-select) alongside an optional free-text
+ * field, rendered natively in every surface (TUI, Dashboard, RPC host).
+ */
+export interface AskRequest {
+ /** The question to ask the user. */
+ question: string;
+ /** Short bold header. Defaults to a generic prompt title when omitted. */
+ title?: string;
+ /** 2-4 suggested options. */
+ options?: string[];
+ /** Offer a "type your own answer" field. Defaults to true. */
+ allowFreeText?: boolean;
+ /** Render options as checkboxes (multiple answers) instead of radios. */
+ multiSelect?: boolean;
+ /** Use a multi-line text area for the free-text field. */
+ multiline?: boolean;
+}
+
+/**
+ * The user's answer to an {@link AskRequest}. A cancelled/skipped/timed-out
+ * question resolves to `undefined` instead of an {@link AskResult}.
+ */
+export interface AskResult {
+ /** Options the user selected (empty when only free text was provided). */
+ selected: string[];
+ /** Free-text answer, when the user typed one. */
+ customText?: string;
+}
+
/** Placement for extension widgets. */
export type WidgetPlacement = "aboveEditor" | "belowEditor";
@@ -110,6 +141,13 @@ export interface ExtensionUIContext {
/** Show a text input dialog. */
input(title: string, placeholder?: string, opts?: ExtensionUIDialogOptions): Promise;
+ /**
+ * Show a rich question with suggested options, optional free text, and
+ * single- or multi-select. Resolves to the user's answer, or `undefined`
+ * when the question is skipped, cancelled, or times out.
+ */
+ ask(request: AskRequest, opts?: ExtensionUIDialogOptions): Promise;
+
/** Show a notification to the user. */
notify(message: string, type?: "info" | "warning" | "error"): void;
diff --git a/packages/coding-agent/src/core/tools/ask-user.ts b/packages/coding-agent/src/core/tools/ask-user.ts
new file mode 100644
index 00000000..048d2ac6
--- /dev/null
+++ b/packages/coding-agent/src/core/tools/ask-user.ts
@@ -0,0 +1,233 @@
+/**
+ * ask_user tool.
+ *
+ * Lets the agent pause and ask the user a structured clarifying question —
+ * with optional multiple-choice options, single- or multi-select, and a
+ * "type your own answer" free-text field — rendered natively in the TUI, the
+ * Dashboard, and over RPC. Cancelling, skipping, aborting, or timing out
+ * always resolves gracefully so the agent never deadlocks on an absent user.
+ *
+ * Concurrent calls are serialized through a per-session FIFO queue: only one
+ * question is ever shown at a time, and a queued call whose signal aborts
+ * resolves as skipped without opening any UI.
+ */
+
+import { Text } from "@dreb/tui";
+import { type Static, Type } from "@sinclair/typebox";
+import type { AskRequest, AskResult, ExtensionContext, ToolDefinition } from "../extensions/types.js";
+
+// ============================================================================
+// Types
+
+export interface AskUserDetails {
+ question: string;
+ title?: string;
+ selected: string[];
+ customText?: string;
+ /** True when the user skipped/cancelled/timed out. */
+ skipped: boolean;
+ /** True when no interactive UI was available (headless/print mode). */
+ unavailable: boolean;
+}
+
+// ============================================================================
+// Schema
+
+const askUserSchema = Type.Object({
+ question: Type.String({
+ description: "The question to ask the user. Be specific about what you need to decide.",
+ }),
+ title: Type.Optional(
+ Type.String({
+ description: "Short bold header shown above the question.",
+ }),
+ ),
+ options: Type.Optional(
+ Type.Array(Type.String(), {
+ minItems: 2,
+ maxItems: 4,
+ description: "2-4 suggested answers the user can pick from.",
+ }),
+ ),
+ allowFreeText: Type.Optional(
+ Type.Boolean({
+ description: "Offer a 'type your own answer' field. Defaults to true.",
+ }),
+ ),
+ multiSelect: Type.Optional(
+ Type.Boolean({
+ description: "Allow selecting multiple options (checkboxes). Only meaningful with options.",
+ }),
+ ),
+ multiline: Type.Optional(
+ Type.Boolean({
+ description: "Use a large multi-line text area for open-ended answers.",
+ }),
+ ),
+});
+
+export type AskUserInput = Static;
+
+// ============================================================================
+// Result helpers
+
+function textResult(text: string, details: AskUserDetails) {
+ return {
+ content: [{ type: "text" as const, text }],
+ details,
+ };
+}
+
+function baseDetails(input: AskUserInput): Omit {
+ return { question: input.question, title: input.title };
+}
+
+function unavailableResult(input: AskUserInput) {
+ return textResult(
+ "The ask_user tool requires an interactive UI, which is not available in this mode. " +
+ "Proceed using your best judgment without waiting for an answer.",
+ { ...baseDetails(input), selected: [], skipped: true, unavailable: true },
+ );
+}
+
+function skippedResult(input: AskUserInput) {
+ return textResult("The user skipped the question without answering. Continue without this input.", {
+ ...baseDetails(input),
+ selected: [],
+ skipped: true,
+ unavailable: false,
+ });
+}
+
+function answeredResult(input: AskUserInput, answer: AskResult) {
+ const customText = answer.customText?.trim() || undefined;
+ const selected = answer.selected;
+ const parts: string[] = [];
+ if (selected.length > 0) {
+ parts.push(
+ selected.length === 1 ? `The user selected: ${selected[0]}` : `The user selected: ${selected.join(", ")}`,
+ );
+ }
+ if (customText) {
+ parts.push(selected.length > 0 ? `They also wrote: "${customText}"` : `The user answered: "${customText}"`);
+ }
+ return textResult(parts.join(" "), {
+ ...baseDetails(input),
+ selected,
+ customText,
+ skipped: false,
+ unavailable: false,
+ });
+}
+
+// ============================================================================
+// Render helpers
+
+function formatCall(args: { question?: string; title?: string } | undefined, theme: any): string {
+ const label = (args?.title || args?.question || "").replace(/\s+/g, " ").trim();
+ const shown = label.length > 80 ? `${label.slice(0, 79)}…` : label;
+ return `${theme.fg("toolTitle", theme.bold("ask_user"))} ${theme.fg("accent", shown)}`;
+}
+
+function formatResult(details: AskUserDetails, theme: any): string {
+ if (details.unavailable) return theme.fg("toolOutput", "no interactive UI — continued without asking");
+ if (details.skipped) return theme.fg("toolOutput", "user skipped");
+ const parts: string[] = [];
+ if (details.selected.length > 0) parts.push(details.selected.join(", "));
+ if (details.customText) parts.push(`"${details.customText}"`);
+ return theme.fg("toolOutput", `→ ${parts.join(" + ")}`);
+}
+
+// ============================================================================
+// Tool definition factory
+
+/**
+ * Create an `ask_user` tool definition. Each call creates an isolated FIFO
+ * queue, so concurrent `ask_user` calls in a single session are shown strictly
+ * one at a time.
+ */
+export function createAskUserToolDefinition(): ToolDefinition {
+ // Per-session serialization: only one question is ever open at a time.
+ let tail: Promise = Promise.resolve();
+ const serialize = (run: () => Promise): Promise => {
+ const result = tail.then(run, run);
+ // Always advance the queue, whether the call resolved, cancelled, timed
+ // out, or threw — so a failure can never wedge later questions.
+ tail = result.then(
+ () => undefined,
+ () => undefined,
+ );
+ return result;
+ };
+
+ return {
+ name: "ask_user",
+ label: "ask_user",
+ description:
+ "Pause and ask the user a structured clarifying question with optional multiple-choice options and a " +
+ "free-text answer. Use only when genuinely blocked by ambiguity with multiple viable paths — not for routine confirmation.",
+
+ parameters: askUserSchema,
+
+ promptSnippet: "Ask the user a clarifying question with optional multiple-choice options and free text",
+
+ promptGuidelines: [
+ "Call ask_user ONLY when you are genuinely blocked by ambiguity and there are multiple viable paths forward",
+ "Do NOT use it for routine confirmation, permission, or things you can reasonably decide yourself",
+ "Provide 2-4 concrete `options` when there are clear candidate answers; the user can always type their own",
+ "Set `multiSelect: true` when several options can be combined; `multiline: true` for open-ended answers",
+ "The user may skip — always handle a skipped answer gracefully and continue with a sensible default",
+ "Prefer one focused question over many; the question blocks the turn until the user responds or skips",
+ ],
+
+ async execute(_toolCallId, input: AskUserInput, signal, _onUpdate, ctx?: ExtensionContext) {
+ const request: AskRequest = {
+ question: input.question,
+ title: input.title,
+ options: input.options,
+ allowFreeText: input.allowFreeText,
+ multiSelect: input.multiSelect,
+ multiline: input.multiline,
+ };
+
+ // Headless / print / no-host modes: never block on an unreachable UI.
+ if (!ctx?.hasUI) {
+ return unavailableResult(input);
+ }
+
+ return serialize(async () => {
+ // A queued call whose signal already aborted resolves as skipped
+ // without ever opening the UI.
+ if (signal?.aborted) return skippedResult(input);
+ try {
+ const answer = await ctx.ui.ask(request, { signal });
+ if (!answer || (answer.selected.length === 0 && !answer.customText?.trim())) {
+ return skippedResult(input);
+ }
+ return answeredResult(input, answer);
+ } catch {
+ // Host failure must still release the queue and never deadlock.
+ return skippedResult(input);
+ }
+ });
+ },
+
+ renderCall(args, theme, context) {
+ const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0, undefined, true);
+ text.setText(formatCall(args, theme));
+ return text;
+ },
+
+ renderResult(result, _options, theme, context) {
+ const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0, undefined, true);
+ const details = (result as any).details as AskUserDetails | undefined;
+ if (details) {
+ text.setText(formatResult(details, theme));
+ } else {
+ const content = result.content?.[0];
+ text.setText(theme.fg("toolOutput", content?.type === "text" ? content.text : ""));
+ }
+ return text;
+ },
+ };
+}
diff --git a/packages/coding-agent/src/core/tools/index.ts b/packages/coding-agent/src/core/tools/index.ts
index ad28ee10..5d000f10 100644
--- a/packages/coding-agent/src/core/tools/index.ts
+++ b/packages/coding-agent/src/core/tools/index.ts
@@ -1,3 +1,8 @@
+export {
+ type AskUserDetails,
+ type AskUserInput,
+ createAskUserToolDefinition,
+} from "./ask-user.js";
export {
type BashOperations,
type BashSpawnContext,
@@ -157,6 +162,7 @@ export {
import type { AgentTool } from "@dreb/agent-core";
import type { ToolDefinition } from "../extensions/types.js";
+import { createAskUserToolDefinition } from "./ask-user.js";
import {
type BashToolOptions,
bashTool,
@@ -211,6 +217,8 @@ export const readOnlyTools: Tool[] = [readTool, grepTool, findTool, lsTool];
const tmpReadToolDefinition = createTmpReadToolDefinition();
const tmpReadTool = wrapToolDefinition(tmpReadToolDefinition);
const waitTool = wrapToolDefinition(waitToolDefinition);
+const askUserToolDefinition = createAskUserToolDefinition();
+const askUserTool = wrapToolDefinition(askUserToolDefinition);
export const allTools = {
read: readTool,
@@ -226,6 +234,7 @@ export const allTools = {
tmp_read: tmpReadTool,
wait: waitTool,
search: searchTool,
+ ask_user: askUserTool,
};
export const allToolDefinitions = {
@@ -242,6 +251,7 @@ export const allToolDefinitions = {
tmp_read: tmpReadToolDefinition,
wait: waitToolDefinition,
search: searchToolDefinition,
+ ask_user: askUserToolDefinition,
};
export type ToolName = keyof typeof allTools;
@@ -288,6 +298,7 @@ export function createAllToolDefinitions(cwd: string, options?: ToolsOptions): R
tmp_read: createTmpReadToolDefinition(options?.read),
wait: createWaitToolDefinition({ getRunningAgents: getRunningBackgroundAgents }),
search: createSearchToolDefinition(cwd),
+ ask_user: createAskUserToolDefinition(),
};
if (options?.skill) {
tools.skill = createSkillToolDefinition(cwd, options.skill);
@@ -329,6 +340,7 @@ export function createAllTools(cwd: string, options?: ToolsOptions): Record void;
+ private onCancelCallback: () => void;
+
+ private titleText: Text;
+ private baseTitle: string;
+ private optionsContainer: Container;
+ private fieldLabel: Text | undefined;
+ private input: Input | undefined;
+ private editor: Editor | undefined;
+ private countdown: CountdownTimer | undefined;
+ private submitted = false;
+
+ private _focused = false;
+ get focused(): boolean {
+ return this._focused;
+ }
+ set focused(value: boolean) {
+ this._focused = value;
+ this.syncFieldFocus();
+ }
+
+ private get freeTextRow(): number {
+ return this.allowFreeText ? this.options.length : -1;
+ }
+
+ private get lastRow(): number {
+ return this.allowFreeText ? this.options.length : this.options.length - 1;
+ }
+
+ private cursorOnField(): boolean {
+ return this.allowFreeText && this.cursorIndex === this.freeTextRow;
+ }
+
+ constructor(
+ request: AskRequest,
+ onSubmit: (result: AskResult) => void,
+ onCancel: () => void,
+ opts?: AskUserComponentOptions,
+ ) {
+ super();
+
+ this.options = request.options ?? [];
+ this.allowFreeText = request.allowFreeText !== false;
+ this.multiSelect = request.multiSelect === true && this.options.length > 0;
+ this.multiline = request.multiline === true;
+ this.checked = this.options.map(() => false);
+ this.onSubmitCallback = onSubmit;
+ this.onCancelCallback = onCancel;
+ this.baseTitle = request.title?.trim() || "Question";
+
+ // Start the cursor on the first option, or the free-text field when there
+ // are no options.
+ this.cursorIndex = this.options.length > 0 ? 0 : this.freeTextRow;
+
+ this.addChild(new DynamicBorder());
+ this.addChild(new Spacer(1));
+
+ this.titleText = new Text(theme.fg("accent", theme.bold(this.baseTitle)), 1, 0);
+ this.addChild(this.titleText);
+ this.addChild(new Spacer(1));
+
+ this.addChild(new Text(theme.fg("text", request.question), 1, 0));
+ this.addChild(new Spacer(1));
+
+ if (opts?.timeout && opts.timeout > 0 && opts.tui) {
+ this.countdown = new CountdownTimer(
+ opts.timeout,
+ opts.tui,
+ (s) => this.titleText.setText(theme.fg("accent", theme.bold(`${this.baseTitle} (${s}s)`))),
+ () => this.cancel(),
+ );
+ }
+
+ this.optionsContainer = new Container();
+ this.addChild(this.optionsContainer);
+
+ if (this.allowFreeText) {
+ this.addChild(new Spacer(1));
+ this.fieldLabel = new Text("", 1, 0);
+ this.addChild(this.fieldLabel);
+ if (this.multiline && opts?.tui) {
+ this.editor = new Editor(opts.tui, getEditorTheme(), {});
+ this.editor.onSubmit = () => this.submit();
+ this.addChild(this.editor);
+ } else {
+ this.input = new Input();
+ this.addChild(this.input);
+ }
+ }
+
+ this.addChild(new Spacer(1));
+ this.addChild(new Text(this.buildHint(), 1, 0));
+ this.addChild(new Spacer(1));
+ this.addChild(new DynamicBorder());
+
+ this.renderRows();
+ this.syncFieldFocus();
+ }
+
+ private buildHint(): string {
+ const parts = [rawKeyHint("↑↓", "move")];
+ if (this.multiSelect) parts.push(rawKeyHint("Space", "toggle"));
+ parts.push(keyHint("tui.select.confirm", "submit"));
+ if (this.multiline) parts.push(keyHint("tui.input.newLine", "newline"));
+ parts.push(keyHint("tui.select.cancel", "skip"));
+ return parts.join(" ");
+ }
+
+ private renderRows(): void {
+ this.optionsContainer.clear();
+ for (let i = 0; i < this.options.length; i++) {
+ const focused = this.cursorIndex === i;
+ const cursor = focused ? theme.fg("accent", "→ ") : " ";
+ const glyph = this.multiSelect ? (this.checked[i] ? "[x]" : "[ ]") : focused ? "(•)" : "( )";
+ const label = focused ? theme.fg("accent", this.options[i]) : theme.fg("text", this.options[i]);
+ const glyphColored = this.checked[i] || (!this.multiSelect && focused) ? theme.fg("accent", glyph) : glyph;
+ this.optionsContainer.addChild(new Text(`${cursor}${glyphColored} ${label}`, 1, 0));
+ }
+ if (this.fieldLabel) {
+ const focused = this.cursorOnField();
+ const prefix = focused ? theme.fg("accent", "→ ") : " ";
+ const labelText = this.options.length > 0 ? "Or type your own answer:" : "Your answer:";
+ this.fieldLabel.setText(prefix + (focused ? theme.fg("accent", labelText) : theme.fg("muted", labelText)));
+ }
+ }
+
+ private syncFieldFocus(): void {
+ const fieldFocused = this._focused && this.cursorOnField();
+ if (this.input) this.input.focused = fieldFocused;
+ if (this.editor) this.editor.focused = fieldFocused;
+ }
+
+ private moveCursor(delta: number): void {
+ const first = this.options.length > 0 ? 0 : this.freeTextRow;
+ const next = Math.max(first, Math.min(this.lastRow, this.cursorIndex + delta));
+ if (next === this.cursorIndex) return;
+ this.cursorIndex = next;
+ this.renderRows();
+ this.syncFieldFocus();
+ }
+
+ private fieldText(): string {
+ if (this.editor) return this.editor.getText().trim();
+ if (this.input) return this.input.getValue().trim();
+ return "";
+ }
+
+ private currentAnswer(): AskResult | undefined {
+ const customText = this.fieldText() || undefined;
+ if (this.multiSelect) {
+ const selected = this.options.filter((_, i) => this.checked[i]);
+ if (selected.length === 0 && !customText) return undefined;
+ return { selected, customText };
+ }
+ if (this.cursorOnField()) {
+ if (!customText) return undefined;
+ return { selected: [], customText };
+ }
+ const option = this.options[this.cursorIndex];
+ return option ? { selected: [option], customText: undefined } : undefined;
+ }
+
+ private submit(): void {
+ if (this.submitted) return;
+ const answer = this.currentAnswer();
+ if (!answer) return; // Nothing to submit yet — Esc skips instead.
+ this.submitted = true;
+ this.onSubmitCallback(answer);
+ }
+
+ private cancel(): void {
+ if (this.submitted) return;
+ this.submitted = true;
+ this.onCancelCallback();
+ }
+
+ handleInput(keyData: string): void {
+ const kb = getKeybindings();
+
+ if (kb.matches(keyData, "tui.select.cancel")) {
+ this.cancel();
+ return;
+ }
+
+ if (this.cursorOnField()) {
+ // Multiline editor: Enter submits (via onSubmit), Shift+Enter inserts a
+ // newline, ↑ at the top line leaves to the options, everything else edits.
+ if (this.editor) {
+ if (kb.matches(keyData, "tui.select.up") && this.editor.getCursor().line === 0) {
+ this.moveCursor(-1);
+ return;
+ }
+ this.editor.handleInput(keyData);
+ return;
+ }
+ // Single-line input: ↑ leaves to the options, Enter submits, else edits.
+ if (this.input) {
+ if (kb.matches(keyData, "tui.select.up")) {
+ this.moveCursor(-1);
+ return;
+ }
+ if (kb.matches(keyData, "tui.select.confirm") || keyData === "\n") {
+ this.submit();
+ return;
+ }
+ this.input.handleInput(keyData);
+ return;
+ }
+ return;
+ }
+
+ // Cursor on an option row.
+ if (kb.matches(keyData, "tui.select.up")) {
+ this.moveCursor(-1);
+ } else if (kb.matches(keyData, "tui.select.down")) {
+ this.moveCursor(1);
+ } else if (this.multiSelect && keyData === " ") {
+ this.checked[this.cursorIndex] = !this.checked[this.cursorIndex];
+ this.renderRows();
+ } else if (kb.matches(keyData, "tui.select.confirm") || keyData === "\n") {
+ this.submit();
+ }
+ }
+
+ dispose(): void {
+ this.countdown?.dispose();
+ }
+}
diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts
index 4a531408..f5e4ca69 100644
--- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts
+++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts
@@ -55,6 +55,8 @@ import {
validateMemoryLinks,
} from "../../core/dream.js";
import type {
+ AskRequest,
+ AskResult,
ExtensionContext,
ExtensionRunner,
ExtensionUIContext,
@@ -88,6 +90,7 @@ import {
} from "../../utils/message-text.js";
import { ensureTool } from "../../utils/tools-manager.js";
import { ArminComponent } from "./components/armin.js";
+import { AskUserComponent } from "./components/ask-user.js";
import { AssistantMessageComponent } from "./components/assistant-message.js";
import { BashExecutionComponent } from "./components/bash-execution.js";
import { BorderedLoader } from "./components/bordered-loader.js";
@@ -254,6 +257,7 @@ export class InteractiveMode {
private extensionSelector: ExtensionSelectorComponent | undefined = undefined;
private extensionInput: ExtensionInputComponent | undefined = undefined;
private extensionEditor: ExtensionEditorComponent | undefined = undefined;
+ private extensionAsk: AskUserComponent | undefined = undefined;
private extensionTerminalInputUnsubscribers = new Set<() => void>();
// Extension widgets (components rendered above/below the editor)
@@ -1512,6 +1516,9 @@ export class InteractiveMode {
if (this.extensionEditor) {
this.hideExtensionEditor();
}
+ if (this.extensionAsk) {
+ this.hideExtensionAsk();
+ }
this.ui.hideOverlay();
this.clearExtensionTerminalInputListeners();
this.setExtensionFooter(undefined);
@@ -1777,6 +1784,7 @@ export class InteractiveMode {
select: (title, options, opts) => this.showExtensionSelector(title, options, opts),
confirm: (title, message, opts) => this.showExtensionConfirm(title, message, opts),
input: (title, placeholder, opts) => this.showExtensionInput(title, placeholder, opts),
+ ask: (request, opts) => this.showExtensionAsk(request, opts),
notify: (message, type) => this.showExtensionNotify(message, type),
onTerminalInput: (handler) => this.addExtensionTerminalInputListener(handler),
setStatus: (key, text) => this.setExtensionStatus(key, text),
@@ -1932,6 +1940,53 @@ export class InteractiveMode {
this.restoreEditorComponent();
}
+ /**
+ * Show a rich ask_user question for extensions/built-in tools.
+ */
+ private showExtensionAsk(request: AskRequest, opts?: ExtensionUIDialogOptions): Promise {
+ return new Promise((resolve) => {
+ if (opts?.signal?.aborted) {
+ resolve(undefined);
+ return;
+ }
+
+ const onAbort = () => {
+ this.hideExtensionAsk();
+ resolve(undefined);
+ };
+ opts?.signal?.addEventListener("abort", onAbort, { once: true });
+
+ this.extensionAsk = new AskUserComponent(
+ request,
+ (result) => {
+ opts?.signal?.removeEventListener("abort", onAbort);
+ this.hideExtensionAsk();
+ resolve(result);
+ },
+ () => {
+ opts?.signal?.removeEventListener("abort", onAbort);
+ this.hideExtensionAsk();
+ resolve(undefined);
+ },
+ { tui: this.ui, timeout: opts?.timeout },
+ );
+
+ this.editorContainer.clear();
+ this.editorContainer.addChild(this.extensionAsk);
+ this.ui.setFocus(this.extensionAsk);
+ this.ui.requestRender();
+ });
+ }
+
+ /**
+ * Hide the ask_user question.
+ */
+ private hideExtensionAsk(): void {
+ this.extensionAsk?.dispose();
+ this.extensionAsk = undefined;
+ this.restoreEditorComponent();
+ }
+
/**
* Show a multi-line editor for extensions (with Ctrl+G support).
*/
diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts
index f04a4739..e4ea6fd7 100644
--- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts
+++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts
@@ -1114,6 +1114,28 @@ export async function runRpcMode(session: AgentSession, modelFallbackMessage?: s
"cancelled" in r && r.cancelled ? undefined : "value" in r ? r.value : undefined,
),
+ ask: (request, opts) =>
+ createDialogPromise(
+ opts,
+ undefined,
+ {
+ method: "ask",
+ title: request.title ?? "Question",
+ question: request.question,
+ options: request.options,
+ allowFreeText: request.allowFreeText,
+ multiSelect: request.multiSelect,
+ multiline: request.multiline,
+ timeout: opts?.timeout,
+ },
+ (r) =>
+ "cancelled" in r && r.cancelled
+ ? undefined
+ : "selected" in r
+ ? { selected: r.selected, customText: r.customText }
+ : undefined,
+ ),
+
notify(message: string, type?: "info" | "warning" | "error"): void {
// Fire and forget - no response needed
output({
diff --git a/packages/coding-agent/src/modes/rpc/rpc-types.ts b/packages/coding-agent/src/modes/rpc/rpc-types.ts
index 78823c77..225fe228 100644
--- a/packages/coding-agent/src/modes/rpc/rpc-types.ts
+++ b/packages/coding-agent/src/modes/rpc/rpc-types.ts
@@ -643,6 +643,18 @@ export type RpcExtensionUIRequest =
timeout?: number;
}
| { type: "extension_ui_request"; id: string; method: "editor"; title: string; prefill?: string }
+ | {
+ type: "extension_ui_request";
+ id: string;
+ method: "ask";
+ title: string;
+ question: string;
+ options?: string[];
+ allowFreeText?: boolean;
+ multiSelect?: boolean;
+ multiline?: boolean;
+ timeout?: number;
+ }
| {
type: "extension_ui_request";
id: string;
@@ -676,6 +688,7 @@ export type RpcExtensionUIRequest =
export type RpcExtensionUIResponse =
| { type: "extension_ui_response"; id: string; value: string }
| { type: "extension_ui_response"; id: string; confirmed: boolean }
+ | { type: "extension_ui_response"; id: string; selected: string[]; customText?: string }
| { type: "extension_ui_response"; id: string; cancelled: true };
// ============================================================================
diff --git a/packages/coding-agent/test/ask-user-component.test.ts b/packages/coding-agent/test/ask-user-component.test.ts
new file mode 100644
index 00000000..a3466d67
--- /dev/null
+++ b/packages/coding-agent/test/ask-user-component.test.ts
@@ -0,0 +1,120 @@
+import { setKeybindings } from "@dreb/tui";
+import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
+import type { AskRequest, AskResult } from "../src/core/extensions/types.js";
+import { KeybindingsManager } from "../src/core/keybindings.js";
+import { AskUserComponent } from "../src/modes/interactive/components/ask-user.js";
+import { initTheme } from "../src/modes/interactive/theme/theme.js";
+
+const UP = "\x1b[A";
+const DOWN = "\x1b[B";
+const ENTER = "\n";
+const ESC = "\x1b";
+const SPACE = " ";
+
+beforeAll(() => {
+ initTheme("dark");
+});
+
+beforeEach(() => {
+ setKeybindings(new KeybindingsManager());
+});
+
+function mount(request: AskRequest) {
+ const onSubmit = vi.fn<(result: AskResult) => void>();
+ const onCancel = vi.fn<() => void>();
+ const component = new AskUserComponent(request, onSubmit, onCancel);
+ component.focused = true;
+ return { component, onSubmit, onCancel };
+}
+
+function type(component: AskUserComponent, text: string) {
+ for (const char of text) component.handleInput(char);
+}
+
+describe("AskUserComponent", () => {
+ it("single-select: Enter picks the highlighted option and submits", () => {
+ const { component, onSubmit } = mount({ question: "DB?", options: ["SQLite", "Postgres", "JSON"] });
+ component.handleInput(DOWN); // move to Postgres
+ component.handleInput(ENTER);
+ expect(onSubmit).toHaveBeenCalledWith({ selected: ["Postgres"], customText: undefined });
+ });
+
+ it("single-select: submitting the free-text field returns only the typed text", () => {
+ const { component, onSubmit } = mount({ question: "DB?", options: ["SQLite", "Postgres"] });
+ component.handleInput(DOWN); // Postgres
+ component.handleInput(DOWN); // free-text row
+ type(component, "duckdb");
+ component.handleInput(ENTER);
+ expect(onSubmit).toHaveBeenCalledWith({ selected: [], customText: "duckdb" });
+ });
+
+ it("multi-select: Space toggles checkboxes and Enter submits the combined answer", () => {
+ const { component, onSubmit } = mount({
+ question: "Checks?",
+ options: ["unit", "browser", "types"],
+ multiSelect: true,
+ });
+ component.handleInput(SPACE); // check unit
+ component.handleInput(DOWN);
+ component.handleInput(DOWN); // types
+ component.handleInput(SPACE); // check types
+ component.handleInput(ENTER);
+ expect(onSubmit).toHaveBeenCalledWith({ selected: ["unit", "types"], customText: undefined });
+ });
+
+ it("multi-select: combines checked options with free text", () => {
+ const { component, onSubmit } = mount({
+ question: "Checks?",
+ options: ["unit", "browser"],
+ multiSelect: true,
+ });
+ component.handleInput(SPACE); // check unit
+ component.handleInput(DOWN); // browser
+ component.handleInput(DOWN); // free-text row
+ type(component, "lint");
+ component.handleInput(ENTER);
+ expect(onSubmit).toHaveBeenCalledWith({ selected: ["unit"], customText: "lint" });
+ });
+
+ it("Esc skips without answering", () => {
+ const { component, onSubmit, onCancel } = mount({ question: "DB?", options: ["a", "b"] });
+ component.handleInput(ESC);
+ expect(onCancel).toHaveBeenCalledTimes(1);
+ expect(onSubmit).not.toHaveBeenCalled();
+ });
+
+ it("ignores Enter on an empty free-text field (Esc is the way to skip)", () => {
+ const { component, onSubmit } = mount({ question: "Name?" });
+ // Free-text-only question: cursor starts on the field, which is empty.
+ component.handleInput(ENTER);
+ expect(onSubmit).not.toHaveBeenCalled();
+ type(component, "hi");
+ component.handleInput(ENTER);
+ expect(onSubmit).toHaveBeenCalledWith({ selected: [], customText: "hi" });
+ });
+
+ it("does not offer a free-text field when allowFreeText is false", () => {
+ const { component, onSubmit } = mount({ question: "Pick", options: ["a", "b"], allowFreeText: false });
+ // Arrowing down past the last option must not reach a field — Enter still
+ // submits the highlighted option.
+ component.handleInput(DOWN);
+ component.handleInput(DOWN);
+ component.handleInput(ENTER);
+ expect(onSubmit).toHaveBeenCalledWith({ selected: ["b"], customText: undefined });
+ });
+
+ it("submits only once even if Enter is pressed repeatedly", () => {
+ const { component, onSubmit } = mount({ question: "DB?", options: ["a", "b"] });
+ component.handleInput(ENTER);
+ component.handleInput(ENTER);
+ expect(onSubmit).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not navigate above the first row", () => {
+ const { component, onSubmit } = mount({ question: "DB?", options: ["a", "b"] });
+ component.handleInput(UP);
+ component.handleInput(UP);
+ component.handleInput(ENTER);
+ expect(onSubmit).toHaveBeenCalledWith({ selected: ["a"], customText: undefined });
+ });
+});
diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts
index 66288a90..67139c23 100644
--- a/packages/coding-agent/test/tools.test.ts
+++ b/packages/coding-agent/test/tools.test.ts
@@ -10,6 +10,7 @@ import { createGrepTool, type GrepOperations, grepTool } from "../src/core/tools
import { allToolDefinitions, allTools, createAllToolDefinitions, createAllTools } from "../src/core/tools/index.js";
import { lsTool } from "../src/core/tools/ls.js";
import { readTool } from "../src/core/tools/read.js";
+import { wrapToolDefinition } from "../src/core/tools/tool-definition-wrapper.js";
import { writeTool } from "../src/core/tools/write.js";
import * as shellModule from "../src/utils/shell.js";
@@ -948,4 +949,57 @@ describe("allTools / allToolDefinitions", () => {
const tools = createAllTools("/tmp");
expect(tools.search).toBeDefined();
});
+
+ it("includes ask_user in both registries unconditionally", () => {
+ expect("ask_user" in allTools).toBe(true);
+ expect("ask_user" in allToolDefinitions).toBe(true);
+ expect(allTools.ask_user.name).toBe("ask_user");
+ expect(typeof allTools.ask_user.execute).toBe("function");
+ expect(createAllToolDefinitions("/tmp").ask_user).toBeDefined();
+ expect(createAllTools("/tmp").ask_user).toBeDefined();
+ });
+});
+
+describe("wrapToolDefinition ctxFactory (base-tool ctx.ui wiring)", () => {
+ it("forwards a per-execution ExtensionContext from the factory to execute", async () => {
+ const seen: unknown[] = [];
+ const definition = {
+ name: "probe",
+ label: "probe",
+ description: "d",
+ parameters: {} as any,
+ execute: async (_id: string, _params: any, _signal: any, _onUpdate: any, ctx: any) => {
+ seen.push(ctx);
+ return { content: [{ type: "text" as const, text: "ok" }] };
+ },
+ };
+ let created = 0;
+ const factory = () => {
+ created += 1;
+ return { hasUI: true, ui: {} } as any;
+ };
+ const tool = wrapToolDefinition(definition as any, factory);
+ await tool.execute("c1", {}, undefined, undefined);
+ await tool.execute("c2", {}, undefined, undefined);
+ expect(created).toBe(2); // resolved fresh per execution
+ expect(seen).toHaveLength(2);
+ expect((seen[0] as any).hasUI).toBe(true);
+ });
+
+ it("passes undefined ctx when no factory is provided", async () => {
+ let received: unknown = "sentinel";
+ const definition = {
+ name: "probe2",
+ label: "probe2",
+ description: "d",
+ parameters: {} as any,
+ execute: async (_id: string, _params: any, _signal: any, _onUpdate: any, ctx: any) => {
+ received = ctx;
+ return { content: [{ type: "text" as const, text: "ok" }] };
+ },
+ };
+ const tool = wrapToolDefinition(definition as any);
+ await tool.execute("c1", {}, undefined, undefined);
+ expect(received).toBeUndefined();
+ });
});
diff --git a/packages/coding-agent/test/tools/ask-user.test.ts b/packages/coding-agent/test/tools/ask-user.test.ts
new file mode 100644
index 00000000..01a47850
--- /dev/null
+++ b/packages/coding-agent/test/tools/ask-user.test.ts
@@ -0,0 +1,206 @@
+import { describe, expect, it, vi } from "vitest";
+import type { AskRequest, AskResult, ExtensionContext } from "../../src/core/extensions/types.js";
+import { type AskUserDetails, createAskUserToolDefinition } from "../../src/core/tools/ask-user.js";
+
+const mockTheme = {
+ fg: (_color: string, text: string) => text,
+ bold: (text: string) => text,
+};
+
+interface ExecResult {
+ content: Array<{ type: string; text?: string }>;
+ details?: AskUserDetails;
+ endTurn?: boolean;
+}
+
+/** Build a ctx whose ui.ask returns a scripted answer (or undefined for skip). */
+function makeCtx(
+ ask: (request: AskRequest, opts?: { signal?: AbortSignal }) => Promise,
+ hasUI = true,
+): ExtensionContext {
+ return { hasUI, ui: { ask } } as unknown as ExtensionContext;
+}
+
+function run(
+ def: ReturnType,
+ params: Record,
+ ctx?: ExtensionContext,
+ signal?: AbortSignal,
+): Promise {
+ return (def.execute as any)("call-1", params, signal, undefined, ctx) as Promise;
+}
+
+describe("ask_user tool", () => {
+ it("exposes narrow usage guidance in prompt metadata", () => {
+ const def = createAskUserToolDefinition();
+ expect(def.name).toBe("ask_user");
+ expect(def.promptSnippet).toBeTruthy();
+ const guidelines = (def.promptGuidelines as string[]).join(" ").toLowerCase();
+ expect(guidelines).toContain("only");
+ expect(guidelines).toContain("blocked");
+ expect(guidelines).toContain("skip");
+ });
+
+ it("validates option bounds (2-4) in its schema", () => {
+ const def = createAskUserToolDefinition();
+ const props = (def.parameters as any).properties;
+ expect(props.options.minItems).toBe(2);
+ expect(props.options.maxItems).toBe(4);
+ expect(props.question.type).toBe("string");
+ });
+
+ it("returns a graceful non-blocking result when no UI is available", async () => {
+ const def = createAskUserToolDefinition();
+ const ask = vi.fn();
+ const result = await run(def, { question: "Which?" }, makeCtx(ask, false));
+ expect(ask).not.toHaveBeenCalled();
+ expect(result.details?.unavailable).toBe(true);
+ expect(result.details?.skipped).toBe(true);
+ expect(result.content[0].text).toContain("not available");
+ });
+
+ it("returns unavailable when ctx is entirely absent", async () => {
+ const def = createAskUserToolDefinition();
+ const result = await run(def, { question: "Which?" }, undefined);
+ expect(result.details?.unavailable).toBe(true);
+ });
+
+ it("formats a single selected option", async () => {
+ const def = createAskUserToolDefinition();
+ const ctx = makeCtx(async () => ({ selected: ["SQLite"] }));
+ const result = await run(def, { question: "DB?", options: ["SQLite", "Postgres"] }, ctx);
+ expect(result.details?.skipped).toBe(false);
+ expect(result.details?.selected).toEqual(["SQLite"]);
+ expect(result.content[0].text).toBe("The user selected: SQLite");
+ });
+
+ it("combines multiple selections with custom text", async () => {
+ const def = createAskUserToolDefinition();
+ const ctx = makeCtx(async () => ({ selected: ["A", "B"], customText: "plus C" }));
+ const result = await run(def, { question: "Pick", options: ["A", "B"], multiSelect: true }, ctx);
+ expect(result.details?.selected).toEqual(["A", "B"]);
+ expect(result.details?.customText).toBe("plus C");
+ expect(result.content[0].text).toContain("The user selected: A, B");
+ expect(result.content[0].text).toContain('They also wrote: "plus C"');
+ });
+
+ it("formats a free-text-only answer", async () => {
+ const def = createAskUserToolDefinition();
+ const ctx = makeCtx(async () => ({ selected: [], customText: "my own answer" }));
+ const result = await run(def, { question: "Name?" }, ctx);
+ expect(result.content[0].text).toBe('The user answered: "my own answer"');
+ });
+
+ it("treats an undefined answer as a graceful skip", async () => {
+ const def = createAskUserToolDefinition();
+ const ctx = makeCtx(async () => undefined);
+ const result = await run(def, { question: "Which?" }, ctx);
+ expect(result.details?.skipped).toBe(true);
+ expect(result.details?.unavailable).toBe(false);
+ expect(result.content[0].text).toContain("skipped");
+ });
+
+ it("treats an empty answer as a skip", async () => {
+ const def = createAskUserToolDefinition();
+ const ctx = makeCtx(async () => ({ selected: [], customText: " " }));
+ const result = await run(def, { question: "Which?" }, ctx);
+ expect(result.details?.skipped).toBe(true);
+ });
+
+ it("resolves as skipped without opening UI when the signal is already aborted", async () => {
+ const def = createAskUserToolDefinition();
+ const ask = vi.fn(async () => ({ selected: ["x"] }));
+ const controller = new AbortController();
+ controller.abort();
+ const result = await run(def, { question: "Which?" }, makeCtx(ask), controller.signal);
+ expect(ask).not.toHaveBeenCalled();
+ expect(result.details?.skipped).toBe(true);
+ expect(result.details?.unavailable).toBe(false);
+ });
+
+ it("resolves gracefully when the host throws", async () => {
+ const def = createAskUserToolDefinition();
+ const ctx = makeCtx(async () => {
+ throw new Error("host exploded");
+ });
+ const result = await run(def, { question: "Which?" }, ctx);
+ expect(result.details?.skipped).toBe(true);
+ });
+
+ it("serializes concurrent calls strictly one at a time (FIFO)", async () => {
+ const def = createAskUserToolDefinition();
+ let active = 0;
+ let maxActive = 0;
+ const order: string[] = [];
+ const ctx = makeCtx(async (request) => {
+ active += 1;
+ maxActive = Math.max(maxActive, active);
+ await new Promise((r) => setTimeout(r, 10));
+ order.push(request.question);
+ active -= 1;
+ return { selected: [request.question] };
+ });
+
+ const results = await Promise.all([
+ run(def, { question: "first" }, ctx),
+ run(def, { question: "second" }, ctx),
+ run(def, { question: "third" }, ctx),
+ ]);
+
+ expect(maxActive).toBe(1); // never two questions open at once
+ expect(order).toEqual(["first", "second", "third"]);
+ expect(results.map((r) => r.details?.selected?.[0])).toEqual(["first", "second", "third"]);
+ });
+
+ it("releases the queue after a failure so later calls still run", async () => {
+ const def = createAskUserToolDefinition();
+ let calls = 0;
+ const ctx = makeCtx(async (request) => {
+ calls += 1;
+ if (request.question === "boom") throw new Error("fail");
+ return { selected: [request.question] };
+ });
+ const [first, second] = await Promise.all([
+ run(def, { question: "boom" }, ctx),
+ run(def, { question: "ok" }, ctx),
+ ]);
+ expect(calls).toBe(2);
+ expect(first.details?.skipped).toBe(true);
+ expect(second.details?.selected).toEqual(["ok"]);
+ });
+
+ it("passes the abort signal through to the UI layer", async () => {
+ const def = createAskUserToolDefinition();
+ const controller = new AbortController();
+ let receivedSignal: AbortSignal | undefined;
+ const ctx = makeCtx(async (_req, opts) => {
+ receivedSignal = opts?.signal;
+ return { selected: ["x"] };
+ });
+ await run(def, { question: "Which?" }, ctx, controller.signal);
+ expect(receivedSignal).toBe(controller.signal);
+ });
+
+ it("renders a call and result without throwing", () => {
+ const def = createAskUserToolDefinition();
+ const call = def.renderCall?.(
+ { question: "Which database?" } as any,
+ mockTheme as any,
+ {
+ lastComponent: undefined,
+ } as any,
+ );
+ expect(call).toBeDefined();
+
+ const result = def.renderResult?.(
+ {
+ content: [{ type: "text", text: "done" }],
+ details: { question: "q", selected: ["A"], skipped: false, unavailable: false },
+ } as any,
+ {} as any,
+ mockTheme as any,
+ { lastComponent: undefined } as any,
+ );
+ expect(result).toBeDefined();
+ });
+});
diff --git a/packages/dashboard/src/client/screens/session.tsx b/packages/dashboard/src/client/screens/session.tsx
index dc1f2900..a7c8919d 100644
--- a/packages/dashboard/src/client/screens/session.tsx
+++ b/packages/dashboard/src/client/screens/session.tsx
@@ -182,6 +182,103 @@ function ExtensionUiModal(props: {
);
}
+function AskUiModal(props: {
+ request: ExtensionUiRequest;
+ onRespond: (response: Record) => void;
+}): JSX.Element {
+ const options = () => props.request.options ?? [];
+ const allowFreeText = () => props.request.allowFreeText !== false;
+ const multiSelect = () => props.request.multiSelect === true && options().length > 0;
+ const [selected, setSelected] = createSignal([]);
+ const [customText, setCustomText] = createSignal("");
+ const respond = (body: Record) =>
+ props.onRespond({ type: "extension_ui_response", id: props.request.id, ...body });
+
+ const toggle = (option: string) => {
+ if (multiSelect()) {
+ setSelected((current) =>
+ current.includes(option) ? current.filter((value) => value !== option) : [...current, option],
+ );
+ } else {
+ setSelected([option]);
+ }
+ };
+
+ const answer = () => {
+ const text = customText().trim();
+ return { selected: selected(), customText: text ? text : undefined };
+ };
+ const canSubmit = () => answer().selected.length > 0 || answer().customText !== undefined;
+ const submit = () => {
+ if (canSubmit()) respond(answer());
+ };
+
+ return (
+ respond({ cancelled: true })}
+ actions={
+ <>
+
+
+ >
+ }
+ >
+
+