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