Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 38 additions & 3 deletions packages/app/src/pages/session/v2/review-panel-v2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -88,7 +98,21 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) {
return (
<SessionReviewV2
title={props.title}
stats={<DiffChanges changes={diffs()} />}
stats={
<>
<DiffChanges changes={diffs()} />
<Show when={truncated().files}>
<span class="ml-2 text-12-regular text-text-warning">
({diffs().length}/{truncated().totalFiles} files - truncated)
</span>
</Show>
<Show when={truncated().patches > 0}>
<span class="ml-2 text-12-regular text-text-warning">
({truncated().patches} large patches truncated)
</span>
</Show>
</>
}
empty={props.empty}
sidebarOpen={props.state.sidebarOpened()}
sidebarToggle={
Expand All @@ -107,6 +131,7 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) {
searching={searching}
kinds={kinds}
activeDiff={activeDiff}
truncated={truncated}
/>
}
activeFile={activeDiff()}
Expand Down Expand Up @@ -157,6 +182,7 @@ function ReviewPanelV2Sidebar(props: {
searching: () => boolean
kinds: () => ReturnType<typeof reviewDiffKinds>
activeDiff: () => string | undefined
truncated: () => { files: boolean; patches: number; totalFiles: number }
}) {
const language = useLanguage()
const [explicitHighlight, setExplicitHighlight] = createSignal<string | undefined>()
Expand All @@ -181,7 +207,16 @@ function ReviewPanelV2Sidebar(props: {
<SessionReviewV2Sidebar
open={props.state.sidebarOpened()}
title={props.title}
stats={<DiffChanges changes={props.diffs()} />}
stats={
<>
<DiffChanges changes={props.diffs()} />
<Show when={props.truncated().files}>
<span class="ml-2 text-12-regular text-text-warning">
({props.diffs().length}/{props.truncated().totalFiles} truncated)
</span>
</Show>
</>
}
filter={props.state.filter()}
onFilterChange={props.state.setFilter}
onFilterKeyDown={onFilterKeyDown}
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -81,6 +82,9 @@ export class Info extends Schema.Class<Info>("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",
}),
Expand Down
16 changes: 16 additions & 0 deletions packages/core/src/config/diff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export * as ConfigDiff from "./diff"

import { Schema } from "effect"
import { PositiveInt } from "../schema"

export class Info extends Schema.Class<Info>("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
70 changes: 70 additions & 0 deletions packages/core/src/util/truncate-diff.ts
Original file line number Diff line number Diff line change
@@ -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<T extends DiffLike> {
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<T extends DiffLike>(
diffs: readonly T[],
options: TruncateDiffOptions = {},
): TruncateDiffResult<T> {
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`
}
96 changes: 96 additions & 0 deletions packages/core/test/util/truncate-diff.test.ts
Original file line number Diff line number Diff line change
@@ -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
})
})
31 changes: 24 additions & 7 deletions packages/tui/src/feature-plugins/system/diff-viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<DiffViewerFocus>("patches")
const [fileTreeEnabled, setFileTreeEnabled] = createSignal(
props.api.kv.get<boolean>(KV_SHOW_FILE_TREE, true) !== false,
Expand Down Expand Up @@ -756,9 +766,16 @@ function DiffViewer(props: { api: TuiPluginApi }) {
<text fg={theme().text}>Diff </text>
<text fg={theme().textMuted}>{diffSourceLabel(mode())}</text>
<box flexGrow={1} />
<text fg={theme().textMuted}>
{files().length} {files().length === 1 ? "file" : "files"}
</text>
<Show when={truncated()?.files}>
<text fg={theme().warning}>
{files().length}/{truncated()?.totalFiles} files (truncated)
</text>
</Show>
<Show when={!truncated()?.files}>
<text fg={theme().textMuted}>
{files().length} {files().length === 1 ? "file" : "files"}
</text>
</Show>
</Panel>

<box flexGrow={1} minHeight={0}>
Expand Down
Loading