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/permission"
  9	"github.com/charmbracelet/crush/internal/tui/components/anim"
 10	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
 11	"github.com/charmbracelet/crush/internal/tui/styles"
 12	"github.com/charmbracelet/crush/internal/tui/util"
 13	"github.com/charmbracelet/lipgloss/v2"
 14	"github.com/charmbracelet/x/ansi"
 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	SetPermissionRequested()           // Mark permission request
 34	SetPermissionGranted()             // Mark permission granted
 35	ID() string
 36}
 37
 38// toolCallCmp implements the ToolCallCmp interface for displaying tool calls.
 39// It handles rendering of tool execution states including pending, completed, and error states.
 40type toolCallCmp struct {
 41	width    int  // Component width for text wrapping
 42	focused  bool // Focus state for border styling
 43	isNested bool // Whether this tool call is nested within another
 44
 45	// Tool call data and state
 46	parentMessageID     string             // ID of the message that initiated this tool call
 47	call                message.ToolCall   // The tool call being executed
 48	result              message.ToolResult // The result of the tool execution
 49	cancelled           bool               // Whether the tool call was cancelled
 50	permissionRequested bool
 51	permissionGranted   bool
 52
 53	// Animation state for pending tool calls
 54	spinning bool       // Whether to show loading animation
 55	anim     util.Model // Animation component for pending states
 56
 57	nestedToolCalls []ToolCallCmp // Nested tool calls for hierarchical display
 58}
 59
 60// ToolCallOption provides functional options for configuring tool call components
 61type ToolCallOption func(*toolCallCmp)
 62
 63// WithToolCallCancelled marks the tool call as cancelled
 64func WithToolCallCancelled() ToolCallOption {
 65	return func(m *toolCallCmp) {
 66		m.cancelled = true
 67	}
 68}
 69
 70// WithToolCallResult sets the initial tool result
 71func WithToolCallResult(result message.ToolResult) ToolCallOption {
 72	return func(m *toolCallCmp) {
 73		m.result = result
 74	}
 75}
 76
 77func WithToolCallNested(isNested bool) ToolCallOption {
 78	return func(m *toolCallCmp) {
 79		m.isNested = isNested
 80	}
 81}
 82
 83func WithToolCallNestedCalls(calls []ToolCallCmp) ToolCallOption {
 84	return func(m *toolCallCmp) {
 85		m.nestedToolCalls = calls
 86	}
 87}
 88
 89func WithToolPermissionRequested() ToolCallOption {
 90	return func(m *toolCallCmp) {
 91		m.permissionRequested = true
 92	}
 93}
 94
 95func WithToolPermissionGranted() ToolCallOption {
 96	return func(m *toolCallCmp) {
 97		m.permissionGranted = true
 98	}
 99}
100
101// NewToolCallCmp creates a new tool call component with the given parent message ID,
102// tool call, and optional configuration
103func NewToolCallCmp(parentMessageID string, tc message.ToolCall, permissions permission.Service, opts ...ToolCallOption) ToolCallCmp {
104	m := &toolCallCmp{
105		call:            tc,
106		parentMessageID: parentMessageID,
107	}
108	for _, opt := range opts {
109		opt(m)
110	}
111	t := styles.CurrentTheme()
112	m.anim = anim.New(anim.Settings{
113		Size:        15,
114		Label:       "Working",
115		GradColorA:  t.Primary,
116		GradColorB:  t.Secondary,
117		LabelColor:  t.FgBase,
118		CycleColors: true,
119	})
120	if m.isNested {
121		m.anim = anim.New(anim.Settings{
122			Size:        10,
123			GradColorA:  t.Primary,
124			GradColorB:  t.Secondary,
125			CycleColors: true,
126		})
127	}
128	return m
129}
130
131// Init initializes the tool call component and starts animations if needed.
132// Returns a command to start the animation for pending tool calls.
133func (m *toolCallCmp) Init() tea.Cmd {
134	m.spinning = m.shouldSpin()
135	return m.anim.Init()
136}
137
138// Update handles incoming messages and updates the component state.
139// Manages animation updates for pending tool calls.
140func (m *toolCallCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
141	switch msg := msg.(type) {
142	case anim.StepMsg:
143		var cmds []tea.Cmd
144		for i, nested := range m.nestedToolCalls {
145			if nested.Spinning() {
146				u, cmd := nested.Update(msg)
147				m.nestedToolCalls[i] = u.(ToolCallCmp)
148				cmds = append(cmds, cmd)
149			}
150		}
151		if m.spinning {
152			u, cmd := m.anim.Update(msg)
153			m.anim = u.(util.Model)
154			cmds = append(cmds, cmd)
155		}
156		return m, tea.Batch(cmds...)
157	}
158	return m, nil
159}
160
161// View renders the tool call component based on its current state.
162// Shows either a pending animation or the tool-specific rendered result.
163func (m *toolCallCmp) View() string {
164	box := m.style()
165
166	if !m.call.Finished && !m.cancelled {
167		return box.Render(m.renderPending())
168	}
169
170	r := registry.lookup(m.call.Name)
171
172	if m.isNested {
173		return box.Render(r.Render(m))
174	}
175	return box.Render(r.Render(m))
176}
177
178// State management methods
179
180// SetCancelled marks the tool call as cancelled
181func (m *toolCallCmp) SetCancelled() {
182	m.cancelled = true
183}
184
185// SetToolCall updates the tool call data and stops spinning if finished
186func (m *toolCallCmp) SetToolCall(call message.ToolCall) {
187	m.call = call
188	if m.call.Finished {
189		m.spinning = false
190	}
191}
192
193// ParentMessageID returns the ID of the message that initiated this tool call
194func (m *toolCallCmp) ParentMessageID() string {
195	return m.parentMessageID
196}
197
198// SetToolResult updates the tool result and stops the spinning animation
199func (m *toolCallCmp) SetToolResult(result message.ToolResult) {
200	m.result = result
201	m.spinning = false
202}
203
204// GetToolCall returns the current tool call data
205func (m *toolCallCmp) GetToolCall() message.ToolCall {
206	return m.call
207}
208
209// GetToolResult returns the current tool result data
210func (m *toolCallCmp) GetToolResult() message.ToolResult {
211	return m.result
212}
213
214// GetNestedToolCalls returns the nested tool calls
215func (m *toolCallCmp) GetNestedToolCalls() []ToolCallCmp {
216	return m.nestedToolCalls
217}
218
219// SetNestedToolCalls sets the nested tool calls
220func (m *toolCallCmp) SetNestedToolCalls(calls []ToolCallCmp) {
221	m.nestedToolCalls = calls
222	for _, nested := range m.nestedToolCalls {
223		nested.SetSize(m.width, 0)
224	}
225}
226
227// SetIsNested sets whether this tool call is nested within another
228func (m *toolCallCmp) SetIsNested(isNested bool) {
229	m.isNested = isNested
230}
231
232// Rendering methods
233
234// renderPending displays the tool name with a loading animation for pending tool calls
235func (m *toolCallCmp) renderPending() string {
236	t := styles.CurrentTheme()
237	icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
238	if m.isNested {
239		tool := t.S().Base.Foreground(t.FgHalfMuted).Render(prettifyToolName(m.call.Name))
240		return fmt.Sprintf("%s %s %s", icon, tool, m.anim.View())
241	}
242	tool := t.S().Base.Foreground(t.Blue).Render(prettifyToolName(m.call.Name))
243	return fmt.Sprintf("%s %s %s", icon, tool, m.anim.View())
244}
245
246// style returns the lipgloss style for the tool call component.
247// Applies muted colors and focus-dependent border styles.
248func (m *toolCallCmp) style() lipgloss.Style {
249	t := styles.CurrentTheme()
250
251	if m.isNested {
252		return t.S().Muted
253	}
254	style := t.S().Muted.PaddingLeft(4)
255
256	if m.focused {
257		style = style.PaddingLeft(3).BorderStyle(focusedMessageBorder).BorderLeft(true).BorderForeground(t.GreenDark)
258	}
259	return style
260}
261
262// textWidth calculates the available width for text content,
263// accounting for borders and padding
264func (m *toolCallCmp) textWidth() int {
265	if m.isNested {
266		return m.width - 6
267	}
268	return m.width - 5 // take into account the border and PaddingLeft
269}
270
271// fit truncates content to fit within the specified width with ellipsis
272func (m *toolCallCmp) fit(content string, width int) string {
273	t := styles.CurrentTheme()
274	lineStyle := t.S().Muted
275	dots := lineStyle.Render("…")
276	return ansi.Truncate(content, width, dots)
277}
278
279// Focus management methods
280
281// Blur removes focus from the tool call component
282func (m *toolCallCmp) Blur() tea.Cmd {
283	m.focused = false
284	return nil
285}
286
287// Focus sets focus on the tool call component
288func (m *toolCallCmp) Focus() tea.Cmd {
289	m.focused = true
290	return nil
291}
292
293// IsFocused returns whether the tool call component is currently focused
294func (m *toolCallCmp) IsFocused() bool {
295	return m.focused
296}
297
298// Size management methods
299
300// GetSize returns the current dimensions of the tool call component
301func (m *toolCallCmp) GetSize() (int, int) {
302	return m.width, 0
303}
304
305// SetSize updates the width of the tool call component for text wrapping
306func (m *toolCallCmp) SetSize(width int, height int) tea.Cmd {
307	m.width = width
308	for _, nested := range m.nestedToolCalls {
309		nested.SetSize(width, height)
310	}
311	return nil
312}
313
314// shouldSpin determines whether the tool call should show a loading animation.
315// Returns true if the tool call is not finished or if the result doesn't match the call ID.
316func (m *toolCallCmp) shouldSpin() bool {
317	return !m.call.Finished
318}
319
320// Spinning returns whether the tool call is currently showing a loading animation
321func (m *toolCallCmp) Spinning() bool {
322	if m.spinning {
323		return true
324	}
325	for _, nested := range m.nestedToolCalls {
326		if nested.Spinning() {
327			return true
328		}
329	}
330	return m.spinning
331}
332
333// SetPermissionRequested marks that a permission request was made for this tool call
334func (m *toolCallCmp) SetPermissionRequested() {
335	m.permissionRequested = true
336}
337
338// SetPermissionGranted marks that permission was granted for this tool call
339func (m *toolCallCmp) SetPermissionGranted() {
340	m.permissionGranted = true
341}
342
343func (m *toolCallCmp) ID() string {
344	return m.call.ID
345}