{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "go-no-go",
  "type": "registry:component",
  "title": "Go / No-go",
  "description": "A clear yes or no for risky agent actions that shows the exact command or diff, answers to Y and N, and folds into a one-line receipt you can undo.",
  "categories": [
    "component",
    "Trust & Control"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://coss.com/ui/r/badge.json",
    "https://coss.com/ui/r/button.json",
    "https://coss.com/ui/r/kbd.json"
  ],
  "files": [
    {
      "path": "registry/devl/go-no-go.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { CheckIcon, Clock3Icon, ShieldAlertIcon, ShieldCheckIcon, ShieldIcon, Undo2Icon, XIcon } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { type KeyboardEvent, useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport { Kbd } from \"@/components/ui/kbd\";\nimport { cn } from \"@/lib/utils\";\n\nexport type GoNoGoRisk = \"low\" | \"medium\" | \"high\";\nexport type GoNoGoDecision = \"approved\" | \"denied\" | \"expired\";\n\nexport type GoNoGoPreview =\n  | { type: \"command\"; command: string; cwd?: string }\n  | { type: \"diff\"; file: string; lines: { kind: \"+\" | \"-\" | \" \"; text: string }[] };\n\nexport interface GoNoGoRequest {\n  agent: string;\n  action: string;\n  risk: GoNoGoRisk;\n  preview: GoNoGoPreview;\n  reason?: string;\n}\n\nexport interface GoNoGoProps {\n  request: GoNoGoRequest;\n  timeout?: number;\n  onDecision?: (decision: GoNoGoDecision) => void;\n  onUndo?: () => void;\n  className?: string;\n}\n\nconst risks: Record<GoNoGoRisk, { label: string; variant: \"success\" | \"warning\" | \"error\"; Icon: typeof ShieldIcon }> = {\n  low: { label: \"Low risk\", variant: \"success\", Icon: ShieldCheckIcon },\n  medium: { label: \"Medium risk\", variant: \"warning\", Icon: ShieldIcon },\n  high: { label: \"High risk\", variant: \"error\", Icon: ShieldAlertIcon },\n};\n\nconst outcomes: Record<GoNoGoDecision, { label: string; tone: string; Icon: typeof CheckIcon }> = {\n  approved: { label: \"Approved\", tone: \"bg-success text-white\", Icon: CheckIcon },\n  denied: { label: \"Denied\", tone: \"bg-destructive text-white\", Icon: XIcon },\n  expired: { label: \"Timed out, denied\", tone: \"bg-muted-foreground/60 text-background\", Icon: Clock3Icon },\n};\n\nconst press = \"transition-[scale,box-shadow,background-color] duration-150 ease-out active:scale-[0.97]\";\n\nfunction previewText(p: GoNoGoPreview) {\n  return p.type === \"command\" ? p.command : p.file;\n}\n\nfunction useThemeRadius(scale: number) {\n  const probe = useRef<HTMLSpanElement>(null);\n  const [radius, setRadius] = useState(scale * 10);\n  useLayoutEffect(() => {\n    const el = probe.current;\n    if (!el) return;\n    const read = () => setRadius(el.offsetWidth);\n    read();\n    const observer = new ResizeObserver(read);\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, []);\n  const marker = (\n    <span\n      aria-hidden\n      className=\"pointer-events-none invisible absolute h-0\"\n      ref={probe}\n      style={{ width: `calc(var(--radius) * ${scale})` }}\n    />\n  );\n  return [radius, marker] as const;\n}\n\nexport function GoNoGo({ request, timeout = 30, onDecision, onUndo, className }: GoNoGoProps) {\n  const reduce = useReducedMotion();\n  const [radius, radiusProbe] = useThemeRadius(1.8);\n  const [decision, setDecision] = useState<GoNoGoDecision | null>(null);\n  const [left, setLeft] = useState(timeout * 1000);\n  const [hovered, setHovered] = useState(false);\n  const [focused, setFocused] = useState(false);\n  const rootRef = useRef<HTMLDivElement>(null);\n  const paused = hovered || focused;\n\n  const decide = (d: GoNoGoDecision) => {\n    setDecision(d);\n    onDecision?.(d);\n  };\n\n  useEffect(() => {\n    if (decision || paused) return;\n    let last = performance.now();\n    const id = setInterval(() => {\n      const now = performance.now();\n      const dt = now - last;\n      last = now;\n      setLeft((l) => Math.max(0, l - dt));\n    }, 100);\n    return () => clearInterval(id);\n  }, [decision, paused]);\n\n  useEffect(() => {\n    if (left <= 0 && !decision) decide(\"expired\");\n  }, [left, decision]);\n\n  const onKeyDown = (e: KeyboardEvent) => {\n    if (decision || e.metaKey || e.ctrlKey || e.altKey) return;\n    const k = e.key.toLowerCase();\n    if (k === \"y\") {\n      e.preventDefault();\n      decide(\"approved\");\n    } else if (k === \"n\" || k === \"escape\") {\n      e.preventDefault();\n      decide(\"denied\");\n    }\n  };\n\n  const undo = () => {\n    setDecision(null);\n    setLeft(timeout * 1000);\n    onUndo?.();\n    requestAnimationFrame(() => rootRef.current?.focus());\n  };\n\n  const risk = risks[request.risk];\n  const spring = reduce ? { duration: 0 } : { type: \"spring\" as const, stiffness: 380, damping: 32 };\n  const fade = reduce ? { duration: 0 } : { duration: 0.18 };\n\n  return (\n    <motion.div\n      aria-label={`${request.agent} asks to ${request.action}`}\n      className={cn(\n        \"w-full max-w-[420px] overflow-hidden border bg-card text-card-foreground shadow-xs/5 outline-none\",\n        !decision &&\n          \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n        className,\n      )}\n      layout\n      onBlur={(e) => {\n        if (!e.currentTarget.contains(e.relatedTarget as Node)) setFocused(false);\n      }}\n      onFocus={() => setFocused(true)}\n      onKeyDown={onKeyDown}\n      onPointerEnter={() => setHovered(true)}\n      onPointerLeave={() => setHovered(false)}\n      ref={rootRef}\n      role=\"group\"\n      style={{ borderRadius: radius }}\n      tabIndex={decision ? -1 : 0}\n      transition={spring}\n    >\n      {radiusProbe}\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        {decision ? (\n          <motion.div\n            animate={{ opacity: 1 }}\n            className=\"flex items-center gap-2.5 px-3 py-2.5\"\n            exit={{ opacity: 0 }}\n            initial={{ opacity: 0 }}\n            key=\"resolved\"\n            layout=\"position\"\n            transition={fade}\n          >\n            <motion.span\n              animate={{ scale: 1, opacity: 1 }}\n              className={cn(\"flex size-5 shrink-0 items-center justify-center rounded-full\", outcomes[decision].tone)}\n              initial={reduce ? false : { scale: 0.8, opacity: 0 }}\n              transition={{ duration: 0.2, ease: [0.23, 1, 0.32, 1], delay: 0.06 }}\n            >\n              {(() => {\n                const Icon = outcomes[decision].Icon;\n                return <Icon className=\"size-3\" strokeWidth={3} />;\n              })()}\n            </motion.span>\n            <p className=\"min-w-0 flex-1 truncate text-sm\">\n              <span className=\"font-medium\">{outcomes[decision].label}</span>\n              <span className=\"text-muted-foreground\">\n                {\" \"}\n                · {request.agent} · <span className=\"font-mono text-xs\">{previewText(request.preview)}</span>\n              </span>\n            </p>\n            <Button aria-label=\"Undo decision\" className={press} onClick={undo} size=\"xs\" variant=\"ghost\">\n              <Undo2Icon />\n              Undo\n            </Button>\n          </motion.div>\n        ) : (\n          <motion.div\n            animate={{ opacity: 1 }}\n            exit={{ opacity: 0 }}\n            initial={{ opacity: 0 }}\n            key=\"pending\"\n            layout=\"position\"\n            transition={fade}\n          >\n            <div className=\"flex items-start gap-3 px-4 pt-4\">\n              <span className=\"flex size-8 shrink-0 items-center justify-center rounded-full bg-primary font-medium text-primary-foreground text-xs\">\n                {request.agent.slice(0, 1)}\n              </span>\n              <div className=\"min-w-0 flex-1\">\n                <p className=\"text-sm\">\n                  <span className=\"font-medium\">{request.agent}</span>\n                  <span className=\"text-muted-foreground\"> wants to</span> {request.action}\n                </p>\n                <Badge className=\"mt-1\" size=\"sm\" variant={risk.variant}>\n                  <risk.Icon />\n                  {risk.label}\n                </Badge>\n              </div>\n              <Countdown left={left} paused={paused} reduce={!!reduce} total={timeout * 1000} />\n            </div>\n\n            <div className=\"px-4 pt-3\">\n              {request.preview.type === \"command\" ? (\n                <div className=\"rounded-lg bg-muted px-3 py-2.5 font-mono text-xs\">\n                  {request.preview.cwd && <p className=\"mb-1 text-muted-foreground\">{request.preview.cwd}</p>}\n                  <p className=\"break-all\">\n                    <span className=\"select-none text-muted-foreground\">$ </span>\n                    {request.preview.command}\n                  </p>\n                </div>\n              ) : (\n                <div className=\"overflow-hidden rounded-lg border font-mono text-xs\">\n                  <p className=\"border-b bg-muted px-3 py-1.5 text-muted-foreground\">{request.preview.file}</p>\n                  <div className=\"py-1\">\n                    {request.preview.lines.map((line, i) => (\n                      <p\n                        className={cn(\n                          \"whitespace-pre-wrap px-3 py-px\",\n                          line.kind === \"+\" && \"bg-success/10 text-success-foreground\",\n                          line.kind === \"-\" && \"bg-destructive/10 text-destructive-foreground\",\n                          line.kind === \" \" && \"text-muted-foreground\",\n                        )}\n                        key={i}\n                      >\n                        <span className=\"mr-2 select-none opacity-60\">{line.kind}</span>\n                        {line.text}\n                      </p>\n                    ))}\n                  </div>\n                </div>\n              )}\n              {request.reason && <p className=\"mt-2 text-muted-foreground text-xs\">{request.reason}</p>}\n            </div>\n\n            <div className=\"mt-3 flex items-center gap-2 border-t px-4 py-3\">\n              <p className=\"mr-auto text-muted-foreground text-xs max-sm:hidden\" aria-live=\"polite\">\n                {focused ? \"Press Y or N\" : paused ? \"Paused while you look\" : \"Denies itself when time runs out\"}\n              </p>\n              <Button className={cn(press, \"max-sm:flex-1\")} onClick={() => decide(\"denied\")} size=\"sm\" variant=\"outline\">\n                Deny\n                <Kbd className={cn(\"transition-opacity\", !focused && \"opacity-50\")}>N</Kbd>\n              </Button>\n              <Button\n                className={cn(press, \"max-sm:flex-1\", request.risk === \"high\" && \"border-red-600 bg-red-600 hover:bg-red-600/90 data-pressed:bg-red-600/90\")}\n                onClick={() => decide(\"approved\")}\n                size=\"sm\"\n                variant={request.risk === \"high\" ? \"destructive\" : \"default\"}\n              >\n                Approve\n                <Kbd className={cn(\"bg-current/20 text-current transition-opacity\", !focused && \"opacity-50\")}>\n                  Y\n                </Kbd>\n              </Button>\n            </div>\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </motion.div>\n  );\n}\n\nfunction Countdown({ left, total, paused, reduce }: { left: number; total: number; paused: boolean; reduce: boolean }) {\n  const frac = total ? left / total : 0;\n  const urgent = left < 10000;\n  return (\n    <div\n      aria-label={`${Math.ceil(left / 1000)} seconds left${paused ? \", paused\" : \"\"}`}\n      className=\"relative flex size-9 shrink-0 items-center justify-center\"\n      role=\"timer\"\n    >\n      <svg aria-hidden className=\"absolute inset-0 -rotate-90\" viewBox=\"0 0 36 36\">\n        <circle className=\"stroke-muted\" cx=\"18\" cy=\"18\" fill=\"none\" r=\"15\" strokeWidth=\"3\" />\n        <motion.circle\n          animate={{ pathLength: frac }}\n          className={cn(\n            \"transition-[stroke] duration-300\",\n            paused ? \"stroke-muted-foreground\" : urgent ? \"stroke-destructive\" : \"stroke-primary\",\n          )}\n          cx=\"18\"\n          cy=\"18\"\n          fill=\"none\"\n          initial={false}\n          r=\"15\"\n          strokeLinecap=\"round\"\n          strokeWidth=\"3\"\n          transition={reduce ? { duration: 0 } : { type: \"tween\", ease: \"linear\", duration: 0.1 }}\n        />\n      </svg>\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        <motion.span\n          animate={{ opacity: 1, scale: 1 }}\n          className=\"font-medium font-mono text-[11px] tabular-nums\"\n          exit={{ opacity: 0, scale: reduce ? 1 : 0.8 }}\n          initial={{ opacity: 0, scale: reduce ? 1 : 0.8 }}\n          key={paused ? \"p\" : \"n\"}\n          transition={{ duration: reduce ? 0.1 : 0.15, ease: \"easeOut\" }}\n        >\n          {paused ? (\n            <svg aria-hidden className=\"size-2.5 fill-current\" viewBox=\"0 0 10 10\">\n              <rect height=\"10\" rx=\"1\" width=\"3\" x=\"1\" />\n              <rect height=\"10\" rx=\"1\" width=\"3\" x=\"6\" />\n            </svg>\n          ) : (\n            Math.ceil(left / 1000)\n          )}\n        </motion.span>\n      </AnimatePresence>\n    </div>\n  );\n}\n"
    }
  ]
}