{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "diff-view",
  "type": "registry:ui",
  "title": "Diff View",
  "description": "Code review for agent edits: a parsed unified diff with syntax and word highlights, line numbers, and per-hunk Accept / Reject with j/k/a/r keys.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "https://kuro.abstergo.fyi/r/kura-theme.json",
    "https://kuro.abstergo.fyi/r/button.json",
    "https://kuro.abstergo.fyi/r/code-block.json",
    "https://kuro.abstergo.fyi/r/icon-button.json",
    "https://kuro.abstergo.fyi/r/tooltip.json",
    "https://kuro.abstergo.fyi/r/use-controllable-state.json",
    "utils"
  ],
  "files": [
    {
      "path": "registry/kura/diff-view/diff-view.tsx",
      "type": "registry:ui",
      "target": "components/kura/diff-view/diff-view.tsx",
      "content": "\"use client\";\n\nimport { Check, ChevronRight, CircleSlash2, FileDiff, RotateCcw, X } from \"lucide-react\";\nimport * as React from \"react\";\nimport { useControllableState } from \"@/hooks/kura/use-controllable-state\";\nimport { cn } from \"@/lib/utils\";\nimport { Button } from \"@/components/kura/button\";\nimport { type CodeToken, type CodeTokenType, highlightCode } from \"@/components/kura/code-block\";\nimport { IconButton } from \"@/components/kura/icon-button\";\nimport { Tooltip } from \"@/components/kura/tooltip\";\nimport {\n  countChanges,\n  type DiffFile,\n  type DiffFileInput,\n  type DiffFileStatus,\n  type DiffHunk,\n  type DiffLine,\n  hunkId,\n  intraLineChanges,\n  languageFromPath,\n  normalizeFiles,\n  parseUnifiedDiff,\n  type Ranges,\n} from \"./parse-diff\";\n\nexport type HunkDecision = \"accepted\" | \"rejected\";\n/** Review state: hunk id → decision. Hunks that are absent are pending. */\nexport type DiffReview = Readonly<Record<string, HunkDecision>>;\n\nexport interface DiffReviewChange {\n  /** File the change happened in (the last file for \"Accept all\"). */\n  path: string;\n  /** Hunks whose decision changed. */\n  hunkIds: string[];\n  /** New decision; \"pending\" means the decision was undone. */\n  decision: HunkDecision | \"pending\";\n}\n\nexport interface DiffViewProps extends Omit<React.ComponentProps<\"div\">, \"title\" | \"children\"> {\n  /** A unified diff (`git diff`, `git show`, `diff -u`, or a model's patch). Ignored when `files` is given. */\n  diff?: string;\n  /** Structured files instead of a diff string. Hunk lines may be DiffLine objects or raw `\"+added\"` / `\"-removed\"` / `\" context\"` strings. */\n  files?: readonly DiffFileInput[];\n  /** Header title. Default \"Changes\". */\n  title?: React.ReactNode;\n  /** Show per-hunk Accept / Reject, per-file \"Accept file\", and the review tally. Default true. */\n  reviewable?: boolean;\n  /** Review decisions keyed by hunk id (controlled). Hunk ids default to `${path}#${index}`. */\n  review?: DiffReview;\n  /** Initial decisions when uncontrolled. Default {}. */\n  defaultReview?: DiffReview;\n  /** Called with the full next review state and what changed. */\n  onReviewChange?: (review: DiffReview, change: DiffReviewChange) => void;\n  /** Collapsed file paths (controlled). */\n  collapsed?: readonly string[];\n  /** Initially collapsed file paths when uncontrolled. Default []. */\n  defaultCollapsed?: readonly string[];\n  onCollapsedChange?: (collapsed: string[]) => void;\n  /** Collapse a file once every hunk in it has a decision. Default true. */\n  collapseReviewed?: boolean;\n  /** After a decision, move focus to the next pending hunk. Default true. */\n  autoAdvance?: boolean;\n  /** Soft-wrap long lines instead of scrolling sideways. Default false. */\n  wrap?: boolean;\n  /** Scroll the files inside this height (px); file headers stay pinned while scrolling. */\n  maxHeight?: number;\n  /** Extra controls at the end of the header (e.g. a \"Commit\" button). */\n  actions?: React.ReactNode;\n  /** Rendered when there are no files. Default \"No changes\". */\n  empty?: React.ReactNode;\n}\n\nconst tokenClass: Record<CodeTokenType, string | undefined> = {\n  plain: undefined,\n  keyword: \"text-(--kura-code-keyword)\",\n  string: \"text-(--kura-code-string)\",\n  comment: \"text-(--kura-code-comment)\",\n  number: \"text-(--kura-code-constant)\",\n  constant: \"text-(--kura-code-constant)\",\n  function: \"text-(--kura-code-function)\",\n  type: \"text-(--kura-code-type)\",\n  tag: \"text-(--kura-code-tag)\",\n  attr: \"text-(--kura-code-attr)\",\n  property: \"text-(--kura-code-property)\",\n  variable: \"text-(--kura-code-constant)\",\n  punctuation: \"text-(--kura-code-punctuation)\",\n  operator: \"text-(--kura-code-punctuation)\",\n};\n\n// Same quiet palette as CodeBlock (override with --kura-syntax-* on a parent).\nconst tint = (name: string, chroma: number, hue: number) =>\n  `var(--kura-syntax-${name}, oklch(from var(--kura-fg-muted) l ${chroma} ${hue}))`;\nconst syntaxVars = {\n  \"--kura-code-keyword\": \"var(--kura-syntax-keyword, var(--kura-fg-muted))\",\n  \"--kura-code-string\": tint(\"string\", 0.075, 150),\n  \"--kura-code-constant\": tint(\"constant\", 0.085, 55),\n  \"--kura-code-function\": \"var(--kura-syntax-function, var(--kura-fg))\",\n  \"--kura-code-type\": tint(\"type\", 0.045, 240),\n  \"--kura-code-tag\": \"var(--kura-syntax-tag, var(--kura-fg))\",\n  \"--kura-code-attr\": \"var(--kura-syntax-attr, var(--kura-fg-muted))\",\n  \"--kura-code-property\": \"var(--kura-syntax-property, var(--kura-fg))\",\n  \"--kura-code-comment\": \"var(--kura-syntax-comment, var(--kura-fg-subtle))\",\n  \"--kura-code-punctuation\": \"var(--kura-syntax-punctuation, var(--kura-fg-subtle))\",\n} as React.CSSProperties;\n\ninterface PreparedHunk {\n  id: string;\n  index: number;\n  hunk: DiffHunk;\n  tokens: CodeToken[][];\n  words: Map<number, Ranges>;\n}\n\ninterface PreparedFile {\n  file: DiffFile;\n  additions: number;\n  deletions: number;\n  digits: number;\n  hunks: PreparedHunk[];\n}\n\n/** Highlight a hunk as two coherent texts (old side, new side) so multi-line strings and comments colour correctly. */\nfunction highlightHunk(lines: readonly DiffLine[], language: string): CodeToken[][] {\n  const oldIdx: number[] = [];\n  const newIdx: number[] = [];\n  lines.forEach((l, i) => {\n    if (l.kind !== \"add\") oldIdx.push(i);\n    if (l.kind !== \"remove\") newIdx.push(i);\n  });\n  const out: CodeToken[][] = lines.map(() => []);\n  const oldTokens = highlightCode(oldIdx.map((i) => lines[i]!.content).join(\"\\n\"), language);\n  const newTokens = highlightCode(newIdx.map((i) => lines[i]!.content).join(\"\\n\"), language);\n  newIdx.forEach((i, k) => {\n    out[i] = newTokens[k] ?? [];\n  });\n  oldIdx.forEach((i, k) => {\n    if (lines[i]!.kind === \"remove\") out[i] = oldTokens[k] ?? [];\n  });\n  return out;\n}\n\n/** Split syntax tokens at word-change boundaries. */\ninterface Segment extends CodeToken {\n  changed: boolean;\n  /** Segment starts / ends a changed range (for rounded ends on a continuous mark). */\n  first?: boolean;\n  last?: boolean;\n}\n\nfunction segment(tokens: CodeToken[], ranges: Ranges | undefined): Segment[] {\n  if (!ranges?.length) return tokens.map((t) => ({ ...t, changed: false }));\n  const out: Segment[] = [];\n  let pos = 0;\n  for (const t of tokens) {\n    let start = 0;\n    while (start < t.text.length) {\n      const abs = pos + start;\n      const inside = ranges.find(([a, b]) => abs >= a && abs < b);\n      const nextEdge = inside\n        ? inside[1]\n        : Math.min(...ranges.map(([a]) => a).filter((a) => a > abs), pos + t.text.length);\n      const end = Math.min(t.text.length, nextEdge - pos);\n      out.push({\n        type: t.type,\n        text: t.text.slice(start, end),\n        changed: !!inside,\n        first: !!inside && abs === inside[0],\n        last: !!inside && pos + end === inside[1],\n      });\n      start = end;\n    }\n    pos += t.text.length;\n  }\n  return out;\n}\n\nfunction splitPath(path: string): [string, string] {\n  const slash = path.lastIndexOf(\"/\");\n  return slash >= 0 ? [path.slice(0, slash + 1), path.slice(slash + 1)] : [\"\", path];\n}\n\nconst STATUS_LABEL: Record<DiffFileStatus, string> = {\n  added: \"Added\",\n  modified: \"Modified\",\n  deleted: \"Deleted\",\n  renamed: \"Renamed\",\n};\n\n/** GitHub-style 5-block bar: green for additions, red for deletions, grey for the rest. */\nfunction RatioBar({ additions, deletions }: { additions: number; deletions: number }) {\n  const total = additions + deletions;\n  let add = total ? Math.round((additions / total) * 5) : 0;\n  let del = total ? Math.round((deletions / total) * 5) : 0;\n  if (additions > 0 && add === 0) add = 1;\n  if (deletions > 0 && del === 0) del = 1;\n  while (add + del > 5) add > del ? add-- : del--;\n  return (\n    <span aria-hidden data-slot=\"diff-view-ratio\" className=\"flex shrink-0 gap-[2px]\">\n      {Array.from({ length: 5 }, (_, i) => (\n        <span\n          key={i}\n          className={cn(\n            \"size-[7px] rounded-[2px]\",\n            i < add ? \"bg-kura-success\" : i < add + del ? \"bg-kura-danger\" : \"bg-kura-gray-5\",\n          )}\n        />\n      ))}\n    </span>\n  );\n}\n\nfunction Counts({ additions, deletions }: { additions: number; deletions: number }) {\n  return (\n    <span className=\"flex shrink-0 items-center gap-1.5 font-kura-mono text-[12px] tabular-nums\">\n      <span className=\"text-kura-success-text\">+{additions}</span>\n      <span className=\"text-kura-danger-text\">−{deletions}</span>\n      <span className=\"sr-only\">\n        {additions} additions, {deletions} deletions\n      </span>\n    </span>\n  );\n}\n\n/**\n * Review an agent's edits: parses unified diffs (or takes structured files) and renders them as\n * collapsible files with old/new line numbers, syntax highlighting and word-level change marks.\n * Each hunk can be accepted or rejected (j/k to move, a/r to decide, u to undo), each file\n * accepted at once, and the header keeps the running tally.\n */\nexport function DiffView({\n  diff,\n  files: filesProp,\n  title = \"Changes\",\n  reviewable = true,\n  review: reviewProp,\n  defaultReview,\n  onReviewChange,\n  collapsed: collapsedProp,\n  defaultCollapsed,\n  onCollapsedChange,\n  collapseReviewed = true,\n  autoAdvance = true,\n  wrap = false,\n  maxHeight,\n  actions,\n  empty,\n  className,\n  style,\n  onKeyDown,\n  ...props\n}: DiffViewProps) {\n  const files = React.useMemo(\n    () => (filesProp ? normalizeFiles(filesProp) : diff ? parseUnifiedDiff(diff) : []),\n    [filesProp, diff],\n  );\n\n  const prepared = React.useMemo<PreparedFile[]>(\n    () =>\n      files.map((file) => {\n        const language = file.language ?? languageFromPath(file.path);\n        const { additions, deletions } = countChanges([file]);\n        let maxLine = 0;\n        const hunks = file.hunks.map((hunk, index) => {\n          for (const l of hunk.lines)\n            maxLine = Math.max(maxLine, l.oldNumber ?? 0, l.newNumber ?? 0);\n          return {\n            id: hunkId(file, hunk, index),\n            index,\n            hunk,\n            tokens: highlightHunk(hunk.lines, language),\n            words: intraLineChanges(hunk.lines),\n          };\n        });\n        return { file, additions, deletions, digits: Math.max(2, String(maxLine).length), hunks };\n      }),\n    [files],\n  );\n\n  const allHunks = React.useMemo(\n    () => prepared.flatMap((p) => p.hunks.map((h) => ({ ...h, path: p.file.path }))),\n    [prepared],\n  );\n  const totals = React.useMemo(() => countChanges(files), [files]);\n\n  const [review, setReview] = useControllableState<DiffReview>({\n    value: reviewProp,\n    defaultValue: defaultReview ?? {},\n  });\n  const [collapsedList, setCollapsedList] = useControllableState<readonly string[]>({\n    value: collapsedProp,\n    defaultValue: defaultCollapsed ?? [],\n    onChange: onCollapsedChange as ((v: readonly string[]) => void) | undefined,\n  });\n  const collapsed = React.useMemo(() => new Set(collapsedList), [collapsedList]);\n\n  const reviewed = allHunks.filter((h) => review[h.id]).length;\n  const [announcement, setAnnouncement] = React.useState(\"\");\n\n  // Roving focus between hunks.\n  const [activeId, setActiveId] = React.useState<string | null>(null);\n  const visibleHunks = allHunks.filter((h) => !collapsed.has(h.path));\n  const tabStop =\n    activeId && visibleHunks.some((h) => h.id === activeId) ? activeId : visibleHunks[0]?.id;\n  const hunkEls = React.useRef(new Map<string, HTMLElement>());\n  const fileEls = React.useRef(new Map<string, HTMLElement>());\n  const focusRequest = React.useRef<{ kind: \"hunk\" | \"file\"; id: string } | null>(null);\n  React.useEffect(() => {\n    const req = focusRequest.current;\n    if (!req) return;\n    focusRequest.current = null;\n    const el = (req.kind === \"hunk\" ? hunkEls : fileEls).current.get(req.id);\n    if (!el) return;\n    el.focus({ preventScroll: true });\n    el.scrollIntoView?.({ block: \"nearest\" });\n  });\n\n  const focusHunk = (id: string) => {\n    setActiveId(id);\n    focusRequest.current = { kind: \"hunk\", id };\n    // Focus now as well when nothing needs to re-render first.\n    const el = hunkEls.current.get(id);\n    if (el) {\n      el.focus({ preventScroll: true });\n      el.scrollIntoView?.({ block: \"nearest\" });\n    }\n  };\n\n  const setFileCollapsed = (path: string, value: boolean) =>\n    setCollapsedList((prev) => {\n      const has = prev.includes(path);\n      if (value === has) return prev;\n      return value ? [...prev, path] : prev.filter((p) => p !== path);\n    });\n\n  const decide = (ids: string[], decision: HunkDecision | \"pending\", path: string) => {\n    const next: Record<string, HunkDecision> = { ...review };\n    let changed = false;\n    for (const id of ids) {\n      if (decision === \"pending\") {\n        if (id in next) {\n          delete next[id];\n          changed = true;\n        }\n      } else if (next[id] !== decision) {\n        next[id] = decision;\n        changed = true;\n      }\n    }\n    if (!changed) return null;\n    setReview(next);\n    onReviewChange?.(next, { path, hunkIds: ids, decision });\n    const done = allHunks.filter((h) => next[h.id]).length;\n    const verb =\n      decision === \"pending\" ? \"Undid\" : decision === \"accepted\" ? \"Accepted\" : \"Rejected\";\n    setAnnouncement(\n      `${verb} ${ids.length === 1 ? \"hunk\" : `${ids.length} hunks`} in ${path}. ${done} of ${allHunks.length} reviewed.`,\n    );\n    return next;\n  };\n\n  /** After a decision on `fromId`: collapse finished files and move on to the next pending hunk. */\n  const afterDecision = (next: DiffReview, fromId: string, path: string) => {\n    const fileHunks = allHunks.filter((h) => h.path === path);\n    const fileDone = fileHunks.every((h) => next[h.id]);\n    const willCollapse = collapseReviewed && fileDone;\n    if (willCollapse) setFileCollapsed(path, true);\n    if (!autoAdvance) {\n      if (willCollapse) focusRequest.current = { kind: \"file\", id: path };\n      return;\n    }\n    const start = allHunks.findIndex((h) => h.id === fromId);\n    const order = [...allHunks.slice(start + 1), ...allHunks.slice(0, start)];\n    const target = order.find(\n      (h) => !next[h.id] && (!collapsed.has(h.path) || h.path === path) && h.id !== fromId,\n    );\n    if (target) {\n      if (collapsed.has(target.path)) setFileCollapsed(target.path, false);\n      setActiveId(target.id);\n      focusRequest.current = { kind: \"hunk\", id: target.id };\n    } else if (willCollapse) {\n      focusRequest.current = { kind: \"file\", id: path };\n    }\n  };\n\n  const decideHunk = (id: string, path: string, decision: HunkDecision | \"pending\") => {\n    const next = decide([id], decision, path);\n    if (next && decision !== \"pending\") afterDecision(next, id, path);\n  };\n\n  const decideFile = (path: string, decision: HunkDecision) => {\n    const ids = allHunks.filter((h) => h.path === path).map((h) => h.id);\n    const next = decide(ids, decision, path);\n    if (next && collapseReviewed) {\n      setFileCollapsed(path, true);\n      focusRequest.current = { kind: \"file\", id: path };\n    }\n  };\n\n  const decideAll = (decision: HunkDecision) => {\n    const pending = allHunks.filter((h) => !review[h.id]);\n    if (!pending.length) return;\n    decide(\n      pending.map((h) => h.id),\n      decision,\n      pending[pending.length - 1]!.path,\n    );\n  };\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    onKeyDown?.(event);\n    if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) return;\n    const target = event.target as HTMLElement;\n    if (target.closest(\"input, textarea, select, [contenteditable='true']\")) return;\n    const key = event.key.toLowerCase();\n    const current = visibleHunks.find((h) => h.id === tabStop);\n    const index = current ? visibleHunks.indexOf(current) : -1;\n    if (key === \"j\" || key === \"k\") {\n      const next = visibleHunks[key === \"j\" ? index + 1 : index - 1];\n      event.preventDefault();\n      if (next) focusHunk(next.id);\n      else if (index === -1 && visibleHunks[0]) focusHunk(visibleHunks[0].id);\n      return;\n    }\n    if (!reviewable || !current) return;\n    // Decisions apply to the hunk that holds focus (not, say, a file header).\n    const inHunk = target.closest(\"[data-slot=diff-view-hunk]\");\n    if (!inHunk || inHunk.getAttribute(\"data-hunk-id\") !== current.id) return;\n    if (key === \"a\" || key === \"r\") {\n      event.preventDefault();\n      decideHunk(current.id, current.path, key === \"a\" ? \"accepted\" : \"rejected\");\n    } else if (key === \"u\" || key === \"backspace\") {\n      event.preventDefault();\n      decideHunk(current.id, current.path, \"pending\");\n    }\n  };\n\n  const pendingCount = allHunks.length - reviewed;\n\n  return (\n    // biome-ignore lint/a11y/noStaticElementInteractions: delegates j/k/a/r/u from the focused hunk (the hunks are the focus targets)\n    <div\n      data-slot=\"diff-view\"\n      data-reviewable={reviewable || undefined}\n      data-state={allHunks.length === 0 ? \"empty\" : pendingCount === 0 ? \"reviewed\" : \"pending\"}\n      className={cn(\n        \"@container/diff flex min-w-0 flex-col overflow-clip rounded-kura-xl bg-kura-surface font-kura-sans text-[13px] text-kura-fg shadow-kura-sm\",\n        className,\n      )}\n      style={{ ...syntaxVars, ...style }}\n      onKeyDown={handleKeyDown}\n      {...props}\n    >\n      <div\n        data-slot=\"diff-view-header\"\n        className=\"flex h-10 shrink-0 items-center gap-3 border-kura-border-subtle border-b pr-2 pl-3.5\"\n      >\n        <span className=\"flex min-w-0 items-baseline gap-2.5\">\n          <span className=\"min-w-[3rem] truncate font-medium tracking-[-0.006em]\">{title}</span>\n          <span\n            data-slot=\"diff-view-summary\"\n            className=\"flex shrink-0 items-baseline gap-2.5 font-kura-mono text-[12px] text-kura-fg-subtle tabular-nums\"\n          >\n            <span className=\"hidden @[24rem]/diff:inline\">\n              {files.length} {files.length === 1 ? \"file\" : \"files\"}\n            </span>\n            {files.length > 0 && (\n              <Counts additions={totals.additions} deletions={totals.deletions} />\n            )}\n          </span>\n        </span>\n        <span className=\"flex-1\" />\n        {reviewable && allHunks.length > 0 && (\n          <span\n            data-slot=\"diff-view-progress\"\n            className=\"hidden shrink-0 items-center gap-2.5 @[34rem]/diff:flex\"\n          >\n            <span className=\"font-kura-mono text-[12px] text-kura-fg-subtle tabular-nums\">\n              {reviewed} of {allHunks.length} reviewed\n            </span>\n            <span aria-hidden className=\"flex w-20 gap-[2px]\">\n              {allHunks.map((h) => (\n                <span\n                  key={h.id}\n                  className={cn(\n                    \"h-1 min-w-0 flex-1 rounded-full transition-colors duration-(--kura-duration-base) ease-kura-out motion-reduce:transition-none\",\n                    review[h.id] === \"accepted\"\n                      ? \"bg-kura-success\"\n                      : review[h.id] === \"rejected\"\n                        ? \"bg-kura-danger\"\n                        : \"bg-kura-gray-5\",\n                  )}\n                />\n              ))}\n            </span>\n          </span>\n        )}\n        {(actions || (reviewable && pendingCount > 0)) && (\n          <span className=\"flex shrink-0 items-center gap-1\">\n            {reviewable && pendingCount > 0 && (\n              <>\n                <Button\n                  size=\"xs\"\n                  variant=\"ghost\"\n                  data-slot=\"diff-view-reject-all\"\n                  onClick={() => decideAll(\"rejected\")}\n                  className=\"hidden @[26rem]/diff:inline-flex\"\n                >\n                  Reject all\n                </Button>\n                <Button\n                  size=\"xs\"\n                  variant=\"primary\"\n                  data-slot=\"diff-view-accept-all\"\n                  onClick={() => decideAll(\"accepted\")}\n                >\n                  Accept all\n                </Button>\n              </>\n            )}\n            {actions}\n          </span>\n        )}\n      </div>\n\n      <div\n        data-slot=\"diff-view-body\"\n        className={cn(\n          \"min-h-0 flex-1\",\n          maxHeight != null && \"overflow-y-auto overscroll-contain [scrollbar-width:thin]\",\n        )}\n        style={maxHeight != null ? { maxHeight } : undefined}\n      >\n        {prepared.length === 0 && (\n          <div\n            data-slot=\"diff-view-empty\"\n            className=\"flex h-24 flex-col items-center justify-center gap-1.5 text-kura-fg-subtle\"\n          >\n            {empty ?? (\n              <>\n                <FileDiff aria-hidden className=\"size-4\" strokeWidth={1.75} />\n                <span>No changes</span>\n              </>\n            )}\n          </div>\n        )}\n        {prepared.map((p, fileIndex) => {\n          const { file } = p;\n          const open = !collapsed.has(file.path);\n          const fileReviewed = p.hunks.filter((h) => review[h.id]).length;\n          const filePending = p.hunks.length - fileReviewed;\n          const allAccepted =\n            p.hunks.length > 0 && p.hunks.every((h) => review[h.id] === \"accepted\");\n          const allRejected =\n            p.hunks.length > 0 && p.hunks.every((h) => review[h.id] === \"rejected\");\n          const [dir, base] = splitPath(file.path);\n          const bodyId = `diff-${fileIndex}-${base.replace(/[^\\w-]/g, \"\")}`;\n          return (\n            <section\n              key={`${file.oldPath ?? \"\"}→${file.path}`}\n              data-slot=\"diff-view-file\"\n              data-status={file.status}\n              data-state={open ? \"open\" : \"closed\"}\n              aria-label={file.path}\n              className={cn(fileIndex > 0 && \"border-kura-border-subtle border-t\")}\n            >\n              <div\n                data-slot=\"diff-view-file-header\"\n                className={cn(\n                  \"sticky top-0 z-[2] flex h-10 items-center gap-2 bg-kura-surface pr-2\",\n                  open && \"shadow-[inset_0_-1px_0_var(--kura-border-subtle)]\",\n                )}\n              >\n                <button\n                  type=\"button\"\n                  ref={(el) => {\n                    if (el) fileEls.current.set(file.path, el);\n                    else fileEls.current.delete(file.path);\n                  }}\n                  aria-expanded={open}\n                  aria-controls={bodyId}\n                  onClick={() => setFileCollapsed(file.path, open)}\n                  className=\"flex h-full min-w-0 flex-1 cursor-pointer items-center gap-2 pl-3 text-left outline-none transition-colors duration-(--kura-duration-fast) ease-kura-out hover:bg-kura-hover focus-visible:shadow-[inset_0_0_0_2px_var(--kura-ring)] motion-reduce:transition-none\"\n                >\n                  <ChevronRight\n                    aria-hidden\n                    strokeWidth={1.75}\n                    className={cn(\n                      \"size-3.5 shrink-0 text-kura-fg-subtle transition-transform duration-(--kura-duration-base) ease-kura-out motion-reduce:transition-none\",\n                      open && \"rotate-90\",\n                    )}\n                  />\n                  <span\n                    data-slot=\"diff-view-path\"\n                    className=\"flex min-w-0 items-baseline font-kura-mono text-[12.5px]\"\n                  >\n                    {file.status === \"renamed\" && file.oldPath && (\n                      <>\n                        <span className=\"min-w-0 truncate text-kura-fg-subtle\">{file.oldPath}</span>\n                        <span aria-hidden className=\"shrink-0 px-1.5 text-kura-fg-subtle\">\n                          →\n                        </span>\n                        <span className=\"sr-only\"> renamed to </span>\n                      </>\n                    )}\n                    {/* The directory truncates first (from the left); the file name keeps priority. */}\n                    {!(file.status === \"renamed\" && file.oldPath) && dir && (\n                      <span className=\"hidden min-w-0 shrink-[999] truncate text-kura-fg-subtle [direction:rtl] @[24rem]/diff:inline\">\n                        <span className=\"[direction:ltr] [unicode-bidi:isolate]\">{dir}</span>\n                      </span>\n                    )}\n                    <span\n                      className={cn(\n                        \"min-w-0 truncate font-medium text-kura-fg\",\n                        file.status === \"deleted\" &&\n                          \"text-kura-fg-muted line-through decoration-kura-fg-subtle/60\",\n                      )}\n                    >\n                      {file.status === \"renamed\" && file.oldPath ? file.path : base}\n                    </span>\n                  </span>\n                  {file.status !== \"modified\" && (\n                    <span\n                      data-slot=\"diff-view-file-status\"\n                      className={cn(\n                        \"inline-flex h-[18px] shrink-0 items-center rounded-kura-xs px-1.5 font-medium text-[11px]\",\n                        file.status === \"added\" && \"bg-kura-success/12 text-kura-success-text\",\n                        file.status === \"deleted\" && \"bg-kura-danger/10 text-kura-danger-text\",\n                        file.status === \"renamed\" && \"bg-kura-hover text-kura-fg-muted\",\n                      )}\n                    >\n                      {STATUS_LABEL[file.status]}\n                    </span>\n                  )}\n                  <span className=\"ml-auto flex shrink-0 items-center gap-2.5 pl-2\">\n                    {file.binary ? (\n                      <span className=\"font-kura-mono text-[12px] text-kura-fg-subtle\">binary</span>\n                    ) : (\n                      <>\n                        <Counts additions={p.additions} deletions={p.deletions} />\n                        <span className=\"hidden @[30rem]/diff:flex\">\n                          <RatioBar additions={p.additions} deletions={p.deletions} />\n                        </span>\n                      </>\n                    )}\n                  </span>\n                </button>\n                {reviewable && p.hunks.length > 0 && (\n                  <span\n                    data-slot=\"diff-view-file-review\"\n                    className=\"flex h-6 shrink-0 items-center justify-end @[30rem]/diff:w-[6.75rem]\"\n                  >\n                    {filePending > 0 ? (\n                      <Button\n                        size=\"xs\"\n                        variant=\"outline\"\n                        onClick={() => decideFile(file.path, \"accepted\")}\n                        aria-label={`Accept all changes in ${file.path}`}\n                      >\n                        <Check aria-hidden strokeWidth={2} />\n                        <span className=\"hidden @[30rem]/diff:inline\">Accept file</span>\n                      </Button>\n                    ) : (\n                      <span\n                        className={cn(\n                          \"inline-flex items-center gap-1 pr-1.5 font-medium text-[12px] animate-kura-fade-in\",\n                          allAccepted\n                            ? \"text-kura-success-text\"\n                            : allRejected\n                              ? \"text-kura-danger-text\"\n                              : \"text-kura-fg-muted\",\n                        )}\n                      >\n                        {allRejected ? (\n                          <X aria-hidden className=\"size-3.5\" strokeWidth={2} />\n                        ) : (\n                          <Check aria-hidden className=\"size-3.5\" strokeWidth={2} />\n                        )}\n                        <span className=\"hidden @[30rem]/diff:inline\">\n                          {allAccepted ? \"Accepted\" : allRejected ? \"Rejected\" : \"Reviewed\"}\n                        </span>\n                      </span>\n                    )}\n                  </span>\n                )}\n              </div>\n\n              <div id={bodyId} data-slot=\"diff-view-file-body\" hidden={!open}>\n                {open && (\n                  <FileBody\n                    prepared={p}\n                    review={review}\n                    reviewable={reviewable}\n                    wrap={wrap}\n                    tabStop={tabStop}\n                    total={allHunks.length}\n                    ordinal={(id) => allHunks.findIndex((h) => h.id === id) + 1}\n                    registerHunk={(id, el) => {\n                      if (el) hunkEls.current.set(id, el);\n                      else hunkEls.current.delete(id);\n                    }}\n                    onFocusHunk={setActiveId}\n                    onDecide={(id, d) => decideHunk(id, file.path, d)}\n                  />\n                )}\n              </div>\n            </section>\n          );\n        })}\n      </div>\n      <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n        {announcement}\n      </span>\n    </div>\n  );\n}\n\ninterface FileBodyProps {\n  prepared: PreparedFile;\n  review: DiffReview;\n  reviewable: boolean;\n  wrap: boolean;\n  tabStop: string | undefined;\n  total: number;\n  ordinal: (id: string) => number;\n  registerHunk: (id: string, el: HTMLElement | null) => void;\n  onFocusHunk: (id: string) => void;\n  onDecide: (id: string, decision: HunkDecision | \"pending\") => void;\n}\n\nfunction FileBody({\n  prepared,\n  review,\n  reviewable,\n  wrap,\n  tabStop,\n  total,\n  ordinal,\n  registerHunk,\n  onFocusHunk,\n  onDecide,\n}: FileBodyProps) {\n  const { file, hunks, digits } = prepared;\n  if (file.binary || hunks.length === 0) {\n    const note = file.binary\n      ? \"Binary file not shown.\"\n      : file.status === \"renamed\"\n        ? \"File renamed without content changes.\"\n        : file.status === \"deleted\"\n          ? \"Empty file deleted.\"\n          : file.status === \"added\"\n            ? \"Empty file added.\"\n            : \"No content changes.\";\n    return (\n      <div className=\"flex h-10 items-center bg-kura-surface-sunken px-3.5 text-kura-fg-subtle\">\n        {note}\n      </div>\n    );\n  }\n  const gutter = `calc(${digits}ch + 1.25rem)`;\n  return (\n    <div className=\"flex flex-col font-kura-mono text-[12.5px] leading-5\">\n      {hunks.map((h, k) => {\n        const decision = review[h.id];\n        const active = tabStop === h.id;\n        const range =\n          h.hunk.newLines > 0\n            ? `lines ${h.hunk.newStart}–${h.hunk.newStart + h.hunk.newLines - 1}`\n            : `at line ${h.hunk.oldStart}`;\n        return (\n          // biome-ignore lint/a11y/useSemanticElements: a hunk is a focusable review region (roving tabindex), not a form fieldset\n          <div\n            key={h.id}\n            ref={(el) => registerHunk(h.id, el)}\n            data-slot=\"diff-view-hunk\"\n            data-hunk-id={h.id}\n            data-review={decision ?? \"pending\"}\n            data-active={active || undefined}\n            role=\"group\"\n            aria-label={`Change ${ordinal(h.id)} of ${total}, ${file.path} ${range}, ${decision ?? \"pending review\"}`}\n            tabIndex={active ? 0 : -1}\n            onFocus={() => onFocusHunk(h.id)}\n            className=\"group/hunk relative scroll-mt-10 outline-none\"\n          >\n            <span\n              aria-hidden\n              className=\"pointer-events-none absolute inset-y-0 left-0 z-[1] w-0.5 bg-kura-accent opacity-0 transition-opacity duration-(--kura-duration-fast) ease-kura-out group-focus-within/hunk:opacity-100 motion-reduce:transition-none\"\n            />\n            <div\n              data-slot=\"diff-view-hunk-header\"\n              className={cn(\n                \"flex h-8 items-center gap-3 bg-kura-surface-sunken pr-1.5 font-kura-mono text-[12px] text-kura-fg-subtle transition-colors duration-(--kura-duration-fast) ease-kura-out group-focus-within/hunk:bg-kura-accent-subtle/60 motion-reduce:transition-none\",\n                k > 0 && \"border-kura-border-subtle border-t\",\n              )}\n            >\n              <span className=\"flex min-w-0 flex-1 items-baseline gap-2.5 pl-3.5\">\n                <span className=\"min-w-0 truncate tabular-nums @[30rem]/diff:shrink-0 @[30rem]/diff:overflow-visible\">\n                  @@ −{h.hunk.oldStart},{h.hunk.oldLines} +{h.hunk.newStart},{h.hunk.newLines} @@\n                </span>\n                {h.hunk.section && (\n                  <span className=\"hidden min-w-0 truncate text-kura-fg-muted @[30rem]/diff:inline\">\n                    {h.hunk.section}\n                  </span>\n                )}\n              </span>\n              {reviewable && (\n                <span\n                  data-slot=\"diff-view-hunk-actions\"\n                  className=\"flex shrink-0 items-center gap-1 font-kura-sans\"\n                >\n                  {decision ? (\n                    <>\n                      <span\n                        data-slot=\"diff-view-hunk-state\"\n                        className={cn(\n                          \"inline-flex items-center gap-1 px-1 font-medium text-[12px] animate-kura-fade-in\",\n                          decision === \"accepted\"\n                            ? \"text-kura-success-text\"\n                            : \"text-kura-danger-text\",\n                        )}\n                      >\n                        {decision === \"accepted\" ? (\n                          <Check aria-hidden className=\"size-3.5\" strokeWidth={2} />\n                        ) : (\n                          <X aria-hidden className=\"size-3.5\" strokeWidth={2} />\n                        )}\n                        {decision === \"accepted\" ? \"Accepted\" : \"Rejected\"}\n                      </span>\n                      <IconButton\n                        size=\"xs\"\n                        label=\"Undo decision\"\n                        shortcut={[\"u\"]}\n                        icon={<RotateCcw strokeWidth={1.75} />}\n                        tabIndex={active ? 0 : -1}\n                        onClick={() => onDecide(h.id, \"pending\")}\n                      />\n                    </>\n                  ) : (\n                    <>\n                      <Tooltip content=\"Reject change\" shortcut={[\"r\"]}>\n                        <Button\n                          size=\"xs\"\n                          variant=\"ghost\"\n                          aria-label=\"Reject change\"\n                          tabIndex={active ? 0 : -1}\n                          onClick={() => onDecide(h.id, \"rejected\")}\n                        >\n                          <X aria-hidden strokeWidth={2} />\n                          <span className=\"hidden @[30rem]/diff:inline\">Reject</span>\n                        </Button>\n                      </Tooltip>\n                      <Tooltip content=\"Accept change\" shortcut={[\"a\"]}>\n                        <Button\n                          size=\"xs\"\n                          variant=\"secondary\"\n                          aria-label=\"Accept change\"\n                          tabIndex={active ? 0 : -1}\n                          onClick={() => onDecide(h.id, \"accepted\")}\n                        >\n                          <Check aria-hidden strokeWidth={2} />\n                          <span className=\"hidden @[30rem]/diff:inline\">Accept</span>\n                        </Button>\n                      </Tooltip>\n                    </>\n                  )}\n                </span>\n              )}\n            </div>\n            <div\n              data-slot=\"diff-view-lines\"\n              className={cn(\n                \"overflow-x-auto transition-opacity duration-(--kura-duration-base) ease-kura-out [scrollbar-width:thin] motion-reduce:transition-none\",\n                decision === \"accepted\" && \"opacity-55\",\n                decision === \"rejected\" && \"opacity-45\",\n              )}\n            >\n              <div className={wrap ? \"w-full\" : \"w-max min-w-full\"}>\n                {h.hunk.lines.map((line, i) => (\n                  <Line\n                    key={i}\n                    line={line}\n                    tokens={h.tokens[i] ?? []}\n                    ranges={h.words.get(i)}\n                    gutter={gutter}\n                    wrap={wrap}\n                    struck={decision === \"rejected\" && line.kind === \"add\"}\n                  />\n                ))}\n              </div>\n            </div>\n          </div>\n        );\n      })}\n    </div>\n  );\n}\n\nconst Line = React.memo(function Line({\n  line,\n  tokens,\n  ranges,\n  gutter,\n  wrap,\n  struck,\n}: {\n  line: DiffLine;\n  tokens: CodeToken[];\n  ranges: Ranges | undefined;\n  gutter: string;\n  wrap: boolean;\n  struck: boolean;\n}) {\n  const add = line.kind === \"add\";\n  const remove = line.kind === \"remove\";\n  const parts = segment(tokens, ranges);\n  return (\n    <div\n      data-slot=\"diff-view-line\"\n      data-kind={line.kind}\n      className={cn(\"flex\", add && \"bg-kura-success/[0.08]\", remove && \"bg-kura-danger/[0.07]\")}\n    >\n      <span\n        aria-hidden\n        className={cn(\n          \"shrink-0 select-none pr-2 text-right text-kura-fg-subtle/70 tabular-nums\",\n          add && \"bg-kura-success/[0.1] text-kura-success-text/70\",\n          remove && \"bg-kura-danger/[0.09] text-kura-danger-text/70\",\n        )}\n        style={{ width: gutter }}\n      >\n        {line.oldNumber}\n      </span>\n      <span\n        aria-hidden\n        className={cn(\n          \"shrink-0 select-none pr-2 text-right text-kura-fg-subtle/70 tabular-nums\",\n          add && \"bg-kura-success/[0.1] text-kura-success-text/70\",\n          remove && \"bg-kura-danger/[0.09] text-kura-danger-text/70\",\n        )}\n        style={{ width: gutter }}\n      >\n        {line.newNumber}\n      </span>\n      <span\n        aria-hidden\n        className={cn(\n          \"w-6 shrink-0 select-none text-center\",\n          add && \"text-kura-success-text\",\n          remove && \"text-kura-danger-text\",\n        )}\n      >\n        {add ? \"+\" : remove ? \"−\" : \"\"}\n      </span>\n      {(add || remove) && <span className=\"sr-only\">{add ? \"added: \" : \"removed: \"}</span>}\n      <span\n        className={cn(\n          \"min-w-0 flex-1 pr-4\",\n          wrap ? \"whitespace-pre-wrap [overflow-wrap:anywhere]\" : \"whitespace-pre\",\n          struck && \"line-through decoration-kura-fg-subtle/70\",\n        )}\n      >\n        {parts.length === 0\n          ? \"​\"\n          : parts.map((t, i) => (\n              <span\n                key={i}\n                className={cn(\n                  tokenClass[t.type],\n                  t.first && \"rounded-l-[3px]\",\n                  t.last && \"rounded-r-[3px]\",\n                  t.changed && add && \"bg-kura-success/25\",\n                  t.changed && remove && \"bg-kura-danger/20\",\n                )}\n              >\n                {t.text}\n              </span>\n            ))}\n        {line.noNewline && (\n          <span\n            className=\"ml-2 select-none font-kura-sans text-[11px] text-kura-fg-subtle\"\n            title=\"No newline at end of file\"\n          >\n            <CircleSlash2\n              aria-hidden\n              className=\"inline size-3 -translate-y-px\"\n              strokeWidth={1.75}\n            />\n            <span className=\"sr-only\">No newline at end of file</span>\n          </span>\n        )}\n      </span>\n    </div>\n  );\n});\n\nexport type {\n  DiffFile,\n  DiffFileInput,\n  DiffFileStatus,\n  DiffHunk,\n  DiffHunkInput,\n  DiffLine,\n  DiffLineKind,\n} from \"./parse-diff\";\nexport {\n  countChanges,\n  hunkId,\n  languageFromPath,\n  normalizeFiles,\n  parseUnifiedDiff,\n  wordDiff,\n} from \"./parse-diff\";\n"
    },
    {
      "path": "registry/kura/diff-view/index.ts",
      "type": "registry:ui",
      "target": "components/kura/diff-view/index.ts",
      "content": "export {\n  countChanges,\n  type DiffFile,\n  type DiffFileInput,\n  type DiffFileStatus,\n  type DiffHunk,\n  type DiffHunkInput,\n  type DiffLine,\n  type DiffLineKind,\n  type DiffReview,\n  type DiffReviewChange,\n  DiffView,\n  type DiffViewProps,\n  type HunkDecision,\n  hunkId,\n  languageFromPath,\n  normalizeFiles,\n  parseUnifiedDiff,\n  wordDiff,\n} from \"./diff-view\";\n"
    },
    {
      "path": "registry/kura/diff-view/parse-diff.ts",
      "type": "registry:ui",
      "target": "components/kura/diff-view/parse-diff.ts",
      "content": "/**\n * Unified-diff parsing and line pairing for DiffView. Pure functions, no React, safe anywhere.\n *\n * The parser is count-driven (it trusts the `@@ -a,b +c,d @@` header to know where a hunk\n * ends, so a removed line such as `-- comment` or `--- a/x` inside a hunk is never mistaken for a\n * file header) but forgiving: agents often emit hunks whose counts are wrong, so a hunk also ends\n * at anything that cannot be a hunk line, and a short hunk simply stops early.\n */\n\nexport type DiffFileStatus = \"added\" | \"modified\" | \"deleted\" | \"renamed\";\nexport type DiffLineKind = \"context\" | \"add\" | \"remove\";\n\nexport interface DiffLine {\n  kind: DiffLineKind;\n  /** The line's text without its `+` / `-` / space prefix. */\n  content: string;\n  /** Line number in the old file (absent for additions). */\n  oldNumber?: number;\n  /** Line number in the new file (absent for removals). */\n  newNumber?: number;\n  /** The diff said `\\ No newline at end of file` after this line. */\n  noNewline?: boolean;\n}\n\nexport interface DiffHunk {\n  /** Stable id for review state. Default `${path}#${index}`. */\n  id?: string;\n  oldStart: number;\n  oldLines: number;\n  newStart: number;\n  newLines: number;\n  /** Section heading git prints after the second `@@` (usually the enclosing function). */\n  section?: string;\n  lines: DiffLine[];\n}\n\nexport interface DiffFile {\n  /** Path after the change (the old path for deletions). */\n  path: string;\n  /** Path before a rename or copy. */\n  oldPath?: string;\n  status: DiffFileStatus;\n  /** Highlighting language. Default: from the file extension. */\n  language?: string;\n  /** Binary change: no hunks are shown. */\n  binary?: boolean;\n  hunks: DiffHunk[];\n}\n\n/** Structured input: like DiffHunk, but positions are optional and lines may be raw diff strings (`\"+added\"`, `\"-removed\"`, `\" context\"`). */\nexport interface DiffHunkInput {\n  id?: string;\n  oldStart?: number;\n  newStart?: number;\n  section?: string;\n  lines: ReadonlyArray<string | DiffLine>;\n}\n\nexport interface DiffFileInput {\n  path: string;\n  oldPath?: string;\n  status?: DiffFileStatus;\n  language?: string;\n  binary?: boolean;\n  hunks: readonly DiffHunkInput[];\n}\n\nconst HUNK_RE = /^@@+ -(\\d+)(?:,(\\d+))? \\+(\\d+)(?:,(\\d+))? @@+ ?(.*)$/;\n\n/** Undo git's C-style quoting: `\"a/caf\\303\\251 menu.ts\"` → `a/café menu.ts`. */\nfunction unquote(path: string): string {\n  if (!(path.length >= 2 && path.startsWith('\"') && path.endsWith('\"'))) return path;\n  const body = path.slice(1, -1);\n  const bytes: number[] = [];\n  const escapes: Record<string, number> = { n: 10, t: 9, r: 13, '\"': 34, \"\\\\\": 92, a: 7, b: 8 };\n  for (let i = 0; i < body.length; i++) {\n    const ch = body[i]!;\n    if (ch !== \"\\\\\") {\n      for (const b of new TextEncoder().encode(ch)) bytes.push(b);\n      continue;\n    }\n    const next = body[i + 1] ?? \"\";\n    const octal = /^[0-7]{3}/.exec(body.slice(i + 1));\n    if (octal) {\n      bytes.push(Number.parseInt(octal[0], 8));\n      i += 3;\n    } else if (next in escapes) {\n      bytes.push(escapes[next]!);\n      i += 1;\n    } else {\n      bytes.push(92);\n    }\n  }\n  return new TextDecoder().decode(new Uint8Array(bytes));\n}\n\n/** Path from a `---` / `+++` line: drops the trailing tab + timestamp and quoting. */\nfunction headerPath(raw: string): string {\n  let p = raw.trim();\n  if (p.startsWith('\"')) {\n    let j = 1;\n    while (j < p.length && !(p[j] === '\"' && p[j - 1] !== \"\\\\\")) j++;\n    p = unquote(p.slice(0, j + 1));\n  } else {\n    const tab = p.indexOf(\"\\t\");\n    if (tab >= 0) p = p.slice(0, tab);\n    // GNU diff without a tab: \"file 2024-01-01 10:00:00.000000000 +0000\"\n    p = p.replace(/\\s+\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)? [+-]\\d{4}$/, \"\");\n  }\n  return p;\n}\n\nconst stripPrefix = (p: string, prefix: \"a/\" | \"b/\") => (p.startsWith(prefix) ? p.slice(2) : p);\n\n/** Paths from `diff --git a/x b/y`, including quoted names and names that contain spaces. */\nfunction gitHeaderPaths(rest: string): [string, string] | null {\n  if (rest.startsWith('\"')) {\n    let j = 1;\n    while (j < rest.length && !(rest[j] === '\"' && rest[j - 1] !== \"\\\\\")) j++;\n    const a = unquote(rest.slice(0, j + 1));\n    const b = headerPath(rest.slice(j + 2));\n    return [stripPrefix(a, \"a/\"), stripPrefix(unquote(b), \"b/\")];\n  }\n  // Same name on both sides (the overwhelmingly common case): split exactly in half.\n  const half = (rest.length - 1) / 2;\n  if (Number.isInteger(half) && rest[half] === \" \") {\n    const a = rest.slice(0, half);\n    const b = rest.slice(half + 1);\n    if (stripPrefix(a, \"a/\") === stripPrefix(b, \"b/\"))\n      return [stripPrefix(a, \"a/\"), stripPrefix(b, \"b/\")];\n  }\n  const split = rest.lastIndexOf(\" b/\");\n  if (split > 0) return [stripPrefix(rest.slice(0, split), \"a/\"), rest.slice(split + 3)];\n  const space = rest.indexOf(\" \");\n  return space > 0 ? [rest.slice(0, space), rest.slice(space + 1)] : null;\n}\n\ninterface Draft {\n  oldPath: string | null;\n  newPath: string | null;\n  added: boolean;\n  deleted: boolean;\n  renamed: boolean;\n  binary: boolean;\n  hunks: DiffHunk[];\n}\n\nfunction finish(d: Draft): DiffFile | null {\n  const oldP = d.oldPath === \"/dev/null\" ? null : d.oldPath;\n  const newP = d.newPath === \"/dev/null\" ? null : d.newPath;\n  const added = d.added || (d.oldPath === \"/dev/null\" && newP != null);\n  const deleted = d.deleted || (d.newPath === \"/dev/null\" && oldP != null);\n  const path = (deleted ? oldP : newP) ?? oldP ?? newP;\n  if (!path) return null;\n  const renamed = !added && !deleted && oldP != null && newP != null && oldP !== newP;\n  const file: DiffFile = {\n    path,\n    status: added ? \"added\" : deleted ? \"deleted\" : renamed || d.renamed ? \"renamed\" : \"modified\",\n    hunks: d.hunks,\n  };\n  if (renamed && oldP) file.oldPath = oldP;\n  if (d.binary) file.binary = true;\n  return file;\n}\n\n/**\n * Parse a unified diff (`git diff`, `git show`, `diff -u`, or an agent's patch) into files and\n * hunks with old/new line numbers. Ignores commit headers and other noise; never throws.\n */\nexport function parseUnifiedDiff(input: string): DiffFile[] {\n  const lines = input.replace(/\\r\\n?/g, \"\\n\").split(\"\\n\");\n  const files: DiffFile[] = [];\n  let cur: Draft | null = null;\n  const fresh = (): Draft => ({\n    oldPath: null,\n    newPath: null,\n    added: false,\n    deleted: false,\n    renamed: false,\n    binary: false,\n    hunks: [],\n  });\n  const flush = () => {\n    if (cur) {\n      const f = finish(cur);\n      if (f) files.push(f);\n    }\n    cur = null;\n  };\n\n  let i = 0;\n  while (i < lines.length) {\n    const line = lines[i]!;\n\n    if (line.startsWith(\"diff --git \") || line.startsWith(\"diff --cc \")) {\n      flush();\n      cur = fresh();\n      const paths = gitHeaderPaths(line.slice(line.indexOf(\" \", 5) + 1));\n      if (paths) [cur.oldPath, cur.newPath] = paths;\n      i++;\n      continue;\n    }\n\n    // A `---` immediately followed by `+++` is a file header. Outside `diff --git` blocks (plain\n    // `diff -u` output, or several patches pasted together) it starts a new file.\n    if (line.startsWith(\"--- \") && lines[i + 1]?.startsWith(\"+++ \")) {\n      // Inside a `diff --git` block that has no hunks yet, this header belongs to it.\n      if (!cur || (cur as Draft).hunks.length > 0) {\n        flush();\n        cur = fresh();\n      }\n      const d = cur as Draft;\n      const oldP = headerPath(line.slice(4));\n      const newP = headerPath(lines[i + 1]!.slice(4));\n      d.oldPath = oldP === \"/dev/null\" ? oldP : stripPrefix(oldP, \"a/\");\n      d.newPath = newP === \"/dev/null\" ? newP : stripPrefix(newP, \"b/\");\n      i += 2;\n      continue;\n    }\n\n    const hunk = HUNK_RE.exec(line);\n    if (hunk) {\n      if (!cur) cur = fresh();\n      const h: DiffHunk = {\n        oldStart: Number(hunk[1]),\n        oldLines: hunk[2] === undefined ? 1 : Number(hunk[2]),\n        newStart: Number(hunk[3]),\n        newLines: hunk[4] === undefined ? 1 : Number(hunk[4]),\n        lines: [],\n      };\n      const section = hunk[5]?.trim();\n      if (section) h.section = section;\n      let oldLeft = h.oldLines;\n      let newLeft = h.newLines;\n      let oldNo = h.oldStart;\n      let newNo = h.newStart;\n      i++;\n      while (i < lines.length) {\n        const l = lines[i]!;\n        const c = l[0];\n        if (c === \"\\\\\") {\n          const last = h.lines[h.lines.length - 1];\n          if (last) last.noNewline = true;\n          i++;\n          continue;\n        }\n        if (oldLeft <= 0 && newLeft <= 0) break;\n        // Counts may be wrong in hand-written or model-written patches: never swallow the next\n        // file's header or hunk.\n        if (l.startsWith(\"diff --git \") || HUNK_RE.test(l)) break;\n        if (\n          l.startsWith(\"--- \") &&\n          lines[i + 1]?.startsWith(\"+++ \") &&\n          HUNK_RE.test(lines[i + 2] ?? \"\")\n        )\n          break;\n        if (c === \" \" || l === \"\") {\n          // A blank line is a context line whose trailing space was stripped by an editor —\n          // unless it is the very end of the input.\n          if (l === \"\" && i === lines.length - 1) break;\n          h.lines.push({\n            kind: \"context\",\n            content: l.slice(1),\n            oldNumber: oldNo++,\n            newNumber: newNo++,\n          });\n          oldLeft--;\n          newLeft--;\n        } else if (c === \"-\") {\n          h.lines.push({ kind: \"remove\", content: l.slice(1), oldNumber: oldNo++ });\n          oldLeft--;\n        } else if (c === \"+\") {\n          h.lines.push({ kind: \"add\", content: l.slice(1), newNumber: newNo++ });\n          newLeft--;\n        } else {\n          break;\n        }\n        i++;\n      }\n      // Trust what was actually read when the header lied.\n      h.oldLines = h.lines.filter((x) => x.kind !== \"add\").length;\n      h.newLines = h.lines.filter((x) => x.kind !== \"remove\").length;\n      cur.hunks.push(h);\n      continue;\n    }\n\n    if (cur && cur.hunks.length === 0) {\n      if (line.startsWith(\"new file mode\")) cur.added = true;\n      else if (line.startsWith(\"deleted file mode\")) cur.deleted = true;\n      else if (line.startsWith(\"rename from \") || line.startsWith(\"copy from \")) {\n        cur.oldPath = unquote(line.slice(line.indexOf(\" from \") + 6));\n        cur.renamed = true;\n      } else if (line.startsWith(\"rename to \") || line.startsWith(\"copy to \")) {\n        cur.newPath = unquote(line.slice(line.indexOf(\" to \") + 4));\n        cur.renamed = true;\n      } else if (line.startsWith(\"Binary files \") || line.startsWith(\"GIT binary patch\")) {\n        cur.binary = true;\n        const m = /^Binary files (.+) and (.+) differ$/.exec(line);\n        if (m && cur.oldPath === null) {\n          cur.oldPath = m[1] === \"/dev/null\" ? m[1] : stripPrefix(m[1]!, \"a/\");\n          cur.newPath = m[2] === \"/dev/null\" ? m[2] : stripPrefix(m[2]!, \"b/\");\n        }\n        if (m?.[1] === \"/dev/null\") cur.added = true;\n        if (m?.[2] === \"/dev/null\") cur.deleted = true;\n      }\n    }\n    i++;\n  }\n  flush();\n  return files;\n}\n\n/** Turn structured input (possibly with raw `\"+line\"` strings and no positions) into DiffFiles. */\nexport function normalizeFiles(input: readonly DiffFileInput[]): DiffFile[] {\n  return input.map((f) => {\n    const hunks = f.hunks.map((h) => {\n      const oldStart = h.oldStart ?? h.newStart ?? 1;\n      const newStart = h.newStart ?? h.oldStart ?? 1;\n      let oldNo = oldStart;\n      let newNo = newStart;\n      const lines: DiffLine[] = [];\n      for (const raw of h.lines) {\n        if (typeof raw !== \"string\") {\n          const l: DiffLine = { ...raw };\n          if (l.kind !== \"add\") {\n            l.oldNumber ??= oldNo;\n            oldNo = l.oldNumber + 1;\n          }\n          if (l.kind !== \"remove\") {\n            l.newNumber ??= newNo;\n            newNo = l.newNumber + 1;\n          }\n          lines.push(l);\n          continue;\n        }\n        if (raw.startsWith(\"\\\\\")) {\n          const last = lines[lines.length - 1];\n          if (last) last.noNewline = true;\n        } else if (raw.startsWith(\"+\"))\n          lines.push({ kind: \"add\", content: raw.slice(1), newNumber: newNo++ });\n        else if (raw.startsWith(\"-\"))\n          lines.push({ kind: \"remove\", content: raw.slice(1), oldNumber: oldNo++ });\n        else\n          lines.push({\n            kind: \"context\",\n            content: raw.startsWith(\" \") ? raw.slice(1) : raw,\n            oldNumber: oldNo++,\n            newNumber: newNo++,\n          });\n      }\n      const hunk: DiffHunk = {\n        oldStart,\n        newStart,\n        oldLines: lines.filter((l) => l.kind !== \"add\").length,\n        newLines: lines.filter((l) => l.kind !== \"remove\").length,\n        lines,\n      };\n      if (h.id) hunk.id = h.id;\n      if (h.section) hunk.section = h.section;\n      return hunk;\n    });\n    const status: DiffFileStatus =\n      f.status ?? (f.oldPath && f.oldPath !== f.path ? \"renamed\" : \"modified\");\n    const file: DiffFile = { path: f.path, status, hunks };\n    if (f.oldPath) file.oldPath = f.oldPath;\n    if (f.language) file.language = f.language;\n    if (f.binary) file.binary = true;\n    return file;\n  });\n}\n\n/** The id DiffView uses for a hunk's review state. */\nexport function hunkId(file: Pick<DiffFile, \"path\">, hunk: Pick<DiffHunk, \"id\">, index: number) {\n  return hunk.id ?? `${file.path}#${index}`;\n}\n\nexport function countChanges(files: readonly DiffFile[]): { additions: number; deletions: number } {\n  let additions = 0;\n  let deletions = 0;\n  for (const f of files)\n    for (const h of f.hunks)\n      for (const l of h.lines) {\n        if (l.kind === \"add\") additions++;\n        else if (l.kind === \"remove\") deletions++;\n      }\n  return { additions, deletions };\n}\n\nconst EXT_LANGUAGE: Record<string, string> = {\n  ts: \"ts\",\n  tsx: \"tsx\",\n  mts: \"ts\",\n  cts: \"ts\",\n  js: \"js\",\n  jsx: \"jsx\",\n  mjs: \"js\",\n  cjs: \"js\",\n  json: \"json\",\n  jsonc: \"jsonc\",\n  json5: \"json5\",\n  sh: \"bash\",\n  bash: \"bash\",\n  zsh: \"bash\",\n  py: \"python\",\n  css: \"css\",\n  scss: \"scss\",\n  html: \"html\",\n  xml: \"xml\",\n  svg: \"svg\",\n  vue: \"vue\",\n  yml: \"yaml\",\n  yaml: \"yaml\",\n  toml: \"toml\",\n  sql: \"sql\",\n  go: \"go\",\n  rs: \"rust\",\n  java: \"java\",\n  kt: \"kotlin\",\n  swift: \"swift\",\n  c: \"c\",\n  h: \"c\",\n  cc: \"cpp\",\n  cpp: \"cpp\",\n  hpp: \"cpp\",\n  cs: \"csharp\",\n  php: \"php\",\n  rb: \"ruby\",\n};\n\n/** Highlighting language for a path, from its extension (\"text\" when unknown). */\nexport function languageFromPath(path: string): string {\n  const name = path.slice(path.lastIndexOf(\"/\") + 1).toLowerCase();\n  if (name === \"dockerfile\" || name.endsWith(\".env\") || name.startsWith(\".env\")) return \"bash\";\n  const dot = name.lastIndexOf(\".\");\n  return dot > 0 ? (EXT_LANGUAGE[name.slice(dot + 1)] ?? \"text\") : \"text\";\n}\n\n// --- word-level (intra-line) changes -----------------------------------------------------------\n\n/** Half-open character ranges `[start, end)` that changed within a line. */\nexport type Ranges = Array<[number, number]>;\n\nconst WORD_RE = /[A-Za-z0-9_$]+|\\s+|[=<>!&|+\\-*/%^~?:]+|[^A-Za-z0-9_$\\s]/g;\n\n/**\n * Character ranges that differ between an old and a new line, at word granularity (LCS over\n * word / space / punctuation tokens). Returns null when the lines are too different for word\n * highlights to help (then the whole line is the change).\n */\nexport function wordDiff(a: string, b: string): { old: Ranges; new: Ranges } | null {\n  if (a === b) return { old: [], new: [] };\n  const ta = a.match(WORD_RE) ?? [];\n  const tb = b.match(WORD_RE) ?? [];\n  if (ta.length * tb.length > 40_000) return null;\n  const n = ta.length;\n  const m = tb.length;\n  // LCS table, (n+1) × (m+1), filled from the end.\n  const dp = new Uint16Array((n + 1) * (m + 1));\n  const at = (i: number, j: number) => i * (m + 1) + j;\n  for (let i = n - 1; i >= 0; i--)\n    for (let j = m - 1; j >= 0; j--)\n      dp[at(i, j)] =\n        ta[i] === tb[j]\n          ? dp[at(i + 1, j + 1)]! + 1\n          : Math.max(dp[at(i + 1, j)]!, dp[at(i, j + 1)]!);\n  const keepA = new Array<boolean>(n).fill(false);\n  const keepB = new Array<boolean>(m).fill(false);\n  let i = 0;\n  let j = 0;\n  let common = 0;\n  while (i < n && j < m) {\n    if (ta[i] === tb[j]) {\n      keepA[i] = true;\n      keepB[j] = true;\n      if (/\\S/.test(ta[i]!)) common += ta[i]!.length;\n      i++;\n      j++;\n    } else if (dp[at(i + 1, j)]! >= dp[at(i, j + 1)]!) i++;\n    else j++;\n  }\n  // Too little in common: highlighting words would just paint the whole line.\n  const solid = (s: string) => s.replace(/\\s+/g, \"\").length;\n  if (common < 0.35 * Math.max(solid(a), solid(b))) return null;\n  const toRanges = (tokens: string[], keep: boolean[]): Ranges => {\n    const out: Ranges = [];\n    let pos = 0;\n    tokens.forEach((t, k) => {\n      if (!keep[k]) {\n        const last = out[out.length - 1];\n        // Merge across a single kept whitespace token so \"foo bar\" reads as one change.\n        if (last && last[1] === pos) last[1] = pos + t.length;\n        else out.push([pos, pos + t.length]);\n      }\n      pos += t.length;\n    });\n    // Join ranges separated only by whitespace.\n    const joined: Ranges = [];\n    const text = tokens.join(\"\");\n    for (const r of out) {\n      const prev = joined[joined.length - 1];\n      if (prev && /^\\s*$/.test(text.slice(prev[1], r[0]))) prev[1] = r[1];\n      else joined.push([r[0], r[1]]);\n    }\n    // Hug the changed tokens: no marks on leading/trailing whitespace.\n    return joined\n      .map(([start, end]): [number, number] => {\n        while (start < end && /\\s/.test(text[start]!)) start++;\n        while (end > start && /\\s/.test(text[end - 1]!)) end--;\n        return [start, end];\n      })\n      .filter(([start, end]) => end > start);\n  };\n  return { old: toRanges(ta, keepA), new: toRanges(tb, keepB) };\n}\n\n/**\n * Pair removed and added lines inside each change block of a hunk (a run of removals followed\n * by a run of additions) and compute word ranges for each pair. Keyed by line index.\n */\nexport function intraLineChanges(lines: readonly DiffLine[]): Map<number, Ranges> {\n  const out = new Map<number, Ranges>();\n  let k = 0;\n  while (k < lines.length) {\n    if (lines[k]!.kind !== \"remove\") {\n      k++;\n      continue;\n    }\n    const removes: number[] = [];\n    while (k < lines.length && lines[k]!.kind === \"remove\") removes.push(k++);\n    const adds: number[] = [];\n    while (k < lines.length && lines[k]!.kind === \"add\") adds.push(k++);\n    const pairs = Math.min(removes.length, adds.length);\n    for (let p = 0; p < pairs; p++) {\n      const r = removes[p]!;\n      const a = adds[p]!;\n      const d = wordDiff(lines[r]!.content, lines[a]!.content);\n      if (!d) continue;\n      if (d.old.length) out.set(r, d.old);\n      if (d.new.length) out.set(a, d.new);\n    }\n  }\n  return out;\n}\n"
    }
  ],
  "categories": [
    "workspace",
    "diff",
    "code-review",
    "patch",
    "git"
  ],
  "docs": "Docs: https://kuro.abstergo.fyi/components/diff-view · Markdown for agents: https://kuro.abstergo.fyi/components/diff-view.md",
  "meta": {
    "kura": {
      "status": "experimental",
      "whenToUse": [
        "Let a user review the edits a coding agent made (Claude Code, Codex, Cursor-style) before applying or committing them.",
        "Show `git diff` / `git show` output or a model-written patch as a readable, highlighted diff.",
        "Per-change approval: accept or reject individual hunks, a whole file, or everything, and act on the resulting decisions.",
        "A read-only diff of what changed in a run (reviewable={false})."
      ],
      "whenNotToUse": [
        "Listing which files changed without showing contents — use file-changes (it composes well beside DiffView).",
        "Showing a single code snippet or a whole file — use code-block.",
        "Command or test output — use terminal.",
        "Approving a tool call or command before it runs — use approval-prompt."
      ]
    }
  }
}
