@@ -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,14 +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
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+// 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)
4751}
4852
53+// IsSpinningState contains the state passed to IsSpinningFn for custom spinning logic.
54+type IsSpinningState struct {
55+ ToolCall message.ToolCall
56+ Result *message.ToolResult
57+ Canceled bool
58+}
59+
60+// IsSpinningFn is a function type for custom spinning logic.
61+// Returns true if the tool should show the spinning animation.
62+type IsSpinningFn func(state IsSpinningState) bool
63+
4964// DefaultToolRenderContext implements the default [ToolRenderer] interface.
5065type DefaultToolRenderContext struct{}
5166
@@ -60,8 +75,8 @@ type ToolRenderOpts struct {
6075 Result *message.ToolResult
6176 Canceled bool
6277 Anim *anim.Anim
63− Expanded bool
64− Simple bool
78+ ExpandedContent bool
79+ Compact bool
6580 IsSpinning bool
6681 PermissionRequested bool
6782 PermissionGranted bool
@@ -106,18 +121,22 @@ type baseToolMessageItem struct {
106121 toolRenderer ToolRenderer
107122 toolCall message.ToolCall
108123 result *message.ToolResult
124+ messageID string
109125 canceled bool
110126 permissionRequested bool
111127 permissionGranted bool
112128 // we use this so we can efficiently cache
113129 // tools that have a capped width (e.x bash.. and others)
114130 hasCappedWidth bool
115− // isSimple indicates this tool should render in simplified/compact mode.
116− isSimple bool
131+ // isCompact indicates this tool should render in compact mode.
132+ isCompact bool
133+ // isSpinningFn allows tools to override the default spinning logic.
134+ // If nil, uses the default: !toolCall.Finished && !canceled.
135+ isSpinningFn IsSpinningFn
117136
118− sty *styles.Styles
119− anim *anim.Anim
120− expanded bool
137+ sty *styles.Styles
138+ anim *anim.Anim
139+ expandedContent bool
121140}
122141
123142// newBaseToolMessageItem is the internal constructor for base tool message items.
@@ -157,45 +176,58 @@ func newBaseToolMessageItem(
157176// NewToolMessageItem creates a new [ToolMessageItem] based on the tool call name.
158177//
159178// It returns a specific tool message item type if implemented, otherwise it
160−// 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.
161181func NewToolMessageItem(
162182 sty *styles.Styles,
183+ messageID string,
163184 toolCall message.ToolCall,
164185 result *message.ToolResult,
165186 canceled bool,
166187) ToolMessageItem {
188+ var item ToolMessageItem
167189 switch toolCall.Name {
168190 case tools.BashToolName:
169− return NewBashToolMessageItem(sty, toolCall, result, canceled)
191+ item = NewBashToolMessageItem(sty, toolCall, result, canceled)
170192 case tools.JobOutputToolName:
171− return NewJobOutputToolMessageItem(sty, toolCall, result, canceled)
193+ item = NewJobOutputToolMessageItem(sty, toolCall, result, canceled)
172194 case tools.JobKillToolName:
173− return NewJobKillToolMessageItem(sty, toolCall, result, canceled)
195+ item = NewJobKillToolMessageItem(sty, toolCall, result, canceled)
174196 case tools.ViewToolName:
175− return NewViewToolMessageItem(sty, toolCall, result, canceled)
197+ item = NewViewToolMessageItem(sty, toolCall, result, canceled)
176198 case tools.WriteToolName:
177− return NewWriteToolMessageItem(sty, toolCall, result, canceled)
199+ item = NewWriteToolMessageItem(sty, toolCall, result, canceled)
178200 case tools.EditToolName:
179− return NewEditToolMessageItem(sty, toolCall, result, canceled)
201+ item = NewEditToolMessageItem(sty, toolCall, result, canceled)
180202 case tools.MultiEditToolName:
181− return NewMultiEditToolMessageItem(sty, toolCall, result, canceled)
203+ item = NewMultiEditToolMessageItem(sty, toolCall, result, canceled)
182204 case tools.GlobToolName:
183− return NewGlobToolMessageItem(sty, toolCall, result, canceled)
205+ item = NewGlobToolMessageItem(sty, toolCall, result, canceled)
184206 case tools.GrepToolName:
185− return NewGrepToolMessageItem(sty, toolCall, result, canceled)
207+ item = NewGrepToolMessageItem(sty, toolCall, result, canceled)
186208 case tools.LSToolName:
187− return NewLSToolMessageItem(sty, toolCall, result, canceled)
209+ item = NewLSToolMessageItem(sty, toolCall, result, canceled)
188210 case tools.DownloadToolName:
189− return NewDownloadToolMessageItem(sty, toolCall, result, canceled)
211+ item = NewDownloadToolMessageItem(sty, toolCall, result, canceled)
190212 case tools.FetchToolName:
191− return NewFetchToolMessageItem(sty, toolCall, result, canceled)
213+ item = NewFetchToolMessageItem(sty, toolCall, result, canceled)
192214 case tools.SourcegraphToolName:
193− return NewSourcegraphToolMessageItem(sty, toolCall, result, canceled)
215+ item = NewSourcegraphToolMessageItem(sty, toolCall, result, canceled)
194216 case tools.DiagnosticsToolName:
195− return NewDiagnosticsToolMessageItem(sty, toolCall, result, canceled)
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)
196228 default:
197229 // TODO: Implement other tool items
198− return newBaseToolMessageItem(
230+ item = newBaseToolMessageItem(
199231 sty,
200232 toolCall,
201233 result,
@@ -203,11 +235,13 @@ func NewToolMessageItem(
203235 canceled,
204236 )
205237 }
238+ item.SetMessageID(messageID)
239+ return item
206240}
207241
208−// SetSimple implements the Simplifiable interface.
209−func (t *baseToolMessageItem) SetSimple(simple bool) {
210− t.isSimple = simple
242+// SetCompact implements the Compactable interface.
243+func (t *baseToolMessageItem) SetCompact(compact bool) {
244+ t.isCompact = compact
211245 t.clearCache()
212246}
213247
@@ -243,6 +277,10 @@ func (t *baseToolMessageItem) Render(width int) string {
243277 style = t.sty.Chat.Message.ToolCallFocused
244278 }
245279
280+ if t.isCompact {
281+ style = t.sty.Chat.Message.ToolCallCompact
282+ }
283+
246284 content, height, ok := t.getCachedRender(toolItemWidth)
247285 // if we are spinning or there is no cache rerender
248286 if !ok || t.isSpinning() {
@@ -251,8 +289,8 @@ func (t *baseToolMessageItem) Render(width int) string {
251289 Result: t.result,
252290 Canceled: t.canceled,
253291 Anim: t.anim,
254− Expanded: t.expanded,
255− Simple: t.isSimple,
292+ ExpandedContent: t.expandedContent,
293+ Compact: t.isCompact,
256294 PermissionRequested: t.permissionRequested,
257295 PermissionGranted: t.permissionGranted,
258296 IsSpinning: t.isSpinning(),
@@ -283,6 +321,16 @@ func (t *baseToolMessageItem) SetResult(res *message.ToolResult) {
283321 t.clearCache()
284322}
285323
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+
286334// SetPermissionRequested sets whether permission has been requested for this tool call.
287335// TODO: Consider merging with SetPermissionGranted and add an interface for
288336// permission management.
@@ -301,12 +349,24 @@ func (t *baseToolMessageItem) SetPermissionGranted(granted bool) {
301349
302350// isSpinning returns true if the tool should show animation.
303351func (t *baseToolMessageItem) isSpinning() bool {
352+ if t.isSpinningFn != nil {
353+ return t.isSpinningFn(IsSpinningState{
354+ ToolCall: t.toolCall,
355+ Result: t.result,
356+ Canceled: t.canceled,
357+ })
358+ }
304359 return !t.toolCall.Finished && !t.canceled
305360}
306361
362+// SetIsSpinningFn sets a custom function to determine if the tool should spin.
363+func (t *baseToolMessageItem) SetIsSpinningFn(fn IsSpinningFn) {
364+ t.isSpinningFn = fn
365+}
366+
307367// ToggleExpanded toggles the expanded state of the thinking box.
308368func (t *baseToolMessageItem) ToggleExpanded() {
309− t.expanded = !t.expanded
369+ t.expandedContent = !t.expandedContent
310370 t.clearCache()
311371}
312372
@@ -654,3 +714,62 @@ func toolOutputMultiEditDiffContent(sty *styles.Styles, file string, meta tools.
654714
655715 return sty.Tool.Body.Render(formatted)
656716}
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 > 120 {
744+ width = 120
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+}