{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"agent-plan","type":"registry:ui","title":"AgentPlan","description":"Glanceable checklist of an agent's plan: a done/total count and completion bar over one row per step, with a status glyph, an optional detail line, and substeps indented under a rail","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["ai-agents"],"registryDependencies":["https://fancy-ui.rama.app/r/internals.json","https://fancy-ui.rama.app/r/internals-dom.json","https://fancy-ui.rama.app/r/sound.json","https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/components/agent-plan/AgentPlan.tsx","content":"\"use client\";\n\nimport { forwardRef } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { cn } from \"../../utils\";\nimport { useFancyId } from \"../../internals/use-id\";\nimport { useSoundCue } from \"../../sound/use-sound\";\nimport type { PlanStepData, RunStatus } from \"../../internals/ai-types\";\nimport \"./agent-plan.css\";\n\n/** Spoken alongside each step, since the glyph says nothing out loud. */\nconst STATUS_LABELS: Record<RunStatus, string> = {\n\tpending: \"Pending\",\n\trunning: \"Running\",\n\tdone: \"Completed\",\n\terror: \"Failed\",\n\tcancelled: \"Cancelled\",\n};\n\n/** One rendered line: the step itself plus where it sits in the flattened list. */\ninterface PlanRow {\n\tstep: PlanStepData;\n\t/**\n\t * What the row list is keyed by. The id leads, so a row keeps its DOM node\n\t * across a re-render, but an occurrence count is appended rather than the\n\t * row's flattened position: a model that emits the same id twice would\n\t * otherwise collide, and counting by occurrence rather than position means\n\t * inserting or removing a step elsewhere in the plan does not change the\n\t * key — and so does not remount — a row whose own id never changed.\n\t */\n\tkey: string;\n\t/** 0 for a top-level step, 1 for anything nested under one. */\n\tdepth: number;\n\t/** Position in visual order, substeps included — what `item` receives. */\n\tindex: number;\n\t/** Last nested row of its group, so the rail can stop rather than run on. */\n\tlastSub: boolean;\n}\n\nfunction buildRows(steps: PlanStepData[]): PlanRow[] {\n\tconst out: PlanRow[] = [];\n\tconst seen = new Map<string, number>();\n\t// Every id in the plan, substeps included: a suffixed key has to clear them\n\t// as well as the keys already handed out, since a step may genuinely be\n\t// called \"x#1\" while two others are called \"x\".\n\tconst ids = new Set<string>();\n\tconst collectIds = (list: PlanStepData[]) => {\n\t\tfor (const step of list) {\n\t\t\tids.add(step.id);\n\t\t\tif (step.substeps) collectIds(step.substeps);\n\t\t}\n\t};\n\tcollectIds(steps);\n\tconst used = new Set<string>();\n\n\tconst push = (step: PlanStepData, depth: number) => {\n\t\tconst occurrence = seen.get(step.id) ?? 0;\n\t\tseen.set(step.id, occurrence + 1);\n\t\tlet key = `${step.id}#${occurrence}`;\n\t\tlet suffix = occurrence;\n\t\twhile (ids.has(key) || used.has(key)) {\n\t\t\tsuffix++;\n\t\t\tkey = `${step.id}#${suffix}`;\n\t\t}\n\t\tused.add(key);\n\t\tout.push({ step, key, depth, index: out.length, lastSub: false });\n\t};\n\n\tfor (const step of steps) {\n\t\tpush(step, 0);\n\n\t\t// One level of indent is all the eye can follow at a glance, so anything\n\t\t// deeper is pulled up beside its parent's own children instead of\n\t\t// marching further right — depth-first, so a grandchild still reads\n\t\t// directly under the child it belongs to.\n\t\tconst queue = [...(step.substeps ?? [])];\n\t\twhile (queue.length > 0) {\n\t\t\tconst sub = queue.shift() as PlanStepData;\n\t\t\tpush(sub, 1);\n\t\t\tif (sub.substeps && sub.substeps.length > 0) queue.unshift(...sub.substeps);\n\t\t}\n\n\t\tconst last = out[out.length - 1]!;\n\t\tif (last.depth === 1) last.lastSub = true;\n\t}\n\n\treturn out;\n}\n\n/**\n * Shape carries the status as well as hue — hollow ring, filled dot, tick,\n * cross, dash — so the four states stay apart for anyone who cannot tell the\n * colours apart. The words are in the `sr-only` label beside the step.\n */\nfunction Glyph({ status }: { status: RunStatus }) {\n\treturn (\n\t\t<span\n\t\t\tclassName={cn(\n\t\t\t\t\"ft-agentplan-glyph\",\n\t\t\t\tstatus === \"pending\" && \"ft-status-pending\",\n\t\t\t\tstatus === \"running\" && \"ft-status-running\",\n\t\t\t\tstatus === \"done\" && \"ft-status-done\",\n\t\t\t\tstatus === \"error\" && \"ft-status-error\",\n\t\t\t\tstatus === \"cancelled\" && \"ft-status-cancelled\"\n\t\t\t)}\n\t\t\taria-hidden=\"true\"\n\t\t>\n\t\t\t{status === \"done\" ? (\n\t\t\t\t<svg\n\t\t\t\t\tclassName=\"ft-agentplan-icon\"\n\t\t\t\t\tviewBox=\"0 0 24 24\"\n\t\t\t\t\tfill=\"none\"\n\t\t\t\t\tstroke=\"currentColor\"\n\t\t\t\t\tstrokeWidth=\"3\"\n\t\t\t\t\tstrokeLinecap=\"round\"\n\t\t\t\t\tstrokeLinejoin=\"round\"\n\t\t\t\t>\n\t\t\t\t\t<path d=\"m5 13 4 4L19 7\" />\n\t\t\t\t</svg>\n\t\t\t) : status === \"error\" ? (\n\t\t\t\t<svg\n\t\t\t\t\tclassName=\"ft-agentplan-icon\"\n\t\t\t\t\tviewBox=\"0 0 24 24\"\n\t\t\t\t\tfill=\"none\"\n\t\t\t\t\tstroke=\"currentColor\"\n\t\t\t\t\tstrokeWidth=\"3\"\n\t\t\t\t\tstrokeLinecap=\"round\"\n\t\t\t\t\tstrokeLinejoin=\"round\"\n\t\t\t\t>\n\t\t\t\t\t<path d=\"M6 6l12 12M18 6 6 18\" />\n\t\t\t\t</svg>\n\t\t\t) : status === \"cancelled\" ? (\n\t\t\t\t<span className=\"ft-agentplan-dash\"></span>\n\t\t\t) : status === \"running\" ? (\n\t\t\t\t<span className=\"ft-agentplan-dot\"></span>\n\t\t\t) : (\n\t\t\t\t<span className=\"ft-agentplan-ring\"></span>\n\t\t\t)}\n\t\t</span>\n\t);\n}\n\nfunction RowBody({\n\trow,\n\titem,\n}: {\n\trow: PlanRow;\n\titem?: (step: PlanStepData, index: number) => ReactNode;\n}) {\n\treturn (\n\t\t<>\n\t\t\t<Glyph status={row.step.status} />\n\n\t\t\t{item ? (\n\t\t\t\t<>\n\t\t\t\t\t<span className=\"sr-only\">{STATUS_LABELS[row.step.status]}</span>\n\t\t\t\t\t{item(row.step, row.index)}\n\t\t\t\t</>\n\t\t\t) : (\n\t\t\t\t<span className=\"ft-agentplan-text min-w-0 flex-1\">\n\t\t\t\t\t{/*\n\t\t\t\t\t\tMuted, never struck through: a finished step is still worth reading,\n\t\t\t\t\t\tand a checklist of crossed-out lines is harder to scan, not easier.\n\t\t\t\t\t*/}\n\t\t\t\t\t<span\n\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\"ft-agentplan-step-label block leading-snug\",\n\t\t\t\t\t\t\t(row.step.status === \"done\" || row.step.status === \"cancelled\") &&\n\t\t\t\t\t\t\t\t\"text-muted-foreground\",\n\t\t\t\t\t\t\trow.step.status === \"running\" && \"font-medium\"\n\t\t\t\t\t\t)}\n\t\t\t\t\t>\n\t\t\t\t\t\t{row.step.label}\n\t\t\t\t\t</span>\n\t\t\t\t\t<span className=\"sr-only\">{STATUS_LABELS[row.step.status]}</span>\n\t\t\t\t\t{row.step.detail ? (\n\t\t\t\t\t\t<span\n\t\t\t\t\t\t\tclassName=\"ft-agentplan-detail text-muted-foreground mt-0.5 block truncate text-xs\"\n\t\t\t\t\t\t\ttitle={row.step.detail}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{row.step.detail}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t) : null}\n\t\t\t\t</span>\n\t\t\t)}\n\t\t</>\n\t);\n}\n\n/**\n * Props for AgentPlan\n */\nexport interface AgentPlanProps {\n\t/** The plan, in the order the agent means to work through it. Nests via `substeps`. */\n\tsteps: PlanStepData[];\n\t/** Header text, sitting beside the done/total count. */\n\tlabel?: string;\n\t/** Whether the thin completion bar shows under the header. */\n\tshowProgress?: boolean;\n\t/** Called when a row is activated; supplying it turns every row into a button. */\n\tonSelect?: (step: PlanStepData) => void;\n\t/** Replaces the built-in row body, keeping the glyph and the indent. */\n\titem?: (step: PlanStepData, index: number) => ReactNode;\n\t/** Additional CSS classes */\n\tclassName?: string;\n\t/**\n\t * Plays the matching interface cue through the sound controller. Off by\n\t * default; only audible once the user has enabled sound.\n\t */\n\tsound?: boolean;\n}\n\nexport const AgentPlan = forwardRef<HTMLDivElement, AgentPlanProps>(function AgentPlan(\n\t{ steps, label = \"Plan\", showProgress = true, onSelect, item, className, sound },\n\tref\n) {\n\tconst uid = useFancyId();\n\tconst labelId = `${uid}-label`;\n\tconst playCue = useSoundCue(sound);\n\n\t/** A row activation is a fresh gesture every time — there is no selected\n\t *  value on the component to compare against, so the cue plays on every\n\t *  pick, repeats included. */\n\tconst selectStep = (step: PlanStepData) => {\n\t\tplayCue(\"select\");\n\t\tonSelect?.(step);\n\t};\n\n\tconst rows = buildRows(steps);\n\n\t// Substeps are steps: a plan that finished four of its five checks and both of\n\t// the two hiding under one of them is 6/7, not 4/5.\n\tconst total = rows.length;\n\tconst doneCount = rows.filter((row) => row.step.status === \"done\").length;\n\tconst percent = total === 0 ? 0 : Math.round((doneCount / total) * 1000) / 10;\n\n\t// Only the first one: a plan with two things in flight still has one place the\n\t// reader's eye — and a screen reader's \"current step\" — should land.\n\tconst currentIndex = rows.findIndex((row) => row.step.status === \"running\");\n\n\treturn (\n\t\t<div ref={ref} className={cn(\"ft-agentplan w-full text-sm\", className)}>\n\t\t\t<div className=\"ft-agentplan-header\">\n\t\t\t\t<div className=\"flex items-baseline gap-2\">\n\t\t\t\t\t<span id={labelId} className=\"ft-agentplan-label text-foreground truncate font-medium\">\n\t\t\t\t\t\t{label}\n\t\t\t\t\t</span>\n\t\t\t\t\t<span className=\"ft-agentplan-count text-muted-foreground ml-auto shrink-0 text-xs tabular-nums\">\n\t\t\t\t\t\t{doneCount}/{total}\n\t\t\t\t\t</span>\n\t\t\t\t</div>\n\n\t\t\t\t{showProgress ? (\n\t\t\t\t\t// The count above already says it in words, so the bar is decoration.\n\t\t\t\t\t<div className=\"ft-agentplan-track\" aria-hidden=\"true\">\n\t\t\t\t\t\t<div className=\"ft-agentplan-bar\" style={{ width: `${percent}%` }}></div>\n\t\t\t\t\t</div>\n\t\t\t\t) : null}\n\t\t\t</div>\n\n\t\t\t{/*\n\t\t\t\tFlat list with an indent class rather than nested lists: `listitem` needs\n\t\t\t\t`list` as its parent, and a wrapper per group would put a plain div between\n\t\t\t\tthem and break the semantics for the sake of a left margin CSS can do.\n\t\t\t*/}\n\t\t\t<div className=\"ft-agentplan-list\" role=\"list\" aria-labelledby={labelId}>\n\t\t\t\t{rows.map((row) => (\n\t\t\t\t\t<div\n\t\t\t\t\t\tkey={row.key}\n\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\"ft-agentplan-row\",\n\t\t\t\t\t\t\trow.depth === 1 && \"ft-agentplan-sub\",\n\t\t\t\t\t\t\trow.lastSub && \"ft-agentplan-sub-last\"\n\t\t\t\t\t\t)}\n\t\t\t\t\t\trole=\"listitem\"\n\t\t\t\t\t\tdata-status={row.step.status}\n\t\t\t\t\t\taria-current={row.index === currentIndex ? \"step\" : undefined}\n\t\t\t\t\t>\n\t\t\t\t\t\t{onSelect ? (\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\tclassName=\"ft-agentplan-body hover:bg-muted/60 focus-visible:ring-ring flex w-full cursor-pointer items-start gap-2 rounded-md text-left transition-colors focus-visible:ring-1 focus-visible:outline-none\"\n\t\t\t\t\t\t\t\tonClick={() => selectStep(row.step)}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<RowBody row={row} item={item} />\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<div className=\"ft-agentplan-body flex items-start gap-2 rounded-md\">\n\t\t\t\t\t\t\t\t<RowBody row={row} item={item} />\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t))}\n\t\t\t</div>\n\t\t</div>\n\t);\n});\n","type":"registry:ui","target":"@ui/fancy/components/agent-plan/AgentPlan.tsx"},{"path":"react/src/components/agent-plan/agent-plan.css","content":".ft-agentplan {\n\t/* Rail and track are mixes of currentColor, so one value reads correctly on\n\t   either theme. The status colours cannot do that — they are ink, and no\n\t   single token clears 4.5:1 on both white and near-black — so they are read\n\t   at their point of use off the `--ft-status-*` vocabulary this family\n\t   shares, whose defaults are `light-dark()` pairs. A glyph is a graphical\n\t   object and owes 3:1, which is why the light half of the grey pair is 0.5\n\t   rather than 0.55. */\n\t--ft-agentplan-rail: color-mix(in oklab, currentColor 16%, transparent);\n\t--ft-agentplan-track-bg: color-mix(in oklab, currentColor 12%, transparent);\n\n\t/* Geometry shared by the glyph, the indent, and the substep rail, so moving\n\t   one moves all three together. */\n\t--ft-agentplan-glyph-size: 0.875rem;\n\t--ft-agentplan-row-pad: 0.25rem;\n\t--ft-agentplan-indent: 1.5rem;\n\t/* Centre of a top-level glyph: body padding plus half the glyph. */\n\t--ft-agentplan-rail-x: 0.9375rem;\n\t/* Where that glyph's centre sits vertically, so the rail can stop on it. */\n\t--ft-agentplan-rail-stop: 0.875rem;\n}\n\n.ft-agentplan-header {\n\tpadding: 0 0.5rem 0.375rem;\n}\n\n.ft-agentplan-track {\n\tmargin-top: 0.375rem;\n\theight: 0.1875rem;\n\tborder-radius: 9999px;\n\tbackground: var(--ft-agentplan-track-bg);\n\toverflow: hidden;\n}\n\n.ft-agentplan-bar {\n\theight: 100%;\n\tborder-radius: inherit;\n\tbackground: var(\n\t\t--ft-agentplan-bar,\n\t\tvar(--ft-status-done, light-dark(oklch(0.5 0.14 145), oklch(0.72 0.15 145)))\n\t);\n}\n\n.ft-agentplan-row {\n\tposition: relative;\n}\n\n.ft-agentplan-sub {\n\tpadding-left: var(--ft-agentplan-indent);\n}\n\n/* Drawn per row rather than once behind the group, so it stretches with the\n   row it belongs to and needs no height measurement. */\n.ft-agentplan-sub::before {\n\tcontent: \"\";\n\tposition: absolute;\n\ttop: 0;\n\tbottom: 0;\n\tleft: calc(var(--ft-agentplan-rail-x) - 1px);\n\twidth: 2px;\n\tbackground: var(--ft-agentplan-rail);\n}\n\n/* Stop on the last child's glyph instead of running on into the next\n   top-level step, which belongs to nobody. */\n.ft-agentplan-sub-last::before {\n\tbottom: auto;\n\theight: calc(var(--ft-agentplan-rail-stop) + var(--ft-agentplan-row-pad));\n}\n\n.ft-agentplan-body {\n\twidth: 100%;\n\tpadding: var(--ft-agentplan-row-pad) 0.5rem;\n}\n\n.ft-agentplan-glyph {\n\tposition: relative;\n\tdisplay: inline-flex;\n\tflex: none;\n\talign-items: center;\n\tjustify-content: center;\n\twidth: var(--ft-agentplan-glyph-size);\n\theight: var(--ft-agentplan-glyph-size);\n\t/* Centres the glyph on the first line of the label rather than its box. */\n\tmargin-top: calc((1.25rem - var(--ft-agentplan-glyph-size)) / 2);\n}\n\n/*\n * Each state sets `color` once and every shape inside reads `currentColor`, so\n * a consumer retints a status by naming one variable. The component's own\n * `--ft-agentplan-*` sits in front of the shared `--ft-status-*`, which sits in\n * front of the literal: setting either anywhere up the tree wins without having\n * to out-specify these scoped rules.\n */\n.ft-agentplan-glyph.ft-status-pending {\n\tcolor: var(\n\t\t--ft-agentplan-pending,\n\t\tcolor-mix(\n\t\t\tin oklab,\n\t\t\tvar(--ft-status-pending, light-dark(oklch(0.5 0.02 260), oklch(0.72 0.02 260))) 70%,\n\t\t\ttransparent\n\t\t)\n\t);\n}\n\n.ft-agentplan-glyph.ft-status-cancelled {\n\tcolor: var(\n\t\t--ft-agentplan-cancelled,\n\t\tcolor-mix(\n\t\t\tin oklab,\n\t\t\tvar(--ft-status-cancelled, light-dark(oklch(0.5 0.02 260), oklch(0.72 0.02 260))) 55%,\n\t\t\ttransparent\n\t\t)\n\t);\n}\n\n.ft-agentplan-glyph.ft-status-running {\n\tcolor: var(\n\t\t--ft-agentplan-running,\n\t\tvar(--ft-status-running, light-dark(oklch(0.5 0.18 265), oklch(0.72 0.15 265)))\n\t);\n}\n\n.ft-agentplan-glyph.ft-status-done {\n\tcolor: var(\n\t\t--ft-agentplan-done,\n\t\tvar(--ft-status-done, light-dark(oklch(0.5 0.14 145), oklch(0.72 0.15 145)))\n\t);\n}\n\n.ft-agentplan-glyph.ft-status-error {\n\tcolor: var(\n\t\t--ft-agentplan-error,\n\t\tvar(--ft-status-error, light-dark(oklch(0.5 0.19 25), oklch(0.7 0.18 25)))\n\t);\n}\n\n.ft-agentplan-icon {\n\twidth: 100%;\n\theight: 100%;\n}\n\n.ft-agentplan-ring {\n\twidth: 0.625rem;\n\theight: 0.625rem;\n\tborder-radius: 9999px;\n\tbox-shadow: inset 0 0 0 1.5px currentColor;\n}\n\n.ft-agentplan-dot {\n\tposition: relative;\n\twidth: 0.5rem;\n\theight: 0.5rem;\n\tborder-radius: 9999px;\n\tbackground: currentColor;\n}\n\n.ft-agentplan-dash {\n\twidth: 0.625rem;\n\theight: 2px;\n\tborder-radius: 9999px;\n\tbackground: currentColor;\n}\n\n/*\n * Everything that moves lives behind the query, so reduced motion is not a\n * degraded variant to keep in sync: the running step is still a filled accent\n * dot, it just stops breathing, and the bar lands at its width instead of\n * sliding to it.\n */\n@media (prefers-reduced-motion: no-preference) {\n\t.ft-agentplan-bar {\n\t\ttransition: width 420ms cubic-bezier(0.4, 0, 0.2, 1);\n\t}\n\n\t.ft-agentplan-dot::after {\n\t\tcontent: \"\";\n\t\tposition: absolute;\n\t\tinset: 0;\n\t\tborder-radius: inherit;\n\t\tbackground: currentColor;\n\t\tanimation: ft-agentplan-ping 1.6s cubic-bezier(0, 0, 0.2, 1) infinite;\n\t}\n}\n\n@keyframes ft-agentplan-ping {\n\tfrom {\n\t\topacity: 0.55;\n\t\ttransform: scale(1);\n\t}\n\tto {\n\t\topacity: 0;\n\t\ttransform: scale(2.4);\n\t}\n}\n","type":"registry:ui","target":"@ui/fancy/components/agent-plan/agent-plan.css"},{"path":"react/src/components/agent-plan/index.ts","content":"\"use client\";\n\nexport { AgentPlan } from \"./AgentPlan\";\nexport type { AgentPlanProps } from \"./AgentPlan\";\n","type":"registry:ui","target":"@ui/fancy/components/agent-plan/index.ts"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/agent-plan?framework=react"}}