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