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}