Skip to content
Kura

Diff View

experimental

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.

@kura-ui/react/diff-viewCopy for agentsMarkdownRegistry JSON

Basic

minimal
Changes+2−22 additions, 2 deletions
@@ −1,4 +1,4 @@
import { NextResponse, type NextRequest } from "next/server";
removed: import { readSessionCookie } from "@/lib/legacy-session";
added: import { auth } from "@/auth/client";
​
const PUBLIC_PATHS = ["/login", "/signup", "/api/health"];
@@ −8,5 +8,5 @@
if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) return NextResponse.next();
​
removed: const session = readSessionCookie(request.headers.get("cookie"));
added: const session = await auth.getSession({ headers: request.headers });
if (!session) {
const url = new URL("/login", request.url);

Review session

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.

full
Review changes+22−3322 additions, 33 deletions
@@ −1,7 +1,8 @@Accepted
import { cache } from "react";
removed: import { fetchJson } from "@/lib/http";
removed: import type { Session, User } from "./types";
added: import { createAuthClient, type Session, type User } from "@acme/auth";
​
removed: const SESSION_URL = "/api/v1/session";
removed: const REFRESH_URL = "/api/v1/session/refresh";
added: export const auth = createAuthClient({
added: baseUrl: process.env.NEXT_PUBLIC_AUTH_URL,
added: refresh: "auto",
added: });
​
@@ −9,14 +10,10 @@
export const fetchUser = cache(async (): Promise<User | null> => {
removed: const res = await fetchJson<Session>(SESSION_URL, { credentials: "include" });
removed: if (!res.ok) return null;
removed: return res.data.user;
added: const session = await auth.getSession();
added: return session?.user ?? null;
});
​
removed: export async function refreshSession(token: string): Promise<Session> {
removed: const res = await fetchJson<Session>(REFRESH_URL, {
removed: method: "POST",
removed: body: JSON.stringify({ token }),
removed: });
removed: if (!res.ok) throw new Error(`refresh failed: ${res.status}`);
removed: return res.data;
added: export async function refreshSession(session: Session): Promise<Session> {
added: const next = await auth.refresh(session.refreshToken);
added: if (!next) throw new Error("refresh failed: session revoked");
added: return next;
}
@@ −24,3 +21,3 @@
export function isExpired(session: Session, now = Date.now()): boolean {
removed: return session.expiresAt < now;
added: return session.expiresAt <= now;
}
@@ −28,3 +25,3 @@
export async function signOut(): Promise<void> {
removed: await fetchJson(SESSION_URL, { method: "DELETE", credentials: "include" });
added: await auth.signOut();
}
@@ −1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
removed: import { refreshSession, isExpired } from "../client";
added: import { auth, isExpired, refreshSession } from "../client";
​
describe("login", () => {
@@ −8,8 +8,10 @@
​
it("refreshes an expired session", async () => {
removed: vi.spyOn(globalThis, "fetch").mockResolvedValue(
removed: new Response(JSON.stringify({ expiresAt: 10, user: { id: "u_1" } })),
removed: );
removed: const next = await refreshSession("tok_expired");
added: vi.spyOn(auth, "refresh").mockResolvedValue({
added: expiresAt: 10,
added: refreshToken: "tok_next",
added: user: { id: "u_1" },
added: } as never);
added: const next = await refreshSession({ refreshToken: "tok_expired" } as never);
expect(next.user.id).toBe("u_1");
});
@@ −1,10 +0,0 @@
removed: /** @deprecated Use `auth.getSession()` from @acme/auth. */
removed: export function readSessionCookie(cookie: string | null) {
removed: const raw = cookie?.match(/acme_session=([^;]+)/)?.[1];
removed: if (!raw) return null;
removed: try {
removed: return JSON.parse(atob(raw)) as { userId: string; expiresAt: number };
removed: } catch {
removed: return null;
removed: }
removed: }
@@ −1,4 +1,4 @@
import { NextResponse, type NextRequest } from "next/server";
removed: import { readSessionCookie } from "@/lib/legacy-session";
added: import { auth } from "@/auth/client";
​
const PUBLIC_PATHS = ["/login", "/signup", "/api/health"];
@@ −8,5 +8,5 @@
if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) return NextResponse.next();
​
removed: const session = readSessionCookie(request.headers.get("cookie"));
added: const session = await auth.getSession({ headers: request.headers });
if (!session) {
const url = new URL("/login", request.url);
JKmoveAacceptRrejectUundo1 accepted · 0 rejected

Structured files

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

variant
Patch from test-writer+4−14 additions, 1 deletions
@@ −3,3 +3,3 @@
#ttl = 15 * 60_000;
removed: read(key: string) {
added: read(key: string): Session | undefined {
return this.#map.get(key);
@@ −1,0 +1,3 @@
added: export type { Session, User } from "@acme/auth";
added: ​
added: export type AuthProvider = "password" | "github" | "google";
Binary file not shown.

Apply accepted changes

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

composition
kura/auth-client+2−22 additions, 2 deletions
@@ −1,4 +1,4 @@
import { NextResponse, type NextRequest } from "next/server";
removed: import { readSessionCookie } from "@/lib/legacy-session";
added: import { auth } from "@/auth/client";
​
const PUBLIC_PATHS = ["/login", "/signup", "/api/health"];
@@ −8,5 +8,5 @@
if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) return NextResponse.next();
​
removed: const session = readSessionCookie(request.headers.get("cookie"));
added: const session = await auth.getSession({ headers: request.headers });
if (!session) {
const url = new URL("/login", request.url);
Accept changes to apply them

Installation

npx shadcn@latest add https://kuro.abstergo.fyi/r/diff-view.json

Copies the source into components/kura/diff-view with its dependencies (kura-theme, button, code-block, icon-button, tooltip, use-controllable-state, utils). Tokens come from the kura-theme item.

When to use it

Use it for

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

Reach for something else when

  • 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

PropTypeDefaultDescription
diffstring—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.
filesreadonly DiffFileInput[]—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.
titleReact.ReactNode"Changes"Header title.
reviewablebooleantrueShow per-hunk Accept / Reject, per-file Accept file, Accept all / Reject all and the “2 of 5 reviewed” tally.
reviewDiffReview—Decisions (controlled): `{ [hunkId]: "accepted" | "rejected" }`; absent = pending. Hunk ids are `hunk.id` or `<path>#<index>` (see hunkId()).
defaultReviewDiffReview{}Initial decisions when uncontrolled.
onReviewChange(review: DiffReview, change: DiffReviewChange) => void—Called with the full next review state and `{ path, hunkIds, decision }` where decision is "accepted", "rejected" or "pending" (undo).
collapsedreadonly string[]—Collapsed file paths (controlled).
defaultCollapsedreadonly string[][]Initially collapsed file paths when uncontrolled.
onCollapsedChange(collapsed: string[]) => void—Called when a file is collapsed or expanded (by click or by collapseReviewed).
collapseReviewedbooleantrueCollapse a file once every hunk in it has a decision (like GitHub's “Viewed”).
autoAdvancebooleantrueAfter a decision, move focus to the next pending hunk (Cursor-style review flow).
wrapbooleanfalseSoft-wrap long lines instead of scrolling each hunk sideways.
maxHeightnumber—Scroll the files inside this height (px). File headers stay pinned while scrolling (also against page scroll without it).
actionsReact.ReactNode—Extra controls at the end of the header, e.g. a Commit button.
emptyReact.ReactNode—Rendered when there are no files. Default an icon + “No changes”.

All native attributes of the root element, className and ref pass through.

Anatomy

  1. DiffView`data-state="empty" | "pending" | "reviewed"`, `data-reviewable`. A container query hides secondary header items below ~26–34rem.data-slot="diff-view"
  2. HeaderTitle, mono summary (`diff-view-summary`: files, +N −M), review progress (`diff-view-progress`) and Accept all / Reject all.data-slot="diff-view-header"
  3. File`<section aria-label={path}>` with `data-status="added" | "modified" | "deleted" | "renamed"` and `data-state="open" | "closed"`.data-slot="diff-view-file"
  4. FileHeaderSticky 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`).data-slot="diff-view-file-header"
  5. Hunk`role="group"`, roving tabindex, `data-hunk-id`, `data-review="pending" | "accepted" | "rejected"`. Accent bar on the left when focused.data-slot="diff-view-hunk"
  6. HunkHeader`@@ −a,b +c,d @@` + section heading, and Reject / Accept (`diff-view-hunk-actions`) or the decision (`diff-view-hunk-state`) with Undo.data-slot="diff-view-hunk-header"
  7. Line`data-kind="context" | "add" | "remove"`: old and new line numbers, sign, highlighted code with word-level change marks.data-slot="diff-view-line"

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.
KeysAction
J / KNext / previous hunk (skips collapsed files)
AAccept the focused hunk, then move to the next pending one
RReject the focused hunk, then move to the next pending one
U / BackspaceUndo the focused hunk's decision
Enter / SpaceOn a file header: collapse or expand the file
TabThrough 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.