diff --git a/packages/app/src/pages/session/v2/review-panel-v2.tsx b/packages/app/src/pages/session/v2/review-panel-v2.tsx
index 4b8e6359c8c6..fdb59af22afa 100644
--- a/packages/app/src/pages/session/v2/review-panel-v2.tsx
+++ b/packages/app/src/pages/session/v2/review-panel-v2.tsx
@@ -18,6 +18,7 @@ import type {
SessionReviewFocus,
SessionReviewLineComment,
} from "@opencode-ai/session-ui/session-review"
+import { truncateDiffs } from "@opencode-ai/core/util/truncate-diff"
import FileTreeV2 from "@/components/file-tree-v2"
import { useLanguage } from "@/context/language"
import { useSDK } from "@/context/sdk"
@@ -54,7 +55,16 @@ export type ReviewPanelV2Props = {
export function ReviewPanelV2(props: ReviewPanelV2Props) {
const sdk = useSDK()
- const diffs = createMemo(() => props.diffs().filter(filterRenderableDiff))
+ const rawDiffs = createMemo(() => props.diffs().filter(filterRenderableDiff))
+ const truncateResult = createMemo(() => {
+ const config = sdk().config
+ return truncateDiffs(rawDiffs(), {
+ maxFiles: config?.diff?.max_files,
+ maxPatchBytes: config?.diff?.max_patch_bytes,
+ })
+ })
+ const diffs = createMemo(() => truncateResult().diffs)
+ const truncated = createMemo(() => truncateResult().truncated)
const filteredFiles = createMemo(() =>
filterReviewFiles(
diffs().map((diff) => diff.file),
@@ -88,7 +98,21 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) {
return (
}
+ stats={
+ <>
+
+
+
+ ({diffs().length}/{truncated().totalFiles} files - truncated)
+
+
+ 0}>
+
+ ({truncated().patches} large patches truncated)
+
+
+ >
+ }
empty={props.empty}
sidebarOpen={props.state.sidebarOpened()}
sidebarToggle={
@@ -107,6 +131,7 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) {
searching={searching}
kinds={kinds}
activeDiff={activeDiff}
+ truncated={truncated}
/>
}
activeFile={activeDiff()}
@@ -157,6 +182,7 @@ function ReviewPanelV2Sidebar(props: {
searching: () => boolean
kinds: () => ReturnType
activeDiff: () => string | undefined
+ truncated: () => { files: boolean; patches: number; totalFiles: number }
}) {
const language = useLanguage()
const [explicitHighlight, setExplicitHighlight] = createSignal()
@@ -181,7 +207,16 @@ function ReviewPanelV2Sidebar(props: {
}
+ stats={
+ <>
+
+
+
+ ({props.diffs().length}/{props.truncated().totalFiles} truncated)
+
+
+ >
+ }
filter={props.state.filter()}
onFilterChange={props.state.setFilter}
onFilterKeyDown={onFilterKeyDown}
diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts
index c76486968b07..f710c2a58548 100644
--- a/packages/core/src/config.ts
+++ b/packages/core/src/config.ts
@@ -14,6 +14,7 @@ import { ConfigAgent } from "./config/agent"
import { ConfigAttachments } from "./config/attachments"
import { ConfigCompaction } from "./config/compaction"
import { ConfigCommand } from "./config/command"
+import { ConfigDiff } from "./config/diff"
import { ConfigExperimental } from "./config/experimental"
import { ConfigFormatter } from "./config/formatter"
import { ConfigLSP } from "./config/lsp"
@@ -81,6 +82,9 @@ export class Info extends Schema.Class("Config.Info")({
tool_output: ConfigToolOutput.Info.pipe(Schema.optional).annotate({
description: "Tool output truncation thresholds",
}),
+ diff: ConfigDiff.Info.pipe(Schema.optional).annotate({
+ description: "Diff viewer rendering limits",
+ }),
mcp: ConfigMCP.Info.pipe(Schema.optional).annotate({
description: "MCP server configuration",
}),
diff --git a/packages/core/src/config/diff.ts b/packages/core/src/config/diff.ts
new file mode 100644
index 000000000000..efb8b2a03328
--- /dev/null
+++ b/packages/core/src/config/diff.ts
@@ -0,0 +1,16 @@
+export * as ConfigDiff from "./diff"
+
+import { Schema } from "effect"
+import { PositiveInt } from "../schema"
+
+export class Info extends Schema.Class("ConfigV2.Diff")({
+ max_files: PositiveInt.pipe(Schema.optional).annotate({
+ description: "Maximum number of files to render in diff viewers (default: 1000)",
+ }),
+ max_patch_bytes: PositiveInt.pipe(Schema.optional).annotate({
+ description: "Maximum size in bytes for a single file patch (default: 102400 = 100KB)",
+ }),
+}) {}
+
+export const DEFAULT_MAX_FILES = 1000
+export const DEFAULT_MAX_PATCH_BYTES = 102400 // 100KB
diff --git a/packages/core/src/util/truncate-diff.ts b/packages/core/src/util/truncate-diff.ts
new file mode 100644
index 000000000000..3b44f2ee9c28
--- /dev/null
+++ b/packages/core/src/util/truncate-diff.ts
@@ -0,0 +1,70 @@
+import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/schema/vcs"
+import { ConfigDiff } from "../config/diff"
+
+export type DiffLike = SnapshotFileDiff | VcsFileDiff
+
+export interface TruncateDiffOptions {
+ maxFiles?: number
+ maxPatchBytes?: number
+}
+
+export interface TruncateDiffResult {
+ diffs: T[]
+ truncated: {
+ files: boolean
+ patches: number
+ totalFiles: number
+ }
+}
+
+/**
+ * Truncate diffs to prevent UI freezing with large changesets.
+ *
+ * Applies two limits:
+ * - `maxFiles`: Maximum number of file diffs to return (default: 1000)
+ * - `maxPatchBytes`: Maximum size for a single file patch in bytes (default: 100KB)
+ *
+ * When a patch exceeds `maxPatchBytes`, it is replaced with a truncation notice.
+ */
+export function truncateDiffs(
+ diffs: readonly T[],
+ options: TruncateDiffOptions = {},
+): TruncateDiffResult {
+ const maxFiles = options.maxFiles ?? ConfigDiff.DEFAULT_MAX_FILES
+ const maxPatchBytes = options.maxPatchBytes ?? ConfigDiff.DEFAULT_MAX_PATCH_BYTES
+
+ const totalFiles = diffs.length
+ const truncatedFiles = totalFiles > maxFiles
+ const selectedDiffs = truncatedFiles ? diffs.slice(0, maxFiles) : diffs
+
+ let truncatedPatches = 0
+ const processedDiffs = selectedDiffs.map((diff) => {
+ if (!diff.patch) return diff
+
+ const patchBytes = Buffer.byteLength(diff.patch, "utf8")
+ if (patchBytes <= maxPatchBytes) return diff
+
+ truncatedPatches++
+ const truncationNotice = `[Patch truncated: ${formatBytes(patchBytes)} exceeds ${formatBytes(maxPatchBytes)} limit]\n\nThis file has too many changes to render safely. To review:\n- View the file directly\n- Use git diff on the command line\n- Increase diff.max_patch_bytes in your config`
+
+ return {
+ ...diff,
+ patch: truncationNotice,
+ } as T
+ })
+
+ return {
+ diffs: processedDiffs,
+ truncated: {
+ files: truncatedFiles,
+ patches: truncatedPatches,
+ totalFiles,
+ },
+ }
+}
+
+function formatBytes(bytes: number): string {
+ if (bytes < 1024) return `${bytes} B`
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
+}
diff --git a/packages/core/test/util/truncate-diff.test.ts b/packages/core/test/util/truncate-diff.test.ts
new file mode 100644
index 000000000000..352da13a0bac
--- /dev/null
+++ b/packages/core/test/util/truncate-diff.test.ts
@@ -0,0 +1,96 @@
+import { describe, expect, test } from "bun:test"
+import { truncateDiffs } from "../../src/util/truncate-diff"
+import type { VcsFileDiff } from "@opencode-ai/schema/vcs"
+
+describe("truncateDiffs", () => {
+ test("returns all diffs when under file limit", () => {
+ const diffs: VcsFileDiff[] = [
+ { file: "a.ts", patch: "diff content", additions: 1, deletions: 0 },
+ { file: "b.ts", patch: "diff content", additions: 2, deletions: 1 },
+ ]
+
+ const result = truncateDiffs(diffs, { maxFiles: 10 })
+
+ expect(result.diffs).toHaveLength(2)
+ expect(result.truncated.files).toBe(false)
+ expect(result.truncated.patches).toBe(0)
+ expect(result.truncated.totalFiles).toBe(2)
+ })
+
+ test("truncates files when over limit", () => {
+ const diffs: VcsFileDiff[] = Array.from({ length: 15 }, (_, i) => ({
+ file: `file-${i}.ts`,
+ patch: "small diff",
+ additions: 1,
+ deletions: 0,
+ }))
+
+ const result = truncateDiffs(diffs, { maxFiles: 10 })
+
+ expect(result.diffs).toHaveLength(10)
+ expect(result.truncated.files).toBe(true)
+ expect(result.truncated.totalFiles).toBe(15)
+ })
+
+ test("truncates large patches", () => {
+ const smallPatch = "a".repeat(100)
+ const largePatch = "b".repeat(200000) // 200KB
+ const diffs: VcsFileDiff[] = [
+ { file: "small.ts", patch: smallPatch, additions: 1, deletions: 0 },
+ { file: "large.ts", patch: largePatch, additions: 1000, deletions: 500 },
+ ]
+
+ const result = truncateDiffs(diffs, { maxPatchBytes: 100000 })
+
+ expect(result.diffs).toHaveLength(2)
+ expect(result.diffs[0].patch).toBe(smallPatch)
+ expect(result.diffs[1].patch).toContain("[Patch truncated:")
+ expect(result.diffs[1].patch).toContain("exceeds")
+ expect(result.truncated.patches).toBe(1)
+ })
+
+ test("uses default limits when not specified", () => {
+ const diffs: VcsFileDiff[] = Array.from({ length: 5 }, (_, i) => ({
+ file: `file-${i}.ts`,
+ patch: "a".repeat(1000),
+ additions: 1,
+ deletions: 0,
+ }))
+
+ const result = truncateDiffs(diffs)
+
+ expect(result.diffs).toHaveLength(5)
+ expect(result.truncated.files).toBe(false)
+ expect(result.truncated.patches).toBe(0)
+ })
+
+ test("handles diffs without patches", () => {
+ const diffs: VcsFileDiff[] = [
+ { file: "binary.png", additions: 0, deletions: 0 },
+ { file: "text.ts", patch: "diff", additions: 1, deletions: 0 },
+ ]
+
+ const result = truncateDiffs(diffs)
+
+ expect(result.diffs).toHaveLength(2)
+ expect(result.diffs[0].patch).toBeUndefined()
+ expect(result.diffs[1].patch).toBe("diff")
+ })
+
+ test("combines file and patch truncation", () => {
+ const largePatch = "x".repeat(150000)
+ const diffs: VcsFileDiff[] = Array.from({ length: 15 }, (_, i) => ({
+ file: `file-${i}.ts`,
+ patch: i < 5 ? largePatch : "small",
+ additions: 10,
+ deletions: 5,
+ }))
+
+ const result = truncateDiffs(diffs, { maxFiles: 10, maxPatchBytes: 100000 })
+
+ expect(result.diffs).toHaveLength(10)
+ expect(result.truncated.files).toBe(true)
+ expect(result.truncated.totalFiles).toBe(15)
+ expect(result.truncated.patches).toBe(5) // First 5 have large patches
+ })
+})
diff --git a/packages/tui/src/feature-plugins/system/diff-viewer.tsx b/packages/tui/src/feature-plugins/system/diff-viewer.tsx
index ed88a1107f9d..f9f047470c50 100644
--- a/packages/tui/src/feature-plugins/system/diff-viewer.tsx
+++ b/packages/tui/src/feature-plugins/system/diff-viewer.tsx
@@ -8,6 +8,7 @@ import {
type DiffRenderable,
type ScrollBoxRenderable,
} from "@opentui/core"
+import { truncateDiffs } from "@opencode-ai/core/util/truncate-diff"
import { LANGUAGE_EXTENSIONS } from "../../util/filetype"
import { useBindings, useCommandShortcut } from "../../keymap"
import { useTheme } from "../../context/theme"
@@ -114,21 +115,30 @@ function DiffViewer(props: { api: TuiPluginApi }) {
const [diff] = createResource(diffInput, async (input) => {
if (input.mode === "last-turn") {
const sessionID = input.sessionID
- if (!sessionID) return []
+ if (!sessionID) return { diffs: [], truncated: { files: false, patches: 0, totalFiles: 0 } }
const result = await props.api.client.session.diff(
{ sessionID, messageID: input.messageID },
{ throwOnError: true },
)
- return normalizeDiffs(result.data ?? [])
+ const normalized = normalizeDiffs(result.data ?? [])
+ return truncateDiffs(normalized, {
+ maxFiles: props.api.config?.diff?.max_files,
+ maxPatchBytes: props.api.config?.diff?.max_patch_bytes,
+ })
}
const result = await props.api.client.vcs.diff(
{ directory: input.directory, mode: input.mode, context: VCS_DIFF_CONTEXT_LINES },
{ throwOnError: true },
)
- return normalizeDiffs(result.data ?? [])
+ const normalized = normalizeDiffs(result.data ?? [])
+ return truncateDiffs(normalized, {
+ maxFiles: props.api.config?.diff?.max_files,
+ maxPatchBytes: props.api.config?.diff?.max_patch_bytes,
+ })
})
- const files = createMemo(() => diff() ?? [])
+ const files = createMemo(() => diff()?.diffs ?? [])
+ const truncated = createMemo(() => diff()?.truncated)
const [focus, setFocus] = createSignal("patches")
const [fileTreeEnabled, setFileTreeEnabled] = createSignal(
props.api.kv.get(KV_SHOW_FILE_TREE, true) !== false,
@@ -756,9 +766,16 @@ function DiffViewer(props: { api: TuiPluginApi }) {
Diff
{diffSourceLabel(mode())}
-
- {files().length} {files().length === 1 ? "file" : "files"}
-
+
+
+ {files().length}/{truncated()?.totalFiles} files (truncated)
+
+
+
+
+ {files().length} {files().length === 1 ? "file" : "files"}
+
+