tools.go

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