@@ -6,6 +6,8 @@ import (
7 tea "charm.land/bubbletea/v2"
7 tea "charm.land/bubbletea/v2"
8 "charm.land/lipgloss/v2"
8 "charm.land/lipgloss/v2"
9+ "charm.land/lipgloss/v2/tree"
10+ "github.com/charmbracelet/crush/internal/agent"
9 "github.com/charmbracelet/crush/internal/agent/tools"
11 "github.com/charmbracelet/crush/internal/agent/tools"
10 "github.com/charmbracelet/crush/internal/message"
12 "github.com/charmbracelet/crush/internal/message"
11 "github.com/charmbracelet/crush/internal/ui/anim"
13 "github.com/charmbracelet/crush/internal/ui/anim"
@@ -38,8 +40,27 @@ type ToolMessageItem interface {
38 ToolCall() message.ToolCall
40 ToolCall() message.ToolCall
39 SetToolCall(tc message.ToolCall)
41 SetToolCall(tc message.ToolCall)
40 SetResult(res *message.ToolResult)
42 SetResult(res *message.ToolResult)
44+ SetMessageID(id string)
47+// Compactable is an interface for tool items that can render in a compacted mode.
48+// When compact mode is enabled, tools render as a compact single-line header.
49+type Compactable interface {
50+ SetCompact(compact bool)
53+// SpinningState contains the state passed to SpinningFunc for custom spinning logic.
54+type SpinningState struct {
55+ ToolCall message.ToolCall
56+ Result *message.ToolResult
60+// SpinningFunc is a function type for custom spinning logic.
61+// Returns true if the tool should show the spinning animation.
62+type SpinningFunc func(state SpinningState) bool
43// DefaultToolRenderContext implements the default [ToolRenderer] interface.
64// DefaultToolRenderContext implements the default [ToolRenderer] interface.
44type DefaultToolRenderContext struct{}
65type DefaultToolRenderContext struct{}
@@ -54,8 +75,8 @@ type ToolRenderOpts struct {
54 Result *message.ToolResult
75 Result *message.ToolResult
55 Canceled bool
76 Canceled bool
56 Anim *anim.Anim
77 Anim *anim.Anim
57− Expanded bool
78+ ExpandedContent bool
58− Nested bool
79+ Compact bool
59 IsSpinning bool
80 IsSpinning bool
60 PermissionRequested bool
81 PermissionRequested bool
61 PermissionGranted bool
82 PermissionGranted bool
@@ -100,16 +121,22 @@ type baseToolMessageItem struct {
100 toolRenderer ToolRenderer
121 toolRenderer ToolRenderer
101 toolCall message.ToolCall
122 toolCall message.ToolCall
102 result *message.ToolResult
123 result *message.ToolResult
103 canceled bool
125 canceled bool
104 permissionRequested bool
126 permissionRequested bool
105 permissionGranted bool
127 permissionGranted bool
106 // we use this so we can efficiently cache
128 // we use this so we can efficiently cache
107 // tools that have a capped width (e.x bash.. and others)
129 // tools that have a capped width (e.x bash.. and others)
108 hasCappedWidth bool
130 hasCappedWidth bool
131+ // isCompact indicates this tool should render in compact mode.
133+ // spinningFunc allows tools to override the default spinning logic.
134+ // If nil, uses the default: !toolCall.Finished && !canceled.
135+ spinningFunc SpinningFunc
110− sty *styles.Styles
137+ sty *styles.Styles
111− anim *anim.Anim
138+ anim *anim.Anim
112− expanded bool
139+ expandedContent bool
115// newBaseToolMessageItem is the internal constructor for base tool message items.
142// newBaseToolMessageItem is the internal constructor for base tool message items.
@@ -149,37 +176,58 @@ func newBaseToolMessageItem(
149// NewToolMessageItem creates a new [ToolMessageItem] based on the tool call name.
176// NewToolMessageItem creates a new [ToolMessageItem] based on the tool call name.
151// It returns a specific tool message item type if implemented, otherwise it
178// It returns a specific tool message item type if implemented, otherwise it
152−// returns a generic tool message item.
179+// returns a generic tool message item. The messageID is the ID of the assistant
180+// message containing this tool call.
153func NewToolMessageItem(
181func NewToolMessageItem(
154 sty *styles.Styles,
182 sty *styles.Styles,
155 toolCall message.ToolCall,
184 toolCall message.ToolCall,
156 result *message.ToolResult,
185 result *message.ToolResult,
157 canceled bool,
186 canceled bool,
158) ToolMessageItem {
187) ToolMessageItem {
188+ var item ToolMessageItem
159 switch toolCall.Name {
189 switch toolCall.Name {
160 case tools.BashToolName:
190 case tools.BashToolName:
161− return NewBashToolMessageItem(sty, toolCall, result, canceled)
191+ item = NewBashToolMessageItem(sty, toolCall, result, canceled)
162 case tools.JobOutputToolName:
192 case tools.JobOutputToolName:
163− return NewJobOutputToolMessageItem(sty, toolCall, result, canceled)
193+ item = NewJobOutputToolMessageItem(sty, toolCall, result, canceled)
164 case tools.JobKillToolName:
194 case tools.JobKillToolName:
165− return NewJobKillToolMessageItem(sty, toolCall, result, canceled)
195+ item = NewJobKillToolMessageItem(sty, toolCall, result, canceled)
166 case tools.ViewToolName:
196 case tools.ViewToolName:
167− return NewViewToolMessageItem(sty, toolCall, result, canceled)
197+ item = NewViewToolMessageItem(sty, toolCall, result, canceled)
168 case tools.WriteToolName:
198 case tools.WriteToolName:
169− return NewWriteToolMessageItem(sty, toolCall, result, canceled)
199+ item = NewWriteToolMessageItem(sty, toolCall, result, canceled)
170 case tools.EditToolName:
200 case tools.EditToolName:
171− return NewEditToolMessageItem(sty, toolCall, result, canceled)
201+ item = NewEditToolMessageItem(sty, toolCall, result, canceled)
172 case tools.MultiEditToolName:
202 case tools.MultiEditToolName:
173− return NewMultiEditToolMessageItem(sty, toolCall, result, canceled)
203+ item = NewMultiEditToolMessageItem(sty, toolCall, result, canceled)
174 case tools.GlobToolName:
204 case tools.GlobToolName:
175− return NewGlobToolMessageItem(sty, toolCall, result, canceled)
205+ item = NewGlobToolMessageItem(sty, toolCall, result, canceled)
176 case tools.GrepToolName:
206 case tools.GrepToolName:
177− return NewGrepToolMessageItem(sty, toolCall, result, canceled)
207+ item = NewGrepToolMessageItem(sty, toolCall, result, canceled)
178 case tools.LSToolName:
208 case tools.LSToolName:
179− return NewLSToolMessageItem(sty, toolCall, result, canceled)
209+ item = NewLSToolMessageItem(sty, toolCall, result, canceled)
210+ case tools.DownloadToolName:
211+ item = NewDownloadToolMessageItem(sty, toolCall, result, canceled)
212+ case tools.FetchToolName:
213+ item = NewFetchToolMessageItem(sty, toolCall, result, canceled)
214+ case tools.SourcegraphToolName:
215+ item = NewSourcegraphToolMessageItem(sty, toolCall, result, canceled)
216+ case tools.DiagnosticsToolName:
217+ item = NewDiagnosticsToolMessageItem(sty, toolCall, result, canceled)
218+ case agent.AgentToolName:
219+ item = NewAgentToolMessageItem(sty, toolCall, result, canceled)
220+ case tools.AgenticFetchToolName:
221+ item = NewAgenticFetchToolMessageItem(sty, toolCall, result, canceled)
222+ case tools.WebFetchToolName:
223+ item = NewWebFetchToolMessageItem(sty, toolCall, result, canceled)
224+ case tools.WebSearchToolName:
225+ item = NewWebSearchToolMessageItem(sty, toolCall, result, canceled)
226+ case tools.TodosToolName:
227+ item = NewTodosToolMessageItem(sty, toolCall, result, canceled)
181 // TODO: Implement other tool items
229 // TODO: Implement other tool items
182− return newBaseToolMessageItem(
230+ item = newBaseToolMessageItem(
184 toolCall,
232 toolCall,
@@ -187,6 +235,14 @@ func NewToolMessageItem(
187 canceled,
235 canceled,
238+ item.SetMessageID(messageID)
242+// SetCompact implements the Compactable interface.
243+func (t *baseToolMessageItem) SetCompact(compact bool) {
244+ t.isCompact = compact
192// ID returns the unique identifier for this tool message item.
248// ID returns the unique identifier for this tool message item.
@@ -221,6 +277,10 @@ func (t *baseToolMessageItem) Render(width int) string {
221 style = t.sty.Chat.Message.ToolCallFocused
277 style = t.sty.Chat.Message.ToolCallFocused
281+ style = t.sty.Chat.Message.ToolCallCompact
224 content, height, ok := t.getCachedRender(toolItemWidth)
284 content, height, ok := t.getCachedRender(toolItemWidth)
225 // if we are spinning or there is no cache rerender
285 // if we are spinning or there is no cache rerender
226 if !ok || t.isSpinning() {
286 if !ok || t.isSpinning() {
@@ -229,7 +289,8 @@ func (t *baseToolMessageItem) Render(width int) string {
229 Result: t.result,
289 Result: t.result,
230 Canceled: t.canceled,
290 Canceled: t.canceled,
231 Anim: t.anim,
291 Anim: t.anim,
232− Expanded: t.expanded,
292+ ExpandedContent: t.expandedContent,
293+ Compact: t.isCompact,
233 PermissionRequested: t.permissionRequested,
294 PermissionRequested: t.permissionRequested,
234 PermissionGranted: t.permissionGranted,
295 PermissionGranted: t.permissionGranted,
235 IsSpinning: t.isSpinning(),
296 IsSpinning: t.isSpinning(),
@@ -260,6 +321,16 @@ func (t *baseToolMessageItem) SetResult(res *message.ToolResult) {
260 t.clearCache()
321 t.clearCache()
324+// MessageID returns the ID of the message containing this tool call.
325+func (t *baseToolMessageItem) MessageID() string {
329+// SetMessageID sets the ID of the message containing this tool call.
330+func (t *baseToolMessageItem) SetMessageID(id string) {
263// SetPermissionRequested sets whether permission has been requested for this tool call.
334// SetPermissionRequested sets whether permission has been requested for this tool call.
264// TODO: Consider merging with SetPermissionGranted and add an interface for
335// TODO: Consider merging with SetPermissionGranted and add an interface for
265// permission management.
336// permission management.
@@ -278,12 +349,24 @@ func (t *baseToolMessageItem) SetPermissionGranted(granted bool) {
279// isSpinning returns true if the tool should show animation.
350// isSpinning returns true if the tool should show animation.
280func (t *baseToolMessageItem) isSpinning() bool {
351func (t *baseToolMessageItem) isSpinning() bool {
352+ if t.spinningFunc != nil {
353+ return t.spinningFunc(SpinningState{
354+ ToolCall: t.toolCall,
356+ Canceled: t.canceled,
281 return !t.toolCall.Finished && !t.canceled
359 return !t.toolCall.Finished && !t.canceled
362+// SetSpinningFunc sets a custom function to determine if the tool should spin.
363+func (t *baseToolMessageItem) SetSpinningFunc(fn SpinningFunc) {
284// ToggleExpanded toggles the expanded state of the thinking box.
367// ToggleExpanded toggles the expanded state of the thinking box.
285func (t *baseToolMessageItem) ToggleExpanded() {
368func (t *baseToolMessageItem) ToggleExpanded() {
286− t.expanded = !t.expanded
369+ t.expandedContent = !t.expandedContent
287 t.clearCache()
370 t.clearCache()
@@ -487,10 +570,10 @@ func toolOutputCodeContent(sty *styles.Styles, path, content string, offset, wid
488 // Add truncation message if needed.
571 // Add truncation message if needed.
489 if len(lines) > maxLines && !expanded {
572 if len(lines) > maxLines && !expanded {
490− truncMsg := sty.Tool.ContentCodeTruncation.
573+ out = append(out, sty.Tool.ContentCodeTruncation.
491 Width(bodyWidth).
574 Width(bodyWidth).
492− Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines))
575+ Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)),
493− out = append([]string{truncMsg}, out...)
576+ )
496 return sty.Tool.Body.Render(strings.Join(out, "\n"))
579 return sty.Tool.Body.Render(strings.Join(out, "\n"))
@@ -551,7 +634,7 @@ func toolOutputDiffContent(sty *styles.Styles, file, oldContent, newContent stri
551 Width(bodyWidth)
634 Width(bodyWidth)
553 // Use split view for wide terminals.
636 // Use split view for wide terminals.
554− if width > 120 {
637+ if width > maxTextWidth {
555 formatter = formatter.Split()
638 formatter = formatter.Split()
@@ -574,6 +657,23 @@ func toolOutputDiffContent(sty *styles.Styles, file, oldContent, newContent stri
574 return sty.Tool.Body.Render(formatted)
657 return sty.Tool.Body.Render(formatted)
660+// formatTimeout converts timeout seconds to a duration string (e.g., "30s").
661+// Returns empty string if timeout is 0.
662+func formatTimeout(timeout int) string {
666+ return fmt.Sprintf("%ds", timeout)
669+// formatNonZero returns string representation of non-zero integers, empty string for zero.
670+func formatNonZero(value int) string {
674+ return fmt.Sprintf("%d", value)
577// toolOutputMultiEditDiffContent renders a diff with optional failed edits note.
677// 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 {
678func toolOutputMultiEditDiffContent(sty *styles.Styles, file string, meta tools.MultiEditResponseMetadata, totalEdits, width int, expanded bool) string {
579 bodyWidth := width - toolBodyLeftPaddingTotal
679 bodyWidth := width - toolBodyLeftPaddingTotal
@@ -584,7 +684,7 @@ func toolOutputMultiEditDiffContent(sty *styles.Styles, file string, meta tools.
584 Width(bodyWidth)
684 Width(bodyWidth)
586 // Use split view for wide terminals.
686 // Use split view for wide terminals.
587− if width > 120 {
687+ if width > maxTextWidth {
588 formatter = formatter.Split()
688 formatter = formatter.Split()
@@ -614,3 +714,62 @@ func toolOutputMultiEditDiffContent(sty *styles.Styles, file string, meta tools.
615 return sty.Tool.Body.Render(formatted)
715 return sty.Tool.Body.Render(formatted)
718+// roundedEnumerator creates a tree enumerator with rounded corners.
719+func roundedEnumerator(lPadding, width int) tree.Enumerator {
726+ return func(children tree.Children, index int) string {
727+ line := strings.Repeat("─", width)
728+ padding := strings.Repeat(" ", lPadding)
729+ if children.Length()-1 == index {
730+ return padding + "╰" + line
732+ return padding + "├" + line
736+// toolOutputMarkdownContent renders markdown content with optional truncation.
737+func toolOutputMarkdownContent(sty *styles.Styles, content string, width int, expanded bool) string {
738+ content = strings.ReplaceAll(content, "\r\n", "\n")
739+ content = strings.ReplaceAll(content, "\t", " ")
740+ content = strings.TrimSpace(content)
742+ // Cap width for readability.
743+ if width > maxTextWidth {
744+ width = maxTextWidth
747+ renderer := common.PlainMarkdownRenderer(sty, width)
748+ rendered, err := renderer.Render(content)
750+ return toolOutputPlainContent(sty, content, width, expanded)
753+ lines := strings.Split(rendered, "\n")
754+ maxLines := responseContextHeight
756+ maxLines = len(lines)
760+ for i, ln := range lines {
764+ out = append(out, ln)
767+ if len(lines) > maxLines && !expanded {
768+ out = append(out, sty.Tool.ContentTruncation.
770+ Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)),
774+ return sty.Tool.Body.Render(strings.Join(out, "\n"))