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