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