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 rendered, err := renderer.Render(content)
1032 if err != nil {
1033 return toolOutputPlainContent(sty, content, width, expanded)
1034 }
1035
1036 lines := strings.Split(rendered, "\n")
1037 maxLines := responseContextHeight
1038 if expanded {
1039 maxLines = len(lines)
1040 }
1041
1042 var out []string
1043 for i, ln := range lines {
1044 if i >= maxLines {
1045 break
1046 }
1047 out = append(out, ln)
1048 }
1049
1050 if len(lines) > maxLines && !expanded {
1051 out = append(out, sty.Tool.ContentTruncation.
1052 Width(width).
1053 Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)),
1054 )
1055 }
1056
1057 return sty.Tool.Body.Render(strings.Join(out, "\n"))
1058}
1059
1060// formatToolForCopy formats the tool call for clipboard copying.
1061func (t *baseToolMessageItem) formatToolForCopy() string {
1062 var parts []string
1063
1064 toolName := prettifyToolName(t.toolCall.Name)
1065 parts = append(parts, fmt.Sprintf("## %s Tool Call", toolName))
1066
1067 if t.toolCall.Input != "" {
1068 params := t.formatParametersForCopy()
1069 if params != "" {
1070 parts = append(parts, "### Parameters:")
1071 parts = append(parts, params)
1072 }
1073 }
1074
1075 if t.result != nil && t.result.ToolCallID != "" {
1076 if t.result.IsError {
1077 parts = append(parts, "### Error:")
1078 parts = append(parts, t.result.Content)
1079 } else {
1080 parts = append(parts, "### Result:")
1081 content := t.formatResultForCopy()
1082 if content != "" {
1083 parts = append(parts, content)
1084 }
1085 }
1086 } else if t.status == ToolStatusCanceled {
1087 parts = append(parts, "### Status:")
1088 parts = append(parts, "Cancelled")
1089 } else {
1090 parts = append(parts, "### Status:")
1091 parts = append(parts, "Pending...")
1092 }
1093
1094 return strings.Join(parts, "\n\n")
1095}
1096
1097// formatParametersForCopy formats tool parameters for clipboard copying.
1098func (t *baseToolMessageItem) formatParametersForCopy() string {
1099 switch t.toolCall.Name {
1100 case tools.BashToolName:
1101 var params tools.BashParams
1102 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1103 cmd := strings.ReplaceAll(params.Command, "\n", " ")
1104 cmd = strings.ReplaceAll(cmd, "\t", " ")
1105 return fmt.Sprintf("**Command:** %s", cmd)
1106 }
1107 case tools.ViewToolName:
1108 var params tools.ViewParams
1109 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1110 var parts []string
1111 parts = append(parts, fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)))
1112 if params.Limit > 0 {
1113 parts = append(parts, fmt.Sprintf("**Limit:** %d", params.Limit))
1114 }
1115 if params.Offset > 0 {
1116 parts = append(parts, fmt.Sprintf("**Offset:** %d", params.Offset))
1117 }
1118 return strings.Join(parts, "\n")
1119 }
1120 case tools.EditToolName:
1121 var params tools.EditParams
1122 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1123 return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath))
1124 }
1125 case tools.MultiEditToolName:
1126 var params tools.MultiEditParams
1127 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1128 var parts []string
1129 parts = append(parts, fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)))
1130 parts = append(parts, fmt.Sprintf("**Edits:** %d", len(params.Edits)))
1131 return strings.Join(parts, "\n")
1132 }
1133 case tools.WriteToolName:
1134 var params tools.WriteParams
1135 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1136 return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath))
1137 }
1138 case tools.FetchToolName:
1139 var params tools.FetchParams
1140 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1141 var parts []string
1142 parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
1143 if params.Format != "" {
1144 parts = append(parts, fmt.Sprintf("**Format:** %s", params.Format))
1145 }
1146 if params.Timeout > 0 {
1147 parts = append(parts, fmt.Sprintf("**Timeout:** %ds", params.Timeout))
1148 }
1149 return strings.Join(parts, "\n")
1150 }
1151 case tools.AgenticFetchToolName:
1152 var params tools.AgenticFetchParams
1153 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1154 var parts []string
1155 if params.URL != "" {
1156 parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
1157 }
1158 if params.Prompt != "" {
1159 parts = append(parts, fmt.Sprintf("**Prompt:** %s", params.Prompt))
1160 }
1161 return strings.Join(parts, "\n")
1162 }
1163 case tools.WebFetchToolName:
1164 var params tools.WebFetchParams
1165 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1166 return fmt.Sprintf("**URL:** %s", params.URL)
1167 }
1168 case tools.GrepToolName:
1169 var params tools.GrepParams
1170 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1171 var parts []string
1172 parts = append(parts, fmt.Sprintf("**Pattern:** %s", params.Pattern))
1173 if params.Path != "" {
1174 parts = append(parts, fmt.Sprintf("**Path:** %s", params.Path))
1175 }
1176 if params.Include != "" {
1177 parts = append(parts, fmt.Sprintf("**Include:** %s", params.Include))
1178 }
1179 if params.LiteralText {
1180 parts = append(parts, "**Literal:** true")
1181 }
1182 return strings.Join(parts, "\n")
1183 }
1184 case tools.GlobToolName:
1185 var params tools.GlobParams
1186 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1187 var parts []string
1188 parts = append(parts, fmt.Sprintf("**Pattern:** %s", params.Pattern))
1189 if params.Path != "" {
1190 parts = append(parts, fmt.Sprintf("**Path:** %s", params.Path))
1191 }
1192 return strings.Join(parts, "\n")
1193 }
1194 case tools.LSToolName:
1195 var params tools.LSParams
1196 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1197 path := params.Path
1198 if path == "" {
1199 path = "."
1200 }
1201 return fmt.Sprintf("**Path:** %s", fsext.PrettyPath(path))
1202 }
1203 case tools.DownloadToolName:
1204 var params tools.DownloadParams
1205 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1206 var parts []string
1207 parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
1208 parts = append(parts, fmt.Sprintf("**File Path:** %s", fsext.PrettyPath(params.FilePath)))
1209 if params.Timeout > 0 {
1210 parts = append(parts, fmt.Sprintf("**Timeout:** %s", (time.Duration(params.Timeout)*time.Second).String()))
1211 }
1212 return strings.Join(parts, "\n")
1213 }
1214 case tools.SourcegraphToolName:
1215 var params tools.SourcegraphParams
1216 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1217 var parts []string
1218 parts = append(parts, fmt.Sprintf("**Query:** %s", params.Query))
1219 if params.Count > 0 {
1220 parts = append(parts, fmt.Sprintf("**Count:** %d", params.Count))
1221 }
1222 if params.ContextWindow > 0 {
1223 parts = append(parts, fmt.Sprintf("**Context:** %d", params.ContextWindow))
1224 }
1225 return strings.Join(parts, "\n")
1226 }
1227 case tools.DiagnosticsToolName:
1228 return "**Project:** diagnostics"
1229 case agent.AgentToolName:
1230 var params agent.AgentParams
1231 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1232 return fmt.Sprintf("**Task:**\n%s", params.Prompt)
1233 }
1234 }
1235
1236 var params map[string]any
1237 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1238 var parts []string
1239 for key, value := range params {
1240 displayKey := strings.ReplaceAll(key, "_", " ")
1241 if len(displayKey) > 0 {
1242 displayKey = strings.ToUpper(displayKey[:1]) + displayKey[1:]
1243 }
1244 parts = append(parts, fmt.Sprintf("**%s:** %v", displayKey, value))
1245 }
1246 return strings.Join(parts, "\n")
1247 }
1248
1249 return ""
1250}
1251
1252// formatResultForCopy formats tool results for clipboard copying.
1253func (t *baseToolMessageItem) formatResultForCopy() string {
1254 if t.result == nil {
1255 return ""
1256 }
1257
1258 if t.result.Data != "" {
1259 if strings.HasPrefix(t.result.MIMEType, "image/") {
1260 return fmt.Sprintf("[Image: %s]", t.result.MIMEType)
1261 }
1262 return fmt.Sprintf("[Media: %s]", t.result.MIMEType)
1263 }
1264
1265 switch t.toolCall.Name {
1266 case tools.BashToolName:
1267 return t.formatBashResultForCopy()
1268 case tools.ViewToolName:
1269 return t.formatViewResultForCopy()
1270 case tools.EditToolName:
1271 return t.formatEditResultForCopy()
1272 case tools.MultiEditToolName:
1273 return t.formatMultiEditResultForCopy()
1274 case tools.WriteToolName:
1275 return t.formatWriteResultForCopy()
1276 case tools.FetchToolName:
1277 return t.formatFetchResultForCopy()
1278 case tools.AgenticFetchToolName:
1279 return t.formatAgenticFetchResultForCopy()
1280 case tools.WebFetchToolName:
1281 return t.formatWebFetchResultForCopy()
1282 case agent.AgentToolName:
1283 return t.formatAgentResultForCopy()
1284 case tools.DownloadToolName, tools.GrepToolName, tools.GlobToolName, tools.LSToolName, tools.SourcegraphToolName, tools.DiagnosticsToolName, tools.TodosToolName:
1285 return fmt.Sprintf("```\n%s\n```", t.result.Content)
1286 default:
1287 return t.result.Content
1288 }
1289}
1290
1291// formatBashResultForCopy formats bash tool results for clipboard.
1292func (t *baseToolMessageItem) formatBashResultForCopy() string {
1293 if t.result == nil {
1294 return ""
1295 }
1296
1297 var meta tools.BashResponseMetadata
1298 if t.result.Metadata != "" {
1299 json.Unmarshal([]byte(t.result.Metadata), &meta)
1300 }
1301
1302 output := meta.Output
1303 if output == "" && t.result.Content != tools.BashNoOutput {
1304 output = t.result.Content
1305 }
1306
1307 if output == "" {
1308 return ""
1309 }
1310
1311 return fmt.Sprintf("```bash\n%s\n```", output)
1312}
1313
1314// formatViewResultForCopy formats view tool results for clipboard.
1315func (t *baseToolMessageItem) formatViewResultForCopy() string {
1316 if t.result == nil {
1317 return ""
1318 }
1319
1320 var meta tools.ViewResponseMetadata
1321 if t.result.Metadata != "" {
1322 json.Unmarshal([]byte(t.result.Metadata), &meta)
1323 }
1324
1325 if meta.Content == "" {
1326 return t.result.Content
1327 }
1328
1329 lang := ""
1330 if meta.FilePath != "" {
1331 ext := strings.ToLower(filepath.Ext(meta.FilePath))
1332 switch ext {
1333 case ".go":
1334 lang = "go"
1335 case ".js", ".mjs":
1336 lang = "javascript"
1337 case ".ts":
1338 lang = "typescript"
1339 case ".py":
1340 lang = "python"
1341 case ".rs":
1342 lang = "rust"
1343 case ".java":
1344 lang = "java"
1345 case ".c":
1346 lang = "c"
1347 case ".cpp", ".cc", ".cxx":
1348 lang = "cpp"
1349 case ".sh", ".bash":
1350 lang = "bash"
1351 case ".json":
1352 lang = "json"
1353 case ".yaml", ".yml":
1354 lang = "yaml"
1355 case ".xml":
1356 lang = "xml"
1357 case ".html":
1358 lang = "html"
1359 case ".css":
1360 lang = "css"
1361 case ".md":
1362 lang = "markdown"
1363 }
1364 }
1365
1366 var result strings.Builder
1367 if lang != "" {
1368 fmt.Fprintf(&result, "```%s\n", lang)
1369 } else {
1370 result.WriteString("```\n")
1371 }
1372 result.WriteString(meta.Content)
1373 result.WriteString("\n```")
1374
1375 return result.String()
1376}
1377
1378// formatEditResultForCopy formats edit tool results for clipboard.
1379func (t *baseToolMessageItem) formatEditResultForCopy() string {
1380 if t.result == nil || t.result.Metadata == "" {
1381 if t.result != nil {
1382 return t.result.Content
1383 }
1384 return ""
1385 }
1386
1387 var meta tools.EditResponseMetadata
1388 if json.Unmarshal([]byte(t.result.Metadata), &meta) != nil {
1389 return t.result.Content
1390 }
1391
1392 var params tools.EditParams
1393 json.Unmarshal([]byte(t.toolCall.Input), ¶ms)
1394
1395 var result strings.Builder
1396
1397 if meta.OldContent != "" || meta.NewContent != "" {
1398 fileName := params.FilePath
1399 if fileName != "" {
1400 fileName = fsext.PrettyPath(fileName)
1401 }
1402 diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName)
1403
1404 fmt.Fprintf(&result, "Changes: +%d -%d\n", additions, removals)
1405 result.WriteString("```diff\n")
1406 result.WriteString(diffContent)
1407 result.WriteString("\n```")
1408 }
1409
1410 return result.String()
1411}
1412
1413// formatMultiEditResultForCopy formats multi-edit tool results for clipboard.
1414func (t *baseToolMessageItem) formatMultiEditResultForCopy() string {
1415 if t.result == nil || t.result.Metadata == "" {
1416 if t.result != nil {
1417 return t.result.Content
1418 }
1419 return ""
1420 }
1421
1422 var meta tools.MultiEditResponseMetadata
1423 if json.Unmarshal([]byte(t.result.Metadata), &meta) != nil {
1424 return t.result.Content
1425 }
1426
1427 var params tools.MultiEditParams
1428 json.Unmarshal([]byte(t.toolCall.Input), ¶ms)
1429
1430 var result strings.Builder
1431 if meta.OldContent != "" || meta.NewContent != "" {
1432 fileName := params.FilePath
1433 if fileName != "" {
1434 fileName = fsext.PrettyPath(fileName)
1435 }
1436 diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName)
1437
1438 fmt.Fprintf(&result, "Changes: +%d -%d\n", additions, removals)
1439 result.WriteString("```diff\n")
1440 result.WriteString(diffContent)
1441 result.WriteString("\n```")
1442 }
1443
1444 return result.String()
1445}
1446
1447// formatWriteResultForCopy formats write tool results for clipboard.
1448func (t *baseToolMessageItem) formatWriteResultForCopy() string {
1449 if t.result == nil {
1450 return ""
1451 }
1452
1453 var params tools.WriteParams
1454 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) != nil {
1455 return t.result.Content
1456 }
1457
1458 lang := ""
1459 if params.FilePath != "" {
1460 ext := strings.ToLower(filepath.Ext(params.FilePath))
1461 switch ext {
1462 case ".go":
1463 lang = "go"
1464 case ".js", ".mjs":
1465 lang = "javascript"
1466 case ".ts":
1467 lang = "typescript"
1468 case ".py":
1469 lang = "python"
1470 case ".rs":
1471 lang = "rust"
1472 case ".java":
1473 lang = "java"
1474 case ".c":
1475 lang = "c"
1476 case ".cpp", ".cc", ".cxx":
1477 lang = "cpp"
1478 case ".sh", ".bash":
1479 lang = "bash"
1480 case ".json":
1481 lang = "json"
1482 case ".yaml", ".yml":
1483 lang = "yaml"
1484 case ".xml":
1485 lang = "xml"
1486 case ".html":
1487 lang = "html"
1488 case ".css":
1489 lang = "css"
1490 case ".md":
1491 lang = "markdown"
1492 }
1493 }
1494
1495 var result strings.Builder
1496 fmt.Fprintf(&result, "File: %s\n", fsext.PrettyPath(params.FilePath))
1497 if lang != "" {
1498 fmt.Fprintf(&result, "```%s\n", lang)
1499 } else {
1500 result.WriteString("```\n")
1501 }
1502 result.WriteString(params.Content)
1503 result.WriteString("\n```")
1504
1505 return result.String()
1506}
1507
1508// formatFetchResultForCopy formats fetch tool results for clipboard.
1509func (t *baseToolMessageItem) formatFetchResultForCopy() string {
1510 if t.result == nil {
1511 return ""
1512 }
1513
1514 var params tools.FetchParams
1515 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) != nil {
1516 return t.result.Content
1517 }
1518
1519 var result strings.Builder
1520 if params.URL != "" {
1521 fmt.Fprintf(&result, "URL: %s\n", params.URL)
1522 }
1523 if params.Format != "" {
1524 fmt.Fprintf(&result, "Format: %s\n", params.Format)
1525 }
1526 if params.Timeout > 0 {
1527 fmt.Fprintf(&result, "Timeout: %ds\n", params.Timeout)
1528 }
1529 result.WriteString("\n")
1530
1531 result.WriteString(t.result.Content)
1532
1533 return result.String()
1534}
1535
1536// formatAgenticFetchResultForCopy formats agentic fetch tool results for clipboard.
1537func (t *baseToolMessageItem) formatAgenticFetchResultForCopy() string {
1538 if t.result == nil {
1539 return ""
1540 }
1541
1542 var params tools.AgenticFetchParams
1543 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) != nil {
1544 return t.result.Content
1545 }
1546
1547 var result strings.Builder
1548 if params.URL != "" {
1549 fmt.Fprintf(&result, "URL: %s\n", params.URL)
1550 }
1551 if params.Prompt != "" {
1552 fmt.Fprintf(&result, "Prompt: %s\n\n", params.Prompt)
1553 }
1554
1555 result.WriteString("```markdown\n")
1556 result.WriteString(t.result.Content)
1557 result.WriteString("\n```")
1558
1559 return result.String()
1560}
1561
1562// formatWebFetchResultForCopy formats web fetch tool results for clipboard.
1563func (t *baseToolMessageItem) formatWebFetchResultForCopy() string {
1564 if t.result == nil {
1565 return ""
1566 }
1567
1568 var params tools.WebFetchParams
1569 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) != nil {
1570 return t.result.Content
1571 }
1572
1573 var result strings.Builder
1574 fmt.Fprintf(&result, "URL: %s\n\n", params.URL)
1575 result.WriteString("```markdown\n")
1576 result.WriteString(t.result.Content)
1577 result.WriteString("\n```")
1578
1579 return result.String()
1580}
1581
1582// formatAgentResultForCopy formats agent tool results for clipboard.
1583func (t *baseToolMessageItem) formatAgentResultForCopy() string {
1584 if t.result == nil {
1585 return ""
1586 }
1587
1588 var result strings.Builder
1589
1590 if t.result.Content != "" {
1591 fmt.Fprintf(&result, "```markdown\n%s\n```", t.result.Content)
1592 }
1593
1594 return result.String()
1595}
1596
1597// prettifyToolName returns a human-readable name for tool names.
1598func prettifyToolName(name string) string {
1599 switch name {
1600 case agent.AgentToolName:
1601 return "Agent"
1602 case tools.BashToolName:
1603 return "Bash"
1604 case tools.JobOutputToolName:
1605 return "Job: Output"
1606 case tools.JobKillToolName:
1607 return "Job: Kill"
1608 case tools.DownloadToolName:
1609 return "Download"
1610 case tools.EditToolName:
1611 return "Edit"
1612 case tools.MultiEditToolName:
1613 return "Multi-Edit"
1614 case tools.FetchToolName:
1615 return "Fetch"
1616 case tools.AgenticFetchToolName:
1617 return "Agentic Fetch"
1618 case tools.WebFetchToolName:
1619 return "Fetch"
1620 case tools.WebSearchToolName:
1621 return "Search"
1622 case tools.GlobToolName:
1623 return "Glob"
1624 case tools.GrepToolName:
1625 return "Grep"
1626 case tools.LSToolName:
1627 return "List"
1628 case tools.SourcegraphToolName:
1629 return "Sourcegraph"
1630 case tools.TodosToolName:
1631 return "To-Do"
1632 case tools.ViewToolName:
1633 return "View"
1634 case tools.WriteToolName:
1635 return "Write"
1636 default:
1637 return humanizedToolName(name)
1638 }
1639}