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