agent.go

  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), &params)
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), &params)
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}