tools.go

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