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}