Skip to content

Commit 0eb791b

Browse files
feat: improve diff tool display and token efficiency (#1146)
* feat: improve diff tool display and token efficiency - Truncate full commit SHAs to 7 characters for compact display - Use RepoBadge component for consistent repository rendering - Convert JSON output to git-diff format for better token efficiency Co-authored-by: Brendan Kellam <brendan@sourcebot.dev> * test: add unit tests for getDiff git-diff formatting Co-authored-by: Brendan Kellam <brendan@sourcebot.dev> * docs: update CHANGELOG for diff tool improvements Co-authored-by: Brendan Kellam <brendan@sourcebot.dev> * feedback --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent 651700b commit 0eb791b

5 files changed

Lines changed: 282 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Changed
11+
- Improved diff tool UI to truncate full commit SHAs to 7 characters, use RepoBadge component for repository display, and output diffs in git-diff format instead of JSON for better token efficiency. [#1146](https://github.com/sourcebot-dev/sourcebot/pull/1146)
12+
1013
### Fixed
1114
- Fixed a missing error boundary in `getFileSourceForRepo` introduced in v4.16.14: the function was extracted outside `sew()` but still re-threw unrecognised git exceptions, causing fatal Next.js task-runner errors. All error paths now return a `ServiceError`. Also tightened the error message for unresolved git refs (e.g. an unfetched `head_sha`) to distinguish them from syntactically invalid refs. [#1145](https://github.com/sourcebot-dev/sourcebot/pull/1145)
1215

packages/web/src/features/chat/components/chatThread/tools/getDiffToolComponent.tsx

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,34 @@
22

33
import { Separator } from '@/components/ui/separator';
44
import { GetDiffMetadata, ToolResult } from '@/features/tools';
5+
import { GitCommitHorizontalIcon } from 'lucide-react';
6+
import { RepoBadge } from './repoBadge';
7+
8+
function truncateSha(ref: string): string {
9+
const match = ref.match(/^([0-9a-f]{40})(.*)$/i);
10+
if (match) {
11+
return match[1].substring(0, 7) + match[2];
12+
}
13+
return ref;
14+
}
515

616
export const GetDiffToolComponent = ({ metadata }: ToolResult<GetDiffMetadata>) => {
717
const fileCount = metadata.files.length;
818

919
return (
1020
<div className="flex items-center gap-2 select-none cursor-default text-sm text-muted-foreground">
1121
<span className="flex-shrink-0">Compared</span>
12-
<span className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded text-foreground">
13-
{metadata.base}
22+
<span className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded text-foreground inline-flex items-center gap-1">
23+
<GitCommitHorizontalIcon className="h-3 w-3" />
24+
{truncateSha(metadata.base)}
1425
</span>
1526
<span className="flex-shrink-0">to</span>
16-
<span className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded text-foreground">
17-
{metadata.head}
27+
<span className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded text-foreground inline-flex items-center gap-1">
28+
<GitCommitHorizontalIcon className="h-3 w-3" />
29+
{truncateSha(metadata.head)}
1830
</span>
1931
<span className="flex-shrink-0">in</span>
20-
<span className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded text-foreground truncate">
21-
{metadata.repo}
22-
</span>
32+
<RepoBadge repo={metadata.repoInfo} />
2333
<span className="flex-1" />
2434
<span className="text-xs flex-shrink-0">
2535
{fileCount} changed {fileCount === 1 ? 'file' : 'files'}

packages/web/src/features/tools/getDiff.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,21 @@ import { getDiff, GetDiffResult } from '@/features/git';
22
import { getDiffRequestSchema } from '@/features/git/schemas';
33
import { isServiceError } from '@/lib/utils';
44
import description from './getDiff.txt';
5+
import { formatDiffAsGitDiff } from './utils';
56
import { logger } from './logger';
67
import { ToolDefinition } from './types';
8+
import { CodeHostType } from '@sourcebot/db';
9+
import { getRepoInfoByName } from '@/actions';
10+
11+
export type GetDiffRepoInfo = {
12+
name: string;
13+
displayName: string;
14+
codeHostType: CodeHostType;
15+
};
716

817
export type GetDiffMetadata = GetDiffResult & {
918
repo: string;
19+
repoInfo: GetDiffRepoInfo;
1020
base: string;
1121
head: string;
1222
};
@@ -27,11 +37,24 @@ export const getDiffDefinition: ToolDefinition<'get_diff', typeof getDiffRequest
2737
throw new Error(response.message);
2838
}
2939

40+
const repoInfoResult = await getRepoInfoByName(repo);
41+
if (isServiceError(repoInfoResult) || !repoInfoResult) {
42+
throw new Error(`Repository "${repo}" not found.`);
43+
}
44+
const repoInfo: GetDiffRepoInfo = {
45+
name: repoInfoResult.name,
46+
displayName: repoInfoResult.displayName ?? repoInfoResult.name,
47+
codeHostType: repoInfoResult.codeHostType,
48+
};
49+
50+
const gitDiffOutput = formatDiffAsGitDiff(response);
51+
3052
return {
31-
output: JSON.stringify(response),
53+
output: gitDiffOutput,
3254
metadata: {
3355
...response,
3456
repo,
57+
repoInfo,
3558
base,
3659
head,
3760
},
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import { describe, it, expect } from 'vitest';
2+
import type { z } from 'zod';
3+
import type { getDiffResponseSchema } from '@/features/git/schemas';
4+
import { formatDiffAsGitDiff } from './utils';
5+
6+
type GetDiffResult = z.infer<typeof getDiffResponseSchema>;
7+
8+
describe('formatDiffAsGitDiff', () => {
9+
it('should format a simple file change correctly', () => {
10+
const input: GetDiffResult = {
11+
files: [
12+
{
13+
oldPath: 'file.txt',
14+
newPath: 'file.txt',
15+
hunks: [
16+
{
17+
oldRange: { start: 1, lines: 3 },
18+
newRange: { start: 1, lines: 4 },
19+
heading: undefined,
20+
body: ' context line\n-removed line\n+added line\n context line',
21+
},
22+
],
23+
},
24+
],
25+
};
26+
27+
const expected = `--- a/file.txt
28+
+++ b/file.txt
29+
@@ -1,3 +1,4 @@
30+
context line
31+
-removed line
32+
+added line
33+
context line
34+
`;
35+
36+
expect(formatDiffAsGitDiff(input)).toBe(expected);
37+
});
38+
39+
it('should handle file deletion', () => {
40+
const input: GetDiffResult = {
41+
files: [
42+
{
43+
oldPath: 'deleted.txt',
44+
newPath: null,
45+
hunks: [
46+
{
47+
oldRange: { start: 1, lines: 2 },
48+
newRange: { start: 0, lines: 0 },
49+
heading: undefined,
50+
body: '-line 1\n-line 2',
51+
},
52+
],
53+
},
54+
],
55+
};
56+
57+
const expected = `--- a/deleted.txt
58+
+++ /dev/null
59+
@@ -1,2 +0,0 @@
60+
-line 1
61+
-line 2
62+
`;
63+
64+
expect(formatDiffAsGitDiff(input)).toBe(expected);
65+
});
66+
67+
it('should handle file addition', () => {
68+
const input: GetDiffResult = {
69+
files: [
70+
{
71+
oldPath: null,
72+
newPath: 'new.txt',
73+
hunks: [
74+
{
75+
oldRange: { start: 0, lines: 0 },
76+
newRange: { start: 1, lines: 2 },
77+
heading: undefined,
78+
body: '+line 1\n+line 2',
79+
},
80+
],
81+
},
82+
],
83+
};
84+
85+
const expected = `--- /dev/null
86+
+++ b/new.txt
87+
@@ -0,0 +1,2 @@
88+
+line 1
89+
+line 2
90+
`;
91+
92+
expect(formatDiffAsGitDiff(input)).toBe(expected);
93+
});
94+
95+
it('should include hunk heading when present', () => {
96+
const input: GetDiffResult = {
97+
files: [
98+
{
99+
oldPath: 'code.ts',
100+
newPath: 'code.ts',
101+
hunks: [
102+
{
103+
oldRange: { start: 10, lines: 5 },
104+
newRange: { start: 10, lines: 6 },
105+
heading: 'function myFunction()',
106+
body: ' function myFunction() {\n+ console.log("new line");\n return true;\n }',
107+
},
108+
],
109+
},
110+
],
111+
};
112+
113+
const expected = `--- a/code.ts
114+
+++ b/code.ts
115+
@@ -10,5 +10,6 @@ function myFunction()
116+
function myFunction() {
117+
+ console.log("new line");
118+
return true;
119+
}
120+
`;
121+
122+
expect(formatDiffAsGitDiff(input)).toBe(expected);
123+
});
124+
125+
it('should handle multiple files', () => {
126+
const input: GetDiffResult = {
127+
files: [
128+
{
129+
oldPath: 'file1.txt',
130+
newPath: 'file1.txt',
131+
hunks: [
132+
{
133+
oldRange: { start: 1, lines: 1 },
134+
newRange: { start: 1, lines: 2 },
135+
heading: undefined,
136+
body: ' old\n+new',
137+
},
138+
],
139+
},
140+
{
141+
oldPath: 'file2.txt',
142+
newPath: 'file2.txt',
143+
hunks: [
144+
{
145+
oldRange: { start: 1, lines: 1 },
146+
newRange: { start: 1, lines: 1 },
147+
heading: undefined,
148+
body: ' unchanged',
149+
},
150+
],
151+
},
152+
],
153+
};
154+
155+
const expected = `--- a/file1.txt
156+
+++ b/file1.txt
157+
@@ -1,1 +1,2 @@
158+
old
159+
+new
160+
--- a/file2.txt
161+
+++ b/file2.txt
162+
@@ -1,1 +1,1 @@
163+
unchanged
164+
`;
165+
166+
expect(formatDiffAsGitDiff(input)).toBe(expected);
167+
});
168+
169+
it('should handle multiple hunks in a single file', () => {
170+
const input: GetDiffResult = {
171+
files: [
172+
{
173+
oldPath: 'file.txt',
174+
newPath: 'file.txt',
175+
hunks: [
176+
{
177+
oldRange: { start: 1, lines: 2 },
178+
newRange: { start: 1, lines: 2 },
179+
heading: undefined,
180+
body: ' line 1\n+line 2',
181+
},
182+
{
183+
oldRange: { start: 10, lines: 2 },
184+
newRange: { start: 11, lines: 2 },
185+
heading: undefined,
186+
body: '-line 10\n line 11',
187+
},
188+
],
189+
},
190+
],
191+
};
192+
193+
const expected = `--- a/file.txt
194+
+++ b/file.txt
195+
@@ -1,2 +1,2 @@
196+
line 1
197+
+line 2
198+
@@ -10,2 +11,2 @@
199+
-line 10
200+
line 11
201+
`;
202+
203+
expect(formatDiffAsGitDiff(input)).toBe(expected);
204+
});
205+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { z } from 'zod';
2+
import type { getDiffResponseSchema } from '@/features/git/schemas';
3+
4+
type DiffResult = z.infer<typeof getDiffResponseSchema>;
5+
6+
export function formatDiffAsGitDiff(result: DiffResult): string {
7+
let output = '';
8+
9+
for (const file of result.files) {
10+
output += file.oldPath ? `--- a/${file.oldPath}\n` : `--- /dev/null\n`;
11+
output += file.newPath ? `+++ b/${file.newPath}\n` : `+++ /dev/null\n`;
12+
13+
for (const hunk of file.hunks) {
14+
const oldStart = hunk.oldRange.start;
15+
const oldLines = hunk.oldRange.lines;
16+
const newStart = hunk.newRange.start;
17+
const newLines = hunk.newRange.lines;
18+
19+
output += `@@ -${oldStart},${oldLines} +${newStart},${newLines} @@`;
20+
if (hunk.heading) {
21+
output += ` ${hunk.heading}`;
22+
}
23+
output += '\n';
24+
25+
output += hunk.body;
26+
if (!hunk.body.endsWith('\n')) {
27+
output += '\n';
28+
}
29+
}
30+
}
31+
32+
return output;
33+
}

0 commit comments

Comments
 (0)