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
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 // Truncate accounting for padding that will be added.
598 ln = ansi.Truncate(ln, codeWidth-sty.Tool.ContentCodeLine.GetHorizontalPadding(), "…")
599
600 codeLine := sty.Tool.ContentCodeLine.
601 Width(codeWidth).
602 Render(ln)
603
604 out = append(out, lipgloss.JoinHorizontal(lipgloss.Left, lineNum, codeLine))
605 }
606
607 // Add truncation message if needed.
608 if len(lines) > maxLines && !expanded {
609 out = append(out, sty.Tool.ContentCodeTruncation.
610 Width(width).
611 Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)),
612 )
613 }
614
615 return sty.Tool.Body.Render(strings.Join(out, "\n"))
616}
617
618// toolOutputImageContent renders image data with size info.
619func toolOutputImageContent(sty *styles.Styles, data, mediaType string) string {
620 dataSize := len(data) * 3 / 4
621 sizeStr := formatSize(dataSize)
622
623 loaded := sty.Base.Foreground(sty.Green).Render("Loaded")
624 arrow := sty.Base.Foreground(sty.GreenDark).Render("→")
625 typeStyled := sty.Base.Render(mediaType)
626 sizeStyled := sty.Subtle.Render(sizeStr)
627
628 return sty.Tool.Body.Render(fmt.Sprintf("%s %s %s %s", loaded, arrow, typeStyled, sizeStyled))
629}
630
631// getDigits returns the number of digits in a number.
632func getDigits(n int) int {
633 if n == 0 {
634 return 1
635 }
636 if n < 0 {
637 n = -n
638 }
639 digits := 0
640 for n > 0 {
641 n /= 10
642 digits++
643 }
644 return digits
645}
646
647// formatSize formats byte size into human readable format.
648func formatSize(bytes int) string {
649 const (
650 kb = 1024
651 mb = kb * 1024
652 )
653 switch {
654 case bytes >= mb:
655 return fmt.Sprintf("%.1f MB", float64(bytes)/float64(mb))
656 case bytes >= kb:
657 return fmt.Sprintf("%.1f KB", float64(bytes)/float64(kb))
658 default:
659 return fmt.Sprintf("%d B", bytes)
660 }
661}
662
663// toolOutputDiffContent renders a diff between old and new content.
664func toolOutputDiffContent(sty *styles.Styles, file, oldContent, newContent string, width int, expanded bool) string {
665 bodyWidth := width - toolBodyLeftPaddingTotal
666
667 formatter := common.DiffFormatter(sty).
668 Before(file, oldContent).
669 After(file, newContent).
670 Width(bodyWidth)
671
672 // Use split view for wide terminals.
673 if width > maxTextWidth {
674 formatter = formatter.Split()
675 }
676
677 formatted := formatter.String()
678 lines := strings.Split(formatted, "\n")
679
680 // Truncate if needed.
681 maxLines := responseContextHeight
682 if expanded {
683 maxLines = len(lines)
684 }
685
686 if len(lines) > maxLines && !expanded {
687 truncMsg := sty.Tool.DiffTruncation.
688 Width(bodyWidth).
689 Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines))
690 formatted = strings.Join(lines[:maxLines], "\n") + "\n" + truncMsg
691 }
692
693 return sty.Tool.Body.Render(formatted)
694}
695
696// formatTimeout converts timeout seconds to a duration string (e.g., "30s").
697// Returns empty string if timeout is 0.
698func formatTimeout(timeout int) string {
699 if timeout == 0 {
700 return ""
701 }
702 return fmt.Sprintf("%ds", timeout)
703}
704
705// formatNonZero returns string representation of non-zero integers, empty string for zero.
706func formatNonZero(value int) string {
707 if value == 0 {
708 return ""
709 }
710 return fmt.Sprintf("%d", value)
711}
712
713// toolOutputMultiEditDiffContent renders a diff with optional failed edits note.
714func toolOutputMultiEditDiffContent(sty *styles.Styles, file string, meta tools.MultiEditResponseMetadata, totalEdits, width int, expanded bool) string {
715 bodyWidth := width - toolBodyLeftPaddingTotal
716
717 formatter := common.DiffFormatter(sty).
718 Before(file, meta.OldContent).
719 After(file, meta.NewContent).
720 Width(bodyWidth)
721
722 // Use split view for wide terminals.
723 if width > maxTextWidth {
724 formatter = formatter.Split()
725 }
726
727 formatted := formatter.String()
728 lines := strings.Split(formatted, "\n")
729
730 // Truncate if needed.
731 maxLines := responseContextHeight
732 if expanded {
733 maxLines = len(lines)
734 }
735
736 if len(lines) > maxLines && !expanded {
737 truncMsg := sty.Tool.DiffTruncation.
738 Width(bodyWidth).
739 Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines))
740 formatted = truncMsg + "\n" + strings.Join(lines[:maxLines], "\n")
741 }
742
743 // Add failed edits note if any exist.
744 if len(meta.EditsFailed) > 0 {
745 noteTag := sty.Tool.NoteTag.Render("Note")
746 noteMsg := fmt.Sprintf("%d of %d edits succeeded", meta.EditsApplied, totalEdits)
747 note := fmt.Sprintf("%s %s", noteTag, sty.Tool.NoteMessage.Render(noteMsg))
748 formatted = formatted + "\n\n" + note
749 }
750
751 return sty.Tool.Body.Render(formatted)
752}
753
754// roundedEnumerator creates a tree enumerator with rounded corners.
755func roundedEnumerator(lPadding, width int) tree.Enumerator {
756 if width == 0 {
757 width = 2
758 }
759 if lPadding == 0 {
760 lPadding = 1
761 }
762 return func(children tree.Children, index int) string {
763 line := strings.Repeat("─", width)
764 padding := strings.Repeat(" ", lPadding)
765 if children.Length()-1 == index {
766 return padding + "╰" + line
767 }
768 return padding + "├" + line
769 }
770}
771
772// toolOutputMarkdownContent renders markdown content with optional truncation.
773func toolOutputMarkdownContent(sty *styles.Styles, content string, width int, expanded bool) string {
774 content = stringext.NormalizeSpace(content)
775
776 // Cap width for readability.
777 if width > maxTextWidth {
778 width = maxTextWidth
779 }
780
781 renderer := common.PlainMarkdownRenderer(sty, width)
782 rendered, err := renderer.Render(content)
783 if err != nil {
784 return toolOutputPlainContent(sty, content, width, expanded)
785 }
786
787 lines := strings.Split(rendered, "\n")
788 maxLines := responseContextHeight
789 if expanded {
790 maxLines = len(lines)
791 }
792
793 var out []string
794 for i, ln := range lines {
795 if i >= maxLines {
796 break
797 }
798 out = append(out, ln)
799 }
800
801 if len(lines) > maxLines && !expanded {
802 out = append(out, sty.Tool.ContentTruncation.
803 Width(width).
804 Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)),
805 )
806 }
807
808 return sty.Tool.Body.Render(strings.Join(out, "\n"))
809}
810
811// formatToolForCopy formats the tool call for clipboard copying.
812func (t *baseToolMessageItem) formatToolForCopy() string {
813 var parts []string
814
815 toolName := prettifyToolName(t.toolCall.Name)
816 parts = append(parts, fmt.Sprintf("## %s Tool Call", toolName))
817
818 if t.toolCall.Input != "" {
819 params := t.formatParametersForCopy()
820 if params != "" {
821 parts = append(parts, "### Parameters:")
822 parts = append(parts, params)
823 }
824 }
825
826 if t.result != nil && t.result.ToolCallID != "" {
827 if t.result.IsError {
828 parts = append(parts, "### Error:")
829 parts = append(parts, t.result.Content)
830 } else {
831 parts = append(parts, "### Result:")
832 content := t.formatResultForCopy()
833 if content != "" {
834 parts = append(parts, content)
835 }
836 }
837 } else if t.status == ToolStatusCanceled {
838 parts = append(parts, "### Status:")
839 parts = append(parts, "Cancelled")
840 } else {
841 parts = append(parts, "### Status:")
842 parts = append(parts, "Pending...")
843 }
844
845 return strings.Join(parts, "\n\n")
846}
847
848// formatParametersForCopy formats tool parameters for clipboard copying.
849func (t *baseToolMessageItem) formatParametersForCopy() string {
850 switch t.toolCall.Name {
851 case tools.BashToolName:
852 var params tools.BashParams
853 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
854 cmd := strings.ReplaceAll(params.Command, "\n", " ")
855 cmd = strings.ReplaceAll(cmd, "\t", " ")
856 return fmt.Sprintf("**Command:** %s", cmd)
857 }
858 case tools.ViewToolName:
859 var params tools.ViewParams
860 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
861 var parts []string
862 parts = append(parts, fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)))
863 if params.Limit > 0 {
864 parts = append(parts, fmt.Sprintf("**Limit:** %d", params.Limit))
865 }
866 if params.Offset > 0 {
867 parts = append(parts, fmt.Sprintf("**Offset:** %d", params.Offset))
868 }
869 return strings.Join(parts, "\n")
870 }
871 case tools.EditToolName:
872 var params tools.EditParams
873 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
874 return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath))
875 }
876 case tools.MultiEditToolName:
877 var params tools.MultiEditParams
878 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
879 var parts []string
880 parts = append(parts, fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)))
881 parts = append(parts, fmt.Sprintf("**Edits:** %d", len(params.Edits)))
882 return strings.Join(parts, "\n")
883 }
884 case tools.WriteToolName:
885 var params tools.WriteParams
886 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
887 return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath))
888 }
889 case tools.FetchToolName:
890 var params tools.FetchParams
891 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
892 var parts []string
893 parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
894 if params.Format != "" {
895 parts = append(parts, fmt.Sprintf("**Format:** %s", params.Format))
896 }
897 if params.Timeout > 0 {
898 parts = append(parts, fmt.Sprintf("**Timeout:** %ds", params.Timeout))
899 }
900 return strings.Join(parts, "\n")
901 }
902 case tools.AgenticFetchToolName:
903 var params tools.AgenticFetchParams
904 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
905 var parts []string
906 if params.URL != "" {
907 parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
908 }
909 if params.Prompt != "" {
910 parts = append(parts, fmt.Sprintf("**Prompt:** %s", params.Prompt))
911 }
912 return strings.Join(parts, "\n")
913 }
914 case tools.WebFetchToolName:
915 var params tools.WebFetchParams
916 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
917 return fmt.Sprintf("**URL:** %s", params.URL)
918 }
919 case tools.GrepToolName:
920 var params tools.GrepParams
921 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
922 var parts []string
923 parts = append(parts, fmt.Sprintf("**Pattern:** %s", params.Pattern))
924 if params.Path != "" {
925 parts = append(parts, fmt.Sprintf("**Path:** %s", params.Path))
926 }
927 if params.Include != "" {
928 parts = append(parts, fmt.Sprintf("**Include:** %s", params.Include))
929 }
930 if params.LiteralText {
931 parts = append(parts, "**Literal:** true")
932 }
933 return strings.Join(parts, "\n")
934 }
935 case tools.GlobToolName:
936 var params tools.GlobParams
937 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
938 var parts []string
939 parts = append(parts, fmt.Sprintf("**Pattern:** %s", params.Pattern))
940 if params.Path != "" {
941 parts = append(parts, fmt.Sprintf("**Path:** %s", params.Path))
942 }
943 return strings.Join(parts, "\n")
944 }
945 case tools.LSToolName:
946 var params tools.LSParams
947 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
948 path := params.Path
949 if path == "" {
950 path = "."
951 }
952 return fmt.Sprintf("**Path:** %s", fsext.PrettyPath(path))
953 }
954 case tools.DownloadToolName:
955 var params tools.DownloadParams
956 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
957 var parts []string
958 parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
959 parts = append(parts, fmt.Sprintf("**File Path:** %s", fsext.PrettyPath(params.FilePath)))
960 if params.Timeout > 0 {
961 parts = append(parts, fmt.Sprintf("**Timeout:** %s", (time.Duration(params.Timeout)*time.Second).String()))
962 }
963 return strings.Join(parts, "\n")
964 }
965 case tools.SourcegraphToolName:
966 var params tools.SourcegraphParams
967 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
968 var parts []string
969 parts = append(parts, fmt.Sprintf("**Query:** %s", params.Query))
970 if params.Count > 0 {
971 parts = append(parts, fmt.Sprintf("**Count:** %d", params.Count))
972 }
973 if params.ContextWindow > 0 {
974 parts = append(parts, fmt.Sprintf("**Context:** %d", params.ContextWindow))
975 }
976 return strings.Join(parts, "\n")
977 }
978 case tools.DiagnosticsToolName:
979 return "**Project:** diagnostics"
980 case agent.AgentToolName:
981 var params agent.AgentParams
982 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
983 return fmt.Sprintf("**Task:**\n%s", params.Prompt)
984 }
985 }
986
987 var params map[string]any
988 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
989 var parts []string
990 for key, value := range params {
991 displayKey := strings.ReplaceAll(key, "_", " ")
992 if len(displayKey) > 0 {
993 displayKey = strings.ToUpper(displayKey[:1]) + displayKey[1:]
994 }
995 parts = append(parts, fmt.Sprintf("**%s:** %v", displayKey, value))
996 }
997 return strings.Join(parts, "\n")
998 }
999
1000 return ""
1001}
1002
1003// formatResultForCopy formats tool results for clipboard copying.
1004func (t *baseToolMessageItem) formatResultForCopy() string {
1005 if t.result == nil {
1006 return ""
1007 }
1008
1009 if t.result.Data != "" {
1010 if strings.HasPrefix(t.result.MIMEType, "image/") {
1011 return fmt.Sprintf("[Image: %s]", t.result.MIMEType)
1012 }
1013 return fmt.Sprintf("[Media: %s]", t.result.MIMEType)
1014 }
1015
1016 switch t.toolCall.Name {
1017 case tools.BashToolName:
1018 return t.formatBashResultForCopy()
1019 case tools.ViewToolName:
1020 return t.formatViewResultForCopy()
1021 case tools.EditToolName:
1022 return t.formatEditResultForCopy()
1023 case tools.MultiEditToolName:
1024 return t.formatMultiEditResultForCopy()
1025 case tools.WriteToolName:
1026 return t.formatWriteResultForCopy()
1027 case tools.FetchToolName:
1028 return t.formatFetchResultForCopy()
1029 case tools.AgenticFetchToolName:
1030 return t.formatAgenticFetchResultForCopy()
1031 case tools.WebFetchToolName:
1032 return t.formatWebFetchResultForCopy()
1033 case agent.AgentToolName:
1034 return t.formatAgentResultForCopy()
1035 case tools.DownloadToolName, tools.GrepToolName, tools.GlobToolName, tools.LSToolName, tools.SourcegraphToolName, tools.DiagnosticsToolName, tools.TodosToolName:
1036 return fmt.Sprintf("```\n%s\n```", t.result.Content)
1037 default:
1038 return t.result.Content
1039 }
1040}
1041
1042// formatBashResultForCopy formats bash tool results for clipboard.
1043func (t *baseToolMessageItem) formatBashResultForCopy() string {
1044 if t.result == nil {
1045 return ""
1046 }
1047
1048 var meta tools.BashResponseMetadata
1049 if t.result.Metadata != "" {
1050 json.Unmarshal([]byte(t.result.Metadata), &meta)
1051 }
1052
1053 output := meta.Output
1054 if output == "" && t.result.Content != tools.BashNoOutput {
1055 output = t.result.Content
1056 }
1057
1058 if output == "" {
1059 return ""
1060 }
1061
1062 return fmt.Sprintf("```bash\n%s\n```", output)
1063}
1064
1065// formatViewResultForCopy formats view tool results for clipboard.
1066func (t *baseToolMessageItem) formatViewResultForCopy() string {
1067 if t.result == nil {
1068 return ""
1069 }
1070
1071 var meta tools.ViewResponseMetadata
1072 if t.result.Metadata != "" {
1073 json.Unmarshal([]byte(t.result.Metadata), &meta)
1074 }
1075
1076 if meta.Content == "" {
1077 return t.result.Content
1078 }
1079
1080 lang := ""
1081 if meta.FilePath != "" {
1082 ext := strings.ToLower(filepath.Ext(meta.FilePath))
1083 switch ext {
1084 case ".go":
1085 lang = "go"
1086 case ".js", ".mjs":
1087 lang = "javascript"
1088 case ".ts":
1089 lang = "typescript"
1090 case ".py":
1091 lang = "python"
1092 case ".rs":
1093 lang = "rust"
1094 case ".java":
1095 lang = "java"
1096 case ".c":
1097 lang = "c"
1098 case ".cpp", ".cc", ".cxx":
1099 lang = "cpp"
1100 case ".sh", ".bash":
1101 lang = "bash"
1102 case ".json":
1103 lang = "json"
1104 case ".yaml", ".yml":
1105 lang = "yaml"
1106 case ".xml":
1107 lang = "xml"
1108 case ".html":
1109 lang = "html"
1110 case ".css":
1111 lang = "css"
1112 case ".md":
1113 lang = "markdown"
1114 }
1115 }
1116
1117 var result strings.Builder
1118 if lang != "" {
1119 fmt.Fprintf(&result, "```%s\n", lang)
1120 } else {
1121 result.WriteString("```\n")
1122 }
1123 result.WriteString(meta.Content)
1124 result.WriteString("\n```")
1125
1126 return result.String()
1127}
1128
1129// formatEditResultForCopy formats edit tool results for clipboard.
1130func (t *baseToolMessageItem) formatEditResultForCopy() string {
1131 if t.result == nil || t.result.Metadata == "" {
1132 if t.result != nil {
1133 return t.result.Content
1134 }
1135 return ""
1136 }
1137
1138 var meta tools.EditResponseMetadata
1139 if json.Unmarshal([]byte(t.result.Metadata), &meta) != nil {
1140 return t.result.Content
1141 }
1142
1143 var params tools.EditParams
1144 json.Unmarshal([]byte(t.toolCall.Input), ¶ms)
1145
1146 var result strings.Builder
1147
1148 if meta.OldContent != "" || meta.NewContent != "" {
1149 fileName := params.FilePath
1150 if fileName != "" {
1151 fileName = fsext.PrettyPath(fileName)
1152 }
1153 diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName)
1154
1155 fmt.Fprintf(&result, "Changes: +%d -%d\n", additions, removals)
1156 result.WriteString("```diff\n")
1157 result.WriteString(diffContent)
1158 result.WriteString("\n```")
1159 }
1160
1161 return result.String()
1162}
1163
1164// formatMultiEditResultForCopy formats multi-edit tool results for clipboard.
1165func (t *baseToolMessageItem) formatMultiEditResultForCopy() string {
1166 if t.result == nil || t.result.Metadata == "" {
1167 if t.result != nil {
1168 return t.result.Content
1169 }
1170 return ""
1171 }
1172
1173 var meta tools.MultiEditResponseMetadata
1174 if json.Unmarshal([]byte(t.result.Metadata), &meta) != nil {
1175 return t.result.Content
1176 }
1177
1178 var params tools.MultiEditParams
1179 json.Unmarshal([]byte(t.toolCall.Input), ¶ms)
1180
1181 var result strings.Builder
1182 if meta.OldContent != "" || meta.NewContent != "" {
1183 fileName := params.FilePath
1184 if fileName != "" {
1185 fileName = fsext.PrettyPath(fileName)
1186 }
1187 diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName)
1188
1189 fmt.Fprintf(&result, "Changes: +%d -%d\n", additions, removals)
1190 result.WriteString("```diff\n")
1191 result.WriteString(diffContent)
1192 result.WriteString("\n```")
1193 }
1194
1195 return result.String()
1196}
1197
1198// formatWriteResultForCopy formats write tool results for clipboard.
1199func (t *baseToolMessageItem) formatWriteResultForCopy() string {
1200 if t.result == nil {
1201 return ""
1202 }
1203
1204 var params tools.WriteParams
1205 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) != nil {
1206 return t.result.Content
1207 }
1208
1209 lang := ""
1210 if params.FilePath != "" {
1211 ext := strings.ToLower(filepath.Ext(params.FilePath))
1212 switch ext {
1213 case ".go":
1214 lang = "go"
1215 case ".js", ".mjs":
1216 lang = "javascript"
1217 case ".ts":
1218 lang = "typescript"
1219 case ".py":
1220 lang = "python"
1221 case ".rs":
1222 lang = "rust"
1223 case ".java":
1224 lang = "java"
1225 case ".c":
1226 lang = "c"
1227 case ".cpp", ".cc", ".cxx":
1228 lang = "cpp"
1229 case ".sh", ".bash":
1230 lang = "bash"
1231 case ".json":
1232 lang = "json"
1233 case ".yaml", ".yml":
1234 lang = "yaml"
1235 case ".xml":
1236 lang = "xml"
1237 case ".html":
1238 lang = "html"
1239 case ".css":
1240 lang = "css"
1241 case ".md":
1242 lang = "markdown"
1243 }
1244 }
1245
1246 var result strings.Builder
1247 fmt.Fprintf(&result, "File: %s\n", fsext.PrettyPath(params.FilePath))
1248 if lang != "" {
1249 fmt.Fprintf(&result, "```%s\n", lang)
1250 } else {
1251 result.WriteString("```\n")
1252 }
1253 result.WriteString(params.Content)
1254 result.WriteString("\n```")
1255
1256 return result.String()
1257}
1258
1259// formatFetchResultForCopy formats fetch tool results for clipboard.
1260func (t *baseToolMessageItem) formatFetchResultForCopy() string {
1261 if t.result == nil {
1262 return ""
1263 }
1264
1265 var params tools.FetchParams
1266 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) != nil {
1267 return t.result.Content
1268 }
1269
1270 var result strings.Builder
1271 if params.URL != "" {
1272 fmt.Fprintf(&result, "URL: %s\n", params.URL)
1273 }
1274 if params.Format != "" {
1275 fmt.Fprintf(&result, "Format: %s\n", params.Format)
1276 }
1277 if params.Timeout > 0 {
1278 fmt.Fprintf(&result, "Timeout: %ds\n", params.Timeout)
1279 }
1280 result.WriteString("\n")
1281
1282 result.WriteString(t.result.Content)
1283
1284 return result.String()
1285}
1286
1287// formatAgenticFetchResultForCopy formats agentic fetch tool results for clipboard.
1288func (t *baseToolMessageItem) formatAgenticFetchResultForCopy() string {
1289 if t.result == nil {
1290 return ""
1291 }
1292
1293 var params tools.AgenticFetchParams
1294 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) != nil {
1295 return t.result.Content
1296 }
1297
1298 var result strings.Builder
1299 if params.URL != "" {
1300 fmt.Fprintf(&result, "URL: %s\n", params.URL)
1301 }
1302 if params.Prompt != "" {
1303 fmt.Fprintf(&result, "Prompt: %s\n\n", params.Prompt)
1304 }
1305
1306 result.WriteString("```markdown\n")
1307 result.WriteString(t.result.Content)
1308 result.WriteString("\n```")
1309
1310 return result.String()
1311}
1312
1313// formatWebFetchResultForCopy formats web fetch tool results for clipboard.
1314func (t *baseToolMessageItem) formatWebFetchResultForCopy() string {
1315 if t.result == nil {
1316 return ""
1317 }
1318
1319 var params tools.WebFetchParams
1320 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) != nil {
1321 return t.result.Content
1322 }
1323
1324 var result strings.Builder
1325 result.WriteString(fmt.Sprintf("URL: %s\n\n", params.URL))
1326 result.WriteString("```markdown\n")
1327 result.WriteString(t.result.Content)
1328 result.WriteString("\n```")
1329
1330 return result.String()
1331}
1332
1333// formatAgentResultForCopy formats agent tool results for clipboard.
1334func (t *baseToolMessageItem) formatAgentResultForCopy() string {
1335 if t.result == nil {
1336 return ""
1337 }
1338
1339 var result strings.Builder
1340
1341 if t.result.Content != "" {
1342 result.WriteString(fmt.Sprintf("```markdown\n%s\n```", t.result.Content))
1343 }
1344
1345 return result.String()
1346}
1347
1348// prettifyToolName returns a human-readable name for tool names.
1349func prettifyToolName(name string) string {
1350 switch name {
1351 case agent.AgentToolName:
1352 return "Agent"
1353 case tools.BashToolName:
1354 return "Bash"
1355 case tools.JobOutputToolName:
1356 return "Job: Output"
1357 case tools.JobKillToolName:
1358 return "Job: Kill"
1359 case tools.DownloadToolName:
1360 return "Download"
1361 case tools.EditToolName:
1362 return "Edit"
1363 case tools.MultiEditToolName:
1364 return "Multi-Edit"
1365 case tools.FetchToolName:
1366 return "Fetch"
1367 case tools.AgenticFetchToolName:
1368 return "Agentic Fetch"
1369 case tools.WebFetchToolName:
1370 return "Fetch"
1371 case tools.WebSearchToolName:
1372 return "Search"
1373 case tools.GlobToolName:
1374 return "Glob"
1375 case tools.GrepToolName:
1376 return "Grep"
1377 case tools.LSToolName:
1378 return "List"
1379 case tools.SourcegraphToolName:
1380 return "Sourcegraph"
1381 case tools.TodosToolName:
1382 return "To-Do"
1383 case tools.ViewToolName:
1384 return "View"
1385 case tools.WriteToolName:
1386 return "Write"
1387 default:
1388 return genericPrettyName(name)
1389 }
1390}