tool.go

  1package messages
  2
  3import (
  4	"encoding/json"
  5	"fmt"
  6	"path/filepath"
  7	"strings"
  8	"time"
  9
 10	"charm.land/bubbles/v2/key"
 11	tea "charm.land/bubbletea/v2"
 12	"charm.land/lipgloss/v2"
 13	"github.com/atotto/clipboard"
 14	"github.com/charmbracelet/crush/internal/agent"
 15	"github.com/charmbracelet/crush/internal/agent/tools"
 16	"github.com/charmbracelet/crush/internal/diff"
 17	"github.com/charmbracelet/crush/internal/fsext"
 18	"github.com/charmbracelet/crush/internal/message"
 19	"github.com/charmbracelet/crush/internal/permission"
 20	"github.com/charmbracelet/crush/internal/tui/components/anim"
 21	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
 22	"github.com/charmbracelet/crush/internal/tui/styles"
 23	"github.com/charmbracelet/crush/internal/tui/util"
 24	"github.com/charmbracelet/x/ansi"
 25)
 26
 27// ToolCallCmp defines the interface for tool call components in the chat interface.
 28// It manages the display of tool execution including pending states, results, and errors.
 29type ToolCallCmp interface {
 30	util.Model                         // Basic Bubble util.Model interface
 31	layout.Sizeable                    // Width/height management
 32	layout.Focusable                   // Focus state management
 33	GetToolCall() message.ToolCall     // Access to tool call data
 34	GetToolResult() message.ToolResult // Access to tool result data
 35	SetToolResult(message.ToolResult)  // Update tool result
 36	SetToolCall(message.ToolCall)      // Update tool call
 37	SetCancelled()                     // Mark as cancelled
 38	ParentMessageID() string           // Get parent message ID
 39	Spinning() bool                    // Animation state for pending tools
 40	GetNestedToolCalls() []ToolCallCmp // Get nested tool calls
 41	SetNestedToolCalls([]ToolCallCmp)  // Set nested tool calls
 42	SetIsNested(bool)                  // Set whether this tool call is nested
 43	ID() string
 44	SetPermissionRequested() // Mark permission request
 45	SetPermissionGranted()   // Mark permission granted
 46}
 47
 48// toolCallCmp implements the ToolCallCmp interface for displaying tool calls.
 49// It handles rendering of tool execution states including pending, completed, and error states.
 50type toolCallCmp struct {
 51	width    int  // Component width for text wrapping
 52	focused  bool // Focus state for border styling
 53	isNested bool // Whether this tool call is nested within another
 54
 55	// Tool call data and state
 56	parentMessageID     string             // ID of the message that initiated this tool call
 57	call                message.ToolCall   // The tool call being executed
 58	result              message.ToolResult // The result of the tool execution
 59	cancelled           bool               // Whether the tool call was cancelled
 60	permissionRequested bool
 61	permissionGranted   bool
 62
 63	// Animation state for pending tool calls
 64	spinning bool       // Whether to show loading animation
 65	anim     util.Model // Animation component for pending states
 66
 67	nestedToolCalls []ToolCallCmp // Nested tool calls for hierarchical display
 68}
 69
 70// ToolCallOption provides functional options for configuring tool call components
 71type ToolCallOption func(*toolCallCmp)
 72
 73// WithToolCallCancelled marks the tool call as cancelled
 74func WithToolCallCancelled() ToolCallOption {
 75	return func(m *toolCallCmp) {
 76		m.cancelled = true
 77	}
 78}
 79
 80// WithToolCallResult sets the initial tool result
 81func WithToolCallResult(result message.ToolResult) ToolCallOption {
 82	return func(m *toolCallCmp) {
 83		m.result = result
 84	}
 85}
 86
 87func WithToolCallNested(isNested bool) ToolCallOption {
 88	return func(m *toolCallCmp) {
 89		m.isNested = isNested
 90	}
 91}
 92
 93func WithToolCallNestedCalls(calls []ToolCallCmp) ToolCallOption {
 94	return func(m *toolCallCmp) {
 95		m.nestedToolCalls = calls
 96	}
 97}
 98
 99func WithToolPermissionRequested() ToolCallOption {
100	return func(m *toolCallCmp) {
101		m.permissionRequested = true
102	}
103}
104
105func WithToolPermissionGranted() ToolCallOption {
106	return func(m *toolCallCmp) {
107		m.permissionGranted = true
108	}
109}
110
111// NewToolCallCmp creates a new tool call component with the given parent message ID,
112// tool call, and optional configuration
113func NewToolCallCmp(parentMessageID string, tc message.ToolCall, permissions permission.Service, opts ...ToolCallOption) ToolCallCmp {
114	m := &toolCallCmp{
115		call:            tc,
116		parentMessageID: parentMessageID,
117	}
118	for _, opt := range opts {
119		opt(m)
120	}
121	t := styles.CurrentTheme()
122	m.anim = anim.New(anim.Settings{
123		Size:        15,
124		Label:       "Working",
125		GradColorA:  t.Primary,
126		GradColorB:  t.Secondary,
127		LabelColor:  t.FgBase,
128		CycleColors: true,
129	})
130	if m.isNested {
131		m.anim = anim.New(anim.Settings{
132			Size:        10,
133			GradColorA:  t.Primary,
134			GradColorB:  t.Secondary,
135			CycleColors: true,
136		})
137	}
138	return m
139}
140
141// Init initializes the tool call component and starts animations if needed.
142// Returns a command to start the animation for pending tool calls.
143func (m *toolCallCmp) Init() tea.Cmd {
144	m.spinning = m.shouldSpin()
145	return m.anim.Init()
146}
147
148// Update handles incoming messages and updates the component state.
149// Manages animation updates for pending tool calls.
150func (m *toolCallCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
151	switch msg := msg.(type) {
152	case anim.StepMsg:
153		var cmds []tea.Cmd
154		for i, nested := range m.nestedToolCalls {
155			if nested.Spinning() {
156				u, cmd := nested.Update(msg)
157				m.nestedToolCalls[i] = u.(ToolCallCmp)
158				cmds = append(cmds, cmd)
159			}
160		}
161		if m.spinning {
162			u, cmd := m.anim.Update(msg)
163			m.anim = u
164			cmds = append(cmds, cmd)
165		}
166		return m, tea.Batch(cmds...)
167	case tea.KeyPressMsg:
168		if key.Matches(msg, CopyKey) {
169			return m, m.copyTool()
170		}
171	}
172	return m, nil
173}
174
175// View renders the tool call component based on its current state.
176// Shows either a pending animation or the tool-specific rendered result.
177func (m *toolCallCmp) View() string {
178	box := m.style()
179
180	if !m.call.Finished && !m.cancelled {
181		return box.Render(m.renderPending())
182	}
183
184	r := registry.lookup(m.call.Name)
185
186	if m.isNested {
187		return box.Render(r.Render(m))
188	}
189	return box.Render(r.Render(m))
190}
191
192// State management methods
193
194// SetCancelled marks the tool call as cancelled
195func (m *toolCallCmp) SetCancelled() {
196	m.cancelled = true
197}
198
199func (m *toolCallCmp) copyTool() tea.Cmd {
200	content := m.formatToolForCopy()
201	return tea.Sequence(
202		tea.SetClipboard(content),
203		func() tea.Msg {
204			_ = clipboard.WriteAll(content)
205			return nil
206		},
207		util.ReportInfo("Tool content copied to clipboard"),
208	)
209}
210
211func (m *toolCallCmp) formatToolForCopy() string {
212	var parts []string
213
214	toolName := prettifyToolName(m.call.Name)
215	parts = append(parts, fmt.Sprintf("## %s Tool Call", toolName))
216
217	if m.call.Input != "" {
218		params := m.formatParametersForCopy()
219		if params != "" {
220			parts = append(parts, "### Parameters:")
221			parts = append(parts, params)
222		}
223	}
224
225	if m.result.ToolCallID != "" {
226		if m.result.IsError {
227			parts = append(parts, "### Error:")
228			parts = append(parts, m.result.Content)
229		} else {
230			parts = append(parts, "### Result:")
231			content := m.formatResultForCopy()
232			if content != "" {
233				parts = append(parts, content)
234			}
235		}
236	} else if m.cancelled {
237		parts = append(parts, "### Status:")
238		parts = append(parts, "Cancelled")
239	} else {
240		parts = append(parts, "### Status:")
241		parts = append(parts, "Pending...")
242	}
243
244	return strings.Join(parts, "\n\n")
245}
246
247func (m *toolCallCmp) formatParametersForCopy() string {
248	switch m.call.Name {
249	case tools.BashToolName:
250		var params tools.BashParams
251		if json.Unmarshal([]byte(m.call.Input), &params) == nil {
252			cmd := strings.ReplaceAll(params.Command, "\n", " ")
253			cmd = strings.ReplaceAll(cmd, "\t", "    ")
254			return fmt.Sprintf("**Command:** %s", cmd)
255		}
256	case tools.ViewToolName:
257		var params tools.ViewParams
258		if json.Unmarshal([]byte(m.call.Input), &params) == nil {
259			var parts []string
260			parts = append(parts, fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)))
261			if params.Limit > 0 {
262				parts = append(parts, fmt.Sprintf("**Limit:** %d", params.Limit))
263			}
264			if params.Offset > 0 {
265				parts = append(parts, fmt.Sprintf("**Offset:** %d", params.Offset))
266			}
267			return strings.Join(parts, "\n")
268		}
269	case tools.EditToolName:
270		var params tools.EditParams
271		if json.Unmarshal([]byte(m.call.Input), &params) == nil {
272			return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath))
273		}
274	case tools.MultiEditToolName:
275		var params tools.MultiEditParams
276		if json.Unmarshal([]byte(m.call.Input), &params) == nil {
277			var parts []string
278			parts = append(parts, fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)))
279			parts = append(parts, fmt.Sprintf("**Edits:** %d", len(params.Edits)))
280			return strings.Join(parts, "\n")
281		}
282	case tools.WriteToolName:
283		var params tools.WriteParams
284		if json.Unmarshal([]byte(m.call.Input), &params) == nil {
285			return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath))
286		}
287	case tools.FetchToolName:
288		var params tools.FetchParams
289		if json.Unmarshal([]byte(m.call.Input), &params) == nil {
290			var parts []string
291			parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
292			if params.Format != "" {
293				parts = append(parts, fmt.Sprintf("**Format:** %s", params.Format))
294			}
295			if params.Timeout > 0 {
296				parts = append(parts, fmt.Sprintf("**Timeout:** %ds", params.Timeout))
297			}
298			return strings.Join(parts, "\n")
299		}
300	case tools.AgenticFetchToolName:
301		var params tools.AgenticFetchParams
302		if json.Unmarshal([]byte(m.call.Input), &params) == nil {
303			var parts []string
304			if params.URL != "" {
305				parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
306			}
307			if params.Prompt != "" {
308				parts = append(parts, fmt.Sprintf("**Prompt:** %s", params.Prompt))
309			}
310			return strings.Join(parts, "\n")
311		}
312	case tools.MemorySearchToolName:
313		var params tools.MemorySearchParams
314		if json.Unmarshal([]byte(m.call.Input), &params) == nil {
315			return fmt.Sprintf("**Query:** %s", params.Query)
316		}
317	case tools.WebFetchToolName:
318		var params tools.WebFetchParams
319		if json.Unmarshal([]byte(m.call.Input), &params) == nil {
320			return fmt.Sprintf("**URL:** %s", params.URL)
321		}
322	case tools.GrepToolName:
323		var params tools.GrepParams
324		if json.Unmarshal([]byte(m.call.Input), &params) == nil {
325			var parts []string
326			parts = append(parts, fmt.Sprintf("**Pattern:** %s", params.Pattern))
327			if params.Path != "" {
328				parts = append(parts, fmt.Sprintf("**Path:** %s", params.Path))
329			}
330			if params.Include != "" {
331				parts = append(parts, fmt.Sprintf("**Include:** %s", params.Include))
332			}
333			if params.LiteralText {
334				parts = append(parts, "**Literal:** true")
335			}
336			return strings.Join(parts, "\n")
337		}
338	case tools.GlobToolName:
339		var params tools.GlobParams
340		if json.Unmarshal([]byte(m.call.Input), &params) == nil {
341			var parts []string
342			parts = append(parts, fmt.Sprintf("**Pattern:** %s", params.Pattern))
343			if params.Path != "" {
344				parts = append(parts, fmt.Sprintf("**Path:** %s", params.Path))
345			}
346			return strings.Join(parts, "\n")
347		}
348	case tools.LSToolName:
349		var params tools.LSParams
350		if json.Unmarshal([]byte(m.call.Input), &params) == nil {
351			path := params.Path
352			if path == "" {
353				path = "."
354			}
355			return fmt.Sprintf("**Path:** %s", fsext.PrettyPath(path))
356		}
357	case tools.DownloadToolName:
358		var params tools.DownloadParams
359		if json.Unmarshal([]byte(m.call.Input), &params) == nil {
360			var parts []string
361			parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
362			parts = append(parts, fmt.Sprintf("**File Path:** %s", fsext.PrettyPath(params.FilePath)))
363			if params.Timeout > 0 {
364				parts = append(parts, fmt.Sprintf("**Timeout:** %s", (time.Duration(params.Timeout)*time.Second).String()))
365			}
366			return strings.Join(parts, "\n")
367		}
368	case tools.SourcegraphToolName:
369		var params tools.SourcegraphParams
370		if json.Unmarshal([]byte(m.call.Input), &params) == nil {
371			var parts []string
372			parts = append(parts, fmt.Sprintf("**Query:** %s", params.Query))
373			if params.Count > 0 {
374				parts = append(parts, fmt.Sprintf("**Count:** %d", params.Count))
375			}
376			if params.ContextWindow > 0 {
377				parts = append(parts, fmt.Sprintf("**Context:** %d", params.ContextWindow))
378			}
379			return strings.Join(parts, "\n")
380		}
381	case tools.DiagnosticsToolName:
382		return "**Project:** diagnostics"
383	case agent.AgentToolName:
384		var params agent.AgentParams
385		if json.Unmarshal([]byte(m.call.Input), &params) == nil {
386			return fmt.Sprintf("**Task:**\n%s", params.Prompt)
387		}
388	}
389
390	var params map[string]any
391	if json.Unmarshal([]byte(m.call.Input), &params) == nil {
392		var parts []string
393		for key, value := range params {
394			displayKey := strings.ReplaceAll(key, "_", " ")
395			if len(displayKey) > 0 {
396				displayKey = strings.ToUpper(displayKey[:1]) + displayKey[1:]
397			}
398			parts = append(parts, fmt.Sprintf("**%s:** %v", displayKey, value))
399		}
400		return strings.Join(parts, "\n")
401	}
402
403	return ""
404}
405
406func (m *toolCallCmp) formatResultForCopy() string {
407	if m.result.Data != "" {
408		if strings.HasPrefix(m.result.MIMEType, "image/") {
409			return fmt.Sprintf("[Image: %s]", m.result.MIMEType)
410		}
411		return fmt.Sprintf("[Media: %s]", m.result.MIMEType)
412	}
413
414	switch m.call.Name {
415	case tools.BashToolName:
416		return m.formatBashResultForCopy()
417	case tools.ViewToolName:
418		return m.formatViewResultForCopy()
419	case tools.EditToolName:
420		return m.formatEditResultForCopy()
421	case tools.MultiEditToolName:
422		return m.formatMultiEditResultForCopy()
423	case tools.WriteToolName:
424		return m.formatWriteResultForCopy()
425	case tools.FetchToolName:
426		return m.formatFetchResultForCopy()
427	case tools.AgenticFetchToolName:
428		return m.formatAgenticFetchResultForCopy()
429	case tools.MemorySearchToolName:
430		return m.formatMemorySearchResultForCopy()
431	case tools.WebFetchToolName:
432		return m.formatWebFetchResultForCopy()
433	case agent.AgentToolName:
434		return m.formatAgentResultForCopy()
435	case tools.DownloadToolName, tools.GrepToolName, tools.GlobToolName, tools.LSToolName, tools.SourcegraphToolName, tools.DiagnosticsToolName, tools.TodosToolName:
436		return fmt.Sprintf("```\n%s\n```", m.result.Content)
437	default:
438		return m.result.Content
439	}
440}
441
442func (m *toolCallCmp) formatBashResultForCopy() string {
443	var meta tools.BashResponseMetadata
444	if m.result.Metadata != "" {
445		json.Unmarshal([]byte(m.result.Metadata), &meta)
446	}
447
448	output := meta.Output
449	if output == "" && m.result.Content != tools.BashNoOutput {
450		output = m.result.Content
451	}
452
453	if output == "" {
454		return ""
455	}
456
457	return fmt.Sprintf("```bash\n%s\n```", output)
458}
459
460func (m *toolCallCmp) formatViewResultForCopy() string {
461	var meta tools.ViewResponseMetadata
462	if m.result.Metadata != "" {
463		json.Unmarshal([]byte(m.result.Metadata), &meta)
464	}
465
466	if meta.Content == "" {
467		return m.result.Content
468	}
469
470	lang := ""
471	if meta.FilePath != "" {
472		ext := strings.ToLower(filepath.Ext(meta.FilePath))
473		switch ext {
474		case ".go":
475			lang = "go"
476		case ".js", ".mjs":
477			lang = "javascript"
478		case ".ts":
479			lang = "typescript"
480		case ".py":
481			lang = "python"
482		case ".rs":
483			lang = "rust"
484		case ".java":
485			lang = "java"
486		case ".c":
487			lang = "c"
488		case ".cpp", ".cc", ".cxx":
489			lang = "cpp"
490		case ".sh", ".bash":
491			lang = "bash"
492		case ".json":
493			lang = "json"
494		case ".yaml", ".yml":
495			lang = "yaml"
496		case ".xml":
497			lang = "xml"
498		case ".html":
499			lang = "html"
500		case ".css":
501			lang = "css"
502		case ".md":
503			lang = "markdown"
504		}
505	}
506
507	var result strings.Builder
508	if lang != "" {
509		result.WriteString(fmt.Sprintf("```%s\n", lang))
510	} else {
511		result.WriteString("```\n")
512	}
513	result.WriteString(meta.Content)
514	result.WriteString("\n```")
515
516	return result.String()
517}
518
519func (m *toolCallCmp) formatEditResultForCopy() string {
520	var meta tools.EditResponseMetadata
521	if m.result.Metadata == "" {
522		return m.result.Content
523	}
524
525	if json.Unmarshal([]byte(m.result.Metadata), &meta) != nil {
526		return m.result.Content
527	}
528
529	var params tools.EditParams
530	json.Unmarshal([]byte(m.call.Input), &params)
531
532	var result strings.Builder
533
534	if meta.OldContent != "" || meta.NewContent != "" {
535		fileName := params.FilePath
536		if fileName != "" {
537			fileName = fsext.PrettyPath(fileName)
538		}
539		diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName)
540
541		result.WriteString(fmt.Sprintf("Changes: +%d -%d\n", additions, removals))
542		result.WriteString("```diff\n")
543		result.WriteString(diffContent)
544		result.WriteString("\n```")
545	}
546
547	return result.String()
548}
549
550func (m *toolCallCmp) formatMultiEditResultForCopy() string {
551	var meta tools.MultiEditResponseMetadata
552	if m.result.Metadata == "" {
553		return m.result.Content
554	}
555
556	if json.Unmarshal([]byte(m.result.Metadata), &meta) != nil {
557		return m.result.Content
558	}
559
560	var params tools.MultiEditParams
561	json.Unmarshal([]byte(m.call.Input), &params)
562
563	var result strings.Builder
564	if meta.OldContent != "" || meta.NewContent != "" {
565		fileName := params.FilePath
566		if fileName != "" {
567			fileName = fsext.PrettyPath(fileName)
568		}
569		diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName)
570
571		result.WriteString(fmt.Sprintf("Changes: +%d -%d\n", additions, removals))
572		result.WriteString("```diff\n")
573		result.WriteString(diffContent)
574		result.WriteString("\n```")
575	}
576
577	return result.String()
578}
579
580func (m *toolCallCmp) formatWriteResultForCopy() string {
581	var params tools.WriteParams
582	if json.Unmarshal([]byte(m.call.Input), &params) != nil {
583		return m.result.Content
584	}
585
586	lang := ""
587	if params.FilePath != "" {
588		ext := strings.ToLower(filepath.Ext(params.FilePath))
589		switch ext {
590		case ".go":
591			lang = "go"
592		case ".js", ".mjs":
593			lang = "javascript"
594		case ".ts":
595			lang = "typescript"
596		case ".py":
597			lang = "python"
598		case ".rs":
599			lang = "rust"
600		case ".java":
601			lang = "java"
602		case ".c":
603			lang = "c"
604		case ".cpp", ".cc", ".cxx":
605			lang = "cpp"
606		case ".sh", ".bash":
607			lang = "bash"
608		case ".json":
609			lang = "json"
610		case ".yaml", ".yml":
611			lang = "yaml"
612		case ".xml":
613			lang = "xml"
614		case ".html":
615			lang = "html"
616		case ".css":
617			lang = "css"
618		case ".md":
619			lang = "markdown"
620		}
621	}
622
623	var result strings.Builder
624	result.WriteString(fmt.Sprintf("File: %s\n", fsext.PrettyPath(params.FilePath)))
625	if lang != "" {
626		result.WriteString(fmt.Sprintf("```%s\n", lang))
627	} else {
628		result.WriteString("```\n")
629	}
630	result.WriteString(params.Content)
631	result.WriteString("\n```")
632
633	return result.String()
634}
635
636func (m *toolCallCmp) formatFetchResultForCopy() string {
637	var params tools.FetchParams
638	if json.Unmarshal([]byte(m.call.Input), &params) != nil {
639		return m.result.Content
640	}
641
642	var result strings.Builder
643	if params.URL != "" {
644		result.WriteString(fmt.Sprintf("URL: %s\n", params.URL))
645	}
646	if params.Format != "" {
647		result.WriteString(fmt.Sprintf("Format: %s\n", params.Format))
648	}
649	if params.Timeout > 0 {
650		result.WriteString(fmt.Sprintf("Timeout: %ds\n", params.Timeout))
651	}
652	result.WriteString("\n")
653
654	result.WriteString(m.result.Content)
655
656	return result.String()
657}
658
659func (m *toolCallCmp) formatAgenticFetchResultForCopy() string {
660	var params tools.AgenticFetchParams
661	if json.Unmarshal([]byte(m.call.Input), &params) != nil {
662		return m.result.Content
663	}
664
665	var result strings.Builder
666	if params.URL != "" {
667		result.WriteString(fmt.Sprintf("URL: %s\n", params.URL))
668	}
669	if params.Prompt != "" {
670		result.WriteString(fmt.Sprintf("Prompt: %s\n\n", params.Prompt))
671	}
672
673	result.WriteString("```markdown\n")
674	result.WriteString(m.result.Content)
675	result.WriteString("\n```")
676
677	return result.String()
678}
679
680func (m *toolCallCmp) formatMemorySearchResultForCopy() string {
681	var params tools.MemorySearchParams
682	if json.Unmarshal([]byte(m.call.Input), &params) != nil {
683		return m.result.Content
684	}
685
686	var result strings.Builder
687	result.WriteString(fmt.Sprintf("Query: %s\n\n", params.Query))
688	result.WriteString("```markdown\n")
689	result.WriteString(m.result.Content)
690	result.WriteString("\n```")
691
692	return result.String()
693}
694
695func (m *toolCallCmp) formatWebFetchResultForCopy() string {
696	var params tools.WebFetchParams
697	if json.Unmarshal([]byte(m.call.Input), &params) != nil {
698		return m.result.Content
699	}
700
701	var result strings.Builder
702	result.WriteString(fmt.Sprintf("URL: %s\n\n", params.URL))
703	result.WriteString("```markdown\n")
704	result.WriteString(m.result.Content)
705	result.WriteString("\n```")
706
707	return result.String()
708}
709
710func (m *toolCallCmp) formatAgentResultForCopy() string {
711	var result strings.Builder
712
713	if len(m.nestedToolCalls) > 0 {
714		result.WriteString("### Nested Tool Calls:\n")
715		for i, nestedCall := range m.nestedToolCalls {
716			nestedContent := nestedCall.(*toolCallCmp).formatToolForCopy()
717			indentedContent := strings.ReplaceAll(nestedContent, "\n", "\n  ")
718			result.WriteString(fmt.Sprintf("%d. %s\n", i+1, indentedContent))
719			if i < len(m.nestedToolCalls)-1 {
720				result.WriteString("\n")
721			}
722		}
723
724		if m.result.Content != "" {
725			result.WriteString("\n### Final Result:\n")
726		}
727	}
728
729	if m.result.Content != "" {
730		result.WriteString(fmt.Sprintf("```markdown\n%s\n```", m.result.Content))
731	}
732
733	return result.String()
734}
735
736// SetToolCall updates the tool call data and stops spinning if finished
737func (m *toolCallCmp) SetToolCall(call message.ToolCall) {
738	m.call = call
739	if m.call.Finished {
740		m.spinning = false
741	}
742}
743
744// ParentMessageID returns the ID of the message that initiated this tool call
745func (m *toolCallCmp) ParentMessageID() string {
746	return m.parentMessageID
747}
748
749// SetToolResult updates the tool result and stops the spinning animation
750func (m *toolCallCmp) SetToolResult(result message.ToolResult) {
751	m.result = result
752	m.spinning = false
753}
754
755// GetToolCall returns the current tool call data
756func (m *toolCallCmp) GetToolCall() message.ToolCall {
757	return m.call
758}
759
760// GetToolResult returns the current tool result data
761func (m *toolCallCmp) GetToolResult() message.ToolResult {
762	return m.result
763}
764
765// GetNestedToolCalls returns the nested tool calls
766func (m *toolCallCmp) GetNestedToolCalls() []ToolCallCmp {
767	return m.nestedToolCalls
768}
769
770// SetNestedToolCalls sets the nested tool calls
771func (m *toolCallCmp) SetNestedToolCalls(calls []ToolCallCmp) {
772	m.nestedToolCalls = calls
773	for _, nested := range m.nestedToolCalls {
774		nested.SetSize(m.width, 0)
775	}
776}
777
778// SetIsNested sets whether this tool call is nested within another
779func (m *toolCallCmp) SetIsNested(isNested bool) {
780	m.isNested = isNested
781}
782
783// Rendering methods
784
785// renderPending displays the tool name with a loading animation for pending tool calls
786func (m *toolCallCmp) renderPending() string {
787	t := styles.CurrentTheme()
788	icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
789	if m.isNested {
790		tool := t.S().Base.Foreground(t.FgHalfMuted).Render(prettifyToolName(m.call.Name))
791		return fmt.Sprintf("%s %s %s", icon, tool, m.anim.View())
792	}
793	tool := t.S().Base.Foreground(t.Blue).Render(prettifyToolName(m.call.Name))
794	return fmt.Sprintf("%s %s %s", icon, tool, m.anim.View())
795}
796
797// style returns the lipgloss style for the tool call component.
798// Applies muted colors and focus-dependent border styles.
799func (m *toolCallCmp) style() lipgloss.Style {
800	t := styles.CurrentTheme()
801
802	if m.isNested {
803		return t.S().Muted
804	}
805	style := t.S().Muted.PaddingLeft(2)
806
807	if m.focused {
808		style = style.PaddingLeft(1).BorderStyle(focusedMessageBorder).BorderLeft(true).BorderForeground(t.GreenDark)
809	}
810	return style
811}
812
813// textWidth calculates the available width for text content,
814// accounting for borders and padding
815func (m *toolCallCmp) textWidth() int {
816	if m.isNested {
817		return m.width - 6
818	}
819	return m.width - 5 // take into account the border and PaddingLeft
820}
821
822// fit truncates content to fit within the specified width with ellipsis
823func (m *toolCallCmp) fit(content string, width int) string {
824	if lipgloss.Width(content) <= width {
825		return content
826	}
827	t := styles.CurrentTheme()
828	lineStyle := t.S().Muted
829	dots := lineStyle.Render("…")
830	return ansi.Truncate(content, width, dots)
831}
832
833// Focus management methods
834
835// Blur removes focus from the tool call component
836func (m *toolCallCmp) Blur() tea.Cmd {
837	m.focused = false
838	return nil
839}
840
841// Focus sets focus on the tool call component
842func (m *toolCallCmp) Focus() tea.Cmd {
843	m.focused = true
844	return nil
845}
846
847// IsFocused returns whether the tool call component is currently focused
848func (m *toolCallCmp) IsFocused() bool {
849	return m.focused
850}
851
852// Size management methods
853
854// GetSize returns the current dimensions of the tool call component
855func (m *toolCallCmp) GetSize() (int, int) {
856	return m.width, 0
857}
858
859// SetSize updates the width of the tool call component for text wrapping
860func (m *toolCallCmp) SetSize(width int, height int) tea.Cmd {
861	m.width = width
862	for _, nested := range m.nestedToolCalls {
863		nested.SetSize(width, height)
864	}
865	return nil
866}
867
868// shouldSpin determines whether the tool call should show a loading animation.
869// Returns true if the tool call is not finished or if the result doesn't match the call ID.
870func (m *toolCallCmp) shouldSpin() bool {
871	return !m.call.Finished && !m.cancelled
872}
873
874// Spinning returns whether the tool call is currently showing a loading animation
875func (m *toolCallCmp) Spinning() bool {
876	if m.spinning {
877		return true
878	}
879	for _, nested := range m.nestedToolCalls {
880		if nested.Spinning() {
881			return true
882		}
883	}
884	return m.spinning
885}
886
887func (m *toolCallCmp) ID() string {
888	return m.call.ID
889}
890
891// SetPermissionRequested marks that a permission request was made for this tool call
892func (m *toolCallCmp) SetPermissionRequested() {
893	m.permissionRequested = true
894}
895
896// SetPermissionGranted marks that permission was granted for this tool call
897func (m *toolCallCmp) SetPermissionGranted() {
898	m.permissionGranted = true
899}