on main help sign in
refactor: add agent tools and rename simple->compact
commitc0e3f79756a0f405da72cb0c78ce93e773bee073 authorKujtim Hoxha <kujtimii.h@gmail.com> dateTue 6 Jan 2026, 15:27 (1 year ago) parent67efe86
12 files changed+1011−76
internal/ui/chat/agent.go+310 internal/ui/chat/bash.go+5−5 internal/ui/chat/diagnostics.go+3−3 internal/ui/chat/fetch.go+111−3 internal/ui/chat/file.go+17−17 internal/ui/chat/messages.go+1 internal/ui/chat/search.go+12−12 internal/ui/chat/todos.go+192 internal/ui/chat/tools.go+152−33 internal/ui/model/chat.go+36 internal/ui/model/ui.go+157−3 internal/ui/styles/styles.go+15
internal/ui/chat/agent.gonew file+310
@@ -0,0 +1,310 @@
1+package chat
2+
3+import (
4+ "encoding/json"
5+ "strings"
6+
7+ tea "charm.land/bubbletea/v2"
8+ "charm.land/lipgloss/v2"
9+ "charm.land/lipgloss/v2/tree"
10+ "github.com/charmbracelet/crush/internal/agent"
11+ "github.com/charmbracelet/crush/internal/message"
12+ "github.com/charmbracelet/crush/internal/ui/anim"
13+ "github.com/charmbracelet/crush/internal/ui/styles"
14+)
15+
16+// -----------------------------------------------------------------------------
17+// Agent Tool
18+// -----------------------------------------------------------------------------
19+
20+// NestedToolContainer is an interface for tool items that can contain nested tool calls.
21+type NestedToolContainer interface {
22+ NestedTools() []ToolMessageItem
23+ SetNestedTools(tools []ToolMessageItem)
24+ AddNestedTool(tool ToolMessageItem)
25+}
26+
27+// AgentToolMessageItem is a message item that represents an agent tool call.
28+type AgentToolMessageItem struct {
29+ *baseToolMessageItem
30+
31+ nestedTools []ToolMessageItem
32+}
33+
34+var (
35+ _ ToolMessageItem = (*AgentToolMessageItem)(nil)
36+ _ NestedToolContainer = (*AgentToolMessageItem)(nil)
37+)
38+
39+// NewAgentToolMessageItem creates a new [AgentToolMessageItem].
40+func NewAgentToolMessageItem(
41+ sty *styles.Styles,
42+ toolCall message.ToolCall,
43+ result *message.ToolResult,
44+ canceled bool,
45+) *AgentToolMessageItem {
46+ t := &AgentToolMessageItem{}
47+ t.baseToolMessageItem = newBaseToolMessageItem(sty, toolCall, result, &AgentToolRenderContext{agent: t}, canceled)
48+ // For the agent tool we keep spinning until the tool call is finished.
49+ t.isSpinningFn = func(state IsSpinningState) bool {
50+ return state.Result == nil && !state.Canceled
51+ }
52+ return t
53+}
54+
55+// Animate progresses the message animation if it should be spinning.
56+func (a *AgentToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
57+ if a.result != nil || a.canceled {
58+ return nil
59+ }
60+ if msg.ID == a.ID() {
61+ return a.anim.Animate(msg)
62+ }
63+ for _, nestedTool := range a.nestedTools {
64+ if msg.ID != nestedTool.ID() {
65+ continue
66+ }
67+ if s, ok := nestedTool.(Animatable); ok {
68+ return s.Animate(msg)
69+ }
70+ }
71+ return nil
72+}
73+
74+// NestedTools returns the nested tools.
75+func (a *AgentToolMessageItem) NestedTools() []ToolMessageItem {
76+ return a.nestedTools
77+}
78+
79+// SetNestedTools sets the nested tools.
80+func (a *AgentToolMessageItem) SetNestedTools(tools []ToolMessageItem) {
81+ a.nestedTools = tools
82+ a.clearCache()
83+}
84+
85+// AddNestedTool adds a nested tool.
86+func (a *AgentToolMessageItem) AddNestedTool(tool ToolMessageItem) {
87+ // Mark nested tools as simple (compact) rendering.
88+ if s, ok := tool.(Compactable); ok {
89+ s.SetCompact(true)
90+ }
91+ a.nestedTools = append(a.nestedTools, tool)
92+ a.clearCache()
93+}
94+
95+// AgentToolRenderContext renders agent tool messages.
96+type AgentToolRenderContext struct {
97+ agent *AgentToolMessageItem
98+}
99+
100+// RenderTool implements the [ToolRenderer] interface.
101+func (r *AgentToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
102+ cappedWidth := cappedMessageWidth(width)
103+ if !opts.ToolCall.Finished && !opts.Canceled && len(r.agent.nestedTools) == 0 {
104+ return pendingTool(sty, "Agent", opts.Anim)
105+ }
106+
107+ var params agent.AgentParams
108+ _ = json.Unmarshal([]byte(opts.ToolCall.Input), &params)
109+
110+ prompt := params.Prompt
111+ prompt = strings.ReplaceAll(prompt, "\n", " ")
112+
113+ header := toolHeader(sty, opts.Status(), "Agent", cappedWidth, opts.Compact)
114+ if opts.Compact {
115+ return header
116+ }
117+
118+ // Build the task tag and prompt.
119+ taskTag := sty.Tool.AgentTaskTag.Render("Task")
120+ taskTagWidth := lipgloss.Width(taskTag)
121+
122+ // Calculate remaining width for prompt.
123+ remainingWidth := min(cappedWidth-taskTagWidth-3, 120-taskTagWidth-3) // -3 for spacing
124+
125+ promptText := sty.Tool.AgentPrompt.Width(remainingWidth).Render(prompt)
126+
127+ header = lipgloss.JoinVertical(
128+ lipgloss.Left,
129+ header,
130+ "",
131+ lipgloss.JoinHorizontal(
132+ lipgloss.Left,
133+ taskTag,
134+ " ",
135+ promptText,
136+ ),
137+ )
138+
139+ // Build tree with nested tool calls.
140+ childTools := tree.Root(header)
141+
142+ for _, nestedTool := range r.agent.nestedTools {
143+ childView := nestedTool.Render(remainingWidth)
144+ childTools.Child(childView)
145+ }
146+
147+ // Build parts.
148+ var parts []string
149+ parts = append(parts, childTools.Enumerator(roundedEnumerator(2, taskTagWidth-5)).String())
150+
151+ // Show animation if still running.
152+ if opts.Result == nil && !opts.Canceled {
153+ parts = append(parts, "", opts.Anim.Render())
154+ }
155+
156+ result := lipgloss.JoinVertical(lipgloss.Left, parts...)
157+
158+ // Add body content when completed.
159+ if opts.Result != nil && opts.Result.Content != "" {
160+ body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth-toolBodyLeftPaddingTotal, opts.ExpandedContent)
161+ return joinToolParts(result, body)
162+ }
163+
164+ return result
165+}
166+
167+// -----------------------------------------------------------------------------
168+// Agentic Fetch Tool
169+// -----------------------------------------------------------------------------
170+
171+// AgenticFetchToolMessageItem is a message item that represents an agentic fetch tool call.
172+type AgenticFetchToolMessageItem struct {
173+ *baseToolMessageItem
174+
175+ nestedTools []ToolMessageItem
176+}
177+
178+var (
179+ _ ToolMessageItem = (*AgenticFetchToolMessageItem)(nil)
180+ _ NestedToolContainer = (*AgenticFetchToolMessageItem)(nil)
181+)
182+
183+// NewAgenticFetchToolMessageItem creates a new [AgenticFetchToolMessageItem].
184+func NewAgenticFetchToolMessageItem(
185+ sty *styles.Styles,
186+ toolCall message.ToolCall,
187+ result *message.ToolResult,
188+ canceled bool,
189+) *AgenticFetchToolMessageItem {
190+ t := &AgenticFetchToolMessageItem{}
191+ t.baseToolMessageItem = newBaseToolMessageItem(sty, toolCall, result, &AgenticFetchToolRenderContext{fetch: t}, canceled)
192+ // For the agentic fetch tool we keep spinning until the tool call is finished.
193+ t.isSpinningFn = func(state IsSpinningState) bool {
194+ return state.Result == nil && !state.Canceled
195+ }
196+ return t
197+}
198+
199+// NestedTools returns the nested tools.
200+func (a *AgenticFetchToolMessageItem) NestedTools() []ToolMessageItem {
201+ return a.nestedTools
202+}
203+
204+// SetNestedTools sets the nested tools.
205+func (a *AgenticFetchToolMessageItem) SetNestedTools(tools []ToolMessageItem) {
206+ a.nestedTools = tools
207+ a.clearCache()
208+}
209+
210+// AddNestedTool adds a nested tool.
211+func (a *AgenticFetchToolMessageItem) AddNestedTool(tool ToolMessageItem) {
212+ // Mark nested tools as simple (compact) rendering.
213+ if s, ok := tool.(Compactable); ok {
214+ s.SetCompact(true)
215+ }
216+ a.nestedTools = append(a.nestedTools, tool)
217+ a.clearCache()
218+}
219+
220+// AgenticFetchToolRenderContext renders agentic fetch tool messages.
221+type AgenticFetchToolRenderContext struct {
222+ fetch *AgenticFetchToolMessageItem
223+}
224+
225+// agenticFetchParams matches tools.AgenticFetchParams.
226+type agenticFetchParams struct {
227+ URL string `json:"url,omitempty"`
228+ Prompt string `json:"prompt"`
229+}
230+
231+// RenderTool implements the [ToolRenderer] interface.
232+func (r *AgenticFetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
233+ cappedWidth := cappedMessageWidth(width)
234+ if !opts.ToolCall.Finished && !opts.Canceled && len(r.fetch.nestedTools) == 0 {
235+ return pendingTool(sty, "Agentic Fetch", opts.Anim)
236+ }
237+
238+ var params agenticFetchParams
239+ _ = json.Unmarshal([]byte(opts.ToolCall.Input), &params)
240+
241+ prompt := params.Prompt
242+ prompt = strings.ReplaceAll(prompt, "\n", " ")
243+
244+ // Build header with optional URL param.
245+ toolParams := []string{}
246+ if params.URL != "" {
247+ toolParams = append(toolParams, params.URL)
248+ }
249+
250+ header := toolHeader(sty, opts.Status(), "Agentic Fetch", cappedWidth, opts.Compact, toolParams...)
251+ if opts.Compact {
252+ return header
253+ }
254+
255+ // Build the prompt tag.
256+ promptTag := sty.Base.Bold(true).
257+ Padding(0, 1).
258+ MarginLeft(2).
259+ Background(sty.Green).
260+ Foreground(sty.Border).
261+ Render("Prompt")
262+ promptTagWidth := lipgloss.Width(promptTag)
263+
264+ // Calculate remaining width for prompt text.
265+ remainingWidth := cappedWidth - promptTagWidth - 3 // -3 for spacing
266+ if remainingWidth > 120-promptTagWidth-3 {
267+ remainingWidth = 120 - promptTagWidth - 3
268+ }
269+
270+ promptText := sty.Base.Width(remainingWidth).Render(prompt)
271+
272+ header = lipgloss.JoinVertical(
273+ lipgloss.Left,
274+ header,
275+ "",
276+ lipgloss.JoinHorizontal(
277+ lipgloss.Left,
278+ promptTag,
279+ " ",
280+ promptText,
281+ ),
282+ )
283+
284+ // Build tree with nested tool calls.
285+ childTools := tree.Root(header)
286+
287+ for _, nestedTool := range r.fetch.nestedTools {
288+ childView := nestedTool.Render(remainingWidth)
289+ childTools.Child(childView)
290+ }
291+
292+ // Build parts.
293+ var parts []string
294+ parts = append(parts, childTools.Enumerator(roundedEnumerator(2, promptTagWidth-5)).String())
295+
296+ // Show animation if still running.
297+ if opts.Result == nil && !opts.Canceled {
298+ parts = append(parts, "", opts.Anim.Render())
299+ }
300+
301+ result := lipgloss.JoinVertical(lipgloss.Left, parts...)
302+
303+ // Add body content when completed.
304+ if opts.Result != nil && opts.Result.Content != "" {
305+ body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth-toolBodyLeftPaddingTotal, opts.ExpandedContent)
306+ return joinToolParts(result, body)
307+ }
308+
309+ return result
310+}
internal/ui/chat/bash.go+5−5
@@ -69,8 +69,8 @@ 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, opts.Simple, toolParams...)
73− if opts.Simple {
72+ header := toolHeader(sty, opts.Status(), "Bash", cappedWidth, opts.Compact, toolParams...)
73+ if opts.Compact {
7474 return header
7575 }
7676
@@ -91,7 +91,7 @@ func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
9191 }
9292
9393 bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
94− body := sty.Tool.Body.Render(toolOutputPlainContent(sty, output, bodyWidth, opts.Expanded))
94+ body := sty.Tool.Body.Render(toolOutputPlainContent(sty, output, bodyWidth, opts.ExpandedContent))
9595 return joinToolParts(header, body)
9696}
9797
@@ -201,7 +201,7 @@ func (j *JobKillToolRenderContext) RenderTool(sty *styles.Styles, width int, opt
201201// header → nested check → early state → body.
202202func renderJobTool(sty *styles.Styles, opts *ToolRenderOpts, width int, action, shellID, description, content string) string {
203203 header := jobHeader(sty, opts.Status(), action, shellID, description, width)
204− if opts.Simple {
204+ if opts.Compact {
205205 return header
206206 }
207207
@@ -214,7 +214,7 @@ func renderJobTool(sty *styles.Styles, opts *ToolRenderOpts, width int, action,
214214 }
215215
216216 bodyWidth := width - toolBodyLeftPaddingTotal
217− body := sty.Tool.Body.Render(toolOutputPlainContent(sty, content, bodyWidth, opts.Expanded))
217+ body := sty.Tool.Body.Render(toolOutputPlainContent(sty, content, bodyWidth, opts.ExpandedContent))
218218 return joinToolParts(header, body)
219219}
220220
internal/ui/chat/diagnostics.go+3−3
@@ -49,8 +49,8 @@ func (d *DiagnosticsToolRenderContext) RenderTool(sty *styles.Styles, width int,
4949 mainParam = fsext.PrettyPath(params.FilePath)
5050 }
5151
52− header := toolHeader(sty, opts.Status(), "Diagnostics", cappedWidth, opts.Simple, mainParam)
53− if opts.Simple {
52+ header := toolHeader(sty, opts.Status(), "Diagnostics", cappedWidth, opts.Compact, mainParam)
53+ if opts.Compact {
5454 return header
5555 }
5656
@@ -63,6 +63,6 @@ func (d *DiagnosticsToolRenderContext) RenderTool(sty *styles.Styles, width int,
6363 }
6464
6565 bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
66− body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.Expanded))
66+ body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
6767 return joinToolParts(header, body)
6868}
internal/ui/chat/fetch.go+111−3
@@ -52,8 +52,8 @@ func (f *FetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts
5252 toolParams = append(toolParams, "timeout", formatTimeout(params.Timeout))
5353 }
5454
55− header := toolHeader(sty, opts.Status(), "Fetch", cappedWidth, opts.Simple, toolParams...)
56− if opts.Simple {
55+ header := toolHeader(sty, opts.Status(), "Fetch", cappedWidth, opts.Compact, toolParams...)
56+ if opts.Compact {
5757 return header
5858 }
5959
@@ -67,7 +67,7 @@ func (f *FetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts
6767
6868 // Determine file extension for syntax highlighting based on format.
6969 file := getFileExtensionForFormat(params.Format)
70− body := toolOutputCodeContent(sty, file, opts.Result.Content, 0, cappedWidth, opts.Expanded)
70+ body := toolOutputCodeContent(sty, file, opts.Result.Content, 0, cappedWidth, opts.ExpandedContent)
7171 return joinToolParts(header, body)
7272}
7373
@@ -82,3 +82,111 @@ func getFileExtensionForFormat(format string) string {
8282 return "fetch.md"
8383 }
8484}
85+
86+// -----------------------------------------------------------------------------
87+// WebFetch Tool
88+// -----------------------------------------------------------------------------
89+
90+// WebFetchToolMessageItem is a message item that represents a web_fetch tool call.
91+type WebFetchToolMessageItem struct {
92+ *baseToolMessageItem
93+}
94+
95+var _ ToolMessageItem = (*WebFetchToolMessageItem)(nil)
96+
97+// NewWebFetchToolMessageItem creates a new [WebFetchToolMessageItem].
98+func NewWebFetchToolMessageItem(
99+ sty *styles.Styles,
100+ toolCall message.ToolCall,
101+ result *message.ToolResult,
102+ canceled bool,
103+) ToolMessageItem {
104+ return newBaseToolMessageItem(sty, toolCall, result, &WebFetchToolRenderContext{}, canceled)
105+}
106+
107+// WebFetchToolRenderContext renders web_fetch tool messages.
108+type WebFetchToolRenderContext struct{}
109+
110+// RenderTool implements the [ToolRenderer] interface.
111+func (w *WebFetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
112+ cappedWidth := cappedMessageWidth(width)
113+ if !opts.ToolCall.Finished && !opts.Canceled {
114+ return pendingTool(sty, "Fetch", opts.Anim)
115+ }
116+
117+ var params tools.WebFetchParams
118+ if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
119+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
120+ }
121+
122+ toolParams := []string{params.URL}
123+ header := toolHeader(sty, opts.Status(), "Fetch", cappedWidth, opts.Compact, toolParams...)
124+ if opts.Compact {
125+ return header
126+ }
127+
128+ if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
129+ return joinToolParts(header, earlyState)
130+ }
131+
132+ if opts.Result == nil || opts.Result.Content == "" {
133+ return header
134+ }
135+
136+ body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth, opts.ExpandedContent)
137+ return joinToolParts(header, body)
138+}
139+
140+// -----------------------------------------------------------------------------
141+// WebSearch Tool
142+// -----------------------------------------------------------------------------
143+
144+// WebSearchToolMessageItem is a message item that represents a web_search tool call.
145+type WebSearchToolMessageItem struct {
146+ *baseToolMessageItem
147+}
148+
149+var _ ToolMessageItem = (*WebSearchToolMessageItem)(nil)
150+
151+// NewWebSearchToolMessageItem creates a new [WebSearchToolMessageItem].
152+func NewWebSearchToolMessageItem(
153+ sty *styles.Styles,
154+ toolCall message.ToolCall,
155+ result *message.ToolResult,
156+ canceled bool,
157+) ToolMessageItem {
158+ return newBaseToolMessageItem(sty, toolCall, result, &WebSearchToolRenderContext{}, canceled)
159+}
160+
161+// WebSearchToolRenderContext renders web_search tool messages.
162+type WebSearchToolRenderContext struct{}
163+
164+// RenderTool implements the [ToolRenderer] interface.
165+func (w *WebSearchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
166+ cappedWidth := cappedMessageWidth(width)
167+ if !opts.ToolCall.Finished && !opts.Canceled {
168+ return pendingTool(sty, "Search", opts.Anim)
169+ }
170+
171+ var params tools.WebSearchParams
172+ if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
173+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
174+ }
175+
176+ toolParams := []string{params.Query}
177+ header := toolHeader(sty, opts.Status(), "Search", cappedWidth, opts.Compact, toolParams...)
178+ if opts.Compact {
179+ return header
180+ }
181+
182+ if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
183+ return joinToolParts(header, earlyState)
184+ }
185+
186+ if opts.Result == nil || opts.Result.Content == "" {
187+ return header
188+ }
189+
190+ body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth, opts.ExpandedContent)
191+ return joinToolParts(header, body)
192+}
internal/ui/chat/file.go+17−17
@@ -56,8 +56,8 @@ func (v *ViewToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
5656 toolParams = append(toolParams, "offset", fmt.Sprintf("%d", params.Offset))
5757 }
5858
59− header := toolHeader(sty, opts.Status(), "View", cappedWidth, opts.Simple, toolParams...)
60− if opts.Simple {
59+ header := toolHeader(sty, opts.Status(), "View", cappedWidth, opts.Compact, toolParams...)
60+ if opts.Compact {
6161 return header
6262 }
6363
@@ -87,7 +87,7 @@ func (v *ViewToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
8787 }
8888
8989 // Render code content with syntax highlighting.
90− body := toolOutputCodeContent(sty, params.FilePath, content, params.Offset, cappedWidth, opts.Expanded)
90+ body := toolOutputCodeContent(sty, params.FilePath, content, params.Offset, cappedWidth, opts.ExpandedContent)
9191 return joinToolParts(header, body)
9292}
9393
@@ -128,8 +128,8 @@ func (w *WriteToolRenderContext) RenderTool(sty *styles.Styles, width int, opts
128128 }
129129
130130 file := fsext.PrettyPath(params.FilePath)
131− header := toolHeader(sty, opts.Status(), "Write", cappedWidth, opts.Simple, file)
132− if opts.Simple {
131+ header := toolHeader(sty, opts.Status(), "Write", cappedWidth, opts.Compact, file)
132+ if opts.Compact {
133133 return header
134134 }
135135
@@ -142,7 +142,7 @@ func (w *WriteToolRenderContext) RenderTool(sty *styles.Styles, width int, opts
142142 }
143143
144144 // Render code content with syntax highlighting.
145− body := toolOutputCodeContent(sty, params.FilePath, params.Content, 0, cappedWidth, opts.Expanded)
145+ body := toolOutputCodeContent(sty, params.FilePath, params.Content, 0, cappedWidth, opts.ExpandedContent)
146146 return joinToolParts(header, body)
147147}
148148
@@ -183,8 +183,8 @@ func (e *EditToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
183183 }
184184
185185 file := fsext.PrettyPath(params.FilePath)
186− header := toolHeader(sty, opts.Status(), "Edit", width, opts.Simple, file)
187− if opts.Simple {
186+ header := toolHeader(sty, opts.Status(), "Edit", width, opts.Compact, file)
187+ if opts.Compact {
188188 return header
189189 }
190190
@@ -200,12 +200,12 @@ func (e *EditToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
200200 var meta tools.EditResponseMetadata
201201 if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err != nil {
202202 bodyWidth := width - toolBodyLeftPaddingTotal
203− body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.Expanded))
203+ body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
204204 return joinToolParts(header, body)
205205 }
206206
207207 // Render diff.
208− body := toolOutputDiffContent(sty, file, meta.OldContent, meta.NewContent, width, opts.Expanded)
208+ body := toolOutputDiffContent(sty, file, meta.OldContent, meta.NewContent, width, opts.ExpandedContent)
209209 return joinToolParts(header, body)
210210}
211211
@@ -251,8 +251,8 @@ func (m *MultiEditToolRenderContext) RenderTool(sty *styles.Styles, width int, o
251251 toolParams = append(toolParams, "edits", fmt.Sprintf("%d", len(params.Edits)))
252252 }
253253
254− header := toolHeader(sty, opts.Status(), "Multi-Edit", width, opts.Simple, toolParams...)
255− if opts.Simple {
254+ header := toolHeader(sty, opts.Status(), "Multi-Edit", width, opts.Compact, toolParams...)
255+ if opts.Compact {
256256 return header
257257 }
258258
@@ -268,12 +268,12 @@ func (m *MultiEditToolRenderContext) RenderTool(sty *styles.Styles, width int, o
268268 var meta tools.MultiEditResponseMetadata
269269 if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err != nil {
270270 bodyWidth := width - toolBodyLeftPaddingTotal
271− body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.Expanded))
271+ body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
272272 return joinToolParts(header, body)
273273 }
274274
275275 // Render diff with optional failed edits note.
276− body := toolOutputMultiEditDiffContent(sty, file, meta, len(params.Edits), width, opts.Expanded)
276+ body := toolOutputMultiEditDiffContent(sty, file, meta, len(params.Edits), width, opts.ExpandedContent)
277277 return joinToolParts(header, body)
278278}
279279
@@ -321,8 +321,8 @@ func (d *DownloadToolRenderContext) RenderTool(sty *styles.Styles, width int, op
321321 toolParams = append(toolParams, "timeout", formatTimeout(params.Timeout))
322322 }
323323
324− header := toolHeader(sty, opts.Status(), "Download", cappedWidth, opts.Simple, toolParams...)
325− if opts.Simple {
324+ header := toolHeader(sty, opts.Status(), "Download", cappedWidth, opts.Compact, toolParams...)
325+ if opts.Compact {
326326 return header
327327 }
328328
@@ -335,6 +335,6 @@ func (d *DownloadToolRenderContext) RenderTool(sty *styles.Styles, width int, op
335335 }
336336
337337 bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
338− body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.Expanded))
338+ body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
339339 return joinToolParts(header, body)
340340}
internal/ui/chat/messages.go+1
@@ -171,6 +171,7 @@ func ExtractMessageItems(sty *styles.Styles, msg *message.Message, toolResults m
171171 }
172172 items = append(items, NewToolMessageItem(
173173 sty,
174+ msg.ID,
174175 tc,
175176 result,
176177 msg.FinishReason() == message.FinishReasonCanceled,
internal/ui/chat/search.go+12−12
@@ -50,8 +50,8 @@ func (g *GlobToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
5050 toolParams = append(toolParams, "path", params.Path)
5151 }
5252
53− header := toolHeader(sty, opts.Status(), "Glob", cappedWidth, opts.Simple, toolParams...)
54− if opts.Simple {
53+ header := toolHeader(sty, opts.Status(), "Glob", cappedWidth, opts.Compact, toolParams...)
54+ if opts.Compact {
5555 return header
5656 }
5757
@@ -64,7 +64,7 @@ func (g *GlobToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
6464 }
6565
6666 bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
67− body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.Expanded))
67+ body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
6868 return joinToolParts(header, body)
6969}
7070
@@ -115,8 +115,8 @@ func (g *GrepToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
115115 toolParams = append(toolParams, "literal", "true")
116116 }
117117
118− header := toolHeader(sty, opts.Status(), "Grep", cappedWidth, opts.Simple, toolParams...)
119− if opts.Simple {
118+ header := toolHeader(sty, opts.Status(), "Grep", cappedWidth, opts.Compact, toolParams...)
119+ if opts.Compact {
120120 return header
121121 }
122122
@@ -129,7 +129,7 @@ func (g *GrepToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
129129 }
130130
131131 bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
132− body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.Expanded))
132+ body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
133133 return joinToolParts(header, body)
134134}
135135
@@ -175,8 +175,8 @@ func (l *LSToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *To
175175 }
176176 path = fsext.PrettyPath(path)
177177
178− header := toolHeader(sty, opts.Status(), "List", cappedWidth, opts.Simple, path)
179− if opts.Simple {
178+ header := toolHeader(sty, opts.Status(), "List", cappedWidth, opts.Compact, path)
179+ if opts.Compact {
180180 return header
181181 }
182182
@@ -189,7 +189,7 @@ func (l *LSToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *To
189189 }
190190
191191 bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
192− body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.Expanded))
192+ body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
193193 return joinToolParts(header, body)
194194}
195195
@@ -237,8 +237,8 @@ func (s *SourcegraphToolRenderContext) RenderTool(sty *styles.Styles, width int,
237237 toolParams = append(toolParams, "context", formatNonZero(params.ContextWindow))
238238 }
239239
240− header := toolHeader(sty, opts.Status(), "Sourcegraph", cappedWidth, opts.Simple, toolParams...)
241− if opts.Simple {
240+ header := toolHeader(sty, opts.Status(), "Sourcegraph", cappedWidth, opts.Compact, toolParams...)
241+ if opts.Compact {
242242 return header
243243 }
244244
@@ -251,6 +251,6 @@ func (s *SourcegraphToolRenderContext) RenderTool(sty *styles.Styles, width int,
251251 }
252252
253253 bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
254− body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.Expanded))
254+ body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
255255 return joinToolParts(header, body)
256256}
internal/ui/chat/todos.gonew file+192
@@ -0,0 +1,192 @@
1+package chat
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "slices"
7+ "strings"
8+
9+ "github.com/charmbracelet/crush/internal/agent/tools"
10+ "github.com/charmbracelet/crush/internal/message"
11+ "github.com/charmbracelet/crush/internal/session"
12+ "github.com/charmbracelet/crush/internal/ui/styles"
13+ "github.com/charmbracelet/x/ansi"
14+)
15+
16+// -----------------------------------------------------------------------------
17+// Todos Tool
18+// -----------------------------------------------------------------------------
19+
20+// TodosToolMessageItem is a message item that represents a todos tool call.
21+type TodosToolMessageItem struct {
22+ *baseToolMessageItem
23+}
24+
25+var _ ToolMessageItem = (*TodosToolMessageItem)(nil)
26+
27+// NewTodosToolMessageItem creates a new [TodosToolMessageItem].
28+func NewTodosToolMessageItem(
29+ sty *styles.Styles,
30+ toolCall message.ToolCall,
31+ result *message.ToolResult,
32+ canceled bool,
33+) ToolMessageItem {
34+ return newBaseToolMessageItem(sty, toolCall, result, &TodosToolRenderContext{}, canceled)
35+}
36+
37+// TodosToolRenderContext renders todos tool messages.
38+type TodosToolRenderContext struct{}
39+
40+// RenderTool implements the [ToolRenderer] interface.
41+func (t *TodosToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
42+ cappedWidth := cappedMessageWidth(width)
43+ if !opts.ToolCall.Finished && !opts.Canceled {
44+ return pendingTool(sty, "To-Do", opts.Anim)
45+ }
46+
47+ var params tools.TodosParams
48+ var meta tools.TodosResponseMetadata
49+ var headerText string
50+ var body string
51+
52+ // Parse params for pending state (before result is available).
53+ if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err == nil {
54+ completedCount := 0
55+ inProgressTask := ""
56+ for _, todo := range params.Todos {
57+ if todo.Status == "completed" {
58+ completedCount++
59+ }
60+ if todo.Status == "in_progress" {
61+ if todo.ActiveForm != "" {
62+ inProgressTask = todo.ActiveForm
63+ } else {
64+ inProgressTask = todo.Content
65+ }
66+ }
67+ }
68+
69+ // Default display from params (used when pending or no metadata).
70+ ratio := sty.Tool.TodoRatio.Render(fmt.Sprintf("%d/%d", completedCount, len(params.Todos)))
71+ headerText = ratio
72+ if inProgressTask != "" {
73+ headerText = fmt.Sprintf("%s · %s", ratio, inProgressTask)
74+ }
75+
76+ // If we have metadata, use it for richer display.
77+ if opts.Result != nil && opts.Result.Metadata != "" {
78+ if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil {
79+ if meta.IsNew {
80+ if meta.JustStarted != "" {
81+ headerText = fmt.Sprintf("created %d todos, starting first", meta.Total)
82+ } else {
83+ headerText = fmt.Sprintf("created %d todos", meta.Total)
84+ }
85+ body = formatTodosList(sty, meta.Todos, styles.ArrowRightIcon, cappedWidth)
86+ } else {
87+ // Build header based on what changed.
88+ hasCompleted := len(meta.JustCompleted) > 0
89+ hasStarted := meta.JustStarted != ""
90+ allCompleted := meta.Completed == meta.Total
91+
92+ ratio := sty.Tool.TodoRatio.Render(fmt.Sprintf("%d/%d", meta.Completed, meta.Total))
93+ if hasCompleted && hasStarted {
94+ text := sty.Subtle.Render(fmt.Sprintf(" · completed %d, starting next", len(meta.JustCompleted)))
95+ headerText = fmt.Sprintf("%s%s", ratio, text)
96+ } else if hasCompleted {
97+ text := sty.Subtle.Render(fmt.Sprintf(" · completed %d", len(meta.JustCompleted)))
98+ if allCompleted {
99+ text = sty.Subtle.Render(" · completed all")
100+ }
101+ headerText = fmt.Sprintf("%s%s", ratio, text)
102+ } else if hasStarted {
103+ headerText = fmt.Sprintf("%s%s", ratio, sty.Subtle.Render(" · starting task"))
104+ } else {
105+ headerText = ratio
106+ }
107+
108+ // Build body with details.
109+ if allCompleted {
110+ // Show all todos when all are completed, like when created.
111+ body = formatTodosList(sty, meta.Todos, styles.ArrowRightIcon, cappedWidth)
112+ } else if meta.JustStarted != "" {
113+ body = sty.Tool.TodoInProgressIcon.Render(styles.ArrowRightIcon+" ") +
114+ sty.Base.Render(meta.JustStarted)
115+ }
116+ }
117+ }
118+ }
119+ }
120+
121+ toolParams := []string{headerText}
122+ header := toolHeader(sty, opts.Status(), "To-Do", cappedWidth, opts.Compact, toolParams...)
123+ if opts.Compact {
124+ return header
125+ }
126+
127+ if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
128+ return joinToolParts(header, earlyState)
129+ }
130+
131+ if body == "" {
132+ return header
133+ }
134+
135+ return joinToolParts(header, sty.Tool.Body.Render(body))
136+}
137+
138+// formatTodosList formats a list of todos for display.
139+func formatTodosList(sty *styles.Styles, todos []session.Todo, inProgressIcon string, width int) string {
140+ if len(todos) == 0 {
141+ return ""
142+ }
143+
144+ sorted := make([]session.Todo, len(todos))
145+ copy(sorted, todos)
146+ sortTodos(sorted)
147+
148+ var lines []string
149+ for _, todo := range sorted {
150+ var prefix string
151+ textStyle := sty.Base
152+
153+ switch todo.Status {
154+ case session.TodoStatusCompleted:
155+ prefix = sty.Tool.TodoCompletedIcon.Render(styles.TodoCompletedIcon) + " "
156+ case session.TodoStatusInProgress:
157+ prefix = sty.Tool.TodoInProgressIcon.Render(inProgressIcon + " ")
158+ default:
159+ prefix = sty.Tool.TodoPendingIcon.Render(styles.TodoPendingIcon) + " "
160+ }
161+
162+ text := todo.Content
163+ if todo.Status == session.TodoStatusInProgress && todo.ActiveForm != "" {
164+ text = todo.ActiveForm
165+ }
166+ line := prefix + textStyle.Render(text)
167+ line = ansi.Truncate(line, width, "…")
168+
169+ lines = append(lines, line)
170+ }
171+
172+ return strings.Join(lines, "\n")
173+}
174+
175+// sortTodos sorts todos by status: completed, in_progress, pending.
176+func sortTodos(todos []session.Todo) {
177+ slices.SortStableFunc(todos, func(a, b session.Todo) int {
178+ return statusOrder(a.Status) - statusOrder(b.Status)
179+ })
180+}
181+
182+// statusOrder returns the sort order for a todo status.
183+func statusOrder(s session.TodoStatus) int {
184+ switch s {
185+ case session.TodoStatusCompleted:
186+ return 0
187+ case session.TodoStatusInProgress:
188+ return 1
189+ default:
190+ return 2
191+ }
192+}
internal/ui/chat/tools.go+152−33
@@ -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+}
internal/ui/model/chat.go+36
@@ -77,6 +77,12 @@ func (m *Chat) SetMessages(msgs ...chat.MessageItem) {
7777 items := make([]list.Item, len(msgs))
7878 for i, msg := range msgs {
7979 m.idInxMap[msg.ID()] = i
80+ // Register nested tool IDs for tools that contain nested tools.
81+ if container, ok := msg.(chat.NestedToolContainer); ok {
82+ for _, nested := range container.NestedTools() {
83+ m.idInxMap[nested.ID()] = i
84+ }
85+ }
8086 items[i] = msg
8187 }
8288 m.list.SetItems(items...)
@@ -89,11 +95,41 @@ func (m *Chat) AppendMessages(msgs ...chat.MessageItem) {
8995 indexOffset := m.list.Len()
9096 for i, msg := range msgs {
9197 m.idInxMap[msg.ID()] = indexOffset + i
98+ // Register nested tool IDs for tools that contain nested tools.
99+ if container, ok := msg.(chat.NestedToolContainer); ok {
100+ for _, nested := range container.NestedTools() {
101+ m.idInxMap[nested.ID()] = indexOffset + i
102+ }
103+ }
92104 items[i] = msg
93105 }
94106 m.list.AppendItems(items...)
95107}
96108
109+// UpdateNestedToolIDs updates the ID map for nested tools within a container.
110+// Call this after modifying nested tools to ensure animations work correctly.
111+func (m *Chat) UpdateNestedToolIDs(containerID string) {
112+ idx, ok := m.idInxMap[containerID]
113+ if !ok {
114+ return
115+ }
116+
117+ item, ok := m.list.ItemAt(idx).(chat.MessageItem)
118+ if !ok {
119+ return
120+ }
121+
122+ container, ok := item.(chat.NestedToolContainer)
123+ if !ok {
124+ return
125+ }
126+
127+ // Register all nested tool IDs to point to the container's index.
128+ for _, nested := range container.NestedTools() {
129+ m.idInxMap[nested.ID()] = idx
130+ }
131+}
132+
97133// Animate animates items in the chat list. Only propagates animation messages
98134// to visible items to save CPU. When items are not visible, their animation ID
99135// is tracked so it can be restarted when they become visible again.
internal/ui/model/ui.go+157−3
@@ -199,8 +199,15 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
199199 }
200200
201201 case pubsub.Event[message.Message]:
202− // TODO: handle nested messages for agentic tools
203− if m.session == nil || msg.Payload.SessionID != m.session.ID {
202+ // Check if this is a child session message for an agent tool.
203+ if m.session == nil {
204+ break
205+ }
206+ if msg.Payload.SessionID != m.session.ID {
207+ // This might be a child session message from an agent tool.
208+ if cmd := m.handleChildSessionMessage(msg); cmd != nil {
209+ cmds = append(cmds, cmd)
210+ }
204211 break
205212 }
206213 switch msg.Type {
@@ -374,6 +381,9 @@ func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
374381 items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
375382 }
376383
384+ // Load nested tool calls for agent/agentic_fetch tools.
385+ m.loadNestedToolCalls(items)
386+
377387 // If the user switches between sessions while the agent is working we want
378388 // to make sure the animations are shown.
379389 for _, item := range items {
@@ -392,6 +402,64 @@ func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
392402 return tea.Batch(cmds...)
393403}
394404
405+// loadNestedToolCalls recursively loads nested tool calls for agent/agentic_fetch tools.
406+func (m *UI) loadNestedToolCalls(items []chat.MessageItem) {
407+ for _, item := range items {
408+ nestedContainer, ok := item.(chat.NestedToolContainer)
409+ if !ok {
410+ continue
411+ }
412+ toolItem, ok := item.(chat.ToolMessageItem)
413+ if !ok {
414+ continue
415+ }
416+
417+ tc := toolItem.ToolCall()
418+ messageID := toolItem.MessageID()
419+
420+ // Get the agent tool session ID.
421+ agentSessionID := m.com.App.Sessions.CreateAgentToolSessionID(messageID, tc.ID)
422+
423+ // Fetch nested messages.
424+ nestedMsgs, err := m.com.App.Messages.List(context.Background(), agentSessionID)
425+ if err != nil || len(nestedMsgs) == 0 {
426+ continue
427+ }
428+
429+ // Build tool result map for nested messages.
430+ nestedMsgPtrs := make([]*message.Message, len(nestedMsgs))
431+ for i := range nestedMsgs {
432+ nestedMsgPtrs[i] = &nestedMsgs[i]
433+ }
434+ nestedToolResultMap := chat.BuildToolResultMap(nestedMsgPtrs)
435+
436+ // Extract nested tool items.
437+ var nestedTools []chat.ToolMessageItem
438+ for _, nestedMsg := range nestedMsgPtrs {
439+ nestedItems := chat.ExtractMessageItems(m.com.Styles, nestedMsg, nestedToolResultMap)
440+ for _, nestedItem := range nestedItems {
441+ if nestedToolItem, ok := nestedItem.(chat.ToolMessageItem); ok {
442+ // Mark nested tools as simple (compact) rendering.
443+ if simplifiable, ok := nestedToolItem.(chat.Compactable); ok {
444+ simplifiable.SetCompact(true)
445+ }
446+ nestedTools = append(nestedTools, nestedToolItem)
447+ }
448+ }
449+ }
450+
451+ // Recursively load nested tool calls for any agent tools within.
452+ nestedMessageItems := make([]chat.MessageItem, len(nestedTools))
453+ for i, nt := range nestedTools {
454+ nestedMessageItems[i] = nt
455+ }
456+ m.loadNestedToolCalls(nestedMessageItems)
457+
458+ // Set nested tools on the parent.
459+ nestedContainer.SetNestedTools(nestedTools)
460+ }
461+}
462+
395463// appendSessionMessage appends a new message to the current session in the chat
396464// if the message is a tool result it will update the corresponding tool call message
397465func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
@@ -455,7 +523,7 @@ func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
455523 }
456524 }
457525 if existingToolItem == nil {
458− items = append(items, chat.NewToolMessageItem(m.com.Styles, tc, nil, false))
526+ items = append(items, chat.NewToolMessageItem(m.com.Styles, msg.ID, tc, nil, false))
459527 }
460528 }
461529
@@ -474,6 +542,92 @@ func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
474542 return tea.Batch(cmds...)
475543}
476544
545+// handleChildSessionMessage handles messages from child sessions (agent tools).
546+func (m *UI) handleChildSessionMessage(event pubsub.Event[message.Message]) tea.Cmd {
547+ var cmds []tea.Cmd
548+
549+ // Only process messages with tool calls or results.
550+ if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 {
551+ return nil
552+ }
553+
554+ // Check if this is an agent tool session and parse it.
555+ childSessionID := event.Payload.SessionID
556+ _, toolCallID, ok := m.com.App.Sessions.ParseAgentToolSessionID(childSessionID)
557+ if !ok {
558+ return nil
559+ }
560+
561+ // Find the parent agent tool item.
562+ var agentItem chat.NestedToolContainer
563+ for i := 0; i < m.chat.Len(); i++ {
564+ item := m.chat.MessageItem(toolCallID)
565+ if item == nil {
566+ continue
567+ }
568+ if agent, ok := item.(chat.NestedToolContainer); ok {
569+ if toolMessageItem, ok := item.(chat.ToolMessageItem); ok {
570+ if toolMessageItem.ToolCall().ID == toolCallID {
571+ // Verify this agent belongs to the correct parent message.
572+ // We can't directly check parentMessageID on the item, so we trust the session parsing.
573+ agentItem = agent
574+ break
575+ }
576+ }
577+ }
578+ }
579+
580+ if agentItem == nil {
581+ return nil
582+ }
583+
584+ // Get existing nested tools.
585+ nestedTools := agentItem.NestedTools()
586+
587+ // Update or create nested tool calls.
588+ for _, tc := range event.Payload.ToolCalls() {
589+ found := false
590+ for _, existingTool := range nestedTools {
591+ if existingTool.ToolCall().ID == tc.ID {
592+ existingTool.SetToolCall(tc)
593+ found = true
594+ break
595+ }
596+ }
597+ if !found {
598+ // Create a new nested tool item.
599+ nestedItem := chat.NewToolMessageItem(m.com.Styles, event.Payload.ID, tc, nil, false)
600+ if simplifiable, ok := nestedItem.(chat.Compactable); ok {
601+ simplifiable.SetCompact(true)
602+ }
603+ if animatable, ok := nestedItem.(chat.Animatable); ok {
604+ if cmd := animatable.StartAnimation(); cmd != nil {
605+ cmds = append(cmds, cmd)
606+ }
607+ }
608+ nestedTools = append(nestedTools, nestedItem)
609+ }
610+ }
611+
612+ // Update nested tool results.
613+ for _, tr := range event.Payload.ToolResults() {
614+ for _, nestedTool := range nestedTools {
615+ if nestedTool.ToolCall().ID == tr.ToolCallID {
616+ nestedTool.SetResult(&tr)
617+ break
618+ }
619+ }
620+ }
621+
622+ // Update the agent item with the new nested tools.
623+ agentItem.SetNestedTools(nestedTools)
624+
625+ // Update the chat so it updates the index map for animations to work as expected
626+ m.chat.UpdateNestedToolIDs(toolCallID)
627+
628+ return tea.Batch(cmds...)
629+}
630+
477631func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
478632 var cmds []tea.Cmd
479633
internal/ui/styles/styles.go+15
@@ -210,6 +210,7 @@ type Styles struct {
210210 ErrorDetails lipgloss.Style
211211 Attachment lipgloss.Style
212212 ToolCallFocused lipgloss.Style
213+ ToolCallCompact lipgloss.Style
213214 ToolCallBlurred lipgloss.Style
214215 SectionHeader lipgloss.Style
215216
@@ -277,6 +278,12 @@ type Styles struct {
277278 // Agent task styles
278279 AgentTaskTag lipgloss.Style // Agent task tag (blue background, bold)
279280 AgentPrompt lipgloss.Style // Agent prompt text
281+
282+ // Todo styles
283+ TodoRatio lipgloss.Style // Todo ratio (e.g., "2/5")
284+ TodoCompletedIcon lipgloss.Style // Completed todo icon
285+ TodoInProgressIcon lipgloss.Style // In-progress todo icon
286+ TodoPendingIcon lipgloss.Style // Pending todo icon
280287 }
281288
282289 // Dialog styles
@@ -1005,6 +1012,12 @@ func DefaultStyles() Styles {
10051012 s.Tool.AgentTaskTag = base.Bold(true).Padding(0, 1).MarginLeft(2).Background(blueLight).Foreground(white)
10061013 s.Tool.AgentPrompt = s.Muted
10071014
1015+ // Todo styles
1016+ s.Tool.TodoRatio = base.Foreground(blueDark)
1017+ s.Tool.TodoCompletedIcon = base.Foreground(green)
1018+ s.Tool.TodoInProgressIcon = base.Foreground(greenDark)
1019+ s.Tool.TodoPendingIcon = base.Foreground(fgMuted)
1020+
10081021 // Buttons
10091022 s.ButtonFocus = lipgloss.NewStyle().Foreground(white).Background(secondary)
10101023 s.ButtonBlur = s.Base.Background(bgSubtle)
@@ -1082,6 +1095,8 @@ func DefaultStyles() Styles {
10821095 BorderLeft(true).
10831096 BorderForeground(greenDark)
10841097 s.Chat.Message.ToolCallBlurred = s.Muted.PaddingLeft(2)
1098+ // No padding or border for compact tool calls within messages
1099+ s.Chat.Message.ToolCallCompact = s.Muted
10851100 s.Chat.Message.SectionHeader = s.Base.PaddingLeft(2)
10861101
10871102 // Thinking section styles