on main help sign in
feat(ui): add difftastic as optional external diff renderer for edit diffs (#1)

Add a new `diff_tool` TUI config option (enum: builtin, difftastic; default: builtin) that shells out to the difftastic binary for syntax-aware structural diffs in the chat view. When set to `difftastic`, Edit, MultiEdit, and unified-diff tool results are rendered by writing before/after content to temp files and running `difft --color=always --display=inline --width=<w>`. If the binary is not in PATH or fails, the renderer falls back to the existing in-process go-udiff + chroma renderer. The permission dialog diff view is intentionally left unchanged — it relies on interactive scrolling/XOffset/YOffset that static difftastic output cannot provide. Config threading: the DiffTool setting flows from config.TUIOptions through ToolRenderOpts and baseToolMessageItem into the three diff rendering functions. To enable: set `"tui": { "diff_tool": "difftastic" }` in crush.json and ensure `difft` is in PATH.

commit2153b16a285dfbc2154d2ed09689513bfd41a62e authoriko <mail@iko.soy> dateThu 9 Jul 2026, 05:24 (3 months ago) parent9af07ec
22 files changed+200−104
internal/cmd/session.go+1−1 internal/config/config.go+1 internal/ui/chat/agent.go+2−2 internal/ui/chat/bash.go+3−3 internal/ui/chat/diagnostics.go+1−1 internal/ui/chat/docker_mcp.go+2−2 internal/ui/chat/fetch.go+3−3 internal/ui/chat/file.go+8−8 internal/ui/chat/generic.go+2−2 internal/ui/chat/lsp_restart.go+1−1 internal/ui/chat/mcp.go+2−2 internal/ui/chat/messages.go+2−1 internal/ui/chat/references.go+1−1 internal/ui/chat/search.go+4−4 internal/ui/chat/todos.go+1−1 internal/ui/chat/tool_result_content.go+2−2 internal/ui/chat/tool_result_content_test.go+5−5 internal/ui/chat/tools.go+65−32 internal/ui/chat/unified_diff.go+13−14 internal/ui/chat/version_bump_test.go+10−10 internal/ui/difftastic/difftastic.go+60 internal/ui/model/ui.go+11−9
internal/cmd/session.go+1−1
@@ -485,7 +485,7 @@ func outputSessionHuman(ctx context.Context, cfg *config.ConfigStore, sess sessi
485485
486486 first := true
487487 for _, msg := range msgs {
488− items := chat.ExtractMessageItems(&styles, msg, toolResults)
488+ items := chat.ExtractMessageItems(&styles, msg, toolResults, "")
489489 for _, item := range items {
490490 if !first {
491491 fmt.Fprintln(&buf)
internal/config/config.go+1
@@ -221,6 +221,7 @@ type LSPConfig struct {
221221type TUIOptions struct {
222222 CompactMode bool `json:"compact_mode,omitempty" jsonschema:"description=Enable compact mode for the TUI interface,default=false"`
223223 DiffMode string `json:"diff_mode,omitempty" jsonschema:"description=Diff mode for the TUI interface,enum=unified,enum=split"`
224+ DiffTool string `json:"diff_tool,omitempty" jsonschema:"description=External diff tool for edit diffs in the TUI,enum=builtin,enum=difftastic,default=builtin"`
224225 // Theme selects the TUI color scheme. "auto" (the default) picks light
225226 // or dark based on the detected terminal background; "dark"/"light"
226227 // force a specific scheme.
internal/ui/chat/agent.go+2−2
@@ -57,7 +57,7 @@ func NewAgentToolMessageItem(
5757 canceled bool,
5858) *AgentToolMessageItem {
5959 t := &AgentToolMessageItem{}
60− t.baseToolMessageItem = newBaseToolMessageItem(sty, toolCall, result, &AgentToolRenderContext{agent: t}, canceled)
60+ t.baseToolMessageItem = newBaseToolMessageItem(sty, toolCall, result, &AgentToolRenderContext{agent: t}, canceled, "")
6161 // For the agent tool we keep spinning until the tool call is finished.
6262 t.spinningFunc = func(state SpinningState) bool {
6363 return !state.HasResult() && !state.IsCanceled()
@@ -238,7 +238,7 @@ func NewAgenticFetchToolMessageItem(
238238 canceled bool,
239239) *AgenticFetchToolMessageItem {
240240 t := &AgenticFetchToolMessageItem{}
241− t.baseToolMessageItem = newBaseToolMessageItem(sty, toolCall, result, &AgenticFetchToolRenderContext{fetch: t}, canceled)
241+ t.baseToolMessageItem = newBaseToolMessageItem(sty, toolCall, result, &AgenticFetchToolRenderContext{fetch: t}, canceled, "")
242242 // For the agentic fetch tool we keep spinning until the tool call is finished.
243243 t.spinningFunc = func(state SpinningState) bool {
244244 return !state.HasResult() && !state.IsCanceled()
internal/ui/chat/bash.go+3−3
@@ -31,7 +31,7 @@ func NewBashToolMessageItem(
3131 result *message.ToolResult,
3232 canceled bool,
3333) ToolMessageItem {
34− return newBaseToolMessageItem(sty, toolCall, result, &BashToolRenderContext{}, canceled)
34+ return newBaseToolMessageItem(sty, toolCall, result, &BashToolRenderContext{}, canceled, "")
3535}
3636
3737// BashToolRenderContext renders bash tool messages.
@@ -116,7 +116,7 @@ func NewJobOutputToolMessageItem(
116116 result *message.ToolResult,
117117 canceled bool,
118118) ToolMessageItem {
119− return newBaseToolMessageItem(sty, toolCall, result, &JobOutputToolRenderContext{}, canceled)
119+ return newBaseToolMessageItem(sty, toolCall, result, &JobOutputToolRenderContext{}, canceled, "")
120120}
121121
122122// JobOutputToolRenderContext renders job_output tool messages.
@@ -167,7 +167,7 @@ func NewJobKillToolMessageItem(
167167 result *message.ToolResult,
168168 canceled bool,
169169) ToolMessageItem {
170− return newBaseToolMessageItem(sty, toolCall, result, &JobKillToolRenderContext{}, canceled)
170+ return newBaseToolMessageItem(sty, toolCall, result, &JobKillToolRenderContext{}, canceled, "")
171171}
172172
173173// JobKillToolRenderContext renders job_kill tool messages.
internal/ui/chat/diagnostics.go+1−1
@@ -27,7 +27,7 @@ func NewDiagnosticsToolMessageItem(
2727 result *message.ToolResult,
2828 canceled bool,
2929) ToolMessageItem {
30− return newBaseToolMessageItem(sty, toolCall, result, &DiagnosticsToolRenderContext{}, canceled)
30+ return newBaseToolMessageItem(sty, toolCall, result, &DiagnosticsToolRenderContext{}, canceled, "")
3131}
3232
3333// DiagnosticsToolRenderContext renders diagnostics tool messages.
internal/ui/chat/docker_mcp.go+2−2
@@ -28,7 +28,7 @@ func NewDockerMCPToolMessageItem(
2828 result *message.ToolResult,
2929 canceled bool,
3030) ToolMessageItem {
31− return newBaseToolMessageItem(sty, toolCall, result, &DockerMCPToolRenderContext{}, canceled)
31+ return newBaseToolMessageItem(sty, toolCall, result, &DockerMCPToolRenderContext{}, canceled, "")
3232}
3333
3434// DockerMCPToolRenderContext renders Docker MCP tool messages.
@@ -138,7 +138,7 @@ func (d *DockerMCPToolRenderContext) RenderTool(sty *styles.Styles, width int, o
138138
139139 // Handle text content.
140140 if opts.Result.Content != "" {
141− body := renderToolResultTextContent(sty, opts.Result.Content, toolResultContentWidths{Body: bodyWidth, Diff: cappedWidth}, opts.ExpandedContent)
141+ body := renderToolResultTextContent(sty, opts.Result.Content, toolResultContentWidths{Body: bodyWidth, Diff: cappedWidth}, opts.ExpandedContent, opts.DiffTool)
142142 parts = append(parts, body)
143143 }
144144
internal/ui/chat/fetch.go+3−3
@@ -26,7 +26,7 @@ func NewFetchToolMessageItem(
2626 result *message.ToolResult,
2727 canceled bool,
2828) ToolMessageItem {
29− return newBaseToolMessageItem(sty, toolCall, result, &FetchToolRenderContext{}, canceled)
29+ return newBaseToolMessageItem(sty, toolCall, result, &FetchToolRenderContext{}, canceled, "")
3030}
3131
3232// FetchToolRenderContext renders fetch tool messages.
@@ -101,7 +101,7 @@ func NewWebFetchToolMessageItem(
101101 result *message.ToolResult,
102102 canceled bool,
103103) ToolMessageItem {
104− return newBaseToolMessageItem(sty, toolCall, result, &WebFetchToolRenderContext{}, canceled)
104+ return newBaseToolMessageItem(sty, toolCall, result, &WebFetchToolRenderContext{}, canceled, "")
105105}
106106
107107// WebFetchToolRenderContext renders web_fetch tool messages.
@@ -155,7 +155,7 @@ func NewWebSearchToolMessageItem(
155155 result *message.ToolResult,
156156 canceled bool,
157157) ToolMessageItem {
158− return newBaseToolMessageItem(sty, toolCall, result, &WebSearchToolRenderContext{}, canceled)
158+ return newBaseToolMessageItem(sty, toolCall, result, &WebSearchToolRenderContext{}, canceled, "")
159159}
160160
161161// WebSearchToolRenderContext renders web_search tool messages.
internal/ui/chat/file.go+8−8
@@ -29,7 +29,7 @@ func NewViewToolMessageItem(
2929 result *message.ToolResult,
3030 canceled bool,
3131) ToolMessageItem {
32− return newBaseToolMessageItem(sty, toolCall, result, &ViewToolRenderContext{}, canceled)
32+ return newBaseToolMessageItem(sty, toolCall, result, &ViewToolRenderContext{}, canceled, "")
3333}
3434
3535// ViewToolRenderContext renders view tool messages.
@@ -115,7 +115,7 @@ func NewWriteToolMessageItem(
115115 result *message.ToolResult,
116116 canceled bool,
117117) ToolMessageItem {
118− return newBaseToolMessageItem(sty, toolCall, result, &WriteToolRenderContext{}, canceled)
118+ return newBaseToolMessageItem(sty, toolCall, result, &WriteToolRenderContext{}, canceled, "")
119119}
120120
121121// WriteToolRenderContext renders write tool messages.
@@ -151,7 +151,7 @@ func (w *WriteToolRenderContext) RenderTool(sty *styles.Styles, width int, opts
151151 var meta tools.WriteResponseMetadata
152152 if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil && meta.Diff != "" {
153153 errLine := toolErrorContent(sty, opts.Result, cappedWidth, opts.ExpandedContent)
154− diff := toolOutputDiffContentFromUnified(sty, meta.Diff, cappedWidth, opts.ExpandedContent)
154+ diff := toolOutputDiffContentFromUnified(sty, meta.Diff, cappedWidth, opts.ExpandedContent, opts.DiffTool)
155155 return strings.Join([]string{header, "", errLine, "", diff}, "\n")
156156 }
157157 return joinToolParts(header, toolErrorContent(sty, opts.Result, cappedWidth, opts.ExpandedContent))
@@ -184,7 +184,7 @@ func NewEditToolMessageItem(
184184 result *message.ToolResult,
185185 canceled bool,
186186) ToolMessageItem {
187− return newBaseToolMessageItem(sty, toolCall, result, &EditToolRenderContext{}, canceled)
187+ return newBaseToolMessageItem(sty, toolCall, result, &EditToolRenderContext{}, canceled, "")
188188}
189189
190190// EditToolRenderContext renders edit tool messages.
@@ -223,7 +223,7 @@ func (e *EditToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
223223 return joinToolParts(header, body)
224224 }
225225
226− diff := toolOutputDiffContent(sty, file, meta.OldContent, meta.NewContent, width, opts.ExpandedContent)
226+ diff := toolOutputDiffContent(sty, file, meta.OldContent, meta.NewContent, width, opts.ExpandedContent, opts.DiffTool)
227227
228228 // On error (e.g. denied permission), show error above the diff.
229229 if opts.Result.IsError {
@@ -252,7 +252,7 @@ func NewMultiEditToolMessageItem(
252252 result *message.ToolResult,
253253 canceled bool,
254254) ToolMessageItem {
255− return newBaseToolMessageItem(sty, toolCall, result, &MultiEditToolRenderContext{}, canceled)
255+ return newBaseToolMessageItem(sty, toolCall, result, &MultiEditToolRenderContext{}, canceled, "")
256256}
257257
258258// MultiEditToolRenderContext renders multi-edit tool messages.
@@ -297,7 +297,7 @@ func (m *MultiEditToolRenderContext) RenderTool(sty *styles.Styles, width int, o
297297 }
298298
299299 // Render diff with optional failed edits note.
300− diff := toolOutputMultiEditDiffContent(sty, file, meta, len(params.Edits), width, opts.ExpandedContent)
300+ diff := toolOutputMultiEditDiffContent(sty, file, meta, len(params.Edits), width, opts.ExpandedContent, opts.DiffTool)
301301
302302 // On error (e.g. denied permission), show error above the diff.
303303 if opts.Result.IsError {
@@ -326,7 +326,7 @@ func NewDownloadToolMessageItem(
326326 result *message.ToolResult,
327327 canceled bool,
328328) ToolMessageItem {
329− return newBaseToolMessageItem(sty, toolCall, result, &DownloadToolRenderContext{}, canceled)
329+ return newBaseToolMessageItem(sty, toolCall, result, &DownloadToolRenderContext{}, canceled, "")
330330}
331331
332332// DownloadToolRenderContext renders download tool messages.
internal/ui/chat/generic.go+2−2
@@ -22,7 +22,7 @@ func NewGenericToolMessageItem(
2222 result *message.ToolResult,
2323 canceled bool,
2424) ToolMessageItem {
25− return newBaseToolMessageItem(sty, toolCall, result, &GenericToolRenderContext{}, canceled)
25+ return newBaseToolMessageItem(sty, toolCall, result, &GenericToolRenderContext{}, canceled, "")
2626}
2727
2828// GenericToolRenderContext renders unknown/generic tool messages.
@@ -68,6 +68,6 @@ func (g *GenericToolRenderContext) RenderTool(sty *styles.Styles, width int, opt
6868 return joinToolParts(header, body)
6969 }
7070
71− body := renderToolResultTextContent(sty, opts.Result.Content, toolResultContentWidths{Body: bodyWidth, Diff: cappedWidth}, opts.ExpandedContent)
71+ body := renderToolResultTextContent(sty, opts.Result.Content, toolResultContentWidths{Body: bodyWidth, Diff: cappedWidth}, opts.ExpandedContent, opts.DiffTool)
7272 return joinToolParts(header, body)
7373}
internal/ui/chat/lsp_restart.go+1−1
@@ -22,7 +22,7 @@ func NewLSPRestartToolMessageItem(
2222 result *message.ToolResult,
2323 canceled bool,
2424) ToolMessageItem {
25− return newBaseToolMessageItem(sty, toolCall, result, &LSPRestartToolRenderContext{}, canceled)
25+ return newBaseToolMessageItem(sty, toolCall, result, &LSPRestartToolRenderContext{}, canceled, "")
2626}
2727
2828// LSPRestartToolRenderContext renders lsprestart tool messages.
internal/ui/chat/mcp.go+2−2
@@ -23,7 +23,7 @@ func NewMCPToolMessageItem(
2323 result *message.ToolResult,
2424 canceled bool,
2525) ToolMessageItem {
26− return newBaseToolMessageItem(sty, toolCall, result, &MCPToolRenderContext{}, canceled)
26+ return newBaseToolMessageItem(sty, toolCall, result, &MCPToolRenderContext{}, canceled, "")
2727}
2828
2929// MCPToolRenderContext renders bash tool messages.
@@ -73,6 +73,6 @@ func (b *MCPToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *T
7373 }
7474
7575 bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
76− body := renderToolResultTextContent(sty, opts.Result.Content, toolResultContentWidths{Body: bodyWidth, Diff: cappedWidth}, opts.ExpandedContent)
76+ body := renderToolResultTextContent(sty, opts.Result.Content, toolResultContentWidths{Body: bodyWidth, Diff: cappedWidth}, opts.ExpandedContent, opts.DiffTool)
7777 return joinToolParts(header, body)
7878}
internal/ui/chat/messages.go+2−1
@@ -364,7 +364,7 @@ func cappedMessageWidth(availableWidth int) int {
364364//
365365// For assistant messages with tool calls, pass a toolResults map to link results.
366366// Use BuildToolResultMap to create this map from all messages in a session.
367−func ExtractMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[string]message.ToolResult) []MessageItem {
367+func ExtractMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[string]message.ToolResult, diffTool string) []MessageItem {
368368 switch msg.Role {
369369 case message.User:
370370 // Reconstruct shell command items from ShellCommand parts.
@@ -401,6 +401,7 @@ func ExtractMessageItems(sty *styles.Styles, msg *message.Message, toolResults m
401401 tc,
402402 result,
403403 msg.FinishReason() == message.FinishReasonCanceled,
404+ diffTool,
404405 ))
405406 }
406407 return items
internal/ui/chat/references.go+1−1
@@ -23,7 +23,7 @@ func NewReferencesToolMessageItem(
2323 result *message.ToolResult,
2424 canceled bool,
2525) ToolMessageItem {
26− return newBaseToolMessageItem(sty, toolCall, result, &ReferencesToolRenderContext{}, canceled)
26+ return newBaseToolMessageItem(sty, toolCall, result, &ReferencesToolRenderContext{}, canceled, "")
2727}
2828
2929// ReferencesToolRenderContext renders references tool messages.
internal/ui/chat/search.go+4−4
@@ -27,7 +27,7 @@ func NewGlobToolMessageItem(
2727 result *message.ToolResult,
2828 canceled bool,
2929) ToolMessageItem {
30− return newBaseToolMessageItem(sty, toolCall, result, &GlobToolRenderContext{}, canceled)
30+ return newBaseToolMessageItem(sty, toolCall, result, &GlobToolRenderContext{}, canceled, "")
3131}
3232
3333// GlobToolRenderContext renders glob tool messages.
@@ -86,7 +86,7 @@ func NewGrepToolMessageItem(
8686 result *message.ToolResult,
8787 canceled bool,
8888) ToolMessageItem {
89− return newBaseToolMessageItem(sty, toolCall, result, &GrepToolRenderContext{}, canceled)
89+ return newBaseToolMessageItem(sty, toolCall, result, &GrepToolRenderContext{}, canceled, "")
9090}
9191
9292// GrepToolRenderContext renders grep tool messages.
@@ -151,7 +151,7 @@ func NewLSToolMessageItem(
151151 result *message.ToolResult,
152152 canceled bool,
153153) ToolMessageItem {
154− return newBaseToolMessageItem(sty, toolCall, result, &LSToolRenderContext{}, canceled)
154+ return newBaseToolMessageItem(sty, toolCall, result, &LSToolRenderContext{}, canceled, "")
155155}
156156
157157// LSToolRenderContext renders ls tool messages.
@@ -211,7 +211,7 @@ func NewSourcegraphToolMessageItem(
211211 result *message.ToolResult,
212212 canceled bool,
213213) ToolMessageItem {
214− return newBaseToolMessageItem(sty, toolCall, result, &SourcegraphToolRenderContext{}, canceled)
214+ return newBaseToolMessageItem(sty, toolCall, result, &SourcegraphToolRenderContext{}, canceled, "")
215215}
216216
217217// SourcegraphToolRenderContext renders sourcegraph tool messages.
internal/ui/chat/todos.go+1−1
@@ -31,7 +31,7 @@ func NewTodosToolMessageItem(
3131 result *message.ToolResult,
3232 canceled bool,
3333) ToolMessageItem {
34− return newBaseToolMessageItem(sty, toolCall, result, &TodosToolRenderContext{}, canceled)
34+ return newBaseToolMessageItem(sty, toolCall, result, &TodosToolRenderContext{}, canceled, "")
3535}
3636
3737// TodosToolRenderContext renders todos tool messages.
internal/ui/chat/tool_result_content.go+2−2
@@ -41,7 +41,7 @@ func looksLikeMarkdown(content string) bool {
4141 return false
4242}
4343
44−func renderToolResultTextContent(sty *styles.Styles, content string, widths toolResultContentWidths, expanded bool) string {
44+func renderToolResultTextContent(sty *styles.Styles, content string, widths toolResultContentWidths, expanded bool, diffTool string) string {
4545 var result json.RawMessage
4646 if err := json.Unmarshal([]byte(content), &result); err == nil {
4747 prettyResult, err := json.MarshalIndent(result, "", " ")
@@ -51,7 +51,7 @@ func renderToolResultTextContent(sty *styles.Styles, content string, widths tool
5151 return sty.Tool.Body.Render(toolOutputPlainContent(sty, content, widths.Body, expanded))
5252 }
5353 if diffdetect.IsUnifiedDiff(content) {
54− return toolOutputDiffContentFromUnified(sty, content, widths.Diff, expanded)
54+ return toolOutputDiffContentFromUnified(sty, content, widths.Diff, expanded, diffTool)
5555 }
5656 if looksLikeMarkdown(content) {
5757 return sty.Tool.Body.Render(toolOutputCodeContent(sty, "result.md", content, 0, widths.Body, expanded))
internal/ui/chat/tool_result_content_test.go+5−5
@@ -72,7 +72,7 @@ func TestRenderToolResultTextContent(t *testing.T) {
7272 t.Fatalf("json.MarshalIndent() error = %v", err)
7373 }
7474 expected := styPtr.Tool.Body.Render(toolOutputCodeContent(styPtr, "result.json", string(prettyResult), 0, widths.Body, false))
75− got := renderToolResultTextContent(styPtr, content, widths, false)
75+ got := renderToolResultTextContent(styPtr, content, widths, false, "")
7676 if got != expected {
7777 t.Fatal("renderToolResultTextContent() did not choose JSON rendering")
7878 }
@@ -87,8 +87,8 @@ func TestRenderToolResultTextContent(t *testing.T) {
8787-# Old
8888+# New
8989`
90− expected := toolOutputDiffContentFromUnified(styPtr, content, widths.Diff, false)
91− got := renderToolResultTextContent(styPtr, content, widths, false)
90+ expected := toolOutputDiffContentFromUnified(styPtr, content, widths.Diff, false, "")
91+ got := renderToolResultTextContent(styPtr, content, widths, false, "")
9292 if got != expected {
9393 t.Fatal("renderToolResultTextContent() did not choose diff rendering")
9494 }
@@ -98,7 +98,7 @@ func TestRenderToolResultTextContent(t *testing.T) {
9898 t.Parallel()
9999 content := "# Title\n\nBody"
100100 expected := styPtr.Tool.Body.Render(toolOutputCodeContent(styPtr, "result.md", content, 0, widths.Body, false))
101− got := renderToolResultTextContent(styPtr, content, widths, false)
101+ got := renderToolResultTextContent(styPtr, content, widths, false, "")
102102 if got != expected {
103103 t.Fatal("renderToolResultTextContent() did not choose markdown rendering")
104104 }
@@ -108,7 +108,7 @@ func TestRenderToolResultTextContent(t *testing.T) {
108108 t.Parallel()
109109 content := "plain text"
110110 expected := styPtr.Tool.Body.Render(toolOutputPlainContent(styPtr, content, widths.Body, false))
111− got := renderToolResultTextContent(styPtr, content, widths, false)
111+ got := renderToolResultTextContent(styPtr, content, widths, false, "")
112112 if got != expected {
113113 t.Fatal("renderToolResultTextContent() did not choose plain rendering")
114114 }
internal/ui/chat/tools.go+65−32
@@ -19,6 +19,7 @@ import (
1919 "github.com/charmbracelet/crush/internal/stringext"
2020 "github.com/charmbracelet/crush/internal/ui/anim"
2121 "github.com/charmbracelet/crush/internal/ui/common"
22+ "github.com/charmbracelet/crush/internal/ui/difftastic"
2223 "github.com/charmbracelet/crush/internal/ui/list"
2324 "github.com/charmbracelet/crush/internal/ui/styles"
2425 "github.com/charmbracelet/x/ansi"
@@ -57,6 +58,7 @@ type ToolMessageItem interface {
5758 SetMessageID(id string)
5859 SetStatus(status ToolStatus)
5960 Status() ToolStatus
61+ SetDiffTool(tool string)
6062}
6163
6264// Compactable is an interface for tool items that can render in a compacted mode.
@@ -103,6 +105,10 @@ type ToolRenderOpts struct {
103105 Compact bool
104106 IsSpinning bool
105107 Status ToolStatus
108+ // DiffTool selects the external diff renderer for edit/multi-edit diffs.
109+ // "builtin" (the default) uses the in-process renderer; "difftastic"
110+ // shells out to the difft binary.
111+ DiffTool string
106112}
107113
108114// IsPending returns true if the tool call is still pending (not finished and
@@ -163,6 +169,8 @@ type baseToolMessageItem struct {
163169 sty *styles.Styles
164170 anim *anim.Anim
165171 expandedContent bool
172+ // diffTool selects the external diff renderer for edit/multi-edit diffs.
173+ diffTool string
166174}
167175
168176var _ Expandable = (*baseToolMessageItem)(nil)
@@ -174,6 +182,7 @@ func newBaseToolMessageItem(
174182 result *message.ToolResult,
175183 toolRenderer ToolRenderer,
176184 canceled bool,
185+ diffTool string,
177186) *baseToolMessageItem {
178187 // we only do full width for diffs (as far as I know)
179188 hasCappedWidth := toolCall.Name != tools.EditToolName && toolCall.Name != tools.MultiEditToolName
@@ -195,6 +204,7 @@ func newBaseToolMessageItem(
195204 result: result,
196205 status: status,
197206 hasCappedWidth: hasCappedWidth,
207+ diffTool: diffTool,
198208 }
199209 t.anim = anim.New(anim.Settings{
200210 ID: toolCall.ID,
@@ -219,6 +229,7 @@ func NewToolMessageItem(
219229 toolCall message.ToolCall,
220230 result *message.ToolResult,
221231 canceled bool,
232+ diffTool string,
222233) ToolMessageItem {
223234 var item ToolMessageItem
224235 switch toolCall.Name {
@@ -273,6 +284,7 @@ func NewToolMessageItem(
273284 item = NewGenericToolMessageItem(sty, toolCall, result, canceled)
274285 }
275286 }
287+ item.SetDiffTool(diffTool)
276288 item.SetMessageID(messageID)
277289 return item
278290}
@@ -287,6 +299,16 @@ func (t *baseToolMessageItem) SetCompact(compact bool) {
287299 t.Bump()
288300}
289301
302+// SetDiffTool sets the external diff tool preference and invalidates the cache.
303+func (t *baseToolMessageItem) SetDiffTool(tool string) {
304+ if t.diffTool == tool {
305+ return
306+ }
307+ t.diffTool = tool
308+ t.clearCache()
309+ t.Bump()
310+}
311+
290312// ID returns the unique identifier for this tool message item.
291313func (t *baseToolMessageItem) ID() string {
292314 return t.toolCall.ID
@@ -338,6 +360,7 @@ func (t *baseToolMessageItem) RawRender(width int) string {
338360 Compact: t.isCompact,
339361 IsSpinning: t.isSpinning(),
340362 Status: t.computeStatus(),
363+ DiffTool: t.diffTool,
341364 })
342365
343366 // Prepend hook indicator if hooks ran for this tool call.
@@ -985,9 +1008,16 @@ func formatSize(bytes int) string {
9851008}
9861009
9871010// toolOutputDiffContent renders a diff between old and new content.
988−func toolOutputDiffContent(sty *styles.Styles, file, oldContent, newContent string, width int, expanded bool) string {
1011+func toolOutputDiffContent(sty *styles.Styles, file, oldContent, newContent string, width int, expanded bool, diffTool string) string {
9891012 bodyWidth := width - toolBodyLeftPaddingTotal
9901013
1014+ // Try difftastic if enabled. Fall back to builtin on any failure.
1015+ if diffTool == "difftastic" {
1016+ if out, err := difftastic.RenderDiff(file, oldContent, newContent, bodyWidth); err == nil && out != "" {
1017+ return sty.Tool.Body.Render(truncateDiff(sty, out, bodyWidth, expanded))
1018+ }
1019+ }
1020+
9911021 formatter := common.DiffFormatter(sty).
9921022 Before(file, oldContent).
9931023 After(file, newContent).
@@ -999,22 +1029,7 @@ func toolOutputDiffContent(sty *styles.Styles, file, oldContent, newContent stri
9991029 }
10001030
10011031 formatted := formatter.String()
1002− lines := strings.Split(formatted, "\n")
1003−
1004− // Truncate if needed.
1005− maxLines := diffContextHeight
1006− if expanded {
1007− maxLines = len(lines)
1008− }
1009−
1010− if len(lines) > maxLines && !expanded {
1011− truncMsg := sty.Tool.DiffTruncation.
1012− Width(bodyWidth).
1013− Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines))
1014− formatted = strings.Join(lines[:maxLines], "\n") + "\n" + truncMsg
1015− }
1016−
1017− return sty.Tool.Body.Render(formatted)
1032+ return sty.Tool.Body.Render(truncateDiff(sty, formatted, bodyWidth, expanded))
10181033}
10191034
10201035// formatTimeout converts timeout seconds to a duration string (e.g., "30s").
@@ -1035,9 +1050,24 @@ func formatNonZero(value int) string {
10351050}
10361051
10371052// toolOutputMultiEditDiffContent renders a diff with optional failed edits note.
1038−func toolOutputMultiEditDiffContent(sty *styles.Styles, file string, meta tools.MultiEditResponseMetadata, totalEdits, width int, expanded bool) string {
1053+func toolOutputMultiEditDiffContent(sty *styles.Styles, file string, meta tools.MultiEditResponseMetadata, totalEdits, width int, expanded bool, diffTool string) string {
10391054 bodyWidth := width - toolBodyLeftPaddingTotal
10401055
1056+ // Try difftastic if enabled. Fall back to builtin on any failure.
1057+ if diffTool == "difftastic" {
1058+ if out, err := difftastic.RenderDiff(file, meta.OldContent, meta.NewContent, bodyWidth); err == nil && out != "" {
1059+ formatted := truncateDiff(sty, out, bodyWidth, expanded)
1060+ // Add failed edits note if any exist.
1061+ if len(meta.EditsFailed) > 0 {
1062+ noteTag := sty.Tool.NoteTag.Render("Note")
1063+ noteMsg := fmt.Sprintf("%d of %d edits succeeded", meta.EditsApplied, totalEdits)
1064+ note := fmt.Sprintf("%s %s", noteTag, sty.Tool.NoteMessage.Render(noteMsg))
1065+ formatted = formatted + "\n\n" + note
1066+ }
1067+ return sty.Tool.Body.Render(formatted)
1068+ }
1069+ }
1070+
10411071 formatter := common.DiffFormatter(sty).
10421072 Before(file, meta.OldContent).
10431073 After(file, meta.NewContent).
@@ -1049,20 +1079,7 @@ func toolOutputMultiEditDiffContent(sty *styles.Styles, file string, meta tools.
10491079 }
10501080
10511081 formatted := formatter.String()
1052− lines := strings.Split(formatted, "\n")
1053−
1054− // Truncate if needed.
1055− maxLines := diffContextHeight
1056− if expanded {
1057− maxLines = len(lines)
1058− }
1059−
1060− if len(lines) > maxLines && !expanded {
1061− truncMsg := sty.Tool.DiffTruncation.
1062− Width(bodyWidth).
1063− Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines))
1064− formatted = strings.Join(lines[:maxLines], "\n") + "\n" + truncMsg
1065− }
1082+ formatted = truncateDiff(sty, formatted, bodyWidth, expanded)
10661083
10671084 // Add failed edits note if any exist.
10681085 if len(meta.EditsFailed) > 0 {
@@ -1075,6 +1092,22 @@ func toolOutputMultiEditDiffContent(sty *styles.Styles, file string, meta tools.
10751092 return sty.Tool.Body.Render(formatted)
10761093}
10771094
1095+// truncateDiff truncates diff output to diffContextHeight lines unless expanded.
1096+func truncateDiff(sty *styles.Styles, content string, bodyWidth int, expanded bool) string {
1097+ lines := strings.Split(content, "\n")
1098+ maxLines := diffContextHeight
1099+ if expanded {
1100+ maxLines = len(lines)
1101+ }
1102+ if len(lines) > maxLines && !expanded {
1103+ truncMsg := sty.Tool.DiffTruncation.
1104+ Width(bodyWidth).
1105+ Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines))
1106+ return strings.Join(lines[:maxLines], "\n") + "\n" + truncMsg
1107+ }
1108+ return content
1109+}
1110+
10781111// roundedEnumerator creates a tree enumerator with rounded corners.
10791112func roundedEnumerator(lPadding, width int) tree.Enumerator {
10801113 if width == 0 {
internal/ui/chat/unified_diff.go+13−14
@@ -1,11 +1,11 @@
11package chat
22
33import (
4− "fmt"
54 "strings"
65
76 "github.com/charmbracelet/crush/internal/diffdetect"
87 "github.com/charmbracelet/crush/internal/ui/common"
8+ "github.com/charmbracelet/crush/internal/ui/difftastic"
99 "github.com/charmbracelet/crush/internal/ui/styles"
1010)
1111
@@ -130,13 +130,23 @@ func parseUnifiedDiff(content string) []parsedDiffFile {
130130 return result
131131}
132132
133−func toolOutputDiffContentFromUnified(sty *styles.Styles, content string, width int, expanded bool) string {
133+func toolOutputDiffContentFromUnified(sty *styles.Styles, content string, width int, expanded bool, diffTool string) string {
134134 files := parseUnifiedDiff(content)
135135 if len(files) == 0 {
136136 bodyWidth := width - toolBodyLeftPaddingTotal
137137 return sty.Tool.Body.Render(toolOutputCodeContent(sty, "result.diff", content, 0, bodyWidth, expanded))
138138 }
139139 bodyWidth := width - toolBodyLeftPaddingTotal
140+
141+ // Try difftastic if enabled and there's a single file diff. Fall back to
142+ // builtin on any failure or when multiple files are involved.
143+ if diffTool == "difftastic" && len(files) == 1 {
144+ f := files[0]
145+ if out, err := difftastic.RenderDiff(f.path, f.before, f.after, bodyWidth); err == nil && out != "" {
146+ return sty.Tool.Body.Render(truncateDiff(sty, out, bodyWidth, expanded))
147+ }
148+ }
149+
140150 var blocks []string
141151 for i, f := range files {
142152 formatter := common.DiffFormatter(sty).
@@ -156,16 +166,5 @@ func toolOutputDiffContentFromUnified(sty *styles.Styles, content string, width
156166 blocks = append(blocks, formatted)
157167 }
158168 combined := strings.Join(blocks, "\n")
159− lines := strings.Split(combined, "\n")
160− maxLines := diffContextHeight
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)
169+ return sty.Tool.Body.Render(truncateDiff(sty, combined, bodyWidth, expanded))
171170}
internal/ui/chat/version_bump_test.go+10−10
@@ -131,7 +131,7 @@ func TestBaseToolMessageItem_MutatorsBumpVersion(t *testing.T) {
131131
132132 sty := styles.CharmtonePantera()
133133 tc := message.ToolCall{ID: "tc1", Name: "bash", Input: "{}", Finished: false}
134− item := NewToolMessageItem(&sty, "msg", tc, nil, false)
134+ item := NewToolMessageItem(&sty, "msg", tc, nil, false, "")
135135
136136 v := item.(versionedItem)
137137
@@ -283,7 +283,7 @@ func TestAgentToolMessageItem_NestedToolMutatorsBumpVersion(t *testing.T) {
283283
284284 mkChild := func(id string) ToolMessageItem {
285285 tc := message.ToolCall{ID: id, Name: "bash", Input: `{}`, Finished: false}
286− return NewToolMessageItem(&sty, "msg", tc, nil, false)
286+ return NewToolMessageItem(&sty, "msg", tc, nil, false, "")
287287 }
288288
289289 // AddNestedTool always bumps.
@@ -320,7 +320,7 @@ func TestAgenticFetchToolMessageItem_NestedToolMutatorsBumpVersion(t *testing.T)
320320
321321 mkChild := func(id string) ToolMessageItem {
322322 tc := message.ToolCall{ID: id, Name: "fetch", Input: `{}`, Finished: false}
323− return NewToolMessageItem(&sty, "msg", tc, nil, false)
323+ return NewToolMessageItem(&sty, "msg", tc, nil, false, "")
324324 }
325325
326326 requireBump(t, "AddNestedTool", item, func() {
@@ -357,7 +357,7 @@ func TestAgentToolMessageItem_NestedChildInPlaceMutationBumpsParent(t *testing.T
357357 item := NewAgentToolMessageItem(&sty, parent, nil, false)
358358
359359 childTC := message.ToolCall{ID: "c1", Name: "bash", Input: `{}`, Finished: false}
360− child := NewToolMessageItem(&sty, "msg", childTC, nil, false)
360+ child := NewToolMessageItem(&sty, "msg", childTC, nil, false, "")
361361 item.AddNestedTool(child)
362362
363363 v0 := item.Version()
@@ -386,7 +386,7 @@ func TestAgenticFetchToolMessageItem_NestedChildInPlaceMutationBumpsParent(t *te
386386 item := NewAgenticFetchToolMessageItem(&sty, parent, nil, false)
387387
388388 childTC := message.ToolCall{ID: "c1", Name: "fetch", Input: `{}`, Finished: false}
389− child := NewToolMessageItem(&sty, "msg", childTC, nil, false)
389+ child := NewToolMessageItem(&sty, "msg", childTC, nil, false, "")
390390 item.AddNestedTool(child)
391391
392392 v0 := item.Version()
@@ -427,7 +427,7 @@ func TestBaseToolMessageItem_AnimateBumpsVersion(t *testing.T) {
427427
428428 sty := styles.CharmtonePantera()
429429 tc := message.ToolCall{ID: "tc-spin", Name: "bash", Input: "{}", Finished: false}
430− item := NewToolMessageItem(&sty, "msg", tc, nil, false)
430+ item := NewToolMessageItem(&sty, "msg", tc, nil, false, "")
431431 v := item.(versionedItem)
432432 a, ok := item.(Animatable)
433433 require.True(t, ok, "base tool message item must implement Animatable")
@@ -475,7 +475,7 @@ func TestAgentToolMessageItem_AnimateBumpsVersion(t *testing.T) {
475475 parent := NewAgentToolMessageItem(&sty, parentTC, nil, false)
476476
477477 childTC := message.ToolCall{ID: "agent-child", Name: "bash", Input: `{}`, Finished: false}
478− child := NewToolMessageItem(&sty, "msg", childTC, nil, false)
478+ child := NewToolMessageItem(&sty, "msg", childTC, nil, false, "")
479479 parent.AddNestedTool(child)
480480
481481 // Spinning + parent's own ID → parent bumps.
@@ -520,7 +520,7 @@ func TestAgenticFetchToolMessageItem_AnimateBumpsVersion(t *testing.T) {
520520 parent := NewAgenticFetchToolMessageItem(&sty, parentTC, nil, false)
521521
522522 childTC := message.ToolCall{ID: "fetch-child", Name: "fetch", Input: `{}`, Finished: false}
523− child := NewToolMessageItem(&sty, "msg", childTC, nil, false)
523+ child := NewToolMessageItem(&sty, "msg", childTC, nil, false, "")
524524 parent.AddNestedTool(child)
525525
526526 requireBump(t, "Animate[spinning,parent ID]", parent, func() {
@@ -551,7 +551,7 @@ func TestBaseToolMessageItem_FinishedTransition(t *testing.T) {
551551
552552 sty := styles.CharmtonePantera()
553553 tc := message.ToolCall{ID: "tc-fin", Name: "bash", Input: "{}", Finished: false}
554− item := NewToolMessageItem(&sty, "msg", tc, nil, false)
554+ item := NewToolMessageItem(&sty, "msg", tc, nil, false, "")
555555 require.False(t, item.Finished(), "running tool must not be Finished()")
556556
557557 tcFinished := tc
@@ -562,6 +562,6 @@ func TestBaseToolMessageItem_FinishedTransition(t *testing.T) {
562562
563563 // Canceled tool with no result is also Finished.
564564 tcCanceled := message.ToolCall{ID: "tc-cancel", Name: "bash", Input: "{}", Finished: false}
565− canceled := NewToolMessageItem(&sty, "msg", tcCanceled, nil, true)
565+ canceled := NewToolMessageItem(&sty, "msg", tcCanceled, nil, true, "")
566566 require.True(t, canceled.Finished(), "canceled tool must be Finished()")
567567}
internal/ui/difftastic/difftastic.gonew file+60
@@ -0,0 +1,60 @@
1+// Package difftastic provides a syntax-aware structural diff renderer by
2+// shelling out to the difftastic binary (https://github.com/Wilfred/difftastic).
3+package difftastic
4+
5+import (
6+ "context"
7+ "fmt"
8+ "os"
9+ "os/exec"
10+ "path/filepath"
11+ "strconv"
12+ "strings"
13+)
14+
15+// RenderDiff runs difftastic on before/after content and returns ANSI-colored
16+// output. Returns empty string and a nil error if difft is not available so
17+// callers can fall back to the builtin renderer.
18+func RenderDiff(filePath, before, after string, width int) (string, error) {
19+ if _, err := exec.LookPath("difft"); err != nil {
20+ return "", nil
21+ }
22+
23+ dir, err := os.MkdirTemp("", "crush-difftastic-*")
24+ if err != nil {
25+ return "", fmt.Errorf("failed to create temp directory: %w", err)
26+ }
27+ defer os.RemoveAll(dir)
28+
29+ ext := filepath.Ext(filePath)
30+ oldPath := filepath.Join(dir, "old"+ext)
31+ newPath := filepath.Join(dir, "new"+ext)
32+
33+ if err := os.WriteFile(oldPath, []byte(before), 0o644); err != nil {
34+ return "", fmt.Errorf("failed to write before content: %w", err)
35+ }
36+ if err := os.WriteFile(newPath, []byte(after), 0o644); err != nil {
37+ return "", fmt.Errorf("failed to write after content: %w", err)
38+ }
39+
40+ args := []string{
41+ "--color=always",
42+ "--display=inline",
43+ "--width=" + strconv.Itoa(width),
44+ oldPath,
45+ newPath,
46+ }
47+
48+ cmd := exec.CommandContext(context.Background(), "difft", args...)
49+ output, err := cmd.CombinedOutput()
50+ if err != nil {
51+ // difftastic returns non-zero exit code when differences are found.
52+ // The output is still valid, so return it rather than the error.
53+ if len(output) > 0 {
54+ return string(output), nil
55+ }
56+ return "", fmt.Errorf("failed to run difftastic: %w", err)
57+ }
58+
59+ return strings.TrimRight(string(output), "\n"), nil
60+}
internal/ui/model/ui.go+11−9
@@ -456,7 +456,8 @@ func New(ctx context.Context, com *common.Common, initialSessionID string, conti
456456 // reconcileTheme applies the light palette on the first frame when
457457 // the terminal reports a light background or "light" is forced.
458458 ui.themeKey = styles.ThemeKeyForProviderMode(
459− cfg.Models[config.SelectedModelTypeLarge].Provider, true)
459+ cfg.Models[config.SelectedModelTypeLarge].Provider, true,
460+ )
460461 }
461462
462463 ui.setEditorPrompt(com.Workspace.PermissionSkipRequests())
@@ -1229,20 +1230,21 @@ func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
12291230 toolResultMap := chat.BuildToolResultMap(msgPtrs)
12301231
12311232 // Add messages to chat with linked tool results
1233+ diffTool := m.com.Config().Options.TUI.DiffTool
12321234 items := make([]chat.MessageItem, 0, len(msgs)*2)
12331235 for _, msg := range msgPtrs {
12341236 switch msg.Role {
12351237 case message.User:
12361238 m.lastUserMessageTime = msg.CreatedAt
1237− items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
1239+ items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap, diffTool)...)
12381240 case message.Assistant:
1239− items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
1241+ items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap, diffTool)...)
12401242 if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
12411243 infoItem := chat.NewAssistantInfoItem(m.com.Styles, msg, m.com.Config(), time.Unix(m.lastUserMessageTime, 0))
12421244 items = append(items, infoItem)
12431245 }
12441246 default:
1245− items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
1247+ items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap, diffTool)...)
12461248 }
12471249 }
12481250
@@ -1303,7 +1305,7 @@ func (m *UI) loadNestedToolCalls(items []chat.MessageItem) {
13031305 // Extract nested tool items.
13041306 var nestedTools []chat.ToolMessageItem
13051307 for _, nestedMsg := range nestedMsgPtrs {
1306− nestedItems := chat.ExtractMessageItems(m.com.Styles, nestedMsg, nestedToolResultMap)
1308+ nestedItems := chat.ExtractMessageItems(m.com.Styles, nestedMsg, nestedToolResultMap, m.com.Config().Options.TUI.DiffTool)
13071309 for _, nestedItem := range nestedItems {
13081310 if nestedToolItem, ok := nestedItem.(chat.ToolMessageItem); ok {
13091311 // Mark nested tools as simple (compact) rendering.
@@ -1353,7 +1355,7 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
13531355 return nil
13541356 }
13551357 m.lastUserMessageTime = msg.CreatedAt
1356− items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
1358+ items := chat.ExtractMessageItems(m.com.Styles, &msg, nil, m.com.Config().Options.TUI.DiffTool)
13571359 for _, item := range items {
13581360 if animatable, ok := item.(chat.Animatable); ok {
13591361 if cmd := animatable.StartAnimation(); cmd != nil {
@@ -1366,7 +1368,7 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
13661368 cmds = append(cmds, cmd)
13671369 }
13681370 case message.Assistant:
1369− items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
1371+ items := chat.ExtractMessageItems(m.com.Styles, &msg, nil, m.com.Config().Options.TUI.DiffTool)
13701372 for _, item := range items {
13711373 if animatable, ok := item.(chat.Animatable); ok {
13721374 if cmd := animatable.StartAnimation(); cmd != nil {
@@ -1475,7 +1477,7 @@ func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
14751477 }
14761478 }
14771479 if existingToolItem == nil {
1478− items = append(items, chat.NewToolMessageItem(m.com.Styles, msg.ID, tc, nil, false))
1480+ items = append(items, chat.NewToolMessageItem(m.com.Styles, msg.ID, tc, nil, false, m.com.Config().Options.TUI.DiffTool))
14791481 }
14801482 }
14811483
@@ -1547,7 +1549,7 @@ func (m *UI) handleChildSessionMessage(event pubsub.Event[message.Message]) tea.
15471549 }
15481550 if !found {
15491551 // Create a new nested tool item.
1550− nestedItem := chat.NewToolMessageItem(m.com.Styles, event.Payload.ID, tc, nil, false)
1552+ nestedItem := chat.NewToolMessageItem(m.com.Styles, event.Payload.ID, tc, nil, false, m.com.Config().Options.TUI.DiffTool)
15511553 if simplifiable, ok := nestedItem.(chat.Compactable); ok {
15521554 simplifiable.SetCompact(true)
15531555 }