From e498c02f7acc5c228bbd45f9e4f294bf5def21ca Mon Sep 17 00:00:00 2001 From: Josh Holmer Date: Fri, 10 Apr 2026 17:13:19 -0400 Subject: [PATCH] ux(cli): visually show when applied patch skips sections --- .changeset/fix-tui-diff-hunk-headers.md | 6 + .../src/cli/cmd/tui/routes/session/index.tsx | 110 +++++++++++------- .../cli/cmd/tui/routes/session/permission.tsx | 53 ++++++--- packages/opencode/src/kilocode/tui/diff.ts | 79 +++++++++++++ .../opencode/test/kilocode/tui-diff.test.ts | 91 +++++++++++++++ 5 files changed, 280 insertions(+), 59 deletions(-) create mode 100644 .changeset/fix-tui-diff-hunk-headers.md create mode 100644 packages/opencode/src/kilocode/tui/diff.ts create mode 100644 packages/opencode/test/kilocode/tui-diff.test.ts diff --git a/.changeset/fix-tui-diff-hunk-headers.md b/.changeset/fix-tui-diff-hunk-headers.md new file mode 100644 index 00000000000..2406a74b89c --- /dev/null +++ b/.changeset/fix-tui-diff-hunk-headers.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Fix TUI diff rendering when header-like content lines appear inside a unified diff hunk. diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 7528f811fae..598fbe1629f 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -83,6 +83,8 @@ import * as Model from "../../util/model" import { formatTranscript } from "../../util/transcript" import { UI } from "@/cli/ui.ts" import { useTuiConfig } from "../../context/tui-config" +import { splitDiffHunks } from "@/kilocode/tui/diff" // kilocode_change + import { formatMarkdownTables } from "../../util/markdown" // kilocode_change import { bell } from "@/kilocode/bell" // kilocode_change import { SessionIndexing } from "@/kilocode/components/session-indexing" // kilocode_change @@ -2221,32 +2223,46 @@ function Edit(props: ToolProps) { const ft = createMemo(() => filetype(props.input.filePath)) const diffContent = createMemo(() => props.metadata.diff) + const hunks = createMemo(() => splitDiffHunks(diffContent() ?? "")) // kilocode_change return ( - - + {/* kilocode_change start */} + + + {(hunk, i) => ( + <> + 0}> + + ... + + + + + )} + + {/* kilocode_change end */} @@ -2272,29 +2288,43 @@ function ApplyPatch(props: ToolProps) { }) function Diff(p: { diff: string; filePath: string }) { + // kilocode_change start + const hunks = createMemo(() => splitDiffHunks(p.diff)) return ( - - + + + {(hunk, i) => ( + <> + 0}> + + ... + + + + + )} + ) + // kilocode_change end } function title(file: { type: string; relativePath: string; filePath: string; deletions: number }) { diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index b461256a4e5..90bba877c3e 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -19,6 +19,7 @@ import { useDialog } from "../../ui/dialog" import { getScrollAcceleration } from "../../util/scroll" import { useTuiConfig } from "../../context/tui-config" import { ConfigProtection } from "@/kilocode/permission/config-paths" // kilocode_change +import { splitDiffHunks } from "@/kilocode/tui/diff" // kilocode_change type PermissionStage = "permission" | "always" | "reject" @@ -66,6 +67,7 @@ function EditBody(props: { request: PermissionRequest }) { const ft = createMemo(() => filetype(filepath())) const scrollAcceleration = createMemo(() => getScrollAcceleration(config)) + const hunks = createMemo(() => splitDiffHunks(diff())) // kilocode_change return ( @@ -80,25 +82,38 @@ function EditBody(props: { request: PermissionRequest }) { }, }} > - + {/* kilocode_change start */} + + + {(hunk, i) => ( + <> + 0}> + ... + + + + )} + + + {/* kilocode_change end */} diff --git a/packages/opencode/src/kilocode/tui/diff.ts b/packages/opencode/src/kilocode/tui/diff.ts new file mode 100644 index 00000000000..65e9b67f701 --- /dev/null +++ b/packages/opencode/src/kilocode/tui/diff.ts @@ -0,0 +1,79 @@ +export function splitDiffHunks(diff: string): string[] { + const parse = (line: string) => { + const match = line.match(/^@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@/) + if (!match) return + return { + old: Number(match[1] ?? "1"), + next: Number(match[2] ?? "1"), + } + } + + const split = (section: string[]) => { + const start = section.findIndex((line) => line.startsWith("@@")) + if (start === -1) return [section.join("\n")] + + const prefix = section.slice(0, start) + const hunks = section.slice(start).reduce((acc, line) => { + if (line.startsWith("@@")) return [...acc, [line]] + if (acc.length === 0) return [[line]] + return [...acc.slice(0, -1), [...acc.at(-1)!, line]] + }, [] as string[][]) + + const head = prefix.join("\n") + return hunks.map((hunk) => [head, ...hunk].join("\n")) + } + + const lines = diff.split("\n") + const files = lines.reduce( + (acc, line, index) => { + const hunk = parse(line) + if (hunk) { + return { files: acc.files, old: hunk.old, next: hunk.next } + } + + if ( + acc.old !== 0 && + acc.next !== 0 && + line.startsWith("--- ") && + lines[index + 1]?.startsWith("+++ ") && + lines[index + 2]?.startsWith("@@") + ) { + return { files: [...acc.files, index], old: 0, next: 0 } + } + + if (acc.old === 0 && acc.next === 0) { + if (line.startsWith("--- ") && lines[index + 1]?.startsWith("+++ ")) { + return { files: [...acc.files, index], old: 0, next: 0 } + } + return acc + } + + if (line.startsWith("\\ ")) return acc + if (line.startsWith("+")) { + return { files: acc.files, old: acc.old, next: acc.next - 1 } + } + if (line.startsWith("-")) { + return { files: acc.files, old: acc.old - 1, next: acc.next } + } + return { files: acc.files, old: acc.old - 1, next: acc.next - 1 } + }, + { files: [] as number[], old: 0, next: 0 }, + ).files + + if (files.length === 0) { + const hunks = split(lines) + if (hunks.length <= 1) return [diff] + return hunks + } + + const hunks = files + .map((line, index) => { + const start = index === 0 ? 0 : line + const end = files[index + 1] ?? lines.length + return split(lines.slice(start, end)) + }) + .flat() + + if (hunks.length <= 1) return [diff] + return hunks +} diff --git a/packages/opencode/test/kilocode/tui-diff.test.ts b/packages/opencode/test/kilocode/tui-diff.test.ts new file mode 100644 index 00000000000..ccc0454192b --- /dev/null +++ b/packages/opencode/test/kilocode/tui-diff.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, test } from "bun:test" +import { splitDiffHunks } from "../../src/kilocode/tui/diff" + +describe("splitDiffHunks", () => { + test("returns original diff when there are no hunks", () => { + const diff = "--- a/file.ts\n+++ b/file.ts" + expect(splitDiffHunks(diff)).toEqual([diff]) + }) + + test("returns original diff when there is one hunk", () => { + const diff = ["--- a/file.ts", "+++ b/file.ts", "@@ -1,2 +1,2 @@", "-one", "+ONE", " two"].join("\n") + + expect(splitDiffHunks(diff)).toEqual([diff]) + }) + + test("ignores header-like content lines inside a hunk", () => { + const diff = [ + "--- a/file.ts", + "+++ b/file.ts", + "@@ -1,1 +1,1 @@", + "--- not-a-header", + "+++ still-not-a-header", + ].join("\n") + + expect(splitDiffHunks(diff)).toEqual([diff]) + }) + + test("splits multi-hunk diff and preserves headers", () => { + const diff = [ + "--- a/file.ts", + "+++ b/file.ts", + "@@ -1,2 +1,2 @@", + "-one", + "+ONE", + " two", + "@@ -10,2 +10,2 @@", + "-ten", + "+TEN", + " eleven", + ].join("\n") + + expect(splitDiffHunks(diff)).toEqual([ + ["--- a/file.ts", "+++ b/file.ts", "@@ -1,2 +1,2 @@", "-one", "+ONE", " two"].join("\n"), + ["--- a/file.ts", "+++ b/file.ts", "@@ -10,2 +10,2 @@", "-ten", "+TEN", " eleven"].join("\n"), + ]) + }) + + test("splits concatenated multi-file diff with per-file headers", () => { + const diff = [ + "--- a/one.ts", + "+++ b/one.ts", + "@@ -1,2 +1,2 @@", + "-one", + "+ONE", + "--- a/two.ts", + "+++ b/two.ts", + "@@ -1,2 +1,2 @@", + "-two", + "+TWO", + ].join("\n") + + expect(splitDiffHunks(diff)).toEqual([ + ["--- a/one.ts", "+++ b/one.ts", "@@ -1,2 +1,2 @@", "-one", "+ONE"].join("\n"), + ["--- a/two.ts", "+++ b/two.ts", "@@ -1,2 +1,2 @@", "-two", "+TWO"].join("\n"), + ]) + }) + + test("splits multi-file diff when first file has multiple hunks", () => { + const diff = [ + "--- a/one.ts", + "+++ b/one.ts", + "@@ -1,2 +1,2 @@", + "-one", + "+ONE", + "@@ -10,2 +10,2 @@", + "-ten", + "+TEN", + "--- a/two.ts", + "+++ b/two.ts", + "@@ -3,2 +3,2 @@", + "-two", + "+TWO", + ].join("\n") + + expect(splitDiffHunks(diff)).toEqual([ + ["--- a/one.ts", "+++ b/one.ts", "@@ -1,2 +1,2 @@", "-one", "+ONE"].join("\n"), + ["--- a/one.ts", "+++ b/one.ts", "@@ -10,2 +10,2 @@", "-ten", "+TEN"].join("\n"), + ["--- a/two.ts", "+++ b/two.ts", "@@ -3,2 +3,2 @@", "-two", "+TWO"].join("\n"), + ]) + }) +})