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 const (
801 okMessage = "OK"
802 denialMessage = "Denied"
803 rewroteMessage = "Rewrote Output"
804 )
805 switch hi.Decision {
806 case "deny":
807 if hi.Reason != "" {
808 return sty.Tool.HookDenied.Render(denialMessage) + " " + sty.Tool.HookDeniedReason.Render(hi.Reason)
809 }
810 return sty.Tool.HookDenied.Render(denialMessage)
811 case "allow":
812 result := sty.Tool.HookOK.Render(okMessage)
813 if hi.InputRewrite {
814 result += " " + sty.Tool.HookRewrote.Render(rewroteMessage)
815 }
816 return result
817 default:
818 result := sty.Tool.HookOK.Render(okMessage)
819 if hi.InputRewrite {
820 result += " " + sty.Tool.HookRewrote.Render(rewroteMessage)
821 }
822 return result
823 }
824}
825
826// getDigits returns the number of digits in a number.
827func getDigits(n int) int {
828 if n == 0 {
829 return 1
830 }
831 if n < 0 {
832 n = -n
833 }
834 digits := 0
835 for n > 0 {
836 n /= 10
837 digits++
838 }
839 return digits
840}
841
842// formatSize formats byte size into human readable format.
843func formatSize(bytes int) string {
844 const (
845 kb = 1024
846 mb = kb * 1024
847 )
848 switch {
849 case bytes >= mb:
850 return fmt.Sprintf("%.1f MB", float64(bytes)/float64(mb))
851 case bytes >= kb:
852 return fmt.Sprintf("%.1f KB", float64(bytes)/float64(kb))
853 default:
854 return fmt.Sprintf("%d B", bytes)
855 }
856}
857
858// toolOutputDiffContent renders a diff between old and new content.
859func toolOutputDiffContent(sty *styles.Styles, file, oldContent, newContent string, width int, expanded bool) string {
860 bodyWidth := width - toolBodyLeftPaddingTotal
861
862 formatter := common.DiffFormatter(sty).
863 Before(file, oldContent).
864 After(file, newContent).
865 Width(bodyWidth)
866
867 // Use split view for wide terminals.
868 if width > maxTextWidth {
869 formatter = formatter.Split()
870 }
871
872 formatted := formatter.String()
873 lines := strings.Split(formatted, "\n")
874
875 // Truncate if needed.
876 maxLines := responseContextHeight
877 if expanded {
878 maxLines = len(lines)
879 }
880
881 if len(lines) > maxLines && !expanded {
882 truncMsg := sty.Tool.DiffTruncation.
883 Width(bodyWidth).
884 Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines))
885 formatted = strings.Join(lines[:maxLines], "\n") + "\n" + truncMsg
886 }
887
888 return sty.Tool.Body.Render(formatted)
889}
890
891// formatTimeout converts timeout seconds to a duration string (e.g., "30s").
892// Returns empty string if timeout is 0.
893func formatTimeout(timeout int) string {
894 if timeout == 0 {
895 return ""
896 }
897 return fmt.Sprintf("%ds", timeout)
898}
899
900// formatNonZero returns string representation of non-zero integers, empty string for zero.
901func formatNonZero(value int) string {
902 if value == 0 {
903 return ""
904 }
905 return fmt.Sprintf("%d", value)
906}
907
908// toolOutputMultiEditDiffContent renders a diff with optional failed edits note.
909func toolOutputMultiEditDiffContent(sty *styles.Styles, file string, meta tools.MultiEditResponseMetadata, totalEdits, width int, expanded bool) string {
910 bodyWidth := width - toolBodyLeftPaddingTotal
911
912 formatter := common.DiffFormatter(sty).
913 Before(file, meta.OldContent).
914 After(file, meta.NewContent).
915 Width(bodyWidth)
916
917 // Use split view for wide terminals.
918 if width > maxTextWidth {
919 formatter = formatter.Split()
920 }
921
922 formatted := formatter.String()
923 lines := strings.Split(formatted, "\n")
924
925 // Truncate if needed.
926 maxLines := responseContextHeight
927 if expanded {
928 maxLines = len(lines)
929 }
930
931 if len(lines) > maxLines && !expanded {
932 truncMsg := sty.Tool.DiffTruncation.
933 Width(bodyWidth).
934 Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines))
935 formatted = truncMsg + "\n" + strings.Join(lines[:maxLines], "\n")
936 }
937
938 // Add failed edits note if any exist.
939 if len(meta.EditsFailed) > 0 {
940 noteTag := sty.Tool.NoteTag.Render("Note")
941 noteMsg := fmt.Sprintf("%d of %d edits succeeded", meta.EditsApplied, totalEdits)
942 note := fmt.Sprintf("%s %s", noteTag, sty.Tool.NoteMessage.Render(noteMsg))
943 formatted = formatted + "\n\n" + note
944 }
945
946 return sty.Tool.Body.Render(formatted)
947}
948
949// roundedEnumerator creates a tree enumerator with rounded corners.
950func roundedEnumerator(lPadding, width int) tree.Enumerator {
951 if width == 0 {
952 width = 2
953 }
954 if lPadding == 0 {
955 lPadding = 1
956 }
957 return func(children tree.Children, index int) string {
958 line := strings.Repeat("─", width)
959 padding := strings.Repeat(" ", lPadding)
960 if children.Length()-1 == index {
961 return padding + "╰" + line
962 }
963 return padding + "├" + line
964 }
965}
966
967// toolOutputMarkdownContent renders markdown content with optional truncation.
968func toolOutputMarkdownContent(sty *styles.Styles, content string, width int, expanded bool) string {
969 content = stringext.NormalizeSpace(content)
970
971 // Cap width for readability.
972 if width > maxTextWidth {
973 width = maxTextWidth
974 }
975
976 renderer := common.QuietMarkdownRenderer(sty, width)
977 rendered, err := renderer.Render(content)
978 if err != nil {
979 return toolOutputPlainContent(sty, content, width, expanded)
980 }
981
982 lines := strings.Split(rendered, "\n")
983 maxLines := responseContextHeight
984 if expanded {
985 maxLines = len(lines)
986 }
987
988 var out []string
989 for i, ln := range lines {
990 if i >= maxLines {
991 break
992 }
993 out = append(out, ln)
994 }
995
996 if len(lines) > maxLines && !expanded {
997 out = append(out, sty.Tool.ContentTruncation.
998 Width(width).
999 Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)),
1000 )
1001 }
1002
1003 return sty.Tool.Body.Render(strings.Join(out, "\n"))
1004}
1005
1006// formatToolForCopy formats the tool call for clipboard copying.
1007func (t *baseToolMessageItem) formatToolForCopy() string {
1008 var parts []string
1009
1010 toolName := prettifyToolName(t.toolCall.Name)
1011 parts = append(parts, fmt.Sprintf("## %s Tool Call", toolName))
1012
1013 if t.toolCall.Input != "" {
1014 params := t.formatParametersForCopy()
1015 if params != "" {
1016 parts = append(parts, "### Parameters:")
1017 parts = append(parts, params)
1018 }
1019 }
1020
1021 if t.result != nil && t.result.ToolCallID != "" {
1022 if t.result.IsError {
1023 parts = append(parts, "### Error:")
1024 parts = append(parts, t.result.Content)
1025 } else {
1026 parts = append(parts, "### Result:")
1027 content := t.formatResultForCopy()
1028 if content != "" {
1029 parts = append(parts, content)
1030 }
1031 }
1032 } else if t.status == ToolStatusCanceled {
1033 parts = append(parts, "### Status:")
1034 parts = append(parts, "Cancelled")
1035 } else {
1036 parts = append(parts, "### Status:")
1037 parts = append(parts, "Pending...")
1038 }
1039
1040 return strings.Join(parts, "\n\n")
1041}
1042
1043// formatParametersForCopy formats tool parameters for clipboard copying.
1044func (t *baseToolMessageItem) formatParametersForCopy() string {
1045 switch t.toolCall.Name {
1046 case tools.BashToolName:
1047 var params tools.BashParams
1048 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1049 cmd := strings.ReplaceAll(params.Command, "\n", " ")
1050 cmd = strings.ReplaceAll(cmd, "\t", " ")
1051 return fmt.Sprintf("**Command:** %s", cmd)
1052 }
1053 case tools.ViewToolName:
1054 var params tools.ViewParams
1055 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1056 var parts []string
1057 parts = append(parts, fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)))
1058 if params.Limit > 0 {
1059 parts = append(parts, fmt.Sprintf("**Limit:** %d", params.Limit))
1060 }
1061 if params.Offset > 0 {
1062 parts = append(parts, fmt.Sprintf("**Offset:** %d", params.Offset))
1063 }
1064 return strings.Join(parts, "\n")
1065 }
1066 case tools.EditToolName:
1067 var params tools.EditParams
1068 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1069 return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath))
1070 }
1071 case tools.MultiEditToolName:
1072 var params tools.MultiEditParams
1073 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1074 var parts []string
1075 parts = append(parts, fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)))
1076 parts = append(parts, fmt.Sprintf("**Edits:** %d", len(params.Edits)))
1077 return strings.Join(parts, "\n")
1078 }
1079 case tools.WriteToolName:
1080 var params tools.WriteParams
1081 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1082 return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath))
1083 }
1084 case tools.FetchToolName:
1085 var params tools.FetchParams
1086 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1087 var parts []string
1088 parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
1089 if params.Format != "" {
1090 parts = append(parts, fmt.Sprintf("**Format:** %s", params.Format))
1091 }
1092 if params.Timeout > 0 {
1093 parts = append(parts, fmt.Sprintf("**Timeout:** %ds", params.Timeout))
1094 }
1095 return strings.Join(parts, "\n")
1096 }
1097 case tools.AgenticFetchToolName:
1098 var params tools.AgenticFetchParams
1099 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1100 var parts []string
1101 if params.URL != "" {
1102 parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
1103 }
1104 if params.Prompt != "" {
1105 parts = append(parts, fmt.Sprintf("**Prompt:** %s", params.Prompt))
1106 }
1107 return strings.Join(parts, "\n")
1108 }
1109 case tools.WebFetchToolName:
1110 var params tools.WebFetchParams
1111 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1112 return fmt.Sprintf("**URL:** %s", params.URL)
1113 }
1114 case tools.GrepToolName:
1115 var params tools.GrepParams
1116 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1117 var parts []string
1118 parts = append(parts, fmt.Sprintf("**Pattern:** %s", params.Pattern))
1119 if params.Path != "" {
1120 parts = append(parts, fmt.Sprintf("**Path:** %s", params.Path))
1121 }
1122 if params.Include != "" {
1123 parts = append(parts, fmt.Sprintf("**Include:** %s", params.Include))
1124 }
1125 if params.LiteralText {
1126 parts = append(parts, "**Literal:** true")
1127 }
1128 return strings.Join(parts, "\n")
1129 }
1130 case tools.GlobToolName:
1131 var params tools.GlobParams
1132 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1133 var parts []string
1134 parts = append(parts, fmt.Sprintf("**Pattern:** %s", params.Pattern))
1135 if params.Path != "" {
1136 parts = append(parts, fmt.Sprintf("**Path:** %s", params.Path))
1137 }
1138 return strings.Join(parts, "\n")
1139 }
1140 case tools.LSToolName:
1141 var params tools.LSParams
1142 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1143 path := params.Path
1144 if path == "" {
1145 path = "."
1146 }
1147 return fmt.Sprintf("**Path:** %s", fsext.PrettyPath(path))
1148 }
1149 case tools.DownloadToolName:
1150 var params tools.DownloadParams
1151 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1152 var parts []string
1153 parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
1154 parts = append(parts, fmt.Sprintf("**File Path:** %s", fsext.PrettyPath(params.FilePath)))
1155 if params.Timeout > 0 {
1156 parts = append(parts, fmt.Sprintf("**Timeout:** %s", (time.Duration(params.Timeout)*time.Second).String()))
1157 }
1158 return strings.Join(parts, "\n")
1159 }
1160 case tools.SourcegraphToolName:
1161 var params tools.SourcegraphParams
1162 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1163 var parts []string
1164 parts = append(parts, fmt.Sprintf("**Query:** %s", params.Query))
1165 if params.Count > 0 {
1166 parts = append(parts, fmt.Sprintf("**Count:** %d", params.Count))
1167 }
1168 if params.ContextWindow > 0 {
1169 parts = append(parts, fmt.Sprintf("**Context:** %d", params.ContextWindow))
1170 }
1171 return strings.Join(parts, "\n")
1172 }
1173 case tools.DiagnosticsToolName:
1174 return "**Project:** diagnostics"
1175 case agent.AgentToolName:
1176 var params agent.AgentParams
1177 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1178 return fmt.Sprintf("**Task:**\n%s", params.Prompt)
1179 }
1180 }
1181
1182 var params map[string]any
1183 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1184 var parts []string
1185 for key, value := range params {
1186 displayKey := strings.ReplaceAll(key, "_", " ")
1187 if len(displayKey) > 0 {
1188 displayKey = strings.ToUpper(displayKey[:1]) + displayKey[1:]
1189 }
1190 parts = append(parts, fmt.Sprintf("**%s:** %v", displayKey, value))
1191 }
1192 return strings.Join(parts, "\n")
1193 }
1194
1195 return ""
1196}
1197
1198// formatResultForCopy formats tool results for clipboard copying.
1199func (t *baseToolMessageItem) formatResultForCopy() string {
1200 if t.result == nil {
1201 return ""
1202 }
1203
1204 if t.result.Data != "" {
1205 if strings.HasPrefix(t.result.MIMEType, "image/") {
1206 return fmt.Sprintf("[Image: %s]", t.result.MIMEType)
1207 }
1208 return fmt.Sprintf("[Media: %s]", t.result.MIMEType)
1209 }
1210
1211 switch t.toolCall.Name {
1212 case tools.BashToolName:
1213 return t.formatBashResultForCopy()
1214 case tools.ViewToolName:
1215 return t.formatViewResultForCopy()
1216 case tools.EditToolName:
1217 return t.formatEditResultForCopy()
1218 case tools.MultiEditToolName:
1219 return t.formatMultiEditResultForCopy()
1220 case tools.WriteToolName:
1221 return t.formatWriteResultForCopy()
1222 case tools.FetchToolName:
1223 return t.formatFetchResultForCopy()
1224 case tools.AgenticFetchToolName:
1225 return t.formatAgenticFetchResultForCopy()
1226 case tools.WebFetchToolName:
1227 return t.formatWebFetchResultForCopy()
1228 case agent.AgentToolName:
1229 return t.formatAgentResultForCopy()
1230 case tools.DownloadToolName, tools.GrepToolName, tools.GlobToolName, tools.LSToolName, tools.SourcegraphToolName, tools.DiagnosticsToolName, tools.TodosToolName:
1231 return fmt.Sprintf("```\n%s\n```", t.result.Content)
1232 default:
1233 return t.result.Content
1234 }
1235}
1236
1237// formatBashResultForCopy formats bash tool results for clipboard.
1238func (t *baseToolMessageItem) formatBashResultForCopy() string {
1239 if t.result == nil {
1240 return ""
1241 }
1242
1243 var meta tools.BashResponseMetadata
1244 if t.result.Metadata != "" {
1245 json.Unmarshal([]byte(t.result.Metadata), &meta)
1246 }
1247
1248 output := meta.Output
1249 if output == "" && t.result.Content != tools.BashNoOutput {
1250 output = t.result.Content
1251 }
1252
1253 if output == "" {
1254 return ""
1255 }
1256
1257 return fmt.Sprintf("```bash\n%s\n```", output)
1258}
1259
1260// formatViewResultForCopy formats view tool results for clipboard.
1261func (t *baseToolMessageItem) formatViewResultForCopy() string {
1262 if t.result == nil {
1263 return ""
1264 }
1265
1266 var meta tools.ViewResponseMetadata
1267 if t.result.Metadata != "" {
1268 json.Unmarshal([]byte(t.result.Metadata), &meta)
1269 }
1270
1271 if meta.Content == "" {
1272 return t.result.Content
1273 }
1274
1275 lang := ""
1276 if meta.FilePath != "" {
1277 ext := strings.ToLower(filepath.Ext(meta.FilePath))
1278 switch ext {
1279 case ".go":
1280 lang = "go"
1281 case ".js", ".mjs":
1282 lang = "javascript"
1283 case ".ts":
1284 lang = "typescript"
1285 case ".py":
1286 lang = "python"
1287 case ".rs":
1288 lang = "rust"
1289 case ".java":
1290 lang = "java"
1291 case ".c":
1292 lang = "c"
1293 case ".cpp", ".cc", ".cxx":
1294 lang = "cpp"
1295 case ".sh", ".bash":
1296 lang = "bash"
1297 case ".json":
1298 lang = "json"
1299 case ".yaml", ".yml":
1300 lang = "yaml"
1301 case ".xml":
1302 lang = "xml"
1303 case ".html":
1304 lang = "html"
1305 case ".css":
1306 lang = "css"
1307 case ".md":
1308 lang = "markdown"
1309 }
1310 }
1311
1312 var result strings.Builder
1313 if lang != "" {
1314 fmt.Fprintf(&result, "```%s\n", lang)
1315 } else {
1316 result.WriteString("```\n")
1317 }
1318 result.WriteString(meta.Content)
1319 result.WriteString("\n```")
1320
1321 return result.String()
1322}
1323
1324// formatEditResultForCopy formats edit tool results for clipboard.
1325func (t *baseToolMessageItem) formatEditResultForCopy() string {
1326 if t.result == nil || t.result.Metadata == "" {
1327 if t.result != nil {
1328 return t.result.Content
1329 }
1330 return ""
1331 }
1332
1333 var meta tools.EditResponseMetadata
1334 if json.Unmarshal([]byte(t.result.Metadata), &meta) != nil {
1335 return t.result.Content
1336 }
1337
1338 var params tools.EditParams
1339 json.Unmarshal([]byte(t.toolCall.Input), ¶ms)
1340
1341 var result strings.Builder
1342
1343 if meta.OldContent != "" || meta.NewContent != "" {
1344 fileName := params.FilePath
1345 if fileName != "" {
1346 fileName = fsext.PrettyPath(fileName)
1347 }
1348 diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName)
1349
1350 fmt.Fprintf(&result, "Changes: +%d -%d\n", additions, removals)
1351 result.WriteString("```diff\n")
1352 result.WriteString(diffContent)
1353 result.WriteString("\n```")
1354 }
1355
1356 return result.String()
1357}
1358
1359// formatMultiEditResultForCopy formats multi-edit tool results for clipboard.
1360func (t *baseToolMessageItem) formatMultiEditResultForCopy() string {
1361 if t.result == nil || t.result.Metadata == "" {
1362 if t.result != nil {
1363 return t.result.Content
1364 }
1365 return ""
1366 }
1367
1368 var meta tools.MultiEditResponseMetadata
1369 if json.Unmarshal([]byte(t.result.Metadata), &meta) != nil {
1370 return t.result.Content
1371 }
1372
1373 var params tools.MultiEditParams
1374 json.Unmarshal([]byte(t.toolCall.Input), ¶ms)
1375
1376 var result strings.Builder
1377 if meta.OldContent != "" || meta.NewContent != "" {
1378 fileName := params.FilePath
1379 if fileName != "" {
1380 fileName = fsext.PrettyPath(fileName)
1381 }
1382 diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName)
1383
1384 fmt.Fprintf(&result, "Changes: +%d -%d\n", additions, removals)
1385 result.WriteString("```diff\n")
1386 result.WriteString(diffContent)
1387 result.WriteString("\n```")
1388 }
1389
1390 return result.String()
1391}
1392
1393// formatWriteResultForCopy formats write tool results for clipboard.
1394func (t *baseToolMessageItem) formatWriteResultForCopy() string {
1395 if t.result == nil {
1396 return ""
1397 }
1398
1399 var params tools.WriteParams
1400 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) != nil {
1401 return t.result.Content
1402 }
1403
1404 lang := ""
1405 if params.FilePath != "" {
1406 ext := strings.ToLower(filepath.Ext(params.FilePath))
1407 switch ext {
1408 case ".go":
1409 lang = "go"
1410 case ".js", ".mjs":
1411 lang = "javascript"
1412 case ".ts":
1413 lang = "typescript"
1414 case ".py":
1415 lang = "python"
1416 case ".rs":
1417 lang = "rust"
1418 case ".java":
1419 lang = "java"
1420 case ".c":
1421 lang = "c"
1422 case ".cpp", ".cc", ".cxx":
1423 lang = "cpp"
1424 case ".sh", ".bash":
1425 lang = "bash"
1426 case ".json":
1427 lang = "json"
1428 case ".yaml", ".yml":
1429 lang = "yaml"
1430 case ".xml":
1431 lang = "xml"
1432 case ".html":
1433 lang = "html"
1434 case ".css":
1435 lang = "css"
1436 case ".md":
1437 lang = "markdown"
1438 }
1439 }
1440
1441 var result strings.Builder
1442 fmt.Fprintf(&result, "File: %s\n", fsext.PrettyPath(params.FilePath))
1443 if lang != "" {
1444 fmt.Fprintf(&result, "```%s\n", lang)
1445 } else {
1446 result.WriteString("```\n")
1447 }
1448 result.WriteString(params.Content)
1449 result.WriteString("\n```")
1450
1451 return result.String()
1452}
1453
1454// formatFetchResultForCopy formats fetch tool results for clipboard.
1455func (t *baseToolMessageItem) formatFetchResultForCopy() string {
1456 if t.result == nil {
1457 return ""
1458 }
1459
1460 var params tools.FetchParams
1461 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) != nil {
1462 return t.result.Content
1463 }
1464
1465 var result strings.Builder
1466 if params.URL != "" {
1467 fmt.Fprintf(&result, "URL: %s\n", params.URL)
1468 }
1469 if params.Format != "" {
1470 fmt.Fprintf(&result, "Format: %s\n", params.Format)
1471 }
1472 if params.Timeout > 0 {
1473 fmt.Fprintf(&result, "Timeout: %ds\n", params.Timeout)
1474 }
1475 result.WriteString("\n")
1476
1477 result.WriteString(t.result.Content)
1478
1479 return result.String()
1480}
1481
1482// formatAgenticFetchResultForCopy formats agentic fetch tool results for clipboard.
1483func (t *baseToolMessageItem) formatAgenticFetchResultForCopy() string {
1484 if t.result == nil {
1485 return ""
1486 }
1487
1488 var params tools.AgenticFetchParams
1489 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) != nil {
1490 return t.result.Content
1491 }
1492
1493 var result strings.Builder
1494 if params.URL != "" {
1495 fmt.Fprintf(&result, "URL: %s\n", params.URL)
1496 }
1497 if params.Prompt != "" {
1498 fmt.Fprintf(&result, "Prompt: %s\n\n", params.Prompt)
1499 }
1500
1501 result.WriteString("```markdown\n")
1502 result.WriteString(t.result.Content)
1503 result.WriteString("\n```")
1504
1505 return result.String()
1506}
1507
1508// formatWebFetchResultForCopy formats web fetch tool results for clipboard.
1509func (t *baseToolMessageItem) formatWebFetchResultForCopy() string {
1510 if t.result == nil {
1511 return ""
1512 }
1513
1514 var params tools.WebFetchParams
1515 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) != nil {
1516 return t.result.Content
1517 }
1518
1519 var result strings.Builder
1520 fmt.Fprintf(&result, "URL: %s\n\n", params.URL)
1521 result.WriteString("```markdown\n")
1522 result.WriteString(t.result.Content)
1523 result.WriteString("\n```")
1524
1525 return result.String()
1526}
1527
1528// formatAgentResultForCopy formats agent tool results for clipboard.
1529func (t *baseToolMessageItem) formatAgentResultForCopy() string {
1530 if t.result == nil {
1531 return ""
1532 }
1533
1534 var result strings.Builder
1535
1536 if t.result.Content != "" {
1537 fmt.Fprintf(&result, "```markdown\n%s\n```", t.result.Content)
1538 }
1539
1540 return result.String()
1541}
1542
1543// prettifyToolName returns a human-readable name for tool names.
1544func prettifyToolName(name string) string {
1545 switch name {
1546 case agent.AgentToolName:
1547 return "Agent"
1548 case tools.BashToolName:
1549 return "Bash"
1550 case tools.JobOutputToolName:
1551 return "Job: Output"
1552 case tools.JobKillToolName:
1553 return "Job: Kill"
1554 case tools.DownloadToolName:
1555 return "Download"
1556 case tools.EditToolName:
1557 return "Edit"
1558 case tools.MultiEditToolName:
1559 return "Multi-Edit"
1560 case tools.FetchToolName:
1561 return "Fetch"
1562 case tools.AgenticFetchToolName:
1563 return "Agentic Fetch"
1564 case tools.WebFetchToolName:
1565 return "Fetch"
1566 case tools.WebSearchToolName:
1567 return "Search"
1568 case tools.GlobToolName:
1569 return "Glob"
1570 case tools.GrepToolName:
1571 return "Grep"
1572 case tools.LSToolName:
1573 return "List"
1574 case tools.SourcegraphToolName:
1575 return "Sourcegraph"
1576 case tools.TodosToolName:
1577 return "To-Do"
1578 case tools.ViewToolName:
1579 return "View"
1580 case tools.WriteToolName:
1581 return "Write"
1582 default:
1583 return humanizedToolName(name)
1584 }
1585}