diff --git a/AGENTS.md b/AGENTS.md index a2baf0565a..75701a321b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,6 +85,7 @@ lib/ pi-types.ts local structural types for pi SDK objects rpc-manager.ts AgentSessionWrapper + registry + startRpcSession session-reader.ts SessionManager wrappers + path cache + buildSessionContext adapter + structured-ask.ts adapter layer turning question-asking tool calls into native forms subagent-settings.ts read/write ~/.pi/agent/agents/settings.json tool-presets.ts PRESET_NONE/READ_ONLY/DEFAULT/FULL + getPresetFromTools() tool-preset-preference.ts browser-persisted default for fresh sessions @@ -98,6 +99,8 @@ components/ ChatWindow.tsx chat composition + completion sound wrapper ChatInput.tsx input bar + model/thinking/tools/compact controls MessageView.tsx renders one message (user/assistant/toolCall/toolResult) + AskCard.tsx native form for a structured ask (see docs/adr/0004) + AskAnswerCard.tsx transcript card for a pending or answered question BranchNavigator.tsx in-session branch switcher ChatMinimap.tsx scroll minimap alongside the message list MarkdownBody.tsx markdown renderer @@ -132,6 +135,9 @@ hooks/ **Fix**: `send("fork")` captures `newSessionId`, then calls `this.destroy()` before returning. The next request for the original session reloads a clean AgentSession from the original file. +### Questions from tools are rendered natively (`lib/structured-ask.ts`) +Extensions ask questions through `ctx.ui.custom()`, which Pi Web otherwise streams as terminal text. A structured-ask adapter recognizes a known question tool (`ask_user`), attaches an `ask` field to the `extension_ui_request` event, and the browser renders `AskCard`. The answer comes back as `extension_ui_ask_response`, is checked against the question, then resolves the extension's promise. Unknown custom UIs keep the terminal panel. See `docs/adr/0004-structured-ask-adapter.md`. + ### Two kinds of branching — don't confuse them - **Fork** ("New session" on user message): creates a new independent `.jsonl` file. Shown as a child in the sidebar tree via `parentSession` header field. - **In-session branch** ("Edit from here" / BranchNavigator): calls `navigate_tree` within the same file. Multiple entries share the same `parentId`. Switching between them calls `/api/sessions/[id]/context?leafId=`. diff --git a/components/AppShell.tsx b/components/AppShell.tsx index d1c8bb99dd..53585ad97e 100644 --- a/components/AppShell.tsx +++ b/components/AppShell.tsx @@ -867,7 +867,8 @@ export function AppShell() { targetSession: selectedSession, title: translate("i18n.attentionNeeded"), body: request.method === "custom" - ? translate("i18n.extensionInputNeeded") + // A structured question carries its own text; other custom UIs do not. + ? request.ask?.question ?? translate("i18n.extensionInputNeeded") : request.title, tag: `pi-extension-ui:${request.id}`, }); diff --git a/components/AskAnswerCard.tsx b/components/AskAnswerCard.tsx new file mode 100644 index 0000000000..63941ad00a --- /dev/null +++ b/components/AskAnswerCard.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { MarkdownBody } from "./MarkdownBody"; +import { useI18n } from "@/hooks/useI18n"; +import type { StructuredAskRecord } from "@/lib/structured-ask"; + +/** + * Transcript view of a finished structured ask: the question, the options that + * were offered, and what the user answered. Keeps a decision visible in history + * instead of collapsing it into a generic tool call. + */ +export function AskAnswerCard({ record, pending }: { record: StructuredAskRecord; pending?: boolean }) { + const { t } = useI18n(); + const answer = record.answer; + const selections = answer?.kind === "selection" ? answer.selections : []; + const freeform = answer?.kind === "freeform" ? answer.text : null; + + const status = pending + ? t("chat.askPending") + : record.cancelled || !answer + ? t("chat.askCancelled") + : t("chat.askAnswered"); + + return ( +
+
+ + {t("chat.askTitle")} + + {status} +
+ +
+ {record.question} +
+ + {record.options.length > 0 && ( + + )} + + {freeform && ( +
+ {t("chat.askFreeformAnswer")} +
{freeform}
+
+ )} + + {answer?.kind === "selection" && answer.comment && ( +
+ {t("chat.askComment")} +
{answer.comment}
+
+ )} +
+ ); +} diff --git a/components/AskCard.test.mjs b/components/AskCard.test.mjs new file mode 100644 index 0000000000..bd05250c9f --- /dev/null +++ b/components/AskCard.test.mjs @@ -0,0 +1,71 @@ +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 answerCard = await readFile(new URL("./AskAnswerCard.tsx", import.meta.url), "utf8"); +const chatWindow = await readFile(new URL("./ChatWindow.tsx", import.meta.url), "utf8"); +const messageView = await readFile(new URL("./MessageView.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\)\}/); +}); + +test("questions stay out of the collapsed process group", () => { + assert.match(chatWindow, /const askViews: ReactNode\[\] = \[\]/); + assert.match(chatWindow, /allBlocks\.filter\(isStructuredAskBlock\)/); + assert.match(chatWindow, /rendered\.push\(\.\.\.askViews\)/); + assert.match(chatWindow, /block\.type === "toolCall" && isStructuredAskToolName\(block\.toolName\)/); +}); + +test("transcript shows a decision card for asked questions", () => { + assert.match(messageView, /parseStructuredAskResult\(block\.toolName, result\?\.details\)/); + assert.match(messageView, /if \(askRecord\) return /); + assert.match(messageView, /pendingAsk[\s\S]*?