1package messages
2
3import (
4 "fmt"
5
6 "github.com/charmbracelet/bubbles/v2/spinner"
7 tea "github.com/charmbracelet/bubbletea/v2"
8 "github.com/charmbracelet/lipgloss/v2"
9 "github.com/charmbracelet/x/ansi"
10 "github.com/opencode-ai/opencode/internal/message"
11 "github.com/opencode-ai/opencode/internal/tui/components/anim"
12 "github.com/opencode-ai/opencode/internal/tui/layout"
13 "github.com/opencode-ai/opencode/internal/tui/styles"
14 "github.com/opencode-ai/opencode/internal/tui/util"
15)
16
17// ToolCallCmp defines the interface for tool call components in the chat interface.
18// It manages the display of tool execution including pending states, results, and errors.
19type ToolCallCmp interface {
20 util.Model // Basic Bubble Tea model interface
21 layout.Sizeable // Width/height management
22 layout.Focusable // Focus state management
23 GetToolCall() message.ToolCall // Access to tool call data
24 GetToolResult() message.ToolResult // Access to tool result data
25 SetToolResult(message.ToolResult) // Update tool result
26 SetToolCall(message.ToolCall) // Update tool call
27 SetCancelled() // Mark as cancelled
28 ParentMessageId() string // Get parent message ID
29 Spinning() bool // Animation state for pending tools
30 GetNestedToolCalls() []ToolCallCmp // Get nested tool calls
31 SetNestedToolCalls([]ToolCallCmp) // Set nested tool calls
32 SetIsNested(bool) // Set whether this tool call is nested
33}
34
35// toolCallCmp implements the ToolCallCmp interface for displaying tool calls.
36// It handles rendering of tool execution states including pending, completed, and error states.
37type toolCallCmp struct {
38 width int // Component width for text wrapping
39 focused bool // Focus state for border styling
40 isNested bool // Whether this tool call is nested within another
41
42 // Tool call data and state
43 parentMessageId string // ID of the message that initiated this tool call
44 call message.ToolCall // The tool call being executed
45 result message.ToolResult // The result of the tool execution
46 cancelled bool // Whether the tool call was cancelled
47
48 // Animation state for pending tool calls
49 spinning bool // Whether to show loading animation
50 anim util.Model // Animation component for pending states
51
52 nestedToolCalls []ToolCallCmp // Nested tool calls for hierarchical display
53}
54
55// ToolCallOption provides functional options for configuring tool call components
56type ToolCallOption func(*toolCallCmp)
57
58// WithToolCallCancelled marks the tool call as cancelled
59func WithToolCallCancelled() ToolCallOption {
60 return func(m *toolCallCmp) {
61 m.cancelled = true
62 }
63}
64
65// WithToolCallResult sets the initial tool result
66func WithToolCallResult(result message.ToolResult) ToolCallOption {
67 return func(m *toolCallCmp) {
68 m.result = result
69 }
70}
71
72func WithToolCallNested(isNested bool) ToolCallOption {
73 return func(m *toolCallCmp) {
74 m.isNested = isNested
75 }
76}
77
78func WithToolCallNestedCalls(calls []ToolCallCmp) ToolCallOption {
79 return func(m *toolCallCmp) {
80 m.nestedToolCalls = calls
81 }
82}
83
84// NewToolCallCmp creates a new tool call component with the given parent message ID,
85// tool call, and optional configuration
86func NewToolCallCmp(parentMessageId string, tc message.ToolCall, opts ...ToolCallOption) ToolCallCmp {
87 m := &toolCallCmp{
88 call: tc,
89 parentMessageId: parentMessageId,
90 }
91 for _, opt := range opts {
92 opt(m)
93 }
94 m.anim = anim.New(15, "Working")
95 if m.isNested {
96 m.anim = anim.New(10, "")
97 }
98 return m
99}
100
101// Init initializes the tool call component and starts animations if needed.
102// Returns a command to start the animation for pending tool calls.
103func (m *toolCallCmp) Init() tea.Cmd {
104 m.spinning = m.shouldSpin()
105 if m.spinning {
106 return m.anim.Init()
107 }
108 return nil
109}
110
111// Update handles incoming messages and updates the component state.
112// Manages animation updates for pending tool calls.
113func (m *toolCallCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
114 switch msg := msg.(type) {
115 case anim.ColorCycleMsg, anim.StepCharsMsg, spinner.TickMsg:
116 var cmds []tea.Cmd
117 for i, nested := range m.nestedToolCalls {
118 if nested.Spinning() {
119 u, cmd := nested.Update(msg)
120 m.nestedToolCalls[i] = u.(ToolCallCmp)
121 cmds = append(cmds, cmd)
122 }
123 }
124 if m.spinning {
125 u, cmd := m.anim.Update(msg)
126 m.anim = u.(util.Model)
127 cmds = append(cmds, cmd)
128 }
129 return m, tea.Batch(cmds...)
130 }
131 return m, nil
132}
133
134// View renders the tool call component based on its current state.
135// Shows either a pending animation or the tool-specific rendered result.
136func (m *toolCallCmp) View() tea.View {
137 box := m.style()
138
139 if !m.call.Finished && !m.cancelled {
140 if m.isNested {
141 return tea.NewView(box.Render(m.renderPending()))
142 }
143 return tea.NewView(box.PaddingLeft(1).Render(m.renderPending()))
144 }
145
146 r := registry.lookup(m.call.Name)
147
148 if m.isNested {
149 return tea.NewView(box.Render(r.Render(m)))
150 }
151 return tea.NewView(box.PaddingLeft(1).Render(r.Render(m)))
152}
153
154// State management methods
155
156// SetCancelled marks the tool call as cancelled
157func (m *toolCallCmp) SetCancelled() {
158 m.cancelled = true
159}
160
161// SetToolCall updates the tool call data and stops spinning if finished
162func (m *toolCallCmp) SetToolCall(call message.ToolCall) {
163 m.call = call
164 if m.call.Finished {
165 m.spinning = false
166 }
167}
168
169// ParentMessageId returns the ID of the message that initiated this tool call
170func (m *toolCallCmp) ParentMessageId() string {
171 return m.parentMessageId
172}
173
174// SetToolResult updates the tool result and stops the spinning animation
175func (m *toolCallCmp) SetToolResult(result message.ToolResult) {
176 m.result = result
177 m.spinning = false
178}
179
180// GetToolCall returns the current tool call data
181func (m *toolCallCmp) GetToolCall() message.ToolCall {
182 return m.call
183}
184
185// GetToolResult returns the current tool result data
186func (m *toolCallCmp) GetToolResult() message.ToolResult {
187 return m.result
188}
189
190// GetNestedToolCalls returns the nested tool calls
191func (m *toolCallCmp) GetNestedToolCalls() []ToolCallCmp {
192 return m.nestedToolCalls
193}
194
195// SetNestedToolCalls sets the nested tool calls
196func (m *toolCallCmp) SetNestedToolCalls(calls []ToolCallCmp) {
197 m.nestedToolCalls = calls
198 for _, nested := range m.nestedToolCalls {
199 nested.SetSize(m.width, 0)
200 }
201}
202
203// SetIsNested sets whether this tool call is nested within another
204func (m *toolCallCmp) SetIsNested(isNested bool) {
205 m.isNested = isNested
206}
207
208// Rendering methods
209
210// renderPending displays the tool name with a loading animation for pending tool calls
211func (m *toolCallCmp) renderPending() string {
212 return fmt.Sprintf("%s: %s", prettifyToolName(m.call.Name), m.anim.View())
213}
214
215// style returns the lipgloss style for the tool call component.
216// Applies muted colors and focus-dependent border styles.
217func (m *toolCallCmp) style() lipgloss.Style {
218 t := styles.CurrentTheme()
219 if m.isNested {
220 return t.S().Muted
221 }
222 borderStyle := lipgloss.NormalBorder()
223 if m.focused {
224 borderStyle = lipgloss.DoubleBorder()
225 }
226 return t.S().Muted.
227 BorderLeft(true).
228 BorderForeground(t.Border).
229 BorderStyle(borderStyle)
230}
231
232// textWidth calculates the available width for text content,
233// accounting for borders and padding
234func (m *toolCallCmp) textWidth() int {
235 return m.width - 2 // take into account the border and PaddingLeft
236}
237
238// fit truncates content to fit within the specified width with ellipsis
239func (m *toolCallCmp) fit(content string, width int) string {
240 t := styles.CurrentTheme()
241 lineStyle := t.S().Muted.Background(t.BgSubtle)
242 dots := lineStyle.Render("...")
243 return ansi.Truncate(content, width, dots)
244}
245
246// Focus management methods
247
248// Blur removes focus from the tool call component
249func (m *toolCallCmp) Blur() tea.Cmd {
250 m.focused = false
251 return nil
252}
253
254// Focus sets focus on the tool call component
255func (m *toolCallCmp) Focus() tea.Cmd {
256 m.focused = true
257 return nil
258}
259
260// IsFocused returns whether the tool call component is currently focused
261func (m *toolCallCmp) IsFocused() bool {
262 return m.focused
263}
264
265// Size management methods
266
267// GetSize returns the current dimensions of the tool call component
268func (m *toolCallCmp) GetSize() (int, int) {
269 return m.width, 0
270}
271
272// SetSize updates the width of the tool call component for text wrapping
273func (m *toolCallCmp) SetSize(width int, height int) tea.Cmd {
274 m.width = width
275 for _, nested := range m.nestedToolCalls {
276 nested.SetSize(width, height)
277 }
278 return nil
279}
280
281// shouldSpin determines whether the tool call should show a loading animation.
282// Returns true if the tool call is not finished or if the result doesn't match the call ID.
283func (m *toolCallCmp) shouldSpin() bool {
284 if !m.call.Finished {
285 return true
286 } else if m.result.ToolCallID != m.call.ID {
287 return true
288 }
289 return false
290}
291
292// Spinning returns whether the tool call is currently showing a loading animation
293func (m *toolCallCmp) Spinning() bool {
294 if m.spinning {
295 return true
296 }
297 for _, nested := range m.nestedToolCalls {
298 if nested.Spinning() {
299 return true
300 }
301 }
302 return m.spinning
303}