# Diff View

> 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.

- **Slug:** `diff-view` · **Category:** workspace · **Status:** experimental
- **Import:** `import { DiffFile, DiffFileInput, DiffFileStatus, DiffHunk, DiffHunkInput, DiffLine, DiffLineKind, DiffReview, DiffReviewChange, DiffView, HunkDecision } from "@kura-ui/react/diff-view";`
- **Docs:** https://kuro.abstergo.fyi/components/diff-view
- **Registry item:** https://kuro.abstergo.fyi/r/diff-view.json
- **Tags:** diff, code-review, patch, git, accept-reject, agent, syntax-highlighting, workspace

## Install

```bash
# shadcn registry (copies source into your project)
npx shadcn@latest add https://kuro.abstergo.fyi/r/diff-view.json
# Kura CLI (same, plus dependency resolution and --json output)
npx @kura-ui/cli add diff-view
# npm package
npm install @kura-ui/react
```

## When to use

- 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}).

## When not to use

- 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.

## Props

| Prop | Type | Default | Required | Description |
| --- | --- | --- | --- | --- |
| `diff` | `string` | — | no | A unified diff (`git diff`, `git show`, `diff -u`, a model's patch). Parsed with parseUnifiedDiff: renames, new/deleted/binary files, quoted paths, CRLF, missing counts, `\ No newline at end of file`, and wrong hunk counts are handled. Ignored when `files` is given. |
| `files` | `readonly DiffFileInput[]` | — | no | Structured files `{ path, oldPath?, status?, language?, binary?, hunks: [{ id?, oldStart?, newStart?, section?, lines }] }`. `lines` may be DiffLine objects or raw strings ("+added", "-removed", " context"); line numbers are computed. |
| `title` | `React.ReactNode` | `"Changes"` | no | Header title. |
| `reviewable` | `boolean` | `true` | no | Show per-hunk Accept / Reject, per-file Accept file, Accept all / Reject all and the “2 of 5 reviewed” tally. |
| `review` | `DiffReview` | — | no | Decisions (controlled): `{ [hunkId]: "accepted" \| "rejected" }`; absent = pending. Hunk ids are `hunk.id` or `<path>#<index>` (see hunkId()). |
| `defaultReview` | `DiffReview` | `{}` | no | Initial decisions when uncontrolled. |
| `onReviewChange` | `(review: DiffReview, change: DiffReviewChange) => void` | — | no | Called with the full next review state and `{ path, hunkIds, decision }` where decision is "accepted", "rejected" or "pending" (undo). |
| `collapsed` | `readonly string[]` | — | no | Collapsed file paths (controlled). |
| `defaultCollapsed` | `readonly string[]` | `[]` | no | Initially collapsed file paths when uncontrolled. |
| `onCollapsedChange` | `(collapsed: string[]) => void` | — | no | Called when a file is collapsed or expanded (by click or by collapseReviewed). |
| `collapseReviewed` | `boolean` | `true` | no | Collapse a file once every hunk in it has a decision (like GitHub's “Viewed”). |
| `autoAdvance` | `boolean` | `true` | no | After a decision, move focus to the next pending hunk (Cursor-style review flow). |
| `wrap` | `boolean` | `false` | no | Soft-wrap long lines instead of scrolling each hunk sideways. |
| `maxHeight` | `number` | — | no | Scroll the files inside this height (px). File headers stay pinned while scrolling (also against page scroll without it). |
| `actions` | `React.ReactNode` | — | no | Extra controls at the end of the header, e.g. a Commit button. |
| `empty` | `React.ReactNode` | — | no | Rendered when there are no files. Default an icon + “No changes”. |

Native attributes of the root element, `className` and `ref` are passed through.

## Anatomy

- **DiffView** (`data-slot="diff-view"`) — `data-state="empty" | "pending" | "reviewed"`, `data-reviewable`. A container query hides secondary header items below ~26–34rem.
- **Header** (`data-slot="diff-view-header"`) — Title, mono summary (`diff-view-summary`: files, +N −M), review progress (`diff-view-progress`) and Accept all / Reject all.
- **File** (`data-slot="diff-view-file"`) — `<section aria-label={path}>` with `data-status="added" | "modified" | "deleted" | "renamed"` and `data-state="open" | "closed"`.
- **FileHeader** (`data-slot="diff-view-file-header"`) — Sticky row: collapse toggle (button, aria-expanded), path (`diff-view-path`: dim directory + bold name, old → new for renames), status chip, +N −M, 5-block ratio bar (`diff-view-ratio`), Accept file / review state (`diff-view-file-review`).
- **Hunk** (`data-slot="diff-view-hunk"`) — `role="group"`, roving tabindex, `data-hunk-id`, `data-review="pending" | "accepted" | "rejected"`. Accent bar on the left when focused.
- **HunkHeader** (`data-slot="diff-view-hunk-header"`) — `@@ −a,b +c,d @@` + section heading, and Reject / Accept (`diff-view-hunk-actions`) or the decision (`diff-view-hunk-state`) with Undo.
- **Line** (`data-slot="diff-view-line"`) — `data-kind="context" | "add" | "remove"`: old and new line numbers, sign, highlighted code with word-level change marks.

## Accessibility

- Each file is a labelled section; its header toggle is a button with aria-expanded/aria-controls.
- Hunks are focusable groups named “Change 2 of 9, src/auth/client.ts lines 10–19, pending review”; only the current hunk (and its buttons) is in the Tab order.
- Added/removed lines carry sr-only “added:” / “removed:” prefixes; colour is never the only signal (+/− signs, status chips, labels).
- Decisions are announced in a polite status region (“Accepted hunk in src/middleware.ts. 3 of 9 reviewed.”) — once per action, never per render.
- Accept / Reject tooltips show their single-key shortcuts.
- Reduced motion: Chevron rotation, the review-dimming fade and the progress colour change apply instantly; no motion is essential.

| Keys | Action |
| --- | --- |
| J / K | Next / previous hunk (skips collapsed files) |
| A | Accept the focused hunk, then move to the next pending one |
| R | Reject the focused hunk, then move to the next pending one |
| U / Backspace | Undo the focused hunk's decision |
| Enter / Space | On a file header: collapse or expand the file |
| Tab | Through file headers and the focused hunk's buttons |

## Notes for agents

- Pass the raw `git diff` string to `diff`; don't pre-split it. Use `files` when you already have structured hunks (raw '+'/'-'/' ' strings are fine as lines).
- Review state is keyed by hunk id: `hunk.id` if you set one (do this when hunks can shift, e.g. while an agent keeps editing), else `<path>#<index>`. Export `hunkId(file, hunk, index)` to compute it.
- To apply decisions, walk parseUnifiedDiff(diff) and keep hunks whose id is "accepted"; rejected and pending hunks are not applied.
- Helpers exported alongside: parseUnifiedDiff, normalizeFiles, wordDiff, countChanges, languageFromPath.
- Syntax highlighting uses code-block's synchronous highlighter; the language is inferred from the file extension (override with files[].language).
- Very large diffs render every line; collapse big files with defaultCollapsed (e.g. lockfiles) to keep it fast.

## Examples

### Basic (minimal)

One file from a unified diff string, with per-hunk review.

```tsx
import { DiffView } from "@kura-ui/react/diff-view";

const MIDDLEWARE_DIFF = `diff --git a/src/middleware.ts b/src/middleware.ts
--- a/src/middleware.ts
+++ b/src/middleware.ts
@@ -1,4 +1,4 @@
 import { NextResponse, type NextRequest } from "next/server";
-import { readSessionCookie } from "@/lib/legacy-session";
+import { auth } from "@/auth/client";
 
 const PUBLIC_PATHS = ["/login", "/signup", "/api/health"];
@@ -8,5 +8,5 @@ export async function middleware(request: NextRequest) {
   if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) return NextResponse.next();
 
-  const session = readSessionCookie(request.headers.get("cookie"));
+  const session = await auth.getSession({ headers: request.headers });
   if (!session) {
     const url = new URL("/login", request.url);
`;

export function DiffViewMinimal() {
  return <DiffView diff={MIDDLEWARE_DIFF} className="w-full max-w-2xl" />;
}
```

### Review session (full)

The agent's whole migration (4 files, 9 hunks) with controlled review state, a scrolling body, pinned file headers and a keyboard legend. Try j, k, a, r, u.

```tsx
"use client";

import * as React from "react";
import { Kbd } from "@kura-ui/react/kbd";
import { type DiffReview, DiffView } from "@kura-ui/react/diff-view";

const AUTH_MIGRATION_DIFF = `diff --git a/src/auth/client.ts b/src/auth/client.ts
index 887cccf..1c08d5a 100644
--- a/src/auth/client.ts
+++ b/src/auth/client.ts
@@ -1,7 +1,8 @@
 import { cache } from "react";
-import { fetchJson } from "@/lib/http";
-import type { Session, User } from "./types";
+import { createAuthClient, type Session, type User } from "@acme/auth";
 
-const SESSION_URL = "/api/v1/session";
-const REFRESH_URL = "/api/v1/session/refresh";
+export const auth = createAuthClient({
+  baseUrl: process.env.NEXT_PUBLIC_AUTH_URL,
+  refresh: "auto",
+});
 
@@ -9,14 +10,10 @@ const REFRESH_URL = "/api/v1/session/refresh";
 export const fetchUser = cache(async (): Promise<User | null> => {
-  const res = await fetchJson<Session>(SESSION_URL, { credentials: "include" });
-  if (!res.ok) return null;
-  return res.data.user;
+  const session = await auth.getSession();
+  return session?.user ?? null;
 });
 
-export async function refreshSession(token: string): Promise<Session> {
-  const res = await fetchJson<Session>(REFRESH_URL, {
-    method: "POST",
-    body: JSON.stringify({ token }),
-  });
-  if (!res.ok) throw new Error(\`refresh failed: \${res.status}\`);
-  return res.data;
+export async function refreshSession(session: Session): Promise<Session> {
+  const next = await auth.refresh(session.refreshToken);
+  if (!next) throw new Error("refresh failed: session revoked");
+  return next;
 }
@@ -24,3 +21,3 @@ export async function refreshSession(token: string): Promise<Session> {
 export function isExpired(session: Session, now = Date.now()): boolean {
-  return session.expiresAt < now;
+  return session.expiresAt <= now;
 }
@@ -28,3 +25,3 @@ export function isExpired(session: Session, now = Date.now()): boolean {
 export async function signOut(): Promise<void> {
-  await fetchJson(SESSION_URL, { method: "DELETE", credentials: "include" });
+  await auth.signOut();
 }
diff --git a/src/auth/__tests__/login.test.ts b/src/auth/__tests__/login.test.ts
index 9fe0aaf..711a4bd 100644
--- a/src/auth/__tests__/login.test.ts
+++ b/src/auth/__tests__/login.test.ts
@@ -1,4 +1,4 @@
 import { describe, expect, it, vi } from "vitest";
-import { refreshSession, isExpired } from "../client";
+import { auth, isExpired, refreshSession } from "../client";
 
 describe("login", () => {
@@ -8,8 +8,10 @@ describe("login", () => {
 
   it("refreshes an expired session", async () => {
-    vi.spyOn(globalThis, "fetch").mockResolvedValue(
-      new Response(JSON.stringify({ expiresAt: 10, user: { id: "u_1" } })),
-    );
-    const next = await refreshSession("tok_expired");
+    vi.spyOn(auth, "refresh").mockResolvedValue({
+      expiresAt: 10,
+      refreshToken: "tok_next",
+      user: { id: "u_1" },
+    } as never);
+    const next = await refreshSession({ refreshToken: "tok_expired" } as never);
     expect(next.user.id).toBe("u_1");
   });
diff --git a/src/lib/legacy-session.ts b/src/lib/legacy-session.ts
deleted file mode 100644
index f0ae86d..0000000
--- a/src/lib/legacy-session.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-/** @deprecated Use \`auth.getSession()\` from @acme/auth. */
-export function readSessionCookie(cookie: string | null) {
-  const raw = cookie?.match(/acme_session=([^;]+)/)?.[1];
-  if (!raw) return null;
-  try {
-    return JSON.parse(atob(raw)) as { userId: string; expiresAt: number };
-  } catch {
-    return null;
-  }
-}
diff --git a/src/middleware.ts b/src/middleware.ts
index 0e93409..7eda2e2 100644
--- a/src/middleware.ts
+++ b/src/middleware.ts
@@ -1,4 +1,4 @@
 import { NextResponse, type NextRequest } from "next/server";
-import { readSessionCookie } from "@/lib/legacy-session";
+import { auth } from "@/auth/client";
 
 const PUBLIC_PATHS = ["/login", "/signup", "/api/health"];
@@ -8,5 +8,5 @@ export async function middleware(request: NextRequest) {
   if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) return NextResponse.next();
 
-  const session = readSessionCookie(request.headers.get("cookie"));
+  const session = await auth.getSession({ headers: request.headers });
   if (!session) {
     const url = new URL("/login", request.url);
`;

export function DiffViewFull() {
  const [review, setReview] = React.useState<DiffReview>({
    "src/auth/client.ts#0": "accepted",
  });
  const decisions = Object.values(review);
  const accepted = decisions.filter((d) => d === "accepted").length;
  const rejected = decisions.length - accepted;
  return (
    <div className="flex w-full max-w-3xl flex-col gap-3">
      <DiffView
        title="Review changes"
        diff={AUTH_MIGRATION_DIFF}
        review={review}
        onReviewChange={setReview}
        maxHeight={620}
      />
      <div className="flex items-center gap-3 px-1 text-[12px] text-kura-fg-subtle">
        <span className="flex items-center gap-1.5">
          <Kbd size="sm">J</Kbd>
          <Kbd size="sm">K</Kbd>
          <span>move</span>
        </span>
        <span className="flex items-center gap-1.5">
          <Kbd size="sm">A</Kbd>
          <span>accept</span>
        </span>
        <span className="flex items-center gap-1.5">
          <Kbd size="sm">R</Kbd>
          <span>reject</span>
        </span>
        <span className="flex items-center gap-1.5">
          <Kbd size="sm">U</Kbd>
          <span>undo</span>
        </span>
        <span className="ml-auto font-kura-mono tabular-nums">
          {accepted} accepted · {rejected} rejected
        </span>
      </div>
    </div>
  );
}
```

### Structured files (variant)

Rename, new file and a binary file from structured input; read-only.

```tsx
import { type DiffFileInput, DiffView } from "@kura-ui/react/diff-view";

const STRUCTURED_FILES: DiffFileInput[] = [
  {
    path: "src/auth/session-store.ts",
    oldPath: "src/auth/session.ts",
    status: "renamed",
    hunks: [
      {
        oldStart: 3,
        newStart: 3,
        section: "export class SessionStore",
        lines: [
          "   #ttl = 15 * 60_000;",
          "-  read(key: string) {",
          "+  read(key: string): Session | undefined {",
          "     return this.#map.get(key);",
        ],
      },
    ],
  },
  {
    path: "src/auth/types.ts",
    status: "added",
    hunks: [
      {
        newStart: 1,
        lines: [
          '+export type { Session, User } from "@acme/auth";',
          "+",
          '+export type AuthProvider = "password" | "github" | "google";',
        ],
      },
    ],
  },
  { path: "public/og-login.png", status: "modified", binary: true, hunks: [] },
];

export function DiffViewStructured() {
  return (
    <DiffView
      title="Patch from test-writer"
      files={STRUCTURED_FILES}
      reviewable={false}
      className="w-full max-w-2xl"
    />
  );
}
```

### Apply accepted changes (composition)

A footer bar with a Button acts on the accepted hunks.

```tsx
"use client";

import * as React from "react";
import { Button } from "@kura-ui/react/button";
import { type DiffReview, DiffView } from "@kura-ui/react/diff-view";

const MIDDLEWARE_DIFF = `diff --git a/src/middleware.ts b/src/middleware.ts
--- a/src/middleware.ts
+++ b/src/middleware.ts
@@ -1,4 +1,4 @@
 import { NextResponse, type NextRequest } from "next/server";
-import { readSessionCookie } from "@/lib/legacy-session";
+import { auth } from "@/auth/client";
 
 const PUBLIC_PATHS = ["/login", "/signup", "/api/health"];
@@ -8,5 +8,5 @@ export async function middleware(request: NextRequest) {
   if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) return NextResponse.next();
 
-  const session = readSessionCookie(request.headers.get("cookie"));
+  const session = await auth.getSession({ headers: request.headers });
   if (!session) {
     const url = new URL("/login", request.url);
`;

export function DiffViewComposition() {
  const [review, setReview] = React.useState<DiffReview>({});
  const [applied, setApplied] = React.useState<number | null>(null);
  const accepted = Object.values(review).filter((d) => d === "accepted").length;
  return (
    <div className="flex w-full max-w-3xl flex-col gap-3">
      <DiffView
        title="kura/auth-client"
        diff={MIDDLEWARE_DIFF}
        review={review}
        onReviewChange={(next) => {
          setReview(next);
          setApplied(null);
        }}
      />
      <div className="flex items-center justify-between gap-3 rounded-kura-xl bg-kura-surface py-2 pr-2 pl-3.5 shadow-kura-sm">
        <span className="text-[13px] text-kura-fg-muted" role="status">
          {applied != null
            ? `Applied ${applied} ${applied === 1 ? "change" : "changes"} to kura/auth-client`
            : accepted > 0
              ? `${accepted} accepted ${accepted === 1 ? "change" : "changes"} ready`
              : "Accept changes to apply them"}
        </span>
        <Button size="sm" disabled={accepted === 0} onClick={() => setApplied(accepted)}>
          Apply changes
        </Button>
      </div>
    </div>
  );
}
```

## Dependencies

- npm: `lucide-react`
- Kura registry items: `kura-theme`, `button`, `code-block`, `icon-button`, `tooltip`, `use-controllable-state`, `utils`
- Related: `file-changes`, `code-block`, `terminal`, `approval-prompt`, `tool-call`
