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