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 an ERROR or WARN 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 if strings.Contains(errContent, "User denied permission") {
546 deniedTag := sty.Tool.WarnTag.Render("WARN")
547 deniedTagWidth := lipgloss.Width(deniedTag)
548 errContent = ansi.Truncate(errContent, width-deniedTagWidth-3, "…")
549 return fmt.Sprintf("%s %s", deniedTag, sty.Tool.WarnMessage.Render(errContent))
550 }
551 errTag := sty.Tool.ErrorTag.Render("ERROR")
552 tagWidth := lipgloss.Width(errTag)
553 errContent = ansi.Truncate(errContent, width-tagWidth-3, "…")
554 return fmt.Sprintf("%s %s", errTag, sty.Tool.ErrorMessage.Render(errContent))
555}
556
557// toolIcon returns the status icon for a tool call.
558// toolIcon returns the status icon for a tool call based on its status.
559func toolIcon(sty *styles.Styles, status ToolStatus) string {
560 switch status {
561 case ToolStatusSuccess:
562 return sty.Tool.IconSuccess.String()
563 case ToolStatusError:
564 return sty.Tool.IconError.String()
565 case ToolStatusCanceled:
566 return sty.Tool.IconCancelled.String()
567 default:
568 return sty.Tool.IconPending.String()
569 }
570}
571
572// toolParamList formats parameters as "main (key=value, ...)" with truncation.
573// toolParamList formats tool parameters as "main (key=value, ...)" with truncation.
574func toolParamList(sty *styles.Styles, params []string, width int) string {
575 // minSpaceForMainParam is the min space required for the main param
576 // if this is less that the value set we will only show the main param nothing else
577 const minSpaceForMainParam = 30
578 if len(params) == 0 {
579 return ""
580 }
581
582 mainParam := params[0]
583
584 // Build key=value pairs from remaining params (consecutive key, value pairs).
585 var kvPairs []string
586 for i := 1; i+1 < len(params); i += 2 {
587 if params[i+1] != "" {
588 kvPairs = append(kvPairs, fmt.Sprintf("%s=%s", params[i], params[i+1]))
589 }
590 }
591
592 // Try to include key=value pairs if there's enough space.
593 output := mainParam
594 if len(kvPairs) > 0 {
595 partsStr := strings.Join(kvPairs, ", ")
596 if remaining := width - lipgloss.Width(partsStr) - 3; remaining >= minSpaceForMainParam {
597 output = fmt.Sprintf("%s (%s)", mainParam, partsStr)
598 }
599 }
600
601 if width >= 0 {
602 output = ansi.Truncate(output, width, "…")
603 }
604 return sty.Tool.ParamMain.Render(output)
605}
606
607// toolHeader builds the tool header line: "● ToolName params..."
608func toolHeader(sty *styles.Styles, status ToolStatus, name string, width int, nested bool, params ...string) string {
609 icon := toolIcon(sty, status)
610 nameStyle := sty.Tool.NameNormal
611 if nested {
612 nameStyle = sty.Tool.NameNested
613 }
614 toolName := nameStyle.Render(name)
615 prefix := fmt.Sprintf("%s %s ", icon, toolName)
616 prefixWidth := lipgloss.Width(prefix)
617 remainingWidth := width - prefixWidth
618 paramsStr := toolParamList(sty, params, remainingWidth)
619 return prefix + paramsStr
620}
621
622// toolOutputPlainContent renders plain text with optional expansion support.
623func toolOutputPlainContent(sty *styles.Styles, content string, width int, expanded bool) string {
624 content = stringext.NormalizeSpace(content)
625 lines := strings.Split(content, "\n")
626
627 maxLines := responseContextHeight
628 if expanded {
629 maxLines = len(lines) // Show all
630 }
631
632 var out []string
633 for i, ln := range lines {
634 if i >= maxLines {
635 break
636 }
637 ln = " " + ln
638 if lipgloss.Width(ln) > width {
639 ln = ansi.Truncate(ln, width, "…")
640 }
641 out = append(out, sty.Tool.ContentLine.Width(width).Render(ln))
642 }
643
644 wasTruncated := len(lines) > responseContextHeight
645
646 if !expanded && wasTruncated {
647 out = append(out, sty.Tool.ContentTruncation.
648 Width(width).
649 Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-responseContextHeight)))
650 }
651
652 return strings.Join(out, "\n")
653}
654
655// toolOutputCodeContent renders code with syntax highlighting and line numbers.
656func toolOutputCodeContent(sty *styles.Styles, path, content string, offset, width int, expanded bool) string {
657 content = stringext.NormalizeSpace(content)
658
659 lines := strings.Split(content, "\n")
660 maxLines := responseContextHeight
661 if expanded {
662 maxLines = len(lines)
663 }
664
665 // Truncate if needed.
666 displayLines := lines
667 if len(lines) > maxLines {
668 displayLines = lines[:maxLines]
669 }
670
671 bg := sty.Tool.ContentCodeBg
672 highlighted, _ := common.SyntaxHighlight(sty, strings.Join(displayLines, "\n"), path, bg)
673 highlightedLines := strings.Split(highlighted, "\n")
674
675 // Calculate line number width.
676 maxLineNumber := len(displayLines) + offset
677 maxDigits := getDigits(maxLineNumber)
678 numFmt := fmt.Sprintf("%%%dd", maxDigits)
679
680 bodyWidth := width - toolBodyLeftPaddingTotal
681 codeWidth := bodyWidth - maxDigits
682
683 var out []string
684 for i, ln := range highlightedLines {
685 lineNum := sty.Tool.ContentLineNumber.Render(fmt.Sprintf(numFmt, i+1+offset))
686
687 // Truncate accounting for padding that will be added.
688 ln = ansi.Truncate(ln, codeWidth-sty.Tool.ContentCodeLine.GetHorizontalPadding(), "…")
689
690 codeLine := sty.Tool.ContentCodeLine.
691 Width(codeWidth).
692 Render(ln)
693
694 out = append(out, lipgloss.JoinHorizontal(lipgloss.Left, lineNum, codeLine))
695 }
696
697 // Add truncation message if needed.
698 if len(lines) > maxLines && !expanded {
699 out = append(
700 out, sty.Tool.ContentCodeTruncation.
701 Width(width).
702 Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)),
703 )
704 }
705
706 return sty.Tool.Body.Render(strings.Join(out, "\n"))
707}
708
709// toolOutputImageContent renders image data with size info.
710func toolOutputImageContent(sty *styles.Styles, data, mediaType string) string {
711 dataSize := len(data) * 3 / 4
712 sizeStr := formatSize(dataSize)
713
714 return sty.Tool.Body.Render(fmt.Sprintf(
715 "%s %s %s %s",
716 sty.Tool.ResourceLoadedText.Render("Loaded Image"),
717 sty.Tool.ResourceLoadedIndicator.Render(styles.ArrowRightIcon),
718 sty.Tool.MediaType.Render(mediaType),
719 sty.Tool.ResourceSize.Render(sizeStr),
720 ))
721}
722
723// toolOutputSkillContent renders a skill loaded indicator.
724func toolOutputSkillContent(sty *styles.Styles, name, description string) string {
725 return sty.Tool.Body.Render(fmt.Sprintf(
726 "%s %s %s %s",
727 sty.Tool.ResourceLoadedText.Render("Loaded Skill"),
728 sty.Tool.ResourceLoadedIndicator.Render(styles.ArrowRightIcon),
729 sty.Tool.ResourceName.Render(name),
730 sty.Tool.ResourceSize.Render(description),
731 ))
732}
733
734// toolOutputHookIndicator renders hook indicator lines from tool metadata.
735// Returns empty string if no hook metadata is present. Hook names are
736// sanitized (newlines replaced with ¶) and truncated to fit the available
737// horizontal space.
738func toolOutputHookIndicator(sty *styles.Styles, metadata string, width int) string {
739 if metadata == "" {
740 return ""
741 }
742 var meta struct {
743 Hook *hooks.HookMetadata `json:"hook"`
744 }
745 if err := json.Unmarshal([]byte(metadata), &meta); err != nil || meta.Hook == nil {
746 return ""
747 }
748 h := meta.Hook
749 if len(h.Hooks) == 0 {
750 return ""
751 }
752
753 // Sanitize names (replace newlines with ¶) and compute max widths
754 // for the name, matcher, and detail columns so they align. The name
755 // column is capped at maxHookNameWidth characters.
756 const maxHookNameWidth = 30
757 sanitizedNames := make([]string, len(h.Hooks))
758 details := make([]string, len(h.Hooks))
759 maxNameWidth := 0
760 maxMatcherWidth := 0
761 maxDetailWidth := 0
762 for i, hi := range h.Hooks {
763 sanitizedNames[i] = strings.ReplaceAll(hi.Name, "\n", "¶")
764 w := lipgloss.Width(sty.Tool.HookName.Render(sanitizedNames[i]))
765 if w > maxNameWidth {
766 maxNameWidth = w
767 }
768 if hi.Matcher != "" {
769 mw := lipgloss.Width(sty.Tool.HookMatcher.Render(hi.Matcher))
770 if mw > maxMatcherWidth {
771 maxMatcherWidth = mw
772 }
773 }
774 details[i] = hookDetail(sty, hi)
775 if dw := lipgloss.Width(details[i]); dw > maxDetailWidth {
776 maxDetailWidth = dw
777 }
778 }
779
780 if maxNameWidth > maxHookNameWidth {
781 maxNameWidth = maxHookNameWidth
782 }
783
784 // Cap the name column so the widest line still fits in width. The
785 // per-line layout is:
786 // "Hook " + name(padded) + [" " + matcher(padded)] + " → " + detail
787 if width > 0 {
788 fixed := lipgloss.Width(sty.Tool.HookLabel.Render("Hook")) + 1
789 if maxMatcherWidth > 0 {
790 fixed += 1 + maxMatcherWidth
791 }
792 fixed += 1 + lipgloss.Width(sty.Tool.HookArrow.Render(styles.ArrowRightIcon)) + 1
793 fixed += maxDetailWidth
794 if budget := width - fixed; budget < maxNameWidth {
795 maxNameWidth = max(1, budget)
796 }
797 }
798
799 var lines []string
800 for i, hi := range h.Hooks {
801 name := truncateHookName(sanitizedNames[i], maxNameWidth)
802 lines = append(lines, renderHookLine(sty, hi, name, details[i], maxNameWidth, maxMatcherWidth))
803 }
804 return strings.Join(lines, "\n")
805}
806
807// truncateHookName truncates a hook name to fit within maxWidth cells,
808// using left-truncation for absolute paths (e.g. `…/format.sh`) and
809// right-truncation for everything else. Left-truncation is only applied
810// when the name looks unambiguously like a path: absolute, single-line,
811// and contains no spaces.
812func truncateHookName(name string, maxWidth int) string {
813 if ansi.StringWidth(name) <= maxWidth {
814 return name
815 }
816 if isLikelyPath(name) {
817 // ansi.TruncateLeft removes n graphemes from the start; pick n
818 // so the result plus the "…" prefix fits in maxWidth.
819 n := ansi.StringWidth(name) - maxWidth + 1
820 return ansi.TruncateLeft(name, n, "…")
821 }
822 return ansi.Truncate(name, maxWidth, "…")
823}
824
825// isLikelyPath reports whether s looks unambiguously like a filesystem
826// path, suitable for left-truncation. We accept absolute paths and
827// relative paths that contain a separator and no shell-ish characters.
828func isLikelyPath(s string) bool {
829 if s == "" || strings.ContainsAny(s, " \t\n¶'\"|&;<>$`*?(){}[]\\") {
830 return false
831 }
832 if filepath.IsAbs(s) {
833 return true
834 }
835 return strings.Contains(s, "/")
836}
837
838// renderHookLine renders a single hook indicator line with aligned columns.
839func renderHookLine(sty *styles.Styles, hi hooks.HookInfo, rawName, detail string, maxNameWidth, maxMatcherWidth int) string {
840 name := sty.Tool.HookName.Render(rawName)
841 namePad := strings.Repeat(" ", max(0, maxNameWidth-lipgloss.Width(name)))
842
843 var matcherPart string
844 if maxMatcherWidth > 0 {
845 if hi.Matcher != "" {
846 matcher := sty.Tool.HookMatcher.Render(hi.Matcher)
847 matcherPad := strings.Repeat(" ", maxMatcherWidth-lipgloss.Width(matcher))
848 matcherPart = " " + matcher + matcherPad
849 } else {
850 matcherPart = " " + strings.Repeat(" ", maxMatcherWidth)
851 }
852 }
853
854 labelStyle := sty.Tool.HookLabel
855 arrowStyle := sty.Tool.HookArrow
856 if hi.Decision == "deny" {
857 labelStyle = sty.Tool.HookDeniedLabel
858 arrowStyle = sty.Tool.HookDeniedLabel
859 }
860
861 return fmt.Sprintf(
862 "%s %s%s%s %s %s",
863 labelStyle.Render("Hook"),
864 name,
865 namePad,
866 matcherPart,
867 arrowStyle.Render(styles.ArrowRightIcon),
868 detail,
869 )
870}
871
872// hookDetail returns the styled detail text for a single hook result.
873func hookDetail(sty *styles.Styles, hi hooks.HookInfo) string {
874 const (
875 okMessage = "OK"
876 denialMessage = "Denied"
877 rewroteMessage = "Rewrote Output"
878 )
879 switch hi.Decision {
880 case "deny":
881 if hi.Reason != "" {
882 return sty.Tool.HookDenied.Render(denialMessage) + " " + sty.Tool.HookDeniedReason.Render(hi.Reason)
883 }
884 return sty.Tool.HookDenied.Render(denialMessage)
885 case "allow":
886 result := sty.Tool.HookOK.Render(okMessage)
887 if hi.InputRewrite {
888 result += " " + sty.Tool.HookRewrote.Render(rewroteMessage)
889 }
890 return result
891 default:
892 result := sty.Tool.HookOK.Render(okMessage)
893 if hi.InputRewrite {
894 result += " " + sty.Tool.HookRewrote.Render(rewroteMessage)
895 }
896 return result
897 }
898}
899
900// getDigits returns the number of digits in a number.
901func getDigits(n int) int {
902 if n == 0 {
903 return 1
904 }
905 if n < 0 {
906 n = -n
907 }
908 digits := 0
909 for n > 0 {
910 n /= 10
911 digits++
912 }
913 return digits
914}
915
916// formatSize formats byte size into human readable format.
917func formatSize(bytes int) string {
918 const (
919 kb = 1024
920 mb = kb * 1024
921 )
922 switch {
923 case bytes >= mb:
924 return fmt.Sprintf("%.1f MB", float64(bytes)/float64(mb))
925 case bytes >= kb:
926 return fmt.Sprintf("%.1f KB", float64(bytes)/float64(kb))
927 default:
928 return fmt.Sprintf("%d B", bytes)
929 }
930}
931
932// toolOutputDiffContent renders a diff between old and new content.
933func toolOutputDiffContent(sty *styles.Styles, file, oldContent, newContent string, width int, expanded bool) string {
934 bodyWidth := width - toolBodyLeftPaddingTotal
935
936 formatter := common.DiffFormatter(sty).
937 Before(file, oldContent).
938 After(file, newContent).
939 Width(bodyWidth)
940
941 // Use split view for wide terminals.
942 if width > maxTextWidth {
943 formatter = formatter.Split()
944 }
945
946 formatted := formatter.String()
947 lines := strings.Split(formatted, "\n")
948
949 // Truncate if needed.
950 maxLines := responseContextHeight
951 if expanded {
952 maxLines = len(lines)
953 }
954
955 if len(lines) > maxLines && !expanded {
956 truncMsg := sty.Tool.DiffTruncation.
957 Width(bodyWidth).
958 Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines))
959 formatted = strings.Join(lines[:maxLines], "\n") + "\n" + truncMsg
960 }
961
962 return sty.Tool.Body.Render(formatted)
963}
964
965// formatTimeout converts timeout seconds to a duration string (e.g., "30s").
966// Returns empty string if timeout is 0.
967func formatTimeout(timeout int) string {
968 if timeout == 0 {
969 return ""
970 }
971 return fmt.Sprintf("%ds", timeout)
972}
973
974// formatNonZero returns string representation of non-zero integers, empty string for zero.
975func formatNonZero(value int) string {
976 if value == 0 {
977 return ""
978 }
979 return fmt.Sprintf("%d", value)
980}
981
982// toolOutputMultiEditDiffContent renders a diff with optional failed edits note.
983func toolOutputMultiEditDiffContent(sty *styles.Styles, file string, meta tools.MultiEditResponseMetadata, totalEdits, width int, expanded bool) string {
984 bodyWidth := width - toolBodyLeftPaddingTotal
985
986 formatter := common.DiffFormatter(sty).
987 Before(file, meta.OldContent).
988 After(file, meta.NewContent).
989 Width(bodyWidth)
990
991 // Use split view for wide terminals.
992 if width > maxTextWidth {
993 formatter = formatter.Split()
994 }
995
996 formatted := formatter.String()
997 lines := strings.Split(formatted, "\n")
998
999 // Truncate if needed.
1000 maxLines := responseContextHeight
1001 if expanded {
1002 maxLines = len(lines)
1003 }
1004
1005 if len(lines) > maxLines && !expanded {
1006 truncMsg := sty.Tool.DiffTruncation.
1007 Width(bodyWidth).
1008 Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines))
1009 formatted = truncMsg + "\n" + strings.Join(lines[:maxLines], "\n")
1010 }
1011
1012 // Add failed edits note if any exist.
1013 if len(meta.EditsFailed) > 0 {
1014 noteTag := sty.Tool.NoteTag.Render("Note")
1015 noteMsg := fmt.Sprintf("%d of %d edits succeeded", meta.EditsApplied, totalEdits)
1016 note := fmt.Sprintf("%s %s", noteTag, sty.Tool.NoteMessage.Render(noteMsg))
1017 formatted = formatted + "\n\n" + note
1018 }
1019
1020 return sty.Tool.Body.Render(formatted)
1021}
1022
1023// roundedEnumerator creates a tree enumerator with rounded corners.
1024func roundedEnumerator(lPadding, width int) tree.Enumerator {
1025 if width == 0 {
1026 width = 2
1027 }
1028 if lPadding == 0 {
1029 lPadding = 1
1030 }
1031 return func(children tree.Children, index int) string {
1032 line := strings.Repeat("─", width)
1033 padding := strings.Repeat(" ", lPadding)
1034 if children.Length()-1 == index {
1035 return padding + "╰" + line
1036 }
1037 return padding + "├" + line
1038 }
1039}
1040
1041// toolOutputMarkdownContent renders markdown content with optional truncation.
1042func toolOutputMarkdownContent(sty *styles.Styles, content string, width int, expanded bool) string {
1043 content = stringext.NormalizeSpace(content)
1044
1045 // Cap width for readability.
1046 if width > maxTextWidth {
1047 width = maxTextWidth
1048 }
1049
1050 renderer := common.QuietMarkdownRenderer(sty, width)
1051 mu := common.LockMarkdownRenderer(renderer)
1052 mu.Lock()
1053 rendered, err := renderer.Render(content)
1054 mu.Unlock()
1055 if err != nil {
1056 return toolOutputPlainContent(sty, content, width, expanded)
1057 }
1058
1059 lines := strings.Split(rendered, "\n")
1060 maxLines := responseContextHeight
1061 if expanded {
1062 maxLines = len(lines)
1063 }
1064
1065 var out []string
1066 for i, ln := range lines {
1067 if i >= maxLines {
1068 break
1069 }
1070 out = append(out, ln)
1071 }
1072
1073 if len(lines) > maxLines && !expanded {
1074 out = append(
1075 out, sty.Tool.ContentTruncation.
1076 Width(width).
1077 Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)),
1078 )
1079 }
1080
1081 return sty.Tool.Body.Render(strings.Join(out, "\n"))
1082}
1083
1084// formatToolForCopy formats the tool call for clipboard copying.
1085func (t *baseToolMessageItem) formatToolForCopy() string {
1086 var parts []string
1087
1088 toolName := prettifyToolName(t.toolCall.Name)
1089 parts = append(parts, fmt.Sprintf("## %s Tool Call", toolName))
1090
1091 if t.toolCall.Input != "" {
1092 params := t.formatParametersForCopy()
1093 if params != "" {
1094 parts = append(parts, "### Parameters:")
1095 parts = append(parts, params)
1096 }
1097 }
1098
1099 if t.result != nil && t.result.ToolCallID != "" {
1100 if t.result.IsError {
1101 parts = append(parts, "### Error:")
1102 parts = append(parts, t.result.Content)
1103 } else {
1104 parts = append(parts, "### Result:")
1105 content := t.formatResultForCopy()
1106 if content != "" {
1107 parts = append(parts, content)
1108 }
1109 }
1110 } else if t.status == ToolStatusCanceled {
1111 parts = append(parts, "### Status:")
1112 parts = append(parts, "Cancelled")
1113 } else {
1114 parts = append(parts, "### Status:")
1115 parts = append(parts, "Pending...")
1116 }
1117
1118 return strings.Join(parts, "\n\n")
1119}
1120
1121// formatParametersForCopy formats tool parameters for clipboard copying.
1122func (t *baseToolMessageItem) formatParametersForCopy() string {
1123 switch t.toolCall.Name {
1124 case tools.BashToolName:
1125 var params tools.BashParams
1126 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1127 cmd := strings.ReplaceAll(params.Command, "\n", " ")
1128 cmd = strings.ReplaceAll(cmd, "\t", " ")
1129 return fmt.Sprintf("**Command:** %s", cmd)
1130 }
1131 case tools.ViewToolName:
1132 var params tools.ViewParams
1133 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1134 var parts []string
1135 parts = append(parts, fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)))
1136 if params.Limit > 0 {
1137 parts = append(parts, fmt.Sprintf("**Limit:** %d", params.Limit))
1138 }
1139 if params.Offset > 0 {
1140 parts = append(parts, fmt.Sprintf("**Offset:** %d", params.Offset))
1141 }
1142 return strings.Join(parts, "\n")
1143 }
1144 case tools.EditToolName:
1145 var params tools.EditParams
1146 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1147 return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath))
1148 }
1149 case tools.MultiEditToolName:
1150 var params tools.MultiEditParams
1151 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1152 var parts []string
1153 parts = append(parts, fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)))
1154 parts = append(parts, fmt.Sprintf("**Edits:** %d", len(params.Edits)))
1155 return strings.Join(parts, "\n")
1156 }
1157 case tools.WriteToolName:
1158 var params tools.WriteParams
1159 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1160 return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath))
1161 }
1162 case tools.FetchToolName:
1163 var params tools.FetchParams
1164 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1165 var parts []string
1166 parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
1167 if params.Format != "" {
1168 parts = append(parts, fmt.Sprintf("**Format:** %s", params.Format))
1169 }
1170 if params.Timeout > 0 {
1171 parts = append(parts, fmt.Sprintf("**Timeout:** %ds", params.Timeout))
1172 }
1173 return strings.Join(parts, "\n")
1174 }
1175 case tools.AgenticFetchToolName:
1176 var params tools.AgenticFetchParams
1177 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1178 var parts []string
1179 if params.URL != "" {
1180 parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
1181 }
1182 if params.Prompt != "" {
1183 parts = append(parts, fmt.Sprintf("**Prompt:** %s", params.Prompt))
1184 }
1185 return strings.Join(parts, "\n")
1186 }
1187 case tools.WebFetchToolName:
1188 var params tools.WebFetchParams
1189 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1190 return fmt.Sprintf("**URL:** %s", params.URL)
1191 }
1192 case tools.GrepToolName:
1193 var params tools.GrepParams
1194 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1195 var parts []string
1196 parts = append(parts, fmt.Sprintf("**Pattern:** %s", params.Pattern))
1197 if params.Path != "" {
1198 parts = append(parts, fmt.Sprintf("**Path:** %s", params.Path))
1199 }
1200 if params.Include != "" {
1201 parts = append(parts, fmt.Sprintf("**Include:** %s", params.Include))
1202 }
1203 if params.LiteralText {
1204 parts = append(parts, "**Literal:** true")
1205 }
1206 return strings.Join(parts, "\n")
1207 }
1208 case tools.GlobToolName:
1209 var params tools.GlobParams
1210 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1211 var parts []string
1212 parts = append(parts, fmt.Sprintf("**Pattern:** %s", params.Pattern))
1213 if params.Path != "" {
1214 parts = append(parts, fmt.Sprintf("**Path:** %s", params.Path))
1215 }
1216 return strings.Join(parts, "\n")
1217 }
1218 case tools.LSToolName:
1219 var params tools.LSParams
1220 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1221 path := params.Path
1222 if path == "" {
1223 path = "."
1224 }
1225 return fmt.Sprintf("**Path:** %s", fsext.PrettyPath(path))
1226 }
1227 case tools.DownloadToolName:
1228 var params tools.DownloadParams
1229 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1230 var parts []string
1231 parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
1232 parts = append(parts, fmt.Sprintf("**File Path:** %s", fsext.PrettyPath(params.FilePath)))
1233 if params.Timeout > 0 {
1234 parts = append(parts, fmt.Sprintf("**Timeout:** %s", (time.Duration(params.Timeout)*time.Second).String()))
1235 }
1236 return strings.Join(parts, "\n")
1237 }
1238 case tools.SourcegraphToolName:
1239 var params tools.SourcegraphParams
1240 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1241 var parts []string
1242 parts = append(parts, fmt.Sprintf("**Query:** %s", params.Query))
1243 if params.Count > 0 {
1244 parts = append(parts, fmt.Sprintf("**Count:** %d", params.Count))
1245 }
1246 if params.ContextWindow > 0 {
1247 parts = append(parts, fmt.Sprintf("**Context:** %d", params.ContextWindow))
1248 }
1249 return strings.Join(parts, "\n")
1250 }
1251 case tools.DiagnosticsToolName:
1252 return "**Project:** diagnostics"
1253 case agent.AgentToolName:
1254 var params agent.AgentParams
1255 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1256 return fmt.Sprintf("**Task:**\n%s", params.Prompt)
1257 }
1258 }
1259
1260 var params map[string]any
1261 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1262 var parts []string
1263 for key, value := range params {
1264 displayKey := strings.ReplaceAll(key, "_", " ")
1265 if len(displayKey) > 0 {
1266 displayKey = strings.ToUpper(displayKey[:1]) + displayKey[1:]
1267 }
1268 parts = append(parts, fmt.Sprintf("**%s:** %v", displayKey, value))
1269 }
1270 return strings.Join(parts, "\n")
1271 }
1272
1273 return ""
1274}
1275
1276// formatResultForCopy formats tool results for clipboard copying.
1277func (t *baseToolMessageItem) formatResultForCopy() string {
1278 if t.result == nil {
1279 return ""
1280 }
1281
1282 if t.result.Data != "" {
1283 if strings.HasPrefix(t.result.MIMEType, "image/") {
1284 return fmt.Sprintf("[Image: %s]", t.result.MIMEType)
1285 }
1286 return fmt.Sprintf("[Media: %s]", t.result.MIMEType)
1287 }
1288
1289 switch t.toolCall.Name {
1290 case tools.BashToolName:
1291 return t.formatBashResultForCopy()
1292 case tools.ViewToolName:
1293 return t.formatViewResultForCopy()
1294 case tools.EditToolName:
1295 return t.formatEditResultForCopy()
1296 case tools.MultiEditToolName:
1297 return t.formatMultiEditResultForCopy()
1298 case tools.WriteToolName:
1299 return t.formatWriteResultForCopy()
1300 case tools.FetchToolName:
1301 return t.formatFetchResultForCopy()
1302 case tools.AgenticFetchToolName:
1303 return t.formatAgenticFetchResultForCopy()
1304 case tools.WebFetchToolName:
1305 return t.formatWebFetchResultForCopy()
1306 case agent.AgentToolName:
1307 return t.formatAgentResultForCopy()
1308 case tools.DownloadToolName, tools.GrepToolName, tools.GlobToolName, tools.LSToolName, tools.SourcegraphToolName, tools.DiagnosticsToolName, tools.TodosToolName:
1309 return fmt.Sprintf("```\n%s\n```", t.result.Content)
1310 default:
1311 return t.result.Content
1312 }
1313}
1314
1315// formatBashResultForCopy formats bash tool results for clipboard.
1316func (t *baseToolMessageItem) formatBashResultForCopy() string {
1317 if t.result == nil {
1318 return ""
1319 }
1320
1321 var meta tools.BashResponseMetadata
1322 if t.result.Metadata != "" {
1323 json.Unmarshal([]byte(t.result.Metadata), &meta)
1324 }
1325
1326 output := meta.Output
1327 if output == "" && t.result.Content != tools.BashNoOutput {
1328 output = t.result.Content
1329 }
1330
1331 if output == "" {
1332 return ""
1333 }
1334
1335 return fmt.Sprintf("```bash\n%s\n```", output)
1336}
1337
1338// formatViewResultForCopy formats view tool results for clipboard.
1339func (t *baseToolMessageItem) formatViewResultForCopy() string {
1340 if t.result == nil {
1341 return ""
1342 }
1343
1344 var meta tools.ViewResponseMetadata
1345 if t.result.Metadata != "" {
1346 json.Unmarshal([]byte(t.result.Metadata), &meta)
1347 }
1348
1349 if meta.Content == "" {
1350 return t.result.Content
1351 }
1352
1353 lang := ""
1354 if meta.FilePath != "" {
1355 ext := strings.ToLower(filepath.Ext(meta.FilePath))
1356 switch ext {
1357 case ".go":
1358 lang = "go"
1359 case ".js", ".mjs":
1360 lang = "javascript"
1361 case ".ts":
1362 lang = "typescript"
1363 case ".py":
1364 lang = "python"
1365 case ".rs":
1366 lang = "rust"
1367 case ".java":
1368 lang = "java"
1369 case ".c":
1370 lang = "c"
1371 case ".cpp", ".cc", ".cxx":
1372 lang = "cpp"
1373 case ".sh", ".bash":
1374 lang = "bash"
1375 case ".json":
1376 lang = "json"
1377 case ".yaml", ".yml":
1378 lang = "yaml"
1379 case ".xml":
1380 lang = "xml"
1381 case ".html":
1382 lang = "html"
1383 case ".css":
1384 lang = "css"
1385 case ".md":
1386 lang = "markdown"
1387 }
1388 }
1389
1390 var result strings.Builder
1391 if lang != "" {
1392 fmt.Fprintf(&result, "```%s\n", lang)
1393 } else {
1394 result.WriteString("```\n")
1395 }
1396 result.WriteString(meta.Content)
1397 result.WriteString("\n```")
1398
1399 return result.String()
1400}
1401
1402// formatEditResultForCopy formats edit tool results for clipboard.
1403func (t *baseToolMessageItem) formatEditResultForCopy() string {
1404 if t.result == nil || t.result.Metadata == "" {
1405 if t.result != nil {
1406 return t.result.Content
1407 }
1408 return ""
1409 }
1410
1411 var meta tools.EditResponseMetadata
1412 if json.Unmarshal([]byte(t.result.Metadata), &meta) != nil {
1413 return t.result.Content
1414 }
1415
1416 var params tools.EditParams
1417 json.Unmarshal([]byte(t.toolCall.Input), ¶ms)
1418
1419 var result strings.Builder
1420
1421 if meta.OldContent != "" || meta.NewContent != "" {
1422 fileName := params.FilePath
1423 if fileName != "" {
1424 fileName = fsext.PrettyPath(fileName)
1425 }
1426 diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName)
1427
1428 fmt.Fprintf(&result, "Changes: +%d -%d\n", additions, removals)
1429 result.WriteString("```diff\n")
1430 result.WriteString(diffContent)
1431 result.WriteString("\n```")
1432 }
1433
1434 return result.String()
1435}
1436
1437// formatMultiEditResultForCopy formats multi-edit tool results for clipboard.
1438func (t *baseToolMessageItem) formatMultiEditResultForCopy() string {
1439 if t.result == nil || t.result.Metadata == "" {
1440 if t.result != nil {
1441 return t.result.Content
1442 }
1443 return ""
1444 }
1445
1446 var meta tools.MultiEditResponseMetadata
1447 if json.Unmarshal([]byte(t.result.Metadata), &meta) != nil {
1448 return t.result.Content
1449 }
1450
1451 var params tools.MultiEditParams
1452 json.Unmarshal([]byte(t.toolCall.Input), ¶ms)
1453
1454 var result strings.Builder
1455 if meta.OldContent != "" || meta.NewContent != "" {
1456 fileName := params.FilePath
1457 if fileName != "" {
1458 fileName = fsext.PrettyPath(fileName)
1459 }
1460 diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName)
1461
1462 fmt.Fprintf(&result, "Changes: +%d -%d\n", additions, removals)
1463 result.WriteString("```diff\n")
1464 result.WriteString(diffContent)
1465 result.WriteString("\n```")
1466 }
1467
1468 return result.String()
1469}
1470
1471// formatWriteResultForCopy formats write tool results for clipboard.
1472func (t *baseToolMessageItem) formatWriteResultForCopy() string {
1473 if t.result == nil {
1474 return ""
1475 }
1476
1477 var params tools.WriteParams
1478 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) != nil {
1479 return t.result.Content
1480 }
1481
1482 lang := ""
1483 if params.FilePath != "" {
1484 ext := strings.ToLower(filepath.Ext(params.FilePath))
1485 switch ext {
1486 case ".go":
1487 lang = "go"
1488 case ".js", ".mjs":
1489 lang = "javascript"
1490 case ".ts":
1491 lang = "typescript"
1492 case ".py":
1493 lang = "python"
1494 case ".rs":
1495 lang = "rust"
1496 case ".java":
1497 lang = "java"
1498 case ".c":
1499 lang = "c"
1500 case ".cpp", ".cc", ".cxx":
1501 lang = "cpp"
1502 case ".sh", ".bash":
1503 lang = "bash"
1504 case ".json":
1505 lang = "json"
1506 case ".yaml", ".yml":
1507 lang = "yaml"
1508 case ".xml":
1509 lang = "xml"
1510 case ".html":
1511 lang = "html"
1512 case ".css":
1513 lang = "css"
1514 case ".md":
1515 lang = "markdown"
1516 }
1517 }
1518
1519 var result strings.Builder
1520 fmt.Fprintf(&result, "File: %s\n", fsext.PrettyPath(params.FilePath))
1521 if lang != "" {
1522 fmt.Fprintf(&result, "```%s\n", lang)
1523 } else {
1524 result.WriteString("```\n")
1525 }
1526 result.WriteString(params.Content)
1527 result.WriteString("\n```")
1528
1529 return result.String()
1530}
1531
1532// formatFetchResultForCopy formats fetch tool results for clipboard.
1533func (t *baseToolMessageItem) formatFetchResultForCopy() string {
1534 if t.result == nil {
1535 return ""
1536 }
1537
1538 var params tools.FetchParams
1539 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) != nil {
1540 return t.result.Content
1541 }
1542
1543 var result strings.Builder
1544 if params.URL != "" {
1545 fmt.Fprintf(&result, "URL: %s\n", params.URL)
1546 }
1547 if params.Format != "" {
1548 fmt.Fprintf(&result, "Format: %s\n", params.Format)
1549 }
1550 if params.Timeout > 0 {
1551 fmt.Fprintf(&result, "Timeout: %ds\n", params.Timeout)
1552 }
1553 result.WriteString("\n")
1554
1555 result.WriteString(t.result.Content)
1556
1557 return result.String()
1558}
1559
1560// formatAgenticFetchResultForCopy formats agentic fetch tool results for clipboard.
1561func (t *baseToolMessageItem) formatAgenticFetchResultForCopy() string {
1562 if t.result == nil {
1563 return ""
1564 }
1565
1566 var params tools.AgenticFetchParams
1567 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) != nil {
1568 return t.result.Content
1569 }
1570
1571 var result strings.Builder
1572 if params.URL != "" {
1573 fmt.Fprintf(&result, "URL: %s\n", params.URL)
1574 }
1575 if params.Prompt != "" {
1576 fmt.Fprintf(&result, "Prompt: %s\n\n", params.Prompt)
1577 }
1578
1579 result.WriteString("```markdown\n")
1580 result.WriteString(t.result.Content)
1581 result.WriteString("\n```")
1582
1583 return result.String()
1584}
1585
1586// formatWebFetchResultForCopy formats web fetch tool results for clipboard.
1587func (t *baseToolMessageItem) formatWebFetchResultForCopy() string {
1588 if t.result == nil {
1589 return ""
1590 }
1591
1592 var params tools.WebFetchParams
1593 if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) != nil {
1594 return t.result.Content
1595 }
1596
1597 var result strings.Builder
1598 fmt.Fprintf(&result, "URL: %s\n\n", params.URL)
1599 result.WriteString("```markdown\n")
1600 result.WriteString(t.result.Content)
1601 result.WriteString("\n```")
1602
1603 return result.String()
1604}
1605
1606// formatAgentResultForCopy formats agent tool results for clipboard.
1607func (t *baseToolMessageItem) formatAgentResultForCopy() string {
1608 if t.result == nil {
1609 return ""
1610 }
1611
1612 var result strings.Builder
1613
1614 if t.result.Content != "" {
1615 fmt.Fprintf(&result, "```markdown\n%s\n```", t.result.Content)
1616 }
1617
1618 return result.String()
1619}
1620
1621// prettifyToolName returns a human-readable name for tool names.
1622func prettifyToolName(name string) string {
1623 switch name {
1624 case agent.AgentToolName:
1625 return "Agent"
1626 case tools.BashToolName:
1627 return "Bash"
1628 case tools.JobOutputToolName:
1629 return "Job: Output"
1630 case tools.JobKillToolName:
1631 return "Job: Kill"
1632 case tools.DownloadToolName:
1633 return "Download"
1634 case tools.EditToolName:
1635 return "Edit"
1636 case tools.MultiEditToolName:
1637 return "Multi-Edit"
1638 case tools.FetchToolName:
1639 return "Fetch"
1640 case tools.AgenticFetchToolName:
1641 return "Agentic Fetch"
1642 case tools.WebFetchToolName:
1643 return "Fetch"
1644 case tools.WebSearchToolName:
1645 return "Search"
1646 case tools.GlobToolName:
1647 return "Glob"
1648 case tools.GrepToolName:
1649 return "Grep"
1650 case tools.LSToolName:
1651 return "List"
1652 case tools.SourcegraphToolName:
1653 return "Sourcegraph"
1654 case tools.TodosToolName:
1655 return "To-Do"
1656 case tools.ViewToolName:
1657 return "View"
1658 case tools.WriteToolName:
1659 return "Write"
1660 default:
1661 return humanizedToolName(name)
1662 }
1663}