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