tool.go

  1package messages
  2
  3import (
  4	"fmt"
  5
  6	tea "github.com/charmbracelet/bubbletea/v2"
  7	"github.com/charmbracelet/crush/internal/message"
  8	"github.com/charmbracelet/crush/internal/tui/components/anim"
  9	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
 10	"github.com/charmbracelet/crush/internal/tui/styles"
 11	"github.com/charmbracelet/crush/internal/tui/util"
 12	"github.com/charmbracelet/lipgloss/v2"
 13	"github.com/charmbracelet/x/ansi"
 14)
 15
 16// ToolCallCmp defines the interface for tool call components in the chat interface.
 17// It manages the display of tool execution including pending states, results, and errors.
 18type ToolCallCmp interface {
 19	util.Model                         // Basic Bubble Tea model interface
 20	layout.Sizeable                    // Width/height management
 21	layout.Focusable                   // Focus state management
 22	GetToolCall() message.ToolCall     // Access to tool call data
 23	GetToolResult() message.ToolResult // Access to tool result data
 24	SetToolResult(message.ToolResult)  // Update tool result
 25	SetToolCall(message.ToolCall)      // Update tool call
 26	SetCancelled()                     // Mark as cancelled
 27	ParentMessageID() string           // Get parent message ID
 28	Spinning() bool                    // Animation state for pending tools
 29	GetNestedToolCalls() []ToolCallCmp // Get nested tool calls
 30	SetNestedToolCalls([]ToolCallCmp)  // Set nested tool calls
 31	SetIsNested(bool)                  // Set whether this tool call is nested
 32	ID() string
 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	t := styles.CurrentTheme()
 95	m.anim = anim.New(anim.Settings{
 96		Size:        15,
 97		Label:       "Working",
 98		GradColorA:  t.Primary,
 99		GradColorB:  t.Secondary,
100		LabelColor:  t.FgBase,
101		CycleColors: true,
102	})
103	if m.isNested {
104		m.anim = anim.New(anim.Settings{
105			Size:        10,
106			GradColorA:  t.Primary,
107			GradColorB:  t.Secondary,
108			CycleColors: true,
109		})
110	}
111	return m
112}
113
114// Init initializes the tool call component and starts animations if needed.
115// Returns a command to start the animation for pending tool calls.
116func (m *toolCallCmp) Init() tea.Cmd {
117	m.spinning = m.shouldSpin()
118	return m.anim.Init()
119}
120
121// Update handles incoming messages and updates the component state.
122// Manages animation updates for pending tool calls.
123func (m *toolCallCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
124	switch msg := msg.(type) {
125	case anim.StepMsg:
126		var cmds []tea.Cmd
127		for i, nested := range m.nestedToolCalls {
128			if nested.Spinning() {
129				u, cmd := nested.Update(msg)
130				m.nestedToolCalls[i] = u.(ToolCallCmp)
131				cmds = append(cmds, cmd)
132			}
133		}
134		if m.spinning {
135			u, cmd := m.anim.Update(msg)
136			m.anim = u.(util.Model)
137			cmds = append(cmds, cmd)
138		}
139		return m, tea.Batch(cmds...)
140	}
141	return m, nil
142}
143
144// View renders the tool call component based on its current state.
145// Shows either a pending animation or the tool-specific rendered result.
146func (m *toolCallCmp) View() string {
147	box := m.style()
148
149	if !m.call.Finished && !m.cancelled {
150		return box.Render(m.renderPending())
151	}
152
153	r := registry.lookup(m.call.Name)
154
155	if m.isNested {
156		return box.Render(r.Render(m))
157	}
158	return box.Render(r.Render(m))
159}
160
161// State management methods
162
163// SetCancelled marks the tool call as cancelled
164func (m *toolCallCmp) SetCancelled() {
165	m.cancelled = true
166}
167
168// SetToolCall updates the tool call data and stops spinning if finished
169func (m *toolCallCmp) SetToolCall(call message.ToolCall) {
170	m.call = call
171	if m.call.Finished {
172		m.spinning = false
173	}
174}
175
176// ParentMessageID returns the ID of the message that initiated this tool call
177func (m *toolCallCmp) ParentMessageID() string {
178	return m.parentMessageID
179}
180
181// SetToolResult updates the tool result and stops the spinning animation
182func (m *toolCallCmp) SetToolResult(result message.ToolResult) {
183	m.result = result
184	m.spinning = false
185}
186
187// GetToolCall returns the current tool call data
188func (m *toolCallCmp) GetToolCall() message.ToolCall {
189	return m.call
190}
191
192// GetToolResult returns the current tool result data
193func (m *toolCallCmp) GetToolResult() message.ToolResult {
194	return m.result
195}
196
197// GetNestedToolCalls returns the nested tool calls
198func (m *toolCallCmp) GetNestedToolCalls() []ToolCallCmp {
199	return m.nestedToolCalls
200}
201
202// SetNestedToolCalls sets the nested tool calls
203func (m *toolCallCmp) SetNestedToolCalls(calls []ToolCallCmp) {
204	m.nestedToolCalls = calls
205	for _, nested := range m.nestedToolCalls {
206		nested.SetSize(m.width, 0)
207	}
208}
209
210// SetIsNested sets whether this tool call is nested within another
211func (m *toolCallCmp) SetIsNested(isNested bool) {
212	m.isNested = isNested
213}
214
215// Rendering methods
216
217// renderPending displays the tool name with a loading animation for pending tool calls
218func (m *toolCallCmp) renderPending() string {
219	t := styles.CurrentTheme()
220	icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
221	if m.isNested {
222		tool := t.S().Base.Foreground(t.FgHalfMuted).Render(prettifyToolName(m.call.Name))
223		return fmt.Sprintf("%s %s %s", icon, tool, m.anim.View())
224	}
225	tool := t.S().Base.Foreground(t.Blue).Render(prettifyToolName(m.call.Name))
226	return fmt.Sprintf("%s %s %s", icon, tool, m.anim.View())
227}
228
229// style returns the lipgloss style for the tool call component.
230// Applies muted colors and focus-dependent border styles.
231func (m *toolCallCmp) style() lipgloss.Style {
232	t := styles.CurrentTheme()
233
234	if m.isNested {
235		return t.S().Muted
236	}
237	style := t.S().Muted.PaddingLeft(4)
238
239	if m.focused {
240		style = style.PaddingLeft(3).BorderStyle(focusedMessageBorder).BorderLeft(true).BorderForeground(t.GreenDark)
241	}
242	return style
243}
244
245// textWidth calculates the available width for text content,
246// accounting for borders and padding
247func (m *toolCallCmp) textWidth() int {
248	if m.isNested {
249		return m.width - 6
250	}
251	return m.width - 5 // take into account the border and PaddingLeft
252}
253
254// fit truncates content to fit within the specified width with ellipsis
255func (m *toolCallCmp) fit(content string, width int) string {
256	t := styles.CurrentTheme()
257	lineStyle := t.S().Muted
258	dots := lineStyle.Render("…")
259	return ansi.Truncate(content, width, dots)
260}
261
262// Focus management methods
263
264// Blur removes focus from the tool call component
265func (m *toolCallCmp) Blur() tea.Cmd {
266	m.focused = false
267	return nil
268}
269
270// Focus sets focus on the tool call component
271func (m *toolCallCmp) Focus() tea.Cmd {
272	m.focused = true
273	return nil
274}
275
276// IsFocused returns whether the tool call component is currently focused
277func (m *toolCallCmp) IsFocused() bool {
278	return m.focused
279}
280
281// Size management methods
282
283// GetSize returns the current dimensions of the tool call component
284func (m *toolCallCmp) GetSize() (int, int) {
285	return m.width, 0
286}
287
288// SetSize updates the width of the tool call component for text wrapping
289func (m *toolCallCmp) SetSize(width int, height int) tea.Cmd {
290	m.width = width
291	for _, nested := range m.nestedToolCalls {
292		nested.SetSize(width, height)
293	}
294	return nil
295}
296
297// shouldSpin determines whether the tool call should show a loading animation.
298// Returns true if the tool call is not finished or if the result doesn't match the call ID.
299func (m *toolCallCmp) shouldSpin() bool {
300	return !m.call.Finished && !m.cancelled
301}
302
303// Spinning returns whether the tool call is currently showing a loading animation
304func (m *toolCallCmp) Spinning() bool {
305	if m.spinning {
306		return true
307	}
308	for _, nested := range m.nestedToolCalls {
309		if nested.Spinning() {
310			return true
311		}
312	}
313	return m.spinning
314}
315
316func (m *toolCallCmp) ID() string {
317	return m.call.ID
318}