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
299 content, height, ok := t.getCachedRender(toolItemWidth)
300 // if we are spinning or there is no cache rerender
301 if !ok || t.isSpinning() {
302 content = t.toolRenderer.RenderTool(t.sty, toolItemWidth, &ToolRenderOpts{
303 ToolCall: t.toolCall,
304 Result: t.result,
305 Anim: t.anim,
306 ExpandedContent: t.expandedContent,
307 Compact: t.isCompact,
308 IsSpinning: t.isSpinning(),
309 Status: t.computeStatus(),
310 })
311 height = lipgloss.Height(content)
312 // cache the rendered content
313 t.setCachedRender(content, toolItemWidth, height)
314 }
315
316 return t.renderHighlighted(content, toolItemWidth, height)
317}
318
319// Render renders the tool message item at the given width.
320func (t *baseToolMessageItem) Render(width int) string {
321 style := t.sty.Chat.Message.ToolCallBlurred
322 if t.focused {
323 style = t.sty.Chat.Message.ToolCallFocused
324 }
325
326 if t.isCompact {
327 style = t.sty.Chat.Message.ToolCallCompact
328 }
329
330 return style.Render(t.RawRender(width))
331}
332
333// ToolCall returns the tool call associated with this message item.
334func (t *baseToolMessageItem) ToolCall() message.ToolCall {
335 return t.toolCall
336}
337
338// SetToolCall sets the tool call associated with this message item.
339func (t *baseToolMessageItem) SetToolCall(tc message.ToolCall) {
340 t.toolCall = tc
341 t.clearCache()
342}
343
344// SetResult sets the tool result associated with this message item.
345func (t *baseToolMessageItem) SetResult(res *message.ToolResult) {
346 t.result = res
347 t.clearCache()
348}
349
350// MessageID returns the ID of the message containing this tool call.
351func (t *baseToolMessageItem) MessageID() string {
352 return t.messageID
353}
354
355// SetMessageID sets the ID of the message containing this tool call.
356func (t *baseToolMessageItem) SetMessageID(id string) {
357 t.messageID = id
358}
359
360// SetStatus sets the tool status.
361func (t *baseToolMessageItem) SetStatus(status ToolStatus) {
362 t.status = status
363 t.clearCache()
364}
365
366// Status returns the current tool status.
367func (t *baseToolMessageItem) Status() ToolStatus {
368 return t.status
369}
370
371// computeStatus computes the effective status considering the result.
372func (t *baseToolMessageItem) computeStatus() ToolStatus {
373 if t.result != nil {
374 if t.result.IsError {
375 return ToolStatusError
376 }
377 return ToolStatusSuccess
378 }
379 return t.status
380}
381
382// isSpinning returns true if the tool should show animation.
383func (t *baseToolMessageItem) isSpinning() bool {
384 if t.spinningFunc != nil {
385 return t.spinningFunc(SpinningState{
386 ToolCall: t.toolCall,
387 Result: t.result,
388 Status: t.status,
389 })
390 }
391 return !t.toolCall.Finished && t.status != ToolStatusCanceled
392}
393
394// SetSpinningFunc sets a custom function to determine if the tool should spin.
395func (t *baseToolMessageItem) SetSpinningFunc(fn SpinningFunc) {
396 t.spinningFunc = fn
397}
398
399// ToggleExpanded toggles the expanded state of the thinking box.
400func (t *baseToolMessageItem) ToggleExpanded() bool {
401 t.expandedContent = !t.expandedContent
402 t.clearCache()
403 return t.expandedContent
404}
405
406// HandleMouseClick implements MouseClickable.
407func (t *baseToolMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
408 return btn == ansi.MouseLeft
409}
410
411// HandleKeyEvent implements KeyEventHandler.
412func (t *baseToolMessageItem) HandleKeyEvent(key tea.KeyMsg) (bool, tea.Cmd) {
413 if k := key.String(); k == "c" || k == "y" {
414 text := t.formatToolForCopy()
415 return true, common.CopyToClipboard(text, "Tool content copied to clipboard")
416 }
417 return false, nil
418}
419
420// pendingTool renders a tool that is still in progress with an animation.
421func pendingTool(sty *styles.Styles, name string, anim *anim.Anim) string {
422 icon := sty.Tool.IconPending.Render()
423 toolName := sty.Tool.NameNormal.Render(name)
424
425 var animView string
426 if anim != nil {
427 animView = anim.Render()
428 }
429
430 return fmt.Sprintf("%s %s %s", icon, toolName, animView)
431}
432
433// toolEarlyStateContent handles error/cancelled/pending states before content rendering.
434// Returns the rendered output and true if early state was handled.
435func toolEarlyStateContent(sty *styles.Styles, opts *ToolRenderOpts, width int) (string, bool) {
436 var msg string
437 switch opts.Status {
438 case ToolStatusError:
439 msg = toolErrorContent(sty, opts.Result, width)
440 case ToolStatusCanceled:
441 msg = sty.Tool.StateCancelled.Render("Canceled.")
442 case ToolStatusAwaitingPermission:
443 msg = sty.Tool.StateWaiting.Render("Requesting permission...")
444 case ToolStatusRunning:
445 msg = sty.Tool.StateWaiting.Render("Waiting for tool response...")
446 default:
447 return "", false
448 }
449 return msg, true
450}
451
452// toolErrorContent formats an error message with ERROR tag.
453func toolErrorContent(sty *styles.Styles, result *message.ToolResult, width int) string {
454 if result == nil {
455 return ""
456 }
457 errContent := strings.ReplaceAll(result.Content, "\n", " ")
458 errTag := sty.Tool.ErrorTag.Render("ERROR")
459 tagWidth := lipgloss.Width(errTag)
460 errContent = ansi.Truncate(errContent, width-tagWidth-3, "…")
461 return fmt.Sprintf("%s %s", errTag, sty.Tool.ErrorMessage.Render(errContent))
462}
463
464// toolIcon returns the status icon for a tool call.
465// toolIcon returns the status icon for a tool call based on its status.
466func toolIcon(sty *styles.Styles, status ToolStatus) string {
467 switch status {
468 case ToolStatusSuccess:
469 return sty.Tool.IconSuccess.String()
470 case ToolStatusError:
471 return sty.Tool.IconError.String()
472 case ToolStatusCanceled:
473 return sty.Tool.IconCancelled.String()
474 default:
475 return sty.Tool.IconPending.String()
476 }
477}
478
479// toolParamList formats parameters as "main (key=value, ...)" with truncation.
480// toolParamList formats tool parameters as "main (key=value, ...)" with truncation.
481func toolParamList(sty *styles.Styles, params []string, width int) string {
482 // minSpaceForMainParam is the min space required for the main param
483 // if this is less that the value set we will only show the main param nothing else
484 const minSpaceForMainParam = 30
485 if len(params) == 0 {
486 return ""
487 }
488
489 mainParam := params[0]
490
491 // Build key=value pairs from remaining params (consecutive key, value pairs).
492 var kvPairs []string
493 for i := 1; i+1 < len(params); i += 2 {
494 if params[i+1] != "" {
495 kvPairs = append(kvPairs, fmt.Sprintf("%s=%s", params[i], params[i+1]))
496 }
497 }
498
499 // Try to include key=value pairs if there's enough space.
500 output := mainParam
501 if len(kvPairs) > 0 {
502 partsStr := strings.Join(kvPairs, ", ")
503 if remaining := width - lipgloss.Width(partsStr) - 3; remaining >= minSpaceForMainParam {
504 output = fmt.Sprintf("%s (%s)", mainParam, partsStr)
505 }
506 }
507
508 if width >= 0 {
509 output = ansi.Truncate(output, width, "…")
510 }
511 return sty.Tool.ParamMain.Render(output)
512}
513
514// toolHeader builds the tool header line: "● ToolName params..."
515func toolHeader(sty *styles.Styles, status ToolStatus, name string, width int, nested bool, params ...string) string {
516 icon := toolIcon(sty, status)
517 nameStyle := sty.Tool.NameNormal
518 if nested {
519 nameStyle = sty.Tool.NameNested
520 }
521 toolName := nameStyle.Render(name)
522 prefix := fmt.Sprintf("%s %s ", icon, toolName)
523 prefixWidth := lipgloss.Width(prefix)
524 remainingWidth := width - prefixWidth
525 paramsStr := toolParamList(sty, params, remainingWidth)
526 return prefix + paramsStr
527}
528
529// toolOutputPlainContent renders plain text with optional expansion support.
530func toolOutputPlainContent(sty *styles.Styles, content string, width int, expanded bool) string {
531 content = stringext.NormalizeSpace(content)
532 lines := strings.Split(content, "\n")
533
534 maxLines := responseContextHeight
535 if expanded {
536 maxLines = len(lines) // Show all
537 }
538
539 var out []string
540 for i, ln := range lines {
541 if i >= maxLines {
542 break
543 }
544 ln = " " + ln
545 if lipgloss.Width(ln) > width {
546 ln = ansi.Truncate(ln, width, "…")
547 }
548 out = append(out, sty.Tool.ContentLine.Width(width).Render(ln))
549 }
550
551 wasTruncated := len(lines) > responseContextHeight
552
553 if !expanded && wasTruncated {
554 out = append(out, sty.Tool.ContentTruncation.
555 Width(width).
556 Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-responseContextHeight)))
557 }
558
559 return strings.Join(out, "\n")
560}
561
562// toolOutputCodeContent renders code with syntax highlighting and line numbers.
563func toolOutputCodeContent(sty *styles.Styles, path, content string, offset, width int, expanded bool) string {
564 content = stringext.NormalizeSpace(content)
565
566 lines := strings.Split(content, "\n")
567 maxLines := responseContextHeight
568 if expanded {
569 maxLines = len(lines)
570 }
571
572 // Truncate if needed.
573 displayLines := lines
574 if len(lines) > maxLines {
575 displayLines = lines[:maxLines]
576 }
577
578 bg := sty.Tool.ContentCodeBg
579 highlighted, _ := common.SyntaxHighlight(sty, strings.Join(displayLines, "\n"), path, bg)
580 highlightedLines := strings.Split(highlighted, "\n")
581
582 // Calculate line number width.
583 maxLineNumber := len(displayLines) + offset
584 maxDigits := getDigits(maxLineNumber)
585 numFmt := fmt.Sprintf("%%%dd", maxDigits)
586
587 bodyWidth := width - toolBodyLeftPaddingTotal
588 codeWidth := bodyWidth - maxDigits
589
590 var out []string
591 for i, ln := range highlightedLines {
592 lineNum := sty.Tool.ContentLineNumber.Render(fmt.Sprintf(numFmt, i+1+offset))
593
594 // Truncate accounting for padding that will be added.
595 ln = ansi.Truncate(ln, codeWidth-sty.Tool.ContentCodeLine.GetHorizontalPadding(), "…")
596
597 codeLine := sty.Tool.ContentCodeLine.
598 Width(codeWidth).
599 Render(ln)
600
601 out = append(out, lipgloss.JoinHorizontal(lipgloss.Left, lineNum, codeLine))
602 }
603
604 // Add truncation message if needed.
605 if len(lines) > maxLines && !expanded {
606 out = append(out, sty.Tool.ContentCodeTruncation.
607 Width(width).
608 Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)),
609 )
610 }
611
612 return sty.Tool.Body.Render(strings.Join(out, "\n"))
613}
614
615// toolOutputImageContent renders image data with size info.
616func toolOutputImageContent(sty *styles.Styles, data, mediaType string) string {
617 dataSize := len(data) * 3 / 4
618 sizeStr := formatSize(dataSize)
619
620 loaded := sty.Base.Foreground(sty.Green).Render("Loaded")
621 arrow := sty.Base.Foreground(sty.GreenDark).Render("→")
622 typeStyled := sty.Base.Render(mediaType)
623 sizeStyled := sty.Subtle.Render(sizeStr)
624
625 return sty.Tool.Body.Render(fmt.Sprintf("%s %s %s %s", loaded, arrow, typeStyled, sizeStyled))
626}
627
628// getDigits returns the number of digits in a number.
629func getDigits(n int) int {
630 if n == 0 {
631 return 1
632 }
633 if n < 0 {
634 n = -n
635 }
636 digits := 0
637 for n > 0 {
638 n /= 10
639 digits++
640 }
641 return digits
642}
643
644// formatSize formats byte size into human readable format.
645func formatSize(bytes int) string {
646 const (
647 kb = 1024
648 mb = kb * 1024
649 )
650 switch {
651 case bytes >= mb:
652 return fmt.Sprintf("%.1f MB", float64(bytes)/float64(mb))
653 case bytes >= kb:
654 return fmt.Sprintf("%.1f KB", float64(bytes)/float64(kb))
655 default:
656 return fmt.Sprintf("%d B", bytes)
657 }
658}
659
660// toolOutputDiffContent renders a diff between old and new content.
661func toolOutputDiffContent(sty *styles.Styles, file, oldContent, newContent string, width int, expanded bool) string {
662 bodyWidth := width - toolBodyLeftPaddingTotal
663
664 formatter := common.DiffFormatter(sty).
665 Before(file, oldContent).
666 After(file, newContent).
667 Width(bodyWidth)
668
669 // Use split view for wide terminals.
670 if width > maxTextWidth {
671 formatter = formatter.Split()
672 }
673
674 formatted := formatter.String()
675 lines := strings.Split(formatted, "\n")
676
677 // Truncate if needed.
678 maxLines := responseContextHeight
679 if expanded {
680 maxLines = len(lines)
681 }
682
683 if len(lines) > maxLines && !expanded {
684 truncMsg := sty.Tool.DiffTruncation.
685 Width(bodyWidth).
686 Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines))
687 formatted = strings.Join(lines[:maxLines], "\n") + "\n" + truncMsg
688 }
689
690 return sty.Tool.Body.Render(formatted)
691}
692
693// formatTimeout converts timeout seconds to a duration string (e.g., "30s").
694// Returns empty string if timeout is 0.
695func formatTimeout(timeout int) string {
696 if timeout == 0 {
697 return ""
698 }
699 return fmt.Sprintf("%ds", timeout)
700}
701
702// formatNonZero returns string representation of non-zero integers, empty string for zero.
703func formatNonZero(value int) string {
704 if value == 0 {
705 return ""
706 }
707 return fmt.Sprintf("%d", value)
708}
709
710// toolOutputMultiEditDiffContent renders a diff with optional failed edits note.
711func toolOutputMultiEditDiffContent(sty *styles.Styles, file string, meta tools.MultiEditResponseMetadata, totalEdits, width int, expanded bool) string {
712 bodyWidth := width - toolBodyLeftPaddingTotal
713
714 formatter := common.DiffFormatter(sty).
715 Before(file, meta.OldContent).
716 After(file, meta.NewContent).
717 Width(bodyWidth)
718
719 // Use split view for wide terminals.
720 if width > maxTextWidth {
721 formatter = formatter.Split()
722 }
723
724 formatted := formatter.String()
725 lines := strings.Split(formatted, "\n")
726
727 // Truncate if needed.
728 maxLines := responseContextHeight
729 if expanded {
730 maxLines = len(lines)
731 }
732
733 if len(lines) > maxLines && !expanded {
734 truncMsg := sty.Tool.DiffTruncation.
735 Width(bodyWidth).
736 Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines))
737 formatted = truncMsg + "\n" + strings.Join(lines[:maxLines], "\n")
738 }
739
740 // Add failed edits note if any exist.
741 if len(meta.EditsFailed) > 0 {
742 noteTag := sty.Tool.NoteTag.Render("Note")
743 noteMsg := fmt.Sprintf("%d of %d edits succeeded", meta.EditsApplied, totalEdits)
744 note := fmt.Sprintf("%s %s", noteTag, sty.Tool.NoteMessage.Render(noteMsg))
745 formatted = formatted + "\n\n" + note
746 }
747
748 return sty.Tool.Body.Render(formatted)
749}
750
751// roundedEnumerator creates a tree enumerator with rounded corners.
752func roundedEnumerator(lPadding, width int) tree.Enumerator {
753 if width == 0 {
754 width = 2
755 }
756 if lPadding == 0 {
757 lPadding = 1
758 }
759 return func(children tree.Children, index int) string {
760 line := strings.Repeat("─", width)
761 padding := strings.Repeat(" ", lPadding)
762 if children.Length()-1 == index {
763 return padding + "╰" + line
764 }
765 return padding + "├" + line
766 }
767}
768
769// toolOutputMarkdownContent renders markdown content with optional truncation.
770func toolOutputMarkdownContent(sty *styles.Styles, content string, width int, expanded bool) string {
771 content = stringext.NormalizeSpace(content)
772
773 renderer := common.PlainMarkdownRenderer(sty, width)
774 rendered, err := renderer.Render(content)
775 if err != nil {
776 return toolOutputPlainContent(sty, content, width, expanded)
777 }
778
779 lines := strings.Split(rendered, "\n")
780 maxLines := responseContextHeight
781 if expanded {
782 maxLines = len(lines)
783 }
784
785 var out []string
786 for i, ln := range lines {
787 if i >= maxLines {
788 break
789 }
790 out = append(out, ln)
791 }
792
793 if len(lines) > maxLines && !expanded {
794 out = append(out, sty.Tool.ContentTruncation.
795 Width(width).
796 Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)),
797 )
798 }
799
800 return sty.Tool.Body.Render(strings.Join(out, "\n"))
801}
802
803// formatToolForCopy formats the tool call for clipboard copying.
804func (t *baseToolMessageItem) formatToolForCopy() string {
805 var parts []string
806
807 toolName := prettifyToolName(t.toolCall.Name)
808 parts = append(parts, fmt.Sprintf("## %s Tool Call", toolName))
809
810 if t.toolCall.Input != "" {
811 params := t.formatParametersForCopy()
812 if params != "" {
813 parts = append(parts, "### Parameters:")
814 parts = append(parts, params)
815 }
816 }
817
818 if t.result != nil && t.result.ToolCallID != "" {
819 if t.result.IsError {
820 parts = append(parts, "### Error:")
821 parts = append(parts, t.result.Content)
822 } else {
823 parts = append(parts, "### Result:")
824 content := t.formatResultForCopy()
825 if content != "" {
826 parts = append(parts, content)
827 }
828 }
829 } else if t.status == ToolStatusCanceled {
830 parts = append(parts, "### Status:")
831 parts = append(parts, "Cancelled")
832 } else {
833 parts = append(parts, "### Status:")
834 parts = append(parts, "Pending...")
835 }
836
837 return strings.Join(parts, "\n\n")
838}
839
840// formatParametersForCopy formats tool parameters for clipboard copying.
841func (t *baseToolMessageItem) formatParametersForCopy() string {
842 switch t.toolCall.Name {
843 case tools.BashToolName:
844 var params tools.BashParams
845 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
846 cmd := strings.ReplaceAll(params.Command, "\n", " ")
847 cmd = strings.ReplaceAll(cmd, "\t", " ")
848 return fmt.Sprintf("**Command:** %s", cmd)
849 }
850 case tools.ViewToolName:
851 var params tools.ViewParams
852 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
853 var parts []string
854 parts = append(parts, fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)))
855 if params.Limit > 0 {
856 parts = append(parts, fmt.Sprintf("**Limit:** %d", params.Limit))
857 }
858 if params.Offset > 0 {
859 parts = append(parts, fmt.Sprintf("**Offset:** %d", params.Offset))
860 }
861 return strings.Join(parts, "\n")
862 }
863 case tools.EditToolName:
864 var params tools.EditParams
865 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
866 return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath))
867 }
868 case tools.MultiEditToolName:
869 var params tools.MultiEditParams
870 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
871 var parts []string
872 parts = append(parts, fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)))
873 parts = append(parts, fmt.Sprintf("**Edits:** %d", len(params.Edits)))
874 return strings.Join(parts, "\n")
875 }
876 case tools.WriteToolName:
877 var params tools.WriteParams
878 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
879 return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath))
880 }
881 case tools.FetchToolName:
882 var params tools.FetchParams
883 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
884 var parts []string
885 parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
886 if params.Format != "" {
887 parts = append(parts, fmt.Sprintf("**Format:** %s", params.Format))
888 }
889 if params.Timeout > 0 {
890 parts = append(parts, fmt.Sprintf("**Timeout:** %ds", params.Timeout))
891 }
892 return strings.Join(parts, "\n")
893 }
894 case tools.AgenticFetchToolName:
895 var params tools.AgenticFetchParams
896 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
897 var parts []string
898 if params.URL != "" {
899 parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
900 }
901 if params.Prompt != "" {
902 parts = append(parts, fmt.Sprintf("**Prompt:** %s", params.Prompt))
903 }
904 return strings.Join(parts, "\n")
905 }
906 case tools.WebFetchToolName:
907 var params tools.WebFetchParams
908 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
909 return fmt.Sprintf("**URL:** %s", params.URL)
910 }
911 case tools.GrepToolName:
912 var params tools.GrepParams
913 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
914 var parts []string
915 parts = append(parts, fmt.Sprintf("**Pattern:** %s", params.Pattern))
916 if params.Path != "" {
917 parts = append(parts, fmt.Sprintf("**Path:** %s", params.Path))
918 }
919 if params.Include != "" {
920 parts = append(parts, fmt.Sprintf("**Include:** %s", params.Include))
921 }
922 if params.LiteralText {
923 parts = append(parts, "**Literal:** true")
924 }
925 return strings.Join(parts, "\n")
926 }
927 case tools.GlobToolName:
928 var params tools.GlobParams
929 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
930 var parts []string
931 parts = append(parts, fmt.Sprintf("**Pattern:** %s", params.Pattern))
932 if params.Path != "" {
933 parts = append(parts, fmt.Sprintf("**Path:** %s", params.Path))
934 }
935 return strings.Join(parts, "\n")
936 }
937 case tools.LSToolName:
938 var params tools.LSParams
939 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
940 path := params.Path
941 if path == "" {
942 path = "."
943 }
944 return fmt.Sprintf("**Path:** %s", fsext.PrettyPath(path))
945 }
946 case tools.DownloadToolName:
947 var params tools.DownloadParams
948 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
949 var parts []string
950 parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
951 parts = append(parts, fmt.Sprintf("**File Path:** %s", fsext.PrettyPath(params.FilePath)))
952 if params.Timeout > 0 {
953 parts = append(parts, fmt.Sprintf("**Timeout:** %s", (time.Duration(params.Timeout)*time.Second).String()))
954 }
955 return strings.Join(parts, "\n")
956 }
957 case tools.SourcegraphToolName:
958 var params tools.SourcegraphParams
959 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
960 var parts []string
961 parts = append(parts, fmt.Sprintf("**Query:** %s", params.Query))
962 if params.Count > 0 {
963 parts = append(parts, fmt.Sprintf("**Count:** %d", params.Count))
964 }
965 if params.ContextWindow > 0 {
966 parts = append(parts, fmt.Sprintf("**Context:** %d", params.ContextWindow))
967 }
968 return strings.Join(parts, "\n")
969 }
970 case tools.DiagnosticsToolName:
971 return "**Project:** diagnostics"
972 case agent.AgentToolName:
973 var params agent.AgentParams
974 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
975 return fmt.Sprintf("**Task:**\n%s", params.Prompt)
976 }
977 }
978
979 var params map[string]any
980 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
981 var parts []string
982 for key, value := range params {
983 displayKey := strings.ReplaceAll(key, "_", " ")
984 if len(displayKey) > 0 {
985 displayKey = strings.ToUpper(displayKey[:1]) + displayKey[1:]
986 }
987 parts = append(parts, fmt.Sprintf("**%s:** %v", displayKey, value))
988 }
989 return strings.Join(parts, "\n")
990 }
991
992 return ""
993}
994
995// formatResultForCopy formats tool results for clipboard copying.
996func (t *baseToolMessageItem) formatResultForCopy() string {
997 if t.result == nil {
998 return ""
999 }
1000
1001 if t.result.Data != "" {
1002 if strings.HasPrefix(t.result.MIMEType, "image/") {
1003 return fmt.Sprintf("[Image: %s]", t.result.MIMEType)
1004 }
1005 return fmt.Sprintf("[Media: %s]", t.result.MIMEType)
1006 }
1007
1008 switch t.toolCall.Name {
1009 case tools.BashToolName:
1010 return t.formatBashResultForCopy()
1011 case tools.ViewToolName:
1012 return t.formatViewResultForCopy()
1013 case tools.EditToolName:
1014 return t.formatEditResultForCopy()
1015 case tools.MultiEditToolName:
1016 return t.formatMultiEditResultForCopy()
1017 case tools.WriteToolName:
1018 return t.formatWriteResultForCopy()
1019 case tools.FetchToolName:
1020 return t.formatFetchResultForCopy()
1021 case tools.AgenticFetchToolName:
1022 return t.formatAgenticFetchResultForCopy()
1023 case tools.WebFetchToolName:
1024 return t.formatWebFetchResultForCopy()
1025 case agent.AgentToolName:
1026 return t.formatAgentResultForCopy()
1027 case tools.DownloadToolName, tools.GrepToolName, tools.GlobToolName, tools.LSToolName, tools.SourcegraphToolName, tools.DiagnosticsToolName, tools.TodosToolName:
1028 return fmt.Sprintf("```\n%s\n```", t.result.Content)
1029 default:
1030 return t.result.Content
1031 }
1032}
1033
1034// formatBashResultForCopy formats bash tool results for clipboard.
1035func (t *baseToolMessageItem) formatBashResultForCopy() string {
1036 if t.result == nil {
1037 return ""
1038 }
1039
1040 var meta tools.BashResponseMetadata
1041 if t.result.Metadata != "" {
1042 json.Unmarshal([]byte(t.result.Metadata), &meta)
1043 }
1044
1045 output := meta.Output
1046 if output == "" && t.result.Content != tools.BashNoOutput {
1047 output = t.result.Content
1048 }
1049
1050 if output == "" {
1051 return ""
1052 }
1053
1054 return fmt.Sprintf("```bash\n%s\n```", output)
1055}
1056
1057// formatViewResultForCopy formats view tool results for clipboard.
1058func (t *baseToolMessageItem) formatViewResultForCopy() string {
1059 if t.result == nil {
1060 return ""
1061 }
1062
1063 var meta tools.ViewResponseMetadata
1064 if t.result.Metadata != "" {
1065 json.Unmarshal([]byte(t.result.Metadata), &meta)
1066 }
1067
1068 if meta.Content == "" {
1069 return t.result.Content
1070 }
1071
1072 lang := ""
1073 if meta.FilePath != "" {
1074 ext := strings.ToLower(filepath.Ext(meta.FilePath))
1075 switch ext {
1076 case ".go":
1077 lang = "go"
1078 case ".js", ".mjs":
1079 lang = "javascript"
1080 case ".ts":
1081 lang = "typescript"
1082 case ".py":
1083 lang = "python"
1084 case ".rs":
1085 lang = "rust"
1086 case ".java":
1087 lang = "java"
1088 case ".c":
1089 lang = "c"
1090 case ".cpp", ".cc", ".cxx":
1091 lang = "cpp"
1092 case ".sh", ".bash":
1093 lang = "bash"
1094 case ".json":
1095 lang = "json"
1096 case ".yaml", ".yml":
1097 lang = "yaml"
1098 case ".xml":
1099 lang = "xml"
1100 case ".html":
1101 lang = "html"
1102 case ".css":
1103 lang = "css"
1104 case ".md":
1105 lang = "markdown"
1106 }
1107 }
1108
1109 var result strings.Builder
1110 if lang != "" {
1111 fmt.Fprintf(&result, "```%s\n", lang)
1112 } else {
1113 result.WriteString("```\n")
1114 }
1115 result.WriteString(meta.Content)
1116 result.WriteString("\n```")
1117
1118 return result.String()
1119}
1120
1121// formatEditResultForCopy formats edit tool results for clipboard.
1122func (t *baseToolMessageItem) formatEditResultForCopy() string {
1123 if t.result == nil || t.result.Metadata == "" {
1124 if t.result != nil {
1125 return t.result.Content
1126 }
1127 return ""
1128 }
1129
1130 var meta tools.EditResponseMetadata
1131 if json.Unmarshal([]byte(t.result.Metadata), &meta) != nil {
1132 return t.result.Content
1133 }
1134
1135 var params tools.EditParams
1136 json.Unmarshal([]byte(t.toolCall.Input), ¶ms)
1137
1138 var result strings.Builder
1139
1140 if meta.OldContent != "" || meta.NewContent != "" {
1141 fileName := params.FilePath
1142 if fileName != "" {
1143 fileName = fsext.PrettyPath(fileName)
1144 }
1145 diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName)
1146
1147 fmt.Fprintf(&result, "Changes: +%d -%d\n", additions, removals)
1148 result.WriteString("```diff\n")
1149 result.WriteString(diffContent)
1150 result.WriteString("\n```")
1151 }
1152
1153 return result.String()
1154}
1155
1156// formatMultiEditResultForCopy formats multi-edit tool results for clipboard.
1157func (t *baseToolMessageItem) formatMultiEditResultForCopy() string {
1158 if t.result == nil || t.result.Metadata == "" {
1159 if t.result != nil {
1160 return t.result.Content
1161 }
1162 return ""
1163 }
1164
1165 var meta tools.MultiEditResponseMetadata
1166 if json.Unmarshal([]byte(t.result.Metadata), &meta) != nil {
1167 return t.result.Content
1168 }
1169
1170 var params tools.MultiEditParams
1171 json.Unmarshal([]byte(t.toolCall.Input), ¶ms)
1172
1173 var result strings.Builder
1174 if meta.OldContent != "" || meta.NewContent != "" {
1175 fileName := params.FilePath
1176 if fileName != "" {
1177 fileName = fsext.PrettyPath(fileName)
1178 }
1179 diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName)
1180
1181 fmt.Fprintf(&result, "Changes: +%d -%d\n", additions, removals)
1182 result.WriteString("```diff\n")
1183 result.WriteString(diffContent)
1184 result.WriteString("\n```")
1185 }
1186
1187 return result.String()
1188}
1189
1190// formatWriteResultForCopy formats write tool results for clipboard.
1191func (t *baseToolMessageItem) formatWriteResultForCopy() string {
1192 if t.result == nil {
1193 return ""
1194 }
1195
1196 var params tools.WriteParams
1197 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) != nil {
1198 return t.result.Content
1199 }
1200
1201 lang := ""
1202 if params.FilePath != "" {
1203 ext := strings.ToLower(filepath.Ext(params.FilePath))
1204 switch ext {
1205 case ".go":
1206 lang = "go"
1207 case ".js", ".mjs":
1208 lang = "javascript"
1209 case ".ts":
1210 lang = "typescript"
1211 case ".py":
1212 lang = "python"
1213 case ".rs":
1214 lang = "rust"
1215 case ".java":
1216 lang = "java"
1217 case ".c":
1218 lang = "c"
1219 case ".cpp", ".cc", ".cxx":
1220 lang = "cpp"
1221 case ".sh", ".bash":
1222 lang = "bash"
1223 case ".json":
1224 lang = "json"
1225 case ".yaml", ".yml":
1226 lang = "yaml"
1227 case ".xml":
1228 lang = "xml"
1229 case ".html":
1230 lang = "html"
1231 case ".css":
1232 lang = "css"
1233 case ".md":
1234 lang = "markdown"
1235 }
1236 }
1237
1238 var result strings.Builder
1239 fmt.Fprintf(&result, "File: %s\n", fsext.PrettyPath(params.FilePath))
1240 if lang != "" {
1241 fmt.Fprintf(&result, "```%s\n", lang)
1242 } else {
1243 result.WriteString("```\n")
1244 }
1245 result.WriteString(params.Content)
1246 result.WriteString("\n```")
1247
1248 return result.String()
1249}
1250
1251// formatFetchResultForCopy formats fetch tool results for clipboard.
1252func (t *baseToolMessageItem) formatFetchResultForCopy() string {
1253 if t.result == nil {
1254 return ""
1255 }
1256
1257 var params tools.FetchParams
1258 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) != nil {
1259 return t.result.Content
1260 }
1261
1262 var result strings.Builder
1263 if params.URL != "" {
1264 fmt.Fprintf(&result, "URL: %s\n", params.URL)
1265 }
1266 if params.Format != "" {
1267 fmt.Fprintf(&result, "Format: %s\n", params.Format)
1268 }
1269 if params.Timeout > 0 {
1270 fmt.Fprintf(&result, "Timeout: %ds\n", params.Timeout)
1271 }
1272 result.WriteString("\n")
1273
1274 result.WriteString(t.result.Content)
1275
1276 return result.String()
1277}
1278
1279// formatAgenticFetchResultForCopy formats agentic fetch tool results for clipboard.
1280func (t *baseToolMessageItem) formatAgenticFetchResultForCopy() string {
1281 if t.result == nil {
1282 return ""
1283 }
1284
1285 var params tools.AgenticFetchParams
1286 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) != nil {
1287 return t.result.Content
1288 }
1289
1290 var result strings.Builder
1291 if params.URL != "" {
1292 fmt.Fprintf(&result, "URL: %s\n", params.URL)
1293 }
1294 if params.Prompt != "" {
1295 fmt.Fprintf(&result, "Prompt: %s\n\n", params.Prompt)
1296 }
1297
1298 result.WriteString("```markdown\n")
1299 result.WriteString(t.result.Content)
1300 result.WriteString("\n```")
1301
1302 return result.String()
1303}
1304
1305// formatWebFetchResultForCopy formats web fetch tool results for clipboard.
1306func (t *baseToolMessageItem) formatWebFetchResultForCopy() string {
1307 if t.result == nil {
1308 return ""
1309 }
1310
1311 var params tools.WebFetchParams
1312 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) != nil {
1313 return t.result.Content
1314 }
1315
1316 var result strings.Builder
1317 result.WriteString(fmt.Sprintf("URL: %s\n\n", params.URL))
1318 result.WriteString("```markdown\n")
1319 result.WriteString(t.result.Content)
1320 result.WriteString("\n```")
1321
1322 return result.String()
1323}
1324
1325// formatAgentResultForCopy formats agent tool results for clipboard.
1326func (t *baseToolMessageItem) formatAgentResultForCopy() string {
1327 if t.result == nil {
1328 return ""
1329 }
1330
1331 var result strings.Builder
1332
1333 if t.result.Content != "" {
1334 result.WriteString(fmt.Sprintf("```markdown\n%s\n```", t.result.Content))
1335 }
1336
1337 return result.String()
1338}
1339
1340// prettifyToolName returns a human-readable name for tool names.
1341func prettifyToolName(name string) string {
1342 switch name {
1343 case agent.AgentToolName:
1344 return "Agent"
1345 case tools.BashToolName:
1346 return "Bash"
1347 case tools.JobOutputToolName:
1348 return "Job: Output"
1349 case tools.JobKillToolName:
1350 return "Job: Kill"
1351 case tools.DownloadToolName:
1352 return "Download"
1353 case tools.EditToolName:
1354 return "Edit"
1355 case tools.MultiEditToolName:
1356 return "Multi-Edit"
1357 case tools.FetchToolName:
1358 return "Fetch"
1359 case tools.AgenticFetchToolName:
1360 return "Agentic Fetch"
1361 case tools.WebFetchToolName:
1362 return "Fetch"
1363 case tools.WebSearchToolName:
1364 return "Search"
1365 case tools.GlobToolName:
1366 return "Glob"
1367 case tools.GrepToolName:
1368 return "Grep"
1369 case tools.LSToolName:
1370 return "List"
1371 case tools.SourcegraphToolName:
1372 return "Sourcegraph"
1373 case tools.TodosToolName:
1374 return "To-Do"
1375 case tools.ViewToolName:
1376 return "View"
1377 case tools.WriteToolName:
1378 return "Write"
1379 default:
1380 return genericPrettyName(name)
1381 }
1382}