@@ -6,6 +6,8 @@ import (
66
77 tea "charm.land/bubbletea/v2"
88 "charm.land/lipgloss/v2"
9+ "charm.land/lipgloss/v2/tree"
10+ "github.com/charmbracelet/crush/internal/agent"
911 "github.com/charmbracelet/crush/internal/agent/tools"
1012 "github.com/charmbracelet/crush/internal/message"
1113 "github.com/charmbracelet/crush/internal/ui/anim"
@@ -38,8 +40,27 @@ type ToolMessageItem interface {
3840 ToolCall() message.ToolCall
3941 SetToolCall(tc message.ToolCall)
4042 SetResult(res *message.ToolResult)
43+ MessageID() string
44+ SetMessageID(id string)
4145}
4246
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)
51+}
52+
53+// SpinningState contains the state passed to SpinningFunc for custom spinning logic.
54+type SpinningState struct {
55+ ToolCall message.ToolCall
56+ Result *message.ToolResult
57+ Canceled bool
58+}
59+
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
63+
4364// DefaultToolRenderContext implements the default [ToolRenderer] interface.
4465type DefaultToolRenderContext struct{}
4566
@@ -54,8 +75,8 @@ type ToolRenderOpts struct {
5475 Result *message.ToolResult
5576 Canceled bool
5677 Anim *anim.Anim
57− Expanded bool
58− Nested bool
78+ ExpandedContent bool
79+ Compact bool
5980 IsSpinning bool
6081 PermissionRequested bool
6182 PermissionGranted bool
@@ -100,16 +121,22 @@ type baseToolMessageItem struct {
100121 toolRenderer ToolRenderer
101122 toolCall message.ToolCall
102123 result *message.ToolResult
124+ messageID string
103125 canceled bool
104126 permissionRequested bool
105127 permissionGranted bool
106128 // we use this so we can efficiently cache
107129 // tools that have a capped width (e.x bash.. and others)
108130 hasCappedWidth bool
131+ // isCompact indicates this tool should render in compact mode.
132+ isCompact bool
133+ // spinningFunc allows tools to override the default spinning logic.
134+ // If nil, uses the default: !toolCall.Finished && !canceled.
135+ spinningFunc SpinningFunc
109136
110− sty *styles.Styles
111− anim *anim.Anim
112− expanded bool
137+ sty *styles.Styles
138+ anim *anim.Anim
139+ expandedContent bool
113140}
114141
115142// newBaseToolMessageItem is the internal constructor for base tool message items.
@@ -149,37 +176,58 @@ func newBaseToolMessageItem(
149176// NewToolMessageItem creates a new [ToolMessageItem] based on the tool call name.
150177//
151178// 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.
153181func NewToolMessageItem(
154182 sty *styles.Styles,
183+ messageID string,
155184 toolCall message.ToolCall,
156185 result *message.ToolResult,
157186 canceled bool,
158187) ToolMessageItem {
188+ var item ToolMessageItem
159189 switch toolCall.Name {
160190 case tools.BashToolName:
161− return NewBashToolMessageItem(sty, toolCall, result, canceled)
191+ item = NewBashToolMessageItem(sty, toolCall, result, canceled)
162192 case tools.JobOutputToolName:
163− return NewJobOutputToolMessageItem(sty, toolCall, result, canceled)
193+ item = NewJobOutputToolMessageItem(sty, toolCall, result, canceled)
164194 case tools.JobKillToolName:
165− return NewJobKillToolMessageItem(sty, toolCall, result, canceled)
195+ item = NewJobKillToolMessageItem(sty, toolCall, result, canceled)
166196 case tools.ViewToolName:
167− return NewViewToolMessageItem(sty, toolCall, result, canceled)
197+ item = NewViewToolMessageItem(sty, toolCall, result, canceled)
168198 case tools.WriteToolName:
169− return NewWriteToolMessageItem(sty, toolCall, result, canceled)
199+ item = NewWriteToolMessageItem(sty, toolCall, result, canceled)
170200 case tools.EditToolName:
171− return NewEditToolMessageItem(sty, toolCall, result, canceled)
201+ item = NewEditToolMessageItem(sty, toolCall, result, canceled)
172202 case tools.MultiEditToolName:
173− return NewMultiEditToolMessageItem(sty, toolCall, result, canceled)
203+ item = NewMultiEditToolMessageItem(sty, toolCall, result, canceled)
174204 case tools.GlobToolName:
175− return NewGlobToolMessageItem(sty, toolCall, result, canceled)
205+ item = NewGlobToolMessageItem(sty, toolCall, result, canceled)
176206 case tools.GrepToolName:
177− return NewGrepToolMessageItem(sty, toolCall, result, canceled)
207+ item = NewGrepToolMessageItem(sty, toolCall, result, canceled)
178208 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)
180228 default:
181229 // TODO: Implement other tool items
182− return newBaseToolMessageItem(
230+ item = newBaseToolMessageItem(
183231 sty,
184232 toolCall,
185233 result,
@@ -187,6 +235,14 @@ func NewToolMessageItem(
187235 canceled,
188236 )
189237 }
238+ item.SetMessageID(messageID)
239+ return item
240+}
241+
242+// SetCompact implements the Compactable interface.
243+func (t *baseToolMessageItem) SetCompact(compact bool) {
244+ t.isCompact = compact
245+ t.clearCache()
190246}
191247
192248// ID returns the unique identifier for this tool message item.
@@ -221,6 +277,10 @@ func (t *baseToolMessageItem) Render(width int) string {
221277 style = t.sty.Chat.Message.ToolCallFocused
222278 }
223279
280+ if t.isCompact {
281+ style = t.sty.Chat.Message.ToolCallCompact
282+ }
283+
224284 content, height, ok := t.getCachedRender(toolItemWidth)
225285 // if we are spinning or there is no cache rerender
226286 if !ok || t.isSpinning() {
@@ -229,7 +289,8 @@ func (t *baseToolMessageItem) Render(width int) string {
229289 Result: t.result,
230290 Canceled: t.canceled,
231291 Anim: t.anim,
232− Expanded: t.expanded,
292+ ExpandedContent: t.expandedContent,
293+ Compact: t.isCompact,
233294 PermissionRequested: t.permissionRequested,
234295 PermissionGranted: t.permissionGranted,
235296 IsSpinning: t.isSpinning(),
@@ -260,6 +321,16 @@ func (t *baseToolMessageItem) SetResult(res *message.ToolResult) {
260321 t.clearCache()
261322}
262323
324+// MessageID returns the ID of the message containing this tool call.
325+func (t *baseToolMessageItem) MessageID() string {
326+ return t.messageID
327+}
328+
329+// SetMessageID sets the ID of the message containing this tool call.
330+func (t *baseToolMessageItem) SetMessageID(id string) {
331+ t.messageID = id
332+}
333+
263334// SetPermissionRequested sets whether permission has been requested for this tool call.
264335// TODO: Consider merging with SetPermissionGranted and add an interface for
265336// permission management.
@@ -278,12 +349,24 @@ func (t *baseToolMessageItem) SetPermissionGranted(granted bool) {
278349
279350// isSpinning returns true if the tool should show animation.
280351func (t *baseToolMessageItem) isSpinning() bool {
352+ if t.spinningFunc != nil {
353+ return t.spinningFunc(SpinningState{
354+ ToolCall: t.toolCall,
355+ Result: t.result,
356+ Canceled: t.canceled,
357+ })
358+ }
281359 return !t.toolCall.Finished && !t.canceled
282360}
283361
362+// SetSpinningFunc sets a custom function to determine if the tool should spin.
363+func (t *baseToolMessageItem) SetSpinningFunc(fn SpinningFunc) {
364+ t.spinningFunc = fn
365+}
366+
284367// ToggleExpanded toggles the expanded state of the thinking box.
285368func (t *baseToolMessageItem) ToggleExpanded() {
286− t.expanded = !t.expanded
369+ t.expandedContent = !t.expandedContent
287370 t.clearCache()
288371}
289372
@@ -487,10 +570,10 @@ func toolOutputCodeContent(sty *styles.Styles, path, content string, offset, wid
487570
488571 // Add truncation message if needed.
489572 if len(lines) > maxLines && !expanded {
490− truncMsg := sty.Tool.ContentCodeTruncation.
573+ out = append(out, sty.Tool.ContentCodeTruncation.
491574 Width(bodyWidth).
492− Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines))
493− out = append([]string{truncMsg}, out...)
575+ Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)),
576+ )
494577 }
495578
496579 return sty.Tool.Body.Render(strings.Join(out, "\n"))
@@ -551,7 +634,7 @@ func toolOutputDiffContent(sty *styles.Styles, file, oldContent, newContent stri
551634 Width(bodyWidth)
552635
553636 // Use split view for wide terminals.
554− if width > 120 {
637+ if width > maxTextWidth {
555638 formatter = formatter.Split()
556639 }
557640
@@ -574,6 +657,23 @@ func toolOutputDiffContent(sty *styles.Styles, file, oldContent, newContent stri
574657 return sty.Tool.Body.Render(formatted)
575658}
576659
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 {
663+ if timeout == 0 {
664+ return ""
665+ }
666+ return fmt.Sprintf("%ds", timeout)
667+}
668+
669+// formatNonZero returns string representation of non-zero integers, empty string for zero.
670+func formatNonZero(value int) string {
671+ if value == 0 {
672+ return ""
673+ }
674+ return fmt.Sprintf("%d", value)
675+}
676+
577677// toolOutputMultiEditDiffContent renders a diff with optional failed edits note.
578678func toolOutputMultiEditDiffContent(sty *styles.Styles, file string, meta tools.MultiEditResponseMetadata, totalEdits, width int, expanded bool) string {
579679 bodyWidth := width - toolBodyLeftPaddingTotal
@@ -584,7 +684,7 @@ func toolOutputMultiEditDiffContent(sty *styles.Styles, file string, meta tools.
584684 Width(bodyWidth)
585685
586686 // Use split view for wide terminals.
587− if width > 120 {
687+ if width > maxTextWidth {
588688 formatter = formatter.Split()
589689 }
590690
@@ -614,3 +714,62 @@ func toolOutputMultiEditDiffContent(sty *styles.Styles, file string, meta tools.
614714
615715 return sty.Tool.Body.Render(formatted)
616716}
717+
718+// roundedEnumerator creates a tree enumerator with rounded corners.
719+func roundedEnumerator(lPadding, width int) tree.Enumerator {
720+ if width == 0 {
721+ width = 2
722+ }
723+ if lPadding == 0 {
724+ lPadding = 1
725+ }
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
731+ }
732+ return padding + "├" + line
733+ }
734+}
735+
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)
741+
742+ // Cap width for readability.
743+ if width > maxTextWidth {
744+ width = maxTextWidth
745+ }
746+
747+ renderer := common.PlainMarkdownRenderer(sty, width)
748+ rendered, err := renderer.Render(content)
749+ if err != nil {
750+ return toolOutputPlainContent(sty, content, width, expanded)
751+ }
752+
753+ lines := strings.Split(rendered, "\n")
754+ maxLines := responseContextHeight
755+ if expanded {
756+ maxLines = len(lines)
757+ }
758+
759+ var out []string
760+ for i, ln := range lines {
761+ if i >= maxLines {
762+ break
763+ }
764+ out = append(out, ln)
765+ }
766+
767+ if len(lines) > maxLines && !expanded {
768+ out = append(out, sty.Tool.ContentTruncation.
769+ Width(width).
770+ Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)),
771+ )
772+ }
773+
774+ return sty.Tool.Body.Render(strings.Join(out, "\n"))
775+}