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