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