From 26f5d79125d4e922a4d7109aea17b0fd2d08b936 Mon Sep 17 00:00:00 2001 From: max <23141894+snowboardit@users.noreply.github.com> Date: Mon, 14 Sep 2026 11:56:56 -0400 Subject: [PATCH 1/7] feat(ask): add an adapter for question-asking tools Pi has no wire protocol for "ask the user a question". Extensions build a terminal component and hand it to ctx.ui.custom(), which leaves the host with rendered text and no idea what was asked. Add a registry that maps one known tool call onto a question description, checks a submitted answer against that question, and produces the value the extension expects back. The first entry covers the ask_user tool from pi-ask-user. Option parsing accepts the key aliases models fall back to when a proxy mangles the schema. Nothing uses this yet. --- lib/structured-ask.test.mjs | 151 +++++++++++++++++++ lib/structured-ask.ts | 290 ++++++++++++++++++++++++++++++++++++ 2 files changed, 441 insertions(+) create mode 100644 lib/structured-ask.test.mjs create mode 100644 lib/structured-ask.ts diff --git a/lib/structured-ask.test.mjs b/lib/structured-ask.test.mjs new file mode 100644 index 000000000..4efa1eb3e --- /dev/null +++ b/lib/structured-ask.test.mjs @@ -0,0 +1,151 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + isStructuredAskToolName, + normalizeStructuredAskAnswer, + parseStructuredAsk, + parseStructuredAskResult, + resolveStructuredAskSubmission, + summarizeStructuredAskAnswer, +} from "./structured-ask.ts"; + +const baseArgs = { + question: "Which path?", + options: ["Path A", { title: "Path B", description: "Slower" }], +}; + +test("parses an ask_user call into a structured question", () => { + const spec = parseStructuredAsk("ask_user", { ...baseArgs, context: " weigh both " }, "call-1"); + assert.deepEqual(spec, { + toolName: "ask_user", + toolCallId: "call-1", + question: "Which path?", + context: "weigh both", + options: [{ title: "Path A" }, { title: "Path B", description: "Slower" }], + allowMultiple: false, + allowFreeform: true, + allowComment: false, + }); +}); + +test("accepts option key aliases and drops unusable entries", () => { + const spec = parseStructuredAsk("ask_user", { + question: "Pick", + options: [{ label: "Alias" }, { detail: "no title" }, 7, "Plain", "Plain"], + }); + assert.deepEqual(spec.options, [{ title: "Alias" }, { title: "Plain" }]); +}); + +test("ignores unknown tools, missing questions, and option-less calls", () => { + assert.equal(parseStructuredAsk("bash", baseArgs), null); + assert.equal(parseStructuredAsk("ask_user", { options: ["A"] }), null); + assert.equal(parseStructuredAsk("ask_user", { question: "Free text?" }), null); + assert.equal(parseStructuredAsk("ask_user", "not-an-object"), null); + assert.equal(isStructuredAskToolName("ask_user"), true); + assert.equal(isStructuredAskToolName("read"), false); +}); + +test("resolves a single selection into the extension result value", () => { + const spec = parseStructuredAsk("ask_user", baseArgs); + const resolved = resolveStructuredAskSubmission(spec, { + answer: { kind: "selection", selections: ["Path B"] }, + }); + assert.deepEqual(resolved, { ok: true, value: { kind: "selection", selections: ["Path B"] } }); +}); + +test("resolves a dismissal into the extension cancel value", () => { + const spec = parseStructuredAsk("ask_user", baseArgs); + assert.deepEqual(resolveStructuredAskSubmission(spec, { cancelled: true }), { ok: true, value: null }); +}); + +test("rejects submissions that do not fit the question", () => { + const spec = parseStructuredAsk("ask_user", baseArgs); + // Single-select has room for one answer, so a typed entry cannot ride along. + assert.deepEqual(resolveStructuredAskSubmission(spec, { answer: { kind: "selection", selections: ["Path C"] } }), { ok: false }); + assert.deepEqual(resolveStructuredAskSubmission(spec, { answer: { kind: "selection", selections: ["Path A", "Path B"] } }), { ok: false }); + assert.deepEqual(resolveStructuredAskSubmission(spec, {}), { ok: false }); + assert.deepEqual(resolveStructuredAskSubmission(spec, null), { ok: false }); +}); + +test("keeps multiple selections only when the question allows them", () => { + const spec = parseStructuredAsk("ask_user", { ...baseArgs, allowMultiple: true }); + assert.deepEqual( + normalizeStructuredAskAnswer(spec, { kind: "selection", selections: ["Path B", "Path A", "Path B"] }), + { kind: "selection", selections: ["Path B", "Path A"] }, + ); +}); + +test("keeps one typed entry beside checked options in a multi-select", () => { + const spec = parseStructuredAsk("ask_user", { ...baseArgs, allowMultiple: true }); + assert.deepEqual( + normalizeStructuredAskAnswer(spec, { kind: "selection", selections: ["Path A", "my own answer"] }), + { kind: "selection", selections: ["Path A", "my own answer"] }, + ); + // Only one typed entry; the rest are not part of the question. + assert.deepEqual( + normalizeStructuredAskAnswer(spec, { kind: "selection", selections: ["mine", "also mine"] }), + { kind: "selection", selections: ["mine"] }, + ); + const closed = parseStructuredAsk("ask_user", { ...baseArgs, allowMultiple: true, allowFreeform: false }); + assert.deepEqual( + normalizeStructuredAskAnswer(closed, { kind: "selection", selections: ["Path A", "my own answer"] }), + { kind: "selection", selections: ["Path A"] }, + ); +}); + +test("drops a comment unless the question allows one", () => { + const withComment = parseStructuredAsk("ask_user", { ...baseArgs, allowComment: true }); + assert.deepEqual( + normalizeStructuredAskAnswer(withComment, { kind: "selection", selections: ["Path A"], comment: " later " }), + { kind: "selection", selections: ["Path A"], comment: "later" }, + ); + const withoutComment = parseStructuredAsk("ask_user", baseArgs); + assert.deepEqual( + normalizeStructuredAskAnswer(withoutComment, { kind: "selection", selections: ["Path A"], comment: "later" }), + { kind: "selection", selections: ["Path A"] }, + ); +}); + +test("rejects freeform text when the question forbids it", () => { + const spec = parseStructuredAsk("ask_user", { ...baseArgs, allowFreeform: false }); + assert.equal(normalizeStructuredAskAnswer(spec, { kind: "freeform", text: "other" }), null); + const open = parseStructuredAsk("ask_user", baseArgs); + assert.deepEqual(normalizeStructuredAskAnswer(open, { kind: "freeform", text: " other " }), { kind: "freeform", text: "other" }); + assert.equal(normalizeStructuredAskAnswer(open, { kind: "freeform", text: " " }), null); +}); + +test("rebuilds a finished question from tool result details", () => { + const record = parseStructuredAskResult("ask_user", { + question: "Which path?", + options: ["Path A", "Path B"], + response: { kind: "selection", selections: ["Path A"], comment: "for now" }, + cancelled: false, + }); + assert.deepEqual(record, { + question: "Which path?", + options: [{ title: "Path A" }, { title: "Path B" }], + answer: { kind: "selection", selections: ["Path A"], comment: "for now" }, + cancelled: false, + }); +}); + +test("reports a cancelled question with no answer", () => { + const record = parseStructuredAskResult("ask_user", { + question: "Which path?", + options: ["Path A"], + response: null, + cancelled: true, + }); + assert.equal(record.answer, null); + assert.equal(record.cancelled, true); + assert.equal(parseStructuredAskResult("ask_user", { error: "boom" }), null); + assert.equal(parseStructuredAskResult(undefined, {}), null); +}); + +test("summarizes an answer in one line", () => { + assert.equal(summarizeStructuredAskAnswer({ kind: "freeform", text: "other" }), "other"); + assert.equal( + summarizeStructuredAskAnswer({ kind: "selection", selections: ["A", "B"], comment: "note" }), + "A, B - note", + ); +}); diff --git a/lib/structured-ask.ts b/lib/structured-ask.ts new file mode 100644 index 000000000..fcf6d8d6e --- /dev/null +++ b/lib/structured-ask.ts @@ -0,0 +1,290 @@ +/** + * Structured-ask adapter layer. + * + * Pi has no first-class "ask the user a question" wire protocol. Extensions + * such as `pi-ask-user` build a terminal component and hand it to + * `ctx.ui.custom()`. Pi Web runs that component headless and streams its + * rendered text lines to the browser, which is correct but unusable on a + * touch screen. + * + * This module maps a known question-asking tool call onto a structured + * description (`StructuredAskSpec`) that the browser can render as a real + * form, and maps the submitted answer back onto the value the extension + * expects from its custom UI promise. + * + * Adding a new question-asking extension means adding one adapter entry. + * When no adapter matches, Pi Web keeps the generic terminal panel. + */ + +export interface StructuredAskOption { + title: string; + description?: string; +} + +export interface StructuredAskSpec { + /** Tool call that produced this question. */ + toolName: string; + toolCallId?: string; + question: string; + context?: string; + options: StructuredAskOption[]; + allowMultiple: boolean; + allowFreeform: boolean; + allowComment: boolean; +} + +export type StructuredAskAnswer = + | { kind: "selection"; selections: string[]; comment?: string } + | { kind: "freeform"; text: string }; + +/** What the browser sends back: an answer, or an explicit cancel. */ +export type StructuredAskSubmission = + | { answer: StructuredAskAnswer } + | { cancelled: true }; + +interface StructuredAskAdapter { + /** Reads the tool arguments. Returns null when the shape does not match. */ + parse: (args: Record) => Omit | null; + /** Builds the value the extension's `ctx.ui.custom()` promise resolves with. */ + toCustomUiValue: (answer: StructuredAskAnswer) => unknown; + /** Value used when the user dismisses the question. */ + cancelValue: unknown; + /** Reads a finished tool result's details for the transcript card. */ + parseDetails: (details: Record) => StructuredAskRecord | null; +} + +/** A finished question, rebuilt from a tool result for the transcript. */ +export interface StructuredAskRecord { + question: string; + context?: string; + options: StructuredAskOption[]; + answer: StructuredAskAnswer | null; + cancelled: boolean; +} + +const MAX_OPTIONS = 64; +const MAX_TEXT = 4000; + +/** Key aliases models fall back to when a proxy mangles the option schema. */ +const OPTION_TITLE_KEYS = ["title", "label", "text", "value", "name", "option"] as const; +const OPTION_DESCRIPTION_KEYS = ["description", "detail", "details", "subtitle"] as const; + +function readString(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + if (!trimmed) return undefined; + return trimmed.length > MAX_TEXT ? trimmed.slice(0, MAX_TEXT) : trimmed; +} + +function readBoolean(value: unknown): boolean { + return value === true; +} + +function readOption(raw: unknown): StructuredAskOption | null { + const direct = readString(raw); + if (direct) return { title: direct }; + if (!raw || typeof raw !== "object") return null; + + const record = raw as Record; + let title: string | undefined; + for (const key of OPTION_TITLE_KEYS) { + title = readString(record[key]); + if (title) break; + } + if (!title) return null; + + let description: string | undefined; + for (const key of OPTION_DESCRIPTION_KEYS) { + description = readString(record[key]); + if (description) break; + } + return description ? { title, description } : { title }; +} + +function readOptions(raw: unknown): StructuredAskOption[] { + if (!Array.isArray(raw)) return []; + const options: StructuredAskOption[] = []; + const seen = new Set(); + for (const entry of raw.slice(0, MAX_OPTIONS)) { + const option = readOption(entry); + // Duplicate titles cannot be told apart in the answer, so keep the first. + if (!option || seen.has(option.title)) continue; + seen.add(option.title); + options.push(option); + } + return options; +} + +function readAnswer(raw: unknown, options: StructuredAskOption[]): StructuredAskAnswer | null { + if (!raw || typeof raw !== "object") return null; + const record = raw as Record; + + if (record.kind === "freeform") { + const text = readString(record.text); + return text ? { kind: "freeform", text } : null; + } + if (record.kind !== "selection") return null; + + const selections = Array.isArray(record.selections) + ? record.selections.map(readString).filter((entry): entry is string => Boolean(entry)) + : []; + if (selections.length === 0) return null; + // Historical answers may reference options that are no longer listed; keep + // them so the transcript still shows what the user picked. + void options; + + const comment = readString(record.comment); + return comment + ? { kind: "selection", selections, comment } + : { kind: "selection", selections }; +} + +/** + * `pi-ask-user` — the ask_user tool shipped by the pi-ask-user package. + * Its custom UI promise resolves with `AskResponse | null`, which is exactly + * the `StructuredAskAnswer` shape, so the answer passes straight through. + */ +const askUserAdapter: StructuredAskAdapter = { + parse: (args) => { + const question = readString(args.question); + if (!question) return null; + const options = readOptions(args.options); + // With no options the extension uses ctx.ui.input(), which Pi Web already + // renders as a native dialog. Only the option list needs this adapter. + if (options.length === 0) return null; + return { + question, + context: readString(args.context), + options, + allowMultiple: readBoolean(args.allowMultiple), + allowFreeform: args.allowFreeform !== false, + allowComment: readBoolean(args.allowComment), + }; + }, + toCustomUiValue: (answer) => answer, + cancelValue: null, + parseDetails: (details) => { + const question = readString(details.question); + if (!question) return null; + const context = readString(details.context); + const options = readOptions(details.options); + return { + question, + ...(context ? { context } : {}), + options, + answer: readAnswer(details.response, options), + cancelled: details.cancelled === true, + }; + }, +}; + +const STRUCTURED_ASK_ADAPTERS = new Map([ + ["ask_user", askUserAdapter], +]); + +export function isStructuredAskToolName(toolName: string): boolean { + return STRUCTURED_ASK_ADAPTERS.has(toolName); +} + +/** + * Builds a structured question from a tool call. Returns null when the tool is + * unknown or its arguments do not match the adapter, which keeps the generic + * terminal panel as the fallback. + */ +export function parseStructuredAsk( + toolName: unknown, + args: unknown, + toolCallId?: string, +): StructuredAskSpec | null { + if (typeof toolName !== "string") return null; + const adapter = STRUCTURED_ASK_ADAPTERS.get(toolName); + if (!adapter || !args || typeof args !== "object" || Array.isArray(args)) return null; + + const parsed = adapter.parse(args as Record); + if (!parsed) return null; + return toolCallId ? { ...parsed, toolName, toolCallId } : { ...parsed, toolName }; +} + +/** + * Validates a browser submission against the question it answers and returns + * the value to resolve the extension's custom UI promise with. + * + * Returns `{ ok: false }` when the submission does not fit the question, so the + * caller can leave the question open instead of resolving it with junk. + */ +export function resolveStructuredAskSubmission( + spec: StructuredAskSpec, + submission: unknown, +): { ok: true; value: unknown } | { ok: false } { + const adapter = STRUCTURED_ASK_ADAPTERS.get(spec.toolName); + if (!adapter || !submission || typeof submission !== "object") return { ok: false }; + + const record = submission as Record; + if (record.cancelled === true) return { ok: true, value: adapter.cancelValue }; + + const answer = normalizeStructuredAskAnswer(spec, record.answer); + if (!answer) return { ok: false }; + return { ok: true, value: adapter.toCustomUiValue(answer) }; +} + +/** + * Checks an answer against the question: freeform only when allowed, selected + * titles must exist, one selection unless multi-select is on, comment only + * when allowed. + */ +export function normalizeStructuredAskAnswer( + spec: StructuredAskSpec, + raw: unknown, +): StructuredAskAnswer | null { + if (!raw || typeof raw !== "object") return null; + const record = raw as Record; + + if (record.kind === "freeform") { + if (!spec.allowFreeform) return null; + const text = readString(record.text); + return text ? { kind: "freeform", text } : null; + } + + if (record.kind !== "selection") return null; + const titles = new Set(spec.options.map((option) => option.title)); + const selections: string[] = []; + let customEntries = 0; + const rawSelections = Array.isArray(record.selections) ? record.selections : []; + for (const entry of rawSelections) { + const title = readString(entry); + if (!title || selections.includes(title)) continue; + if (!titles.has(title)) { + // A multi-select answer may carry one typed entry beside the offered + // options, so a custom answer does not cost the user their checks. + if (!spec.allowFreeform || customEntries > 0) continue; + customEntries += 1; + } + selections.push(title); + } + if (selections.length === 0) return null; + if (!spec.allowMultiple && selections.length > 1) return null; + if (!spec.allowMultiple && customEntries > 0) return null; + + const comment = spec.allowComment ? readString(record.comment) : undefined; + return comment + ? { kind: "selection", selections, comment } + : { kind: "selection", selections }; +} + +/** Rebuilds a finished question from a tool result, for the transcript card. */ +export function parseStructuredAskResult( + toolName: string | undefined, + details: unknown, +): StructuredAskRecord | null { + if (!toolName) return null; + const adapter = STRUCTURED_ASK_ADAPTERS.get(toolName); + if (!adapter || !details || typeof details !== "object" || Array.isArray(details)) return null; + return adapter.parseDetails(details as Record); +} + +/** One-line summary of an answer, for collapsed views. */ +export function summarizeStructuredAskAnswer(answer: StructuredAskAnswer): string { + if (answer.kind === "freeform") return answer.text; + const selections = answer.selections.join(", "); + return answer.comment ? `${selections} - ${answer.comment}` : selections; +} From bbb9c4ea13a55d2ec2df56215764f246ed6246b4 Mon Sep 17 00:00:00 2001 From: max <23141894+snowboardit@users.noreply.github.com> Date: Mon, 14 Sep 2026 11:56:56 -0400 Subject: [PATCH 2/7] feat(ask): carry questions and answers over the session protocol Correlate a custom UI request with the tool call that runs when the request arrives. The extension API supplies no tool identity with ctx.ui.custom(), and an asking tool blocks while its question is open, so the running tool calls are both the only correlation available and a sufficient one. A recognized question rides along on the extension_ui_request event, and a new extension_ui_ask_response command answers it. The submission is checked against the question before the extension's promise resolves; one that does not fit leaves the question open. Unrecognized custom UIs are untouched and keep streaming terminal lines. --- lib/rpc-manager-structured-ask.test.mjs | 155 ++++++++++++++++++++++++ lib/rpc-manager.ts | 49 ++++++++ lib/types.ts | 13 ++ 3 files changed, 217 insertions(+) create mode 100644 lib/rpc-manager-structured-ask.test.mjs diff --git a/lib/rpc-manager-structured-ask.test.mjs b/lib/rpc-manager-structured-ask.test.mjs new file mode 100644 index 000000000..65e288f5a --- /dev/null +++ b/lib/rpc-manager-structured-ask.test.mjs @@ -0,0 +1,155 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { AgentSession } from "@earendil-works/pi-coding-agent"; +import { createJiti } from "jiti"; + +const jiti = createJiti(import.meta.url, { tsconfigPaths: true }); +const { AgentSessionWrapper } = await jiti.import("./rpc-manager.ts"); +const nextTurn = () => new Promise((resolve) => setImmediate(resolve)); + +const ASK_ARGS = { + question: "Which path?", + options: ["Path A", { title: "Path B", description: "Slower" }], + allowComment: true, +}; + +function setup(t, handler = async () => {}) { + let ui; + const inner = { + sessionId: "structured-ask-test", + isStreaming: false, isCompacting: false, isBashRunning: false, isIdle: true, + sessionManager: { getCwd: () => "/tmp" }, + agent: { state: {}, abort() {} }, + abortRetry() {}, abortCompaction() {}, abortBranchSummary() {}, dispose() {}, + prompt: AgentSession.prototype.prompt, + _tryExecuteExtensionCommand: AgentSession.prototype._tryExecuteExtensionCommand, + abort: AgentSession.prototype.abort, + waitForIdle: AgentSession.prototype.waitForIdle, + _extensionRunner: { + getCommand: () => ({ handler: () => handler(ui) }), + createCommandContext: () => ({}), + emitError: () => {}, + }, + extensionRunner: {}, + }; + const wrapper = new AgentSessionWrapper(inner); + t.after(() => wrapper.destroy()); + ui = wrapper.createExtensionUiContext(); + const events = []; + wrapper.onEvent((event) => events.push(event)); + return { wrapper, events }; +} + +/** Mimics the tool-execution event the wrapper tracks while a tool runs. */ +function runningAskTool(wrapper, args = ASK_ARGS) { + wrapper.activeToolEvents.set("call-1", { + type: "tool_execution_start", + toolCallId: "call-1", + toolName: "ask_user", + args, + }); +} + +/** + * Starts the extension command that opens the custom UI. The returned promise + * is deliberately not awaited here: it settles only once the question is + * answered. + */ +function openCustomUi(wrapper) { + return wrapper.send({ type: "prompt", message: "/ask" }); +} + +test("attaches the structured question to the custom UI request", async (t) => { + const results = []; + const { wrapper, events } = setup(t, async (ui) => { + results.push(await ui.custom(() => ({ render: () => ["Choose"] }))); + }); + runningAskTool(wrapper); + const sending = openCustomUi(wrapper); + await nextTurn(); + + const request = events.find((event) => event.method === "custom"); + assert.equal(request.ask.toolName, "ask_user"); + assert.equal(request.ask.question, "Which path?"); + assert.deepEqual(request.ask.options, [{ title: "Path A" }, { title: "Path B", description: "Slower" }]); + assert.equal(request.ask.allowComment, true); + + await wrapper.send({ + type: "extension_ui_ask_response", + id: request.id, + answer: { kind: "selection", selections: ["Path B"], comment: "ship it" }, + }); + await sending; + assert.deepEqual(results[0], { kind: "selection", selections: ["Path B"], comment: "ship it" }); + assert.equal(wrapper.activeCustomUis.size, 0); +}); + +test("a dismissed question resolves the extension with a cancel", async (t) => { + const results = []; + const { wrapper, events } = setup(t, async (ui) => { + results.push(await ui.custom(() => ({ render: () => ["Choose"] }))); + }); + runningAskTool(wrapper); + const sending = openCustomUi(wrapper); + await nextTurn(); + + const request = events.find((event) => event.method === "custom"); + await wrapper.send({ type: "extension_ui_ask_response", id: request.id, cancelled: true }); + await sending; + assert.equal(results[0], null); +}); + +test("a submission that does not fit the question leaves it open", async (t) => { + const results = []; + const { wrapper, events } = setup(t, async (ui) => { + results.push(await ui.custom(() => ({ render: () => ["Choose"] }))); + }); + runningAskTool(wrapper); + const sending = openCustomUi(wrapper); + await nextTurn(); + const request = events.find((event) => event.method === "custom"); + + await wrapper.send({ + type: "extension_ui_ask_response", + id: request.id, + answer: { kind: "selection", selections: ["Path C"] }, + }); + await nextTurn(); + assert.equal(results.length, 0); + assert.equal(wrapper.activeCustomUis.size, 1); + + // The terminal path still works while the question is open. + await wrapper.send({ type: "extension_ui_ask_response", id: request.id, cancelled: true }); + await sending; + assert.equal(results[0], null); +}); + +test("leaves unknown custom UIs on the terminal path", async (t) => { + const results = []; + const { wrapper, events } = setup(t, async (ui) => { + results.push(await ui.custom(() => ({ render: () => ["Choose"] }))); + }); + // A tool the adapter does not know about, plus an ask_user call with no options. + wrapper.activeToolEvents.set("call-2", { + type: "tool_execution_start", + toolCallId: "call-2", + toolName: "bash", + args: { command: "ls" }, + }); + runningAskTool(wrapper, { question: "Free text?" }); + const sending = openCustomUi(wrapper); + await nextTurn(); + + const request = events.find((event) => event.method === "custom"); + assert.equal(request.ask, undefined); + await wrapper.send({ + type: "extension_ui_ask_response", + id: request.id, + answer: { kind: "freeform", text: "hi" }, + }); + await nextTurn(); + assert.equal(results.length, 0); + + wrapper.closeCustomUi(request.id, null); + await sending; +}); diff --git a/lib/rpc-manager.ts b/lib/rpc-manager.ts index 45ea91eb2..9377fc1fb 100644 --- a/lib/rpc-manager.ts +++ b/lib/rpc-manager.ts @@ -28,6 +28,11 @@ import type { SessionMessageEntry, } from "./types"; import { createHeadlessCustomUiTui, DEFAULT_CUSTOM_UI_COLUMNS, type HeadlessCustomUiTui } from "./custom-ui-terminal"; +import { + parseStructuredAsk, + resolveStructuredAskSubmission, + type StructuredAskSpec, +} from "./structured-ask"; import { createSubagentExtension, preferPiWebSubagentExtension, @@ -93,6 +98,8 @@ type ActiveCustomUi = { width: number; resolve: (value: unknown) => void; settled: boolean; + /** Structured question behind this custom UI, when an adapter matched. */ + ask?: StructuredAskSpec; }; type ExtensionUiRequestBody = Record & { @@ -156,6 +163,7 @@ const COMMANDS_ALLOWED_DURING_SESSION_REPLACEMENT = new Set([ "get_commands", "extension_ui_response", "extension_ui_input", + "extension_ui_ask_response", ]); export interface RpcSessionStartOptions { @@ -976,6 +984,11 @@ export class AgentSessionWrapper { return null; } + case "extension_ui_ask_response": { + this.handleExtensionAskResponse(command.id as string, command); + return null; + } + case "set_auto_retry": { this.inner.setAutoRetryEnabled(command.enabled as boolean); return null; @@ -1325,6 +1338,7 @@ export class AgentSessionWrapper { id, method: "custom", lines, + ...(custom.ask ? { ask: custom.ask } : {}), } as ExtensionUiRequest as AgentEvent; this.pendingUiRequests.set(id, event); this.emit(event); @@ -1351,6 +1365,40 @@ export class AgentSessionWrapper { custom.resolve(value); } + /** + * Finds the question-asking tool call that this custom UI belongs to. + * + * The extension API gives the host no tool identity with `ctx.ui.custom()`, + * so the running tool calls are the only correlation available. A tool blocks + * while its question is open, so at most one structured ask is in flight in + * practice; the newest match wins. + */ + private detectStructuredAsk(): StructuredAskSpec | undefined { + let found: StructuredAskSpec | undefined; + for (const event of this.activeToolEvents.values()) { + const spec = parseStructuredAsk( + (event as { toolName?: unknown }).toolName, + (event as { args?: unknown }).args, + (event as { toolCallId?: string }).toolCallId, + ); + if (spec) found = spec; + } + return found; + } + + /** + * Answers a structured question from the browser form. The submission is + * checked against the question before the extension's custom UI promise is + * resolved; a submission that does not fit leaves the question open. + */ + private handleExtensionAskResponse(id: string, submission: unknown): void { + const custom = this.activeCustomUis.get(id); + if (!custom?.ask) return; + const resolved = resolveStructuredAskSubmission(custom.ask, submission); + if (!resolved.ok) return; + this.closeCustomUi(id, resolved.value); + } + private handleExtensionUiInput(id: string, data: string): void { const custom = this.activeCustomUis.get(id); if (!custom || typeof data !== "string") return; @@ -1426,6 +1474,7 @@ export class AgentSessionWrapper { width, resolve: (value) => finish(value as T), settled: false, + ask: this.detectStructuredAsk(), }; this.activeCustomUis.set(id, custom); this.emitCustomUiRender(id, custom); diff --git a/lib/types.ts b/lib/types.ts index bbd721280..4fda3c429 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1,5 +1,7 @@ // Types mirrored from pi-mono coding-agent session-manager +import type { StructuredAskSpec, StructuredAskSubmission } from "./structured-ask"; + export interface SessionHeader { type: "session"; version?: number; @@ -191,6 +193,12 @@ export type ExtensionUiRequest = method: "custom"; lines: string[]; closed?: boolean; + /** + * Set when a structured-ask adapter recognized the tool call behind this + * custom UI. The browser renders a native question form instead of the + * terminal panel. See lib/structured-ask.ts. + */ + ask?: StructuredAskSpec; }; export type BlockingExtensionUiRequest = Extract< @@ -198,6 +206,11 @@ export type BlockingExtensionUiRequest = Extract< { method: "select" | "confirm" | "input" | "editor" | "custom" } >; +export type ExtensionUiAskResponse = { + type: "extension_ui_ask_response"; + id: string; +} & StructuredAskSubmission; + export type ExtensionUiResponse = | { type: "extension_ui_response"; id: string; value: string } | { type: "extension_ui_response"; id: string; confirmed: boolean } From d69ac956e6d250b7bd2e468112a0e0975b1030bc Mon Sep 17 00:00:00 2001 From: max <23141894+snowboardit@users.noreply.github.com> Date: Mon, 14 Sep 2026 11:56:56 -0400 Subject: [PATCH 3/7] feat(ask): render a recognized question as a native form A terminal panel cannot be used with a pointer and is close to unusable on a phone, so a recognized question now gets a real form: single select that submits on tap, multi-select with a count, a freeform row, an optional comment, and Skip. Multi-select keeps a typed answer beside the checked options, because a custom answer should not cost the user their checks. Keyboard support sits on top of real buttons, not in place of them. The header chevron minimizes the card instead of dismissing it, so the conversation stays readable while the user decides. Only Skip cancels. Unrecognized custom UIs keep the terminal panel, and a recognized question can still be answered there through the terminal-view button. --- components/AskCard.test.mjs | 56 +++++ components/AskCard.tsx | 427 ++++++++++++++++++++++++++++++++++++ components/ChatWindow.tsx | 17 +- hooks/useAgentSession.ts | 25 ++- lib/i18n/messages/en.ts | 11 + lib/i18n/messages/zh-CN.ts | 11 + lib/i18n/messages/zh-TW.ts | 11 + 7 files changed, 555 insertions(+), 3 deletions(-) create mode 100644 components/AskCard.test.mjs create mode 100644 components/AskCard.tsx diff --git a/components/AskCard.test.mjs b/components/AskCard.test.mjs new file mode 100644 index 000000000..4093e62c1 --- /dev/null +++ b/components/AskCard.test.mjs @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const askCard = await readFile(new URL("./AskCard.tsx", import.meta.url), "utf8"); +const chatWindow = await readFile(new URL("./ChatWindow.tsx", import.meta.url), "utf8"); + +test("renders a structured question as pointer-first controls", () => { + // Options and the freeform entry are real controls, not terminal text. + assert.match(askCard, /ask\.options\.map/); + assert.match(askCard, /data-ask-option/); + assert.match(askCard, /minHeight: ROW_MIN_HEIGHT/); + assert.equal(/AnsiText/.test(askCard), false); + assert.match(askCard, /const ROW_MIN_HEIGHT = 48/); +}); + +test("submits a single choice immediately and a multi choice from the footer", () => { + assert.match(askCard, /if \(!ask\.allowComment\) submit\(\{ kind: "selection", selections: \[title\] \}\)/); + assert.match(askCard, /current\.includes\(title\) \? current\.filter\(/); + assert.match(askCard, /chat\.askSubmitCount/); +}); + +test("a typed answer and checked options coexist in a multi-select", () => { + assert.match(askCard, /ask\.allowMultiple && freeformText\s*\?\s*\[\.\.\.selected, freeformText\]/); + assert.match(askCard, /if \(!ask\.allowMultiple && event\.target\.value\.trim\(\)\) setSelected\(\[\]\)/); + assert.match(askCard, /askSubmitCount", \{ count: answer\.selections\.length \}/); +}); + +test("offers freeform text and an optional comment only when allowed", () => { + assert.match(askCard, /ask\.allowFreeform && \(/); + assert.match(askCard, /ask\.allowComment && answer !== null && \(/); +}); + +test("minimizes instead of dismissing, so the conversation stays readable", () => { + assert.match(askCard, /const \[minimized, setMinimized\] = useState\(false\)/); + assert.match(askCard, /onClick=\{\(\) => setMinimized\(true\)\}[\s\S]*?chat\.askMinimize/); + assert.match(askCard, /minimized \? \([\s\S]*?setMinimized\(false\)[\s\S]*?chat\.askExpand/); + // Only Skip cancels the question. + assert.equal((askCard.match(/onClick=\{onCancel\}/g) ?? []).length, 1); + assert.match(askCard, /onClick=\{onCancel\}[\s\S]*?chat\.askSkip/); +}); + +test("keeps keyboard support as a second layer", () => { + assert.match(askCard, /event\.key === "Escape"[\s\S]*?setMinimized\(true\)/); + assert.match(askCard, /\/\^\[1-9\]\$\/\.test\(event\.key\)/); + assert.match(askCard, /ArrowDown/); +}); + +test("chat window prefers the native card and keeps the terminal escape hatch", () => { + assert.match(chatWindow, /extensionCustomUi\.ask && extensionCustomUi\.id !== rawCustomUiId[\s\S]*? setRawCustomUiId\(extensionCustomUi\.id\)\}/); +}); + + diff --git a/components/AskCard.tsx b/components/AskCard.tsx new file mode 100644 index 000000000..67408efdf --- /dev/null +++ b/components/AskCard.tsx @@ -0,0 +1,427 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { MarkdownBody } from "./MarkdownBody"; +import { useI18n } from "@/hooks/useI18n"; +import type { StructuredAskAnswer, StructuredAskSpec } from "@/lib/structured-ask"; + +/** + * Native question form for a structured ask (see lib/structured-ask.ts). + * + * Pointer first: every action is a tap target that works on a phone. Keyboard + * support is a second layer on top of real buttons and inputs. + */ + +const ROW_MIN_HEIGHT = 48; + +function rowStyle(selected: boolean, first: boolean): React.CSSProperties { + return { + display: "flex", + alignItems: "flex-start", + gap: 12, + width: "100%", + minHeight: ROW_MIN_HEIGHT, + padding: "10px 12px", + textAlign: "left", + background: selected ? "var(--accent-soft, rgba(99,102,241,0.12))" : "transparent", + color: "var(--text)", + border: "none", + borderTop: first ? "none" : "1px solid var(--border)", + cursor: "pointer", + font: "inherit", + }; +} + +function badgeStyle(selected: boolean): React.CSSProperties { + return { + display: "grid", + placeItems: "center", + flexShrink: 0, + width: 26, + height: 26, + borderRadius: 6, + border: `1px solid ${selected ? "var(--accent)" : "var(--border)"}`, + background: selected ? "var(--accent)" : "var(--bg-panel)", + color: selected ? "var(--accent-contrast)" : "var(--text-dim)", + fontSize: 12, + fontVariantNumeric: "tabular-nums", + }; +} + +function CheckIcon() { + return ( + + ); +} + +export function AskCard({ + ask, + onSubmit, + onCancel, + onShowRaw, +}: { + ask: StructuredAskSpec; + onSubmit: (answer: StructuredAskAnswer) => void; + onCancel: () => void; + onShowRaw?: () => void; +}) { + const { t } = useI18n(); + const [selected, setSelected] = useState([]); + const [freeform, setFreeform] = useState(""); + const [comment, setComment] = useState(""); + const [contextOpen, setContextOpen] = useState(false); + const [submitted, setSubmitted] = useState(false); + // Minimized, not dismissed: the question stays open while the user reads the + // conversation behind it. Only Skip cancels. + const [minimized, setMinimized] = useState(false); + const rowsRef = useRef(null); + const commentRef = useRef(null); + + const needsFooter = ask.allowMultiple || ask.allowComment; + const freeformText = freeform.trim(); + const canSubmit = freeformText.length > 0 || selected.length > 0; + + const answer = useMemo(() => { + // Multi-select treats typed text as one more answer, so a custom entry and + // checked options travel together. Single-select allows one answer only, so + // the two clear each other. + const selections = ask.allowMultiple && freeformText + ? [...selected, freeformText] + : selected; + if (selections.length === 0) { + return freeformText ? { kind: "freeform", text: freeformText } : null; + } + const trimmedComment = comment.trim(); + return trimmedComment && ask.allowComment + ? { kind: "selection", selections, comment: trimmedComment } + : { kind: "selection", selections }; + }, [ask.allowComment, ask.allowMultiple, comment, freeformText, selected]); + + const submit = (value: StructuredAskAnswer | null) => { + if (!value || submitted) return; + setSubmitted(true); + onSubmit(value); + }; + + const chooseOption = (title: string) => { + if (ask.allowMultiple) { + setSelected((current) => + current.includes(title) ? current.filter((entry) => entry !== title) : [...current, title], + ); + return; + } + setSelected([title]); + setFreeform(""); + // A single choice with nothing left to add is the answer itself. + if (!ask.allowComment) submit({ kind: "selection", selections: [title] }); + }; + + useEffect(() => { + if (ask.allowComment && selected.length > 0) commentRef.current?.focus(); + }, [ask.allowComment, selected.length]); + + + /** Arrow keys walk the option rows; digits pick one; Escape dismisses. */ + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + setMinimized(true); + return; + } + const target = event.target as HTMLElement; + const typing = target.tagName === "INPUT" || target.tagName === "TEXTAREA"; + + if (!typing && /^[1-9]$/.test(event.key)) { + const option = ask.options[Number(event.key) - 1]; + if (option) { + event.preventDefault(); + chooseOption(option.title); + } + return; + } + if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return; + const rows = Array.from(rowsRef.current?.querySelectorAll("[data-ask-option]") ?? []); + if (rows.length === 0) return; + event.preventDefault(); + const current = rows.indexOf(document.activeElement as HTMLButtonElement); + const step = event.key === "ArrowDown" ? 1 : -1; + const next = current === -1 ? 0 : (current + step + rows.length) % rows.length; + rows[next]?.focus(); + }; + + return ( +
+ {minimized ? ( + + ) : ( +
+
+
+ {ask.question} +
+ {onShowRaw && ( + + )} + +
+ + {ask.context && ( +
+ + {contextOpen && ( +
+ {ask.context} +
+ )} +
+ )} + +
+ {ask.options.map((option, index) => { + const isSelected = selected.includes(option.title); + return ( + + ); + })} + + {ask.allowFreeform && ( +
+ + { + setFreeform(event.target.value); + if (!ask.allowMultiple && event.target.value.trim()) setSelected([]); + }} + onKeyDown={(event) => { + if (event.key !== "Enter" || event.nativeEvent.isComposing) return; + event.preventDefault(); + submit(answer); + }} + placeholder={t("chat.askFreeformPlaceholder")} + aria-label={t("chat.askFreeformPlaceholder")} + style={{ + flex: 1, + minWidth: 0, + padding: "8px 10px", + borderRadius: 8, + border: "1px solid var(--border)", + background: "var(--bg-panel)", + color: "var(--text)", + fontSize: 14, + outline: "none", + }} + /> +
+ )} +
+ + {ask.allowComment && answer !== null && ( +
+