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