@@ -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 {