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