on main help sign in
Merge pull request #1778 from charmbracelet/add-more-tools
d56acc8Ayman Bagabas
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 *
69 toolParams = append(toolParams, "background", "true")
69 toolParams = append(toolParams, "background", "true")
70 }
70 }
71
71
72− header := toolHeader(sty, opts.Status(), "Bash", cappedWidth, opts.Nested, toolParams...)
72+ header := toolHeader(sty, opts.Status(), "Bash", cappedWidth, opts.Compact, toolParams...)
73− if opts.Nested {
73+ if opts.Compact {
74 return header
74 return header
75 }
75 }
76
76
@@ -91,7 +91,7 @@ func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
91 }
91 }
92
92
93 bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
93 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))
95 return joinToolParts(header, body)
95 return joinToolParts(header, body)
96}
96}
97
97
@@ -201,7 +201,7 @@ func (j *JobKillToolRenderContext) RenderTool(sty *styles.Styles, width int, opt
201// header → nested check → early state → body.
201// header → nested check → early state → body.
202func renderJobTool(sty *styles.Styles, opts *ToolRenderOpts, width int, action, shellID, description, content string) string {
202func renderJobTool(sty *styles.Styles, opts *ToolRenderOpts, width int, action, shellID, description, content string) string {
203 header := jobHeader(sty, opts.Status(), action, shellID, description, width)
203 header := jobHeader(sty, opts.Status(), action, shellID, description, width)
204− if opts.Nested {
204+ if opts.Compact {
205 return header
205 return header
206 }
206 }
207
207
@@ -214,7 +214,7 @@ func renderJobTool(sty *styles.Styles, opts *ToolRenderOpts, width int, action,
214 }
214 }
215
215
216 bodyWidth := width - toolBodyLeftPaddingTotal
216 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))
218 return joinToolParts(header, body)
218 return joinToolParts(header, body)
219}
219}
220
220
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 *
56 toolParams = append(toolParams, "offset", fmt.Sprintf("%d", params.Offset))
56 toolParams = append(toolParams, "offset", fmt.Sprintf("%d", params.Offset))
57 }
57 }
58
58
59− header := toolHeader(sty, opts.Status(), "View", cappedWidth, opts.Nested, toolParams...)
59+ header := toolHeader(sty, opts.Status(), "View", cappedWidth, opts.Compact, toolParams...)
60− if opts.Nested {
60+ if opts.Compact {
61 return header
61 return header
62 }
62 }
63
63
@@ -87,7 +87,7 @@ func (v *ViewToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
87 }
87 }
88
88
89 // Render code content with syntax highlighting.
89 // 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)
91 return joinToolParts(header, body)
91 return joinToolParts(header, body)
92}
92}
93
93
@@ -128,8 +128,8 @@ func (w *WriteToolRenderContext) RenderTool(sty *styles.Styles, width int, opts
128 }
128 }
129
129
130 file := fsext.PrettyPath(params.FilePath)
130 file := fsext.PrettyPath(params.FilePath)
131− header := toolHeader(sty, opts.Status(), "Write", cappedWidth, opts.Nested, file)
131+ header := toolHeader(sty, opts.Status(), "Write", cappedWidth, opts.Compact, file)
132− if opts.Nested {
132+ if opts.Compact {
133 return header
133 return header
134 }
134 }
135
135
@@ -142,7 +142,7 @@ func (w *WriteToolRenderContext) RenderTool(sty *styles.Styles, width int, opts
142 }
142 }
143
143
144 // Render code content with syntax highlighting.
144 // 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)
146 return joinToolParts(header, body)
146 return joinToolParts(header, body)
147}
147}
148
148
@@ -183,8 +183,8 @@ func (e *EditToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
183 }
183 }
184
184
185 file := fsext.PrettyPath(params.FilePath)
185 file := fsext.PrettyPath(params.FilePath)
186− header := toolHeader(sty, opts.Status(), "Edit", width, opts.Nested, file)
186+ header := toolHeader(sty, opts.Status(), "Edit", width, opts.Compact, file)
187− if opts.Nested {
187+ if opts.Compact {
188 return header
188 return header
189 }
189 }
190
190
@@ -200,12 +200,12 @@ func (e *EditToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
200 var meta tools.EditResponseMetadata
200 var meta tools.EditResponseMetadata
201 if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err != nil {
201 if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err != nil {
202 bodyWidth := width - toolBodyLeftPaddingTotal
202 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))
204 return joinToolParts(header, body)
204 return joinToolParts(header, body)
205 }
205 }
206
206
207 // Render diff.
207 // 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)
209 return joinToolParts(header, body)
209 return joinToolParts(header, body)
210}
210}
211
211
@@ -251,8 +251,8 @@ func (m *MultiEditToolRenderContext) RenderTool(sty *styles.Styles, width int, o
251 toolParams = append(toolParams, "edits", fmt.Sprintf("%d", len(params.Edits)))
251 toolParams = append(toolParams, "edits", fmt.Sprintf("%d", len(params.Edits)))
252 }
252 }
253
253
254− header := toolHeader(sty, opts.Status(), "Multi-Edit", width, opts.Nested, toolParams...)
254+ header := toolHeader(sty, opts.Status(), "Multi-Edit", width, opts.Compact, toolParams...)
255− if opts.Nested {
255+ if opts.Compact {
256 return header
256 return header
257 }
257 }
258
258
@@ -268,11 +268,73 @@ func (m *MultiEditToolRenderContext) RenderTool(sty *styles.Styles, width int, o
268 var meta tools.MultiEditResponseMetadata
268 var meta tools.MultiEditResponseMetadata
269 if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err != nil {
269 if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err != nil {
270 bodyWidth := width - toolBodyLeftPaddingTotal
270 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))
272 return joinToolParts(header, body)
272 return joinToolParts(header, body)
273 }
273 }
274
274
275 // Render diff with optional failed edits note.
275 // 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))
277 return joinToolParts(header, body)
339 return joinToolParts(header, body)
278}
340}
internal/ui/chat/messages.go+1
@@ -171,6 +171,7 @@ func ExtractMessageItems(sty *styles.Styles, msg *message.Message, toolResults m
171 }
171 }
172 items = append(items, NewToolMessageItem(
172 items = append(items, NewToolMessageItem(
173 sty,
173 sty,
174+ msg.ID,
174 tc,
175 tc,
175 result,
176 result,
176 msg.FinishReason() == message.FinishReasonCanceled,
177 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 *
50 toolParams = append(toolParams, "path", params.Path)
50 toolParams = append(toolParams, "path", params.Path)
51 }
51 }
52
52
53− header := toolHeader(sty, opts.Status(), "Glob", cappedWidth, opts.Nested, toolParams...)
53+ header := toolHeader(sty, opts.Status(), "Glob", cappedWidth, opts.Compact, toolParams...)
54− if opts.Nested {
54+ if opts.Compact {
55 return header
55 return header
56 }
56 }
57
57
@@ -64,7 +64,7 @@ func (g *GlobToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
64 }
64 }
65
65
66 bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
66 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))
68 return joinToolParts(header, body)
68 return joinToolParts(header, body)
69}
69}
70
70
@@ -115,8 +115,8 @@ func (g *GrepToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
115 toolParams = append(toolParams, "literal", "true")
115 toolParams = append(toolParams, "literal", "true")
116 }
116 }
117
117
118− header := toolHeader(sty, opts.Status(), "Grep", cappedWidth, opts.Nested, toolParams...)
118+ header := toolHeader(sty, opts.Status(), "Grep", cappedWidth, opts.Compact, toolParams...)
119− if opts.Nested {
119+ if opts.Compact {
120 return header
120 return header
121 }
121 }
122
122
@@ -129,7 +129,7 @@ func (g *GrepToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
129 }
129 }
130
130
131 bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
131 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))
133 return joinToolParts(header, body)
133 return joinToolParts(header, body)
134}
134}
135
135
@@ -175,8 +175,8 @@ func (l *LSToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *To
175 }
175 }
176 path = fsext.PrettyPath(path)
176 path = fsext.PrettyPath(path)
177
177
178− header := toolHeader(sty, opts.Status(), "List", cappedWidth, opts.Nested, path)
178+ header := toolHeader(sty, opts.Status(), "List", cappedWidth, opts.Compact, path)
179− if opts.Nested {
179+ if opts.Compact {
180 return header
180 return header
181 }
181 }
182
182
@@ -189,6 +189,68 @@ func (l *LSToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *To
189 }
189 }
190
190
191 bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
191 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))
193 return joinToolParts(header, body)
255 return joinToolParts(header, body)
194}
256}
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 (
6
6
7 tea "charm.land/bubbletea/v2"
7 tea "charm.land/bubbletea/v2"
8 "charm.land/lipgloss/v2"
8 "charm.land/lipgloss/v2"
9+ "charm.land/lipgloss/v2/tree"
10+ "github.com/charmbracelet/crush/internal/agent"
9 "github.com/charmbracelet/crush/internal/agent/tools"
11 "github.com/charmbracelet/crush/internal/agent/tools"
10 "github.com/charmbracelet/crush/internal/message"
12 "github.com/charmbracelet/crush/internal/message"
11 "github.com/charmbracelet/crush/internal/ui/anim"
13 "github.com/charmbracelet/crush/internal/ui/anim"
@@ -38,8 +40,27 @@ type ToolMessageItem interface {
38 ToolCall() message.ToolCall
40 ToolCall() message.ToolCall
39 SetToolCall(tc message.ToolCall)
41 SetToolCall(tc message.ToolCall)
40 SetResult(res *message.ToolResult)
42 SetResult(res *message.ToolResult)
43+ MessageID() string
44+ SetMessageID(id string)
41}
45}
42
46
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+
43// DefaultToolRenderContext implements the default [ToolRenderer] interface.
64// DefaultToolRenderContext implements the default [ToolRenderer] interface.
44type DefaultToolRenderContext struct{}
65type DefaultToolRenderContext struct{}
45
66
@@ -54,8 +75,8 @@ type ToolRenderOpts struct {
54 Result *message.ToolResult
75 Result *message.ToolResult
55 Canceled bool
76 Canceled bool
56 Anim *anim.Anim
77 Anim *anim.Anim
57− Expanded bool
78+ ExpandedContent bool
58− Nested bool
79+ Compact bool
59 IsSpinning bool
80 IsSpinning bool
60 PermissionRequested bool
81 PermissionRequested bool
61 PermissionGranted bool
82 PermissionGranted bool
@@ -100,16 +121,22 @@ type baseToolMessageItem struct {
100 toolRenderer ToolRenderer
121 toolRenderer ToolRenderer
101 toolCall message.ToolCall
122 toolCall message.ToolCall
102 result *message.ToolResult
123 result *message.ToolResult
124+ messageID string
103 canceled bool
125 canceled bool
104 permissionRequested bool
126 permissionRequested bool
105 permissionGranted bool
127 permissionGranted bool
106 // we use this so we can efficiently cache
128 // we use this so we can efficiently cache
107 // tools that have a capped width (e.x bash.. and others)
129 // tools that have a capped width (e.x bash.. and others)
108 hasCappedWidth bool
130 hasCappedWidth bool
131+ // isCompact indicates this tool should render in compact mode.
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
109
136
110− sty *styles.Styles
137+ sty *styles.Styles
111− anim *anim.Anim
138+ anim *anim.Anim
112− expanded bool
139+ expandedContent bool
113}
140}
114
141
115// newBaseToolMessageItem is the internal constructor for base tool message items.
142// newBaseToolMessageItem is the internal constructor for base tool message items.
@@ -149,37 +176,58 @@ func newBaseToolMessageItem(
149// NewToolMessageItem creates a new [ToolMessageItem] based on the tool call name.
176// NewToolMessageItem creates a new [ToolMessageItem] based on the tool call name.
150//
177//
151// It returns a specific tool message item type if implemented, otherwise it
178// It returns a specific tool message item type if implemented, otherwise it
152−// returns a generic tool message item.
179+// returns a generic tool message item. The messageID is the ID of the assistant
180+// message containing this tool call.
153func NewToolMessageItem(
181func NewToolMessageItem(
154 sty *styles.Styles,
182 sty *styles.Styles,
183+ messageID string,
155 toolCall message.ToolCall,
184 toolCall message.ToolCall,
156 result *message.ToolResult,
185 result *message.ToolResult,
157 canceled bool,
186 canceled bool,
158) ToolMessageItem {
187) ToolMessageItem {
188+ var item ToolMessageItem
159 switch toolCall.Name {
189 switch toolCall.Name {
160 case tools.BashToolName:
190 case tools.BashToolName:
161− return NewBashToolMessageItem(sty, toolCall, result, canceled)
191+ item = NewBashToolMessageItem(sty, toolCall, result, canceled)
162 case tools.JobOutputToolName:
192 case tools.JobOutputToolName:
163− return NewJobOutputToolMessageItem(sty, toolCall, result, canceled)
193+ item = NewJobOutputToolMessageItem(sty, toolCall, result, canceled)
164 case tools.JobKillToolName:
194 case tools.JobKillToolName:
165− return NewJobKillToolMessageItem(sty, toolCall, result, canceled)
195+ item = NewJobKillToolMessageItem(sty, toolCall, result, canceled)
166 case tools.ViewToolName:
196 case tools.ViewToolName:
167− return NewViewToolMessageItem(sty, toolCall, result, canceled)
197+ item = NewViewToolMessageItem(sty, toolCall, result, canceled)
168 case tools.WriteToolName:
198 case tools.WriteToolName:
169− return NewWriteToolMessageItem(sty, toolCall, result, canceled)
199+ item = NewWriteToolMessageItem(sty, toolCall, result, canceled)
170 case tools.EditToolName:
200 case tools.EditToolName:
171− return NewEditToolMessageItem(sty, toolCall, result, canceled)
201+ item = NewEditToolMessageItem(sty, toolCall, result, canceled)
172 case tools.MultiEditToolName:
202 case tools.MultiEditToolName:
173− return NewMultiEditToolMessageItem(sty, toolCall, result, canceled)
203+ item = NewMultiEditToolMessageItem(sty, toolCall, result, canceled)
174 case tools.GlobToolName:
204 case tools.GlobToolName:
175− return NewGlobToolMessageItem(sty, toolCall, result, canceled)
205+ item = NewGlobToolMessageItem(sty, toolCall, result, canceled)
176 case tools.GrepToolName:
206 case tools.GrepToolName:
177− return NewGrepToolMessageItem(sty, toolCall, result, canceled)
207+ item = NewGrepToolMessageItem(sty, toolCall, result, canceled)
178 case tools.LSToolName:
208 case tools.LSToolName:
179− return NewLSToolMessageItem(sty, toolCall, result, canceled)
209+ item = NewLSToolMessageItem(sty, toolCall, result, canceled)
210+ case tools.DownloadToolName:
211+ item = NewDownloadToolMessageItem(sty, toolCall, result, canceled)
212+ case tools.FetchToolName:
213+ item = NewFetchToolMessageItem(sty, toolCall, result, canceled)
214+ case tools.SourcegraphToolName:
215+ item = NewSourcegraphToolMessageItem(sty, toolCall, result, canceled)
216+ case tools.DiagnosticsToolName:
217+ item = NewDiagnosticsToolMessageItem(sty, toolCall, result, canceled)
218+ case agent.AgentToolName:
219+ item = NewAgentToolMessageItem(sty, toolCall, result, canceled)
220+ case tools.AgenticFetchToolName:
221+ item = NewAgenticFetchToolMessageItem(sty, toolCall, result, canceled)
222+ case tools.WebFetchToolName:
223+ item = NewWebFetchToolMessageItem(sty, toolCall, result, canceled)
224+ case tools.WebSearchToolName:
225+ item = NewWebSearchToolMessageItem(sty, toolCall, result, canceled)
226+ case tools.TodosToolName:
227+ item = NewTodosToolMessageItem(sty, toolCall, result, canceled)
180 default:
228 default:
181 // TODO: Implement other tool items
229 // TODO: Implement other tool items
182− return newBaseToolMessageItem(
230+ item = newBaseToolMessageItem(
183 sty,
231 sty,
184 toolCall,
232 toolCall,
185 result,
233 result,
@@ -187,6 +235,14 @@ func NewToolMessageItem(
187 canceled,
235 canceled,
188 )
236 )
189 }
237 }
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()
190}
246}
191
247
192// ID returns the unique identifier for this tool message item.
248// ID returns the unique identifier for this tool message item.
@@ -221,6 +277,10 @@ func (t *baseToolMessageItem) Render(width int) string {
221 style = t.sty.Chat.Message.ToolCallFocused
277 style = t.sty.Chat.Message.ToolCallFocused
222 }
278 }
223
279
280+ if t.isCompact {
281+ style = t.sty.Chat.Message.ToolCallCompact
282+ }
283+
224 content, height, ok := t.getCachedRender(toolItemWidth)
284 content, height, ok := t.getCachedRender(toolItemWidth)
225 // if we are spinning or there is no cache rerender
285 // if we are spinning or there is no cache rerender
226 if !ok || t.isSpinning() {
286 if !ok || t.isSpinning() {
@@ -229,7 +289,8 @@ func (t *baseToolMessageItem) Render(width int) string {
229 Result: t.result,
289 Result: t.result,
230 Canceled: t.canceled,
290 Canceled: t.canceled,
231 Anim: t.anim,
291 Anim: t.anim,
232− Expanded: t.expanded,
292+ ExpandedContent: t.expandedContent,
293+ Compact: t.isCompact,
233 PermissionRequested: t.permissionRequested,
294 PermissionRequested: t.permissionRequested,
234 PermissionGranted: t.permissionGranted,
295 PermissionGranted: t.permissionGranted,
235 IsSpinning: t.isSpinning(),
296 IsSpinning: t.isSpinning(),
@@ -260,6 +321,16 @@ func (t *baseToolMessageItem) SetResult(res *message.ToolResult) {
260 t.clearCache()
321 t.clearCache()
261}
322}
262
323
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+
263// SetPermissionRequested sets whether permission has been requested for this tool call.
334// SetPermissionRequested sets whether permission has been requested for this tool call.
264// TODO: Consider merging with SetPermissionGranted and add an interface for
335// TODO: Consider merging with SetPermissionGranted and add an interface for
265// permission management.
336// permission management.
@@ -278,12 +349,24 @@ func (t *baseToolMessageItem) SetPermissionGranted(granted bool) {
278
349
279// isSpinning returns true if the tool should show animation.
350// isSpinning returns true if the tool should show animation.
280func (t *baseToolMessageItem) isSpinning() bool {
351func (t *baseToolMessageItem) isSpinning() bool {
352+ if t.spinningFunc != nil {
353+ return t.spinningFunc(SpinningState{
354+ ToolCall: t.toolCall,
355+ Result: t.result,
356+ Canceled: t.canceled,
357+ })
358+ }
281 return !t.toolCall.Finished && !t.canceled
359 return !t.toolCall.Finished && !t.canceled
282}
360}
283
361
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+
284// ToggleExpanded toggles the expanded state of the thinking box.
367// ToggleExpanded toggles the expanded state of the thinking box.
285func (t *baseToolMessageItem) ToggleExpanded() {
368func (t *baseToolMessageItem) ToggleExpanded() {
286− t.expanded = !t.expanded
369+ t.expandedContent = !t.expandedContent
287 t.clearCache()
370 t.clearCache()
288}
371}
289
372
@@ -487,10 +570,10 @@ func toolOutputCodeContent(sty *styles.Styles, path, content string, offset, wid
487
570
488 // Add truncation message if needed.
571 // Add truncation message if needed.
489 if len(lines) > maxLines && !expanded {
572 if len(lines) > maxLines && !expanded {
490− truncMsg := sty.Tool.ContentCodeTruncation.
573+ out = append(out, sty.Tool.ContentCodeTruncation.
491 Width(bodyWidth).
574 Width(bodyWidth).
492− Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines))
575+ Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)),
493− out = append([]string{truncMsg}, out...)
576+ )
494 }
577 }
495
578
496 return sty.Tool.Body.Render(strings.Join(out, "\n"))
579 return sty.Tool.Body.Render(strings.Join(out, "\n"))
@@ -551,7 +634,7 @@ func toolOutputDiffContent(sty *styles.Styles, file, oldContent, newContent stri
551 Width(bodyWidth)
634 Width(bodyWidth)
552
635
553 // Use split view for wide terminals.
636 // Use split view for wide terminals.
554− if width > 120 {
637+ if width > maxTextWidth {
555 formatter = formatter.Split()
638 formatter = formatter.Split()
556 }
639 }
557
640
@@ -574,6 +657,23 @@ func toolOutputDiffContent(sty *styles.Styles, file, oldContent, newContent stri
574 return sty.Tool.Body.Render(formatted)
657 return sty.Tool.Body.Render(formatted)
575}
658}
576
659
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+
577// toolOutputMultiEditDiffContent renders a diff with optional failed edits note.
677// toolOutputMultiEditDiffContent renders a diff with optional failed edits note.
578func toolOutputMultiEditDiffContent(sty *styles.Styles, file string, meta tools.MultiEditResponseMetadata, totalEdits, width int, expanded bool) string {
678func toolOutputMultiEditDiffContent(sty *styles.Styles, file string, meta tools.MultiEditResponseMetadata, totalEdits, width int, expanded bool) string {
579 bodyWidth := width - toolBodyLeftPaddingTotal
679 bodyWidth := width - toolBodyLeftPaddingTotal
@@ -584,7 +684,7 @@ func toolOutputMultiEditDiffContent(sty *styles.Styles, file string, meta tools.
584 Width(bodyWidth)
684 Width(bodyWidth)
585
685
586 // Use split view for wide terminals.
686 // Use split view for wide terminals.
587− if width > 120 {
687+ if width > maxTextWidth {
588 formatter = formatter.Split()
688 formatter = formatter.Split()
589 }
689 }
590
690
@@ -614,3 +714,62 @@ func toolOutputMultiEditDiffContent(sty *styles.Styles, file string, meta tools.
614
714
615 return sty.Tool.Body.Render(formatted)
715 return sty.Tool.Body.Render(formatted)
616}
716}
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) {
68
68
69 f.List.SetSelected(index)
69 f.List.SetSelected(index)
70 for {
70 for {
71− selectedItem := f.List.SelectedItem()
71+ selectedItem := f.SelectedItem()
72 if _, ok := selectedItem.(*ModelItem); ok {
72 if _, ok := selectedItem.(*ModelItem); ok {
73 return
73 return
74 }
74 }
@@ -104,7 +104,7 @@ func (f *ModelsList) SetSelectedItem(itemID string) {
104func (f *ModelsList) SelectNext() (v bool) {
104func (f *ModelsList) SelectNext() (v bool) {
105 for {
105 for {
106 v = f.List.SelectNext()
106 v = f.List.SelectNext()
107− selectedItem := f.List.SelectedItem()
107+ selectedItem := f.SelectedItem()
108 if _, ok := selectedItem.(*ModelItem); ok {
108 if _, ok := selectedItem.(*ModelItem); ok {
109 return v
109 return v
110 }
110 }
@@ -116,7 +116,7 @@ func (f *ModelsList) SelectNext() (v bool) {
116func (f *ModelsList) SelectPrev() (v bool) {
116func (f *ModelsList) SelectPrev() (v bool) {
117 for {
117 for {
118 v = f.List.SelectPrev()
118 v = f.List.SelectPrev()
119− selectedItem := f.List.SelectedItem()
119+ selectedItem := f.SelectedItem()
120 if _, ok := selectedItem.(*ModelItem); ok {
120 if _, ok := selectedItem.(*ModelItem); ok {
121 return v
121 return v
122 }
122 }
@@ -127,7 +127,7 @@ func (f *ModelsList) SelectPrev() (v bool) {
127func (f *ModelsList) SelectFirst() (v bool) {
127func (f *ModelsList) SelectFirst() (v bool) {
128 v = f.List.SelectFirst()
128 v = f.List.SelectFirst()
129 for {
129 for {
130− selectedItem := f.List.SelectedItem()
130+ selectedItem := f.SelectedItem()
131 if _, ok := selectedItem.(*ModelItem); ok {
131 if _, ok := selectedItem.(*ModelItem); ok {
132 return v
132 return v
133 }
133 }
@@ -139,7 +139,7 @@ func (f *ModelsList) SelectFirst() (v bool) {
139func (f *ModelsList) SelectLast() (v bool) {
139func (f *ModelsList) SelectLast() (v bool) {
140 v = f.List.SelectLast()
140 v = f.List.SelectLast()
141 for {
141 for {
142− selectedItem := f.List.SelectedItem()
142+ selectedItem := f.SelectedItem()
143 if _, ok := selectedItem.(*ModelItem); ok {
143 if _, ok := selectedItem.(*ModelItem); ok {
144 return v
144 return v
145 }
145 }
@@ -149,18 +149,18 @@ func (f *ModelsList) SelectLast() (v bool) {
149
149
150// IsSelectedFirst checks if the selected item is the first model item.
150// IsSelectedFirst checks if the selected item is the first model item.
151func (f *ModelsList) IsSelectedFirst() bool {
151func (f *ModelsList) IsSelectedFirst() bool {
152− originalIndex := f.List.Selected()
152+ originalIndex := f.Selected()
153 f.SelectFirst()
153 f.SelectFirst()
154− isFirst := f.List.Selected() == originalIndex
154+ isFirst := f.Selected() == originalIndex
155 f.List.SetSelected(originalIndex)
155 f.List.SetSelected(originalIndex)
156 return isFirst
156 return isFirst
157}
157}
158
158
159// IsSelectedLast checks if the selected item is the last model item.
159// IsSelectedLast checks if the selected item is the last model item.
160func (f *ModelsList) IsSelectedLast() bool {
160func (f *ModelsList) IsSelectedLast() bool {
161− originalIndex := f.List.Selected()
161+ originalIndex := f.Selected()
162 f.SelectLast()
162 f.SelectLast()
163− isLast := f.List.Selected() == originalIndex
163+ isLast := f.Selected() == originalIndex
164 f.List.SetSelected(originalIndex)
164 f.List.SetSelected(originalIndex)
165 return isLast
165 return isLast
166}
166}
internal/ui/model/chat.go+36
@@ -77,6 +77,12 @@ func (m *Chat) SetMessages(msgs ...chat.MessageItem) {
77 items := make([]list.Item, len(msgs))
77 items := make([]list.Item, len(msgs))
78 for i, msg := range msgs {
78 for i, msg := range msgs {
79 m.idInxMap[msg.ID()] = i
79 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+ }
80 items[i] = msg
86 items[i] = msg
81 }
87 }
82 m.list.SetItems(items...)
88 m.list.SetItems(items...)
@@ -89,11 +95,41 @@ func (m *Chat) AppendMessages(msgs ...chat.MessageItem) {
89 indexOffset := m.list.Len()
95 indexOffset := m.list.Len()
90 for i, msg := range msgs {
96 for i, msg := range msgs {
91 m.idInxMap[msg.ID()] = indexOffset + i
97 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+ }
92 items[i] = msg
104 items[i] = msg
93 }
105 }
94 m.list.AppendItems(items...)
106 m.list.AppendItems(items...)
95}
107}
96
108
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+
97// Animate animates items in the chat list. Only propagates animation messages
133// Animate animates items in the chat list. Only propagates animation messages
98// to visible items to save CPU. When items are not visible, their animation ID
134// to visible items to save CPU. When items are not visible, their animation ID
99// is tracked so it can be restarted when they become visible again.
135// 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) {
200 }
200 }
201
201
202 case pubsub.Event[message.Message]:
202 case pubsub.Event[message.Message]:
203− // TODO: handle nested messages for agentic tools
203+ // Check if this is a child session message for an agent tool.
204+ if m.session == nil {
205+ break
206+ }
204− if m.session == nil || msg.Payload.SessionID != m.session.ID {
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+ }
205 break
212 break
206 }
213 }
207 switch msg.Type {
214 switch msg.Type {
@@ -384,6 +391,9 @@ func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
384 items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
391 items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
385 }
392 }
386
393
394+ // Load nested tool calls for agent/agentic_fetch tools.
395+ m.loadNestedToolCalls(items)
396+
387 // If the user switches between sessions while the agent is working we want
397 // If the user switches between sessions while the agent is working we want
388 // to make sure the animations are shown.
398 // to make sure the animations are shown.
389 for _, item := range items {
399 for _, item := range items {
@@ -402,6 +412,64 @@ func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
402 return tea.Batch(cmds...)
412 return tea.Batch(cmds...)
403}
413}
404
414
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+
405// appendSessionMessage appends a new message to the current session in the chat
473// appendSessionMessage appends a new message to the current session in the chat
406// if the message is a tool result it will update the corresponding tool call message
474// if the message is a tool result it will update the corresponding tool call message
407func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
475func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
@@ -465,7 +533,7 @@ func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
465 }
533 }
466 }
534 }
467 if existingToolItem == nil {
535 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))
469 }
537 }
470 }
538 }
471
539
@@ -484,6 +552,92 @@ func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
484 return tea.Batch(cmds...)
552 return tea.Batch(cmds...)
485}
553}
486
554
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+
487func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
641func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
488 var cmds []tea.Cmd
642 var cmds []tea.Cmd
489
643
internal/ui/styles/styles.go+21
@@ -210,6 +210,7 @@ type Styles struct {
210 ErrorDetails lipgloss.Style
210 ErrorDetails lipgloss.Style
211 Attachment lipgloss.Style
211 Attachment lipgloss.Style
212 ToolCallFocused lipgloss.Style
212 ToolCallFocused lipgloss.Style
213+ ToolCallCompact lipgloss.Style
213 ToolCallBlurred lipgloss.Style
214 ToolCallBlurred lipgloss.Style
214 SectionHeader lipgloss.Style
215 SectionHeader lipgloss.Style
215
216
@@ -277,6 +278,15 @@ type Styles struct {
277 // Agent task styles
278 // Agent task styles
278 AgentTaskTag lipgloss.Style // Agent task tag (blue background, bold)
279 AgentTaskTag lipgloss.Style // Agent task tag (blue background, bold)
279 AgentPrompt lipgloss.Style // Agent prompt text
280 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
280 }
290 }
281
291
282 // Dialog styles
292 // Dialog styles
@@ -1022,6 +1032,15 @@ func DefaultStyles() Styles {
1022 s.Tool.AgentTaskTag = base.Bold(true).Padding(0, 1).MarginLeft(2).Background(blueLight).Foreground(white)
1032 s.Tool.AgentTaskTag = base.Bold(true).Padding(0, 1).MarginLeft(2).Background(blueLight).Foreground(white)
1023 s.Tool.AgentPrompt = s.Muted
1033 s.Tool.AgentPrompt = s.Muted
1024
1034
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+
1025 // Buttons
1044 // Buttons
1026 s.ButtonFocus = lipgloss.NewStyle().Foreground(white).Background(secondary)
1045 s.ButtonFocus = lipgloss.NewStyle().Foreground(white).Background(secondary)
1027 s.ButtonBlur = s.Base.Background(bgSubtle)
1046 s.ButtonBlur = s.Base.Background(bgSubtle)
@@ -1099,6 +1118,8 @@ func DefaultStyles() Styles {
1099 BorderLeft(true).
1118 BorderLeft(true).
1100 BorderForeground(greenDark)
1119 BorderForeground(greenDark)
1101 s.Chat.Message.ToolCallBlurred = s.Muted.PaddingLeft(2)
1120 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
1102 s.Chat.Message.SectionHeader = s.Base.PaddingLeft(2)
1123 s.Chat.Message.SectionHeader = s.Base.PaddingLeft(2)
1103
1124
1104 // Thinking section styles
1125 // Thinking section styles