tool.go

  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() string {
138	box := m.style()
139
140	if !m.call.Finished && !m.cancelled {
141		return box.PaddingLeft(1).Render(m.renderPending())
142	}
143
144	r := registry.lookup(m.call.Name)
145
146	if m.isNested {
147		return box.Render(
148			lipgloss.JoinHorizontal(lipgloss.Left,
149				" └ ",
150				r.Render(m),
151			),
152		)
153	}
154	return box.PaddingLeft(1).Render(r.Render(m))
155}
156
157// State management methods
158
159// SetCancelled marks the tool call as cancelled
160func (m *toolCallCmp) SetCancelled() {
161	m.cancelled = true
162}
163
164// SetToolCall updates the tool call data and stops spinning if finished
165func (m *toolCallCmp) SetToolCall(call message.ToolCall) {
166	m.call = call
167	if m.call.Finished {
168		m.spinning = false
169	}
170}
171
172// ParentMessageId returns the ID of the message that initiated this tool call
173func (m *toolCallCmp) ParentMessageId() string {
174	return m.parentMessageId
175}
176
177// SetToolResult updates the tool result and stops the spinning animation
178func (m *toolCallCmp) SetToolResult(result message.ToolResult) {
179	m.result = result
180	m.spinning = false
181}
182
183// GetToolCall returns the current tool call data
184func (m *toolCallCmp) GetToolCall() message.ToolCall {
185	return m.call
186}
187
188// GetToolResult returns the current tool result data
189func (m *toolCallCmp) GetToolResult() message.ToolResult {
190	return m.result
191}
192
193// GetNestedToolCalls returns the nested tool calls
194func (m *toolCallCmp) GetNestedToolCalls() []ToolCallCmp {
195	return m.nestedToolCalls
196}
197
198// SetNestedToolCalls sets the nested tool calls
199func (m *toolCallCmp) SetNestedToolCalls(calls []ToolCallCmp) {
200	m.nestedToolCalls = calls
201	for _, nested := range m.nestedToolCalls {
202		nested.SetSize(m.width, 0)
203	}
204}
205
206// SetIsNested sets whether this tool call is nested within another
207func (m *toolCallCmp) SetIsNested(isNested bool) {
208	m.isNested = isNested
209}
210
211// Rendering methods
212
213// renderPending displays the tool name with a loading animation for pending tool calls
214func (m *toolCallCmp) renderPending() string {
215	if m.isNested {
216		return fmt.Sprintf("└ %s: %s", prettifyToolName(m.call.Name), m.anim.View())
217	}
218	return fmt.Sprintf("%s: %s", prettifyToolName(m.call.Name), m.anim.View())
219}
220
221// style returns the lipgloss style for the tool call component.
222// Applies muted colors and focus-dependent border styles.
223func (m *toolCallCmp) style() lipgloss.Style {
224	t := theme.CurrentTheme()
225	if m.isNested {
226		return styles.BaseStyle().
227			Foreground(t.TextMuted())
228	}
229	borderStyle := lipgloss.NormalBorder()
230	if m.focused {
231		borderStyle = lipgloss.DoubleBorder()
232	}
233	return styles.BaseStyle().
234		BorderLeft(true).
235		Foreground(t.TextMuted()).
236		BorderForeground(t.TextMuted()).
237		BorderStyle(borderStyle)
238}
239
240// textWidth calculates the available width for text content,
241// accounting for borders and padding
242func (m *toolCallCmp) textWidth() int {
243	return m.width - 2 // take into account the border and PaddingLeft
244}
245
246// fit truncates content to fit within the specified width with ellipsis
247func (m *toolCallCmp) fit(content string, width int) string {
248	t := theme.CurrentTheme()
249	lineStyle := lipgloss.NewStyle().Background(t.BackgroundSecondary()).Foreground(t.TextMuted())
250	dots := lineStyle.Render("...")
251	return ansi.Truncate(content, width, dots)
252}
253
254// Focus management methods
255
256// Blur removes focus from the tool call component
257func (m *toolCallCmp) Blur() tea.Cmd {
258	m.focused = false
259	return nil
260}
261
262// Focus sets focus on the tool call component
263func (m *toolCallCmp) Focus() tea.Cmd {
264	m.focused = true
265	return nil
266}
267
268// IsFocused returns whether the tool call component is currently focused
269func (m *toolCallCmp) IsFocused() bool {
270	return m.focused
271}
272
273// Size management methods
274
275// GetSize returns the current dimensions of the tool call component
276func (m *toolCallCmp) GetSize() (int, int) {
277	return m.width, 0
278}
279
280// SetSize updates the width of the tool call component for text wrapping
281func (m *toolCallCmp) SetSize(width int, height int) tea.Cmd {
282	m.width = width
283	for _, nested := range m.nestedToolCalls {
284		nested.SetSize(width, height)
285	}
286	return nil
287}
288
289// shouldSpin determines whether the tool call should show a loading animation.
290// Returns true if the tool call is not finished or if the result doesn't match the call ID.
291func (m *toolCallCmp) shouldSpin() bool {
292	if !m.call.Finished {
293		return true
294	} else if m.result.ToolCallID != m.call.ID {
295		return true
296	}
297	return false
298}
299
300// Spinning returns whether the tool call is currently showing a loading animation
301func (m *toolCallCmp) Spinning() bool {
302	if m.spinning {
303		return true
304	}
305	for _, nested := range m.nestedToolCalls {
306		if nested.Spinning() {
307			return true
308		}
309	}
310	return m.spinning
311}