1package messages
2
3import (
4 "encoding/json"
5 "fmt"
6 "path/filepath"
7 "strings"
8 "time"
9
10 "github.com/atotto/clipboard"
11 "github.com/charmbracelet/bubbles/v2/key"
12 tea "github.com/charmbracelet/bubbletea/v2"
13 "github.com/charmbracelet/crush/internal/diff"
14 "github.com/charmbracelet/crush/internal/fsext"
15 "github.com/charmbracelet/crush/internal/llm/agent"
16 "github.com/charmbracelet/crush/internal/llm/tools"
17 "github.com/charmbracelet/crush/internal/message"
18 "github.com/charmbracelet/crush/internal/permission"
19 "github.com/charmbracelet/crush/internal/tui/components/anim"
20 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
21 "github.com/charmbracelet/crush/internal/tui/styles"
22 "github.com/charmbracelet/crush/internal/tui/util"
23 "github.com/charmbracelet/lipgloss/v2"
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 Tea 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) (tea.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.(util.Model)
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 err := clipboard.WriteAll(content)
202 if err != nil {
203 return util.ReportError(fmt.Errorf("failed to copy tool content to clipboard: %w", err))
204 }
205 return util.ReportInfo("Tool content copied to clipboard")
206}
207
208func (m *toolCallCmp) formatToolForCopy() string {
209 var parts []string
210
211 toolName := prettifyToolName(m.call.Name)
212 parts = append(parts, fmt.Sprintf("## %s Tool Call", toolName))
213
214 if m.call.Input != "" {
215 params := m.formatParametersForCopy()
216 if params != "" {
217 parts = append(parts, "### Parameters:")
218 parts = append(parts, params)
219 }
220 }
221
222 if m.result.ToolCallID != "" {
223 if m.result.IsError {
224 parts = append(parts, "### Error:")
225 parts = append(parts, m.result.Content)
226 } else {
227 parts = append(parts, "### Result:")
228 content := m.formatResultForCopy()
229 if content != "" {
230 parts = append(parts, content)
231 }
232 }
233 } else if m.cancelled {
234 parts = append(parts, "### Status:")
235 parts = append(parts, "Cancelled")
236 } else {
237 parts = append(parts, "### Status:")
238 parts = append(parts, "Pending...")
239 }
240
241 return strings.Join(parts, "\n\n")
242}
243
244func (m *toolCallCmp) formatParametersForCopy() string {
245 switch m.call.Name {
246 case tools.BashToolName:
247 var params tools.BashParams
248 if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil {
249 cmd := strings.ReplaceAll(params.Command, "\n", " ")
250 cmd = strings.ReplaceAll(cmd, "\t", " ")
251 return fmt.Sprintf("**Command:** %s", cmd)
252 }
253 case tools.ViewToolName:
254 var params tools.ViewParams
255 if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil {
256 var parts []string
257 parts = append(parts, fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)))
258 if params.Limit > 0 {
259 parts = append(parts, fmt.Sprintf("**Limit:** %d", params.Limit))
260 }
261 if params.Offset > 0 {
262 parts = append(parts, fmt.Sprintf("**Offset:** %d", params.Offset))
263 }
264 return strings.Join(parts, "\n")
265 }
266 case tools.EditToolName:
267 var params tools.EditParams
268 if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil {
269 return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath))
270 }
271 case tools.MultiEditToolName:
272 var params tools.MultiEditParams
273 if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil {
274 var parts []string
275 parts = append(parts, fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)))
276 parts = append(parts, fmt.Sprintf("**Edits:** %d", len(params.Edits)))
277 return strings.Join(parts, "\n")
278 }
279 case tools.WriteToolName:
280 var params tools.WriteParams
281 if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil {
282 return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath))
283 }
284 case tools.FetchToolName:
285 var params tools.FetchParams
286 if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil {
287 var parts []string
288 parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
289 if params.Format != "" {
290 parts = append(parts, fmt.Sprintf("**Format:** %s", params.Format))
291 }
292 if params.Timeout > 0 {
293 parts = append(parts, fmt.Sprintf("**Timeout:** %s", (time.Duration(params.Timeout)*time.Second).String()))
294 }
295 return strings.Join(parts, "\n")
296 }
297 case tools.GrepToolName:
298 var params tools.GrepParams
299 if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil {
300 var parts []string
301 parts = append(parts, fmt.Sprintf("**Pattern:** %s", params.Pattern))
302 if params.Path != "" {
303 parts = append(parts, fmt.Sprintf("**Path:** %s", params.Path))
304 }
305 if params.Include != "" {
306 parts = append(parts, fmt.Sprintf("**Include:** %s", params.Include))
307 }
308 if params.LiteralText {
309 parts = append(parts, "**Literal:** true")
310 }
311 return strings.Join(parts, "\n")
312 }
313 case tools.GlobToolName:
314 var params tools.GlobParams
315 if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil {
316 var parts []string
317 parts = append(parts, fmt.Sprintf("**Pattern:** %s", params.Pattern))
318 if params.Path != "" {
319 parts = append(parts, fmt.Sprintf("**Path:** %s", params.Path))
320 }
321 return strings.Join(parts, "\n")
322 }
323 case tools.LSToolName:
324 var params tools.LSParams
325 if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil {
326 path := params.Path
327 if path == "" {
328 path = "."
329 }
330 return fmt.Sprintf("**Path:** %s", fsext.PrettyPath(path))
331 }
332 case tools.DownloadToolName:
333 var params tools.DownloadParams
334 if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil {
335 var parts []string
336 parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
337 parts = append(parts, fmt.Sprintf("**File Path:** %s", fsext.PrettyPath(params.FilePath)))
338 if params.Timeout > 0 {
339 parts = append(parts, fmt.Sprintf("**Timeout:** %s", (time.Duration(params.Timeout)*time.Second).String()))
340 }
341 return strings.Join(parts, "\n")
342 }
343 case tools.SourcegraphToolName:
344 var params tools.SourcegraphParams
345 if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil {
346 var parts []string
347 parts = append(parts, fmt.Sprintf("**Query:** %s", params.Query))
348 if params.Count > 0 {
349 parts = append(parts, fmt.Sprintf("**Count:** %d", params.Count))
350 }
351 if params.ContextWindow > 0 {
352 parts = append(parts, fmt.Sprintf("**Context:** %d", params.ContextWindow))
353 }
354 return strings.Join(parts, "\n")
355 }
356 case tools.DiagnosticsToolName:
357 return "**Project:** diagnostics"
358 case agent.AgentToolName:
359 var params agent.AgentParams
360 if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil {
361 return fmt.Sprintf("**Task:**\n%s", params.Prompt)
362 }
363 }
364
365 var params map[string]any
366 if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil {
367 var parts []string
368 for key, value := range params {
369 displayKey := strings.ReplaceAll(key, "_", " ")
370 if len(displayKey) > 0 {
371 displayKey = strings.ToUpper(displayKey[:1]) + displayKey[1:]
372 }
373 parts = append(parts, fmt.Sprintf("**%s:** %v", displayKey, value))
374 }
375 return strings.Join(parts, "\n")
376 }
377
378 return ""
379}
380
381func (m *toolCallCmp) formatResultForCopy() string {
382 switch m.call.Name {
383 case tools.BashToolName:
384 return m.formatBashResultForCopy()
385 case tools.ViewToolName:
386 return m.formatViewResultForCopy()
387 case tools.EditToolName:
388 return m.formatEditResultForCopy()
389 case tools.MultiEditToolName:
390 return m.formatMultiEditResultForCopy()
391 case tools.WriteToolName:
392 return m.formatWriteResultForCopy()
393 case tools.FetchToolName:
394 return m.formatFetchResultForCopy()
395 case agent.AgentToolName:
396 return m.formatAgentResultForCopy()
397 case tools.DownloadToolName, tools.GrepToolName, tools.GlobToolName, tools.LSToolName, tools.SourcegraphToolName, tools.DiagnosticsToolName:
398 return fmt.Sprintf("```\n%s\n```", m.result.Content)
399 default:
400 return m.result.Content
401 }
402}
403
404func (m *toolCallCmp) formatBashResultForCopy() string {
405 var meta tools.BashResponseMetadata
406 if m.result.Metadata != "" {
407 json.Unmarshal([]byte(m.result.Metadata), &meta)
408 }
409
410 output := meta.Output
411 if output == "" && m.result.Content != tools.BashNoOutput {
412 output = m.result.Content
413 }
414
415 if output == "" {
416 return ""
417 }
418
419 return fmt.Sprintf("```bash\n%s\n```", output)
420}
421
422func (m *toolCallCmp) formatViewResultForCopy() string {
423 var meta tools.ViewResponseMetadata
424 if m.result.Metadata != "" {
425 json.Unmarshal([]byte(m.result.Metadata), &meta)
426 }
427
428 if meta.Content == "" {
429 return m.result.Content
430 }
431
432 lang := ""
433 if meta.FilePath != "" {
434 ext := strings.ToLower(filepath.Ext(meta.FilePath))
435 switch ext {
436 case ".go":
437 lang = "go"
438 case ".js", ".mjs":
439 lang = "javascript"
440 case ".ts":
441 lang = "typescript"
442 case ".py":
443 lang = "python"
444 case ".rs":
445 lang = "rust"
446 case ".java":
447 lang = "java"
448 case ".c":
449 lang = "c"
450 case ".cpp", ".cc", ".cxx":
451 lang = "cpp"
452 case ".sh", ".bash":
453 lang = "bash"
454 case ".json":
455 lang = "json"
456 case ".yaml", ".yml":
457 lang = "yaml"
458 case ".xml":
459 lang = "xml"
460 case ".html":
461 lang = "html"
462 case ".css":
463 lang = "css"
464 case ".md":
465 lang = "markdown"
466 }
467 }
468
469 var result strings.Builder
470 if lang != "" {
471 result.WriteString(fmt.Sprintf("```%s\n", lang))
472 } else {
473 result.WriteString("```\n")
474 }
475 result.WriteString(meta.Content)
476 result.WriteString("\n```")
477
478 return result.String()
479}
480
481func (m *toolCallCmp) formatEditResultForCopy() string {
482 var meta tools.EditResponseMetadata
483 if m.result.Metadata == "" {
484 return m.result.Content
485 }
486
487 if json.Unmarshal([]byte(m.result.Metadata), &meta) != nil {
488 return m.result.Content
489 }
490
491 var params tools.EditParams
492 json.Unmarshal([]byte(m.call.Input), ¶ms)
493
494 var result strings.Builder
495
496 if meta.OldContent != "" || meta.NewContent != "" {
497 fileName := params.FilePath
498 if fileName != "" {
499 fileName = fsext.PrettyPath(fileName)
500 }
501 diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName)
502
503 result.WriteString(fmt.Sprintf("Changes: +%d -%d\n", additions, removals))
504 result.WriteString("```diff\n")
505 result.WriteString(diffContent)
506 result.WriteString("\n```")
507 }
508
509 return result.String()
510}
511
512func (m *toolCallCmp) formatMultiEditResultForCopy() string {
513 var meta tools.MultiEditResponseMetadata
514 if m.result.Metadata == "" {
515 return m.result.Content
516 }
517
518 if json.Unmarshal([]byte(m.result.Metadata), &meta) != nil {
519 return m.result.Content
520 }
521
522 var params tools.MultiEditParams
523 json.Unmarshal([]byte(m.call.Input), ¶ms)
524
525 var result strings.Builder
526 if meta.OldContent != "" || meta.NewContent != "" {
527 fileName := params.FilePath
528 if fileName != "" {
529 fileName = fsext.PrettyPath(fileName)
530 }
531 diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName)
532
533 result.WriteString(fmt.Sprintf("Changes: +%d -%d\n", additions, removals))
534 result.WriteString("```diff\n")
535 result.WriteString(diffContent)
536 result.WriteString("\n```")
537 }
538
539 return result.String()
540}
541
542func (m *toolCallCmp) formatWriteResultForCopy() string {
543 var params tools.WriteParams
544 if json.Unmarshal([]byte(m.call.Input), ¶ms) != nil {
545 return m.result.Content
546 }
547
548 lang := ""
549 if params.FilePath != "" {
550 ext := strings.ToLower(filepath.Ext(params.FilePath))
551 switch ext {
552 case ".go":
553 lang = "go"
554 case ".js", ".mjs":
555 lang = "javascript"
556 case ".ts":
557 lang = "typescript"
558 case ".py":
559 lang = "python"
560 case ".rs":
561 lang = "rust"
562 case ".java":
563 lang = "java"
564 case ".c":
565 lang = "c"
566 case ".cpp", ".cc", ".cxx":
567 lang = "cpp"
568 case ".sh", ".bash":
569 lang = "bash"
570 case ".json":
571 lang = "json"
572 case ".yaml", ".yml":
573 lang = "yaml"
574 case ".xml":
575 lang = "xml"
576 case ".html":
577 lang = "html"
578 case ".css":
579 lang = "css"
580 case ".md":
581 lang = "markdown"
582 }
583 }
584
585 var result strings.Builder
586 result.WriteString(fmt.Sprintf("File: %s\n", fsext.PrettyPath(params.FilePath)))
587 if lang != "" {
588 result.WriteString(fmt.Sprintf("```%s\n", lang))
589 } else {
590 result.WriteString("```\n")
591 }
592 result.WriteString(params.Content)
593 result.WriteString("\n```")
594
595 return result.String()
596}
597
598func (m *toolCallCmp) formatFetchResultForCopy() string {
599 var params tools.FetchParams
600 if json.Unmarshal([]byte(m.call.Input), ¶ms) != nil {
601 return m.result.Content
602 }
603
604 var result strings.Builder
605 if params.URL != "" {
606 result.WriteString(fmt.Sprintf("URL: %s\n", params.URL))
607 }
608
609 switch params.Format {
610 case "html":
611 result.WriteString("```html\n")
612 case "text":
613 result.WriteString("```\n")
614 default: // markdown
615 result.WriteString("```markdown\n")
616 }
617 result.WriteString(m.result.Content)
618 result.WriteString("\n```")
619
620 return result.String()
621}
622
623func (m *toolCallCmp) formatAgentResultForCopy() string {
624 var result strings.Builder
625
626 if len(m.nestedToolCalls) > 0 {
627 result.WriteString("### Nested Tool Calls:\n")
628 for i, nestedCall := range m.nestedToolCalls {
629 nestedContent := nestedCall.(*toolCallCmp).formatToolForCopy()
630 indentedContent := strings.ReplaceAll(nestedContent, "\n", "\n ")
631 result.WriteString(fmt.Sprintf("%d. %s\n", i+1, indentedContent))
632 if i < len(m.nestedToolCalls)-1 {
633 result.WriteString("\n")
634 }
635 }
636
637 if m.result.Content != "" {
638 result.WriteString("\n### Final Result:\n")
639 }
640 }
641
642 if m.result.Content != "" {
643 result.WriteString(fmt.Sprintf("```markdown\n%s\n```", m.result.Content))
644 }
645
646 return result.String()
647}
648
649// SetToolCall updates the tool call data and stops spinning if finished
650func (m *toolCallCmp) SetToolCall(call message.ToolCall) {
651 m.call = call
652 if m.call.Finished {
653 m.spinning = false
654 }
655}
656
657// ParentMessageID returns the ID of the message that initiated this tool call
658func (m *toolCallCmp) ParentMessageID() string {
659 return m.parentMessageID
660}
661
662// SetToolResult updates the tool result and stops the spinning animation
663func (m *toolCallCmp) SetToolResult(result message.ToolResult) {
664 m.result = result
665 m.spinning = false
666}
667
668// GetToolCall returns the current tool call data
669func (m *toolCallCmp) GetToolCall() message.ToolCall {
670 return m.call
671}
672
673// GetToolResult returns the current tool result data
674func (m *toolCallCmp) GetToolResult() message.ToolResult {
675 return m.result
676}
677
678// GetNestedToolCalls returns the nested tool calls
679func (m *toolCallCmp) GetNestedToolCalls() []ToolCallCmp {
680 return m.nestedToolCalls
681}
682
683// SetNestedToolCalls sets the nested tool calls
684func (m *toolCallCmp) SetNestedToolCalls(calls []ToolCallCmp) {
685 m.nestedToolCalls = calls
686 for _, nested := range m.nestedToolCalls {
687 nested.SetSize(m.width, 0)
688 }
689}
690
691// SetIsNested sets whether this tool call is nested within another
692func (m *toolCallCmp) SetIsNested(isNested bool) {
693 m.isNested = isNested
694}
695
696// Rendering methods
697
698// renderPending displays the tool name with a loading animation for pending tool calls
699func (m *toolCallCmp) renderPending() string {
700 t := styles.CurrentTheme()
701 icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
702 if m.isNested {
703 tool := t.S().Base.Foreground(t.FgHalfMuted).Render(prettifyToolName(m.call.Name))
704 return fmt.Sprintf("%s %s %s", icon, tool, m.anim.View())
705 }
706 tool := t.S().Base.Foreground(t.Blue).Render(prettifyToolName(m.call.Name))
707 return fmt.Sprintf("%s %s %s", icon, tool, m.anim.View())
708}
709
710// style returns the lipgloss style for the tool call component.
711// Applies muted colors and focus-dependent border styles.
712func (m *toolCallCmp) style() lipgloss.Style {
713 t := styles.CurrentTheme()
714
715 if m.isNested {
716 return t.S().Muted
717 }
718 style := t.S().Muted.PaddingLeft(4)
719
720 if m.focused {
721 style = style.PaddingLeft(3).BorderStyle(focusedMessageBorder).BorderLeft(true).BorderForeground(t.GreenDark)
722 }
723 return style
724}
725
726// textWidth calculates the available width for text content,
727// accounting for borders and padding
728func (m *toolCallCmp) textWidth() int {
729 if m.isNested {
730 return m.width - 6
731 }
732 return m.width - 5 // take into account the border and PaddingLeft
733}
734
735// fit truncates content to fit within the specified width with ellipsis
736func (m *toolCallCmp) fit(content string, width int) string {
737 t := styles.CurrentTheme()
738 lineStyle := t.S().Muted
739 dots := lineStyle.Render("…")
740 return ansi.Truncate(content, width, dots)
741}
742
743// Focus management methods
744
745// Blur removes focus from the tool call component
746func (m *toolCallCmp) Blur() tea.Cmd {
747 m.focused = false
748 return nil
749}
750
751// Focus sets focus on the tool call component
752func (m *toolCallCmp) Focus() tea.Cmd {
753 m.focused = true
754 return nil
755}
756
757// IsFocused returns whether the tool call component is currently focused
758func (m *toolCallCmp) IsFocused() bool {
759 return m.focused
760}
761
762// Size management methods
763
764// GetSize returns the current dimensions of the tool call component
765func (m *toolCallCmp) GetSize() (int, int) {
766 return m.width, 0
767}
768
769// SetSize updates the width of the tool call component for text wrapping
770func (m *toolCallCmp) SetSize(width int, height int) tea.Cmd {
771 m.width = width
772 for _, nested := range m.nestedToolCalls {
773 nested.SetSize(width, height)
774 }
775 return nil
776}
777
778// shouldSpin determines whether the tool call should show a loading animation.
779// Returns true if the tool call is not finished or if the result doesn't match the call ID.
780func (m *toolCallCmp) shouldSpin() bool {
781 return !m.call.Finished && !m.cancelled
782}
783
784// Spinning returns whether the tool call is currently showing a loading animation
785func (m *toolCallCmp) Spinning() bool {
786 if m.spinning {
787 return true
788 }
789 for _, nested := range m.nestedToolCalls {
790 if nested.Spinning() {
791 return true
792 }
793 }
794 return m.spinning
795}
796
797func (m *toolCallCmp) ID() string {
798 return m.call.ID
799}
800
801// SetPermissionRequested marks that a permission request was made for this tool call
802func (m *toolCallCmp) SetPermissionRequested() {
803 m.permissionRequested = true
804}
805
806// SetPermissionGranted marks that permission was granted for this tool call
807func (m *toolCallCmp) SetPermissionGranted() {
808 m.permissionGranted = true
809}