1package chat
2
3import (
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.
21type 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.
28type AgentToolMessageItem struct {
29 *baseToolMessageItem
30
31 nestedTools []ToolMessageItem
32}
33
34var (
35 _ ToolMessageItem = (*AgentToolMessageItem)(nil)
36 _ NestedToolContainer = (*AgentToolMessageItem)(nil)
37)
38
39// NewAgentToolMessageItem creates a new [AgentToolMessageItem].
40func 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.HasResult() && !state.IsCanceled()
51 }
52 return t
53}
54
55// Animate progresses the message animation if it should be spinning.
56func (a *AgentToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
57 if a.result != nil || a.Status() == ToolStatusCanceled {
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.
75func (a *AgentToolMessageItem) NestedTools() []ToolMessageItem {
76 return a.nestedTools
77}
78
79// SetNestedTools sets the nested tools.
80//
81// SetNestedTools always bumps the version. The previous design
82// deduped when the slice's length and element pointers were
83// unchanged, but the live update path in internal/ui/model/ui.go
84// mutates existing children in place (SetToolCall / SetResult on the
85// same pointers) and then calls SetNestedTools with the same slice.
86// Pointer-equality dedupe in that case skips the parent Bump even
87// though the parent's rendered output (which embeds the children
88// inline) has changed, leaving a stale parent entry in the list
89// cache. Always bumping is cheap (one uint64 increment) and called
90// at most once per agent event; in the rare case the slice is
91// truly unchanged the worst case is one extra parent re-render
92// while every child cache hit stays warm.
93func (a *AgentToolMessageItem) SetNestedTools(tools []ToolMessageItem) {
94 a.nestedTools = tools
95 a.clearCache()
96 a.Bump()
97}
98
99// AddNestedTool adds a nested tool.
100func (a *AgentToolMessageItem) AddNestedTool(tool ToolMessageItem) {
101 // Mark nested tools as simple (compact) rendering.
102 if s, ok := tool.(Compactable); ok {
103 s.SetCompact(true)
104 }
105 a.nestedTools = append(a.nestedTools, tool)
106 a.clearCache()
107 a.Bump()
108}
109
110// AgentToolRenderContext renders agent tool messages.
111type AgentToolRenderContext struct {
112 agent *AgentToolMessageItem
113}
114
115// RenderTool implements the [ToolRenderer] interface.
116func (r *AgentToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
117 cappedWidth := cappedMessageWidth(width)
118 if !opts.ToolCall.Finished && !opts.IsCanceled() && len(r.agent.nestedTools) == 0 {
119 return pendingTool(sty, "Agent", opts.Anim, opts.Compact)
120 }
121
122 var params agent.AgentParams
123 _ = json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms)
124
125 prompt := params.Prompt
126 prompt = strings.ReplaceAll(prompt, "\n", " ")
127
128 header := toolHeader(sty, opts.Status, "Agent", cappedWidth, opts.Compact)
129 if opts.Compact {
130 return header
131 }
132
133 // Build the task tag and prompt.
134 taskTag := sty.Tool.AgentTaskTag.Render("Task")
135 taskTagWidth := lipgloss.Width(taskTag)
136
137 // Calculate remaining width for prompt.
138 remainingWidth := min(cappedWidth-taskTagWidth-3, maxTextWidth-taskTagWidth-3) // -3 for spacing
139
140 promptText := sty.Tool.AgentPrompt.Width(remainingWidth).Render(prompt)
141
142 header = lipgloss.JoinVertical(
143 lipgloss.Left,
144 header,
145 "",
146 lipgloss.JoinHorizontal(
147 lipgloss.Left,
148 taskTag,
149 " ",
150 promptText,
151 ),
152 )
153
154 // Build tree with nested tool calls.
155 childTools := tree.Root(header)
156
157 for _, nestedTool := range r.agent.nestedTools {
158 childView := nestedTool.Render(remainingWidth)
159 childTools.Child(childView)
160 }
161
162 // Build parts.
163 var parts []string
164 parts = append(parts, childTools.Enumerator(roundedEnumerator(2, taskTagWidth-5)).String())
165
166 // Show animation if still running.
167 if !opts.HasResult() && !opts.IsCanceled() {
168 parts = append(parts, "", opts.Anim.Render())
169 }
170
171 result := lipgloss.JoinVertical(lipgloss.Left, parts...)
172
173 // Add body content when completed.
174 if opts.HasResult() && opts.Result.Content != "" {
175 body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth-toolBodyLeftPaddingTotal, opts.ExpandedContent)
176 return joinToolParts(result, body)
177 }
178
179 return result
180}
181
182// -----------------------------------------------------------------------------
183// Agentic Fetch Tool
184// -----------------------------------------------------------------------------
185
186// AgenticFetchToolMessageItem is a message item that represents an agentic fetch tool call.
187type AgenticFetchToolMessageItem struct {
188 *baseToolMessageItem
189
190 nestedTools []ToolMessageItem
191}
192
193var (
194 _ ToolMessageItem = (*AgenticFetchToolMessageItem)(nil)
195 _ NestedToolContainer = (*AgenticFetchToolMessageItem)(nil)
196)
197
198// NewAgenticFetchToolMessageItem creates a new [AgenticFetchToolMessageItem].
199func NewAgenticFetchToolMessageItem(
200 sty *styles.Styles,
201 toolCall message.ToolCall,
202 result *message.ToolResult,
203 canceled bool,
204) *AgenticFetchToolMessageItem {
205 t := &AgenticFetchToolMessageItem{}
206 t.baseToolMessageItem = newBaseToolMessageItem(sty, toolCall, result, &AgenticFetchToolRenderContext{fetch: t}, canceled)
207 // For the agentic fetch tool we keep spinning until the tool call is finished.
208 t.spinningFunc = func(state SpinningState) bool {
209 return !state.HasResult() && !state.IsCanceled()
210 }
211 return t
212}
213
214// NestedTools returns the nested tools.
215func (a *AgenticFetchToolMessageItem) NestedTools() []ToolMessageItem {
216 return a.nestedTools
217}
218
219// SetNestedTools sets the nested tools. Always bumps the version;
220// see [AgentToolMessageItem.SetNestedTools] for the rationale.
221func (a *AgenticFetchToolMessageItem) SetNestedTools(tools []ToolMessageItem) {
222 a.nestedTools = tools
223 a.clearCache()
224 a.Bump()
225}
226
227// AddNestedTool adds a nested tool.
228func (a *AgenticFetchToolMessageItem) AddNestedTool(tool ToolMessageItem) {
229 // Mark nested tools as simple (compact) rendering.
230 if s, ok := tool.(Compactable); ok {
231 s.SetCompact(true)
232 }
233 a.nestedTools = append(a.nestedTools, tool)
234 a.clearCache()
235 a.Bump()
236}
237
238// AgenticFetchToolRenderContext renders agentic fetch tool messages.
239type AgenticFetchToolRenderContext struct {
240 fetch *AgenticFetchToolMessageItem
241}
242
243// agenticFetchParams matches tools.AgenticFetchParams.
244type agenticFetchParams struct {
245 URL string `json:"url,omitempty"`
246 Prompt string `json:"prompt"`
247}
248
249// RenderTool implements the [ToolRenderer] interface.
250func (r *AgenticFetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
251 cappedWidth := cappedMessageWidth(width)
252 if !opts.ToolCall.Finished && !opts.IsCanceled() && len(r.fetch.nestedTools) == 0 {
253 return pendingTool(sty, "Agentic Fetch", opts.Anim, opts.Compact)
254 }
255
256 var params agenticFetchParams
257 _ = json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms)
258
259 prompt := params.Prompt
260 prompt = strings.ReplaceAll(prompt, "\n", " ")
261
262 // Build header with optional URL param.
263 var toolParams []string
264 if params.URL != "" {
265 toolParams = append(toolParams, params.URL)
266 }
267
268 header := toolHeader(sty, opts.Status, "Agentic Fetch", cappedWidth, opts.Compact, toolParams...)
269 if opts.Compact {
270 return header
271 }
272
273 // Build the prompt tag.
274 promptTag := sty.Tool.AgenticFetchPromptTag.Render("Prompt")
275 promptTagWidth := lipgloss.Width(promptTag)
276
277 // Calculate remaining width for prompt text.
278 remainingWidth := min(cappedWidth-promptTagWidth-3, maxTextWidth-promptTagWidth-3) // -3 for spacing
279
280 promptText := sty.Tool.AgentPrompt.Width(remainingWidth).Render(prompt)
281
282 header = lipgloss.JoinVertical(
283 lipgloss.Left,
284 header,
285 "",
286 lipgloss.JoinHorizontal(
287 lipgloss.Left,
288 promptTag,
289 " ",
290 promptText,
291 ),
292 )
293
294 // Build tree with nested tool calls.
295 childTools := tree.Root(header)
296
297 for _, nestedTool := range r.fetch.nestedTools {
298 childView := nestedTool.Render(remainingWidth)
299 childTools.Child(childView)
300 }
301
302 // Build parts.
303 var parts []string
304 parts = append(parts, childTools.Enumerator(roundedEnumerator(2, promptTagWidth-5)).String())
305
306 // Show animation if still running.
307 if !opts.HasResult() && !opts.IsCanceled() {
308 parts = append(parts, "", opts.Anim.Render())
309 }
310
311 result := lipgloss.JoinVertical(lipgloss.Left, parts...)
312
313 // Add body content when completed.
314 if opts.HasResult() && opts.Result.Content != "" {
315 body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth-toolBodyLeftPaddingTotal, opts.ExpandedContent)
316 return joinToolParts(result, body)
317 }
318
319 return result
320}