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