1package chat
2
3import (
4 "encoding/json"
5 "fmt"
6 "path/filepath"
7 "strings"
8 "time"
9
10 tea "charm.land/bubbletea/v2"
11 "charm.land/lipgloss/v2"
12 "charm.land/lipgloss/v2/tree"
13 "github.com/charmbracelet/crush/internal/agent"
14 "github.com/charmbracelet/crush/internal/agent/tools"
15 "github.com/charmbracelet/crush/internal/diff"
16 "github.com/charmbracelet/crush/internal/fsext"
17 "github.com/charmbracelet/crush/internal/message"
18 "github.com/charmbracelet/crush/internal/stringext"
19 "github.com/charmbracelet/crush/internal/ui/anim"
20 "github.com/charmbracelet/crush/internal/ui/common"
21 "github.com/charmbracelet/crush/internal/ui/styles"
22 "github.com/charmbracelet/x/ansi"
23)
24
25// responseContextHeight limits the number of lines displayed in tool output.
26const responseContextHeight = 10
27
28// toolBodyLeftPaddingTotal represents the padding that should be applied to each tool body
29const toolBodyLeftPaddingTotal = 2
30
31// ToolStatus represents the current state of a tool call.
32type ToolStatus int
33
34const (
35 ToolStatusAwaitingPermission ToolStatus = iota
36 ToolStatusRunning
37 ToolStatusSuccess
38 ToolStatusError
39 ToolStatusCanceled
40)
41
42// ToolMessageItem represents a tool call message in the chat UI.
43type ToolMessageItem interface {
44 MessageItem
45
46 ToolCall() message.ToolCall
47 SetToolCall(tc message.ToolCall)
48 SetResult(res *message.ToolResult)
49 MessageID() string
50 SetMessageID(id string)
51 SetStatus(status ToolStatus)
52 Status() ToolStatus
53}
54
55// Compactable is an interface for tool items that can render in a compacted mode.
56// When compact mode is enabled, tools render as a compact single-line header.
57type Compactable interface {
58 SetCompact(compact bool)
59}
60
61// SpinningState contains the state passed to SpinningFunc for custom spinning logic.
62type SpinningState struct {
63 ToolCall message.ToolCall
64 Result *message.ToolResult
65 Status ToolStatus
66}
67
68// IsCanceled returns true if the tool status is canceled.
69func (s *SpinningState) IsCanceled() bool {
70 return s.Status == ToolStatusCanceled
71}
72
73// HasResult returns true if the result is not nil.
74func (s *SpinningState) HasResult() bool {
75 return s.Result != nil
76}
77
78// SpinningFunc is a function type for custom spinning logic.
79// Returns true if the tool should show the spinning animation.
80type SpinningFunc func(state SpinningState) bool
81
82// DefaultToolRenderContext implements the default [ToolRenderer] interface.
83type DefaultToolRenderContext struct{}
84
85// RenderTool implements the [ToolRenderer] interface.
86func (d *DefaultToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
87 return "TODO: Implement Tool Renderer For: " + opts.ToolCall.Name
88}
89
90// ToolRenderOpts contains the data needed to render a tool call.
91type ToolRenderOpts struct {
92 ToolCall message.ToolCall
93 Result *message.ToolResult
94 Anim *anim.Anim
95 ExpandedContent bool
96 Compact bool
97 IsSpinning bool
98 Status ToolStatus
99}
100
101// IsPending returns true if the tool call is still pending (not finished and
102// not canceled).
103func (o *ToolRenderOpts) IsPending() bool {
104 return !o.ToolCall.Finished && !o.IsCanceled()
105}
106
107// IsCanceled returns true if the tool status is canceled.
108func (o *ToolRenderOpts) IsCanceled() bool {
109 return o.Status == ToolStatusCanceled
110}
111
112// HasResult returns true if the result is not nil.
113func (o *ToolRenderOpts) HasResult() bool {
114 return o.Result != nil
115}
116
117// HasEmptyResult returns true if the result is nil or has empty content.
118func (o *ToolRenderOpts) HasEmptyResult() bool {
119 return o.Result == nil || o.Result.Content == ""
120}
121
122// ToolRenderer represents an interface for rendering tool calls.
123type ToolRenderer interface {
124 RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string
125}
126
127// ToolRendererFunc is a function type that implements the [ToolRenderer] interface.
128type ToolRendererFunc func(sty *styles.Styles, width int, opts *ToolRenderOpts) string
129
130// RenderTool implements the ToolRenderer interface.
131func (f ToolRendererFunc) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
132 return f(sty, width, opts)
133}
134
135// baseToolMessageItem represents a tool call message that can be displayed in the UI.
136type baseToolMessageItem struct {
137 *highlightableMessageItem
138 *cachedMessageItem
139 *focusableMessageItem
140
141 toolRenderer ToolRenderer
142 toolCall message.ToolCall
143 result *message.ToolResult
144 messageID string
145 status ToolStatus
146 // we use this so we can efficiently cache
147 // tools that have a capped width (e.x bash.. and others)
148 hasCappedWidth bool
149 // isCompact indicates this tool should render in compact mode.
150 isCompact bool
151 // spinningFunc allows tools to override the default spinning logic.
152 // If nil, uses the default: !toolCall.Finished && !canceled.
153 spinningFunc SpinningFunc
154
155 sty *styles.Styles
156 anim *anim.Anim
157 expandedContent bool
158}
159
160var _ Expandable = (*baseToolMessageItem)(nil)
161
162// newBaseToolMessageItem is the internal constructor for base tool message items.
163func newBaseToolMessageItem(
164 sty *styles.Styles,
165 toolCall message.ToolCall,
166 result *message.ToolResult,
167 toolRenderer ToolRenderer,
168 canceled bool,
169) *baseToolMessageItem {
170 // we only do full width for diffs (as far as I know)
171 hasCappedWidth := toolCall.Name != tools.EditToolName && toolCall.Name != tools.MultiEditToolName
172
173 status := ToolStatusRunning
174 if canceled {
175 status = ToolStatusCanceled
176 }
177
178 t := &baseToolMessageItem{
179 highlightableMessageItem: defaultHighlighter(sty),
180 cachedMessageItem: &cachedMessageItem{},
181 focusableMessageItem: &focusableMessageItem{},
182 sty: sty,
183 toolRenderer: toolRenderer,
184 toolCall: toolCall,
185 result: result,
186 status: status,
187 hasCappedWidth: hasCappedWidth,
188 }
189 t.anim = anim.New(anim.Settings{
190 ID: toolCall.ID,
191 Size: 15,
192 GradColorA: sty.Primary,
193 GradColorB: sty.Secondary,
194 LabelColor: sty.FgBase,
195 CycleColors: true,
196 })
197
198 return t
199}
200
201// NewToolMessageItem creates a new [ToolMessageItem] based on the tool call name.
202//
203// It returns a specific tool message item type if implemented, otherwise it
204// returns a generic tool message item. The messageID is the ID of the assistant
205// message containing this tool call.
206func NewToolMessageItem(
207 sty *styles.Styles,
208 messageID string,
209 toolCall message.ToolCall,
210 result *message.ToolResult,
211 canceled bool,
212) ToolMessageItem {
213 var item ToolMessageItem
214 switch toolCall.Name {
215 case tools.BashToolName:
216 item = NewBashToolMessageItem(sty, toolCall, result, canceled)
217 case tools.JobOutputToolName:
218 item = NewJobOutputToolMessageItem(sty, toolCall, result, canceled)
219 case tools.JobKillToolName:
220 item = NewJobKillToolMessageItem(sty, toolCall, result, canceled)
221 case tools.ViewToolName:
222 item = NewViewToolMessageItem(sty, toolCall, result, canceled)
223 case tools.WriteToolName:
224 item = NewWriteToolMessageItem(sty, toolCall, result, canceled)
225 case tools.EditToolName:
226 item = NewEditToolMessageItem(sty, toolCall, result, canceled)
227 case tools.MultiEditToolName:
228 item = NewMultiEditToolMessageItem(sty, toolCall, result, canceled)
229 case tools.GlobToolName:
230 item = NewGlobToolMessageItem(sty, toolCall, result, canceled)
231 case tools.GrepToolName:
232 item = NewGrepToolMessageItem(sty, toolCall, result, canceled)
233 case tools.LSToolName:
234 item = NewLSToolMessageItem(sty, toolCall, result, canceled)
235 case tools.DownloadToolName:
236 item = NewDownloadToolMessageItem(sty, toolCall, result, canceled)
237 case tools.FetchToolName:
238 item = NewFetchToolMessageItem(sty, toolCall, result, canceled)
239 case tools.SourcegraphToolName:
240 item = NewSourcegraphToolMessageItem(sty, toolCall, result, canceled)
241 case tools.DiagnosticsToolName:
242 item = NewDiagnosticsToolMessageItem(sty, toolCall, result, canceled)
243 case agent.AgentToolName:
244 item = NewAgentToolMessageItem(sty, toolCall, result, canceled)
245 case tools.AgenticFetchToolName:
246 item = NewAgenticFetchToolMessageItem(sty, toolCall, result, canceled)
247 case tools.WebFetchToolName:
248 item = NewWebFetchToolMessageItem(sty, toolCall, result, canceled)
249 case tools.WebSearchToolName:
250 item = NewWebSearchToolMessageItem(sty, toolCall, result, canceled)
251 case tools.TodosToolName:
252 item = NewTodosToolMessageItem(sty, toolCall, result, canceled)
253 case tools.ReferencesToolName:
254 item = NewReferencesToolMessageItem(sty, toolCall, result, canceled)
255 case tools.LSPRestartToolName:
256 item = NewLSPRestartToolMessageItem(sty, toolCall, result, canceled)
257 default:
258 if strings.HasPrefix(toolCall.Name, "mcp_") {
259 item = NewMCPToolMessageItem(sty, toolCall, result, canceled)
260 } else {
261 item = NewGenericToolMessageItem(sty, toolCall, result, canceled)
262 }
263 }
264 item.SetMessageID(messageID)
265 return item
266}
267
268// SetCompact implements the Compactable interface.
269func (t *baseToolMessageItem) SetCompact(compact bool) {
270 t.isCompact = compact
271 t.clearCache()
272}
273
274// ID returns the unique identifier for this tool message item.
275func (t *baseToolMessageItem) ID() string {
276 return t.toolCall.ID
277}
278
279// StartAnimation starts the assistant message animation if it should be spinning.
280func (t *baseToolMessageItem) StartAnimation() tea.Cmd {
281 if !t.isSpinning() {
282 return nil
283 }
284 return t.anim.Start()
285}
286
287// Animate progresses the assistant message animation if it should be spinning.
288func (t *baseToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
289 if !t.isSpinning() {
290 return nil
291 }
292 return t.anim.Animate(msg)
293}
294
295// RawRender implements [MessageItem].
296func (t *baseToolMessageItem) RawRender(width int) string {
297 toolItemWidth := width - MessageLeftPaddingTotal
298 if t.hasCappedWidth {
299 toolItemWidth = cappedMessageWidth(width)
300 }
301
302 content, height, ok := t.getCachedRender(toolItemWidth)
303 // if we are spinning or there is no cache rerender
304 if !ok || t.isSpinning() {
305 content = t.toolRenderer.RenderTool(t.sty, toolItemWidth, &ToolRenderOpts{
306 ToolCall: t.toolCall,
307 Result: t.result,
308 Anim: t.anim,
309 ExpandedContent: t.expandedContent,
310 Compact: t.isCompact,
311 IsSpinning: t.isSpinning(),
312 Status: t.computeStatus(),
313 })
314 height = lipgloss.Height(content)
315 // cache the rendered content
316 t.setCachedRender(content, toolItemWidth, height)
317 }
318
319 return t.renderHighlighted(content, toolItemWidth, height)
320}
321
322// Render renders the tool message item at the given width.
323func (t *baseToolMessageItem) Render(width int) string {
324 style := t.sty.Chat.Message.ToolCallBlurred
325 if t.focused {
326 style = t.sty.Chat.Message.ToolCallFocused
327 }
328
329 if t.isCompact {
330 style = t.sty.Chat.Message.ToolCallCompact
331 }
332
333 return style.Render(t.RawRender(width))
334}
335
336// ToolCall returns the tool call associated with this message item.
337func (t *baseToolMessageItem) ToolCall() message.ToolCall {
338 return t.toolCall
339}
340
341// SetToolCall sets the tool call associated with this message item.
342func (t *baseToolMessageItem) SetToolCall(tc message.ToolCall) {
343 t.toolCall = tc
344 t.clearCache()
345}
346
347// SetResult sets the tool result associated with this message item.
348func (t *baseToolMessageItem) SetResult(res *message.ToolResult) {
349 t.result = res
350 t.clearCache()
351}
352
353// MessageID returns the ID of the message containing this tool call.
354func (t *baseToolMessageItem) MessageID() string {
355 return t.messageID
356}
357
358// SetMessageID sets the ID of the message containing this tool call.
359func (t *baseToolMessageItem) SetMessageID(id string) {
360 t.messageID = id
361}
362
363// SetStatus sets the tool status.
364func (t *baseToolMessageItem) SetStatus(status ToolStatus) {
365 t.status = status
366 t.clearCache()
367}
368
369// Status returns the current tool status.
370func (t *baseToolMessageItem) Status() ToolStatus {
371 return t.status
372}
373
374// computeStatus computes the effective status considering the result.
375func (t *baseToolMessageItem) computeStatus() ToolStatus {
376 if t.result != nil {
377 if t.result.IsError {
378 return ToolStatusError
379 }
380 return ToolStatusSuccess
381 }
382 return t.status
383}
384
385// isSpinning returns true if the tool should show animation.
386func (t *baseToolMessageItem) isSpinning() bool {
387 if t.spinningFunc != nil {
388 return t.spinningFunc(SpinningState{
389 ToolCall: t.toolCall,
390 Result: t.result,
391 Status: t.status,
392 })
393 }
394 return !t.toolCall.Finished && t.status != ToolStatusCanceled
395}
396
397// SetSpinningFunc sets a custom function to determine if the tool should spin.
398func (t *baseToolMessageItem) SetSpinningFunc(fn SpinningFunc) {
399 t.spinningFunc = fn
400}
401
402// ToggleExpanded toggles the expanded state of the thinking box.
403func (t *baseToolMessageItem) ToggleExpanded() bool {
404 t.expandedContent = !t.expandedContent
405 t.clearCache()
406 return t.expandedContent
407}
408
409// HandleMouseClick implements MouseClickable.
410func (t *baseToolMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
411 return btn == ansi.MouseLeft
412}
413
414// HandleKeyEvent implements KeyEventHandler.
415func (t *baseToolMessageItem) HandleKeyEvent(key tea.KeyMsg) (bool, tea.Cmd) {
416 if k := key.String(); k == "c" || k == "y" {
417 text := t.formatToolForCopy()
418 return true, common.CopyToClipboard(text, "Tool content copied to clipboard")
419 }
420 return false, nil
421}
422
423// pendingTool renders a tool that is still in progress with an animation.
424func pendingTool(sty *styles.Styles, name string, anim *anim.Anim) string {
425 icon := sty.Tool.IconPending.Render()
426 toolName := sty.Tool.NameNormal.Render(name)
427
428 var animView string
429 if anim != nil {
430 animView = anim.Render()
431 }
432
433 return fmt.Sprintf("%s %s %s", icon, toolName, animView)
434}
435
436// toolEarlyStateContent handles error/cancelled/pending states before content rendering.
437// Returns the rendered output and true if early state was handled.
438func toolEarlyStateContent(sty *styles.Styles, opts *ToolRenderOpts, width int) (string, bool) {
439 var msg string
440 switch opts.Status {
441 case ToolStatusError:
442 msg = toolErrorContent(sty, opts.Result, width)
443 case ToolStatusCanceled:
444 msg = sty.Tool.StateCancelled.Render("Canceled.")
445 case ToolStatusAwaitingPermission:
446 msg = sty.Tool.StateWaiting.Render("Requesting permission...")
447 case ToolStatusRunning:
448 msg = sty.Tool.StateWaiting.Render("Waiting for tool response...")
449 default:
450 return "", false
451 }
452 return msg, true
453}
454
455// toolErrorContent formats an error message with ERROR tag.
456func toolErrorContent(sty *styles.Styles, result *message.ToolResult, width int) string {
457 if result == nil {
458 return ""
459 }
460 errContent := strings.ReplaceAll(result.Content, "\n", " ")
461 errTag := sty.Tool.ErrorTag.Render("ERROR")
462 tagWidth := lipgloss.Width(errTag)
463 errContent = ansi.Truncate(errContent, width-tagWidth-3, "…")
464 return fmt.Sprintf("%s %s", errTag, sty.Tool.ErrorMessage.Render(errContent))
465}
466
467// toolIcon returns the status icon for a tool call.
468// toolIcon returns the status icon for a tool call based on its status.
469func toolIcon(sty *styles.Styles, status ToolStatus) string {
470 switch status {
471 case ToolStatusSuccess:
472 return sty.Tool.IconSuccess.String()
473 case ToolStatusError:
474 return sty.Tool.IconError.String()
475 case ToolStatusCanceled:
476 return sty.Tool.IconCancelled.String()
477 default:
478 return sty.Tool.IconPending.String()
479 }
480}
481
482// toolParamList formats parameters as "main (key=value, ...)" with truncation.
483// toolParamList formats tool parameters as "main (key=value, ...)" with truncation.
484func toolParamList(sty *styles.Styles, params []string, width int) string {
485 // minSpaceForMainParam is the min space required for the main param
486 // if this is less that the value set we will only show the main param nothing else
487 const minSpaceForMainParam = 30
488 if len(params) == 0 {
489 return ""
490 }
491
492 mainParam := params[0]
493
494 // Build key=value pairs from remaining params (consecutive key, value pairs).
495 var kvPairs []string
496 for i := 1; i+1 < len(params); i += 2 {
497 if params[i+1] != "" {
498 kvPairs = append(kvPairs, fmt.Sprintf("%s=%s", params[i], params[i+1]))
499 }
500 }
501
502 // Try to include key=value pairs if there's enough space.
503 output := mainParam
504 if len(kvPairs) > 0 {
505 partsStr := strings.Join(kvPairs, ", ")
506 if remaining := width - lipgloss.Width(partsStr) - 3; remaining >= minSpaceForMainParam {
507 output = fmt.Sprintf("%s (%s)", mainParam, partsStr)
508 }
509 }
510
511 if width >= 0 {
512 output = ansi.Truncate(output, width, "…")
513 }
514 return sty.Tool.ParamMain.Render(output)
515}
516
517// toolHeader builds the tool header line: "● ToolName params..."
518func toolHeader(sty *styles.Styles, status ToolStatus, name string, width int, nested bool, params ...string) string {
519 icon := toolIcon(sty, status)
520 nameStyle := sty.Tool.NameNormal
521 if nested {
522 nameStyle = sty.Tool.NameNested
523 }
524 toolName := nameStyle.Render(name)
525 prefix := fmt.Sprintf("%s %s ", icon, toolName)
526 prefixWidth := lipgloss.Width(prefix)
527 remainingWidth := width - prefixWidth
528 paramsStr := toolParamList(sty, params, remainingWidth)
529 return prefix + paramsStr
530}
531
532// toolOutputPlainContent renders plain text with optional expansion support.
533func toolOutputPlainContent(sty *styles.Styles, content string, width int, expanded bool) string {
534 content = stringext.NormalizeSpace(content)
535 lines := strings.Split(content, "\n")
536
537 maxLines := responseContextHeight
538 if expanded {
539 maxLines = len(lines) // Show all
540 }
541
542 var out []string
543 for i, ln := range lines {
544 if i >= maxLines {
545 break
546 }
547 ln = " " + ln
548 if lipgloss.Width(ln) > width {
549 ln = ansi.Truncate(ln, width, "…")
550 }
551 out = append(out, sty.Tool.ContentLine.Width(width).Render(ln))
552 }
553
554 wasTruncated := len(lines) > responseContextHeight
555
556 if !expanded && wasTruncated {
557 out = append(out, sty.Tool.ContentTruncation.
558 Width(width).
559 Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-responseContextHeight)))
560 }
561
562 return strings.Join(out, "\n")
563}
564
565// toolOutputCodeContent renders code with syntax highlighting and line numbers.
566func toolOutputCodeContent(sty *styles.Styles, path, content string, offset, width int, expanded bool) string {
567 content = stringext.NormalizeSpace(content)
568
569 lines := strings.Split(content, "\n")
570 maxLines := responseContextHeight
571 if expanded {
572 maxLines = len(lines)
573 }
574
575 // Truncate if needed.
576 displayLines := lines
577 if len(lines) > maxLines {
578 displayLines = lines[:maxLines]
579 }
580
581 bg := sty.Tool.ContentCodeBg
582 highlighted, _ := common.SyntaxHighlight(sty, strings.Join(displayLines, "\n"), path, bg)
583 highlightedLines := strings.Split(highlighted, "\n")
584
585 // Calculate line number width.
586 maxLineNumber := len(displayLines) + offset
587 maxDigits := getDigits(maxLineNumber)
588 numFmt := fmt.Sprintf("%%%dd", maxDigits)
589
590 bodyWidth := width - toolBodyLeftPaddingTotal
591 codeWidth := bodyWidth - maxDigits - 4 // -4 for line number padding
592
593 var out []string
594 for i, ln := range highlightedLines {
595 lineNum := sty.Tool.ContentLineNumber.Render(fmt.Sprintf(numFmt, i+1+offset))
596
597 if lipgloss.Width(ln) > codeWidth {
598 ln = ansi.Truncate(ln, codeWidth, "…")
599 }
600
601 codeLine := sty.Tool.ContentCodeLine.
602 Width(codeWidth).
603 PaddingLeft(2).
604 Render(ln)
605
606 out = append(out, lipgloss.JoinHorizontal(lipgloss.Left, lineNum, codeLine))
607 }
608
609 // Add truncation message if needed.
610 if len(lines) > maxLines && !expanded {
611 out = append(out, sty.Tool.ContentCodeTruncation.
612 Width(bodyWidth).
613 Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)),
614 )
615 }
616
617 return sty.Tool.Body.Render(strings.Join(out, "\n"))
618}
619
620// toolOutputImageContent renders image data with size info.
621func toolOutputImageContent(sty *styles.Styles, data, mediaType string) string {
622 dataSize := len(data) * 3 / 4
623 sizeStr := formatSize(dataSize)
624
625 loaded := sty.Base.Foreground(sty.Green).Render("Loaded")
626 arrow := sty.Base.Foreground(sty.GreenDark).Render("→")
627 typeStyled := sty.Base.Render(mediaType)
628 sizeStyled := sty.Subtle.Render(sizeStr)
629
630 return sty.Tool.Body.Render(fmt.Sprintf("%s %s %s %s", loaded, arrow, typeStyled, sizeStyled))
631}
632
633// getDigits returns the number of digits in a number.
634func getDigits(n int) int {
635 if n == 0 {
636 return 1
637 }
638 if n < 0 {
639 n = -n
640 }
641 digits := 0
642 for n > 0 {
643 n /= 10
644 digits++
645 }
646 return digits
647}
648
649// formatSize formats byte size into human readable format.
650func formatSize(bytes int) string {
651 const (
652 kb = 1024
653 mb = kb * 1024
654 )
655 switch {
656 case bytes >= mb:
657 return fmt.Sprintf("%.1f MB", float64(bytes)/float64(mb))
658 case bytes >= kb:
659 return fmt.Sprintf("%.1f KB", float64(bytes)/float64(kb))
660 default:
661 return fmt.Sprintf("%d B", bytes)
662 }
663}
664
665// toolOutputDiffContent renders a diff between old and new content.
666func toolOutputDiffContent(sty *styles.Styles, file, oldContent, newContent string, width int, expanded bool) string {
667 bodyWidth := width - toolBodyLeftPaddingTotal
668
669 formatter := common.DiffFormatter(sty).
670 Before(file, oldContent).
671 After(file, newContent).
672 Width(bodyWidth)
673
674 // Use split view for wide terminals.
675 if width > maxTextWidth {
676 formatter = formatter.Split()
677 }
678
679 formatted := formatter.String()
680 lines := strings.Split(formatted, "\n")
681
682 // Truncate if needed.
683 maxLines := responseContextHeight
684 if expanded {
685 maxLines = len(lines)
686 }
687
688 if len(lines) > maxLines && !expanded {
689 truncMsg := sty.Tool.DiffTruncation.
690 Width(bodyWidth).
691 Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines))
692 formatted = strings.Join(lines[:maxLines], "\n") + "\n" + truncMsg
693 }
694
695 return sty.Tool.Body.Render(formatted)
696}
697
698// formatTimeout converts timeout seconds to a duration string (e.g., "30s").
699// Returns empty string if timeout is 0.
700func formatTimeout(timeout int) string {
701 if timeout == 0 {
702 return ""
703 }
704 return fmt.Sprintf("%ds", timeout)
705}
706
707// formatNonZero returns string representation of non-zero integers, empty string for zero.
708func formatNonZero(value int) string {
709 if value == 0 {
710 return ""
711 }
712 return fmt.Sprintf("%d", value)
713}
714
715// toolOutputMultiEditDiffContent renders a diff with optional failed edits note.
716func toolOutputMultiEditDiffContent(sty *styles.Styles, file string, meta tools.MultiEditResponseMetadata, totalEdits, width int, expanded bool) string {
717 bodyWidth := width - toolBodyLeftPaddingTotal
718
719 formatter := common.DiffFormatter(sty).
720 Before(file, meta.OldContent).
721 After(file, meta.NewContent).
722 Width(bodyWidth)
723
724 // Use split view for wide terminals.
725 if width > maxTextWidth {
726 formatter = formatter.Split()
727 }
728
729 formatted := formatter.String()
730 lines := strings.Split(formatted, "\n")
731
732 // Truncate if needed.
733 maxLines := responseContextHeight
734 if expanded {
735 maxLines = len(lines)
736 }
737
738 if len(lines) > maxLines && !expanded {
739 truncMsg := sty.Tool.DiffTruncation.
740 Width(bodyWidth).
741 Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines))
742 formatted = truncMsg + "\n" + strings.Join(lines[:maxLines], "\n")
743 }
744
745 // Add failed edits note if any exist.
746 if len(meta.EditsFailed) > 0 {
747 noteTag := sty.Tool.NoteTag.Render("Note")
748 noteMsg := fmt.Sprintf("%d of %d edits succeeded", meta.EditsApplied, totalEdits)
749 note := fmt.Sprintf("%s %s", noteTag, sty.Tool.NoteMessage.Render(noteMsg))
750 formatted = formatted + "\n\n" + note
751 }
752
753 return sty.Tool.Body.Render(formatted)
754}
755
756// roundedEnumerator creates a tree enumerator with rounded corners.
757func roundedEnumerator(lPadding, width int) tree.Enumerator {
758 if width == 0 {
759 width = 2
760 }
761 if lPadding == 0 {
762 lPadding = 1
763 }
764 return func(children tree.Children, index int) string {
765 line := strings.Repeat("─", width)
766 padding := strings.Repeat(" ", lPadding)
767 if children.Length()-1 == index {
768 return padding + "╰" + line
769 }
770 return padding + "├" + line
771 }
772}
773
774// toolOutputMarkdownContent renders markdown content with optional truncation.
775func toolOutputMarkdownContent(sty *styles.Styles, content string, width int, expanded bool) string {
776 content = stringext.NormalizeSpace(content)
777
778 // Cap width for readability.
779 if width > maxTextWidth {
780 width = maxTextWidth
781 }
782
783 renderer := common.PlainMarkdownRenderer(sty, width)
784 rendered, err := renderer.Render(content)
785 if err != nil {
786 return toolOutputPlainContent(sty, content, width, expanded)
787 }
788
789 lines := strings.Split(rendered, "\n")
790 maxLines := responseContextHeight
791 if expanded {
792 maxLines = len(lines)
793 }
794
795 var out []string
796 for i, ln := range lines {
797 if i >= maxLines {
798 break
799 }
800 out = append(out, ln)
801 }
802
803 if len(lines) > maxLines && !expanded {
804 out = append(out, sty.Tool.ContentTruncation.
805 Width(width).
806 Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)),
807 )
808 }
809
810 return sty.Tool.Body.Render(strings.Join(out, "\n"))
811}
812
813// formatToolForCopy formats the tool call for clipboard copying.
814func (t *baseToolMessageItem) formatToolForCopy() string {
815 var parts []string
816
817 toolName := prettifyToolName(t.toolCall.Name)
818 parts = append(parts, fmt.Sprintf("## %s Tool Call", toolName))
819
820 if t.toolCall.Input != "" {
821 params := t.formatParametersForCopy()
822 if params != "" {
823 parts = append(parts, "### Parameters:")
824 parts = append(parts, params)
825 }
826 }
827
828 if t.result != nil && t.result.ToolCallID != "" {
829 if t.result.IsError {
830 parts = append(parts, "### Error:")
831 parts = append(parts, t.result.Content)
832 } else {
833 parts = append(parts, "### Result:")
834 content := t.formatResultForCopy()
835 if content != "" {
836 parts = append(parts, content)
837 }
838 }
839 } else if t.status == ToolStatusCanceled {
840 parts = append(parts, "### Status:")
841 parts = append(parts, "Cancelled")
842 } else {
843 parts = append(parts, "### Status:")
844 parts = append(parts, "Pending...")
845 }
846
847 return strings.Join(parts, "\n\n")
848}
849
850// formatParametersForCopy formats tool parameters for clipboard copying.
851func (t *baseToolMessageItem) formatParametersForCopy() string {
852 switch t.toolCall.Name {
853 case tools.BashToolName:
854 var params tools.BashParams
855 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
856 cmd := strings.ReplaceAll(params.Command, "\n", " ")
857 cmd = strings.ReplaceAll(cmd, "\t", " ")
858 return fmt.Sprintf("**Command:** %s", cmd)
859 }
860 case tools.ViewToolName:
861 var params tools.ViewParams
862 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
863 var parts []string
864 parts = append(parts, fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)))
865 if params.Limit > 0 {
866 parts = append(parts, fmt.Sprintf("**Limit:** %d", params.Limit))
867 }
868 if params.Offset > 0 {
869 parts = append(parts, fmt.Sprintf("**Offset:** %d", params.Offset))
870 }
871 return strings.Join(parts, "\n")
872 }
873 case tools.EditToolName:
874 var params tools.EditParams
875 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
876 return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath))
877 }
878 case tools.MultiEditToolName:
879 var params tools.MultiEditParams
880 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
881 var parts []string
882 parts = append(parts, fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)))
883 parts = append(parts, fmt.Sprintf("**Edits:** %d", len(params.Edits)))
884 return strings.Join(parts, "\n")
885 }
886 case tools.WriteToolName:
887 var params tools.WriteParams
888 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
889 return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath))
890 }
891 case tools.FetchToolName:
892 var params tools.FetchParams
893 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
894 var parts []string
895 parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
896 if params.Format != "" {
897 parts = append(parts, fmt.Sprintf("**Format:** %s", params.Format))
898 }
899 if params.Timeout > 0 {
900 parts = append(parts, fmt.Sprintf("**Timeout:** %ds", params.Timeout))
901 }
902 return strings.Join(parts, "\n")
903 }
904 case tools.AgenticFetchToolName:
905 var params tools.AgenticFetchParams
906 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
907 var parts []string
908 if params.URL != "" {
909 parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
910 }
911 if params.Prompt != "" {
912 parts = append(parts, fmt.Sprintf("**Prompt:** %s", params.Prompt))
913 }
914 return strings.Join(parts, "\n")
915 }
916 case tools.WebFetchToolName:
917 var params tools.WebFetchParams
918 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
919 return fmt.Sprintf("**URL:** %s", params.URL)
920 }
921 case tools.GrepToolName:
922 var params tools.GrepParams
923 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
924 var parts []string
925 parts = append(parts, fmt.Sprintf("**Pattern:** %s", params.Pattern))
926 if params.Path != "" {
927 parts = append(parts, fmt.Sprintf("**Path:** %s", params.Path))
928 }
929 if params.Include != "" {
930 parts = append(parts, fmt.Sprintf("**Include:** %s", params.Include))
931 }
932 if params.LiteralText {
933 parts = append(parts, "**Literal:** true")
934 }
935 return strings.Join(parts, "\n")
936 }
937 case tools.GlobToolName:
938 var params tools.GlobParams
939 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
940 var parts []string
941 parts = append(parts, fmt.Sprintf("**Pattern:** %s", params.Pattern))
942 if params.Path != "" {
943 parts = append(parts, fmt.Sprintf("**Path:** %s", params.Path))
944 }
945 return strings.Join(parts, "\n")
946 }
947 case tools.LSToolName:
948 var params tools.LSParams
949 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
950 path := params.Path
951 if path == "" {
952 path = "."
953 }
954 return fmt.Sprintf("**Path:** %s", fsext.PrettyPath(path))
955 }
956 case tools.DownloadToolName:
957 var params tools.DownloadParams
958 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
959 var parts []string
960 parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
961 parts = append(parts, fmt.Sprintf("**File Path:** %s", fsext.PrettyPath(params.FilePath)))
962 if params.Timeout > 0 {
963 parts = append(parts, fmt.Sprintf("**Timeout:** %s", (time.Duration(params.Timeout)*time.Second).String()))
964 }
965 return strings.Join(parts, "\n")
966 }
967 case tools.SourcegraphToolName:
968 var params tools.SourcegraphParams
969 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
970 var parts []string
971 parts = append(parts, fmt.Sprintf("**Query:** %s", params.Query))
972 if params.Count > 0 {
973 parts = append(parts, fmt.Sprintf("**Count:** %d", params.Count))
974 }
975 if params.ContextWindow > 0 {
976 parts = append(parts, fmt.Sprintf("**Context:** %d", params.ContextWindow))
977 }
978 return strings.Join(parts, "\n")
979 }
980 case tools.DiagnosticsToolName:
981 return "**Project:** diagnostics"
982 case agent.AgentToolName:
983 var params agent.AgentParams
984 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
985 return fmt.Sprintf("**Task:**\n%s", params.Prompt)
986 }
987 }
988
989 var params map[string]any
990 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
991 var parts []string
992 for key, value := range params {
993 displayKey := strings.ReplaceAll(key, "_", " ")
994 if len(displayKey) > 0 {
995 displayKey = strings.ToUpper(displayKey[:1]) + displayKey[1:]
996 }
997 parts = append(parts, fmt.Sprintf("**%s:** %v", displayKey, value))
998 }
999 return strings.Join(parts, "\n")
1000 }
1001
1002 return ""
1003}
1004
1005// formatResultForCopy formats tool results for clipboard copying.
1006func (t *baseToolMessageItem) formatResultForCopy() string {
1007 if t.result == nil {
1008 return ""
1009 }
1010
1011 if t.result.Data != "" {
1012 if strings.HasPrefix(t.result.MIMEType, "image/") {
1013 return fmt.Sprintf("[Image: %s]", t.result.MIMEType)
1014 }
1015 return fmt.Sprintf("[Media: %s]", t.result.MIMEType)
1016 }
1017
1018 switch t.toolCall.Name {
1019 case tools.BashToolName:
1020 return t.formatBashResultForCopy()
1021 case tools.ViewToolName:
1022 return t.formatViewResultForCopy()
1023 case tools.EditToolName:
1024 return t.formatEditResultForCopy()
1025 case tools.MultiEditToolName:
1026 return t.formatMultiEditResultForCopy()
1027 case tools.WriteToolName:
1028 return t.formatWriteResultForCopy()
1029 case tools.FetchToolName:
1030 return t.formatFetchResultForCopy()
1031 case tools.AgenticFetchToolName:
1032 return t.formatAgenticFetchResultForCopy()
1033 case tools.WebFetchToolName:
1034 return t.formatWebFetchResultForCopy()
1035 case agent.AgentToolName:
1036 return t.formatAgentResultForCopy()
1037 case tools.DownloadToolName, tools.GrepToolName, tools.GlobToolName, tools.LSToolName, tools.SourcegraphToolName, tools.DiagnosticsToolName, tools.TodosToolName:
1038 return fmt.Sprintf("```\n%s\n```", t.result.Content)
1039 default:
1040 return t.result.Content
1041 }
1042}
1043
1044// formatBashResultForCopy formats bash tool results for clipboard.
1045func (t *baseToolMessageItem) formatBashResultForCopy() string {
1046 if t.result == nil {
1047 return ""
1048 }
1049
1050 var meta tools.BashResponseMetadata
1051 if t.result.Metadata != "" {
1052 json.Unmarshal([]byte(t.result.Metadata), &meta)
1053 }
1054
1055 output := meta.Output
1056 if output == "" && t.result.Content != tools.BashNoOutput {
1057 output = t.result.Content
1058 }
1059
1060 if output == "" {
1061 return ""
1062 }
1063
1064 return fmt.Sprintf("```bash\n%s\n```", output)
1065}
1066
1067// formatViewResultForCopy formats view tool results for clipboard.
1068func (t *baseToolMessageItem) formatViewResultForCopy() string {
1069 if t.result == nil {
1070 return ""
1071 }
1072
1073 var meta tools.ViewResponseMetadata
1074 if t.result.Metadata != "" {
1075 json.Unmarshal([]byte(t.result.Metadata), &meta)
1076 }
1077
1078 if meta.Content == "" {
1079 return t.result.Content
1080 }
1081
1082 lang := ""
1083 if meta.FilePath != "" {
1084 ext := strings.ToLower(filepath.Ext(meta.FilePath))
1085 switch ext {
1086 case ".go":
1087 lang = "go"
1088 case ".js", ".mjs":
1089 lang = "javascript"
1090 case ".ts":
1091 lang = "typescript"
1092 case ".py":
1093 lang = "python"
1094 case ".rs":
1095 lang = "rust"
1096 case ".java":
1097 lang = "java"
1098 case ".c":
1099 lang = "c"
1100 case ".cpp", ".cc", ".cxx":
1101 lang = "cpp"
1102 case ".sh", ".bash":
1103 lang = "bash"
1104 case ".json":
1105 lang = "json"
1106 case ".yaml", ".yml":
1107 lang = "yaml"
1108 case ".xml":
1109 lang = "xml"
1110 case ".html":
1111 lang = "html"
1112 case ".css":
1113 lang = "css"
1114 case ".md":
1115 lang = "markdown"
1116 }
1117 }
1118
1119 var result strings.Builder
1120 if lang != "" {
1121 fmt.Fprintf(&result, "```%s\n", lang)
1122 } else {
1123 result.WriteString("```\n")
1124 }
1125 result.WriteString(meta.Content)
1126 result.WriteString("\n```")
1127
1128 return result.String()
1129}
1130
1131// formatEditResultForCopy formats edit tool results for clipboard.
1132func (t *baseToolMessageItem) formatEditResultForCopy() string {
1133 if t.result == nil || t.result.Metadata == "" {
1134 if t.result != nil {
1135 return t.result.Content
1136 }
1137 return ""
1138 }
1139
1140 var meta tools.EditResponseMetadata
1141 if json.Unmarshal([]byte(t.result.Metadata), &meta) != nil {
1142 return t.result.Content
1143 }
1144
1145 var params tools.EditParams
1146 json.Unmarshal([]byte(t.toolCall.Input), ¶ms)
1147
1148 var result strings.Builder
1149
1150 if meta.OldContent != "" || meta.NewContent != "" {
1151 fileName := params.FilePath
1152 if fileName != "" {
1153 fileName = fsext.PrettyPath(fileName)
1154 }
1155 diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName)
1156
1157 fmt.Fprintf(&result, "Changes: +%d -%d\n", additions, removals)
1158 result.WriteString("```diff\n")
1159 result.WriteString(diffContent)
1160 result.WriteString("\n```")
1161 }
1162
1163 return result.String()
1164}
1165
1166// formatMultiEditResultForCopy formats multi-edit tool results for clipboard.
1167func (t *baseToolMessageItem) formatMultiEditResultForCopy() string {
1168 if t.result == nil || t.result.Metadata == "" {
1169 if t.result != nil {
1170 return t.result.Content
1171 }
1172 return ""
1173 }
1174
1175 var meta tools.MultiEditResponseMetadata
1176 if json.Unmarshal([]byte(t.result.Metadata), &meta) != nil {
1177 return t.result.Content
1178 }
1179
1180 var params tools.MultiEditParams
1181 json.Unmarshal([]byte(t.toolCall.Input), ¶ms)
1182
1183 var result strings.Builder
1184 if meta.OldContent != "" || meta.NewContent != "" {
1185 fileName := params.FilePath
1186 if fileName != "" {
1187 fileName = fsext.PrettyPath(fileName)
1188 }
1189 diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName)
1190
1191 fmt.Fprintf(&result, "Changes: +%d -%d\n", additions, removals)
1192 result.WriteString("```diff\n")
1193 result.WriteString(diffContent)
1194 result.WriteString("\n```")
1195 }
1196
1197 return result.String()
1198}
1199
1200// formatWriteResultForCopy formats write tool results for clipboard.
1201func (t *baseToolMessageItem) formatWriteResultForCopy() string {
1202 if t.result == nil {
1203 return ""
1204 }
1205
1206 var params tools.WriteParams
1207 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) != nil {
1208 return t.result.Content
1209 }
1210
1211 lang := ""
1212 if params.FilePath != "" {
1213 ext := strings.ToLower(filepath.Ext(params.FilePath))
1214 switch ext {
1215 case ".go":
1216 lang = "go"
1217 case ".js", ".mjs":
1218 lang = "javascript"
1219 case ".ts":
1220 lang = "typescript"
1221 case ".py":
1222 lang = "python"
1223 case ".rs":
1224 lang = "rust"
1225 case ".java":
1226 lang = "java"
1227 case ".c":
1228 lang = "c"
1229 case ".cpp", ".cc", ".cxx":
1230 lang = "cpp"
1231 case ".sh", ".bash":
1232 lang = "bash"
1233 case ".json":
1234 lang = "json"
1235 case ".yaml", ".yml":
1236 lang = "yaml"
1237 case ".xml":
1238 lang = "xml"
1239 case ".html":
1240 lang = "html"
1241 case ".css":
1242 lang = "css"
1243 case ".md":
1244 lang = "markdown"
1245 }
1246 }
1247
1248 var result strings.Builder
1249 fmt.Fprintf(&result, "File: %s\n", fsext.PrettyPath(params.FilePath))
1250 if lang != "" {
1251 fmt.Fprintf(&result, "```%s\n", lang)
1252 } else {
1253 result.WriteString("```\n")
1254 }
1255 result.WriteString(params.Content)
1256 result.WriteString("\n```")
1257
1258 return result.String()
1259}
1260
1261// formatFetchResultForCopy formats fetch tool results for clipboard.
1262func (t *baseToolMessageItem) formatFetchResultForCopy() string {
1263 if t.result == nil {
1264 return ""
1265 }
1266
1267 var params tools.FetchParams
1268 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) != nil {
1269 return t.result.Content
1270 }
1271
1272 var result strings.Builder
1273 if params.URL != "" {
1274 fmt.Fprintf(&result, "URL: %s\n", params.URL)
1275 }
1276 if params.Format != "" {
1277 fmt.Fprintf(&result, "Format: %s\n", params.Format)
1278 }
1279 if params.Timeout > 0 {
1280 fmt.Fprintf(&result, "Timeout: %ds\n", params.Timeout)
1281 }
1282 result.WriteString("\n")
1283
1284 result.WriteString(t.result.Content)
1285
1286 return result.String()
1287}
1288
1289// formatAgenticFetchResultForCopy formats agentic fetch tool results for clipboard.
1290func (t *baseToolMessageItem) formatAgenticFetchResultForCopy() string {
1291 if t.result == nil {
1292 return ""
1293 }
1294
1295 var params tools.AgenticFetchParams
1296 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) != nil {
1297 return t.result.Content
1298 }
1299
1300 var result strings.Builder
1301 if params.URL != "" {
1302 fmt.Fprintf(&result, "URL: %s\n", params.URL)
1303 }
1304 if params.Prompt != "" {
1305 fmt.Fprintf(&result, "Prompt: %s\n\n", params.Prompt)
1306 }
1307
1308 result.WriteString("```markdown\n")
1309 result.WriteString(t.result.Content)
1310 result.WriteString("\n```")
1311
1312 return result.String()
1313}
1314
1315// formatWebFetchResultForCopy formats web fetch tool results for clipboard.
1316func (t *baseToolMessageItem) formatWebFetchResultForCopy() string {
1317 if t.result == nil {
1318 return ""
1319 }
1320
1321 var params tools.WebFetchParams
1322 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) != nil {
1323 return t.result.Content
1324 }
1325
1326 var result strings.Builder
1327 result.WriteString(fmt.Sprintf("URL: %s\n\n", params.URL))
1328 result.WriteString("```markdown\n")
1329 result.WriteString(t.result.Content)
1330 result.WriteString("\n```")
1331
1332 return result.String()
1333}
1334
1335// formatAgentResultForCopy formats agent tool results for clipboard.
1336func (t *baseToolMessageItem) formatAgentResultForCopy() string {
1337 if t.result == nil {
1338 return ""
1339 }
1340
1341 var result strings.Builder
1342
1343 if t.result.Content != "" {
1344 result.WriteString(fmt.Sprintf("```markdown\n%s\n```", t.result.Content))
1345 }
1346
1347 return result.String()
1348}
1349
1350// prettifyToolName returns a human-readable name for tool names.
1351func prettifyToolName(name string) string {
1352 switch name {
1353 case agent.AgentToolName:
1354 return "Agent"
1355 case tools.BashToolName:
1356 return "Bash"
1357 case tools.JobOutputToolName:
1358 return "Job: Output"
1359 case tools.JobKillToolName:
1360 return "Job: Kill"
1361 case tools.DownloadToolName:
1362 return "Download"
1363 case tools.EditToolName:
1364 return "Edit"
1365 case tools.MultiEditToolName:
1366 return "Multi-Edit"
1367 case tools.FetchToolName:
1368 return "Fetch"
1369 case tools.AgenticFetchToolName:
1370 return "Agentic Fetch"
1371 case tools.WebFetchToolName:
1372 return "Fetch"
1373 case tools.WebSearchToolName:
1374 return "Search"
1375 case tools.GlobToolName:
1376 return "Glob"
1377 case tools.GrepToolName:
1378 return "Grep"
1379 case tools.LSToolName:
1380 return "List"
1381 case tools.SourcegraphToolName:
1382 return "Sourcegraph"
1383 case tools.TodosToolName:
1384 return "To-Do"
1385 case tools.ViewToolName:
1386 return "View"
1387 case tools.WriteToolName:
1388 return "Write"
1389 default:
1390 return genericPrettyName(name)
1391 }
1392}