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