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