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	default:
162		// TODO: Implement other tool items
163		return newBaseToolMessageItem(
164			sty,
165			toolCall,
166			result,
167			&DefaultToolRenderContext{},
168			canceled,
169		)
170	}
171}
172
173// ID returns the unique identifier for this tool message item.
174func (t *baseToolMessageItem) ID() string {
175	return t.toolCall.ID
176}
177
178// StartAnimation starts the assistant message animation if it should be spinning.
179func (t *baseToolMessageItem) StartAnimation() tea.Cmd {
180	if !t.isSpinning() {
181		return nil
182	}
183	return t.anim.Start()
184}
185
186// Animate progresses the assistant message animation if it should be spinning.
187func (t *baseToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
188	if !t.isSpinning() {
189		return nil
190	}
191	return t.anim.Animate(msg)
192}
193
194// Render renders the tool message item at the given width.
195func (t *baseToolMessageItem) Render(width int) string {
196	toolItemWidth := width - messageLeftPaddingTotal
197	if t.hasCappedWidth {
198		toolItemWidth = cappedMessageWidth(width)
199	}
200	style := t.sty.Chat.Message.ToolCallBlurred
201	if t.focused {
202		style = t.sty.Chat.Message.ToolCallFocused
203	}
204
205	content, height, ok := t.getCachedRender(toolItemWidth)
206	// if we are spinning or there is no cache rerender
207	if !ok || t.isSpinning() {
208		content = t.toolRenderer.RenderTool(t.sty, toolItemWidth, &ToolRenderOpts{
209			ToolCall:            t.toolCall,
210			Result:              t.result,
211			Canceled:            t.canceled,
212			Anim:                t.anim,
213			Expanded:            t.expanded,
214			PermissionRequested: t.permissionRequested,
215			PermissionGranted:   t.permissionGranted,
216			IsSpinning:          t.isSpinning(),
217		})
218		height = lipgloss.Height(content)
219		// cache the rendered content
220		t.setCachedRender(content, toolItemWidth, height)
221	}
222
223	highlightedContent := t.renderHighlighted(content, toolItemWidth, height)
224	return style.Render(highlightedContent)
225}
226
227// ToolCall returns the tool call associated with this message item.
228func (t *baseToolMessageItem) ToolCall() message.ToolCall {
229	return t.toolCall
230}
231
232// SetToolCall sets the tool call associated with this message item.
233func (t *baseToolMessageItem) SetToolCall(tc message.ToolCall) {
234	t.toolCall = tc
235	t.clearCache()
236}
237
238// SetResult sets the tool result associated with this message item.
239func (t *baseToolMessageItem) SetResult(res *message.ToolResult) {
240	t.result = res
241	t.clearCache()
242}
243
244// SetPermissionRequested sets whether permission has been requested for this tool call.
245// TODO: Consider merging with SetPermissionGranted and add an interface for
246// permission management.
247func (t *baseToolMessageItem) SetPermissionRequested(requested bool) {
248	t.permissionRequested = requested
249	t.clearCache()
250}
251
252// SetPermissionGranted sets whether permission has been granted for this tool call.
253// TODO: Consider merging with SetPermissionRequested and add an interface for
254// permission management.
255func (t *baseToolMessageItem) SetPermissionGranted(granted bool) {
256	t.permissionGranted = granted
257	t.clearCache()
258}
259
260// isSpinning returns true if the tool should show animation.
261func (t *baseToolMessageItem) isSpinning() bool {
262	return !t.toolCall.Finished && !t.canceled
263}
264
265// ToggleExpanded toggles the expanded state of the thinking box.
266func (t *baseToolMessageItem) ToggleExpanded() {
267	t.expanded = !t.expanded
268	t.clearCache()
269}
270
271// HandleMouseClick implements MouseClickable.
272func (t *baseToolMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
273	if btn != ansi.MouseLeft {
274		return false
275	}
276	t.ToggleExpanded()
277	return true
278}
279
280// pendingTool renders a tool that is still in progress with an animation.
281func pendingTool(sty *styles.Styles, name string, anim *anim.Anim) string {
282	icon := sty.Tool.IconPending.Render()
283	toolName := sty.Tool.NameNormal.Render(name)
284
285	var animView string
286	if anim != nil {
287		animView = anim.Render()
288	}
289
290	return fmt.Sprintf("%s %s %s", icon, toolName, animView)
291}
292
293// toolEarlyStateContent handles error/cancelled/pending states before content rendering.
294// Returns the rendered output and true if early state was handled.
295func toolEarlyStateContent(sty *styles.Styles, opts *ToolRenderOpts, width int) (string, bool) {
296	var msg string
297	switch opts.Status() {
298	case ToolStatusError:
299		msg = toolErrorContent(sty, opts.Result, width)
300	case ToolStatusCanceled:
301		msg = sty.Tool.StateCancelled.Render("Canceled.")
302	case ToolStatusAwaitingPermission:
303		msg = sty.Tool.StateWaiting.Render("Requesting permission...")
304	case ToolStatusRunning:
305		msg = sty.Tool.StateWaiting.Render("Waiting for tool response...")
306	default:
307		return "", false
308	}
309	return msg, true
310}
311
312// toolErrorContent formats an error message with ERROR tag.
313func toolErrorContent(sty *styles.Styles, result *message.ToolResult, width int) string {
314	if result == nil {
315		return ""
316	}
317	errContent := strings.ReplaceAll(result.Content, "\n", " ")
318	errTag := sty.Tool.ErrorTag.Render("ERROR")
319	tagWidth := lipgloss.Width(errTag)
320	errContent = ansi.Truncate(errContent, width-tagWidth-3, "…")
321	return fmt.Sprintf("%s %s", errTag, sty.Tool.ErrorMessage.Render(errContent))
322}
323
324// toolIcon returns the status icon for a tool call.
325// toolIcon returns the status icon for a tool call based on its status.
326func toolIcon(sty *styles.Styles, status ToolStatus) string {
327	switch status {
328	case ToolStatusSuccess:
329		return sty.Tool.IconSuccess.String()
330	case ToolStatusError:
331		return sty.Tool.IconError.String()
332	case ToolStatusCanceled:
333		return sty.Tool.IconCancelled.String()
334	default:
335		return sty.Tool.IconPending.String()
336	}
337}
338
339// toolParamList formats parameters as "main (key=value, ...)" with truncation.
340// toolParamList formats tool parameters as "main (key=value, ...)" with truncation.
341func toolParamList(sty *styles.Styles, params []string, width int) string {
342	// minSpaceForMainParam is the min space required for the main param
343	// if this is less that the value set we will only show the main param nothing else
344	const minSpaceForMainParam = 30
345	if len(params) == 0 {
346		return ""
347	}
348
349	mainParam := params[0]
350
351	// Build key=value pairs from remaining params (consecutive key, value pairs).
352	var kvPairs []string
353	for i := 1; i+1 < len(params); i += 2 {
354		if params[i+1] != "" {
355			kvPairs = append(kvPairs, fmt.Sprintf("%s=%s", params[i], params[i+1]))
356		}
357	}
358
359	// Try to include key=value pairs if there's enough space.
360	output := mainParam
361	if len(kvPairs) > 0 {
362		partsStr := strings.Join(kvPairs, ", ")
363		if remaining := width - lipgloss.Width(partsStr) - 3; remaining >= minSpaceForMainParam {
364			output = fmt.Sprintf("%s (%s)", mainParam, partsStr)
365		}
366	}
367
368	if width >= 0 {
369		output = ansi.Truncate(output, width, "…")
370	}
371	return sty.Tool.ParamMain.Render(output)
372}
373
374// toolHeader builds the tool header line: "● ToolName params..."
375func toolHeader(sty *styles.Styles, status ToolStatus, name string, width int, params ...string) string {
376	icon := toolIcon(sty, status)
377	toolName := sty.Tool.NameNested.Render(name)
378	prefix := fmt.Sprintf("%s %s ", icon, toolName)
379	prefixWidth := lipgloss.Width(prefix)
380	remainingWidth := width - prefixWidth
381	paramsStr := toolParamList(sty, params, remainingWidth)
382	return prefix + paramsStr
383}
384
385// toolOutputPlainContent renders plain text with optional expansion support.
386func toolOutputPlainContent(sty *styles.Styles, content string, width int, expanded bool) string {
387	content = strings.ReplaceAll(content, "\r\n", "\n")
388	content = strings.ReplaceAll(content, "\t", "    ")
389	content = strings.TrimSpace(content)
390	lines := strings.Split(content, "\n")
391
392	maxLines := responseContextHeight
393	if expanded {
394		maxLines = len(lines) // Show all
395	}
396
397	var out []string
398	for i, ln := range lines {
399		if i >= maxLines {
400			break
401		}
402		ln = " " + ln
403		if lipgloss.Width(ln) > width {
404			ln = ansi.Truncate(ln, width, "…")
405		}
406		out = append(out, sty.Tool.ContentLine.Width(width).Render(ln))
407	}
408
409	wasTruncated := len(lines) > responseContextHeight
410
411	if !expanded && wasTruncated {
412		out = append(out, sty.Tool.ContentTruncation.
413			Width(width).
414			Render(fmt.Sprintf("… (%d lines) [click or space to expand]", len(lines)-responseContextHeight)))
415	}
416
417	return strings.Join(out, "\n")
418}