{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "diff-review",
  "type": "registry:ui",
  "title": "Diff Review",
  "description": "Accept or reject an agent's edits hunk by hunk; decided hunks fold into a tick.",
  "categories": [
    "ai-agents"
  ],
  "dependencies": [
    "@base-ui/react",
    "@number-flow/react",
    "motion",
    "sugar-high"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/diff-review.tsx",
      "content": "\"use client\";\nimport { Tabs } from \"@base-ui/react/tabs\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport { highlight } from \"sugar-high\";\nimport { cn } from \"@/lib/cn\";\nimport { ArrowRight, Undo } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\n/* -------------------------------------------------------------------------------------------------\n * Data\n * -----------------------------------------------------------------------------------------------*/\n\n/** One hunk of a unified diff. Lines keep their prefix: \"+\" added, \"-\" removed, \" \" unchanged. */\nexport type DiffHunk = {\n  /** Stable id. Falls back to the hunk's index in its file. */\n  id?: string;\n  oldStart: number;\n  newStart: number;\n  /** The text after the second @@, usually the enclosing function. */\n  section?: string;\n  lines: string[];\n};\n\nexport type DiffFile = {\n  path: string;\n  status?: \"modified\" | \"added\" | \"deleted\" | \"renamed\";\n  hunks: DiffHunk[];\n};\n\nexport type HunkDecision = \"accepted\" | \"rejected\";\n/** Decisions by hunk key (see `hunkKey`). A hunk that isn't in the record is still pending. */\nexport type DiffDecisions = Record<string, HunkDecision>;\n\nexport const hunkKey = (file: DiffFile, hunk: DiffHunk, index: number) => `${file.path}#${hunk.id ?? index}`;\n\nconst HEADER = /^@@ -(\\d+)(?:,(\\d+))? \\+(\\d+)(?:,(\\d+))? @@ ?(.*)$/;\nconst stripPath = (p: string) => p.replace(/\\t.*$/, \"\").replace(/^[ab]\\//, \"\");\n\n/** Turns `git diff` output (one or many files) into the shape DiffReview takes. */\nexport function parsePatch(patch: string): DiffFile[] {\n  const files: DiffFile[] = [];\n  let file: DiffFile | null = null;\n  let hunk: DiffHunk | null = null;\n  let oldLeft = 0;\n  let newLeft = 0;\n  const start = (path = \"\") => {\n    file = { path, status: \"modified\", hunks: [] };\n    files.push(file);\n    hunk = null;\n    return file;\n  };\n\n  for (const raw of patch.split(/\\r?\\n/)) {\n    // Inside a hunk, the header's counts say where it ends, so a removed line\n    // that happens to start with \"--\" is never mistaken for a file header.\n    // A line that can't belong to a hunk ends it early, so a header with wrong counts can't swallow the next file.\n    if (hunk && (oldLeft > 0 || newLeft > 0) && /^[+\\- \\\\]|^$/.test(raw)) {\n      if (raw.startsWith(\"\\\\\")) continue;\n      const sign = raw[0];\n      if (sign !== \"+\") oldLeft--;\n      if (sign !== \"-\") newLeft--;\n      (hunk as DiffHunk).lines.push(sign === \"+\" || sign === \"-\" ? raw : ` ${raw.slice(1)}`);\n      continue;\n    }\n    const h = HEADER.exec(raw);\n    if (h) {\n      const f: DiffFile = file ?? start(\"untitled\");\n      hunk = { oldStart: +h[1], newStart: +h[3], section: h[5] || undefined, lines: [] };\n      oldLeft = h[2] === undefined ? 1 : +h[2];\n      newLeft = h[4] === undefined ? 1 : +h[4];\n      f.hunks.push(hunk);\n    } else if (raw.startsWith(\"diff --git \")) {\n      start(/ b\\/(.+)$/.exec(raw)?.[1] ?? raw.slice(11));\n    } else if (raw.startsWith(\"--- \")) {\n      const current = file as DiffFile | null;\n      const f: DiffFile = !current || current.hunks.length ? start() : current;\n      if (raw.slice(4).startsWith(\"/dev/null\")) f.status = \"added\";\n      else if (!f.path) f.path = stripPath(raw.slice(4));\n    } else if (raw.startsWith(\"+++ \") && file) {\n      const f: DiffFile = file;\n      if (raw.slice(4).startsWith(\"/dev/null\")) f.status = \"deleted\";\n      else f.path = stripPath(raw.slice(4));\n    } else if (file && raw.startsWith(\"new file mode\")) (file as DiffFile).status = \"added\";\n    else if (file && raw.startsWith(\"deleted file mode\")) (file as DiffFile).status = \"deleted\";\n    else if (file && raw.startsWith(\"rename to \")) Object.assign(file as DiffFile, { status: \"renamed\", path: raw.slice(10) });\n  }\n  return files.filter((f) => f.hunks.length);\n}\n\ntype Row = { sign: \"+\" | \"-\" | \" \"; html: string; old?: number; new?: number };\ntype HunkModel = { key: string; hunk: DiffHunk; rows: Row[]; adds: number; dels: number; from: number; to: number };\n\nfunction model(file: DiffFile): HunkModel[] {\n  return file.hunks.map((hunk, i) => {\n    let o = hunk.oldStart;\n    let n = hunk.newStart;\n    let adds = 0;\n    let dels = 0;\n    const rows = hunk.lines.map((line): Row => {\n      const sign = line[0] === \"+\" || line[0] === \"-\" ? line[0] : \" \";\n      // Highlighted one line at a time: good enough for review, and cheap.\n      const html = highlight(line.slice(1) || \" \");\n      if (sign === \"+\") {\n        adds++;\n        return { sign, html, new: n++ };\n      }\n      if (sign === \"-\") {\n        dels++;\n        return { sign, html, old: o++ };\n      }\n      return { sign, html, old: o++, new: n++ };\n    });\n    const pureDelete = n === hunk.newStart;\n    const from = pureDelete ? hunk.oldStart : hunk.newStart;\n    const to = Math.max(from, (pureDelete ? o : n) - 1);\n    return { key: hunkKey(file, hunk, i), hunk, rows, adds, dels, from, to };\n  });\n}\n\nconst tally = (hunks: HunkModel[], d: DiffDecisions) => {\n  let decided = 0;\n  let accepted = 0;\n  for (const h of hunks) {\n    if (d[h.key]) decided++;\n    if (d[h.key] === \"accepted\") accepted++;\n  }\n  return { decided, accepted, rejected: decided - accepted, total: hunks.length };\n};\n\n/* -------------------------------------------------------------------------------------------------\n * DiffReview\n * -----------------------------------------------------------------------------------------------*/\n\nexport type DiffReviewProps = Omit<React.ComponentProps<\"section\">, \"defaultValue\" | \"onChange\"> & {\n  files: DiffFile[];\n  /** Decisions by hunk key. Controlled. */\n  value?: DiffDecisions;\n  defaultValue?: DiffDecisions;\n  onValueChange?: (value: DiffDecisions) => void;\n  /** Called once each time the last pending hunk is decided. */\n  onComplete?: (value: DiffDecisions) => void;\n  title?: string;\n  /** The file shown first. Defaults to the first file with pending changes. */\n  defaultFile?: string;\n  /** Show the changes but block decisions, e.g. while the agent is still writing them. */\n  disabled?: boolean;\n};\n\ntype Focus = { key: string; part: \"accept\" | \"undo\" } | null;\n\n// Diff tokens in four foreground steps: structure without a rainbow.\nconst tokens = {\n  \"--sh-keyword\": \"var(--fg)\",\n  \"--sh-class\": \"var(--fg)\",\n  \"--sh-entity\": \"var(--fg)\",\n  \"--sh-identifier\": \"var(--fg)\",\n  \"--sh-property\": \"var(--fg-2)\",\n  \"--sh-string\": \"var(--fg-2)\",\n  \"--sh-jsxliterals\": \"var(--fg-2)\",\n  \"--sh-sign\": \"var(--fg-3)\",\n  \"--sh-comment\": \"var(--fg-3)\",\n} as React.CSSProperties;\n\nexport function DiffReview({\n  files,\n  value,\n  defaultValue,\n  onValueChange,\n  onComplete,\n  title = \"Proposed changes\",\n  defaultFile,\n  disabled = false,\n  className,\n  style,\n  ...rest\n}: DiffReviewProps) {\n  const reduce = !!useReducedMotion();\n  const id = useId();\n  const [decisions, setDecisions] = useControllableState<DiffDecisions>({ value, defaultValue: defaultValue ?? {}, onChange: onValueChange });\n  const models = useMemo(() => files.map((f) => ({ file: f, hunks: model(f) })), [files]);\n  const all = useMemo(() => models.flatMap((m) => m.hunks), [models]);\n  const totals = tally(all, decisions);\n  const done = totals.total > 0 && totals.decided === totals.total;\n\n  // Chosen once: the requested file, else the first with something left to review.\n  // Finishing a file never switches the view on its own; Next file does.\n  const [picked, setPicked] = useState<string | undefined>(() => defaultFile ?? models.find((m) => m.hunks.some((h) => !decisions[h.key]))?.file.path);\n  const active = models.some((m) => m.file.path === picked) ? picked! : models[0]?.file.path;\n\n  const buttons = useRef(new Map<string, HTMLButtonElement>());\n  const focusNext = useRef<Focus>(null);\n  const [announcement, setAnnouncement] = useState(\"\");\n\n  // Focus moves after the render that mounted its target (an Undo, or the next Accept).\n  useEffect(() => {\n    const f = focusNext.current;\n    if (!f) return;\n    focusNext.current = null;\n    buttons.current.get(`${f.part}:${f.key}`)?.focus({ preventScroll: false });\n  });\n\n  const commit = (next: DiffDecisions, message: string) => {\n    setDecisions(next);\n    setAnnouncement(message);\n    const t = tally(all, next);\n    if (t.total && t.decided === t.total && totals.decided !== totals.total) onComplete?.(next);\n  };\n\n  const decide = (fileIndex: number, h: HunkModel, decision: HunkDecision | null, viaKeyboard: boolean) => {\n    if (disabled) return;\n    const { file, hunks } = models[fileIndex];\n    const next = { ...decisions };\n    if (decision) next[h.key] = decision;\n    else delete next[h.key];\n    const n = hunks.indexOf(h) + 1;\n    commit(next, decision ? `${decision === \"accepted\" ? \"Accepted\" : \"Rejected\"} change ${n} in ${basename(file.path)}` : `Change ${n} in ${basename(file.path)} is pending again`);\n\n    if (!decision) {\n      focusNext.current = { key: h.key, part: \"accept\" };\n      return;\n    }\n    // Keep a keyboard reviewer moving: on to the next pending hunk in this file,\n    // or onto this hunk's Undo when the file is finished. Pointers stay put.\n    if (!viaKeyboard) return;\n    const i = hunks.indexOf(h);\n    const after = [...hunks.slice(i + 1), ...hunks.slice(0, i)].find((x) => !next[x.key]);\n    focusNext.current = { key: (after ?? h).key, part: after ? \"accept\" : \"undo\" };\n  };\n\n  const acceptAll = () => {\n    if (disabled || done) return;\n    const next = { ...decisions };\n    let count = 0;\n    for (const h of all) {\n      if (next[h.key]) continue;\n      next[h.key] = \"accepted\";\n      count++;\n    }\n    commit(next, `Accepted ${count} remaining ${count === 1 ? \"change\" : \"changes\"}`);\n  };\n\n  const openFile = (path: string) => {\n    setPicked(path);\n    const m = models.find((x) => x.file.path === path);\n    const h = m?.hunks.find((x) => !decisions[x.key]);\n    if (h) focusNext.current = { key: h.key, part: \"accept\" };\n  };\n\n  if (!files.length)\n    return (\n      <section aria-labelledby={`${id}-t`} className={cn(\"rounded-xl border border-line bg-raised px-4 py-8 text-center\", className)} style={style} {...rest}>\n        <h3 id={`${id}-t`} className=\"text-[13px] font-medium tracking-[-0.01em] text-fg\">No changes to review</h3>\n        <p className=\"mt-1 text-[12px] text-fg-3\">When the agent proposes edits, they appear here file by file.</p>\n      </section>\n    );\n\n  const fileCount = `${files.length} ${files.length === 1 ? \"file\" : \"files\"}`;\n\n  return (\n    <section\n      aria-labelledby={`${id}-t`}\n      data-state={done ? \"done\" : \"pending\"}\n      data-disabled={disabled ? \"\" : undefined}\n      className={cn(\"flex min-h-0 flex-col overflow-hidden rounded-xl border border-line-2 bg-raised shadow-[var(--shadow)]\", className)}\n      style={{ ...tokens, ...style }}\n      {...rest}\n    >\n      <header className=\"relative flex items-center gap-3 border-b border-line py-2.5 pl-3.5 pr-2.5\">\n        <div className=\"min-w-0 flex-1\">\n          <h3 id={`${id}-t`} className=\"text-balance text-[13px] font-medium tracking-[-0.01em] text-fg\">{title}</h3>\n          <p className=\"tabular mt-px truncate text-[12px] text-fg-3\">\n            {fileCount}\n            <span className=\"px-1.5 text-fg-4\">·</span>\n            <NumberFlow value={totals.decided} className=\"text-fg-2\" /> of {totals.total} reviewed\n          </p>\n        </div>\n        <AcceptAll done={done} pending={totals.total - totals.decided} disabled={disabled} reduce={reduce} onPress={acceptAll} />\n        {/* Review progress along the header's edge. The text above says the same thing to a screen reader. */}\n        <span aria-hidden className=\"absolute inset-x-0 -bottom-px h-px overflow-hidden\">\n          <span\n            className=\"block h-full origin-left bg-fg-2 transition-transform duration-500 ease-out-expo motion-reduce:transition-none\"\n            style={{ transform: `scaleX(${totals.total ? totals.decided / totals.total : 0})` }}\n          />\n        </span>\n      </header>\n\n      <Tabs.Root orientation=\"vertical\" value={active} onValueChange={(v) => setPicked(v as string)} className=\"flex min-h-0 flex-1 flex-col\">\n        <Tabs.List activateOnFocus aria-label=\"Changed files\" className=\"relative flex shrink-0 flex-col border-b border-line p-1\">\n          <Tabs.Indicator className=\"absolute left-1 right-1 top-0 h-(--active-tab-height) translate-y-(--active-tab-top) rounded-md bg-fg/[0.06] transition-[translate,height] duration-220 ease-in-out-quart motion-reduce:transition-none\" />\n          {models.map(({ file, hunks }) => (\n            <FileTab key={file.path} file={file} hunks={hunks} decisions={decisions} reduce={reduce} />\n          ))}\n        </Tabs.List>\n\n        {models.map(({ file, hunks }, fi) => {\n          const t = tally(hunks, decisions);\n          const nextFile = models.find((m, i) => i !== fi && m.hunks.some((h) => !decisions[h.key]))?.file.path;\n          return (\n            <Tabs.Panel\n              key={file.path}\n              value={file.path}\n              aria-label={`Changes in ${file.path}`}\n              className=\"flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto overscroll-contain p-2 outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-3\"\n            >\n              {hunks.map((h, hi) => (\n                <Hunk\n                  key={h.key}\n                  model={h}\n                  index={hi}\n                  count={hunks.length}\n                  decision={decisions[h.key]}\n                  disabled={disabled}\n                  reduce={reduce}\n                  register={(part, el) => {\n                    const k = `${part}:${h.key}`;\n                    if (el) buttons.current.set(k, el);\n                    else buttons.current.delete(k);\n                  }}\n                  onDecide={(d, viaKeyboard) => decide(fi, h, d, viaKeyboard)}\n                />\n              ))}\n              <AnimatePresence initial={false}>\n                {t.decided === t.total && (\n                  <motion.div\n                    key=\"done\"\n                    initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4 }}\n                    animate={{ opacity: 1, y: 0 }}\n                    exit={{ opacity: 0, transition: { duration: 0.12 } }}\n                    transition={{ duration: 0.24, ease: ease.out, delay: reduce ? 0 : 0.12 }}\n                    className=\"flex min-h-9 flex-wrap items-center gap-x-3 gap-y-1 px-1.5 text-[12px] text-fg-3\"\n                  >\n                    <span className=\"min-w-0 flex-1\">\n                      {done ? `Review complete: ${outcome(totals)}` : `${basename(file.path)} reviewed: ${outcome(t)}`}\n                    </span>\n                    {nextFile && (\n                      <button\n                        type=\"button\"\n                        onClick={() => openFile(nextFile)}\n                        className={cn(ghost, \"group/next -mr-1 h-7 gap-1.5 px-2 text-[12px] text-fg-2\")}\n                      >\n                        Next file\n                        <ArrowRight size={14} className=\"transition-transform duration-150 ease-out group-hover/next:translate-x-0.5\" />\n                      </button>\n                    )}\n                  </motion.div>\n                )}\n              </AnimatePresence>\n            </Tabs.Panel>\n          );\n        })}\n      </Tabs.Root>\n\n      <footer aria-hidden className=\"hidden shrink-0 items-center gap-3 border-t border-line px-3.5 py-2 font-mono text-2xs text-fg-4 pointer-fine:flex\">\n        <span className=\"whitespace-nowrap\"><Kbd>A</Kbd>accept</span>\n        <span className=\"whitespace-nowrap\"><Kbd>R</Kbd>reject</span>\n        <span className=\"whitespace-nowrap\"><Kbd>U</Kbd>undo</span>\n        <span className=\"ml-auto hidden truncate min-[480px]:block\">in the focused change</span>\n      </footer>\n\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">{announcement}</span>\n    </section>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Parts\n * -----------------------------------------------------------------------------------------------*/\n\nconst focusRing = \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\";\nconst pressable = cn(\n  focusRing,\n  \"relative inline-flex shrink-0 select-none items-center justify-center rounded-md font-medium\",\n  \"transition-[background-color,border-color,color,scale] duration-150 ease-out active:scale-[0.96] active:duration-75\",\n  \"disabled:pointer-events-none disabled:opacity-50\",\n  // 28px to draw, 44px to touch.\n  \"before:absolute before:-inset-x-1 before:-inset-y-2 before:content-[''] pointer-fine:before:hidden\",\n);\nconst ghost = cn(pressable, \"text-fg-2 hover:bg-hover hover:text-fg\");\n\nfunction Kbd({ children }: { children: React.ReactNode }) {\n  return <kbd className=\"mr-1 inline-grid h-4 min-w-4 place-items-center rounded border border-line-2 bg-frame px-1 text-[10px] text-fg-3\">{children}</kbd>;\n}\n\nconst basename = (p: string) => p.slice(p.lastIndexOf(\"/\") + 1);\nconst outcome = ({ accepted, rejected }: { accepted: number; rejected: number }) =>\n  [accepted && `${accepted} accepted`, rejected && `${rejected} rejected`].filter(Boolean).join(\", \");\n\nfunction AcceptAll({ done, pending, disabled, reduce, onPress }: { done: boolean; pending: number; disabled: boolean; reduce: boolean; onPress: () => void }) {\n  const labels = [\"Accept all\", \"All reviewed\"];\n  return (\n    <button\n      type=\"button\"\n      // Stays focusable once everything is reviewed so focus isn't dropped on the page.\n      aria-disabled={done || undefined}\n      disabled={disabled}\n      data-state={done ? \"done\" : \"pending\"}\n      aria-label={done ? \"All changes reviewed\" : `Accept all ${pending} remaining ${pending === 1 ? \"change\" : \"changes\"}`}\n      onClick={onPress}\n      className={cn(\n        pressable,\n        \"h-7 gap-1.5 rounded-md px-2.5 text-[12px]\",\n        done\n          ? \"cursor-default border border-line-2 bg-transparent text-fg-2 active:scale-100\"\n          : \"border border-transparent bg-fg text-frame hover:bg-fg/90\",\n      )}\n    >\n      <span className=\"relative grid size-3.5 place-items-center\">\n        <AnimatePresence initial={false}>\n          {done && (\n            <motion.svg\n              key=\"tick\"\n              width={14}\n              height={14}\n              viewBox=\"0 0 16 16\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth={1.6}\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              aria-hidden\n              className=\"absolute\"\n              initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}\n              animate={{ opacity: 1, scale: 1 }}\n              exit={{ opacity: 0, transition: { duration: 0.1 } }}\n              transition={reduce ? { duration: 0.15 } : spring.pop}\n            >\n              <motion.path d=\"M3.5 8.5 6.5 11.5 12.5 4.5\" initial={reduce ? false : { pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ duration: 0.32, ease: ease.out, delay: 0.06 }} />\n            </motion.svg>\n          )}\n          {!done && (\n            <motion.svg\n              key=\"all\"\n              width={14}\n              height={14}\n              viewBox=\"0 0 16 16\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth={1.5}\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              aria-hidden\n              className=\"absolute\"\n              initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}\n              animate={{ opacity: 1, scale: 1 }}\n              exit={{ opacity: 0, scale: reduce ? 1 : 0.6, transition: { duration: 0.1 } }}\n              transition={reduce ? { duration: 0.15 } : spring.pop}\n            >\n              <path d=\"M1.75 8.25 4.5 11 10 4.75M8 10.25l.75.75 5.5-6.25\" />\n            </motion.svg>\n          )}\n        </AnimatePresence>\n      </span>\n      {/* Both labels share one grid cell, so the button keeps its width when it flips. */}\n      <span className=\"grid text-left\">\n        {labels.map((l) => (\n          <span key={l} aria-hidden className=\"invisible col-start-1 row-start-1\">{l}</span>\n        ))}\n        <AnimatePresence initial={false}>\n          <motion.span\n            key={done ? \"done\" : \"todo\"}\n            className=\"col-start-1 row-start-1\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6, filter: \"blur(2px)\" }}\n            animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, y: -6, filter: \"blur(2px)\" }}\n            transition={{ duration: reduce ? 0.15 : 0.22, ease: ease.out }}\n          >\n            {done ? labels[1] : labels[0]}\n          </motion.span>\n        </AnimatePresence>\n      </span>\n    </button>\n  );\n}\n\nfunction FileTab({ file, hunks, decisions, reduce }: { file: DiffFile; hunks: HunkModel[]; decisions: DiffDecisions; reduce: boolean }) {\n  const t = tally(hunks, decisions);\n  const adds = hunks.reduce((s, h) => s + h.adds, 0);\n  const dels = hunks.reduce((s, h) => s + h.dels, 0);\n  const slash = file.path.lastIndexOf(\"/\");\n  const dir = slash >= 0 ? file.path.slice(0, slash + 1) : \"\";\n  const name = file.path.slice(slash + 1);\n  const spoken = t.decided === t.total ? \"reviewed\" : `${t.decided} of ${t.total} reviewed`;\n\n  return (\n    <Tabs.Tab\n      value={file.path}\n      aria-label={`${file.path}, ${adds} added, ${dels} removed, ${spoken}`}\n      className={cn(\n        \"group/tab relative z-1 flex h-8 min-w-0 items-center gap-2 rounded-md px-2 text-left text-[12.5px] text-fg-2\",\n        \"outline-none transition-[color,scale] duration-150 ease-out hover:text-fg active:scale-[0.99] data-active:text-fg\",\n        \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-3\",\n      )}\n    >\n      <FileProgress decided={t.decided} total={t.total} rejectedAll={t.rejected === t.total} reduce={reduce} />\n      <span className=\"flex min-w-0 flex-1 items-baseline font-mono text-[12px]\">\n        <span className=\"min-w-0 truncate text-fg-4\">{dir}</span>\n        <span className=\"shrink-0\">{name}</span>\n        {file.status && file.status !== \"modified\" && (\n          <span className=\"ml-2 shrink-0 rounded-sm border border-line-2 px-1 text-[10px] leading-3.5 text-fg-3\">\n            {file.status === \"added\" ? \"new\" : file.status}\n          </span>\n        )}\n      </span>\n      <span aria-hidden className=\"tabular flex shrink-0 gap-1.5 font-mono text-[11px]\">\n        {adds > 0 && <span className=\"text-success\">+{adds}</span>}\n        {dels > 0 && <span className=\"text-danger\">−{dels}</span>}\n      </span>\n    </Tabs.Tab>\n  );\n}\n\n/** A ring that fills as hunks are decided, then turns into a tick (or a cross when all were rejected). */\nfunction FileProgress({ decided, total, rejectedAll, reduce }: { decided: number; total: number; rejectedAll: boolean; reduce: boolean }) {\n  const complete = decided === total;\n  const c = 2 * Math.PI * 5.25;\n  return (\n    <span aria-hidden className=\"relative grid size-4 shrink-0 place-items-center\">\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        {complete ? (\n          <motion.svg\n            key={rejectedAll ? \"x\" : \"tick\"}\n            width={16}\n            height={16}\n            viewBox=\"0 0 16 16\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth={1.5}\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            className={rejectedAll ? \"text-fg-3\" : \"text-success\"}\n            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}\n            animate={{ opacity: 1, scale: 1 }}\n            exit={{ opacity: 0, transition: { duration: 0.1 } }}\n            transition={reduce ? { duration: 0.15 } : spring.pop}\n          >\n            {rejectedAll ? (\n              <path d=\"m5 5 6 6M11 5l-6 6\" />\n            ) : (\n              <motion.path d=\"M3.5 8.5 6.5 11.5 12.5 4.5\" initial={reduce ? false : { pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ duration: 0.3, ease: ease.out, delay: 0.05 }} />\n            )}\n          </motion.svg>\n        ) : (\n          <motion.svg key=\"ring\" width={16} height={16} viewBox=\"0 0 16 16\" fill=\"none\" className=\"-rotate-90\" exit={{ opacity: 0, scale: reduce ? 1 : 0.6, transition: { duration: 0.12 } }}>\n            <circle cx=\"8\" cy=\"8\" r=\"5.25\" stroke=\"var(--fg-4)\" strokeOpacity={0.6} strokeWidth={1.5} />\n            <circle\n              cx=\"8\"\n              cy=\"8\"\n              r=\"5.25\"\n              stroke=\"var(--fg-2)\"\n              strokeWidth={1.5}\n              strokeLinecap=\"round\"\n              strokeDasharray={c}\n              strokeDashoffset={c * (1 - decided / total)}\n              className=\"transition-[stroke-dashoffset] duration-300 ease-out-expo motion-reduce:transition-none\"\n              opacity={decided ? 1 : 0}\n            />\n          </motion.svg>\n        )}\n      </AnimatePresence>\n    </span>\n  );\n}\n\nfunction Hunk({\n  model: h,\n  index,\n  count,\n  decision,\n  disabled,\n  reduce,\n  register,\n  onDecide,\n}: {\n  model: HunkModel;\n  index: number;\n  count: number;\n  decision?: HunkDecision;\n  disabled: boolean;\n  reduce: boolean;\n  register: (part: \"accept\" | \"undo\", el: HTMLButtonElement | null) => void;\n  onDecide: (decision: HunkDecision | null, viaKeyboard: boolean) => void;\n}) {\n  const decided = !!decision;\n  const range = h.from === h.to ? `Line ${h.from}` : `Lines ${h.from}–${h.to}`;\n  // detail is 0 for clicks that came from Enter or Space.\n  const kb = (e: React.MouseEvent) => e.detail === 0;\n\n  return (\n    <div\n      role=\"group\"\n      aria-label={`Change ${index + 1} of ${count}, ${range.toLowerCase()}${decision ? `, ${decision}` : \"\"}`}\n      data-state={decision ?? \"pending\"}\n      onKeyDown={(e) => {\n        if (disabled || e.metaKey || e.ctrlKey || e.altKey || e.repeat) return;\n        const k = e.key.toLowerCase();\n        if (!decided && (k === \"a\" || k === \"r\")) {\n          e.preventDefault();\n          onDecide(k === \"a\" ? \"accepted\" : \"rejected\", true);\n        } else if (decided && k === \"u\") {\n          e.preventDefault();\n          onDecide(null, true);\n        }\n      }}\n      className={cn(\n        \"shrink-0 overflow-hidden rounded-lg border transition-[background-color,border-color] duration-200 ease-out\",\n        decided ? \"border-line bg-frame/60\" : \"border-line-2 bg-frame\",\n      )}\n    >\n      <div className=\"flex h-10 items-center gap-2 pl-2.5 pr-1.5\">\n        <span aria-hidden className=\"relative grid size-4 shrink-0 place-items-center\">\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            <motion.span\n              key={decision ?? \"pending\"}\n              className=\"grid place-items-center\"\n              initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, filter: \"blur(2px)\" }}\n              animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n              exit={{ opacity: 0, transition: { duration: 0.1 } }}\n              transition={reduce ? { duration: 0.15 } : spring.pop}\n            >\n              <HunkGlyph decision={decision} reduce={reduce} />\n            </motion.span>\n          </AnimatePresence>\n        </span>\n\n        <span className=\"flex min-w-0 flex-1 items-baseline gap-2 font-mono text-[11.5px]\">\n          <span className={cn(\"shrink-0 transition-colors duration-200\", decided ? \"text-fg-3\" : \"text-fg-2\")}>\n            {decision === \"accepted\" ? \"Accepted\" : decision === \"rejected\" ? \"Rejected\" : range}\n          </span>\n          {decided && <span className=\"shrink-0 text-fg-4\">{range}</span>}\n          {!decided && h.hunk.section && <span className=\"min-w-0 truncate text-fg-4\">{h.hunk.section}</span>}\n          <span aria-hidden className={cn(\"tabular ml-auto hidden shrink-0 gap-1.5 text-[11px] min-[420px]:flex\", decision === \"rejected\" && \"line-through decoration-fg-4\")}>\n            {h.adds > 0 && <span className={decided ? \"text-fg-4\" : \"text-success\"}>+{h.adds}</span>}\n            {h.dels > 0 && <span className={decided ? \"text-fg-4\" : \"text-danger\"}>−{h.dels}</span>}\n          </span>\n        </span>\n\n        {/* The actions swap in place; the row never changes height. */}\n        <span className=\"relative flex shrink-0 items-center justify-end\">\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            {decided ? (\n              <motion.span\n                key=\"undo\"\n                initial={reduce ? { opacity: 0 } : { opacity: 0, x: 6, filter: \"blur(2px)\" }}\n                animate={{ opacity: 1, x: 0, filter: \"blur(0px)\" }}\n                exit={reduce ? { opacity: 0 } : { opacity: 0, x: 6, filter: \"blur(2px)\", transition: { duration: 0.12 } }}\n                transition={{ duration: 0.22, ease: ease.out, delay: reduce ? 0 : 0.08 }}\n                className=\"flex\"\n              >\n                <button\n                  ref={(el) => register(\"undo\", el)}\n                  type=\"button\"\n                  disabled={disabled}\n                  aria-keyshortcuts=\"u\"\n                  aria-label={`Undo ${decision === \"accepted\" ? \"accept\" : \"reject\"}, ${range.toLowerCase()}`}\n                  onClick={(e) => onDecide(null, kb(e))}\n                  className={cn(ghost, \"group/undo h-7 gap-1.5 px-2 text-[12px]\")}\n                >\n                  <Undo size={14} className=\"transition-transform duration-200 ease-out group-hover/undo:-rotate-12\" />\n                  Undo\n                </button>\n              </motion.span>\n            ) : (\n              <motion.span\n                key=\"actions\"\n                initial={reduce ? { opacity: 0 } : { opacity: 0, x: -6, filter: \"blur(2px)\" }}\n                animate={{ opacity: 1, x: 0, filter: \"blur(0px)\" }}\n                exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, filter: \"blur(2px)\", transition: { duration: 0.12 } }}\n                transition={{ duration: 0.22, ease: ease.out }}\n                className=\"flex gap-1\"\n              >\n                <button\n                  type=\"button\"\n                  disabled={disabled}\n                  aria-keyshortcuts=\"r\"\n                  aria-label={`Reject, ${range.toLowerCase()}`}\n                  onClick={(e) => onDecide(\"rejected\", kb(e))}\n                  className={cn(ghost, \"h-7 px-2 text-[12px] hover:text-danger\")}\n                >\n                  Reject\n                </button>\n                <button\n                  ref={(el) => register(\"accept\", el)}\n                  type=\"button\"\n                  disabled={disabled}\n                  aria-keyshortcuts=\"a\"\n                  aria-label={`Accept, ${range.toLowerCase()}`}\n                  onClick={(e) => onDecide(\"accepted\", kb(e))}\n                  className={cn(pressable, \"h-7 border border-line-2 bg-raised px-2.5 text-[12px] text-fg shadow-[var(--shadow)] hover:border-fg-4 hover:bg-hover\")}\n                >\n                  Accept\n                </button>\n              </motion.span>\n            )}\n          </AnimatePresence>\n        </span>\n      </div>\n\n      {/* Decided hunks fold away to their header; Undo unfolds them from the same place. */}\n      <motion.div\n        initial={false}\n        animate={decided ? { height: 0, opacity: 0 } : { height: \"auto\", opacity: 1 }}\n        transition={\n          reduce\n            ? { duration: 0.15 }\n            : decided\n              ? { height: { duration: 0.28, ease: ease.inOut, delay: 0.06 }, opacity: { duration: 0.16, ease: ease.out } }\n              : { height: { duration: 0.3, ease: ease.out }, opacity: { duration: 0.2, ease: ease.out, delay: 0.06 } }\n        }\n        inert={decided}\n        className=\"overflow-hidden\"\n      >\n        <div className=\"overflow-x-auto overscroll-x-contain border-t border-line\">\n          <div className=\"w-max min-w-full py-1 font-mono text-[12px] leading-5\">\n            {h.rows.map((r, i) => (\n              <div\n                key={i}\n                className={cn(\n                  \"flex whitespace-pre\",\n                  r.sign === \"+\" && \"bg-success-soft\",\n                  r.sign === \"-\" && \"bg-danger-soft\",\n                )}\n              >\n                <span aria-hidden className=\"tabular w-9 shrink-0 select-none pr-1.5 text-right text-fg-4\">{r.old ?? \"\"}</span>\n                <span aria-hidden className=\"tabular w-9 shrink-0 select-none pr-1.5 text-right text-fg-4\">{r.new ?? \"\"}</span>\n                <span aria-hidden className={cn(\"w-4 shrink-0 select-none text-center\", r.sign === \"+\" ? \"text-success\" : r.sign === \"-\" ? \"text-danger\" : \"text-fg-4\")}>\n                  {r.sign === \"+\" ? \"+\" : r.sign === \"-\" ? \"−\" : \"\"}\n                </span>\n                {r.sign !== \" \" && <span className=\"sr-only\">{r.sign === \"+\" ? \"Added: \" : \"Removed: \"}</span>}\n                <code className={cn(\"pr-4\", r.sign === \"-\" && \"opacity-80\")} dangerouslySetInnerHTML={{ __html: r.html }} />\n              </div>\n            ))}\n          </div>\n        </div>\n      </motion.div>\n    </div>\n  );\n}\n\nfunction HunkGlyph({ decision, reduce }: { decision?: HunkDecision; reduce: boolean }) {\n  const draw = reduce ? {} : { initial: { pathLength: 0 }, animate: { pathLength: 1 } };\n  if (decision === \"accepted\")\n    return (\n      <svg width={14} height={14} viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.6} strokeLinecap=\"round\" strokeLinejoin=\"round\" className=\"text-success\">\n        <motion.path d=\"M3.5 8.5 6.5 11.5 12.5 4.5\" {...draw} transition={{ duration: 0.32, ease: ease.out, delay: 0.05 }} />\n      </svg>\n    );\n  if (decision === \"rejected\")\n    return (\n      <svg width={14} height={14} viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.6} strokeLinecap=\"round\" className=\"text-fg-3\">\n        <motion.path d=\"m4.5 4.5 7 7\" {...draw} transition={{ duration: 0.2, ease: ease.out, delay: 0.04 }} />\n        <motion.path d=\"m11.5 4.5-7 7\" {...draw} transition={{ duration: 0.2, ease: ease.out, delay: 0.14 }} />\n      </svg>\n    );\n  return <span className=\"size-1.5 rounded-full bg-fg-4\" />;\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/diff-review"
}
