Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
8e75084
fix(cli): prevent freeze on huge-file diffs
alex-alecu Apr 17, 2026
9567140
Merge remote-tracking branch 'origin/main' into fix/snapshot-diff-freeze
alex-alecu Apr 17, 2026
449f629
log errors instead of swallowing them
alex-alecu Apr 17, 2026
d49aadd
move summary dispatcher to kilo helper
alex-alecu Apr 17, 2026
871e655
extract diff loop to kilo helper
alex-alecu Apr 17, 2026
7ca9c2f
remove skip_slow_tests opt-out
alex-alecu Apr 17, 2026
4ed4ae3
scope terminate to owning pool
alex-alecu Apr 17, 2026
d04da0e
gate pool init with shared promise
alex-alecu Apr 17, 2026
83739c3
fix(session): skip warning on superseded summary
alex-alecu Apr 17, 2026
01edbfa
fix(ci): use block annotation for diffFull delegation
alex-alecu Apr 17, 2026
cceb3d6
test(cli): drop tests that reach into private summary state
alex-alecu Apr 17, 2026
c567f30
refactor(cli): drop session.warning toast and warning bus event
alex-alecu Apr 17, 2026
2b071e4
Merge branch 'main' into fix/snapshot-diff-freeze
alex-alecu Apr 17, 2026
0f932ab
resolve merge conflicts with main
alex-alecu Apr 20, 2026
a7e06d6
refactor(cli): narrow snapshot-diff freeze fix to caps-only
alex-alecu Apr 20, 2026
eca1cc7
Merge remote-tracking branch 'origin/main' into fix/snapshot-diff-freeze
alex-alecu Apr 20, 2026
e2e89ae
Merge branch 'main' into fix/snapshot-diff-freeze
alex-alecu Apr 21, 2026
94e8c78
core: stop TUI freeze when viewing diffs of huge files
alex-alecu Apr 21, 2026
a5946ab
core: drop the JavaScript diff fallback that's no longer needed
alex-alecu Apr 21, 2026
ce50f5c
core: preserve upstream Myers diff code to keep upstream merges clean
alex-alecu Apr 21, 2026
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
5 changes: 5 additions & 0 deletions .changeset/snapshot-diff-freeze.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Fix TUI freeze on huge-file diffs. Session-summary and file-view patches now use git directly instead of a JavaScript Myers implementation, so files of any size render a full diff without blocking the session.
8 changes: 8 additions & 0 deletions packages/opencode/src/file/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Git } from "@/git"
import { Effect, Layer, Context } from "effect"
import * as Stream from "effect/Stream"
import { formatPatch, structuredPatch } from "diff"
import { DiffFull } from "@/kilocode/snapshot/diff-full" // kilocode_change
import fuzzysort from "fuzzysort"
import ignore from "ignore"
import path from "path"
Expand Down Expand Up @@ -559,6 +560,13 @@ export namespace File {
diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file])
}
if (diff.trim()) {
// kilocode_change start — patch via git (DiffFull.file) instead of the JS Myers
// implementation. Upstream structuredPatch branch below is kept as dead code so
// our diff from upstream stays minimal and future merges don't conflict.
const got = yield* DiffFull.file(gitText, file)
if (got) return { type: "text" as const, content, patch: got.patch, diff: got.text }
return { type: "text" as const, content }
// kilocode_change end
const original = yield* git.show(Instance.directory, "HEAD", file)
const patch = structuredPatch(file, file, original, content, "old", "new", {
context: Infinity,
Expand Down
151 changes: 151 additions & 0 deletions packages/opencode/src/kilocode/snapshot/diff-full.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
// kilocode_change - new file
//
// Patch generation. Runs `git diff --unified=INT_MAX` to produce
// unified-diff text for a set of files, instead of the npm `diff` package's
// JS Myers implementation. Myers is O(N*M) with full context, so on
// huge-file diffs it can block the event loop for minutes (the TUI freeze
// where ESC stopped working after a turn).
//
// Both helpers fail soft: on any git error they return an empty value so
// callers emit an empty patch string. Additions/deletions come from
// `git --numstat` and stay accurate.

import { Effect } from "effect"
import { parsePatch } from "diff"
import type { StructuredPatch } from "diff"
import { Log } from "../../util/log"

export namespace DiffFull {
const log = Log.create({ service: "snapshot.diff-full" })

// INT_MAX — git clamps to this, effectively infinite context.
const unified = "--unified=2147483647"

interface GitResult {
readonly code: number
readonly text: string
readonly stderr: string
}

/**
* Run `git diff --unified=INT_MAX` for a set of files between two refs and
* return a `file → unified-diff text` map. Output format matches what the
* `diff` package's `parsePatch` expects, so downstream clients continue to
* work.
*
* `files` entries must use forward slashes (git's output uses `/` even on
* Windows); paths with backslashes will silently miss the suffix match.
*
* Returns an empty map if `files` is empty or git fails. Callers emit an
* empty patch string for any file missing from the map; numstat-derived
* additions/deletions stay accurate.
*/
export const batch = Effect.fn("DiffFull.batch")(function* (
git: (cmd: string[]) => Effect.Effect<GitResult>,
from: string,
to: string,
files: string[],
) {
const map = new Map<string, string>()
if (files.length === 0) return map

// Windows cmdline limit is ~8191 chars. 500 * avg-15-char filename ≈ 7500.
const size = 500
let failed = 0
let stderr = ""
for (let i = 0; i < files.length; i += size) {
const chunk = files.slice(i, i + size)
const result = yield* git([
"diff",
"--no-color",
"--no-ext-diff",
"--no-renames",
unified,
from,
to,
"--",
...chunk,
])
if (result.code !== 0) {
failed += 1
stderr = result.stderr || stderr
continue
}
parseBatch(result.text, chunk, map)
}
if (failed) {
log.info("git diff failed, emitting empty patches for affected files", {
chunksFailed: failed,
filesTotal: files.length,
stderr,
})
}
return map
})

/**
* Generate a structured + unified diff for a single file in the working
* tree vs HEAD using `git diff --ignore-all-space --unified=INT_MAX`.
* Returns `null` if git produces no output (caller emits a content-only
* response with no patch).
*/
export const file = Effect.fn("DiffFull.file")(function* (
gitText: (args: string[]) => Effect.Effect<string>,
file: string,
) {
const flags = ["-c", "core.fsmonitor=false", "diff", "--no-color", "--no-ext-diff", "--ignore-all-space", unified]
const primary = yield* gitText([...flags, "--", file])
const text = primary.trim() ? primary : yield* gitText([...flags, "--staged", "--", file])
if (!text.trim()) return null
const parsed = parsePatch(text)[0]
if (!parsed) return null
// Normalize paths to match what `structuredPatch(file, file, ...)` used to
// produce — downstream UIs key off the bare filename, not `a/…` / `b/…`.
const patch: StructuredPatch = {
...parsed,
oldFileName: file,
newFileName: file,
}
return { patch, text }
})

/**
* Split a multi-file `git diff` output into one entry per file, keyed by
* the input filename (not the path from the header — that can be quoted).
* Silently drops sections whose header does not match any entry in `chunk`.
*/
function parseBatch(text: string, chunk: string[], map: Map<string, string>) {
// Longest-first so `lib/a.txt` beats `a.txt` on suffix matches.
const ordered = chunk.slice().sort((a, b) => b.length - a.length)
// With `--no-renames` the header is always `diff --git a/PATH b/PATH` with
// both PATHs identical, so we can confirm both halves to avoid false
// positives where PATH happens to also appear as a substring earlier in
// the line (e.g. a filename containing ` b/`).
const match = (header: string) => {
for (const f of ordered) {
if (header.endsWith(" b/" + f) && header.includes(" a/" + f + " ")) return f
if (header.endsWith(` "b/${f}"`) && header.includes(` "a/${f}" `)) return f
}
return null
}

let current: string | null = null
let buffer: string[] = []
const flush = () => {
if (current !== null && buffer.length) map.set(current, buffer.join("\n"))
current = null
buffer = []
}

for (const line of text.split("\n")) {
if (line.startsWith("diff --git ")) {
flush()
current = match(line)
if (current !== null) buffer.push(line)
continue
}
if (current !== null) buffer.push(line)
}
flush()
}
}
25 changes: 25 additions & 0 deletions packages/opencode/src/snapshot/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { Config } from "../config/config"
import { Global } from "../global"
import { Hash } from "../util/hash"
import { Log } from "../util/log"
import { DiffFull } from "../kilocode/snapshot/diff-full" // kilocode_change

export namespace Snapshot {
export const Patch = z.object({
Expand Down Expand Up @@ -718,6 +719,30 @@ export namespace Snapshot {
const patch = (file: string, before: string, after: string) =>
formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER }))

// kilocode_change start — route patches through git (DiffFull.batch) instead of the
// JS Myers implementation. Upstream Myers loop below is kept as dead code so our
// diff from upstream stays minimal and future merges don't conflict.
for (let i = 0; i < rows.length; i += step) {
const run = rows.slice(i, i + step)
const patches = yield* DiffFull.batch(
(cmd) => git([...quote, ...args(cmd)], { cwd: state.directory }),
from,
to,
run.filter((r) => !r.binary).map((r) => r.file),
)
for (const row of run) {
result.push({
file: row.file,
patch: row.binary ? "" : (patches.get(row.file) ?? ""),
additions: row.additions,
deletions: row.deletions,
status: row.status,
})
}
}
return result
// kilocode_change end

for (let i = 0; i < rows.length; i += step) {
const run = rows.slice(i, i + step)
const text = yield* load(run)
Expand Down
Loading
Loading