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
 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		// Truncate accounting for padding that will be added.
 598		ln = ansi.Truncate(ln, codeWidth-sty.Tool.ContentCodeLine.GetHorizontalPadding(), "…")
 599
 600		codeLine := sty.Tool.ContentCodeLine.
 601			Width(codeWidth).
 602			Render(ln)
 603
 604		out = append(out, lipgloss.JoinHorizontal(lipgloss.Left, lineNum, codeLine))
 605	}
 606
 607	// Add truncation message if needed.
 608	if len(lines) > maxLines && !expanded {
 609		out = append(out, sty.Tool.ContentCodeTruncation.
 610			Width(width).
 611			Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)),
 612		)
 613	}
 614
 615	return sty.Tool.Body.Render(strings.Join(out, "\n"))
 616}
 617
 618// toolOutputImageContent renders image data with size info.
 619func toolOutputImageContent(sty *styles.Styles, data, mediaType string) string {
 620	dataSize := len(data) * 3 / 4
 621	sizeStr := formatSize(dataSize)
 622
 623	loaded := sty.Base.Foreground(sty.Green).Render("Loaded")
 624	arrow := sty.Base.Foreground(sty.GreenDark).Render("→")
 625	typeStyled := sty.Base.Render(mediaType)
 626	sizeStyled := sty.Subtle.Render(sizeStr)
 627
 628	return sty.Tool.Body.Render(fmt.Sprintf("%s %s %s %s", loaded, arrow, typeStyled, sizeStyled))
 629}
 630
 631// getDigits returns the number of digits in a number.
 632func getDigits(n int) int {
 633	if n == 0 {
 634		return 1
 635	}
 636	if n < 0 {
 637		n = -n
 638	}
 639	digits := 0
 640	for n > 0 {
 641		n /= 10
 642		digits++
 643	}
 644	return digits
 645}
 646
 647// formatSize formats byte size into human readable format.
 648func formatSize(bytes int) string {
 649	const (
 650		kb = 1024
 651		mb = kb * 1024
 652	)
 653	switch {
 654	case bytes >= mb:
 655		return fmt.Sprintf("%.1f MB", float64(bytes)/float64(mb))
 656	case bytes >= kb:
 657		return fmt.Sprintf("%.1f KB", float64(bytes)/float64(kb))
 658	default:
 659		return fmt.Sprintf("%d B", bytes)
 660	}
 661}
 662
 663// toolOutputDiffContent renders a diff between old and new content.
 664func toolOutputDiffContent(sty *styles.Styles, file, oldContent, newContent string, width int, expanded bool) string {
 665	bodyWidth := width - toolBodyLeftPaddingTotal
 666
 667	formatter := common.DiffFormatter(sty).
 668		Before(file, oldContent).
 669		After(file, newContent).
 670		Width(bodyWidth)
 671
 672	// Use split view for wide terminals.
 673	if width > maxTextWidth {
 674		formatter = formatter.Split()
 675	}
 676
 677	formatted := formatter.String()
 678	lines := strings.Split(formatted, "\n")
 679
 680	// Truncate if needed.
 681	maxLines := responseContextHeight
 682	if expanded {
 683		maxLines = len(lines)
 684	}
 685
 686	if len(lines) > maxLines && !expanded {
 687		truncMsg := sty.Tool.DiffTruncation.
 688			Width(bodyWidth).
 689			Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines))
 690		formatted = strings.Join(lines[:maxLines], "\n") + "\n" + truncMsg
 691	}
 692
 693	return sty.Tool.Body.Render(formatted)
 694}
 695
 696// formatTimeout converts timeout seconds to a duration string (e.g., "30s").
 697// Returns empty string if timeout is 0.
 698func formatTimeout(timeout int) string {
 699	if timeout == 0 {
 700		return ""
 701	}
 702	return fmt.Sprintf("%ds", timeout)
 703}
 704
 705// formatNonZero returns string representation of non-zero integers, empty string for zero.
 706func formatNonZero(value int) string {
 707	if value == 0 {
 708		return ""
 709	}
 710	return fmt.Sprintf("%d", value)
 711}
 712
 713// toolOutputMultiEditDiffContent renders a diff with optional failed edits note.
 714func toolOutputMultiEditDiffContent(sty *styles.Styles, file string, meta tools.MultiEditResponseMetadata, totalEdits, width int, expanded bool) string {
 715	bodyWidth := width - toolBodyLeftPaddingTotal
 716
 717	formatter := common.DiffFormatter(sty).
 718		Before(file, meta.OldContent).
 719		After(file, meta.NewContent).
 720		Width(bodyWidth)
 721
 722	// Use split view for wide terminals.
 723	if width > maxTextWidth {
 724		formatter = formatter.Split()
 725	}
 726
 727	formatted := formatter.String()
 728	lines := strings.Split(formatted, "\n")
 729
 730	// Truncate if needed.
 731	maxLines := responseContextHeight
 732	if expanded {
 733		maxLines = len(lines)
 734	}
 735
 736	if len(lines) > maxLines && !expanded {
 737		truncMsg := sty.Tool.DiffTruncation.
 738			Width(bodyWidth).
 739			Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines))
 740		formatted = truncMsg + "\n" + strings.Join(lines[:maxLines], "\n")
 741	}
 742
 743	// Add failed edits note if any exist.
 744	if len(meta.EditsFailed) > 0 {
 745		noteTag := sty.Tool.NoteTag.Render("Note")
 746		noteMsg := fmt.Sprintf("%d of %d edits succeeded", meta.EditsApplied, totalEdits)
 747		note := fmt.Sprintf("%s %s", noteTag, sty.Tool.NoteMessage.Render(noteMsg))
 748		formatted = formatted + "\n\n" + note
 749	}
 750
 751	return sty.Tool.Body.Render(formatted)
 752}
 753
 754// roundedEnumerator creates a tree enumerator with rounded corners.
 755func roundedEnumerator(lPadding, width int) tree.Enumerator {
 756	if width == 0 {
 757		width = 2
 758	}
 759	if lPadding == 0 {
 760		lPadding = 1
 761	}
 762	return func(children tree.Children, index int) string {
 763		line := strings.Repeat("─", width)
 764		padding := strings.Repeat(" ", lPadding)
 765		if children.Length()-1 == index {
 766			return padding + "╰" + line
 767		}
 768		return padding + "├" + line
 769	}
 770}
 771
 772// toolOutputMarkdownContent renders markdown content with optional truncation.
 773func toolOutputMarkdownContent(sty *styles.Styles, content string, width int, expanded bool) string {
 774	content = stringext.NormalizeSpace(content)
 775
 776	// Cap width for readability.
 777	if width > maxTextWidth {
 778		width = maxTextWidth
 779	}
 780
 781	renderer := common.PlainMarkdownRenderer(sty, width)
 782	rendered, err := renderer.Render(content)
 783	if err != nil {
 784		return toolOutputPlainContent(sty, content, width, expanded)
 785	}
 786
 787	lines := strings.Split(rendered, "\n")
 788	maxLines := responseContextHeight
 789	if expanded {
 790		maxLines = len(lines)
 791	}
 792
 793	var out []string
 794	for i, ln := range lines {
 795		if i >= maxLines {
 796			break
 797		}
 798		out = append(out, ln)
 799	}
 800
 801	if len(lines) > maxLines && !expanded {
 802		out = append(out, sty.Tool.ContentTruncation.
 803			Width(width).
 804			Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)),
 805		)
 806	}
 807
 808	return sty.Tool.Body.Render(strings.Join(out, "\n"))
 809}
 810
 811// formatToolForCopy formats the tool call for clipboard copying.
 812func (t *baseToolMessageItem) formatToolForCopy() string {
 813	var parts []string
 814
 815	toolName := prettifyToolName(t.toolCall.Name)
 816	parts = append(parts, fmt.Sprintf("## %s Tool Call", toolName))
 817
 818	if t.toolCall.Input != "" {
 819		params := t.formatParametersForCopy()
 820		if params != "" {
 821			parts = append(parts, "### Parameters:")
 822			parts = append(parts, params)
 823		}
 824	}
 825
 826	if t.result != nil && t.result.ToolCallID != "" {
 827		if t.result.IsError {
 828			parts = append(parts, "### Error:")
 829			parts = append(parts, t.result.Content)
 830		} else {
 831			parts = append(parts, "### Result:")
 832			content := t.formatResultForCopy()
 833			if content != "" {
 834				parts = append(parts, content)
 835			}
 836		}
 837	} else if t.status == ToolStatusCanceled {
 838		parts = append(parts, "### Status:")
 839		parts = append(parts, "Cancelled")
 840	} else {
 841		parts = append(parts, "### Status:")
 842		parts = append(parts, "Pending...")
 843	}
 844
 845	return strings.Join(parts, "\n\n")
 846}
 847
 848// formatParametersForCopy formats tool parameters for clipboard copying.
 849func (t *baseToolMessageItem) formatParametersForCopy() string {
 850	switch t.toolCall.Name {
 851	case tools.BashToolName:
 852		var params tools.BashParams
 853		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
 854			cmd := strings.ReplaceAll(params.Command, "\n", " ")
 855			cmd = strings.ReplaceAll(cmd, "\t", "    ")
 856			return fmt.Sprintf("**Command:** %s", cmd)
 857		}
 858	case tools.ViewToolName:
 859		var params tools.ViewParams
 860		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
 861			var parts []string
 862			parts = append(parts, fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)))
 863			if params.Limit > 0 {
 864				parts = append(parts, fmt.Sprintf("**Limit:** %d", params.Limit))
 865			}
 866			if params.Offset > 0 {
 867				parts = append(parts, fmt.Sprintf("**Offset:** %d", params.Offset))
 868			}
 869			return strings.Join(parts, "\n")
 870		}
 871	case tools.EditToolName:
 872		var params tools.EditParams
 873		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
 874			return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath))
 875		}
 876	case tools.MultiEditToolName:
 877		var params tools.MultiEditParams
 878		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
 879			var parts []string
 880			parts = append(parts, fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)))
 881			parts = append(parts, fmt.Sprintf("**Edits:** %d", len(params.Edits)))
 882			return strings.Join(parts, "\n")
 883		}
 884	case tools.WriteToolName:
 885		var params tools.WriteParams
 886		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
 887			return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath))
 888		}
 889	case tools.FetchToolName:
 890		var params tools.FetchParams
 891		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
 892			var parts []string
 893			parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
 894			if params.Format != "" {
 895				parts = append(parts, fmt.Sprintf("**Format:** %s", params.Format))
 896			}
 897			if params.Timeout > 0 {
 898				parts = append(parts, fmt.Sprintf("**Timeout:** %ds", params.Timeout))
 899			}
 900			return strings.Join(parts, "\n")
 901		}
 902	case tools.AgenticFetchToolName:
 903		var params tools.AgenticFetchParams
 904		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
 905			var parts []string
 906			if params.URL != "" {
 907				parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
 908			}
 909			if params.Prompt != "" {
 910				parts = append(parts, fmt.Sprintf("**Prompt:** %s", params.Prompt))
 911			}
 912			return strings.Join(parts, "\n")
 913		}
 914	case tools.WebFetchToolName:
 915		var params tools.WebFetchParams
 916		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
 917			return fmt.Sprintf("**URL:** %s", params.URL)
 918		}
 919	case tools.GrepToolName:
 920		var params tools.GrepParams
 921		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
 922			var parts []string
 923			parts = append(parts, fmt.Sprintf("**Pattern:** %s", params.Pattern))
 924			if params.Path != "" {
 925				parts = append(parts, fmt.Sprintf("**Path:** %s", params.Path))
 926			}
 927			if params.Include != "" {
 928				parts = append(parts, fmt.Sprintf("**Include:** %s", params.Include))
 929			}
 930			if params.LiteralText {
 931				parts = append(parts, "**Literal:** true")
 932			}
 933			return strings.Join(parts, "\n")
 934		}
 935	case tools.GlobToolName:
 936		var params tools.GlobParams
 937		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
 938			var parts []string
 939			parts = append(parts, fmt.Sprintf("**Pattern:** %s", params.Pattern))
 940			if params.Path != "" {
 941				parts = append(parts, fmt.Sprintf("**Path:** %s", params.Path))
 942			}
 943			return strings.Join(parts, "\n")
 944		}
 945	case tools.LSToolName:
 946		var params tools.LSParams
 947		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
 948			path := params.Path
 949			if path == "" {
 950				path = "."
 951			}
 952			return fmt.Sprintf("**Path:** %s", fsext.PrettyPath(path))
 953		}
 954	case tools.DownloadToolName:
 955		var params tools.DownloadParams
 956		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
 957			var parts []string
 958			parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
 959			parts = append(parts, fmt.Sprintf("**File Path:** %s", fsext.PrettyPath(params.FilePath)))
 960			if params.Timeout > 0 {
 961				parts = append(parts, fmt.Sprintf("**Timeout:** %s", (time.Duration(params.Timeout)*time.Second).String()))
 962			}
 963			return strings.Join(parts, "\n")
 964		}
 965	case tools.SourcegraphToolName:
 966		var params tools.SourcegraphParams
 967		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
 968			var parts []string
 969			parts = append(parts, fmt.Sprintf("**Query:** %s", params.Query))
 970			if params.Count > 0 {
 971				parts = append(parts, fmt.Sprintf("**Count:** %d", params.Count))
 972			}
 973			if params.ContextWindow > 0 {
 974				parts = append(parts, fmt.Sprintf("**Context:** %d", params.ContextWindow))
 975			}
 976			return strings.Join(parts, "\n")
 977		}
 978	case tools.DiagnosticsToolName:
 979		return "**Project:** diagnostics"
 980	case agent.AgentToolName:
 981		var params agent.AgentParams
 982		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
 983			return fmt.Sprintf("**Task:**\n%s", params.Prompt)
 984		}
 985	}
 986
 987	var params map[string]any
 988	if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
 989		var parts []string
 990		for key, value := range params {
 991			displayKey := strings.ReplaceAll(key, "_", " ")
 992			if len(displayKey) > 0 {
 993				displayKey = strings.ToUpper(displayKey[:1]) + displayKey[1:]
 994			}
 995			parts = append(parts, fmt.Sprintf("**%s:** %v", displayKey, value))
 996		}
 997		return strings.Join(parts, "\n")
 998	}
 999
1000	return ""
1001}
1002
1003// formatResultForCopy formats tool results for clipboard copying.
1004func (t *baseToolMessageItem) formatResultForCopy() string {
1005	if t.result == nil {
1006		return ""
1007	}
1008
1009	if t.result.Data != "" {
1010		if strings.HasPrefix(t.result.MIMEType, "image/") {
1011			return fmt.Sprintf("[Image: %s]", t.result.MIMEType)
1012		}
1013		return fmt.Sprintf("[Media: %s]", t.result.MIMEType)
1014	}
1015
1016	switch t.toolCall.Name {
1017	case tools.BashToolName:
1018		return t.formatBashResultForCopy()
1019	case tools.ViewToolName:
1020		return t.formatViewResultForCopy()
1021	case tools.EditToolName:
1022		return t.formatEditResultForCopy()
1023	case tools.MultiEditToolName:
1024		return t.formatMultiEditResultForCopy()
1025	case tools.WriteToolName:
1026		return t.formatWriteResultForCopy()
1027	case tools.FetchToolName:
1028		return t.formatFetchResultForCopy()
1029	case tools.AgenticFetchToolName:
1030		return t.formatAgenticFetchResultForCopy()
1031	case tools.WebFetchToolName:
1032		return t.formatWebFetchResultForCopy()
1033	case agent.AgentToolName:
1034		return t.formatAgentResultForCopy()
1035	case tools.DownloadToolName, tools.GrepToolName, tools.GlobToolName, tools.LSToolName, tools.SourcegraphToolName, tools.DiagnosticsToolName, tools.TodosToolName:
1036		return fmt.Sprintf("```\n%s\n```", t.result.Content)
1037	default:
1038		return t.result.Content
1039	}
1040}
1041
1042// formatBashResultForCopy formats bash tool results for clipboard.
1043func (t *baseToolMessageItem) formatBashResultForCopy() string {
1044	if t.result == nil {
1045		return ""
1046	}
1047
1048	var meta tools.BashResponseMetadata
1049	if t.result.Metadata != "" {
1050		json.Unmarshal([]byte(t.result.Metadata), &meta)
1051	}
1052
1053	output := meta.Output
1054	if output == "" && t.result.Content != tools.BashNoOutput {
1055		output = t.result.Content
1056	}
1057
1058	if output == "" {
1059		return ""
1060	}
1061
1062	return fmt.Sprintf("```bash\n%s\n```", output)
1063}
1064
1065// formatViewResultForCopy formats view tool results for clipboard.
1066func (t *baseToolMessageItem) formatViewResultForCopy() string {
1067	if t.result == nil {
1068		return ""
1069	}
1070
1071	var meta tools.ViewResponseMetadata
1072	if t.result.Metadata != "" {
1073		json.Unmarshal([]byte(t.result.Metadata), &meta)
1074	}
1075
1076	if meta.Content == "" {
1077		return t.result.Content
1078	}
1079
1080	lang := ""
1081	if meta.FilePath != "" {
1082		ext := strings.ToLower(filepath.Ext(meta.FilePath))
1083		switch ext {
1084		case ".go":
1085			lang = "go"
1086		case ".js", ".mjs":
1087			lang = "javascript"
1088		case ".ts":
1089			lang = "typescript"
1090		case ".py":
1091			lang = "python"
1092		case ".rs":
1093			lang = "rust"
1094		case ".java":
1095			lang = "java"
1096		case ".c":
1097			lang = "c"
1098		case ".cpp", ".cc", ".cxx":
1099			lang = "cpp"
1100		case ".sh", ".bash":
1101			lang = "bash"
1102		case ".json":
1103			lang = "json"
1104		case ".yaml", ".yml":
1105			lang = "yaml"
1106		case ".xml":
1107			lang = "xml"
1108		case ".html":
1109			lang = "html"
1110		case ".css":
1111			lang = "css"
1112		case ".md":
1113			lang = "markdown"
1114		}
1115	}
1116
1117	var result strings.Builder
1118	if lang != "" {
1119		fmt.Fprintf(&result, "```%s\n", lang)
1120	} else {
1121		result.WriteString("```\n")
1122	}
1123	result.WriteString(meta.Content)
1124	result.WriteString("\n```")
1125
1126	return result.String()
1127}
1128
1129// formatEditResultForCopy formats edit tool results for clipboard.
1130func (t *baseToolMessageItem) formatEditResultForCopy() string {
1131	if t.result == nil || t.result.Metadata == "" {
1132		if t.result != nil {
1133			return t.result.Content
1134		}
1135		return ""
1136	}
1137
1138	var meta tools.EditResponseMetadata
1139	if json.Unmarshal([]byte(t.result.Metadata), &meta) != nil {
1140		return t.result.Content
1141	}
1142
1143	var params tools.EditParams
1144	json.Unmarshal([]byte(t.toolCall.Input), &params)
1145
1146	var result strings.Builder
1147
1148	if meta.OldContent != "" || meta.NewContent != "" {
1149		fileName := params.FilePath
1150		if fileName != "" {
1151			fileName = fsext.PrettyPath(fileName)
1152		}
1153		diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName)
1154
1155		fmt.Fprintf(&result, "Changes: +%d -%d\n", additions, removals)
1156		result.WriteString("```diff\n")
1157		result.WriteString(diffContent)
1158		result.WriteString("\n```")
1159	}
1160
1161	return result.String()
1162}
1163
1164// formatMultiEditResultForCopy formats multi-edit tool results for clipboard.
1165func (t *baseToolMessageItem) formatMultiEditResultForCopy() string {
1166	if t.result == nil || t.result.Metadata == "" {
1167		if t.result != nil {
1168			return t.result.Content
1169		}
1170		return ""
1171	}
1172
1173	var meta tools.MultiEditResponseMetadata
1174	if json.Unmarshal([]byte(t.result.Metadata), &meta) != nil {
1175		return t.result.Content
1176	}
1177
1178	var params tools.MultiEditParams
1179	json.Unmarshal([]byte(t.toolCall.Input), &params)
1180
1181	var result strings.Builder
1182	if meta.OldContent != "" || meta.NewContent != "" {
1183		fileName := params.FilePath
1184		if fileName != "" {
1185			fileName = fsext.PrettyPath(fileName)
1186		}
1187		diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName)
1188
1189		fmt.Fprintf(&result, "Changes: +%d -%d\n", additions, removals)
1190		result.WriteString("```diff\n")
1191		result.WriteString(diffContent)
1192		result.WriteString("\n```")
1193	}
1194
1195	return result.String()
1196}
1197
1198// formatWriteResultForCopy formats write tool results for clipboard.
1199func (t *baseToolMessageItem) formatWriteResultForCopy() string {
1200	if t.result == nil {
1201		return ""
1202	}
1203
1204	var params tools.WriteParams
1205	if json.Unmarshal([]byte(t.toolCall.Input), &params) != nil {
1206		return t.result.Content
1207	}
1208
1209	lang := ""
1210	if params.FilePath != "" {
1211		ext := strings.ToLower(filepath.Ext(params.FilePath))
1212		switch ext {
1213		case ".go":
1214			lang = "go"
1215		case ".js", ".mjs":
1216			lang = "javascript"
1217		case ".ts":
1218			lang = "typescript"
1219		case ".py":
1220			lang = "python"
1221		case ".rs":
1222			lang = "rust"
1223		case ".java":
1224			lang = "java"
1225		case ".c":
1226			lang = "c"
1227		case ".cpp", ".cc", ".cxx":
1228			lang = "cpp"
1229		case ".sh", ".bash":
1230			lang = "bash"
1231		case ".json":
1232			lang = "json"
1233		case ".yaml", ".yml":
1234			lang = "yaml"
1235		case ".xml":
1236			lang = "xml"
1237		case ".html":
1238			lang = "html"
1239		case ".css":
1240			lang = "css"
1241		case ".md":
1242			lang = "markdown"
1243		}
1244	}
1245
1246	var result strings.Builder
1247	fmt.Fprintf(&result, "File: %s\n", fsext.PrettyPath(params.FilePath))
1248	if lang != "" {
1249		fmt.Fprintf(&result, "```%s\n", lang)
1250	} else {
1251		result.WriteString("```\n")
1252	}
1253	result.WriteString(params.Content)
1254	result.WriteString("\n```")
1255
1256	return result.String()
1257}
1258
1259// formatFetchResultForCopy formats fetch tool results for clipboard.
1260func (t *baseToolMessageItem) formatFetchResultForCopy() string {
1261	if t.result == nil {
1262		return ""
1263	}
1264
1265	var params tools.FetchParams
1266	if json.Unmarshal([]byte(t.toolCall.Input), &params) != nil {
1267		return t.result.Content
1268	}
1269
1270	var result strings.Builder
1271	if params.URL != "" {
1272		fmt.Fprintf(&result, "URL: %s\n", params.URL)
1273	}
1274	if params.Format != "" {
1275		fmt.Fprintf(&result, "Format: %s\n", params.Format)
1276	}
1277	if params.Timeout > 0 {
1278		fmt.Fprintf(&result, "Timeout: %ds\n", params.Timeout)
1279	}
1280	result.WriteString("\n")
1281
1282	result.WriteString(t.result.Content)
1283
1284	return result.String()
1285}
1286
1287// formatAgenticFetchResultForCopy formats agentic fetch tool results for clipboard.
1288func (t *baseToolMessageItem) formatAgenticFetchResultForCopy() string {
1289	if t.result == nil {
1290		return ""
1291	}
1292
1293	var params tools.AgenticFetchParams
1294	if json.Unmarshal([]byte(t.toolCall.Input), &params) != nil {
1295		return t.result.Content
1296	}
1297
1298	var result strings.Builder
1299	if params.URL != "" {
1300		fmt.Fprintf(&result, "URL: %s\n", params.URL)
1301	}
1302	if params.Prompt != "" {
1303		fmt.Fprintf(&result, "Prompt: %s\n\n", params.Prompt)
1304	}
1305
1306	result.WriteString("```markdown\n")
1307	result.WriteString(t.result.Content)
1308	result.WriteString("\n```")
1309
1310	return result.String()
1311}
1312
1313// formatWebFetchResultForCopy formats web fetch tool results for clipboard.
1314func (t *baseToolMessageItem) formatWebFetchResultForCopy() string {
1315	if t.result == nil {
1316		return ""
1317	}
1318
1319	var params tools.WebFetchParams
1320	if json.Unmarshal([]byte(t.toolCall.Input), &params) != nil {
1321		return t.result.Content
1322	}
1323
1324	var result strings.Builder
1325	result.WriteString(fmt.Sprintf("URL: %s\n\n", params.URL))
1326	result.WriteString("```markdown\n")
1327	result.WriteString(t.result.Content)
1328	result.WriteString("\n```")
1329
1330	return result.String()
1331}
1332
1333// formatAgentResultForCopy formats agent tool results for clipboard.
1334func (t *baseToolMessageItem) formatAgentResultForCopy() string {
1335	if t.result == nil {
1336		return ""
1337	}
1338
1339	var result strings.Builder
1340
1341	if t.result.Content != "" {
1342		result.WriteString(fmt.Sprintf("```markdown\n%s\n```", t.result.Content))
1343	}
1344
1345	return result.String()
1346}
1347
1348// prettifyToolName returns a human-readable name for tool names.
1349func prettifyToolName(name string) string {
1350	switch name {
1351	case agent.AgentToolName:
1352		return "Agent"
1353	case tools.BashToolName:
1354		return "Bash"
1355	case tools.JobOutputToolName:
1356		return "Job: Output"
1357	case tools.JobKillToolName:
1358		return "Job: Kill"
1359	case tools.DownloadToolName:
1360		return "Download"
1361	case tools.EditToolName:
1362		return "Edit"
1363	case tools.MultiEditToolName:
1364		return "Multi-Edit"
1365	case tools.FetchToolName:
1366		return "Fetch"
1367	case tools.AgenticFetchToolName:
1368		return "Agentic Fetch"
1369	case tools.WebFetchToolName:
1370		return "Fetch"
1371	case tools.WebSearchToolName:
1372		return "Search"
1373	case tools.GlobToolName:
1374		return "Glob"
1375	case tools.GrepToolName:
1376		return "Grep"
1377	case tools.LSToolName:
1378		return "List"
1379	case tools.SourcegraphToolName:
1380		return "Sourcegraph"
1381	case tools.TodosToolName:
1382		return "To-Do"
1383	case tools.ViewToolName:
1384		return "View"
1385	case tools.WriteToolName:
1386		return "Write"
1387	default:
1388		return genericPrettyName(name)
1389	}
1390}