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}