{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "agent-plan",
  "title": "Agent Plan",
  "description": "An agent's plan while it is still a draft — ordered steps with their rationale, risk chips on the dangerous ones, per-step include/exclude, inline rewriting, a live duration and cost estimate for the steps you kept, and a one-shot Approve or Request changes.",
  "dependencies": [
    "lucide-react",
    "zod"
  ],
  "registryDependencies": [
    "badge",
    "button",
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/ui/agent-plan.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  Check,\n  CircleAlert,\n  CircleCheck,\n  ClipboardList,\n  Clock3,\n  Coins,\n  Info,\n  ListChecks,\n  Lock,\n  MessageSquare,\n  Pencil,\n  RefreshCcw,\n  Send,\n  ShieldAlert,\n  TriangleAlert,\n  Undo2,\n  X,\n} from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Badge } from \"@/components/ui/badge\"\nimport { Button } from \"@/components/ui/button\"\nimport type {\n  AgentPlanDecisionKind,\n  AgentPlanProposal,\n  AgentPlanRiskLevel,\n  AgentPlanStatus,\n  AgentPlanStep,\n} from \"./agent-plan.contract\"\n\n/* --------------------------------------------------------------- formatting */\n\nconst costFormatter = new Intl.NumberFormat(\"en-US\", {\n  style: \"currency\",\n  currency: \"USD\",\n  minimumFractionDigits: 2,\n  maximumFractionDigits: 2,\n})\n\n/**\n * A plan estimate is a guess about the future, so it is rounded to the unit a\n * human would say out loud (\"about 12 min\"), never to the millisecond. Anything\n * more precise reads as a promise the agent cannot keep.\n */\nfunction defaultFormatDuration(ms: number): string {\n  if (!Number.isFinite(ms) || ms <= 0) return \"0 s\"\n  if (ms < 60_000) return `${Math.max(1, Math.round(ms / 1000))} s`\n  const minutes = Math.round(ms / 60_000)\n  if (minutes < 60) return `${minutes} min`\n  const hours = Math.floor(minutes / 60)\n  const rest = minutes % 60\n  return rest === 0 ? `${hours} h` : `${hours} h ${rest} min`\n}\n\n/**\n * Sub-cent totals are the norm for a short plan, and `$0.00` would read as\n * \"free\" — which is exactly the wrong thing to tell someone who is about to\n * approve spending. Anything above zero that rounds to nothing says so.\n */\nfunction defaultFormatCost(usd: number): string {\n  if (!Number.isFinite(usd) || usd < 0) return \"—\"\n  if (usd > 0 && usd < 0.01) return \"< $0.01\"\n  return costFormatter.format(usd)\n}\n\nconst RISK_WORD: Record<AgentPlanRiskLevel, string> = {\n  low: \"low risk\",\n  medium: \"medium risk\",\n  high: \"high risk\",\n}\n\nfunction plural(count: number, one: string, many: string): string {\n  return count === 1 ? one : many\n}\n\n/* ------------------------------------------------------------------- pieces */\n\n/**\n * Three levels, three SHAPES (shield / triangle / info) and three tones. Colour\n * alone is unreadable in a monochrome theme and invisible to a colour-blind\n * reviewer — and this is the chip that decides whether someone reads the step\n * twice.\n */\nfunction RiskChip({ level }: { level: AgentPlanRiskLevel }) {\n  const Icon = level === \"high\" ? ShieldAlert : level === \"medium\" ? TriangleAlert : Info\n  return (\n    <Badge\n      className={cn(\"shrink-0\", level === \"medium\" && \"text-foreground\", level === \"low\" && \"text-muted-foreground\")}\n      data-risk={level}\n      variant={level === \"high\" ? \"destructive\" : level === \"medium\" ? \"outline\" : \"ghost\"}\n    >\n      <Icon aria-hidden=\"true\" />\n      {RISK_WORD[level]}\n    </Badge>\n  )\n}\n\nfunction SkeletonRow() {\n  return (\n    <div className=\"flex items-start gap-2.5 border-t p-3\">\n      <div className=\"size-4 shrink-0 animate-pulse rounded-sm bg-muted motion-reduce:animate-none\" />\n      <div className=\"flex min-w-0 flex-1 flex-col gap-1.5\">\n        <div className=\"h-3 w-3/5 animate-pulse rounded bg-muted motion-reduce:animate-none\" />\n        <div className=\"h-3 w-2/5 animate-pulse rounded bg-muted motion-reduce:animate-none\" />\n      </div>\n    </div>\n  )\n}\n\n/* ------------------------------------------------------------------ derived */\n\ninterface PlanRow {\n  step: AgentPlanStep\n  /** The text as it stands now — the reviewer's edit if there is one, otherwise the agent's. */\n  text: string\n  included: boolean\n  edited: boolean\n  /** 1-based position in the run that would actually happen; `null` while excluded. */\n  order: number | null\n}\n\nconst NO_STEPS: AgentPlanStep[] = []\n\n/* ---------------------------------------------------------------- component */\n\nexport interface AgentPlanProps extends React.HTMLAttributes<HTMLElement> {\n  /** The proposal to review. `null` is allowed for the loading / empty / error envelopes. */\n  plan: AgentPlanProposal | null\n  /** Envelope state — whether there is a proposal at all. Not the reviewer's decision. */\n  status: AgentPlanStatus\n  /**\n   * Freeze the draft: no toggling, no editing, no decision. For a transcript of a\n   * plan that was already answered, or for a viewer without approval rights.\n   */\n  readOnly?: boolean\n  /** Fired on every inclusion change with the step's NEW inclusion. Purely a notification — the card owns the draft. */\n  onToggleStep?: (stepId: string, included: boolean) => void\n  /** Fired when an inline edit is saved or reverted, with the text as it now stands. */\n  onEditStep?: (stepId: string, text: string) => void\n  /** Fired at most once per proposal id, with the included steps in run order, edits applied. */\n  onApprove?: (planId: string, steps: AgentPlanStep[]) => void\n  /**\n   * Fired at most once per proposal id. Gets the note (undefined when blank) and\n   * ALL steps with the reviewer's draft applied — what they dropped is half the\n   * feedback.\n   */\n  onRequestChanges?: (planId: string, note: string | undefined, steps: AgentPlanStep[]) => void\n  /** Renders \"Try again\" in the `status=\"error\"` branch; omit it to hide the affordance. */\n  onRetry?: () => void\n  /** Show each step's rationale line. Turn it off for a dense re-read of a plan you already understand. */\n  showRationale?: boolean\n  /** How many steps must stay in for Approve to be live. Clamped to >= 0. Default 1 — approving nothing is not approving. */\n  minIncludedSteps?: number\n  /** Override the duration wording (a working-day estimate, a token budget…). */\n  formatDuration?: (ms: number) => string\n  /** Override the money wording (another currency, credits, \"≈ 3k tokens\"). */\n  formatCost?: (usd: number) => string\n  /** Replaces the default Approve wording; the step count is appended by default. */\n  approveLabel?: string\n  /** Replaces the default `status=\"empty\"` body. */\n  emptyState?: React.ReactNode\n  /** Message shown in the `status=\"error\"` branch. */\n  errorMessage?: string\n  /** Accessible name prefix for the card region. */\n  label?: string\n}\n\n/**\n * A plan the agent wants to run, presented while it is still a draft: ordered\n * steps with the reason each one exists, risk chips on the ones that can hurt,\n * per-step include/exclude, inline rewriting, a live duration/cost estimate for\n * exactly the steps that are still in — and one Approve / Request changes\n * decision that can only be made once.\n */\nexport const AgentPlan = React.forwardRef<HTMLElement, AgentPlanProps>(\n  (\n    {\n      plan,\n      status,\n      readOnly = false,\n      onToggleStep,\n      onEditStep,\n      onApprove,\n      onRequestChanges,\n      onRetry,\n      showRationale = true,\n      minIncludedSteps = 1,\n      formatDuration = defaultFormatDuration,\n      formatCost = defaultFormatCost,\n      approveLabel,\n      emptyState,\n      errorMessage = \"This plan couldn't be loaded.\",\n      label = \"Proposed plan\",\n      className,\n      ...props\n    },\n    ref,\n  ) => {\n    const uid = React.useId()\n    const planId = plan?.id ?? \"\"\n    const steps = plan?.steps ?? NO_STEPS\n\n    /**\n     * The card owns the DRAFT, the consumer owns the plan. Inclusion and text\n     * live here as sparse overrides on top of the contract, so a step the\n     * reviewer never touched keeps following the data — and the callbacks stay\n     * pure notifications instead of a state machine the consumer has to mirror.\n     */\n    const [overrides, setOverrides] = React.useState<Record<string, boolean>>({})\n    const [edits, setEdits] = React.useState<Record<string, string>>({})\n    const [editing, setEditing] = React.useState<{ id: string; draft: string } | null>(null)\n    const [editBlocked, setEditBlocked] = React.useState(false)\n    const [changesOpen, setChangesOpen] = React.useState(false)\n    const [note, setNote] = React.useState(\"\")\n    const [decision, setDecision] = React.useState<{ kind: AgentPlanDecisionKind; note?: string } | null>(null)\n    const [prevPlanId, setPrevPlanId] = React.useState(planId)\n    /**\n     * Generation of the decision gate. It ticks whenever a NEW proposal arrives,\n     * which is what makes \"already answered\" expire by itself.\n     */\n    const [gate, setGate] = React.useState(0)\n\n    /**\n     * The one-shot lock. It lives in a REF, not in state: several clicks\n     * dispatched inside a single task all read the same stale state, so a\n     * state-only guard would let the extra ones through and start the same run\n     * twice. Written only from event handlers.\n     */\n    const answeredGate = React.useRef(-1)\n    const editorRef = React.useRef<HTMLTextAreaElement | null>(null)\n    const noteRef = React.useRef<HTMLTextAreaElement | null>(null)\n    /**\n     * Which row's Edit button should take focus once it is back on screen.\n     * Handed over in the ref callback rather than an effect: closing an editor\n     * unmounts the control that had focus, and the callback runs during the\n     * commit that puts the button back — no extra render, no focus flicker.\n     */\n    const pendingFocus = React.useRef<string | null>(null)\n\n    // Adjust-state-during-render, no effect: there is never a frame where the\n    // previous proposal's toggles, edits or decision are applied to a new one.\n    if (prevPlanId !== planId) {\n      setPrevPlanId(planId)\n      setOverrides({})\n      setEdits({})\n      setEditing(null)\n      setEditBlocked(false)\n      setChangesOpen(false)\n      setNote(\"\")\n      setDecision(null)\n      setGate(g => g + 1)\n    }\n\n    const rows = React.useMemo<PlanRow[]>(() => {\n      const seen = new Set<string>()\n      const out: PlanRow[] = []\n      let order = 0\n      for (const step of steps) {\n        // Duplicate ids would make one toggle move two rows and one edit rewrite\n        // two steps, because both are keyed by id. First occurrence wins.\n        if (!step.id || seen.has(step.id)) continue\n        seen.add(step.id)\n        const included = step.required === true ? true : (overrides[step.id] ?? step.included)\n        const edit = edits[step.id]\n        if (included) order += 1\n        out.push({\n          step,\n          text: edit ?? step.text,\n          included,\n          edited: edit !== undefined && edit !== step.text,\n          order: included ? order : null,\n        })\n      }\n      return out\n    }, [steps, overrides, edits])\n\n    const effectiveSteps = React.useMemo<AgentPlanStep[]>(\n      () => rows.map(row => ({ ...row.step, text: row.text, included: row.included })),\n      [rows],\n    )\n    const includedSteps = React.useMemo(() => effectiveSteps.filter(step => step.included), [effectiveSteps])\n\n    /**\n     * Totals cover the INCLUDED steps only and are recomputed on every toggle —\n     * an estimate that still counts a step you dropped is a lie that gets\n     * budgeted against. Steps with no estimate at all are counted separately\n     * instead of contributing a silent zero.\n     */\n    const totals = React.useMemo(() => {\n      let ms = 0\n      let usd = 0\n      let hasDuration = false\n      let hasCost = false\n      let unestimated = 0\n      let highRisk = 0\n      for (const row of rows) {\n        if (!row.included) continue\n        if (row.step.risk?.level === \"high\") highRisk += 1\n        const duration = row.step.estimate?.durationMs\n        const cost = row.step.estimate?.costUsd\n        const okDuration = typeof duration === \"number\" && Number.isFinite(duration)\n        const okCost = typeof cost === \"number\" && Number.isFinite(cost)\n        if (okDuration) {\n          ms += Math.max(0, duration)\n          hasDuration = true\n        }\n        if (okCost) {\n          usd += Math.max(0, cost)\n          hasCost = true\n        }\n        if (!okDuration && !okCost) unestimated += 1\n      }\n      return { ms: hasDuration ? ms : null, usd: hasCost ? usd : null, unestimated, highRisk }\n    }, [rows])\n\n    const editingId = editing?.id ?? null\n    React.useEffect(() => {\n      if (editingId === null) return\n      const el = editorRef.current\n      if (!el) return\n      el.focus()\n      // Caret at the end, not a full selection: the reviewer is amending the\n      // agent's wording, and a select-all makes the first keystroke destroy it.\n      const end = el.value.length\n      el.setSelectionRange(end, end)\n    }, [editingId])\n\n    React.useEffect(() => {\n      if (!changesOpen) return\n      noteRef.current?.focus()\n    }, [changesOpen])\n\n    const answered = decision !== null\n    const locked = readOnly || answered\n    const editorOpen = editing !== null\n\n    const closeEditor = (focusId: string) => {\n      setEditing(null)\n      setEditBlocked(false)\n      pendingFocus.current = focusId\n    }\n\n    const openEditor = (row: PlanRow) => {\n      if (locked || !row.step.editable) return\n      if (editing) {\n        if (editing.id === row.step.id) return\n        const current = rows.find(item => item.step.id === editing.id)\n        const dirty = current ? editing.draft !== current.text : editing.draft.trim() !== \"\"\n        // A second editor would have to discard this draft to open, and silently\n        // throwing away someone's rewrite is the one thing this card must not do.\n        if (dirty) {\n          setEditBlocked(true)\n          return\n        }\n      }\n      setEditBlocked(false)\n      setEditing({ id: row.step.id, draft: row.text })\n    }\n\n    const saveEditor = () => {\n      const draft = editing\n      if (!draft) return\n      const next = draft.draft.trim()\n      // An empty step is not an edit, it is a deletion — and deletion already has\n      // a control: the include toggle.\n      if (next === \"\") return\n      const row = rows.find(item => item.step.id === draft.id)\n      const original = row?.step.text\n      setEdits(map => {\n        const copy = { ...map }\n        // Typing the agent's wording back drops the override entirely, so the\n        // \"edited\" marker only ever means \"this differs from what was proposed\".\n        if (next === original) delete copy[draft.id]\n        else copy[draft.id] = next\n        return copy\n      })\n      if (next !== row?.text) onEditStep?.(draft.id, next)\n      closeEditor(draft.id)\n    }\n\n    const revertEdit = (row: PlanRow) => {\n      if (locked) return\n      setEdits(map => {\n        const copy = { ...map }\n        delete copy[row.step.id]\n        return copy\n      })\n      onEditStep?.(row.step.id, row.step.text)\n      pendingFocus.current = row.step.id\n    }\n\n    const toggleStep = (row: PlanRow) => {\n      if (locked || row.step.required === true) return\n      const next = !row.included\n      setOverrides(map => ({ ...map, [row.step.id]: next }))\n      onToggleStep?.(row.step.id, next)\n    }\n\n    const minSteps = Math.max(0, Math.floor(Number.isFinite(minIncludedSteps) ? minIncludedSteps : 1))\n    const approveBlockedReason = editorOpen\n      ? \"Save or cancel the step you're editing before you decide.\"\n      : includedSteps.length < minSteps\n        ? `Keep at least ${minSteps} ${plural(minSteps, \"step\", \"steps\")} in the run to approve this plan.`\n        : null\n\n    const decide = (kind: AgentPlanDecisionKind) => {\n      if (!plan) return\n      if (answeredGate.current === gate) return\n      if (editorOpen) return\n      if (kind === \"approved\" && includedSteps.length < minSteps) return\n      answeredGate.current = gate\n      const trimmed = note.trim()\n      const noteText = trimmed === \"\" ? undefined : trimmed\n      setDecision({ kind, note: kind === \"changes-requested\" ? noteText : undefined })\n      if (kind === \"approved\") onApprove?.(plan.id, includedSteps)\n      else onRequestChanges?.(plan.id, noteText, effectiveSteps)\n    }\n\n    /* ------------------------------------------------------------ envelopes */\n\n    const rootClass = cn(\"w-full min-w-0 rounded-lg border bg-card text-sm\", className)\n\n    if (status === \"loading\") {\n      return (\n        <section aria-busy=\"true\" aria-label={label} className={rootClass} ref={ref} {...props}>\n          <span className=\"sr-only\" role=\"status\">\n            Loading the proposed plan\n          </span>\n          <div aria-hidden=\"true\">\n            <div className=\"flex items-center gap-2 p-3\">\n              <div className=\"size-4 shrink-0 animate-pulse rounded bg-muted motion-reduce:animate-none\" />\n              <div className=\"h-3 w-40 animate-pulse rounded bg-muted motion-reduce:animate-none\" />\n              <div className=\"ml-auto h-5 w-24 shrink-0 animate-pulse rounded-4xl bg-muted motion-reduce:animate-none\" />\n            </div>\n            <SkeletonRow />\n            <SkeletonRow />\n            <SkeletonRow />\n            <div className=\"flex items-center gap-2 border-t p-3\">\n              <div className=\"h-3 w-28 animate-pulse rounded bg-muted motion-reduce:animate-none\" />\n              <div className=\"ml-auto h-8 w-32 shrink-0 animate-pulse rounded-lg bg-muted motion-reduce:animate-none\" />\n            </div>\n          </div>\n        </section>\n      )\n    }\n\n    if (status === \"error\") {\n      return (\n        <section aria-label={label} className={rootClass} ref={ref} {...props}>\n          <div className=\"flex flex-col items-start gap-2 p-3\" role=\"alert\">\n            <p className=\"flex items-center gap-2 font-medium\">\n              <CircleAlert aria-hidden=\"true\" className=\"size-4 shrink-0 text-destructive\" />\n              Couldn&apos;t load this plan\n            </p>\n            <p className=\"min-w-0 whitespace-pre-wrap wrap-anywhere text-muted-foreground\">{errorMessage}</p>\n            {onRetry && (\n              <Button onClick={onRetry} size=\"sm\" type=\"button\" variant=\"outline\">\n                <RefreshCcw aria-hidden=\"true\" />\n                Try again\n              </Button>\n            )}\n          </div>\n        </section>\n      )\n    }\n\n    // A proposal with no steps renders the empty body on purpose: a plan with\n    // nothing in it is not a plan you can approve, and showing its title with an\n    // Approve button under it would invite exactly that.\n    if (status === \"empty\" || !plan || rows.length === 0) {\n      return (\n        <section aria-label={label} className={rootClass} ref={ref} {...props}>\n          {emptyState ?? (\n            <div className=\"flex flex-col items-start gap-1 p-3\">\n              <p className=\"flex items-center gap-2 text-muted-foreground\">\n                <ClipboardList aria-hidden=\"true\" className=\"size-4 shrink-0\" />\n                No plan to review yet.\n              </p>\n              <p className=\"text-xs text-muted-foreground\">\n                The agent will propose its steps here before it runs any of them.\n              </p>\n            </div>\n          )}\n        </section>\n      )\n    }\n\n    /* ---------------------------------------------------------------- ready */\n\n    const total = rows.length\n    const includedCount = includedSteps.length\n    const durationText = totals.ms === null ? null : formatDuration(totals.ms)\n    const costText = totals.usd === null ? null : formatCost(totals.usd)\n    const hintId = `${uid}-hint`\n    const noteId = `${uid}-note`\n    const decisionKind = decision?.kind ?? null\n    const decisionNote = decision?.note\n\n    const stateWord =\n      decisionKind === \"approved\"\n        ? \"Approved\"\n        : decisionKind === \"changes-requested\"\n          ? \"Changes requested\"\n          : readOnly\n            ? \"Read only\"\n            : \"Needs review\"\n\n    const announcement = [\n      `${includedCount} of ${total} ${plural(total, \"step\", \"steps\")} in the run`,\n      durationText ? `about ${durationText}` : null,\n      costText ? `about ${costText}` : null,\n      totals.highRisk > 0 ? `${totals.highRisk} high risk` : null,\n      decisionKind === \"approved\" ? \"plan approved\" : decisionKind === null ? null : \"changes requested\",\n    ]\n      .filter(Boolean)\n      .join(\", \")\n\n    return (\n      <section\n        aria-label={`${label}: ${plan.title}`}\n        className={cn(\n          rootClass,\n          decisionKind === \"approved\" && \"border-primary/50\",\n          decisionKind === \"changes-requested\" && \"border-destructive/40\",\n        )}\n        data-decision={decisionKind ?? undefined}\n        ref={ref}\n        {...props}\n      >\n        {/* One persistent live region, mounted for the whole ready branch, so a\n            CHANGE (toggle, edit, decision) is announced from an element the\n            screen reader is already watching instead of a fresh node. */}\n        <p className=\"sr-only\" role=\"status\">\n          {announcement}\n        </p>\n\n        <header className=\"flex items-start gap-2 p-3\">\n          <ClipboardList aria-hidden=\"true\" className=\"mt-0.5 size-4 shrink-0 text-muted-foreground\" />\n          <div className=\"flex min-w-0 flex-1 flex-col gap-0.5\">\n            <p className=\"min-w-0 wrap-anywhere font-medium\">{plan.title}</p>\n            {plan.goal && <p className=\"min-w-0 wrap-anywhere text-xs text-muted-foreground\">{plan.goal}</p>}\n          </div>\n          <Badge\n            className=\"mt-0.5 shrink-0\"\n            variant={\n              decisionKind === \"approved\"\n                ? \"default\"\n                : decisionKind === \"changes-requested\"\n                  ? \"destructive\"\n                  : readOnly\n                    ? \"ghost\"\n                    : \"outline\"\n            }\n          >\n            {stateWord}\n          </Badge>\n        </header>\n\n        <ol className=\"flex flex-col\">\n          {rows.map(row => {\n            const textId = `${uid}-step-${row.step.id}`\n            // The open editor belongs to exactly one row; hold it as a nullable\n            // local so the branch below reads the draft without re-checking it.\n            const draft = editing !== null && editing.id === row.step.id ? editing : null\n            const required = row.step.required === true\n            const risk = row.step.risk\n            return (\n              <li\n                className={cn(\"flex min-w-0 items-start gap-2.5 border-t p-3\", !row.included && \"bg-muted/30\")}\n                data-included={row.included}\n                data-step=\"\"\n                key={row.step.id}\n              >\n                {/* role=checkbox, not aria-pressed: this is \"is this step part of\n                    the run\", a selection, not a pushed state. A <button> answers\n                    both Space and Enter, which covers the checkbox key contract. */}\n                <button\n                  aria-checked={row.included}\n                  aria-disabled={locked || required || undefined}\n                  aria-labelledby={textId}\n                  className={cn(\n                    // The tick is 16px but the target is 24px: the visual box is a\n                    // child, so the hit area can grow without the row getting taller.\n                    \"-mx-1 -my-0.5 flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n                    (locked || required) && \"cursor-default\",\n                  )}\n                  data-toggle=\"\"\n                  onClick={() => toggleStep(row)}\n                  role=\"checkbox\"\n                  type=\"button\"\n                >\n                  <span\n                    className={cn(\n                      \"flex size-4 items-center justify-center rounded-sm border transition-colors motion-reduce:transition-none\",\n                      row.included ? \"border-primary bg-primary text-primary-foreground\" : \"bg-background\",\n                      (locked || required) && \"opacity-70\",\n                    )}\n                  >\n                    {required ? (\n                      <Lock aria-hidden=\"true\" className=\"size-2.5\" />\n                    ) : row.included ? (\n                      <Check aria-hidden=\"true\" className=\"size-3\" />\n                    ) : null}\n                  </span>\n                </button>\n\n                <div className=\"flex min-w-0 flex-1 flex-col gap-1.5\">\n                  <div className=\"flex min-w-0 flex-wrap items-start gap-x-2 gap-y-1\">\n                    {/* The number is the position in the run that would actually\n                        happen, so excluding step 2 renumbers everything after it\n                        instead of leaving a hole in the order. */}\n                    <span\n                      aria-hidden=\"true\"\n                      className={cn(\n                        \"shrink-0 pt-px font-mono text-xs tabular-nums\",\n                        row.included ? \"text-muted-foreground\" : \"text-muted-foreground/70\",\n                      )}\n                    >\n                      {row.order === null ? \"—\" : `${row.order}.`}\n                    </span>\n                    <span\n                      className={cn(\n                        \"min-w-0 flex-1 wrap-anywhere\",\n                        !row.included && \"text-muted-foreground line-through\",\n                      )}\n                      id={textId}\n                    >\n                      {row.text}\n                    </span>\n                    {risk && <RiskChip level={risk.level} />}\n                    {row.edited && (\n                      <Badge className=\"shrink-0 text-muted-foreground\" variant=\"ghost\">\n                        <Pencil aria-hidden=\"true\" />\n                        edited\n                      </Badge>\n                    )}\n                  </div>\n\n                  {showRationale && row.step.rationale && draft === null && (\n                    <p className=\"min-w-0 wrap-anywhere text-xs text-muted-foreground\">\n                      <span className=\"font-medium\">Why: </span>\n                      {row.step.rationale}\n                    </p>\n                  )}\n\n                  {risk?.note && (\n                    <p\n                      className={cn(\n                        \"min-w-0 wrap-anywhere rounded-md border px-2 py-1 text-xs\",\n                        risk.level === \"high\"\n                          ? \"border-destructive/40 bg-destructive/5 text-destructive\"\n                          : \"bg-muted/40 text-muted-foreground\",\n                      )}\n                    >\n                      {risk.note}\n                    </p>\n                  )}\n\n                  {draft !== null ? (\n                    // The shortcuts are advertised next to Save/Cancel, so they are\n                    // bound to the whole editor and not just the field: a reviewer\n                    // left on the Save button by a refused empty draft must still be\n                    // able to press Esc to get out.\n                    <div\n                      className=\"flex min-w-0 flex-col gap-2\"\n                      onKeyDown={event => {\n                        if (event.key === \"Escape\") {\n                          event.preventDefault()\n                          closeEditor(row.step.id)\n                          return\n                        }\n                        if (event.key === \"Enter\" && (event.metaKey || event.ctrlKey)) {\n                          event.preventDefault()\n                          saveEditor()\n                        }\n                      }}\n                    >\n                      <textarea\n                        className=\"min-h-16 w-full min-w-0 resize-y rounded-md border bg-background p-2 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n                        data-editor=\"\"\n                        onChange={event => setEditing({ id: row.step.id, draft: event.target.value })}\n                        ref={editorRef}\n                        value={draft.draft}\n                      />\n                      <div className=\"flex flex-wrap items-center gap-2\">\n                        <Button\n                          aria-disabled={draft.draft.trim() === \"\" || undefined}\n                          className={cn(draft.draft.trim() === \"\" && \"opacity-60\")}\n                          data-editor-save=\"\"\n                          onClick={saveEditor}\n                          size=\"xs\"\n                          type=\"button\"\n                        >\n                          <Check aria-hidden=\"true\" />\n                          Save step\n                        </Button>\n                        <Button onClick={() => closeEditor(row.step.id)} size=\"xs\" type=\"button\" variant=\"outline\">\n                          <X aria-hidden=\"true\" />\n                          Cancel\n                        </Button>\n                        <span className=\"text-xs text-muted-foreground\">\n                          {draft.draft.trim() === \"\"\n                            ? \"A step can't be empty — exclude it instead.\"\n                            : \"⌘/Ctrl + Enter saves · Esc cancels\"}\n                        </span>\n                      </div>\n                      {editBlocked && (\n                        <p className=\"text-xs text-destructive\" data-edit-blocked=\"\">\n                          Save or cancel this step before editing another one.\n                        </p>\n                      )}\n                    </div>\n                  ) : (\n                    (row.step.estimate || (row.step.editable && !locked) || row.edited) && (\n                      <div className=\"flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground\">\n                        {row.step.estimate?.durationMs !== undefined && (\n                          <span className=\"inline-flex items-center gap-1 tabular-nums\">\n                            <Clock3 aria-hidden=\"true\" className=\"size-3\" />\n                            {formatDuration(row.step.estimate.durationMs)}\n                          </span>\n                        )}\n                        {row.step.estimate?.costUsd !== undefined && (\n                          <span className=\"inline-flex items-center gap-1 tabular-nums\">\n                            <Coins aria-hidden=\"true\" className=\"size-3\" />\n                            {formatCost(row.step.estimate.costUsd)}\n                          </span>\n                        )}\n                        {row.step.editable && !locked && (\n                          <Button\n                            data-edit=\"\"\n                            onClick={() => openEditor(row)}\n                            ref={node => {\n                              if (node && pendingFocus.current === row.step.id) {\n                                pendingFocus.current = null\n                                node.focus()\n                              }\n                            }}\n                            size=\"xs\"\n                            type=\"button\"\n                            variant=\"ghost\"\n                          >\n                            <Pencil aria-hidden=\"true\" />\n                            Edit\n                          </Button>\n                        )}\n                        {row.edited && !locked && (\n                          <Button onClick={() => revertEdit(row)} size=\"xs\" type=\"button\" variant=\"ghost\">\n                            <Undo2 aria-hidden=\"true\" />\n                            Revert\n                          </Button>\n                        )}\n                      </div>\n                    )\n                  )}\n                </div>\n              </li>\n            )\n          })}\n        </ol>\n\n        <footer className=\"flex flex-col gap-3 border-t p-3\">\n          <div\n            className=\"flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground\"\n            data-estimate=\"\"\n          >\n            <span className=\"inline-flex items-center gap-1.5 tabular-nums\">\n              <ListChecks aria-hidden=\"true\" className=\"size-3.5\" />\n              {includedCount} of {total} {plural(total, \"step\", \"steps\")}\n            </span>\n            {durationText && (\n              <span className=\"inline-flex items-center gap-1.5 tabular-nums\">\n                <Clock3 aria-hidden=\"true\" className=\"size-3.5\" />≈ {durationText}\n              </span>\n            )}\n            {costText && (\n              <span className=\"inline-flex items-center gap-1.5 tabular-nums\">\n                <Coins aria-hidden=\"true\" className=\"size-3.5\" />≈ {costText}\n              </span>\n            )}\n            {/* Never a silent understatement: the steps that carry no estimate are\n                named, so the total is read as \"at least this much\". */}\n            {totals.unestimated > 0 && (\n              <span data-estimate-gap=\"\">\n                {totals.unestimated} {plural(totals.unestimated, \"step has\", \"steps have\")} no estimate\n              </span>\n            )}\n          </div>\n\n          {totals.highRisk > 0 && (\n            <p className=\"flex items-start gap-2 text-xs text-destructive\">\n              <ShieldAlert aria-hidden=\"true\" className=\"mt-px size-3.5 shrink-0\" />\n              <span className=\"min-w-0 wrap-anywhere\">\n                {totals.highRisk} high-risk {plural(totals.highRisk, \"step is\", \"steps are\")} still in the run.\n                Excluding {plural(totals.highRisk, \"it\", \"them\")} here is cheaper than undoing{\" \"}\n                {plural(totals.highRisk, \"it\", \"them\")} later.\n              </span>\n            </p>\n          )}\n\n          {decisionKind !== null ? (\n            decisionKind === \"approved\" ? (\n              <p className=\"flex items-start gap-2 text-xs\" data-decision-note=\"\">\n                <CircleCheck aria-hidden=\"true\" className=\"mt-px size-4 shrink-0 text-primary\" />\n                <span className=\"min-w-0 wrap-anywhere\">\n                  <span className=\"font-medium\">Plan approved. </span>\n                  <span className=\"text-muted-foreground\">\n                    {includedCount} {plural(includedCount, \"step\", \"steps\")} went to the agent\n                    {durationText ? `, about ${durationText}` : \"\"}. The draft is locked — a plan you can still\n                    edit after approving it is not the plan that runs.\n                  </span>\n                </span>\n              </p>\n            ) : (\n              <div className=\"flex flex-col gap-1.5\" data-decision-note=\"\">\n                <p className=\"flex items-start gap-2 text-xs\">\n                  <MessageSquare aria-hidden=\"true\" className=\"mt-px size-4 shrink-0 text-destructive\" />\n                  <span className=\"min-w-0 wrap-anywhere\">\n                    <span className=\"font-medium\">Changes requested. </span>\n                    <span className=\"text-muted-foreground\">\n                      Sent back with {includedCount} of {total} {plural(total, \"step\", \"steps\")} still selected.\n                    </span>\n                  </span>\n                </p>\n                <p className=\"min-w-0 wrap-anywhere border-l-2 pl-2 text-xs text-muted-foreground\">\n                  {decisionNote ?? \"No note — the agent only learns which steps you dropped.\"}\n                </p>\n              </div>\n            )\n          ) : readOnly ? (\n            <p className=\"text-xs text-muted-foreground\">This plan is read-only for you.</p>\n          ) : onApprove || onRequestChanges ? (\n            <div className=\"flex flex-col gap-2\">\n              {changesOpen ? (\n                <div className=\"flex flex-col gap-2\">\n                  <label className=\"text-xs text-muted-foreground\" htmlFor={noteId}>\n                    What should change? (optional — sent back to the agent with your selection)\n                  </label>\n                  <textarea\n                    className=\"min-h-16 w-full min-w-0 resize-y rounded-md border bg-background p-2 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n                    data-note=\"\"\n                    id={noteId}\n                    onChange={event => setNote(event.target.value)}\n                    placeholder=\"e.g. don't touch production data — stage it first\"\n                    ref={noteRef}\n                    value={note}\n                  />\n                  <div className=\"flex flex-wrap items-center gap-2\">\n                    <Button\n                      aria-describedby={editorOpen ? hintId : undefined}\n                      aria-disabled={editorOpen || undefined}\n                      className={cn(editorOpen && \"opacity-60\")}\n                      data-send-changes=\"\"\n                      onClick={() => decide(\"changes-requested\")}\n                      size=\"sm\"\n                      type=\"button\"\n                      variant=\"destructive\"\n                    >\n                      <Send aria-hidden=\"true\" />\n                      Send request\n                    </Button>\n                    <Button onClick={() => setChangesOpen(false)} size=\"sm\" type=\"button\" variant=\"outline\">\n                      Back\n                    </Button>\n                  </div>\n                </div>\n              ) : (\n                <div className=\"flex flex-wrap items-center gap-2\">\n                  {onApprove && (\n                    // aria-disabled, never the native attribute: `disabled` drops\n                    // focus to <body> the instant it flips and takes the button\n                    // out of the tab order, so the reason is never announced.\n                    <Button\n                      aria-describedby={approveBlockedReason ? hintId : undefined}\n                      aria-disabled={approveBlockedReason !== null || undefined}\n                      className={cn(approveBlockedReason !== null && \"opacity-60\")}\n                      data-approve=\"\"\n                      onClick={() => decide(\"approved\")}\n                      size=\"sm\"\n                      type=\"button\"\n                    >\n                      <Check aria-hidden=\"true\" />\n                      {approveLabel ??\n                        `Approve ${includedCount} ${plural(includedCount, \"step\", \"steps\")}`}\n                    </Button>\n                  )}\n                  {onRequestChanges && (\n                    <Button\n                      aria-describedby={editorOpen ? hintId : undefined}\n                      aria-disabled={editorOpen || undefined}\n                      className={cn(editorOpen && \"opacity-60\")}\n                      data-request-changes=\"\"\n                      onClick={() => {\n                        if (editorOpen) return\n                        setChangesOpen(true)\n                      }}\n                      size=\"sm\"\n                      type=\"button\"\n                      variant=\"outline\"\n                    >\n                      <MessageSquare aria-hidden=\"true\" />\n                      Request changes\n                    </Button>\n                  )}\n                </div>\n              )}\n              {/* The \"keep at least N steps\" reason belongs to Approve only, so it\n                  is not repeated inside the request-changes pane — you are allowed\n                  to send an empty selection back. */}\n              {(editorOpen || !changesOpen) && approveBlockedReason && (\n                <p className=\"text-xs text-muted-foreground\" id={hintId}>\n                  {approveBlockedReason}\n                </p>\n              )}\n            </div>\n          ) : (\n            // No handler, no buttons: an Approve button that approves nothing is\n            // worse than no button at all.\n            <p className=\"text-xs text-muted-foreground\">Waiting for a decision elsewhere in your app.</p>\n          )}\n        </footer>\n      </section>\n    )\n  },\n)\n\nAgentPlan.displayName = \"AgentPlan\"\n\nexport default AgentPlan\n",
      "type": "registry:ui"
    },
    {
      "path": "src/registry/ui/agent-plan.contract.ts",
      "content": "import { z } from \"zod\"\n\n/**\n * A plan an agent proposes BEFORE it runs anything.\n *\n * The whole contract is written for the moment where a human is still allowed to\n * say no: every step carries the reason it exists (`rationale`), how badly it can\n * hurt (`risk`), whether it is currently part of the run (`included`) and whether\n * its wording may be rewritten (`editable`). Nothing here describes progress —\n * there is no \"running\", no \"done\", no percentage. A plan that has started\n * executing is a different object, and a different component.\n */\n\n/**\n * How much damage a step can do if it is wrong.\n *\n * Three levels, not a boolean: \"this reads a file\" / \"this posts to a staging\n * webhook\" / \"this drops a production table\" are genuinely three different\n * decisions, and a reviewer shown one warning colour for all of them stops\n * reading warnings.\n */\nexport const AGENT_PLAN_RISK_LEVELS = [\"low\", \"medium\", \"high\"] as const\nexport const agentPlanRiskLevelSchema = z.enum(AGENT_PLAN_RISK_LEVELS)\nexport type AgentPlanRiskLevel = z.infer<typeof agentPlanRiskLevelSchema>\n\nexport const agentPlanRiskSchema = z.object({\n  level: agentPlanRiskLevelSchema,\n  /**\n   * The blast radius in the reviewer's words — \"deletes 3 files the billing job\n   * still imports\". A level on its own is a colour; the note is the thing people\n   * actually decide on.\n   */\n  note: z.string().optional(),\n})\nexport type AgentPlanRisk = z.infer<typeof agentPlanRiskSchema>\n\n/**\n * What one step is expected to cost. Both fields are optional and independent —\n * an agent usually knows one and guesses the other. A missing value is never\n * treated as zero: the footer counts the steps that carry no estimate and says\n * so, because an understated total is worse than no total.\n */\nexport const agentPlanEstimateSchema = z.object({\n  /** Expected wall-clock duration in milliseconds. */\n  durationMs: z.number().nonnegative().optional(),\n  /** Expected spend in USD (token cost, API cost — your definition). */\n  costUsd: z.number().nonnegative().optional(),\n})\nexport type AgentPlanEstimate = z.infer<typeof agentPlanEstimateSchema>\n\nexport const agentPlanStepSchema = z.object({\n  /**\n   * Stable identity. It keys the React list, the inclusion override and the text\n   * edit, so duplicates are dropped (first occurrence wins) instead of letting\n   * one toggle move two rows at once.\n   */\n  id: z.string().min(1),\n  /** The instruction itself, as the agent would execute it. Rendered in full, never clamped. */\n  text: z.string(),\n  /**\n   * Why the agent put this step in the plan. This is the difference between a\n   * plan you can review and a list you can only trust: without it, excluding a\n   * step is a guess.\n   */\n  rationale: z.string().optional(),\n  risk: agentPlanRiskSchema.optional(),\n  /** Whether the step starts in the run. Reviewer toggles override this locally until the plan id changes. */\n  included: z.boolean(),\n  /** Whether the reviewer may rewrite `text` in place. `false` renders no edit affordance at all. */\n  editable: z.boolean(),\n  /**\n   * A step the rest of the plan depends on. Its toggle renders locked and it is\n   * always counted as included — `included: false` on a required step is a\n   * contradiction, and the component resolves it in favour of \"required\".\n   */\n  required: z.boolean().optional(),\n  estimate: agentPlanEstimateSchema.optional(),\n})\nexport type AgentPlanStep = z.infer<typeof agentPlanStepSchema>\n\nexport const agentPlanProposalSchema = z.object({\n  /**\n   * Identity of this *proposal*. Changing it is how you hand over a revised plan:\n   * the card throws away every local toggle, edit and decision and re-arms the\n   * approve gate. Reusing the id while changing the steps leaves the reviewer's\n   * draft sitting on top of a plan they never saw.\n   */\n  id: z.string().min(1),\n  title: z.string(),\n  /** The request this plan answers, in the user's own words. Shown under the title. */\n  goal: z.string().optional(),\n  /** Ordered. The numbering the reviewer sees is derived from this order, never stored. */\n  steps: z.array(agentPlanStepSchema),\n})\nexport type AgentPlanProposal = z.infer<typeof agentPlanProposalSchema>\n\n/**\n * The card's own render state — \"is there a proposal to review at all\". It is\n * independent of what the reviewer then decides: `error` here means the proposal\n * failed to load, not that the plan is bad.\n */\nexport const agentPlanStatusSchema = z.enum([\"loading\", \"empty\", \"error\", \"ready\"])\nexport type AgentPlanStatus = z.infer<typeof agentPlanStatusSchema>\n\n/** The envelope a data layer / mock factory hands over; the demo spreads it into the props. */\nexport const agentPlanSchema = z.object({\n  status: agentPlanStatusSchema,\n  plan: agentPlanProposalSchema.nullable(),\n})\nexport type AgentPlanData = z.infer<typeof agentPlanSchema>\n\n/** The two ways a review ends. Both are terminal for one proposal id. */\nexport const agentPlanDecisionKindSchema = z.enum([\"approved\", \"changes-requested\"])\nexport type AgentPlanDecisionKind = z.infer<typeof agentPlanDecisionKindSchema>\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}