diff --git a/src/renderer/src/components/native-chat/NativeChatDiffCard.tsx b/src/renderer/src/components/native-chat/NativeChatDiffCard.tsx
index d009f8ea66f..8bbdb6ce988 100644
--- a/src/renderer/src/components/native-chat/NativeChatDiffCard.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatDiffCard.tsx
@@ -1,4 +1,4 @@
-import { useState } from 'react'
+import { useMemo, useState } from 'react'
import { ChevronRight, FilePlus2, FileMinus2, FilePen } from 'lucide-react'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
@@ -38,8 +38,8 @@ function baseName(path: string): string {
return path.split(/[\\/]/).at(-1) || path
}
-function patchText(file: NativeChatEditFile): string {
- return file.lines
+function patchText(lines: readonly NativeChatEditLine[]): string {
+ return lines
.filter((line) => line.kind !== 'gap')
.map((line) => `${line.kind === 'add' ? '+' : line.kind === 'del' ? '-' : ' '}${line.text}`)
.join('\n')
@@ -117,6 +117,9 @@ export function NativeChatDiffCard({
initiallyExpanded?: boolean
}): React.JSX.Element {
const [expanded, setExpanded] = useState(initiallyExpanded)
+ // Joining every row to seed the copy button is the card's most expensive
+ // work, and a collapsed card renders none of those rows.
+ const copyText = useMemo(() => patchText(file.lines), [file.lines])
const hasBody = file.lines.length > 0
const widest = file.lineNumbersKnown
? file.lines.reduce((max, line) => Math.max(max, unifiedLineNumber(line) ?? 0), 0)
@@ -171,7 +174,7 @@ export function NativeChatDiffCard({
) : null}
diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx
index f830713377e..050b3f7c84b 100644
--- a/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx
@@ -2,8 +2,8 @@
import '@testing-library/jest-dom/vitest'
-import { cleanup, render, screen } from '@testing-library/react'
-import { afterEach, describe, expect, it } from 'vitest'
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
import type { NativeChatBlock } from '../../../../shared/native-chat-types'
import { projectStructuredItemToNativeChat } from '../../../../shared/structured-agent-session-projection'
@@ -196,13 +196,42 @@ describe('NativeChatToolRun', () => {
{ type: 'tool-result', output: '@@ -1,3 +1,3 @@\n ctx\n-was\n+now\n… (48210 bytes)' }
]
- // expandSignal false leaves every card's body closed.
+ // A defined expandOverride opens the run while leaving each card closed.
render()
expect(screen.getByText('Diff truncated')).toBeInTheDocument()
expect(screen.queryByText('was')).toBeNull()
})
+ it('copies the diff as signed rows, with the region breaks left out', () => {
+ const writeClipboardText = vi.fn()
+ Object.assign(window, { api: { ui: { writeClipboardText } } })
+ const blocks: NativeChatBlock[] = [
+ {
+ type: 'tool-call',
+ name: 'Edit',
+ input: { file_path: '/repo/a.ts' },
+ state: 'completed'
+ },
+ {
+ type: 'tool-result',
+ output: 'ok',
+ editPatch: {
+ filePath: '/repo/a.ts',
+ hunks: [
+ { oldStart: 1, oldLines: 2, newStart: 1, newLines: 2, lines: [' ctx', '-was', '+now'] },
+ { oldStart: 90, oldLines: 1, newStart: 90, newLines: 1, lines: ['+tail'] }
+ ]
+ }
+ }
+ ]
+
+ render()
+ fireEvent.click(screen.getByRole('button', { name: 'Copy diff' }))
+
+ expect(writeClipboardText).toHaveBeenCalledWith(' ctx\n-was\n+now\n+tail')
+ })
+
it('keeps a grouped active run to one stable row showing only the latest tool', () => {
const blocks: NativeChatBlock[] = [
{ type: 'tool-call', name: 'shell', input: { command: 'date' }, state: 'completed' },
diff --git a/src/shared/native-chat-begin-patch.ts b/src/shared/native-chat-begin-patch.ts
index a39fd28568e..becd0ece46a 100644
--- a/src/shared/native-chat-begin-patch.ts
+++ b/src/shared/native-chat-begin-patch.ts
@@ -108,7 +108,6 @@ function envelopeArgument(
/** Splits a `*** Begin Patch` envelope into one entry per file it touches. */
export function editFilesFromBeginPatch(envelope: string): NativeChatEditFile[] {
const sections: { kind: 'Add' | 'Update' | 'Delete'; path: string; body: string[] }[] = []
- let movePath: string | null = null
const moves = new Map()
// Split on both newline forms once, so every marker below can be matched
@@ -125,8 +124,7 @@ export function editFilesFromBeginPatch(envelope: string): NativeChatEditFile[]
}
const move = MOVE_HEADER.exec(raw)
if (move && sections.length > 0) {
- movePath = move[1]!.trim()
- moves.set(sections.length - 1, movePath)
+ moves.set(sections.length - 1, move[1]!.trim())
continue
}
if (raw === BEGIN || raw === END || CONTROL_LINE.test(raw) || sections.length === 0) {
diff --git a/src/shared/native-chat-diff.ts b/src/shared/native-chat-diff.ts
index a4e66f49af2..a6dde625a14 100644
--- a/src/shared/native-chat-diff.ts
+++ b/src/shared/native-chat-diff.ts
@@ -14,8 +14,11 @@ const DIFF_TRUNCATED_LINE: NativeChatDiffLine = {
}
const HUNK_HEADER = /^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@/
-// Lines that open a new file section, so any hunk before them has ended.
-const FILE_SECTION_START =
+/** Lines that open a new file section, so any hunk before them has ended.
+ * `--- `/`+++ ` are deliberately absent: inside a hunk they are content — a
+ * removed `-- comment` is emitted as `--- comment` — so they go through
+ * `isFileHeaderPair` instead. */
+export const FILE_SECTION_START =
/^(?:diff |index |old mode |new mode |new file mode |deleted file mode |similarity index |dissimilarity index |rename |copy |Binary files )/
// Markdown thematic break or YAML document separator, not a marker.
const BARE_RULE = /^(?:-{3,}|\+{3,})$/
diff --git a/src/shared/native-chat-edit-model.ts b/src/shared/native-chat-edit-model.ts
index 1b0beef2c78..6a26f87c599 100644
--- a/src/shared/native-chat-edit-model.ts
+++ b/src/shared/native-chat-edit-model.ts
@@ -54,7 +54,7 @@ export function splitEditContent(content: string): EditContentLines {
}
/** The break between two regions of a file. Carries no text and no position. */
-export function editGapLine(): NativeChatEditLine {
+function editGapLine(): NativeChatEditLine {
return { kind: 'gap', text: '', oldLineNumber: null, newLineNumber: null }
}
diff --git a/src/shared/native-chat-unified-patch.ts b/src/shared/native-chat-unified-patch.ts
index a15268b2132..2e34c79a6b3 100644
--- a/src/shared/native-chat-unified-patch.ts
+++ b/src/shared/native-chat-unified-patch.ts
@@ -1,13 +1,7 @@
-import { isFileHeaderPair } from './native-chat-diff'
+import { FILE_SECTION_START, isFileHeaderPair } from './native-chat-diff'
import { pushEditGap, splitEditContent, type NativeChatEditLine } from './native-chat-edit-model'
const HUNK_RANGES = /^@@+ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/
-/** Structural lines that open a file section, so any hunk before them has ended.
- * `--- `/`+++ ` are deliberately absent: inside a hunk they are content — a
- * removed `-- comment` is emitted as `--- comment` — so they go through
- * `isFileHeaderPair` instead. */
-const FILE_SECTION =
- /^(?:diff |index |old mode |new mode |new file mode |deleted file mode |similarity index |dissimilarity index |rename |copy |Binary files )/
export type UnifiedPatchLines = {
lines: NativeChatEditLine[]
@@ -59,7 +53,7 @@ export function editLinesFromUnifiedPatch(
index += 1
continue
}
- if (FILE_SECTION.test(raw)) {
+ if (FILE_SECTION_START.test(raw)) {
inHunk = false
continue
}
@@ -186,7 +180,7 @@ export function unifiedPatchSections(text: string): {
}
if (raw.startsWith('@@')) {
inHunk = true
- } else if (FILE_SECTION.test(raw)) {
+ } else if (FILE_SECTION_START.test(raw)) {
inHunk = false
}
current ??= open()