Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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=`.
Expand Down
3 changes: 2 additions & 1 deletion components/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
});
Expand Down
87 changes: 87 additions & 0 deletions components/AskAnswerCard.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
style={{
borderRadius: 8,
border: "1px solid var(--border)",
background: "var(--bg-panel)",
overflow: "hidden",
fontSize: 13,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8, padding: "6px 10px", borderBottom: "1px solid var(--border)" }}>
<span style={{ fontSize: 11, fontWeight: 600, color: "var(--accent)", fontFamily: "var(--font-mono)" }}>
{t("chat.askTitle")}
</span>
<span style={{ fontSize: 11, color: "var(--text-dim)" }}>{status}</span>
</div>

<div style={{ padding: "8px 10px", color: "var(--text)" }}>
<MarkdownBody>{record.question}</MarkdownBody>
</div>

{record.options.length > 0 && (
<ul style={{ listStyle: "none", margin: 0, padding: "0 10px 8px", display: "flex", flexDirection: "column", gap: 4 }}>
{record.options.map((option) => {
const chosen = selections.includes(option.title);
return (
<li
key={option.title}
style={{
display: "flex",
alignItems: "baseline",
gap: 8,
padding: "4px 8px",
borderRadius: 6,
border: `1px solid ${chosen ? "var(--accent)" : "transparent"}`,
color: chosen ? "var(--text)" : "var(--text-dim)",
}}
>
<span aria-hidden="true" style={{ fontSize: 11, color: chosen ? "var(--accent)" : "var(--text-dim)" }}>
{chosen ? "\u2713" : "\u00b7"}
</span>
<span>{option.title}</span>
</li>
);
})}
</ul>
)}

{freeform && (
<div style={{ padding: "0 10px 8px", color: "var(--text)" }}>
<span style={{ fontSize: 11, color: "var(--text-dim)" }}>{t("chat.askFreeformAnswer")}</span>
<div>{freeform}</div>
</div>
)}

{answer?.kind === "selection" && answer.comment && (
<div style={{ padding: "0 10px 8px", color: "var(--text-muted)" }}>
<span style={{ fontSize: 11, color: "var(--text-dim)" }}>{t("chat.askComment")}</span>
<div>{answer.comment}</div>
</div>
)}
</div>
);
}
71 changes: 71 additions & 0 deletions components/AskCard.test.mjs
Original file line number Diff line number Diff line change
@@ -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]*?<AskCard/);
assert.match(chatWindow, /\(!extensionCustomUi\.ask \|\| extensionCustomUi\.id === rawCustomUiId\)[\s\S]*?<ExtensionCustomPanel/);
assert.match(chatWindow, /respondToExtensionAsk\(extensionCustomUi, \{ cancelled: true \}\)/);
assert.match(chatWindow, /onShowRaw=\{\(\) => 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 <AskAnswerCard record=\{askRecord\} \/>/);
assert.match(messageView, /pendingAsk[\s\S]*?<AskAnswerCard\s+pending/);
assert.match(answerCard, /chat\.askAnswered/);
assert.match(answerCard, /chat\.askCancelled/);
});
Loading