on main help sign in
refactor: add more tools
67efe86Kujtim Hoxha
internal/ui/chat/bash.go+3−3
@@ -69,8 +69,8 @@ func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
69 toolParams = append(toolParams, "background", "true")
69 toolParams = append(toolParams, "background", "true")
70 }
70 }
71
71
72− header := toolHeader(sty, opts.Status(), "Bash", cappedWidth, opts.Nested, toolParams...)
72+ header := toolHeader(sty, opts.Status(), "Bash", cappedWidth, opts.Simple, toolParams...)
73− if opts.Nested {
73+ if opts.Simple {
74 return header
74 return header
75 }
75 }
76
76
@@ -201,7 +201,7 @@ func (j *JobKillToolRenderContext) RenderTool(sty *styles.Styles, width int, opt
201// header → nested check → early state → body.
201// header → nested check → early state → body.
202func renderJobTool(sty *styles.Styles, opts *ToolRenderOpts, width int, action, shellID, description, content string) string {
202func renderJobTool(sty *styles.Styles, opts *ToolRenderOpts, width int, action, shellID, description, content string) string {
203 header := jobHeader(sty, opts.Status(), action, shellID, description, width)
203 header := jobHeader(sty, opts.Status(), action, shellID, description, width)
204− if opts.Nested {
204+ if opts.Simple {
205 return header
205 return header
206 }
206 }
207
207
internal/ui/chat/diagnostics.gonew file+68
@@ -0,0 +1,68 @@
1+package chat
2+
3+import (
4+ "encoding/json"
5+
6+ "github.com/charmbracelet/crush/internal/agent/tools"
7+ "github.com/charmbracelet/crush/internal/fsext"
8+ "github.com/charmbracelet/crush/internal/message"
9+ "github.com/charmbracelet/crush/internal/ui/styles"
10+)
11+
12+// -----------------------------------------------------------------------------
13+// Diagnostics Tool
14+// -----------------------------------------------------------------------------
15+
16+// DiagnosticsToolMessageItem is a message item that represents a diagnostics tool call.
17+type DiagnosticsToolMessageItem struct {
18+ *baseToolMessageItem
19+}
20+
21+var _ ToolMessageItem = (*DiagnosticsToolMessageItem)(nil)
22+
23+// NewDiagnosticsToolMessageItem creates a new [DiagnosticsToolMessageItem].
24+func NewDiagnosticsToolMessageItem(
25+ sty *styles.Styles,
26+ toolCall message.ToolCall,
27+ result *message.ToolResult,
28+ canceled bool,
29+) ToolMessageItem {
30+ return newBaseToolMessageItem(sty, toolCall, result, &DiagnosticsToolRenderContext{}, canceled)
31+}
32+
33+// DiagnosticsToolRenderContext renders diagnostics tool messages.
34+type DiagnosticsToolRenderContext struct{}
35+
36+// RenderTool implements the [ToolRenderer] interface.
37+func (d *DiagnosticsToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
38+ cappedWidth := cappedMessageWidth(width)
39+ if !opts.ToolCall.Finished && !opts.Canceled {
40+ return pendingTool(sty, "Diagnostics", opts.Anim)
41+ }
42+
43+ var params tools.DiagnosticsParams
44+ _ = json.Unmarshal([]byte(opts.ToolCall.Input), &params)
45+
46+ // Show "project" if no file path, otherwise show the file path.
47+ mainParam := "project"
48+ if params.FilePath != "" {
49+ mainParam = fsext.PrettyPath(params.FilePath)
50+ }
51+
52+ header := toolHeader(sty, opts.Status(), "Diagnostics", cappedWidth, opts.Simple, mainParam)
53+ if opts.Simple {
54+ return header
55+ }
56+
57+ if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
58+ return joinToolParts(header, earlyState)
59+ }
60+
61+ if opts.Result == nil || opts.Result.Content == "" {
62+ return header
63+ }
64+
65+ bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
66+ body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.Expanded))
67+ return joinToolParts(header, body)
68+}
internal/ui/chat/fetch.gonew file+84
@@ -0,0 +1,84 @@
1+package chat
2+
3+import (
4+ "encoding/json"
5+
6+ "github.com/charmbracelet/crush/internal/agent/tools"
7+ "github.com/charmbracelet/crush/internal/message"
8+ "github.com/charmbracelet/crush/internal/ui/styles"
9+)
10+
11+// -----------------------------------------------------------------------------
12+// Fetch Tool
13+// -----------------------------------------------------------------------------
14+
15+// FetchToolMessageItem is a message item that represents a fetch tool call.
16+type FetchToolMessageItem struct {
17+ *baseToolMessageItem
18+}
19+
20+var _ ToolMessageItem = (*FetchToolMessageItem)(nil)
21+
22+// NewFetchToolMessageItem creates a new [FetchToolMessageItem].
23+func NewFetchToolMessageItem(
24+ sty *styles.Styles,
25+ toolCall message.ToolCall,
26+ result *message.ToolResult,
27+ canceled bool,
28+) ToolMessageItem {
29+ return newBaseToolMessageItem(sty, toolCall, result, &FetchToolRenderContext{}, canceled)
30+}
31+
32+// FetchToolRenderContext renders fetch tool messages.
33+type FetchToolRenderContext struct{}
34+
35+// RenderTool implements the [ToolRenderer] interface.
36+func (f *FetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
37+ cappedWidth := cappedMessageWidth(width)
38+ if !opts.ToolCall.Finished && !opts.Canceled {
39+ return pendingTool(sty, "Fetch", opts.Anim)
40+ }
41+
42+ var params tools.FetchParams
43+ if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
44+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
45+ }
46+
47+ toolParams := []string{params.URL}
48+ if params.Format != "" {
49+ toolParams = append(toolParams, "format", params.Format)
50+ }
51+ if params.Timeout != 0 {
52+ toolParams = append(toolParams, "timeout", formatTimeout(params.Timeout))
53+ }
54+
55+ header := toolHeader(sty, opts.Status(), "Fetch", cappedWidth, opts.Simple, toolParams...)
56+ if opts.Simple {
57+ return header
58+ }
59+
60+ if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
61+ return joinToolParts(header, earlyState)
62+ }
63+
64+ if opts.Result == nil || opts.Result.Content == "" {
65+ return header
66+ }
67+
68+ // Determine file extension for syntax highlighting based on format.
69+ file := getFileExtensionForFormat(params.Format)
70+ body := toolOutputCodeContent(sty, file, opts.Result.Content, 0, cappedWidth, opts.Expanded)
71+ return joinToolParts(header, body)
72+}
73+
74+// getFileExtensionForFormat returns a filename with appropriate extension for syntax highlighting.
75+func getFileExtensionForFormat(format string) string {
76+ switch format {
77+ case "text":
78+ return "fetch.txt"
79+ case "html":
80+ return "fetch.html"
81+ default:
82+ return "fetch.md"
83+ }
84+}
internal/ui/chat/file.go+70−8
@@ -56,8 +56,8 @@ func (v *ViewToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
56 toolParams = append(toolParams, "offset", fmt.Sprintf("%d", params.Offset))
56 toolParams = append(toolParams, "offset", fmt.Sprintf("%d", params.Offset))
57 }
57 }
58
58
59− header := toolHeader(sty, opts.Status(), "View", cappedWidth, opts.Nested, toolParams...)
59+ header := toolHeader(sty, opts.Status(), "View", cappedWidth, opts.Simple, toolParams...)
60− if opts.Nested {
60+ if opts.Simple {
61 return header
61 return header
62 }
62 }
63
63
@@ -128,8 +128,8 @@ func (w *WriteToolRenderContext) RenderTool(sty *styles.Styles, width int, opts
128 }
128 }
129
129
130 file := fsext.PrettyPath(params.FilePath)
130 file := fsext.PrettyPath(params.FilePath)
131− header := toolHeader(sty, opts.Status(), "Write", cappedWidth, opts.Nested, file)
131+ header := toolHeader(sty, opts.Status(), "Write", cappedWidth, opts.Simple, file)
132− if opts.Nested {
132+ if opts.Simple {
133 return header
133 return header
134 }
134 }
135
135
@@ -183,8 +183,8 @@ func (e *EditToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
183 }
183 }
184
184
185 file := fsext.PrettyPath(params.FilePath)
185 file := fsext.PrettyPath(params.FilePath)
186− header := toolHeader(sty, opts.Status(), "Edit", width, opts.Nested, file)
186+ header := toolHeader(sty, opts.Status(), "Edit", width, opts.Simple, file)
187− if opts.Nested {
187+ if opts.Simple {
188 return header
188 return header
189 }
189 }
190
190
@@ -251,8 +251,8 @@ func (m *MultiEditToolRenderContext) RenderTool(sty *styles.Styles, width int, o
251 toolParams = append(toolParams, "edits", fmt.Sprintf("%d", len(params.Edits)))
251 toolParams = append(toolParams, "edits", fmt.Sprintf("%d", len(params.Edits)))
252 }
252 }
253
253
254− header := toolHeader(sty, opts.Status(), "Multi-Edit", width, opts.Nested, toolParams...)
254+ header := toolHeader(sty, opts.Status(), "Multi-Edit", width, opts.Simple, toolParams...)
255− if opts.Nested {
255+ if opts.Simple {
256 return header
256 return header
257 }
257 }
258
258
@@ -276,3 +276,65 @@ func (m *MultiEditToolRenderContext) RenderTool(sty *styles.Styles, width int, o
276 body := toolOutputMultiEditDiffContent(sty, file, meta, len(params.Edits), width, opts.Expanded)
276 body := toolOutputMultiEditDiffContent(sty, file, meta, len(params.Edits), width, opts.Expanded)
277 return joinToolParts(header, body)
277 return joinToolParts(header, body)
278}
278}
279+
280+// -----------------------------------------------------------------------------
281+// Download Tool
282+// -----------------------------------------------------------------------------
283+
284+// DownloadToolMessageItem is a message item that represents a download tool call.
285+type DownloadToolMessageItem struct {
286+ *baseToolMessageItem
287+}
288+
289+var _ ToolMessageItem = (*DownloadToolMessageItem)(nil)
290+
291+// NewDownloadToolMessageItem creates a new [DownloadToolMessageItem].
292+func NewDownloadToolMessageItem(
293+ sty *styles.Styles,
294+ toolCall message.ToolCall,
295+ result *message.ToolResult,
296+ canceled bool,
297+) ToolMessageItem {
298+ return newBaseToolMessageItem(sty, toolCall, result, &DownloadToolRenderContext{}, canceled)
299+}
300+
301+// DownloadToolRenderContext renders download tool messages.
302+type DownloadToolRenderContext struct{}
303+
304+// RenderTool implements the [ToolRenderer] interface.
305+func (d *DownloadToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
306+ cappedWidth := cappedMessageWidth(width)
307+ if !opts.ToolCall.Finished && !opts.Canceled {
308+ return pendingTool(sty, "Download", opts.Anim)
309+ }
310+
311+ var params tools.DownloadParams
312+ if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
313+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
314+ }
315+
316+ toolParams := []string{params.URL}
317+ if params.FilePath != "" {
318+ toolParams = append(toolParams, "file_path", fsext.PrettyPath(params.FilePath))
319+ }
320+ if params.Timeout != 0 {
321+ toolParams = append(toolParams, "timeout", formatTimeout(params.Timeout))
322+ }
323+
324+ header := toolHeader(sty, opts.Status(), "Download", cappedWidth, opts.Simple, toolParams...)
325+ if opts.Simple {
326+ return header
327+ }
328+
329+ if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
330+ return joinToolParts(header, earlyState)
331+ }
332+
333+ if opts.Result == nil || opts.Result.Content == "" {
334+ return header
335+ }
336+
337+ bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
338+ body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.Expanded))
339+ return joinToolParts(header, body)
340+}
internal/ui/chat/search.go+68−6
@@ -50,8 +50,8 @@ func (g *GlobToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
50 toolParams = append(toolParams, "path", params.Path)
50 toolParams = append(toolParams, "path", params.Path)
51 }
51 }
52
52
53− header := toolHeader(sty, opts.Status(), "Glob", cappedWidth, opts.Nested, toolParams...)
53+ header := toolHeader(sty, opts.Status(), "Glob", cappedWidth, opts.Simple, toolParams...)
54− if opts.Nested {
54+ if opts.Simple {
55 return header
55 return header
56 }
56 }
57
57
@@ -115,8 +115,8 @@ func (g *GrepToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
115 toolParams = append(toolParams, "literal", "true")
115 toolParams = append(toolParams, "literal", "true")
116 }
116 }
117
117
118− header := toolHeader(sty, opts.Status(), "Grep", cappedWidth, opts.Nested, toolParams...)
118+ header := toolHeader(sty, opts.Status(), "Grep", cappedWidth, opts.Simple, toolParams...)
119− if opts.Nested {
119+ if opts.Simple {
120 return header
120 return header
121 }
121 }
122
122
@@ -175,8 +175,70 @@ func (l *LSToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *To
175 }
175 }
176 path = fsext.PrettyPath(path)
176 path = fsext.PrettyPath(path)
177
177
178− header := toolHeader(sty, opts.Status(), "List", cappedWidth, opts.Nested, path)
178+ header := toolHeader(sty, opts.Status(), "List", cappedWidth, opts.Simple, path)
179− if opts.Nested {
179+ if opts.Simple {
180+ return header
181+ }
182+
183+ if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
184+ return joinToolParts(header, earlyState)
185+ }
186+
187+ if opts.Result == nil || opts.Result.Content == "" {
188+ return header
189+ }
190+
191+ bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
192+ body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.Expanded))
193+ return joinToolParts(header, body)
194+}
195+
196+// -----------------------------------------------------------------------------
197+// Sourcegraph Tool
198+// -----------------------------------------------------------------------------
199+
200+// SourcegraphToolMessageItem is a message item that represents a sourcegraph tool call.
201+type SourcegraphToolMessageItem struct {
202+ *baseToolMessageItem
203+}
204+
205+var _ ToolMessageItem = (*SourcegraphToolMessageItem)(nil)
206+
207+// NewSourcegraphToolMessageItem creates a new [SourcegraphToolMessageItem].
208+func NewSourcegraphToolMessageItem(
209+ sty *styles.Styles,
210+ toolCall message.ToolCall,
211+ result *message.ToolResult,
212+ canceled bool,
213+) ToolMessageItem {
214+ return newBaseToolMessageItem(sty, toolCall, result, &SourcegraphToolRenderContext{}, canceled)
215+}
216+
217+// SourcegraphToolRenderContext renders sourcegraph tool messages.
218+type SourcegraphToolRenderContext struct{}
219+
220+// RenderTool implements the [ToolRenderer] interface.
221+func (s *SourcegraphToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
222+ cappedWidth := cappedMessageWidth(width)
223+ if !opts.ToolCall.Finished && !opts.Canceled {
224+ return pendingTool(sty, "Sourcegraph", opts.Anim)
225+ }
226+
227+ var params tools.SourcegraphParams
228+ if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
229+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
230+ }
231+
232+ toolParams := []string{params.Query}
233+ if params.Count != 0 {
234+ toolParams = append(toolParams, "count", formatNonZero(params.Count))
235+ }
236+ if params.ContextWindow != 0 {
237+ toolParams = append(toolParams, "context", formatNonZero(params.ContextWindow))
238+ }
239+
240+ header := toolHeader(sty, opts.Status(), "Sourcegraph", cappedWidth, opts.Simple, toolParams...)
241+ if opts.Simple {
180 return header
242 return header
181 }
243 }
182
244
internal/ui/chat/tools.go+44−4
@@ -40,6 +40,12 @@ type ToolMessageItem interface {
40 SetResult(res *message.ToolResult)
40 SetResult(res *message.ToolResult)
41}
41}
42
42
43+// Simplifiable is an interface for tool items that can render in a simplified mode.
44+// When simple mode is enabled, tools render as a compact single-line header.
45+type Simplifiable interface {
46+ SetSimple(simple bool)
47+}
48+
43// DefaultToolRenderContext implements the default [ToolRenderer] interface.
49// DefaultToolRenderContext implements the default [ToolRenderer] interface.
44type DefaultToolRenderContext struct{}
50type DefaultToolRenderContext struct{}
45
51
@@ -55,7 +61,7 @@ type ToolRenderOpts struct {
55 Canceled bool
61 Canceled bool
56 Anim *anim.Anim
62 Anim *anim.Anim
57 Expanded bool
63 Expanded bool
58− Nested bool
64+ Simple bool
59 IsSpinning bool
65 IsSpinning bool
60 PermissionRequested bool
66 PermissionRequested bool
61 PermissionGranted bool
67 PermissionGranted bool
@@ -106,6 +112,8 @@ type baseToolMessageItem struct {
106 // we use this so we can efficiently cache
112 // we use this so we can efficiently cache
107 // tools that have a capped width (e.x bash.. and others)
113 // tools that have a capped width (e.x bash.. and others)
108 hasCappedWidth bool
114 hasCappedWidth bool
115+ // isSimple indicates this tool should render in simplified/compact mode.
116+ isSimple bool
109
117
110 sty *styles.Styles
118 sty *styles.Styles
111 anim *anim.Anim
119 anim *anim.Anim
@@ -177,6 +185,14 @@ func NewToolMessageItem(
177 return NewGrepToolMessageItem(sty, toolCall, result, canceled)
185 return NewGrepToolMessageItem(sty, toolCall, result, canceled)
178 case tools.LSToolName:
186 case tools.LSToolName:
179 return NewLSToolMessageItem(sty, toolCall, result, canceled)
187 return NewLSToolMessageItem(sty, toolCall, result, canceled)
188+ case tools.DownloadToolName:
189+ return NewDownloadToolMessageItem(sty, toolCall, result, canceled)
190+ case tools.FetchToolName:
191+ return NewFetchToolMessageItem(sty, toolCall, result, canceled)
192+ case tools.SourcegraphToolName:
193+ return NewSourcegraphToolMessageItem(sty, toolCall, result, canceled)
194+ case tools.DiagnosticsToolName:
195+ return NewDiagnosticsToolMessageItem(sty, toolCall, result, canceled)
180 default:
196 default:
181 // TODO: Implement other tool items
197 // TODO: Implement other tool items
182 return newBaseToolMessageItem(
198 return newBaseToolMessageItem(
@@ -189,6 +205,12 @@ func NewToolMessageItem(
189 }
205 }
190}
206}
191
207
208+// SetSimple implements the Simplifiable interface.
209+func (t *baseToolMessageItem) SetSimple(simple bool) {
210+ t.isSimple = simple
211+ t.clearCache()
212+}
213+
192// ID returns the unique identifier for this tool message item.
214// ID returns the unique identifier for this tool message item.
193func (t *baseToolMessageItem) ID() string {
215func (t *baseToolMessageItem) ID() string {
194 return t.toolCall.ID
216 return t.toolCall.ID
@@ -230,6 +252,7 @@ func (t *baseToolMessageItem) Render(width int) string {
230 Canceled: t.canceled,
252 Canceled: t.canceled,
231 Anim: t.anim,
253 Anim: t.anim,
232 Expanded: t.expanded,
254 Expanded: t.expanded,
255+ Simple: t.isSimple,
233 PermissionRequested: t.permissionRequested,
256 PermissionRequested: t.permissionRequested,
234 PermissionGranted: t.permissionGranted,
257 PermissionGranted: t.permissionGranted,
235 IsSpinning: t.isSpinning(),
258 IsSpinning: t.isSpinning(),
@@ -487,10 +510,10 @@ func toolOutputCodeContent(sty *styles.Styles, path, content string, offset, wid
487
510
488 // Add truncation message if needed.
511 // Add truncation message if needed.
489 if len(lines) > maxLines && !expanded {
512 if len(lines) > maxLines && !expanded {
490− truncMsg := sty.Tool.ContentCodeTruncation.
513+ out = append(out, sty.Tool.ContentCodeTruncation.
491 Width(bodyWidth).
514 Width(bodyWidth).
492− Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines))
515+ Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)),
493− out = append([]string{truncMsg}, out...)
516+ )
494 }
517 }
495
518
496 return sty.Tool.Body.Render(strings.Join(out, "\n"))
519 return sty.Tool.Body.Render(strings.Join(out, "\n"))
@@ -574,6 +597,23 @@ func toolOutputDiffContent(sty *styles.Styles, file, oldContent, newContent stri
574 return sty.Tool.Body.Render(formatted)
597 return sty.Tool.Body.Render(formatted)
575}
598}
576
599
600+// formatTimeout converts timeout seconds to a duration string (e.g., "30s").
601+// Returns empty string if timeout is 0.
602+func formatTimeout(timeout int) string {
603+ if timeout == 0 {
604+ return ""
605+ }
606+ return fmt.Sprintf("%ds", timeout)
607+}
608+
609+// formatNonZero returns string representation of non-zero integers, empty string for zero.
610+func formatNonZero(value int) string {
611+ if value == 0 {
612+ return ""
613+ }
614+ return fmt.Sprintf("%d", value)
615+}
616+
577// toolOutputMultiEditDiffContent renders a diff with optional failed edits note.
617// toolOutputMultiEditDiffContent renders a diff with optional failed edits note.
578func toolOutputMultiEditDiffContent(sty *styles.Styles, file string, meta tools.MultiEditResponseMetadata, totalEdits, width int, expanded bool) string {
618func toolOutputMultiEditDiffContent(sty *styles.Styles, file string, meta tools.MultiEditResponseMetadata, totalEdits, width int, expanded bool) string {
579 bodyWidth := width - toolBodyLeftPaddingTotal
619 bodyWidth := width - toolBodyLeftPaddingTotal