Skip to content

Docs

Components

DiffReview

Accept or reject an agent’s edits hunk by hunk, across files, and get the patched files back.

Example

Review changes

2 files · 4 hunks · +21 −1

Added:lib/ratelimit.ts+10 −0
@@ -1,0 +1,10 @@accepted
Added: import { Ratelimit } from '@upstash/ratelimit';
Added: import { redis } from '@/lib/redis';
Added:
Added: /** 10 requests per 10 seconds per key, sliding window. */
Added: export const ratelimit = new Ratelimit({
Added: redis,
Added: limiter: Ratelimit.slidingWindow(10, '10 s'),
Added: prefix: 'ratelimit:chat',
Added: analytics: true,
Added: });
Modified:app/api/chat/route.ts+11 −1
@@ -1,6 +1,7 @@
import { convertToModelMessages, streamText, type UIMessage } from 'ai';
import { openai } from '@ai-sdk/openai';
import { tools } from '@/lib/tools';
Added: import { ratelimit } from '@/lib/ratelimit';
export const maxDuration = 30;
@@ -11,6 +12,15 @@
].join(' ');
export async function POST(req: Request) {
Added: const ip = req.headers.get('x-forwarded-for') ?? 'anonymous';
Added: const { success, reset } = await ratelimit.limit(ip);
Added: if (!success) {
Added: return new Response('Too many requests', {
Added: status: 429,
Added: headers: { 'Retry-After': String(Math.ceil((reset - Date.now()) / 1000)) },
Added: });
Added: }
Added:
const { messages }: { messages: UIMessage[] } = await req.json();
if (messages.length === 0) return new Response('Empty conversation', { status: 400 });
const modelMessages = convertToModelMessages(messages);
@@ -18,7 +28,7 @@rejected
console.info('chat', { turns: messages.length, last: lastUserMessage?.id });
const result = streamText({
Removed: model: openai('gpt-4o'),
Added: model: openai('gpt-4.1-mini'),
system: SYSTEM,
messages: modelMessages,
tools,

2/4 reviewed · unreviewed hunks are skipped

Keyboard: J or K to move between hunks, A to accept, R to reject, U to reset, Shift A or Shift R for all hunks, Ctrl Enter to apply.

Interactive, and the same on the components page. Try the keyboard below on it.

Installation

npm i @dgesteves/agent-ui-kit ai
import { DiffReview } from '@dgesteves/agent-ui-kit';
// Once per app (with Tailwind v4: @import '@dgesteves/agent-ui-kit/tailwind.css'; in your CSS)
import '@dgesteves/agent-ui-kit/styles.css';

The styles are once per app; Getting started has the Tailwind v4 and plain CSS options.

Usage

<DiffReview
  files={[{ path: 'app/api/chat/route.ts', oldContent, newContent }]}
  // Each file comes back with only the accepted hunks applied.
  onSubmit={(result) => addToolOutput({ tool: 'review_changes', toolCallId, output: result })}
/>

API reference

Generated from the types the package ships, so it matches the version you install.

DiffReview

Review agent file edits hunk by hunk, in unified or split view, with word-level highlights. Keyboard: J/K or arrows move between hunks, A accepts, R rejects, U resets, Shift+A / Shift+R decide all, ⌘/Ctrl+Enter applies.

Props

filesrequired
readonly FileChange[]
title
ReactNodeDefault 'Review changes'
view
DiffViewMode
defaultView
DiffViewModeDefault 'unified'
'unified' | 'split'
onViewChange
(view: DiffViewMode) => void
decisions
Readonly<Record<string, HunkDecision>>

Controlled decisions keyed by hunk id: ${path}:${index}. A repeated path gets ${path}#2:${index}, #3 and so on, skipping a suffix another file's path already has.

defaultDecisions
Readonly<Record<string, HunkDecision>>
onDecisionsChange
(decisions: Record<string, HunkDecision>) => void
onSubmit
(result: DiffReviewResult) => void | PromiseLike<void>

Called with the reviewed result. Pending hunks are not applied. Fires once per set of decisions, so a double click or a repeated ⌘/Ctrl+Enter sends one review. Submitting again needs a changed decision, changed files, the returned promise to settle, or the handler to throw. Files compare by content and decisions by value: an equal new array or object does not count as a change.

submitLabel
string
readOnly
booleanDefault false

Hide review controls, e.g. once the review has been submitted.

context
numberDefault 3

Lines of context around each change.

autoAdvance
booleanDefault true

After accepting or rejecting with the keyboard, move to the next pending hunk.

headingLevel
HeadingLevelDefault 3

Heading level for the title, to fit your document outline.

Other props go to the root <section>: className (merged with tailwind-merge), id, aria-*, data-* and event handlers.

FileChange

A file edit proposed by an agent. Provide either full contents or a unified patch.

pathrequired
string
oldPath
string

Previous path, for renames.

oldContent
string

Original file contents. Omit (or pass '') for new files.

newContent
string

Proposed file contents. Omit (or pass '') for deletions.

patch
string

A unified diff for this file, used when contents are not available.

language
string

Language id for highlighting, inferred from the extension when omitted.

DiffReviewResult

filesrequired
DiffReviewFileResult[]
acceptedrequired
number
rejectedrequired
number
pendingrequired
number

DiffReviewFileResult

pathrequired
string
contentrequired
string

File contents with only accepted hunks applied. undefined for patch-only input.

acceptedrequired
string[]
rejectedrequired
string[]
pendingrequired
string[]

HunkDecision

type HunkDecision = 'pending' | 'accepted' | 'rejected';

DiffViewMode

type DiffViewMode = 'unified' | 'split';

Accessibility

Keyboard
KeysAction
JKNext or previous hunk (↓ and ↑ too, from a hunk)
AAccept the hunk, and move to the next undecided one
RReject the hunk, and move on (X works too)
UReset the hunk
⇧A⇧RAccept or reject every hunk
⌘/Ctrl↵Apply the review
  • A <section> named by its title. Hunks use a roving tabindex, so the whole review is one tab stop that J, K and the arrows move through.
  • Each hunk is a group named like "Hunk 2 of 4, app/api/chat/route.ts, lines 12 to 20, accepted".
  • Every shortcut is also a button: "Accept hunk 2", "Reject hunk 2" and "Reset hunk 2" with aria-pressed, the layout toggle and Apply. Shortcuts only work while focus is inside the review.
  • Progress is announced: "Hunk 2 of 4 accepted. 2 remaining."
  • Changed lines keep their + and − glyphs and are read as "Added:" or "Removed:", so cyan and magenta never carry the meaning alone.

Theming

Every color, radius and font is a CSS variable, so the overrides in Theming apply, on :root or scoped to any element. These hooks are read from the component's markup.

Slots (data-slot)
diff-hunkdiff-reviewdiff-filediff-submit
State attributes
data-decisionon diff-hunk'pending' | 'accepted' | 'rejected'data-line
Tokens it uses
--aui-accent--aui-accent-fg--aui-add-bg--aui-add-strong--aui-bg--aui-border--aui-border-strong--aui-del-bg--aui-del-strong--aui-fg--aui-fg-muted--aui-fg-subtle--aui-font-mono--aui-font-sans--aui-hot--aui-hot-fg--aui-on-accent--aui-radius--aui-ring--aui-surface--aui-surface-2--aui-warn--aui-warn-fg
app/globals.css
/* Only this component, and only inside .settings-panel */
.settings-panel [data-slot='diff-review'] {
  --aui-radius: 4px;
}

[data-slot='diff-hunk'][data-decision='pending'] {
  outline: 1px solid var(--aui-accent);
}