import { useState } from "react"; import type { ApprovalDecision, Item } from "../types"; import { humanizeApprovalTitle, type HumanLine } from "../humanize"; import { Icon } from "./Icon"; export function shortArgs(args: any): string { if (!args || typeof args !== "object") return ""; return Object.entries(args) .map(([k, v]) => { let s = typeof v === "string" ? v : JSON.stringify(v); if (s.length > 96) s = s.slice(0, 95) + "..."; return `${k}=${s.replace(/\n/g, " ")}`; }) .join(" "); } // Human verbs kept for the §25 grant lines (the card title now comes from humanize.ts). const TOOL_VERBS: Record = { write_file: "Write a file", replace_in_file: "Edit a file", apply_patch: "Apply a patch", apply_unified_diff: "Apply a patch", run_shell: "Run a command", send_message: "Send a message", send_file: "Send a file", }; // §35: routine workspace writes render as a compact ROW; everything else is a full card. const FILE_WRITES = new Set(["write_file", "replace_in_file", "apply_patch", "apply_unified_diff"]); // Actions that leave the Mac get the warm border + explicit destination note. const EXTERNAL = new Set(["send_message", "send_file"]); type ApprovalItem = Extract; // Per-tool button copy (§7): a skill proposal is an "add", not an "allow". Shared with the // parked Inbox card so both dialects match. export function approvalActionLabels(name?: string): { allow: string; deny: string } { return name === "save_skill" ? { allow: "Add to my skills", deny: "Not now" } : { allow: "Allow once", deny: "Deny" }; } // save_skill's review surface (SKILLS-SPEC §5.2): description, the full instructions // (clamped, expandable, scrollable), every bundled file, and the guaranteed footer that // answers "added WHERE, available WHEN". Shared verbatim with the parked Inbox card — // one decision, one dialect. export function SaveSkillPreview({ args }: { args: any }) { return ( <> {args?.description &&
{String(args.description)}
} {args?.instructions && } {Array.isArray(args?.files) && args.files.length > 0 && (
{args.files.map((f: unknown, i: number) => ( {String(f).split(/[\\/]/).pop() || String(f)} ))}
)}
Approving adds it to your skills on this computer — usable in every conversation from then on.
); } // A `permissions` proposal on the create_scheduled_task consent card (§25): reads are // disclosure lines, writes are the standing grants the approval mints. interface PermissionLine { tool: string; target: string; access: string; } function permissionLines(args: any): PermissionLine[] { const raw = args?.permissions; if (!Array.isArray(raw)) return []; return raw .filter((p) => p && typeof p === "object" && p.tool && p.target) .map((p) => ({ tool: String(p.tool), target: String(p.target), access: String(p.access || "read") })); } export function TitleText({ line }: { line: HumanLine }) { return ( {line.pre} {line.obj && {line.obj}} {line.post} ); } // The host a fetch-card domain grant would cover (§1.9): lowercased, `www.` stripped — // pure spelling only, mirroring the server's minting in `allow_domain_for_session`. The // button must name exactly what the grant covers. "" when the URL doesn't parse. export function grantHost(url: any): string { try { const h = new URL(String(url ?? "")).hostname.toLowerCase(); return h.startsWith("www.") ? h.slice(4) : h; } catch { return ""; } } // Plain-words scope note (replaces the "local action" badge): where does this act? // Shared with the parked-approval card (InboxItemCard) so both dialects match (§35). export function scopeNote( name: string, args: any, category?: string, ): { text: string; external: boolean } { // save_skill's corner answers WHERE (SKILLS-SPEC §5.2): the exact place to find, edit, // or turn off the skill afterwards. if (name === "save_skill") return { text: "saves to Settings ▸ Skills", external: false }; if (category === "connector") return { text: "acts on a connected service", external: true }; // Egress (§1.9): the request itself reaches the network — never "stays on this computer". if (name === "web_fetch") return { text: `leaves this computer → ${grantHost(args?.url) || "the web"}`, external: true }; if (name === "web_search") return { text: "leaves this computer → your search provider", external: true }; if (EXTERNAL.has(name)) { const platform = String(args?.target ?? "").split(":")[0]; const names: Record = { slack: "Slack", telegram: "Telegram" }; return { text: `leaves this computer → ${names[platform] || platform || "a connected chat"}`, external: true }; } const overwrite = name === "write_file" && args?.overwrite; return { text: "stays on this computer" + (overwrite ? " · overwrites the existing file" : ""), external: false }; } // The proposed content/command, straight from the tool call's ARGS — the file/action // doesn't exist yet, so no viewer could show it (§35; see UX-018 mock note). // Clamps by CHARACTERS as well as lines: a one-paragraph Slack digest has no // newlines at all and once ballooned the card to full-transcript height. const PREVIEW_LINES = 5; const PREVIEW_CHARS = 420; export function PreviewBlock({ text, mono = true }: { text: string; mono?: boolean }) { const [all, setAll] = useState(false); const lines = text.split("\n"); const clipped = lines.length > PREVIEW_LINES || text.length > PREVIEW_CHARS; let shown = text; if (!all && clipped) { shown = lines.slice(0, PREVIEW_LINES).join("\n"); if (shown.length > PREVIEW_CHARS) shown = shown.slice(0, PREVIEW_CHARS).trimEnd() + "…"; } return (
{shown} {clipped && ( )}
); } // Outbound message text: short one-liners keep the cozy inline quote; anything // long (or multi-line) gets the clamped preview so the card stays card-sized. function MessagePreview({ text, label }: { text: string; label?: string }) { if (text.length <= 220 && !text.includes("\n")) { return (
{label ? `${label}: ` : ""}“{text}”
); } return ; } function Buttons({ item, onApprove, runTask, primaryLabel, denyLabel = "Deny", autoApprove = false, }: { item: ApprovalItem; onApprove: (decision: ApprovalDecision) => void; runTask?: { id: string; title: string } | null; primaryLabel: string; denyLabel?: string; // Session is in Auto-Approve mode: session grants don't skip the reviewer there (§1.5), // so no session-scoped "always" button is shown at all — a button that lies is worse // than none. Allow once / Deny only. autoApprove?: boolean; }) { const connector = item.category === "connector"; const offerStanding = !!(runTask && item.standingTarget); // §1.9: egress grants are destination-shaped. web_fetch offers the DOMAIN — tool-wide // would cover every future destination, so it's withheld (and server-refused). web_search // has a fixed destination (the configured provider), so tool-wide IS provider-wide and // the button is labelled by what it actually grants: searches. const fetchHost = item.name === "web_fetch" ? grantHost(item.args?.url) : ""; const noSessionGrant = autoApprove || offerStanding || connector || item.name === "run_shell" || item.name === "save_skill" || item.name === "web_fetch" || item.name === "web_search"; return (
{offerStanding && ( )} {/* In a run context the task-persistent grant replaces the session-scoped one — a run session is ephemeral, and two adjacent "always" buttons would blur exactly the scope distinction §25 exists to draw. Same rule for run_shell: the command-scoped button below is the specific (safer) grant, so the tool-wide one stays out of the card. */} {/* save_skill: no session-wide "always" — every skill proposal gets its own review (SKILLS-SPEC §5: one gate, always). */} {!noSessionGrant && ( )} {!autoApprove && !offerStanding && item.name === "web_fetch" && fetchHost && ( )} {!autoApprove && !offerStanding && item.name === "web_search" && ( )} {!autoApprove && item.name === "run_shell" && ( )}
); } export function ApprovalCard({ item, onApprove, runTask, compact = false, autoApprove = false, }: { item: ApprovalItem; onApprove: (decision: ApprovalDecision) => void; // Present when this approval was raised inside an automation run — unlocks the // task-persistent "Allow every time" (in-app only, §25). runTask?: { id: string; title: string } | null; compact?: boolean; // Session is in Auto-Approve mode — this card is a reviewer fall-through, and session // grants wouldn't skip the reviewer anyway (§1.5), so the "always" buttons are hidden. autoApprove?: boolean; }) { const [peek, setPeek] = useState(false); const title = humanizeApprovalTitle(item.name, item.args); const scope = scopeNote(item.name, item.args, item.category); const grants = item.name === "create_scheduled_task" ? permissionLines(item.args) : []; // "requires approval" is the engine's default boilerplate — only surface a real reason. const reason = item.reason && item.reason !== "requires approval" ? item.reason : ""; const offerStanding = !!(runTask && item.standingTarget); const dock = compact ? " approval-dock" : ""; // §35 compact row: routine workspace writes — one line, preview expands inline from the // tool args. Standing/grant flows keep the full card (they carry §25 consent weight). const content = typeof item.args?.content === "string" ? item.args.content : ""; if (FILE_WRITES.has(item.name) && !offerStanding && !grants.length && !item.resolved) { return (
{content && ( )}
{peek && content && } {reason &&
{reason}
}
); } return (
{scope.text}
{/* Tool-shaped previews — the proposal, not an args dump. */} {item.name === "run_shell" && item.args?.command && ( )} {FILE_WRITES.has(item.name) && content && } {item.name === "send_file" && ( <> {String(item.args?.path ?? "").split("/").pop() || "file"} {item.args?.as_screenshot ? " · as a PNG screenshot" : ""} {item.args?.comment && ( )} )} {item.name === "send_message" && item.args?.text && ( )} {/* save_skill (SKILLS-SPEC §5.2): the arguments ARE the review surface. */} {item.name === "save_skill" && } {/* web_search (§1.9): name the LIVE destination — "currently", never "default", because the card must show the setting as it stands right now. */} {item.name === "web_search" && (
Queries go to your configured search provider {item.searchProvider ? ` (currently: ${item.searchProvider})` : ""}.
)} {grants.length > 0 && (
{grants.map((g, i) => (
{g.access === "write" ? "✓" : "·"} {TOOL_VERBS[g.tool] || g.tool} {g.target} {g.access === "write" ? " — always allowed once you approve" : " — read-only"}
))}
)} {/* Long-tail tools: no bespoke preview — fall back to the compact args line. */} {!FILE_WRITES.has(item.name) && !["run_shell", "send_message", "send_file", "save_skill"].includes(item.name) && !grants.length && shortArgs(item.args) &&
{shortArgs(item.args)}
} {reason &&
{reason}
} {item.resolved ? (
Approved: {item.resolved.replace("_", " ")}
) : ( )}
); }