on main help sign in
Merge pull request #1778 from charmbracelet/add-more-tools

Add more tools

commitd56acc8470f49d728693aa11ad67c326e9c6ce94 authorAyman Bagabas <ayman.bagabas@gmail.com> dateTue 6 Jan 2026, 15:11 (9 months ago) parent17e6616bbce33f
13 files changed+1313−64
internal/ui/chat/agent.go+302 internal/ui/chat/bash.go+5−5 internal/ui/chat/diagnostics.go+68 internal/ui/chat/fetch.go+192 internal/ui/chat/file.go+76−14 internal/ui/chat/messages.go+1 internal/ui/chat/search.go+71−9 internal/ui/chat/todos.go+192 internal/ui/chat/tools.go+183−24 internal/ui/dialog/models_list.go+9−9 internal/ui/model/chat.go+36 internal/ui/model/ui.go+157−3 internal/ui/styles/styles.go+21
internal/ui/chat/agent.gonew file+302
@@ -0,0 +1,302 @@
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.spinningFunc = func(state SpinningState) 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, maxTextWidth-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.spinningFunc = func(state SpinningState) 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.Tool.AgenticFetchPromptTag.Render("Prompt")
257+ promptTagWidth := lipgloss.Width(promptTag)
258+
259+ // Calculate remaining width for prompt text.
260+ remainingWidth := min(cappedWidth-promptTagWidth-3, maxTextWidth-promptTagWidth-3) // -3 for spacing
261+
262+ promptText := sty.Tool.AgentPrompt.Width(remainingWidth).Render(prompt)
263+
264+ header = lipgloss.JoinVertical(
265+ lipgloss.Left,
266+ header,
267+ "",
268+ lipgloss.JoinHorizontal(
269+ lipgloss.Left,
270+ promptTag,
271+ " ",
272+ promptText,
273+ ),
274+ )
275+
276+ // Build tree with nested tool calls.
277+ childTools := tree.Root(header)
278+
279+ for _, nestedTool := range r.fetch.nestedTools {
280+ childView := nestedTool.Render(remainingWidth)
281+ childTools.Child(childView)
282+ }
283+
284+ // Build parts.
285+ var parts []string
286+ parts = append(parts, childTools.Enumerator(roundedEnumerator(2, promptTagWidth-5)).String())
287+
288+ // Show animation if still running.
289+ if opts.Result == nil && !opts.Canceled {
290+ parts = append(parts, "", opts.Anim.Render())
291+ }
292+
293+ result := lipgloss.JoinVertical(lipgloss.Left, parts...)
294+
295+ // Add body content when completed.
296+ if opts.Result != nil && opts.Result.Content != "" {
297+ body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth-toolBodyLeftPaddingTotal, opts.ExpandedContent)
298+ return joinToolParts(result, body)
299+ }
300+
301+ return result
302+}
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.Nested, toolParams...)
73− if opts.Nested {
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.Nested {
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.gonew file+68
@@ -0,0 +1,68 @@
1+package chat
2+
3+import (
4+ "encoding/json"
5+
6+ "github.com/charmbracelet/crush/internal/agent/tools"
7+ "github.com/charmbracelet/crush/internal/fsext"
8+ "github.com/charmbracelet/crush/internal/message"
9+ "github.com/charmbracelet/crush/internal/ui/styles"
10+)
11+
12+// -----------------------------------------------------------------------------
13+// Diagnostics Tool
14+// -----------------------------------------------------------------------------
15+
16+// DiagnosticsToolMessageItem is a message item that represents a diagnostics tool call.
17+type DiagnosticsToolMessageItem struct {
18+ *baseToolMessageItem
19+}
20+
21+var _ ToolMessageItem = (*DiagnosticsToolMessageItem)(nil)
22+
23+// NewDiagnosticsToolMessageItem creates a new [DiagnosticsToolMessageItem].
24+func NewDiagnosticsToolMessageItem(
25+ sty *styles.Styles,
26+ toolCall message.ToolCall,
27+ result *message.ToolResult,
28+ canceled bool,
29+) ToolMessageItem {
30+ return newBaseToolMessageItem(sty, toolCall, result, &DiagnosticsToolRenderContext{}, canceled)
31+}
32+
33+// DiagnosticsToolRenderContext renders diagnostics tool messages.
34+type DiagnosticsToolRenderContext struct{}
35+
36+// RenderTool implements the [ToolRenderer] interface.
37+func (d *DiagnosticsToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
38+ cappedWidth := cappedMessageWidth(width)
39+ if !opts.ToolCall.Finished && !opts.Canceled {
40+ return pendingTool(sty, "Diagnostics", opts.Anim)
41+ }
42+
43+ var params tools.DiagnosticsParams
44+ _ = json.Unmarshal([]byte(opts.ToolCall.Input), &params)
45+
46+ // Show "project" if no file path, otherwise show the file path.
47+ mainParam := "project"
48+ if params.FilePath != "" {
49+ mainParam = fsext.PrettyPath(params.FilePath)
50+ }
51+
52+ header := toolHeader(sty, opts.Status(), "Diagnostics", cappedWidth, opts.Compact, mainParam)
53+ if opts.Compact {
54+ return header
55+ }
56+
57+ if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
58+ return joinToolParts(header, earlyState)
59+ }
60+
61+ if opts.Result == nil || opts.Result.Content == "" {
62+ return header
63+ }
64+
65+ bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
66+ body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
67+ return joinToolParts(header, body)
68+}
internal/ui/chat/fetch.gonew file+192
@@ -0,0 +1,192 @@
1+package chat
2+
3+import (
4+ "encoding/json"
5+
6+ "github.com/charmbracelet/crush/internal/agent/tools"
7+ "github.com/charmbracelet/crush/internal/message"
8+ "github.com/charmbracelet/crush/internal/ui/styles"
9+)
10+
11+// -----------------------------------------------------------------------------
12+// Fetch Tool
13+// -----------------------------------------------------------------------------
14+
15+// FetchToolMessageItem is a message item that represents a fetch tool call.
16+type FetchToolMessageItem struct {
17+ *baseToolMessageItem
18+}
19+
20+var _ ToolMessageItem = (*FetchToolMessageItem)(nil)
21+
22+// NewFetchToolMessageItem creates a new [FetchToolMessageItem].
23+func NewFetchToolMessageItem(
24+ sty *styles.Styles,
25+ toolCall message.ToolCall,
26+ result *message.ToolResult,
27+ canceled bool,
28+) ToolMessageItem {
29+ return newBaseToolMessageItem(sty, toolCall, result, &FetchToolRenderContext{}, canceled)
30+}
31+
32+// FetchToolRenderContext renders fetch tool messages.
33+type FetchToolRenderContext struct{}
34+
35+// RenderTool implements the [ToolRenderer] interface.
36+func (f *FetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
37+ cappedWidth := cappedMessageWidth(width)
38+ if !opts.ToolCall.Finished && !opts.Canceled {
39+ return pendingTool(sty, "Fetch", opts.Anim)
40+ }
41+
42+ var params tools.FetchParams
43+ if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
44+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
45+ }
46+
47+ toolParams := []string{params.URL}
48+ if params.Format != "" {
49+ toolParams = append(toolParams, "format", params.Format)
50+ }
51+ if params.Timeout != 0 {
52+ toolParams = append(toolParams, "timeout", formatTimeout(params.Timeout))
53+ }
54+
55+ header := toolHeader(sty, opts.Status(), "Fetch", cappedWidth, opts.Compact, toolParams...)
56+ if opts.Compact {
57+ return header
58+ }
59+
60+ if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
61+ return joinToolParts(header, earlyState)
62+ }
63+
64+ if opts.Result == nil || opts.Result.Content == "" {
65+ return header
66+ }
67+
68+ // Determine file extension for syntax highlighting based on format.
69+ file := getFileExtensionForFormat(params.Format)
70+ body := toolOutputCodeContent(sty, file, opts.Result.Content, 0, cappedWidth, opts.ExpandedContent)
71+ return joinToolParts(header, body)
72+}
73+
74+// getFileExtensionForFormat returns a filename with appropriate extension for syntax highlighting.
75+func getFileExtensionForFormat(format string) string {
76+ switch format {
77+ case "text":
78+ return "fetch.txt"
79+ case "html":
80+ return "fetch.html"
81+ default:
82+ return "fetch.md"
83+ }
84+}
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+76−14
@@ -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.Nested, toolParams...)
60− if opts.Nested {
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.Nested, file)
132− if opts.Nested {
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.Nested, file)
187− if opts.Nested {
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.Nested, toolParams...)
255− if opts.Nested {
254+ header := toolHeader(sty, opts.Status(), "Multi-Edit", width, opts.Compact, toolParams...)
255+ if opts.Compact {
256256 return header
257257 }
258258
@@ -268,11 +268,73 @@ 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)
277+ return joinToolParts(header, body)
278+}
279+
280+// -----------------------------------------------------------------------------
281+// Download Tool
282+// -----------------------------------------------------------------------------
283+
284+// DownloadToolMessageItem is a message item that represents a download tool call.
285+type DownloadToolMessageItem struct {
286+ *baseToolMessageItem
287+}
288+
289+var _ ToolMessageItem = (*DownloadToolMessageItem)(nil)
290+
291+// NewDownloadToolMessageItem creates a new [DownloadToolMessageItem].
292+func NewDownloadToolMessageItem(
293+ sty *styles.Styles,
294+ toolCall message.ToolCall,
295+ result *message.ToolResult,
296+ canceled bool,
297+) ToolMessageItem {
298+ return newBaseToolMessageItem(sty, toolCall, result, &DownloadToolRenderContext{}, canceled)
299+}
300+
301+// DownloadToolRenderContext renders download tool messages.
302+type DownloadToolRenderContext struct{}
303+
304+// RenderTool implements the [ToolRenderer] interface.
305+func (d *DownloadToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
306+ cappedWidth := cappedMessageWidth(width)
307+ if !opts.ToolCall.Finished && !opts.Canceled {
308+ return pendingTool(sty, "Download", opts.Anim)
309+ }
310+
311+ var params tools.DownloadParams
312+ if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
313+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
314+ }
315+
316+ toolParams := []string{params.URL}
317+ if params.FilePath != "" {
318+ toolParams = append(toolParams, "file_path", fsext.PrettyPath(params.FilePath))
319+ }
320+ if params.Timeout != 0 {
321+ toolParams = append(toolParams, "timeout", formatTimeout(params.Timeout))
322+ }
323+
324+ header := toolHeader(sty, opts.Status(), "Download", cappedWidth, opts.Compact, toolParams...)
325+ if opts.Compact {
326+ return header
327+ }
328+
329+ if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
330+ return joinToolParts(header, earlyState)
331+ }
332+
333+ if opts.Result == nil || opts.Result.Content == "" {
334+ return header
335+ }
336+
337+ bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
338+ body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
277339 return joinToolParts(header, body)
278340}
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+71−9
@@ -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.Nested, toolParams...)
54− if opts.Nested {
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.Nested, toolParams...)
119− if opts.Nested {
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.Nested, path)
179− if opts.Nested {
178+ header := toolHeader(sty, opts.Status(), "List", cappedWidth, opts.Compact, path)
179+ if opts.Compact {
180180 return header
181181 }
182182
@@ -189,6 +189,68 @@ 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))
193+ return joinToolParts(header, body)
194+}
195+
196+// -----------------------------------------------------------------------------
197+// Sourcegraph Tool
198+// -----------------------------------------------------------------------------
199+
200+// SourcegraphToolMessageItem is a message item that represents a sourcegraph tool call.
201+type SourcegraphToolMessageItem struct {
202+ *baseToolMessageItem
203+}
204+
205+var _ ToolMessageItem = (*SourcegraphToolMessageItem)(nil)
206+
207+// NewSourcegraphToolMessageItem creates a new [SourcegraphToolMessageItem].
208+func NewSourcegraphToolMessageItem(
209+ sty *styles.Styles,
210+ toolCall message.ToolCall,
211+ result *message.ToolResult,
212+ canceled bool,
213+) ToolMessageItem {
214+ return newBaseToolMessageItem(sty, toolCall, result, &SourcegraphToolRenderContext{}, canceled)
215+}
216+
217+// SourcegraphToolRenderContext renders sourcegraph tool messages.
218+type SourcegraphToolRenderContext struct{}
219+
220+// RenderTool implements the [ToolRenderer] interface.
221+func (s *SourcegraphToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
222+ cappedWidth := cappedMessageWidth(width)
223+ if !opts.ToolCall.Finished && !opts.Canceled {
224+ return pendingTool(sty, "Sourcegraph", opts.Anim)
225+ }
226+
227+ var params tools.SourcegraphParams
228+ if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
229+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
230+ }
231+
232+ toolParams := []string{params.Query}
233+ if params.Count != 0 {
234+ toolParams = append(toolParams, "count", formatNonZero(params.Count))
235+ }
236+ if params.ContextWindow != 0 {
237+ toolParams = append(toolParams, "context", formatNonZero(params.ContextWindow))
238+ }
239+
240+ header := toolHeader(sty, opts.Status(), "Sourcegraph", cappedWidth, opts.Compact, toolParams...)
241+ if opts.Compact {
242+ return header
243+ }
244+
245+ if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
246+ return joinToolParts(header, earlyState)
247+ }
248+
249+ if opts.Result == nil || opts.Result.Content == "" {
250+ return header
251+ }
252+
253+ bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
254+ body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
193255 return joinToolParts(header, body)
194256}
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+183−24
@@ -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+}
internal/ui/dialog/models_list.go+9−9
@@ -68,7 +68,7 @@ func (f *ModelsList) SetSelected(index int) {
6868
6969 f.List.SetSelected(index)
7070 for {
71− selectedItem := f.List.SelectedItem()
71+ selectedItem := f.SelectedItem()
7272 if _, ok := selectedItem.(*ModelItem); ok {
7373 return
7474 }
@@ -104,7 +104,7 @@ func (f *ModelsList) SetSelectedItem(itemID string) {
104104func (f *ModelsList) SelectNext() (v bool) {
105105 for {
106106 v = f.List.SelectNext()
107− selectedItem := f.List.SelectedItem()
107+ selectedItem := f.SelectedItem()
108108 if _, ok := selectedItem.(*ModelItem); ok {
109109 return v
110110 }
@@ -116,7 +116,7 @@ func (f *ModelsList) SelectNext() (v bool) {
116116func (f *ModelsList) SelectPrev() (v bool) {
117117 for {
118118 v = f.List.SelectPrev()
119− selectedItem := f.List.SelectedItem()
119+ selectedItem := f.SelectedItem()
120120 if _, ok := selectedItem.(*ModelItem); ok {
121121 return v
122122 }
@@ -127,7 +127,7 @@ func (f *ModelsList) SelectPrev() (v bool) {
127127func (f *ModelsList) SelectFirst() (v bool) {
128128 v = f.List.SelectFirst()
129129 for {
130− selectedItem := f.List.SelectedItem()
130+ selectedItem := f.SelectedItem()
131131 if _, ok := selectedItem.(*ModelItem); ok {
132132 return v
133133 }
@@ -139,7 +139,7 @@ func (f *ModelsList) SelectFirst() (v bool) {
139139func (f *ModelsList) SelectLast() (v bool) {
140140 v = f.List.SelectLast()
141141 for {
142− selectedItem := f.List.SelectedItem()
142+ selectedItem := f.SelectedItem()
143143 if _, ok := selectedItem.(*ModelItem); ok {
144144 return v
145145 }
@@ -149,18 +149,18 @@ func (f *ModelsList) SelectLast() (v bool) {
149149
150150// IsSelectedFirst checks if the selected item is the first model item.
151151func (f *ModelsList) IsSelectedFirst() bool {
152− originalIndex := f.List.Selected()
152+ originalIndex := f.Selected()
153153 f.SelectFirst()
154− isFirst := f.List.Selected() == originalIndex
154+ isFirst := f.Selected() == originalIndex
155155 f.List.SetSelected(originalIndex)
156156 return isFirst
157157}
158158
159159// IsSelectedLast checks if the selected item is the last model item.
160160func (f *ModelsList) IsSelectedLast() bool {
161− originalIndex := f.List.Selected()
161+ originalIndex := f.Selected()
162162 f.SelectLast()
163− isLast := f.List.Selected() == originalIndex
163+ isLast := f.Selected() == originalIndex
164164 f.List.SetSelected(originalIndex)
165165 return isLast
166166}
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
@@ -200,8 +200,15 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
200200 }
201201
202202 case pubsub.Event[message.Message]:
203− // TODO: handle nested messages for agentic tools
204− if m.session == nil || msg.Payload.SessionID != m.session.ID {
203+ // Check if this is a child session message for an agent tool.
204+ if m.session == nil {
205+ break
206+ }
207+ if msg.Payload.SessionID != m.session.ID {
208+ // This might be a child session message from an agent tool.
209+ if cmd := m.handleChildSessionMessage(msg); cmd != nil {
210+ cmds = append(cmds, cmd)
211+ }
205212 break
206213 }
207214 switch msg.Type {
@@ -384,6 +391,9 @@ func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
384391 items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
385392 }
386393
394+ // Load nested tool calls for agent/agentic_fetch tools.
395+ m.loadNestedToolCalls(items)
396+
387397 // If the user switches between sessions while the agent is working we want
388398 // to make sure the animations are shown.
389399 for _, item := range items {
@@ -402,6 +412,64 @@ func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
402412 return tea.Batch(cmds...)
403413}
404414
415+// loadNestedToolCalls recursively loads nested tool calls for agent/agentic_fetch tools.
416+func (m *UI) loadNestedToolCalls(items []chat.MessageItem) {
417+ for _, item := range items {
418+ nestedContainer, ok := item.(chat.NestedToolContainer)
419+ if !ok {
420+ continue
421+ }
422+ toolItem, ok := item.(chat.ToolMessageItem)
423+ if !ok {
424+ continue
425+ }
426+
427+ tc := toolItem.ToolCall()
428+ messageID := toolItem.MessageID()
429+
430+ // Get the agent tool session ID.
431+ agentSessionID := m.com.App.Sessions.CreateAgentToolSessionID(messageID, tc.ID)
432+
433+ // Fetch nested messages.
434+ nestedMsgs, err := m.com.App.Messages.List(context.Background(), agentSessionID)
435+ if err != nil || len(nestedMsgs) == 0 {
436+ continue
437+ }
438+
439+ // Build tool result map for nested messages.
440+ nestedMsgPtrs := make([]*message.Message, len(nestedMsgs))
441+ for i := range nestedMsgs {
442+ nestedMsgPtrs[i] = &nestedMsgs[i]
443+ }
444+ nestedToolResultMap := chat.BuildToolResultMap(nestedMsgPtrs)
445+
446+ // Extract nested tool items.
447+ var nestedTools []chat.ToolMessageItem
448+ for _, nestedMsg := range nestedMsgPtrs {
449+ nestedItems := chat.ExtractMessageItems(m.com.Styles, nestedMsg, nestedToolResultMap)
450+ for _, nestedItem := range nestedItems {
451+ if nestedToolItem, ok := nestedItem.(chat.ToolMessageItem); ok {
452+ // Mark nested tools as simple (compact) rendering.
453+ if simplifiable, ok := nestedToolItem.(chat.Compactable); ok {
454+ simplifiable.SetCompact(true)
455+ }
456+ nestedTools = append(nestedTools, nestedToolItem)
457+ }
458+ }
459+ }
460+
461+ // Recursively load nested tool calls for any agent tools within.
462+ nestedMessageItems := make([]chat.MessageItem, len(nestedTools))
463+ for i, nt := range nestedTools {
464+ nestedMessageItems[i] = nt
465+ }
466+ m.loadNestedToolCalls(nestedMessageItems)
467+
468+ // Set nested tools on the parent.
469+ nestedContainer.SetNestedTools(nestedTools)
470+ }
471+}
472+
405473// appendSessionMessage appends a new message to the current session in the chat
406474// if the message is a tool result it will update the corresponding tool call message
407475func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
@@ -465,7 +533,7 @@ func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
465533 }
466534 }
467535 if existingToolItem == nil {
468− items = append(items, chat.NewToolMessageItem(m.com.Styles, tc, nil, false))
536+ items = append(items, chat.NewToolMessageItem(m.com.Styles, msg.ID, tc, nil, false))
469537 }
470538 }
471539
@@ -484,6 +552,92 @@ func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
484552 return tea.Batch(cmds...)
485553}
486554
555+// handleChildSessionMessage handles messages from child sessions (agent tools).
556+func (m *UI) handleChildSessionMessage(event pubsub.Event[message.Message]) tea.Cmd {
557+ var cmds []tea.Cmd
558+
559+ // Only process messages with tool calls or results.
560+ if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 {
561+ return nil
562+ }
563+
564+ // Check if this is an agent tool session and parse it.
565+ childSessionID := event.Payload.SessionID
566+ _, toolCallID, ok := m.com.App.Sessions.ParseAgentToolSessionID(childSessionID)
567+ if !ok {
568+ return nil
569+ }
570+
571+ // Find the parent agent tool item.
572+ var agentItem chat.NestedToolContainer
573+ for i := 0; i < m.chat.Len(); i++ {
574+ item := m.chat.MessageItem(toolCallID)
575+ if item == nil {
576+ continue
577+ }
578+ if agent, ok := item.(chat.NestedToolContainer); ok {
579+ if toolMessageItem, ok := item.(chat.ToolMessageItem); ok {
580+ if toolMessageItem.ToolCall().ID == toolCallID {
581+ // Verify this agent belongs to the correct parent message.
582+ // We can't directly check parentMessageID on the item, so we trust the session parsing.
583+ agentItem = agent
584+ break
585+ }
586+ }
587+ }
588+ }
589+
590+ if agentItem == nil {
591+ return nil
592+ }
593+
594+ // Get existing nested tools.
595+ nestedTools := agentItem.NestedTools()
596+
597+ // Update or create nested tool calls.
598+ for _, tc := range event.Payload.ToolCalls() {
599+ found := false
600+ for _, existingTool := range nestedTools {
601+ if existingTool.ToolCall().ID == tc.ID {
602+ existingTool.SetToolCall(tc)
603+ found = true
604+ break
605+ }
606+ }
607+ if !found {
608+ // Create a new nested tool item.
609+ nestedItem := chat.NewToolMessageItem(m.com.Styles, event.Payload.ID, tc, nil, false)
610+ if simplifiable, ok := nestedItem.(chat.Compactable); ok {
611+ simplifiable.SetCompact(true)
612+ }
613+ if animatable, ok := nestedItem.(chat.Animatable); ok {
614+ if cmd := animatable.StartAnimation(); cmd != nil {
615+ cmds = append(cmds, cmd)
616+ }
617+ }
618+ nestedTools = append(nestedTools, nestedItem)
619+ }
620+ }
621+
622+ // Update nested tool results.
623+ for _, tr := range event.Payload.ToolResults() {
624+ for _, nestedTool := range nestedTools {
625+ if nestedTool.ToolCall().ID == tr.ToolCallID {
626+ nestedTool.SetResult(&tr)
627+ break
628+ }
629+ }
630+ }
631+
632+ // Update the agent item with the new nested tools.
633+ agentItem.SetNestedTools(nestedTools)
634+
635+ // Update the chat so it updates the index map for animations to work as expected
636+ m.chat.UpdateNestedToolIDs(toolCallID)
637+
638+ return tea.Batch(cmds...)
639+}
640+
487641func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
488642 var cmds []tea.Cmd
489643
internal/ui/styles/styles.go+21
@@ -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,15 @@ 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+ // Agentic fetch styles
283+ AgenticFetchPromptTag lipgloss.Style // Agentic fetch prompt tag (green background, bold)
284+
285+ // Todo styles
286+ TodoRatio lipgloss.Style // Todo ratio (e.g., "2/5")
287+ TodoCompletedIcon lipgloss.Style // Completed todo icon
288+ TodoInProgressIcon lipgloss.Style // In-progress todo icon
289+ TodoPendingIcon lipgloss.Style // Pending todo icon
280290 }
281291
282292 // Dialog styles
@@ -1022,6 +1032,15 @@ func DefaultStyles() Styles {
10221032 s.Tool.AgentTaskTag = base.Bold(true).Padding(0, 1).MarginLeft(2).Background(blueLight).Foreground(white)
10231033 s.Tool.AgentPrompt = s.Muted
10241034
1035+ // Agentic fetch styles
1036+ s.Tool.AgenticFetchPromptTag = base.Bold(true).Padding(0, 1).MarginLeft(2).Background(green).Foreground(border)
1037+
1038+ // Todo styles
1039+ s.Tool.TodoRatio = base.Foreground(blueDark)
1040+ s.Tool.TodoCompletedIcon = base.Foreground(green)
1041+ s.Tool.TodoInProgressIcon = base.Foreground(greenDark)
1042+ s.Tool.TodoPendingIcon = base.Foreground(fgMuted)
1043+
10251044 // Buttons
10261045 s.ButtonFocus = lipgloss.NewStyle().Foreground(white).Background(secondary)
10271046 s.ButtonBlur = s.Base.Background(bgSubtle)
@@ -1099,6 +1118,8 @@ func DefaultStyles() Styles {
10991118 BorderLeft(true).
11001119 BorderForeground(greenDark)
11011120 s.Chat.Message.ToolCallBlurred = s.Muted.PaddingLeft(2)
1121+ // No padding or border for compact tool calls within messages
1122+ s.Chat.Message.ToolCallCompact = s.Muted
11021123 s.Chat.Message.SectionHeader = s.Base.PaddingLeft(2)
11031124
11041125 // Thinking section styles