on main help sign in
feat: generally render output that looks like a diff as a diff (#2607)

For example, the GitHub MCP server can fetch diffs. This makes those diffs render as such, rather than just showing their raw output.

commitf7beb12689a337dade9d9c9bef575947188bcab7 authorChristian Rocha <christian@rocha.is> dateTue 21 Apr 2026, 10:00 (6 months ago) parent7edf395
13 files changed+1015−88
internal/diffdetect/detect.go+36 internal/diffdetect/detect_test.go+152 internal/ui/chat/docker_mcp.go+1−14 internal/ui/chat/generic.go+2−27 internal/ui/chat/mcp.go+3−46 internal/ui/chat/mcp_test.go+406 internal/ui/chat/tool_result_content.go+59 internal/ui/chat/tool_result_content_test.go+116 internal/ui/chat/tools.go+1−1 internal/ui/chat/unified_diff.go+171 internal/ui/diffview/diffview.go+43 internal/ui/diffview/style.go+17 internal/ui/styles/styles.go+8
internal/diffdetect/detect.gonew file+36
@@ -0,0 +1,36 @@
1+package diffdetect
2+
3+import "strings"
4+
5+// Signal describes which unified-diff markers were found while scanning text.
6+type Signal struct {
7+ HasHunk bool
8+ HasFileHeader bool
9+ HasGitHeader bool
10+}
11+
12+// Inspect scans content for unified-diff markers.
13+func Inspect(content string) Signal {
14+ var signal Signal
15+ for line := range strings.SplitSeq(content, "\n") {
16+ if strings.HasPrefix(line, "@@") {
17+ signal.HasHunk = true
18+ }
19+ if strings.HasPrefix(line, "--- ") || strings.HasPrefix(line, "+++ ") {
20+ signal.HasFileHeader = true
21+ }
22+ if strings.HasPrefix(line, "diff --git ") {
23+ signal.HasGitHeader = true
24+ }
25+ }
26+ return signal
27+}
28+
29+// IsUnifiedDiff reports whether content appears to be a unified diff.
30+func IsUnifiedDiff(content string) bool {
31+ signal := Inspect(content)
32+ if signal.HasGitHeader && signal.HasFileHeader {
33+ return true
34+ }
35+ return signal.HasHunk && signal.HasFileHeader
36+}
internal/diffdetect/detect_test.gonew file+152
@@ -0,0 +1,152 @@
1+package diffdetect
2+
3+import "testing"
4+
5+func TestInspect(t *testing.T) {
6+ t.Parallel()
7+
8+ tests := []struct {
9+ name string
10+ content string
11+ want Signal
12+ }{
13+ {
14+ name: "git unified diff",
15+ content: `diff --git a/main.go b/main.go
16+--- a/main.go
17++++ b/main.go
18+@@ -1,2 +1,3 @@
19+ package main
20++import "fmt"
21+`,
22+ want: Signal{HasHunk: true, HasFileHeader: true, HasGitHeader: true},
23+ },
24+ {
25+ name: "non-git unified diff",
26+ content: `--- old.c
27++++ old.c
28+@@ -1 +1 @@
29+-old
30++new
31+`,
32+ want: Signal{HasHunk: true, HasFileHeader: true, HasGitHeader: false},
33+ },
34+ {
35+ name: "plain text",
36+ content: "hello world",
37+ want: Signal{},
38+ },
39+ {
40+ name: "hunk only",
41+ content: "@@ -1 +1 @@\n-old\n+new\n",
42+ want: Signal{HasHunk: true},
43+ },
44+ {
45+ name: "headers only",
46+ content: "--- a/file\n+++ b/file\n",
47+ want: Signal{HasFileHeader: true},
48+ },
49+ }
50+
51+ for _, tt := range tests {
52+ t.Run(tt.name, func(t *testing.T) {
53+ t.Parallel()
54+ got := Inspect(tt.content)
55+ if got != tt.want {
56+ t.Errorf("Inspect() = %+v, want %+v", got, tt.want)
57+ }
58+ })
59+ }
60+}
61+
62+func TestIsUnifiedDiff(t *testing.T) {
63+ t.Parallel()
64+
65+ tests := []struct {
66+ name string
67+ content string
68+ want bool
69+ }{
70+ {
71+ name: "github-style multi-file diff",
72+ content: `diff --git a/one.txt b/one.txt
73+--- a/one.txt
74++++ b/one.txt
75+@@ -1 +1 @@
76+-a
77++b
78+diff --git a/two.txt b/two.txt
79+--- a/two.txt
80++++ b/two.txt
81+@@ -1 +1 @@
82+-c
83++d
84+`,
85+ want: true,
86+ },
87+ {
88+ name: "non-git unified patch",
89+ content: `--- a/old.c
90++++ b/old.c
91+@@ -1,3 +1,4 @@
92+ #include <stdio.h>
93+-int main() {
94++int main(int argc, char **argv) {
95+ return 0;
96+ }
97+`,
98+ want: true,
99+ },
100+ {
101+ name: "new file from dev null",
102+ content: `--- /dev/null
103++++ newfile.txt
104+@@ -0,0 +1,2 @@
105++hello
106++world
107+`,
108+ want: true,
109+ },
110+ {
111+ name: "markdown false positive candidate",
112+ content: `- Item one
113+- Item two
114++ Bonus item
115+- Item three
116+`,
117+ want: false,
118+ },
119+ {
120+ name: "headers without hunk",
121+ content: `--- a/somefile.txt
122++++ b/somefile.txt
123+Just some content here
124+No hunk markers at all
125+`,
126+ want: false,
127+ },
128+ {
129+ name: "hunk without headers",
130+ content: `@@ -1,3 +1,4 @@
131+ some line
132+-another line
133++changed line
134+`,
135+ want: false,
136+ },
137+ {
138+ name: "empty",
139+ content: "",
140+ want: false,
141+ },
142+ }
143+
144+ for _, tt := range tests {
145+ t.Run(tt.name, func(t *testing.T) {
146+ t.Parallel()
147+ if got := IsUnifiedDiff(tt.content); got != tt.want {
148+ t.Errorf("IsUnifiedDiff() = %v, want %v", got, tt.want)
149+ }
150+ })
151+ }
152+}
internal/ui/chat/docker_mcp.go+1−14
@@ -138,20 +138,7 @@ func (d *DockerMCPToolRenderContext) RenderTool(sty *styles.Styles, width int, o
138138
139139 // Handle text content.
140140 if opts.Result.Content != "" {
141− var body string
142− var result json.RawMessage
143− if err := json.Unmarshal([]byte(opts.Result.Content), &result); err == nil {
144− prettyResult, err := json.MarshalIndent(result, "", " ")
145− if err == nil {
146− body = sty.Tool.Body.Render(toolOutputCodeContent(sty, "result.json", string(prettyResult), 0, bodyWidth, opts.ExpandedContent))
147− } else {
148− body = sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
149− }
150− } else if looksLikeMarkdown(opts.Result.Content) {
151− body = sty.Tool.Body.Render(toolOutputCodeContent(sty, "result.md", opts.Result.Content, 0, bodyWidth, opts.ExpandedContent))
152− } else {
153− body = sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
154− }
141+ body := renderToolResultTextContent(sty, opts.Result.Content, toolResultContentWidths{Body: bodyWidth, Diff: cappedWidth}, opts.ExpandedContent)
155142 parts = append(parts, body)
156143 }
157144
internal/ui/chat/generic.go+2−27
@@ -5,7 +5,6 @@ import (
55 "strings"
66
77 "github.com/charmbracelet/crush/internal/message"
8− "github.com/charmbracelet/crush/internal/stringext"
98 "github.com/charmbracelet/crush/internal/ui/styles"
109)
1110
@@ -32,7 +31,7 @@ type GenericToolRenderContext struct{}
3231// RenderTool implements the [ToolRenderer] interface.
3332func (g *GenericToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
3433 cappedWidth := cappedMessageWidth(width)
35− name := genericPrettyName(opts.ToolCall.Name)
34+ name := humanizedToolName(opts.ToolCall.Name)
3635
3736 if opts.IsPending() {
3837 return pendingTool(sty, name, opts.Anim, opts.Compact)
@@ -64,35 +63,11 @@ func (g *GenericToolRenderContext) RenderTool(sty *styles.Styles, width int, opt
6463
6564 bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
6665
67− // Handle image data.
6866 if opts.Result.Data != "" && strings.HasPrefix(opts.Result.MIMEType, "image/") {
6967 body := sty.Tool.Body.Render(toolOutputImageContent(sty, opts.Result.Data, opts.Result.MIMEType))
7068 return joinToolParts(header, body)
7169 }
7270
73− // Try to parse result as JSON for pretty display.
74− var result json.RawMessage
75− var body string
76− if err := json.Unmarshal([]byte(opts.Result.Content), &result); err == nil {
77− prettyResult, err := json.MarshalIndent(result, "", " ")
78− if err == nil {
79− body = sty.Tool.Body.Render(toolOutputCodeContent(sty, "result.json", string(prettyResult), 0, bodyWidth, opts.ExpandedContent))
80− } else {
81− body = sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
82− }
83− } else if looksLikeMarkdown(opts.Result.Content) {
84− body = sty.Tool.Body.Render(toolOutputCodeContent(sty, "result.md", opts.Result.Content, 0, bodyWidth, opts.ExpandedContent))
85− } else {
86− body = sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
87− }
88−
71+ body := renderToolResultTextContent(sty, opts.Result.Content, toolResultContentWidths{Body: bodyWidth, Diff: cappedWidth}, opts.ExpandedContent)
8972 return joinToolParts(header, body)
9073}
91−
92−// genericPrettyName converts a snake_case or kebab-case tool name to a
93−// human-readable title case name.
94−func genericPrettyName(name string) string {
95− name = strings.ReplaceAll(name, "_", " ")
96− name = strings.ReplaceAll(name, "-", " ")
97− return stringext.Capitalize(name)
98−}
internal/ui/chat/mcp.go+3−46
@@ -6,7 +6,6 @@ import (
66 "strings"
77
88 "github.com/charmbracelet/crush/internal/message"
9− "github.com/charmbracelet/crush/internal/stringext"
109 "github.com/charmbracelet/crush/internal/ui/styles"
1110)
1211
@@ -37,8 +36,8 @@ func (b *MCPToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *T
3736 if len(toolNameParts) != 3 {
3837 return toolErrorContent(sty, &message.ToolResult{Content: "Invalid tool name"}, cappedWidth)
3938 }
40− mcpName := prettyName(toolNameParts[1])
41− toolName := prettyName(toolNameParts[2])
39+ mcpName := humanizedToolName(toolNameParts[1])
40+ toolName := humanizedToolName(toolNameParts[2])
4241
4342 mcpName = sty.Tool.MCPName.Render(mcpName)
4443 toolName = sty.Tool.MCPToolName.Render(toolName)
@@ -74,48 +73,6 @@ func (b *MCPToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *T
7473 }
7574
7675 bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
77− // see if the result is json
78− var result json.RawMessage
79− var body string
80− if err := json.Unmarshal([]byte(opts.Result.Content), &result); err == nil {
81− prettyResult, err := json.MarshalIndent(result, "", " ")
82− if err == nil {
83− body = sty.Tool.Body.Render(toolOutputCodeContent(sty, "result.json", string(prettyResult), 0, bodyWidth, opts.ExpandedContent))
84− } else {
85− body = sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
86− }
87− } else if looksLikeMarkdown(opts.Result.Content) {
88− body = sty.Tool.Body.Render(toolOutputCodeContent(sty, "result.md", opts.Result.Content, 0, bodyWidth, opts.ExpandedContent))
89− } else {
90− body = sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
91− }
76+ body := renderToolResultTextContent(sty, opts.Result.Content, toolResultContentWidths{Body: bodyWidth, Diff: cappedWidth}, opts.ExpandedContent)
9277 return joinToolParts(header, body)
9378}
94−
95−func prettyName(name string) string {
96− name = strings.ReplaceAll(name, "_", " ")
97− name = strings.ReplaceAll(name, "-", " ")
98− return stringext.Capitalize(name)
99−}
100−
101−// looksLikeMarkdown checks if content appears to be markdown by looking for
102−// common markdown patterns.
103−func looksLikeMarkdown(content string) bool {
104− patterns := []string{
105− "# ", // headers
106− "## ", // headers
107− "**", // bold
108− "```", // code fence
109− "- ", // unordered list
110− "1. ", // ordered list
111− "> ", // blockquote
112− "---", // horizontal rule
113− "***", // horizontal rule
114− }
115− for _, p := range patterns {
116− if strings.Contains(content, p) {
117− return true
118− }
119− }
120− return false
121−}
internal/ui/chat/mcp_test.gonew file+406
@@ -0,0 +1,406 @@
1+package chat
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+func TestLooksLikeDiff(t *testing.T) {
9+ t.Parallel()
10+
11+ tests := []struct {
12+ name string
13+ content string
14+ want bool
15+ }{
16+ {
17+ name: "simple unified diff",
18+ content: `diff --git a/main.go b/main.go
19+--- a/main.go
20++++ b/main.go
21+@@ -1,5 +1,6 @@
22+ package main
23+
24++import "fmt"
25++
26+ func main() {
27+- println("hello")
28++ fmt.Println("hello")
29+ }
30+`,
31+ want: true,
32+ },
33+ {
34+ name: "plain text",
35+ content: "This is just some plain text with no diff markers.",
36+ want: false,
37+ },
38+ {
39+ name: "empty string",
40+ content: "",
41+ want: false,
42+ },
43+ {
44+ name: "markdown with headers",
45+ content: `# Title
46+
47+Some content here.
48+
49+## Subtitle
50+
51+More content with **bold** text.
52+`,
53+ want: false,
54+ },
55+ {
56+ name: "diff with mixed content",
57+ content: `diff --git a/file.txt b/file.txt
58+--- a/file.txt
59++++ b/file.txt
60+@@ -1 +1 @@
61+-old line
62++new line
63+`,
64+ want: true,
65+ },
66+ {
67+ name: "only plus/minus without hunk or headers",
68+ content: `Hello world
69+---
70+This is not really a diff
71+Just some text with a few symbols
72++ another line
73+More regular content here
74+And even more content
75+`,
76+ want: false,
77+ },
78+ {
79+ name: "GitHub PR diff format",
80+ content: `diff --git a/src/app.ts b/src/app.ts
81+index abc1234..def5678 100644
82+--- a/src/app.ts
83++++ b/src/app.ts
84+@@ -10,6 +10,8 @@ function handleRequest() {
85+ const data = getData();
86++ validate(data);
87++ log(data);
88+ return process(data);
89+ }
90+`,
91+ want: true,
92+ },
93+ {
94+ name: "non-git unified patch with hunk and headers",
95+ content: `--- a/old.c
96++++ b/old.c
97+@@ -1,3 +1,4 @@
98+ #include <stdio.h>
99+-int main() {
100++int main(int argc, char **argv) {
101+ return 0;
102+ }
103+`,
104+ want: true,
105+ },
106+ {
107+ name: "file headers without hunk markers",
108+ content: `--- a/somefile.txt
109++++ b/somefile.txt
110+Just some content here
111+No hunk markers at all
112+`,
113+ want: false,
114+ },
115+ {
116+ name: "hunk markers without file headers",
117+ content: `@@ -1,3 +1,4 @@
118+ some line
119+-another line
120++changed line
121+`,
122+ want: false,
123+ },
124+ {
125+ name: "markdown list with plus signs",
126+ content: `- Item one
127+- Item two
128++ Bonus item
129+- Item three
130+`,
131+ want: false,
132+ },
133+ }
134+
135+ for _, tt := range tests {
136+ t.Run(tt.name, func(t *testing.T) {
137+ t.Parallel()
138+ got := looksLikeDiff(tt.content)
139+ if got != tt.want {
140+ t.Errorf("looksLikeDiff() = %v, want %v", got, tt.want)
141+ }
142+ })
143+ }
144+}
145+
146+func TestParseUnifiedDiff(t *testing.T) {
147+ t.Parallel()
148+
149+ tests := []struct {
150+ name string
151+ input string
152+ want []parsedDiffFile
153+ }{
154+ {
155+ name: "simple diff with additions and removals",
156+ input: `diff --git a/main.go b/main.go
157+--- a/main.go
158++++ b/main.go
159+@@ -1,5 +1,6 @@
160+ package main
161+
162++import "fmt"
163++
164+ func main() {
165+- println("hello")
166++ fmt.Println("hello")
167+ }
168+`,
169+ want: []parsedDiffFile{
170+ {
171+ path: "main.go",
172+ before: "package main\n\nfunc main() {\n println(\"hello\")\n}",
173+ after: "package main\n\nimport \"fmt\"\n\nfunc main() {\n fmt.Println(\"hello\")\n}",
174+ },
175+ },
176+ },
177+ {
178+ name: "new file creation",
179+ input: `diff --git a/newfile.go b/newfile.go
180+new file mode 100644
181+--- /dev/null
182++++ b/newfile.go
183+@@ -0,0 +1,3 @@
184++package main
185++
186++func main() {}
187+`,
188+ want: []parsedDiffFile{
189+ {
190+ path: "newfile.go",
191+ before: "",
192+ after: "package main\n\nfunc main() {}",
193+ },
194+ },
195+ },
196+ {
197+ name: "file deletion",
198+ input: `diff --git a/oldfile.go b/oldfile.go
199+deleted file mode 100644
200+--- a/oldfile.go
201++++ /dev/null
202+@@ -1,3 +0,0 @@
203+-package main
204+-
205+-func main() {}
206+`,
207+ want: []parsedDiffFile{
208+ {
209+ path: "oldfile.go",
210+ before: "package main\n\nfunc main() {}",
211+ after: "",
212+ },
213+ },
214+ },
215+ {
216+ name: "non-diff content",
217+ input: "Just some regular text",
218+ want: nil,
219+ },
220+ {
221+ name: "diff with timestamp in header",
222+ input: `diff --git a/config.yml b/config.yml
223+--- a/config.yml 2024-01-15 10:30:00
224++++ b/config.yml 2024-01-15 10:31:00
225+@@ -1,3 +1,4 @@
226+ name: myapp
227+-version: 1.0
228++version: 1.1
229++debug: true
230+`,
231+ want: []parsedDiffFile{
232+ {
233+ path: "config.yml",
234+ before: "name: myapp\nversion: 1.0",
235+ after: "name: myapp\nversion: 1.1\ndebug: true",
236+ },
237+ },
238+ },
239+ {
240+ name: "multi-file diff",
241+ input: `diff --git a/one.txt b/one.txt
242+--- a/one.txt
243++++ b/one.txt
244+@@ -1,3 +1,3 @@
245+ line one
246+-line two
247++line two updated
248+ line three
249+diff --git a/two.txt b/two.txt
250+--- a/two.txt
251++++ b/two.txt
252+@@ -1,2 +1,3 @@
253+ alpha
254++beta
255+ gamma
256+`,
257+ want: []parsedDiffFile{
258+ {
259+ path: "one.txt",
260+ before: "line one\nline two\nline three",
261+ after: "line one\nline two updated\nline three",
262+ },
263+ {
264+ path: "two.txt",
265+ before: "alpha\ngamma",
266+ after: "alpha\nbeta\ngamma",
267+ },
268+ },
269+ },
270+ {
271+ name: "non-git unified patch",
272+ input: `--- old.c
273++++ old.c
274+@@ -1,3 +1,4 @@
275+ #include <stdio.h>
276+-int main() {
277++int main(int argc, char **argv) {
278+ return 0;
279+ }
280+`,
281+ want: []parsedDiffFile{
282+ {
283+ path: "old.c",
284+ before: "#include <stdio.h>\nint main() {\n return 0;\n}",
285+ after: "#include <stdio.h>\nint main(int argc, char **argv) {\n return 0;\n}",
286+ },
287+ },
288+ },
289+ {
290+ name: "non-git new file from /dev/null",
291+ input: `--- /dev/null
292++++ newfile.txt
293+@@ -0,0 +1,2 @@
294++hello
295++world
296+`,
297+ want: []parsedDiffFile{
298+ {
299+ path: "newfile.txt",
300+ before: "",
301+ after: "hello\nworld",
302+ },
303+ },
304+ },
305+ {
306+ name: "non-git new file with only +++ header",
307+ input: `+++ brand_new.go
308+@@ -0,0 +1,3 @@
309++package main
310++
311++func main() {}
312+`,
313+ want: []parsedDiffFile{
314+ {
315+ path: "brand_new.go",
316+ before: "",
317+ after: "package main\n\nfunc main() {}",
318+ },
319+ },
320+ },
321+ {
322+ name: "multi-hunk single file",
323+ input: `diff --git a/big.go b/big.go
324+--- a/big.go
325++++ b/big.go
326+@@ -1,4 +1,5 @@
327+ package main
328++import "os"
329+
330+ func init() {
331+@@ -10,3 +11,3 @@
332+- println("done")
333++ fmt.Println("done")
334+ }
335+`,
336+ want: []parsedDiffFile{
337+ {
338+ path: "big.go",
339+ before: "package main\n\nfunc init() {\n println(\"done\")\n}",
340+ after: "package main\nimport \"os\"\n\nfunc init() {\n fmt.Println(\"done\")\n}",
341+ },
342+ },
343+ },
344+ {
345+ name: "hunk content starting with header-like prefixes",
346+ input: `diff --git a/file.txt b/file.txt
347+--- a/file.txt
348++++ b/file.txt
349+@@ -1,3 +1,3 @@
350+---- tricky
351+++++ newer
352+ keep
353+`,
354+ want: []parsedDiffFile{
355+ {
356+ path: "file.txt",
357+ before: "--- tricky\nkeep",
358+ after: "+++ newer\nkeep",
359+ },
360+ },
361+ },
362+ }
363+
364+ for _, tt := range tests {
365+ t.Run(tt.name, func(t *testing.T) {
366+ t.Parallel()
367+ got := parseUnifiedDiff(tt.input)
368+ if len(got) != len(tt.want) {
369+ t.Errorf("parseUnifiedDiff() returned %d files, want %d", len(got), len(tt.want))
370+ return
371+ }
372+ for i, w := range tt.want {
373+ if got[i].path != w.path {
374+ t.Errorf("parseUnifiedDiff()[%d].path = %q, want %q", i, got[i].path, w.path)
375+ }
376+ if got[i].before != w.before {
377+ t.Errorf("parseUnifiedDiff()[%d].before = %q, want %q", i, got[i].before, w.before)
378+ }
379+ if got[i].after != w.after {
380+ t.Errorf("parseUnifiedDiff()[%d].after = %q, want %q", i, got[i].after, w.after)
381+ }
382+ }
383+ })
384+ }
385+}
386+
387+func TestLooksLikeDiffVersusMarkdown(t *testing.T) {
388+ t.Parallel()
389+
390+ // A unified diff should be detected as a diff, not markdown,
391+ // even though it contains "-" which could match markdown patterns.
392+ diffContent := strings.Join([]string{
393+ "diff --git a/README.md b/README.md",
394+ "--- a/README.md",
395+ "+++ b/README.md",
396+ "@@ -1,3 +1,3 @@",
397+ " # Title",
398+ "-Old subtitle",
399+ "+New subtitle",
400+ " Some content",
401+ }, "\n")
402+
403+ if !looksLikeDiff(diffContent) {
404+ t.Error("looksLikeDiff() should detect unified diff")
405+ }
406+}
internal/ui/chat/tool_result_content.gonew file+59
@@ -0,0 +1,59 @@
1+package chat
2+
3+import (
4+ "encoding/json"
5+ "strings"
6+
7+ "github.com/charmbracelet/crush/internal/diffdetect"
8+ "github.com/charmbracelet/crush/internal/stringext"
9+ "github.com/charmbracelet/crush/internal/ui/styles"
10+)
11+
12+type toolResultContentWidths struct {
13+ Body int
14+ Diff int
15+}
16+
17+func humanizedToolName(name string) string {
18+ name = strings.ReplaceAll(name, "_", " ")
19+ name = strings.ReplaceAll(name, "-", " ")
20+ return stringext.Capitalize(name)
21+}
22+
23+func looksLikeMarkdown(content string) bool {
24+ patterns := []string{
25+ "# ",
26+ "## ",
27+ "**",
28+ "```",
29+ "- ",
30+ "1. ",
31+ "> ",
32+ "---",
33+ "***",
34+ }
35+ for _, p := range patterns {
36+ if strings.Contains(content, p) {
37+ return true
38+ }
39+ }
40+ return false
41+}
42+
43+func renderToolResultTextContent(sty *styles.Styles, content string, widths toolResultContentWidths, expanded bool) string {
44+ var result json.RawMessage
45+ if err := json.Unmarshal([]byte(content), &result); err == nil {
46+ prettyResult, err := json.MarshalIndent(result, "", " ")
47+ if err == nil {
48+ return sty.Tool.Body.Render(toolOutputCodeContent(sty, "result.json", string(prettyResult), 0, widths.Body, expanded))
49+ }
50+ return sty.Tool.Body.Render(toolOutputPlainContent(sty, content, widths.Body, expanded))
51+ }
52+ if diffdetect.IsUnifiedDiff(content) {
53+ return toolOutputDiffContentFromUnified(sty, content, widths.Diff, expanded)
54+ }
55+ if looksLikeMarkdown(content) {
56+ return sty.Tool.Body.Render(toolOutputCodeContent(sty, "result.md", content, 0, widths.Body, expanded))
57+ }
58+ return sty.Tool.Body.Render(toolOutputPlainContent(sty, content, widths.Body, expanded))
59+}
internal/ui/chat/tool_result_content_test.gonew file+116
@@ -0,0 +1,116 @@
1+package chat
2+
3+import (
4+ "encoding/json"
5+ "testing"
6+
7+ "github.com/charmbracelet/crush/internal/ui/styles"
8+)
9+
10+func TestHumanizedToolName(t *testing.T) {
11+ t.Parallel()
12+
13+ tests := []struct {
14+ name string
15+ input string
16+ want string
17+ }{
18+ {name: "snake case", input: "mcp_github_get", want: "Mcp Github Get"},
19+ {name: "kebab case", input: "web-fetch", want: "Web Fetch"},
20+ {name: "mixed", input: "job_output-tool", want: "Job Output Tool"},
21+ }
22+
23+ for _, tt := range tests {
24+ t.Run(tt.name, func(t *testing.T) {
25+ t.Parallel()
26+ if got := humanizedToolName(tt.input); got != tt.want {
27+ t.Fatalf("humanizedToolName() = %q, want %q", got, tt.want)
28+ }
29+ })
30+ }
31+}
32+
33+func TestLooksLikeMarkdown(t *testing.T) {
34+ t.Parallel()
35+
36+ tests := []struct {
37+ name string
38+ content string
39+ want bool
40+ }{
41+ {name: "header", content: "# Title", want: true},
42+ {name: "code fence", content: "```go\nfmt.Println(\"x\")\n```", want: true},
43+ {name: "plain", content: "hello world", want: false},
44+ }
45+
46+ for _, tt := range tests {
47+ t.Run(tt.name, func(t *testing.T) {
48+ t.Parallel()
49+ if got := looksLikeMarkdown(tt.content); got != tt.want {
50+ t.Fatalf("looksLikeMarkdown() = %v, want %v", got, tt.want)
51+ }
52+ })
53+ }
54+}
55+
56+func TestRenderToolResultTextContent(t *testing.T) {
57+ t.Parallel()
58+
59+ sty := styles.DefaultStyles()
60+ styPtr := &sty
61+ widths := toolResultContentWidths{Body: 80, Diff: 82}
62+
63+ t.Run("json branch", func(t *testing.T) {
64+ t.Parallel()
65+ content := `{"a":1}`
66+ var result json.RawMessage
67+ if err := json.Unmarshal([]byte(content), &result); err != nil {
68+ t.Fatalf("json.Unmarshal() error = %v", err)
69+ }
70+ prettyResult, err := json.MarshalIndent(result, "", " ")
71+ if err != nil {
72+ t.Fatalf("json.MarshalIndent() error = %v", err)
73+ }
74+ expected := styPtr.Tool.Body.Render(toolOutputCodeContent(styPtr, "result.json", string(prettyResult), 0, widths.Body, false))
75+ got := renderToolResultTextContent(styPtr, content, widths, false)
76+ if got != expected {
77+ t.Fatal("renderToolResultTextContent() did not choose JSON rendering")
78+ }
79+ })
80+
81+ t.Run("diff branch before markdown", func(t *testing.T) {
82+ t.Parallel()
83+ content := `diff --git a/README.md b/README.md
84+--- a/README.md
85++++ b/README.md
86+@@ -1 +1 @@
87+-# Old
88++# New
89+`
90+ expected := toolOutputDiffContentFromUnified(styPtr, content, widths.Diff, false)
91+ got := renderToolResultTextContent(styPtr, content, widths, false)
92+ if got != expected {
93+ t.Fatal("renderToolResultTextContent() did not choose diff rendering")
94+ }
95+ })
96+
97+ t.Run("markdown branch", func(t *testing.T) {
98+ t.Parallel()
99+ content := "# Title\n\nBody"
100+ expected := styPtr.Tool.Body.Render(toolOutputCodeContent(styPtr, "result.md", content, 0, widths.Body, false))
101+ got := renderToolResultTextContent(styPtr, content, widths, false)
102+ if got != expected {
103+ t.Fatal("renderToolResultTextContent() did not choose markdown rendering")
104+ }
105+ })
106+
107+ t.Run("plain branch", func(t *testing.T) {
108+ t.Parallel()
109+ content := "plain text"
110+ expected := styPtr.Tool.Body.Render(toolOutputPlainContent(styPtr, content, widths.Body, false))
111+ got := renderToolResultTextContent(styPtr, content, widths, false)
112+ if got != expected {
113+ t.Fatal("renderToolResultTextContent() did not choose plain rendering")
114+ }
115+ })
116+}
internal/ui/chat/tools.go+1−1
@@ -1406,6 +1406,6 @@ func prettifyToolName(name string) string {
14061406 case tools.WriteToolName:
14071407 return "Write"
14081408 default:
1409− return genericPrettyName(name)
1409+ return humanizedToolName(name)
14101410 }
14111411}
internal/ui/chat/unified_diff.gonew file+171
@@ -0,0 +1,171 @@
1+package chat
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/charmbracelet/crush/internal/diffdetect"
8+ "github.com/charmbracelet/crush/internal/ui/common"
9+ "github.com/charmbracelet/crush/internal/ui/styles"
10+)
11+
12+type parsedDiffFile struct {
13+ path string
14+ before string
15+ after string
16+}
17+
18+func looksLikeDiff(content string) bool {
19+ return diffdetect.IsUnifiedDiff(content)
20+}
21+
22+func parseUnifiedDiff(content string) []parsedDiffFile {
23+ type fileBuilder struct {
24+ path string
25+ before strings.Builder
26+ after strings.Builder
27+ }
28+
29+ var files []fileBuilder
30+ currentIdx := -1
31+ inHunk := false
32+ lines := strings.Split(content, "\n")
33+
34+ for i, line := range lines {
35+ if strings.HasPrefix(line, "diff --git ") {
36+ inHunk = false
37+ parts := strings.SplitN(line, " ", 4)
38+ if len(parts) >= 4 {
39+ files = append(files, fileBuilder{path: strings.TrimPrefix(parts[3], "b/")})
40+ currentIdx = len(files) - 1
41+ }
42+ continue
43+ }
44+
45+ if strings.HasPrefix(line, "@@") {
46+ inHunk = true
47+ continue
48+ }
49+
50+ if strings.HasPrefix(line, "index ") || strings.HasPrefix(line, "new file") || strings.HasPrefix(line, "deleted file") {
51+ inHunk = false
52+ continue
53+ }
54+
55+ nextIsPlusHeader := i+1 < len(lines) && strings.HasPrefix(lines[i+1], "+++ ")
56+ if strings.HasPrefix(line, "--- ") && (!inHunk || nextIsPlusHeader) {
57+ startedNewFileFromHunk := inHunk && nextIsPlusHeader
58+ inHunk = false
59+ p := strings.TrimPrefix(line, "--- ")
60+ p = strings.TrimPrefix(p, "a/")
61+ if idx := strings.Index(p, "\t"); idx >= 0 {
62+ p = p[:idx]
63+ }
64+ if currentIdx < 0 || startedNewFileFromHunk {
65+ files = append(files, fileBuilder{path: p})
66+ currentIdx = len(files) - 1
67+ continue
68+ }
69+ if p != "/dev/null" {
70+ files[currentIdx].path = p
71+ }
72+ continue
73+ }
74+
75+ if strings.HasPrefix(line, "+++ ") && !inHunk {
76+ p := strings.TrimPrefix(line, "+++ ")
77+ p = strings.TrimPrefix(p, "b/")
78+ if idx := strings.Index(p, "\t"); idx >= 0 {
79+ p = p[:idx]
80+ }
81+ if currentIdx < 0 {
82+ if p != "/dev/null" {
83+ files = append(files, fileBuilder{path: p})
84+ currentIdx = len(files) - 1
85+ }
86+ continue
87+ }
88+ if p != "/dev/null" && (files[currentIdx].path == "" || strings.HasPrefix(files[currentIdx].path, "/dev/null")) {
89+ files[currentIdx].path = p
90+ }
91+ continue
92+ }
93+
94+ if currentIdx < 0 {
95+ continue
96+ }
97+
98+ if strings.HasPrefix(line, "-") {
99+ inHunk = true
100+ files[currentIdx].before.WriteString(line[1:])
101+ files[currentIdx].before.WriteByte('\n')
102+ continue
103+ }
104+
105+ if strings.HasPrefix(line, "+") {
106+ inHunk = true
107+ files[currentIdx].after.WriteString(line[1:])
108+ files[currentIdx].after.WriteByte('\n')
109+ continue
110+ }
111+
112+ if strings.HasPrefix(line, " ") {
113+ inHunk = true
114+ lineContent := line[1:]
115+ files[currentIdx].before.WriteString(lineContent)
116+ files[currentIdx].before.WriteByte('\n')
117+ files[currentIdx].after.WriteString(lineContent)
118+ files[currentIdx].after.WriteByte('\n')
119+ }
120+ }
121+
122+ result := make([]parsedDiffFile, 0, len(files))
123+ for _, f := range files {
124+ result = append(result, parsedDiffFile{
125+ path: f.path,
126+ before: strings.TrimSuffix(f.before.String(), "\n"),
127+ after: strings.TrimSuffix(f.after.String(), "\n"),
128+ })
129+ }
130+ return result
131+}
132+
133+func toolOutputDiffContentFromUnified(sty *styles.Styles, content string, width int, expanded bool) string {
134+ files := parseUnifiedDiff(content)
135+ if len(files) == 0 {
136+ bodyWidth := width - toolBodyLeftPaddingTotal
137+ return sty.Tool.Body.Render(toolOutputCodeContent(sty, "result.diff", content, 0, bodyWidth, expanded))
138+ }
139+ bodyWidth := width - toolBodyLeftPaddingTotal
140+ var blocks []string
141+ for i, f := range files {
142+ formatter := common.DiffFormatter(sty).
143+ Before(f.path, f.before).
144+ After(f.path, f.after).
145+ Width(bodyWidth)
146+ if len(files) > 1 {
147+ formatter = formatter.FileName(f.path)
148+ }
149+ if width > maxTextWidth {
150+ formatter = formatter.Split()
151+ }
152+ formatted := formatter.String()
153+ if i < len(files)-1 {
154+ formatted += "\n"
155+ }
156+ blocks = append(blocks, formatted)
157+ }
158+ combined := strings.Join(blocks, "\n")
159+ lines := strings.Split(combined, "\n")
160+ maxLines := responseContextHeight
161+ if expanded {
162+ maxLines = len(lines)
163+ }
164+ if len(lines) > maxLines && !expanded {
165+ truncMsg := sty.Tool.DiffTruncation.
166+ Width(bodyWidth).
167+ Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines))
168+ combined = strings.Join(lines[:maxLines], "\n") + "\n" + truncMsg
169+ }
170+ return sty.Tool.Body.Render(combined)
171+}
internal/ui/diffview/diffview.go+43
@@ -38,6 +38,7 @@ type DiffView struct {
3838 layout layout
3939 before file
4040 after file
41+ fileName string
4142 contextLines int
4243 lineNumbers bool
4344 height int
@@ -112,6 +113,12 @@ func (dv *DiffView) After(path, content string) *DiffView {
112113 return dv
113114}
114115
116+// FileName sets the file name header to display above the diff.
117+func (dv *DiffView) FileName(name string) *DiffView {
118+ dv.fileName = name
119+ return dv
120+}
121+
115122// clearCaches clears all caches when content or major settings change.
116123func (dv *DiffView) clearCaches() {
117124 dv.cachedLexer = nil
@@ -287,6 +294,7 @@ func (dv *DiffView) adjustStyles() {
287294 dv.style.EqualLine.LineNumber = setPadding(dv.style.EqualLine.LineNumber)
288295 dv.style.InsertLine.LineNumber = setPadding(dv.style.InsertLine.LineNumber)
289296 dv.style.DeleteLine.LineNumber = setPadding(dv.style.DeleteLine.LineNumber)
297+ dv.style.Filename.LineNumber = setPadding(dv.style.Filename.LineNumber)
290298}
291299
292300// detectNumDigits calculates the maximum number of digits needed for before and
@@ -304,6 +312,10 @@ func (dv *DiffView) detectNumDigits() {
304312func (dv *DiffView) detectTotalLines() {
305313 dv.totalLines = 0
306314
315+ if dv.fileName != "" {
316+ dv.totalLines++
317+ }
318+
307319 switch dv.layout {
308320 case layoutUnified:
309321 for _, h := range dv.unified.Hunks {
@@ -415,6 +427,20 @@ func (dv *DiffView) renderUnified() string {
415427
416428outer:
417429 for i, h := range dv.unified.Hunks {
430+ // Render file name header before the first hunk.
431+ if i == 0 && dv.fileName != "" {
432+ if shouldWrite() {
433+ ls := dv.style.Filename
434+ if dv.lineNumbers {
435+ b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
436+ b.WriteString(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
437+ }
438+ content := ansi.Truncate(" "+dv.fileName, dv.fullCodeWidth, "…")
439+ b.WriteString(ls.Code.Width(dv.fullCodeWidth).Render(content))
440+ b.WriteString("\n")
441+ }
442+ printedLines++
443+ }
418444 if shouldWrite() {
419445 ls := dv.style.DividerLine
420446 if dv.lineNumbers {
@@ -525,6 +551,23 @@ func (dv *DiffView) renderSplit() string {
525551
526552outer:
527553 for i, h := range dv.splitHunks {
554+ // Render file name header before the first hunk.
555+ if i == 0 && dv.fileName != "" {
556+ if shouldWrite() {
557+ ls := dv.style.Filename
558+ if dv.lineNumbers {
559+ b.WriteString(ls.LineNumber.Render(pad("…", dv.beforeNumDigits)))
560+ }
561+ content := ansi.Truncate(" "+dv.fileName, dv.fullCodeWidth, "…")
562+ b.WriteString(ls.Code.Width(dv.fullCodeWidth).Render(content))
563+ if dv.lineNumbers {
564+ b.WriteString(ls.LineNumber.Render(pad("…", dv.afterNumDigits)))
565+ }
566+ b.WriteString(ls.Code.Width(dv.fullCodeWidth + btoi(dv.extraColOnAfter)).Render(" "))
567+ b.WriteRune('\n')
568+ }
569+ printedLines++
570+ }
528571 if shouldWrite() {
529572 ls := dv.style.DividerLine
530573 if dv.lineNumbers {
internal/ui/diffview/style.go+17
@@ -21,6 +21,7 @@ type Style struct {
2121 EqualLine LineStyle
2222 InsertLine LineStyle
2323 DeleteLine LineStyle
24+ Filename LineStyle
2425}
2526
2627// DefaultLightStyle provides a default light theme style for the diff view.
@@ -70,6 +71,14 @@ func DefaultLightStyle() Style {
7071 Foreground(charmtone.Pepper).
7172 Background(lipgloss.Color("#ffebee")),
7273 },
74+ Filename: LineStyle{
75+ LineNumber: lipgloss.NewStyle().
76+ Foreground(charmtone.Iron).
77+ Background(charmtone.Thunder),
78+ Code: lipgloss.NewStyle().
79+ Foreground(charmtone.Iron).
80+ Background(charmtone.Thunder),
81+ },
7382 }
7483}
7584
@@ -120,5 +129,13 @@ func DefaultDarkStyle() Style {
120129 Foreground(charmtone.Salt).
121130 Background(lipgloss.Color("#3a3030")),
122131 },
132+ Filename: LineStyle{
133+ LineNumber: lipgloss.NewStyle().
134+ Foreground(charmtone.Smoke).
135+ Background(charmtone.Sapphire),
136+ Code: lipgloss.NewStyle().
137+ Foreground(charmtone.Smoke).
138+ Background(charmtone.Sapphire),
139+ },
123140 }
124141}
internal/ui/styles/styles.go+8
@@ -1056,6 +1056,14 @@ func DefaultStyles() Styles {
10561056 Code: lipgloss.NewStyle().
10571057 Background(lipgloss.Color("#383030")),
10581058 },
1059+ Filename: diffview.LineStyle{
1060+ LineNumber: lipgloss.NewStyle().
1061+ Foreground(fgHalfMuted).
1062+ Background(bgBaseLighter),
1063+ Code: lipgloss.NewStyle().
1064+ Foreground(fgHalfMuted).
1065+ Background(bgBaseLighter),
1066+ },
10591067 }
10601068
10611069 s.FilePicker = filepicker.Styles{