on main help sign in
Merge pull request #1678 from charmbracelet/chat-file-tools

refactor(chat): add file tools

commit78544f708e6fb6a14ae68bb8b2e01a6d8ad82fca authorAyman Bagabas <ayman.bagabas@gmail.com> dateFri 19 Dec 2025, 12:01 (1 year ago) parent0af0ebedf22d89
4 files changed+479−11
internal/ui/chat/bash.go+1−1 internal/ui/chat/file.go+278 internal/ui/chat/tools.go+190−2 internal/ui/styles/styles.go+10−8
internal/ui/chat/bash.go+1−1
@@ -69,7 +69,7 @@ func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
6969 toolParams = append(toolParams, "background", "true")
7070 }
7171
72− header := toolHeader(sty, opts.Status(), "Bash", cappedWidth, toolParams...)
72+ header := toolHeader(sty, opts.Status(), "Bash", cappedWidth, opts.Nested, toolParams...)
7373 if opts.Nested {
7474 return header
7575 }
internal/ui/chat/file.gonew file+278
@@ -0,0 +1,278 @@
1+package chat
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "strings"
7+
8+ "github.com/charmbracelet/crush/internal/agent/tools"
9+ "github.com/charmbracelet/crush/internal/fsext"
10+ "github.com/charmbracelet/crush/internal/message"
11+ "github.com/charmbracelet/crush/internal/ui/styles"
12+)
13+
14+// -----------------------------------------------------------------------------
15+// View Tool
16+// -----------------------------------------------------------------------------
17+
18+// ViewToolMessageItem is a message item that represents a view tool call.
19+type ViewToolMessageItem struct {
20+ *baseToolMessageItem
21+}
22+
23+var _ ToolMessageItem = (*ViewToolMessageItem)(nil)
24+
25+// NewViewToolMessageItem creates a new [ViewToolMessageItem].
26+func NewViewToolMessageItem(
27+ sty *styles.Styles,
28+ toolCall message.ToolCall,
29+ result *message.ToolResult,
30+ canceled bool,
31+) ToolMessageItem {
32+ return newBaseToolMessageItem(sty, toolCall, result, &ViewToolRenderContext{}, canceled)
33+}
34+
35+// ViewToolRenderContext renders view tool messages.
36+type ViewToolRenderContext struct{}
37+
38+// RenderTool implements the [ToolRenderer] interface.
39+func (v *ViewToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
40+ cappedWidth := cappedMessageWidth(width)
41+ if !opts.ToolCall.Finished && !opts.Canceled {
42+ return pendingTool(sty, "View", opts.Anim)
43+ }
44+
45+ var params tools.ViewParams
46+ if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
47+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
48+ }
49+
50+ file := fsext.PrettyPath(params.FilePath)
51+ toolParams := []string{file}
52+ if params.Limit != 0 {
53+ toolParams = append(toolParams, "limit", fmt.Sprintf("%d", params.Limit))
54+ }
55+ if params.Offset != 0 {
56+ toolParams = append(toolParams, "offset", fmt.Sprintf("%d", params.Offset))
57+ }
58+
59+ header := toolHeader(sty, opts.Status(), "View", cappedWidth, opts.Nested, toolParams...)
60+ if opts.Nested {
61+ return header
62+ }
63+
64+ if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
65+ return joinToolParts(header, earlyState)
66+ }
67+
68+ if opts.Result == nil {
69+ return header
70+ }
71+
72+ // Handle image content.
73+ if opts.Result.Data != "" && strings.HasPrefix(opts.Result.MIMEType, "image/") {
74+ body := toolOutputImageContent(sty, opts.Result.Data, opts.Result.MIMEType)
75+ return joinToolParts(header, body)
76+ }
77+
78+ // Try to get content from metadata first (contains actual file content).
79+ var meta tools.ViewResponseMetadata
80+ content := opts.Result.Content
81+ if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil && meta.Content != "" {
82+ content = meta.Content
83+ }
84+
85+ if content == "" {
86+ return header
87+ }
88+
89+ // Render code content with syntax highlighting.
90+ body := toolOutputCodeContent(sty, params.FilePath, content, params.Offset, cappedWidth, opts.Expanded)
91+ return joinToolParts(header, body)
92+}
93+
94+// -----------------------------------------------------------------------------
95+// Write Tool
96+// -----------------------------------------------------------------------------
97+
98+// WriteToolMessageItem is a message item that represents a write tool call.
99+type WriteToolMessageItem struct {
100+ *baseToolMessageItem
101+}
102+
103+var _ ToolMessageItem = (*WriteToolMessageItem)(nil)
104+
105+// NewWriteToolMessageItem creates a new [WriteToolMessageItem].
106+func NewWriteToolMessageItem(
107+ sty *styles.Styles,
108+ toolCall message.ToolCall,
109+ result *message.ToolResult,
110+ canceled bool,
111+) ToolMessageItem {
112+ return newBaseToolMessageItem(sty, toolCall, result, &WriteToolRenderContext{}, canceled)
113+}
114+
115+// WriteToolRenderContext renders write tool messages.
116+type WriteToolRenderContext struct{}
117+
118+// RenderTool implements the [ToolRenderer] interface.
119+func (w *WriteToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
120+ cappedWidth := cappedMessageWidth(width)
121+ if !opts.ToolCall.Finished && !opts.Canceled {
122+ return pendingTool(sty, "Write", opts.Anim)
123+ }
124+
125+ var params tools.WriteParams
126+ if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
127+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
128+ }
129+
130+ file := fsext.PrettyPath(params.FilePath)
131+ header := toolHeader(sty, opts.Status(), "Write", cappedWidth, opts.Nested, file)
132+ if opts.Nested {
133+ return header
134+ }
135+
136+ if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
137+ return joinToolParts(header, earlyState)
138+ }
139+
140+ if params.Content == "" {
141+ return header
142+ }
143+
144+ // Render code content with syntax highlighting.
145+ body := toolOutputCodeContent(sty, params.FilePath, params.Content, 0, cappedWidth, opts.Expanded)
146+ return joinToolParts(header, body)
147+}
148+
149+// -----------------------------------------------------------------------------
150+// Edit Tool
151+// -----------------------------------------------------------------------------
152+
153+// EditToolMessageItem is a message item that represents an edit tool call.
154+type EditToolMessageItem struct {
155+ *baseToolMessageItem
156+}
157+
158+var _ ToolMessageItem = (*EditToolMessageItem)(nil)
159+
160+// NewEditToolMessageItem creates a new [EditToolMessageItem].
161+func NewEditToolMessageItem(
162+ sty *styles.Styles,
163+ toolCall message.ToolCall,
164+ result *message.ToolResult,
165+ canceled bool,
166+) ToolMessageItem {
167+ return newBaseToolMessageItem(sty, toolCall, result, &EditToolRenderContext{}, canceled)
168+}
169+
170+// EditToolRenderContext renders edit tool messages.
171+type EditToolRenderContext struct{}
172+
173+// RenderTool implements the [ToolRenderer] interface.
174+func (e *EditToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
175+ // Edit tool uses full width for diffs.
176+ if !opts.ToolCall.Finished && !opts.Canceled {
177+ return pendingTool(sty, "Edit", opts.Anim)
178+ }
179+
180+ var params tools.EditParams
181+ if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
182+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, width)
183+ }
184+
185+ file := fsext.PrettyPath(params.FilePath)
186+ header := toolHeader(sty, opts.Status(), "Edit", width, opts.Nested, file)
187+ if opts.Nested {
188+ return header
189+ }
190+
191+ if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok {
192+ return joinToolParts(header, earlyState)
193+ }
194+
195+ if opts.Result == nil {
196+ return header
197+ }
198+
199+ // Get diff content from metadata.
200+ var meta tools.EditResponseMetadata
201+ if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err != nil {
202+ bodyWidth := width - toolBodyLeftPaddingTotal
203+ body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.Expanded))
204+ return joinToolParts(header, body)
205+ }
206+
207+ // Render diff.
208+ body := toolOutputDiffContent(sty, file, meta.OldContent, meta.NewContent, width, opts.Expanded)
209+ return joinToolParts(header, body)
210+}
211+
212+// -----------------------------------------------------------------------------
213+// MultiEdit Tool
214+// -----------------------------------------------------------------------------
215+
216+// MultiEditToolMessageItem is a message item that represents a multi-edit tool call.
217+type MultiEditToolMessageItem struct {
218+ *baseToolMessageItem
219+}
220+
221+var _ ToolMessageItem = (*MultiEditToolMessageItem)(nil)
222+
223+// NewMultiEditToolMessageItem creates a new [MultiEditToolMessageItem].
224+func NewMultiEditToolMessageItem(
225+ sty *styles.Styles,
226+ toolCall message.ToolCall,
227+ result *message.ToolResult,
228+ canceled bool,
229+) ToolMessageItem {
230+ return newBaseToolMessageItem(sty, toolCall, result, &MultiEditToolRenderContext{}, canceled)
231+}
232+
233+// MultiEditToolRenderContext renders multi-edit tool messages.
234+type MultiEditToolRenderContext struct{}
235+
236+// RenderTool implements the [ToolRenderer] interface.
237+func (m *MultiEditToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
238+ // MultiEdit tool uses full width for diffs.
239+ if !opts.ToolCall.Finished && !opts.Canceled {
240+ return pendingTool(sty, "Multi-Edit", opts.Anim)
241+ }
242+
243+ var params tools.MultiEditParams
244+ if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
245+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, width)
246+ }
247+
248+ file := fsext.PrettyPath(params.FilePath)
249+ toolParams := []string{file}
250+ if len(params.Edits) > 0 {
251+ toolParams = append(toolParams, "edits", fmt.Sprintf("%d", len(params.Edits)))
252+ }
253+
254+ header := toolHeader(sty, opts.Status(), "Multi-Edit", width, opts.Nested, toolParams...)
255+ if opts.Nested {
256+ return header
257+ }
258+
259+ if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok {
260+ return joinToolParts(header, earlyState)
261+ }
262+
263+ if opts.Result == nil {
264+ return header
265+ }
266+
267+ // Get diff content from metadata.
268+ var meta tools.MultiEditResponseMetadata
269+ if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err != nil {
270+ bodyWidth := width - toolBodyLeftPaddingTotal
271+ body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.Expanded))
272+ return joinToolParts(header, body)
273+ }
274+
275+ // Render diff with optional failed edits note.
276+ body := toolOutputMultiEditDiffContent(sty, file, meta, len(params.Edits), width, opts.Expanded)
277+ return joinToolParts(header, body)
278+}
internal/ui/chat/tools.go+190−2
@@ -9,6 +9,7 @@ import (
99 "github.com/charmbracelet/crush/internal/agent/tools"
1010 "github.com/charmbracelet/crush/internal/message"
1111 "github.com/charmbracelet/crush/internal/ui/anim"
12+ "github.com/charmbracelet/crush/internal/ui/common"
1213 "github.com/charmbracelet/crush/internal/ui/styles"
1314 "github.com/charmbracelet/x/ansi"
1415)
@@ -162,6 +163,14 @@ func NewToolMessageItem(
162163 return NewJobOutputToolMessageItem(sty, toolCall, result, canceled)
163164 case tools.JobKillToolName:
164165 return NewJobKillToolMessageItem(sty, toolCall, result, canceled)
166+ case tools.ViewToolName:
167+ return NewViewToolMessageItem(sty, toolCall, result, canceled)
168+ case tools.WriteToolName:
169+ return NewWriteToolMessageItem(sty, toolCall, result, canceled)
170+ case tools.EditToolName:
171+ return NewEditToolMessageItem(sty, toolCall, result, canceled)
172+ case tools.MultiEditToolName:
173+ return NewMultiEditToolMessageItem(sty, toolCall, result, canceled)
165174 default:
166175 // TODO: Implement other tool items
167176 return newBaseToolMessageItem(
@@ -376,9 +385,13 @@ func toolParamList(sty *styles.Styles, params []string, width int) string {
376385}
377386
378387// toolHeader builds the tool header line: "● ToolName params..."
379−func toolHeader(sty *styles.Styles, status ToolStatus, name string, width int, params ...string) string {
388+func toolHeader(sty *styles.Styles, status ToolStatus, name string, width int, nested bool, params ...string) string {
380389 icon := toolIcon(sty, status)
381− toolName := sty.Tool.NameNested.Render(name)
390+ nameStyle := sty.Tool.NameNormal
391+ if nested {
392+ nameStyle = sty.Tool.NameNested
393+ }
394+ toolName := nameStyle.Render(name)
382395 prefix := fmt.Sprintf("%s %s ", icon, toolName)
383396 prefixWidth := lipgloss.Width(prefix)
384397 remainingWidth := width - prefixWidth
@@ -420,3 +433,178 @@ func toolOutputPlainContent(sty *styles.Styles, content string, width int, expan
420433
421434 return strings.Join(out, "\n")
422435}
436+
437+// toolOutputCodeContent renders code with syntax highlighting and line numbers.
438+func toolOutputCodeContent(sty *styles.Styles, path, content string, offset, width int, expanded bool) string {
439+ content = strings.ReplaceAll(content, "\r\n", "\n")
440+ content = strings.ReplaceAll(content, "\t", " ")
441+
442+ lines := strings.Split(content, "\n")
443+ maxLines := responseContextHeight
444+ if expanded {
445+ maxLines = len(lines)
446+ }
447+
448+ // Truncate if needed.
449+ displayLines := lines
450+ if len(lines) > maxLines {
451+ displayLines = lines[:maxLines]
452+ }
453+
454+ bg := sty.Tool.ContentCodeBg
455+ highlighted, _ := common.SyntaxHighlight(sty, strings.Join(displayLines, "\n"), path, bg)
456+ highlightedLines := strings.Split(highlighted, "\n")
457+
458+ // Calculate line number width.
459+ maxLineNumber := len(displayLines) + offset
460+ maxDigits := getDigits(maxLineNumber)
461+ numFmt := fmt.Sprintf("%%%dd", maxDigits)
462+
463+ bodyWidth := width - toolBodyLeftPaddingTotal
464+ codeWidth := bodyWidth - maxDigits - 4 // -4 for line number padding
465+
466+ var out []string
467+ for i, ln := range highlightedLines {
468+ lineNum := sty.Tool.ContentLineNumber.Render(fmt.Sprintf(numFmt, i+1+offset))
469+
470+ if lipgloss.Width(ln) > codeWidth {
471+ ln = ansi.Truncate(ln, codeWidth, "…")
472+ }
473+
474+ codeLine := sty.Tool.ContentCodeLine.
475+ Width(codeWidth).
476+ PaddingLeft(2).
477+ Render(ln)
478+
479+ out = append(out, lipgloss.JoinHorizontal(lipgloss.Left, lineNum, codeLine))
480+ }
481+
482+ // Add truncation message if needed.
483+ if len(lines) > maxLines && !expanded {
484+ truncMsg := sty.Tool.ContentCodeTruncation.
485+ Width(bodyWidth).
486+ Render(fmt.Sprintf("… (%d lines) [click or space to expand]", len(lines)-maxLines))
487+ out = append(out, truncMsg)
488+ }
489+
490+ return sty.Tool.Body.Render(strings.Join(out, "\n"))
491+}
492+
493+// toolOutputImageContent renders image data with size info.
494+func toolOutputImageContent(sty *styles.Styles, data, mediaType string) string {
495+ dataSize := len(data) * 3 / 4
496+ sizeStr := formatSize(dataSize)
497+
498+ loaded := sty.Base.Foreground(sty.Green).Render("Loaded")
499+ arrow := sty.Base.Foreground(sty.GreenDark).Render("→")
500+ typeStyled := sty.Base.Render(mediaType)
501+ sizeStyled := sty.Subtle.Render(sizeStr)
502+
503+ return sty.Tool.Body.Render(fmt.Sprintf("%s %s %s %s", loaded, arrow, typeStyled, sizeStyled))
504+}
505+
506+// getDigits returns the number of digits in a number.
507+func getDigits(n int) int {
508+ if n == 0 {
509+ return 1
510+ }
511+ if n < 0 {
512+ n = -n
513+ }
514+ digits := 0
515+ for n > 0 {
516+ n /= 10
517+ digits++
518+ }
519+ return digits
520+}
521+
522+// formatSize formats byte size into human readable format.
523+func formatSize(bytes int) string {
524+ const (
525+ kb = 1024
526+ mb = kb * 1024
527+ )
528+ switch {
529+ case bytes >= mb:
530+ return fmt.Sprintf("%.1f MB", float64(bytes)/float64(mb))
531+ case bytes >= kb:
532+ return fmt.Sprintf("%.1f KB", float64(bytes)/float64(kb))
533+ default:
534+ return fmt.Sprintf("%d B", bytes)
535+ }
536+}
537+
538+// toolOutputDiffContent renders a diff between old and new content.
539+func toolOutputDiffContent(sty *styles.Styles, file, oldContent, newContent string, width int, expanded bool) string {
540+ bodyWidth := width - toolBodyLeftPaddingTotal
541+
542+ formatter := common.DiffFormatter(sty).
543+ Before(file, oldContent).
544+ After(file, newContent).
545+ Width(bodyWidth)
546+
547+ // Use split view for wide terminals.
548+ if width > 120 {
549+ formatter = formatter.Split()
550+ }
551+
552+ formatted := formatter.String()
553+ lines := strings.Split(formatted, "\n")
554+
555+ // Truncate if needed.
556+ maxLines := responseContextHeight
557+ if expanded {
558+ maxLines = len(lines)
559+ }
560+
561+ if len(lines) > maxLines && !expanded {
562+ truncMsg := sty.Tool.DiffTruncation.
563+ Width(bodyWidth).
564+ Render(fmt.Sprintf("… (%d lines) [click or space to expand]", len(lines)-maxLines))
565+ formatted = strings.Join(lines[:maxLines], "\n") + "\n" + truncMsg
566+ }
567+
568+ return sty.Tool.Body.Render(formatted)
569+}
570+
571+// toolOutputMultiEditDiffContent renders a diff with optional failed edits note.
572+func toolOutputMultiEditDiffContent(sty *styles.Styles, file string, meta tools.MultiEditResponseMetadata, totalEdits, width int, expanded bool) string {
573+ bodyWidth := width - toolBodyLeftPaddingTotal
574+
575+ formatter := common.DiffFormatter(sty).
576+ Before(file, meta.OldContent).
577+ After(file, meta.NewContent).
578+ Width(bodyWidth)
579+
580+ // Use split view for wide terminals.
581+ if width > 120 {
582+ formatter = formatter.Split()
583+ }
584+
585+ formatted := formatter.String()
586+ lines := strings.Split(formatted, "\n")
587+
588+ // Truncate if needed.
589+ maxLines := responseContextHeight
590+ if expanded {
591+ maxLines = len(lines)
592+ }
593+
594+ if len(lines) > maxLines && !expanded {
595+ truncMsg := sty.Tool.DiffTruncation.
596+ Width(bodyWidth).
597+ Render(fmt.Sprintf("… (%d lines) [click or space to expand]", len(lines)-maxLines))
598+ formatted = strings.Join(lines[:maxLines], "\n") + "\n" + truncMsg
599+ }
600+
601+ // Add failed edits note if any exist.
602+ if len(meta.EditsFailed) > 0 {
603+ noteTag := sty.Tool.NoteTag.Render("Note")
604+ noteMsg := fmt.Sprintf("%d of %d edits succeeded", meta.EditsApplied, totalEdits)
605+ note := fmt.Sprintf("%s %s", noteTag, sty.Tool.NoteMessage.Render(noteMsg))
606+ formatted = formatted + "\n\n" + note
607+ }
608+
609+ return sty.Tool.Body.Render(formatted)
610+}
internal/ui/styles/styles.go+10−8
@@ -238,11 +238,12 @@ type Styles struct {
238238 ParamKey lipgloss.Style // Parameter keys
239239
240240 // Content rendering styles
241− ContentLine lipgloss.Style // Individual content line with background and width
242− ContentTruncation lipgloss.Style // Truncation message "… (N lines)"
243− ContentCodeLine lipgloss.Style // Code line with background and width
244− ContentCodeBg color.Color // Background color for syntax highlighting
245− Body lipgloss.Style // Body content padding (PaddingLeft(2))
241+ ContentLine lipgloss.Style // Individual content line with background and width
242+ ContentTruncation lipgloss.Style // Truncation message "… (N lines)"
243+ ContentCodeLine lipgloss.Style // Code line with background and width
244+ ContentCodeTruncation lipgloss.Style // Code truncation message with bgBase
245+ ContentCodeBg color.Color // Background color for syntax highlighting
246+ Body lipgloss.Style // Body content padding (PaddingLeft(2))
246247
247248 // Deprecated - kept for backward compatibility
248249 ContentBg lipgloss.Style // Content background
@@ -970,14 +971,15 @@ func DefaultStyles() Styles {
970971 // Content rendering - prepared styles that accept width parameter
971972 s.Tool.ContentLine = s.Muted.Background(bgBaseLighter)
972973 s.Tool.ContentTruncation = s.Muted.Background(bgBaseLighter)
973− s.Tool.ContentCodeLine = s.Base.Background(bgBaseLighter)
974+ s.Tool.ContentCodeLine = s.Base.Background(bgBase)
975+ s.Tool.ContentCodeTruncation = s.Muted.Background(bgBase).PaddingLeft(2)
974976 s.Tool.ContentCodeBg = bgBase
975977 s.Tool.Body = base.PaddingLeft(2)
976978
977979 // Deprecated - kept for backward compatibility
978980 s.Tool.ContentBg = s.Muted.Background(bgBaseLighter)
979981 s.Tool.ContentText = s.Muted
980− s.Tool.ContentLineNumber = s.Subtle
982+ s.Tool.ContentLineNumber = base.Foreground(fgMuted).Background(bgBase).PaddingRight(1).PaddingLeft(1)
981983
982984 s.Tool.StateWaiting = base.Foreground(fgSubtle)
983985 s.Tool.StateCancelled = base.Foreground(fgSubtle)
@@ -987,7 +989,7 @@ func DefaultStyles() Styles {
987989
988990 // Diff and multi-edit styles
989991 s.Tool.DiffTruncation = s.Muted.Background(bgBaseLighter).PaddingLeft(2)
990− s.Tool.NoteTag = base.Padding(0, 1).Background(yellow).Foreground(white)
992+ s.Tool.NoteTag = base.Padding(0, 1).Background(info).Foreground(white)
991993 s.Tool.NoteMessage = base.Foreground(fgHalfMuted)
992994
993995 // Job header styles