tools.go

  1package chat
  2
  3import (
  4	"fmt"
  5	"strings"
  6
  7	tea "charm.land/bubbletea/v2"
  8	"charm.land/lipgloss/v2"
  9	"github.com/charmbracelet/crush/internal/agent/tools"
 10	"github.com/charmbracelet/crush/internal/message"
 11	"github.com/charmbracelet/crush/internal/ui/anim"
 12	"github.com/charmbracelet/crush/internal/ui/styles"
 13	"github.com/charmbracelet/x/ansi"
 14)
 15
 16// responseContextHeight limits the number of lines displayed in tool output.
 17const responseContextHeight = 10
 18
 19// toolBodyLeftPaddingTotal represents the padding that should be applied to each tool body
 20const toolBodyLeftPaddingTotal = 2
 21
 22// ToolStatus represents the current state of a tool call.
 23type ToolStatus int
 24
 25const (
 26	ToolStatusAwaitingPermission ToolStatus = iota
 27	ToolStatusRunning
 28	ToolStatusSuccess
 29	ToolStatusError
 30	ToolStatusCanceled
 31)
 32
 33// ToolMessageItem represents a tool call message in the chat UI.
 34type ToolMessageItem interface {
 35	MessageItem
 36
 37	ToolCall() message.ToolCall
 38	SetToolCall(tc message.ToolCall)
 39	SetResult(res *message.ToolResult)
 40}
 41
 42// DefaultToolRenderContext implements the default [ToolRenderer] interface.
 43type DefaultToolRenderContext struct{}
 44
 45// RenderTool implements the [ToolRenderer] interface.
 46func (d *DefaultToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
 47	return "TODO: Implement Tool Renderer For: " + opts.ToolCall.Name
 48}
 49
 50// ToolRenderOpts contains the data needed to render a tool call.
 51type ToolRenderOpts struct {
 52	ToolCall            message.ToolCall
 53	Result              *message.ToolResult
 54	Canceled            bool
 55	Anim                *anim.Anim
 56	Expanded            bool
 57	Nested              bool
 58	IsSpinning          bool
 59	PermissionRequested bool
 60	PermissionGranted   bool
 61}
 62
 63// Status returns the current status of the tool call.
 64func (opts *ToolRenderOpts) Status() ToolStatus {
 65	if opts.Canceled && opts.Result == nil {
 66		return ToolStatusCanceled
 67	}
 68	if opts.Result != nil {
 69		if opts.Result.IsError {
 70			return ToolStatusError
 71		}
 72		return ToolStatusSuccess
 73	}
 74	if opts.PermissionRequested && !opts.PermissionGranted {
 75		return ToolStatusAwaitingPermission
 76	}
 77	return ToolStatusRunning
 78}
 79
 80// ToolRenderer represents an interface for rendering tool calls.
 81type ToolRenderer interface {
 82	RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string
 83}
 84
 85// ToolRendererFunc is a function type that implements the [ToolRenderer] interface.
 86type ToolRendererFunc func(sty *styles.Styles, width int, opts *ToolRenderOpts) string
 87
 88// RenderTool implements the ToolRenderer interface.
 89func (f ToolRendererFunc) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
 90	return f(sty, width, opts)
 91}
 92
 93// baseToolMessageItem represents a tool call message that can be displayed in the UI.
 94type baseToolMessageItem struct {
 95	*highlightableMessageItem
 96	*cachedMessageItem
 97	*focusableMessageItem
 98
 99	toolRenderer        ToolRenderer
100	toolCall            message.ToolCall
101	result              *message.ToolResult
102	canceled            bool
103	permissionRequested bool
104	permissionGranted   bool
105	// we use this so we can efficiently cache
106	// tools that have a capped width (e.x bash.. and others)
107	hasCappedWidth bool
108
109	sty      *styles.Styles
110	anim     *anim.Anim
111	expanded bool
112}
113
114// newBaseToolMessageItem is the internal constructor for base tool message items.
115func newBaseToolMessageItem(
116	sty *styles.Styles,
117	toolCall message.ToolCall,
118	result *message.ToolResult,
119	toolRenderer ToolRenderer,
120	canceled bool,
121) *baseToolMessageItem {
122	// we only do full width for diffs (as far as I know)
123	hasCappedWidth := toolCall.Name != tools.EditToolName && toolCall.Name != tools.MultiEditToolName
124
125	t := &baseToolMessageItem{
126		highlightableMessageItem: defaultHighlighter(sty),
127		cachedMessageItem:        &cachedMessageItem{},
128		focusableMessageItem:     &focusableMessageItem{},
129		sty:                      sty,
130		toolRenderer:             toolRenderer,
131		toolCall:                 toolCall,
132		result:                   result,
133		canceled:                 canceled,
134		hasCappedWidth:           hasCappedWidth,
135	}
136	t.anim = anim.New(anim.Settings{
137		ID:          toolCall.ID,
138		Size:        15,
139		GradColorA:  sty.Primary,
140		GradColorB:  sty.Secondary,
141		LabelColor:  sty.FgBase,
142		CycleColors: true,
143	})
144
145	return t
146}
147
148// NewToolMessageItem creates a new [ToolMessageItem] based on the tool call name.
149//
150// It returns a specific tool message item type if implemented, otherwise it
151// returns a generic tool message item.
152func NewToolMessageItem(
153	sty *styles.Styles,
154	toolCall message.ToolCall,
155	result *message.ToolResult,
156	canceled bool,
157) ToolMessageItem {
158	switch toolCall.Name {
159	case tools.BashToolName:
160		return NewBashToolMessageItem(sty, toolCall, result, canceled)
161	case tools.JobOutputToolName:
162		return NewJobOutputToolMessageItem(sty, toolCall, result, canceled)
163	case tools.JobKillToolName:
164		return NewJobKillToolMessageItem(sty, toolCall, result, canceled)
165	default:
166		// TODO: Implement other tool items
167		return newBaseToolMessageItem(
168			sty,
169			toolCall,
170			result,
171			&DefaultToolRenderContext{},
172			canceled,
173		)
174	}
175}
176
177// ID returns the unique identifier for this tool message item.
178func (t *baseToolMessageItem) ID() string {
179	return t.toolCall.ID
180}
181
182// StartAnimation starts the assistant message animation if it should be spinning.
183func (t *baseToolMessageItem) StartAnimation() tea.Cmd {
184	if !t.isSpinning() {
185		return nil
186	}
187	return t.anim.Start()
188}
189
190// Animate progresses the assistant message animation if it should be spinning.
191func (t *baseToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
192	if !t.isSpinning() {
193		return nil
194	}
195	return t.anim.Animate(msg)
196}
197
198// Render renders the tool message item at the given width.
199func (t *baseToolMessageItem) Render(width int) string {
200	toolItemWidth := width - messageLeftPaddingTotal
201	if t.hasCappedWidth {
202		toolItemWidth = cappedMessageWidth(width)
203	}
204	style := t.sty.Chat.Message.ToolCallBlurred
205	if t.focused {
206		style = t.sty.Chat.Message.ToolCallFocused
207	}
208
209	content, height, ok := t.getCachedRender(toolItemWidth)
210	// if we are spinning or there is no cache rerender
211	if !ok || t.isSpinning() {
212		content = t.toolRenderer.RenderTool(t.sty, toolItemWidth, &ToolRenderOpts{
213			ToolCall:            t.toolCall,
214			Result:              t.result,
215			Canceled:            t.canceled,
216			Anim:                t.anim,
217			Expanded:            t.expanded,
218			PermissionRequested: t.permissionRequested,
219			PermissionGranted:   t.permissionGranted,
220			IsSpinning:          t.isSpinning(),
221		})
222		height = lipgloss.Height(content)
223		// cache the rendered content
224		t.setCachedRender(content, toolItemWidth, height)
225	}
226
227	highlightedContent := t.renderHighlighted(content, toolItemWidth, height)
228	return style.Render(highlightedContent)
229}
230
231// ToolCall returns the tool call associated with this message item.
232func (t *baseToolMessageItem) ToolCall() message.ToolCall {
233	return t.toolCall
234}
235
236// SetToolCall sets the tool call associated with this message item.
237func (t *baseToolMessageItem) SetToolCall(tc message.ToolCall) {
238	t.toolCall = tc
239	t.clearCache()
240}
241
242// SetResult sets the tool result associated with this message item.
243func (t *baseToolMessageItem) SetResult(res *message.ToolResult) {
244	t.result = res
245	t.clearCache()
246}
247
248// SetPermissionRequested sets whether permission has been requested for this tool call.
249// TODO: Consider merging with SetPermissionGranted and add an interface for
250// permission management.
251func (t *baseToolMessageItem) SetPermissionRequested(requested bool) {
252	t.permissionRequested = requested
253	t.clearCache()
254}
255
256// SetPermissionGranted sets whether permission has been granted for this tool call.
257// TODO: Consider merging with SetPermissionRequested and add an interface for
258// permission management.
259func (t *baseToolMessageItem) SetPermissionGranted(granted bool) {
260	t.permissionGranted = granted
261	t.clearCache()
262}
263
264// isSpinning returns true if the tool should show animation.
265func (t *baseToolMessageItem) isSpinning() bool {
266	return !t.toolCall.Finished && !t.canceled
267}
268
269// ToggleExpanded toggles the expanded state of the thinking box.
270func (t *baseToolMessageItem) ToggleExpanded() {
271	t.expanded = !t.expanded
272	t.clearCache()
273}
274
275// HandleMouseClick implements MouseClickable.
276func (t *baseToolMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
277	if btn != ansi.MouseLeft {
278		return false
279	}
280	t.ToggleExpanded()
281	return true
282}
283
284// pendingTool renders a tool that is still in progress with an animation.
285func pendingTool(sty *styles.Styles, name string, anim *anim.Anim) string {
286	icon := sty.Tool.IconPending.Render()
287	toolName := sty.Tool.NameNormal.Render(name)
288
289	var animView string
290	if anim != nil {
291		animView = anim.Render()
292	}
293
294	return fmt.Sprintf("%s %s %s", icon, toolName, animView)
295}
296
297// toolEarlyStateContent handles error/cancelled/pending states before content rendering.
298// Returns the rendered output and true if early state was handled.
299func toolEarlyStateContent(sty *styles.Styles, opts *ToolRenderOpts, width int) (string, bool) {
300	var msg string
301	switch opts.Status() {
302	case ToolStatusError:
303		msg = toolErrorContent(sty, opts.Result, width)
304	case ToolStatusCanceled:
305		msg = sty.Tool.StateCancelled.Render("Canceled.")
306	case ToolStatusAwaitingPermission:
307		msg = sty.Tool.StateWaiting.Render("Requesting permission...")
308	case ToolStatusRunning:
309		msg = sty.Tool.StateWaiting.Render("Waiting for tool response...")
310	default:
311		return "", false
312	}
313	return msg, true
314}
315
316// toolErrorContent formats an error message with ERROR tag.
317func toolErrorContent(sty *styles.Styles, result *message.ToolResult, width int) string {
318	if result == nil {
319		return ""
320	}
321	errContent := strings.ReplaceAll(result.Content, "\n", " ")
322	errTag := sty.Tool.ErrorTag.Render("ERROR")
323	tagWidth := lipgloss.Width(errTag)
324	errContent = ansi.Truncate(errContent, width-tagWidth-3, "…")
325	return fmt.Sprintf("%s %s", errTag, sty.Tool.ErrorMessage.Render(errContent))
326}
327
328// toolIcon returns the status icon for a tool call.
329// toolIcon returns the status icon for a tool call based on its status.
330func toolIcon(sty *styles.Styles, status ToolStatus) string {
331	switch status {
332	case ToolStatusSuccess:
333		return sty.Tool.IconSuccess.String()
334	case ToolStatusError:
335		return sty.Tool.IconError.String()
336	case ToolStatusCanceled:
337		return sty.Tool.IconCancelled.String()
338	default:
339		return sty.Tool.IconPending.String()
340	}
341}
342
343// toolParamList formats parameters as "main (key=value, ...)" with truncation.
344// toolParamList formats tool parameters as "main (key=value, ...)" with truncation.
345func toolParamList(sty *styles.Styles, params []string, width int) string {
346	// minSpaceForMainParam is the min space required for the main param
347	// if this is less that the value set we will only show the main param nothing else
348	const minSpaceForMainParam = 30
349	if len(params) == 0 {
350		return ""
351	}
352
353	mainParam := params[0]
354
355	// Build key=value pairs from remaining params (consecutive key, value pairs).
356	var kvPairs []string
357	for i := 1; i+1 < len(params); i += 2 {
358		if params[i+1] != "" {
359			kvPairs = append(kvPairs, fmt.Sprintf("%s=%s", params[i], params[i+1]))
360		}
361	}
362
363	// Try to include key=value pairs if there's enough space.
364	output := mainParam
365	if len(kvPairs) > 0 {
366		partsStr := strings.Join(kvPairs, ", ")
367		if remaining := width - lipgloss.Width(partsStr) - 3; remaining >= minSpaceForMainParam {
368			output = fmt.Sprintf("%s (%s)", mainParam, partsStr)
369		}
370	}
371
372	if width >= 0 {
373		output = ansi.Truncate(output, width, "…")
374	}
375	return sty.Tool.ParamMain.Render(output)
376}
377
378// toolHeader builds the tool header line: "● ToolName params..."
379func toolHeader(sty *styles.Styles, status ToolStatus, name string, width int, params ...string) string {
380	icon := toolIcon(sty, status)
381	toolName := sty.Tool.NameNested.Render(name)
382	prefix := fmt.Sprintf("%s %s ", icon, toolName)
383	prefixWidth := lipgloss.Width(prefix)
384	remainingWidth := width - prefixWidth
385	paramsStr := toolParamList(sty, params, remainingWidth)
386	return prefix + paramsStr
387}
388
389// toolOutputPlainContent renders plain text with optional expansion support.
390func toolOutputPlainContent(sty *styles.Styles, content string, width int, expanded bool) string {
391	content = strings.ReplaceAll(content, "\r\n", "\n")
392	content = strings.ReplaceAll(content, "\t", "    ")
393	content = strings.TrimSpace(content)
394	lines := strings.Split(content, "\n")
395
396	maxLines := responseContextHeight
397	if expanded {
398		maxLines = len(lines) // Show all
399	}
400
401	var out []string
402	for i, ln := range lines {
403		if i >= maxLines {
404			break
405		}
406		ln = " " + ln
407		if lipgloss.Width(ln) > width {
408			ln = ansi.Truncate(ln, width, "…")
409		}
410		out = append(out, sty.Tool.ContentLine.Width(width).Render(ln))
411	}
412
413	wasTruncated := len(lines) > responseContextHeight
414
415	if !expanded && wasTruncated {
416		out = append(out, sty.Tool.ContentTruncation.
417			Width(width).
418			Render(fmt.Sprintf("… (%d lines) [click or space to expand]", len(lines)-responseContextHeight)))
419	}
420
421	return strings.Join(out, "\n")
422}