tools.go

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