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	var prefix string
 336	if t.isCompact {
 337		prefix = t.sty.Messages.ToolCallCompact.Render()
 338	} else if t.focused {
 339		prefix = t.sty.Messages.ToolCallFocused.Render()
 340	} else {
 341		prefix = t.sty.Messages.ToolCallBlurred.Render()
 342	}
 343	lines := strings.Split(t.RawRender(width), "\n")
 344	for i, ln := range lines {
 345		lines[i] = prefix + ln
 346	}
 347	return strings.Join(lines, "\n")
 348}
 349
 350// ToolCall returns the tool call associated with this message item.
 351func (t *baseToolMessageItem) ToolCall() message.ToolCall {
 352	return t.toolCall
 353}
 354
 355// SetToolCall sets the tool call associated with this message item.
 356func (t *baseToolMessageItem) SetToolCall(tc message.ToolCall) {
 357	t.toolCall = tc
 358	t.clearCache()
 359}
 360
 361// SetResult sets the tool result associated with this message item.
 362func (t *baseToolMessageItem) SetResult(res *message.ToolResult) {
 363	t.result = res
 364	t.clearCache()
 365}
 366
 367// MessageID returns the ID of the message containing this tool call.
 368func (t *baseToolMessageItem) MessageID() string {
 369	return t.messageID
 370}
 371
 372// SetMessageID sets the ID of the message containing this tool call.
 373func (t *baseToolMessageItem) SetMessageID(id string) {
 374	t.messageID = id
 375}
 376
 377// SetStatus sets the tool status.
 378func (t *baseToolMessageItem) SetStatus(status ToolStatus) {
 379	t.status = status
 380	t.clearCache()
 381}
 382
 383// Status returns the current tool status.
 384func (t *baseToolMessageItem) Status() ToolStatus {
 385	return t.status
 386}
 387
 388// computeStatus computes the effective status considering the result.
 389func (t *baseToolMessageItem) computeStatus() ToolStatus {
 390	if t.result != nil {
 391		if t.result.IsError {
 392			return ToolStatusError
 393		}
 394		return ToolStatusSuccess
 395	}
 396	return t.status
 397}
 398
 399// isSpinning returns true if the tool should show animation.
 400func (t *baseToolMessageItem) isSpinning() bool {
 401	if t.spinningFunc != nil {
 402		return t.spinningFunc(SpinningState{
 403			ToolCall: t.toolCall,
 404			Result:   t.result,
 405			Status:   t.status,
 406		})
 407	}
 408	return !t.toolCall.Finished && t.status != ToolStatusCanceled
 409}
 410
 411// SetSpinningFunc sets a custom function to determine if the tool should spin.
 412func (t *baseToolMessageItem) SetSpinningFunc(fn SpinningFunc) {
 413	t.spinningFunc = fn
 414}
 415
 416// ToggleExpanded toggles the expanded state of the thinking box.
 417func (t *baseToolMessageItem) ToggleExpanded() bool {
 418	t.expandedContent = !t.expandedContent
 419	t.clearCache()
 420	return t.expandedContent
 421}
 422
 423// HandleMouseClick implements MouseClickable.
 424func (t *baseToolMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
 425	return btn == ansi.MouseLeft
 426}
 427
 428// HandleKeyEvent implements KeyEventHandler.
 429func (t *baseToolMessageItem) HandleKeyEvent(key tea.KeyMsg) (bool, tea.Cmd) {
 430	if k := key.String(); k == "c" || k == "y" {
 431		text := t.formatToolForCopy()
 432		return true, common.CopyToClipboard(text, "Tool content copied to clipboard")
 433	}
 434	return false, nil
 435}
 436
 437// pendingTool renders a tool that is still in progress with an animation.
 438func pendingTool(sty *styles.Styles, name string, anim *anim.Anim, nested bool) string {
 439	icon := sty.Tool.IconPending.Render()
 440	nameStyle := sty.Tool.NameNormal
 441	if nested {
 442		nameStyle = sty.Tool.NameNested
 443	}
 444	toolName := nameStyle.Render(name)
 445
 446	var animView string
 447	if anim != nil {
 448		animView = anim.Render()
 449	}
 450
 451	return fmt.Sprintf("%s %s %s", icon, toolName, animView)
 452}
 453
 454// toolEarlyStateContent handles error/cancelled/pending states before content rendering.
 455// Returns the rendered output and true if early state was handled.
 456func toolEarlyStateContent(sty *styles.Styles, opts *ToolRenderOpts, width int) (string, bool) {
 457	var msg string
 458	switch opts.Status {
 459	case ToolStatusError:
 460		msg = toolErrorContent(sty, opts.Result, width)
 461	case ToolStatusCanceled:
 462		msg = sty.Tool.StateCancelled.Render("Canceled.")
 463	case ToolStatusAwaitingPermission:
 464		msg = sty.Tool.StateWaiting.Render("Requesting permission...")
 465	case ToolStatusRunning:
 466		msg = sty.Tool.StateWaiting.Render("Waiting for tool response...")
 467	default:
 468		return "", false
 469	}
 470	return msg, true
 471}
 472
 473// toolErrorContent formats an error message with ERROR tag.
 474func toolErrorContent(sty *styles.Styles, result *message.ToolResult, width int) string {
 475	if result == nil {
 476		return ""
 477	}
 478	errContent := strings.ReplaceAll(result.Content, "\n", " ")
 479	errTag := sty.Tool.ErrorTag.Render("ERROR")
 480	tagWidth := lipgloss.Width(errTag)
 481	errContent = ansi.Truncate(errContent, width-tagWidth-3, "…")
 482	return fmt.Sprintf("%s %s", errTag, sty.Tool.ErrorMessage.Render(errContent))
 483}
 484
 485// toolIcon returns the status icon for a tool call.
 486// toolIcon returns the status icon for a tool call based on its status.
 487func toolIcon(sty *styles.Styles, status ToolStatus) string {
 488	switch status {
 489	case ToolStatusSuccess:
 490		return sty.Tool.IconSuccess.String()
 491	case ToolStatusError:
 492		return sty.Tool.IconError.String()
 493	case ToolStatusCanceled:
 494		return sty.Tool.IconCancelled.String()
 495	default:
 496		return sty.Tool.IconPending.String()
 497	}
 498}
 499
 500// toolParamList formats parameters as "main (key=value, ...)" with truncation.
 501// toolParamList formats tool parameters as "main (key=value, ...)" with truncation.
 502func toolParamList(sty *styles.Styles, params []string, width int) string {
 503	// minSpaceForMainParam is the min space required for the main param
 504	// if this is less that the value set we will only show the main param nothing else
 505	const minSpaceForMainParam = 30
 506	if len(params) == 0 {
 507		return ""
 508	}
 509
 510	mainParam := params[0]
 511
 512	// Build key=value pairs from remaining params (consecutive key, value pairs).
 513	var kvPairs []string
 514	for i := 1; i+1 < len(params); i += 2 {
 515		if params[i+1] != "" {
 516			kvPairs = append(kvPairs, fmt.Sprintf("%s=%s", params[i], params[i+1]))
 517		}
 518	}
 519
 520	// Try to include key=value pairs if there's enough space.
 521	output := mainParam
 522	if len(kvPairs) > 0 {
 523		partsStr := strings.Join(kvPairs, ", ")
 524		if remaining := width - lipgloss.Width(partsStr) - 3; remaining >= minSpaceForMainParam {
 525			output = fmt.Sprintf("%s (%s)", mainParam, partsStr)
 526		}
 527	}
 528
 529	if width >= 0 {
 530		output = ansi.Truncate(output, width, "…")
 531	}
 532	return sty.Tool.ParamMain.Render(output)
 533}
 534
 535// toolHeader builds the tool header line: "● ToolName params..."
 536func toolHeader(sty *styles.Styles, status ToolStatus, name string, width int, nested bool, params ...string) string {
 537	icon := toolIcon(sty, status)
 538	nameStyle := sty.Tool.NameNormal
 539	if nested {
 540		nameStyle = sty.Tool.NameNested
 541	}
 542	toolName := nameStyle.Render(name)
 543	prefix := fmt.Sprintf("%s %s ", icon, toolName)
 544	prefixWidth := lipgloss.Width(prefix)
 545	remainingWidth := width - prefixWidth
 546	paramsStr := toolParamList(sty, params, remainingWidth)
 547	return prefix + paramsStr
 548}
 549
 550// toolOutputPlainContent renders plain text with optional expansion support.
 551func toolOutputPlainContent(sty *styles.Styles, content string, width int, expanded bool) string {
 552	content = stringext.NormalizeSpace(content)
 553	lines := strings.Split(content, "\n")
 554
 555	maxLines := responseContextHeight
 556	if expanded {
 557		maxLines = len(lines) // Show all
 558	}
 559
 560	var out []string
 561	for i, ln := range lines {
 562		if i >= maxLines {
 563			break
 564		}
 565		ln = " " + ln
 566		if lipgloss.Width(ln) > width {
 567			ln = ansi.Truncate(ln, width, "…")
 568		}
 569		out = append(out, sty.Tool.ContentLine.Width(width).Render(ln))
 570	}
 571
 572	wasTruncated := len(lines) > responseContextHeight
 573
 574	if !expanded && wasTruncated {
 575		out = append(out, sty.Tool.ContentTruncation.
 576			Width(width).
 577			Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-responseContextHeight)))
 578	}
 579
 580	return strings.Join(out, "\n")
 581}
 582
 583// toolOutputCodeContent renders code with syntax highlighting and line numbers.
 584func toolOutputCodeContent(sty *styles.Styles, path, content string, offset, width int, expanded bool) string {
 585	content = stringext.NormalizeSpace(content)
 586
 587	lines := strings.Split(content, "\n")
 588	maxLines := responseContextHeight
 589	if expanded {
 590		maxLines = len(lines)
 591	}
 592
 593	// Truncate if needed.
 594	displayLines := lines
 595	if len(lines) > maxLines {
 596		displayLines = lines[:maxLines]
 597	}
 598
 599	bg := sty.Tool.ContentCodeBg
 600	highlighted, _ := common.SyntaxHighlight(sty, strings.Join(displayLines, "\n"), path, bg)
 601	highlightedLines := strings.Split(highlighted, "\n")
 602
 603	// Calculate line number width.
 604	maxLineNumber := len(displayLines) + offset
 605	maxDigits := getDigits(maxLineNumber)
 606	numFmt := fmt.Sprintf("%%%dd", maxDigits)
 607
 608	bodyWidth := width - toolBodyLeftPaddingTotal
 609	codeWidth := bodyWidth - maxDigits
 610
 611	var out []string
 612	for i, ln := range highlightedLines {
 613		lineNum := sty.Tool.ContentLineNumber.Render(fmt.Sprintf(numFmt, i+1+offset))
 614
 615		// Truncate accounting for padding that will be added.
 616		ln = ansi.Truncate(ln, codeWidth-sty.Tool.ContentCodeLine.GetHorizontalPadding(), "…")
 617
 618		codeLine := sty.Tool.ContentCodeLine.
 619			Width(codeWidth).
 620			Render(ln)
 621
 622		out = append(out, lipgloss.JoinHorizontal(lipgloss.Left, lineNum, codeLine))
 623	}
 624
 625	// Add truncation message if needed.
 626	if len(lines) > maxLines && !expanded {
 627		out = append(out, sty.Tool.ContentCodeTruncation.
 628			Width(width).
 629			Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)),
 630		)
 631	}
 632
 633	return sty.Tool.Body.Render(strings.Join(out, "\n"))
 634}
 635
 636// toolOutputImageContent renders image data with size info.
 637func toolOutputImageContent(sty *styles.Styles, data, mediaType string) string {
 638	dataSize := len(data) * 3 / 4
 639	sizeStr := formatSize(dataSize)
 640
 641	return sty.Tool.Body.Render(fmt.Sprintf(
 642		"%s %s %s %s",
 643		sty.Tool.ResourceLoadedText.Render("Loaded Image"),
 644		sty.Tool.ResourceLoadedIndicator.Render(styles.ArrowRightIcon),
 645		sty.Tool.MediaType.Render(mediaType),
 646		sty.Tool.ResourceSize.Render(sizeStr),
 647	))
 648}
 649
 650// toolOutputSkillContent renders a skill loaded indicator.
 651func toolOutputSkillContent(sty *styles.Styles, name, description string) string {
 652	return sty.Tool.Body.Render(fmt.Sprintf(
 653		"%s %s %s %s",
 654		sty.Tool.ResourceLoadedText.Render("Loaded Skill"),
 655		sty.Tool.ResourceLoadedIndicator.Render(styles.ArrowRightIcon),
 656		sty.Tool.ResourceName.Render(name),
 657		sty.Tool.ResourceSize.Render(description),
 658	))
 659}
 660
 661// toolOutputHookIndicator renders hook indicator lines from tool metadata.
 662// Returns empty string if no hook metadata is present. Hook names are
 663// sanitized (newlines replaced with ¶) and truncated to fit the available
 664// horizontal space.
 665func toolOutputHookIndicator(sty *styles.Styles, metadata string, width int) string {
 666	if metadata == "" {
 667		return ""
 668	}
 669	var meta struct {
 670		Hook *hooks.HookMetadata `json:"hook"`
 671	}
 672	if err := json.Unmarshal([]byte(metadata), &meta); err != nil || meta.Hook == nil {
 673		return ""
 674	}
 675	h := meta.Hook
 676	if len(h.Hooks) == 0 {
 677		return ""
 678	}
 679
 680	// Sanitize names (replace newlines with ¶) and compute max widths
 681	// for the name, matcher, and detail columns so they align. The name
 682	// column is capped at maxHookNameWidth characters.
 683	const maxHookNameWidth = 30
 684	sanitizedNames := make([]string, len(h.Hooks))
 685	details := make([]string, len(h.Hooks))
 686	maxNameWidth := 0
 687	maxMatcherWidth := 0
 688	maxDetailWidth := 0
 689	for i, hi := range h.Hooks {
 690		sanitizedNames[i] = strings.ReplaceAll(hi.Name, "\n", "¶")
 691		w := lipgloss.Width(sty.Tool.HookName.Render(sanitizedNames[i]))
 692		if w > maxNameWidth {
 693			maxNameWidth = w
 694		}
 695		if hi.Matcher != "" {
 696			mw := lipgloss.Width(sty.Tool.HookMatcher.Render(hi.Matcher))
 697			if mw > maxMatcherWidth {
 698				maxMatcherWidth = mw
 699			}
 700		}
 701		details[i] = hookDetail(sty, hi)
 702		if dw := lipgloss.Width(details[i]); dw > maxDetailWidth {
 703			maxDetailWidth = dw
 704		}
 705	}
 706
 707	if maxNameWidth > maxHookNameWidth {
 708		maxNameWidth = maxHookNameWidth
 709	}
 710
 711	// Cap the name column so the widest line still fits in width. The
 712	// per-line layout is:
 713	//   "Hook " + name(padded) + [" " + matcher(padded)] + " → " + detail
 714	if width > 0 {
 715		fixed := lipgloss.Width(sty.Tool.HookLabel.Render("Hook")) + 1
 716		if maxMatcherWidth > 0 {
 717			fixed += 1 + maxMatcherWidth
 718		}
 719		fixed += 1 + lipgloss.Width(sty.Tool.HookArrow.Render(styles.ArrowRightIcon)) + 1
 720		fixed += maxDetailWidth
 721		if budget := width - fixed; budget < maxNameWidth {
 722			maxNameWidth = max(1, budget)
 723		}
 724	}
 725
 726	var lines []string
 727	for i, hi := range h.Hooks {
 728		name := truncateHookName(sanitizedNames[i], maxNameWidth)
 729		lines = append(lines, renderHookLine(sty, hi, name, details[i], maxNameWidth, maxMatcherWidth))
 730	}
 731	return strings.Join(lines, "\n")
 732}
 733
 734// truncateHookName truncates a hook name to fit within maxWidth cells,
 735// using left-truncation for absolute paths (e.g. `…/format.sh`) and
 736// right-truncation for everything else. Left-truncation is only applied
 737// when the name looks unambiguously like a path: absolute, single-line,
 738// and contains no spaces.
 739func truncateHookName(name string, maxWidth int) string {
 740	if ansi.StringWidth(name) <= maxWidth {
 741		return name
 742	}
 743	if isLikelyPath(name) {
 744		// ansi.TruncateLeft removes n graphemes from the start; pick n
 745		// so the result plus the "…" prefix fits in maxWidth.
 746		n := ansi.StringWidth(name) - maxWidth + 1
 747		return ansi.TruncateLeft(name, n, "…")
 748	}
 749	return ansi.Truncate(name, maxWidth, "…")
 750}
 751
 752// isLikelyPath reports whether s looks unambiguously like a filesystem
 753// path, suitable for left-truncation. We accept absolute paths and
 754// relative paths that contain a separator and no shell-ish characters.
 755func isLikelyPath(s string) bool {
 756	if s == "" || strings.ContainsAny(s, " \t\n¶'\"|&;<>$`*?(){}[]\\") {
 757		return false
 758	}
 759	if filepath.IsAbs(s) {
 760		return true
 761	}
 762	return strings.Contains(s, "/")
 763}
 764
 765// renderHookLine renders a single hook indicator line with aligned columns.
 766func renderHookLine(sty *styles.Styles, hi hooks.HookInfo, rawName, detail string, maxNameWidth, maxMatcherWidth int) string {
 767	name := sty.Tool.HookName.Render(rawName)
 768	namePad := strings.Repeat(" ", max(0, maxNameWidth-lipgloss.Width(name)))
 769
 770	var matcherPart string
 771	if maxMatcherWidth > 0 {
 772		if hi.Matcher != "" {
 773			matcher := sty.Tool.HookMatcher.Render(hi.Matcher)
 774			matcherPad := strings.Repeat(" ", maxMatcherWidth-lipgloss.Width(matcher))
 775			matcherPart = " " + matcher + matcherPad
 776		} else {
 777			matcherPart = " " + strings.Repeat(" ", maxMatcherWidth)
 778		}
 779	}
 780
 781	labelStyle := sty.Tool.HookLabel
 782	arrowStyle := sty.Tool.HookArrow
 783	if hi.Decision == "deny" {
 784		labelStyle = sty.Tool.HookDeniedLabel
 785		arrowStyle = sty.Tool.HookDeniedLabel
 786	}
 787
 788	return fmt.Sprintf("%s %s%s%s %s %s",
 789		labelStyle.Render("Hook"),
 790		name,
 791		namePad,
 792		matcherPart,
 793		arrowStyle.Render(styles.ArrowRightIcon),
 794		detail,
 795	)
 796}
 797
 798// hookDetail returns the styled detail text for a single hook result.
 799func hookDetail(sty *styles.Styles, hi hooks.HookInfo) string {
 800	const (
 801		okMessage      = "OK"
 802		denialMessage  = "Denied"
 803		rewroteMessage = "Rewrote Output"
 804	)
 805	switch hi.Decision {
 806	case "deny":
 807		if hi.Reason != "" {
 808			return sty.Tool.HookDenied.Render(denialMessage) + " " + sty.Tool.HookDeniedReason.Render(hi.Reason)
 809		}
 810		return sty.Tool.HookDenied.Render(denialMessage)
 811	case "allow":
 812		result := sty.Tool.HookOK.Render(okMessage)
 813		if hi.InputRewrite {
 814			result += " " + sty.Tool.HookRewrote.Render(rewroteMessage)
 815		}
 816		return result
 817	default:
 818		result := sty.Tool.HookOK.Render(okMessage)
 819		if hi.InputRewrite {
 820			result += " " + sty.Tool.HookRewrote.Render(rewroteMessage)
 821		}
 822		return result
 823	}
 824}
 825
 826// getDigits returns the number of digits in a number.
 827func getDigits(n int) int {
 828	if n == 0 {
 829		return 1
 830	}
 831	if n < 0 {
 832		n = -n
 833	}
 834	digits := 0
 835	for n > 0 {
 836		n /= 10
 837		digits++
 838	}
 839	return digits
 840}
 841
 842// formatSize formats byte size into human readable format.
 843func formatSize(bytes int) string {
 844	const (
 845		kb = 1024
 846		mb = kb * 1024
 847	)
 848	switch {
 849	case bytes >= mb:
 850		return fmt.Sprintf("%.1f MB", float64(bytes)/float64(mb))
 851	case bytes >= kb:
 852		return fmt.Sprintf("%.1f KB", float64(bytes)/float64(kb))
 853	default:
 854		return fmt.Sprintf("%d B", bytes)
 855	}
 856}
 857
 858// toolOutputDiffContent renders a diff between old and new content.
 859func toolOutputDiffContent(sty *styles.Styles, file, oldContent, newContent string, width int, expanded bool) string {
 860	bodyWidth := width - toolBodyLeftPaddingTotal
 861
 862	formatter := common.DiffFormatter(sty).
 863		Before(file, oldContent).
 864		After(file, newContent).
 865		Width(bodyWidth)
 866
 867	// Use split view for wide terminals.
 868	if width > maxTextWidth {
 869		formatter = formatter.Split()
 870	}
 871
 872	formatted := formatter.String()
 873	lines := strings.Split(formatted, "\n")
 874
 875	// Truncate if needed.
 876	maxLines := responseContextHeight
 877	if expanded {
 878		maxLines = len(lines)
 879	}
 880
 881	if len(lines) > maxLines && !expanded {
 882		truncMsg := sty.Tool.DiffTruncation.
 883			Width(bodyWidth).
 884			Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines))
 885		formatted = strings.Join(lines[:maxLines], "\n") + "\n" + truncMsg
 886	}
 887
 888	return sty.Tool.Body.Render(formatted)
 889}
 890
 891// formatTimeout converts timeout seconds to a duration string (e.g., "30s").
 892// Returns empty string if timeout is 0.
 893func formatTimeout(timeout int) string {
 894	if timeout == 0 {
 895		return ""
 896	}
 897	return fmt.Sprintf("%ds", timeout)
 898}
 899
 900// formatNonZero returns string representation of non-zero integers, empty string for zero.
 901func formatNonZero(value int) string {
 902	if value == 0 {
 903		return ""
 904	}
 905	return fmt.Sprintf("%d", value)
 906}
 907
 908// toolOutputMultiEditDiffContent renders a diff with optional failed edits note.
 909func toolOutputMultiEditDiffContent(sty *styles.Styles, file string, meta tools.MultiEditResponseMetadata, totalEdits, width int, expanded bool) string {
 910	bodyWidth := width - toolBodyLeftPaddingTotal
 911
 912	formatter := common.DiffFormatter(sty).
 913		Before(file, meta.OldContent).
 914		After(file, meta.NewContent).
 915		Width(bodyWidth)
 916
 917	// Use split view for wide terminals.
 918	if width > maxTextWidth {
 919		formatter = formatter.Split()
 920	}
 921
 922	formatted := formatter.String()
 923	lines := strings.Split(formatted, "\n")
 924
 925	// Truncate if needed.
 926	maxLines := responseContextHeight
 927	if expanded {
 928		maxLines = len(lines)
 929	}
 930
 931	if len(lines) > maxLines && !expanded {
 932		truncMsg := sty.Tool.DiffTruncation.
 933			Width(bodyWidth).
 934			Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines))
 935		formatted = truncMsg + "\n" + strings.Join(lines[:maxLines], "\n")
 936	}
 937
 938	// Add failed edits note if any exist.
 939	if len(meta.EditsFailed) > 0 {
 940		noteTag := sty.Tool.NoteTag.Render("Note")
 941		noteMsg := fmt.Sprintf("%d of %d edits succeeded", meta.EditsApplied, totalEdits)
 942		note := fmt.Sprintf("%s %s", noteTag, sty.Tool.NoteMessage.Render(noteMsg))
 943		formatted = formatted + "\n\n" + note
 944	}
 945
 946	return sty.Tool.Body.Render(formatted)
 947}
 948
 949// roundedEnumerator creates a tree enumerator with rounded corners.
 950func roundedEnumerator(lPadding, width int) tree.Enumerator {
 951	if width == 0 {
 952		width = 2
 953	}
 954	if lPadding == 0 {
 955		lPadding = 1
 956	}
 957	return func(children tree.Children, index int) string {
 958		line := strings.Repeat("─", width)
 959		padding := strings.Repeat(" ", lPadding)
 960		if children.Length()-1 == index {
 961			return padding + "╰" + line
 962		}
 963		return padding + "├" + line
 964	}
 965}
 966
 967// toolOutputMarkdownContent renders markdown content with optional truncation.
 968func toolOutputMarkdownContent(sty *styles.Styles, content string, width int, expanded bool) string {
 969	content = stringext.NormalizeSpace(content)
 970
 971	// Cap width for readability.
 972	if width > maxTextWidth {
 973		width = maxTextWidth
 974	}
 975
 976	renderer := common.QuietMarkdownRenderer(sty, width)
 977	rendered, err := renderer.Render(content)
 978	if err != nil {
 979		return toolOutputPlainContent(sty, content, width, expanded)
 980	}
 981
 982	lines := strings.Split(rendered, "\n")
 983	maxLines := responseContextHeight
 984	if expanded {
 985		maxLines = len(lines)
 986	}
 987
 988	var out []string
 989	for i, ln := range lines {
 990		if i >= maxLines {
 991			break
 992		}
 993		out = append(out, ln)
 994	}
 995
 996	if len(lines) > maxLines && !expanded {
 997		out = append(out, sty.Tool.ContentTruncation.
 998			Width(width).
 999			Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)),
1000		)
1001	}
1002
1003	return sty.Tool.Body.Render(strings.Join(out, "\n"))
1004}
1005
1006// formatToolForCopy formats the tool call for clipboard copying.
1007func (t *baseToolMessageItem) formatToolForCopy() string {
1008	var parts []string
1009
1010	toolName := prettifyToolName(t.toolCall.Name)
1011	parts = append(parts, fmt.Sprintf("## %s Tool Call", toolName))
1012
1013	if t.toolCall.Input != "" {
1014		params := t.formatParametersForCopy()
1015		if params != "" {
1016			parts = append(parts, "### Parameters:")
1017			parts = append(parts, params)
1018		}
1019	}
1020
1021	if t.result != nil && t.result.ToolCallID != "" {
1022		if t.result.IsError {
1023			parts = append(parts, "### Error:")
1024			parts = append(parts, t.result.Content)
1025		} else {
1026			parts = append(parts, "### Result:")
1027			content := t.formatResultForCopy()
1028			if content != "" {
1029				parts = append(parts, content)
1030			}
1031		}
1032	} else if t.status == ToolStatusCanceled {
1033		parts = append(parts, "### Status:")
1034		parts = append(parts, "Cancelled")
1035	} else {
1036		parts = append(parts, "### Status:")
1037		parts = append(parts, "Pending...")
1038	}
1039
1040	return strings.Join(parts, "\n\n")
1041}
1042
1043// formatParametersForCopy formats tool parameters for clipboard copying.
1044func (t *baseToolMessageItem) formatParametersForCopy() string {
1045	switch t.toolCall.Name {
1046	case tools.BashToolName:
1047		var params tools.BashParams
1048		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
1049			cmd := strings.ReplaceAll(params.Command, "\n", " ")
1050			cmd = strings.ReplaceAll(cmd, "\t", "    ")
1051			return fmt.Sprintf("**Command:** %s", cmd)
1052		}
1053	case tools.ViewToolName:
1054		var params tools.ViewParams
1055		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
1056			var parts []string
1057			parts = append(parts, fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)))
1058			if params.Limit > 0 {
1059				parts = append(parts, fmt.Sprintf("**Limit:** %d", params.Limit))
1060			}
1061			if params.Offset > 0 {
1062				parts = append(parts, fmt.Sprintf("**Offset:** %d", params.Offset))
1063			}
1064			return strings.Join(parts, "\n")
1065		}
1066	case tools.EditToolName:
1067		var params tools.EditParams
1068		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
1069			return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath))
1070		}
1071	case tools.MultiEditToolName:
1072		var params tools.MultiEditParams
1073		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
1074			var parts []string
1075			parts = append(parts, fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)))
1076			parts = append(parts, fmt.Sprintf("**Edits:** %d", len(params.Edits)))
1077			return strings.Join(parts, "\n")
1078		}
1079	case tools.WriteToolName:
1080		var params tools.WriteParams
1081		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
1082			return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath))
1083		}
1084	case tools.FetchToolName:
1085		var params tools.FetchParams
1086		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
1087			var parts []string
1088			parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
1089			if params.Format != "" {
1090				parts = append(parts, fmt.Sprintf("**Format:** %s", params.Format))
1091			}
1092			if params.Timeout > 0 {
1093				parts = append(parts, fmt.Sprintf("**Timeout:** %ds", params.Timeout))
1094			}
1095			return strings.Join(parts, "\n")
1096		}
1097	case tools.AgenticFetchToolName:
1098		var params tools.AgenticFetchParams
1099		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
1100			var parts []string
1101			if params.URL != "" {
1102				parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
1103			}
1104			if params.Prompt != "" {
1105				parts = append(parts, fmt.Sprintf("**Prompt:** %s", params.Prompt))
1106			}
1107			return strings.Join(parts, "\n")
1108		}
1109	case tools.WebFetchToolName:
1110		var params tools.WebFetchParams
1111		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
1112			return fmt.Sprintf("**URL:** %s", params.URL)
1113		}
1114	case tools.GrepToolName:
1115		var params tools.GrepParams
1116		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
1117			var parts []string
1118			parts = append(parts, fmt.Sprintf("**Pattern:** %s", params.Pattern))
1119			if params.Path != "" {
1120				parts = append(parts, fmt.Sprintf("**Path:** %s", params.Path))
1121			}
1122			if params.Include != "" {
1123				parts = append(parts, fmt.Sprintf("**Include:** %s", params.Include))
1124			}
1125			if params.LiteralText {
1126				parts = append(parts, "**Literal:** true")
1127			}
1128			return strings.Join(parts, "\n")
1129		}
1130	case tools.GlobToolName:
1131		var params tools.GlobParams
1132		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
1133			var parts []string
1134			parts = append(parts, fmt.Sprintf("**Pattern:** %s", params.Pattern))
1135			if params.Path != "" {
1136				parts = append(parts, fmt.Sprintf("**Path:** %s", params.Path))
1137			}
1138			return strings.Join(parts, "\n")
1139		}
1140	case tools.LSToolName:
1141		var params tools.LSParams
1142		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
1143			path := params.Path
1144			if path == "" {
1145				path = "."
1146			}
1147			return fmt.Sprintf("**Path:** %s", fsext.PrettyPath(path))
1148		}
1149	case tools.DownloadToolName:
1150		var params tools.DownloadParams
1151		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
1152			var parts []string
1153			parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
1154			parts = append(parts, fmt.Sprintf("**File Path:** %s", fsext.PrettyPath(params.FilePath)))
1155			if params.Timeout > 0 {
1156				parts = append(parts, fmt.Sprintf("**Timeout:** %s", (time.Duration(params.Timeout)*time.Second).String()))
1157			}
1158			return strings.Join(parts, "\n")
1159		}
1160	case tools.SourcegraphToolName:
1161		var params tools.SourcegraphParams
1162		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
1163			var parts []string
1164			parts = append(parts, fmt.Sprintf("**Query:** %s", params.Query))
1165			if params.Count > 0 {
1166				parts = append(parts, fmt.Sprintf("**Count:** %d", params.Count))
1167			}
1168			if params.ContextWindow > 0 {
1169				parts = append(parts, fmt.Sprintf("**Context:** %d", params.ContextWindow))
1170			}
1171			return strings.Join(parts, "\n")
1172		}
1173	case tools.DiagnosticsToolName:
1174		return "**Project:** diagnostics"
1175	case agent.AgentToolName:
1176		var params agent.AgentParams
1177		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
1178			return fmt.Sprintf("**Task:**\n%s", params.Prompt)
1179		}
1180	}
1181
1182	var params map[string]any
1183	if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
1184		var parts []string
1185		for key, value := range params {
1186			displayKey := strings.ReplaceAll(key, "_", " ")
1187			if len(displayKey) > 0 {
1188				displayKey = strings.ToUpper(displayKey[:1]) + displayKey[1:]
1189			}
1190			parts = append(parts, fmt.Sprintf("**%s:** %v", displayKey, value))
1191		}
1192		return strings.Join(parts, "\n")
1193	}
1194
1195	return ""
1196}
1197
1198// formatResultForCopy formats tool results for clipboard copying.
1199func (t *baseToolMessageItem) formatResultForCopy() string {
1200	if t.result == nil {
1201		return ""
1202	}
1203
1204	if t.result.Data != "" {
1205		if strings.HasPrefix(t.result.MIMEType, "image/") {
1206			return fmt.Sprintf("[Image: %s]", t.result.MIMEType)
1207		}
1208		return fmt.Sprintf("[Media: %s]", t.result.MIMEType)
1209	}
1210
1211	switch t.toolCall.Name {
1212	case tools.BashToolName:
1213		return t.formatBashResultForCopy()
1214	case tools.ViewToolName:
1215		return t.formatViewResultForCopy()
1216	case tools.EditToolName:
1217		return t.formatEditResultForCopy()
1218	case tools.MultiEditToolName:
1219		return t.formatMultiEditResultForCopy()
1220	case tools.WriteToolName:
1221		return t.formatWriteResultForCopy()
1222	case tools.FetchToolName:
1223		return t.formatFetchResultForCopy()
1224	case tools.AgenticFetchToolName:
1225		return t.formatAgenticFetchResultForCopy()
1226	case tools.WebFetchToolName:
1227		return t.formatWebFetchResultForCopy()
1228	case agent.AgentToolName:
1229		return t.formatAgentResultForCopy()
1230	case tools.DownloadToolName, tools.GrepToolName, tools.GlobToolName, tools.LSToolName, tools.SourcegraphToolName, tools.DiagnosticsToolName, tools.TodosToolName:
1231		return fmt.Sprintf("```\n%s\n```", t.result.Content)
1232	default:
1233		return t.result.Content
1234	}
1235}
1236
1237// formatBashResultForCopy formats bash tool results for clipboard.
1238func (t *baseToolMessageItem) formatBashResultForCopy() string {
1239	if t.result == nil {
1240		return ""
1241	}
1242
1243	var meta tools.BashResponseMetadata
1244	if t.result.Metadata != "" {
1245		json.Unmarshal([]byte(t.result.Metadata), &meta)
1246	}
1247
1248	output := meta.Output
1249	if output == "" && t.result.Content != tools.BashNoOutput {
1250		output = t.result.Content
1251	}
1252
1253	if output == "" {
1254		return ""
1255	}
1256
1257	return fmt.Sprintf("```bash\n%s\n```", output)
1258}
1259
1260// formatViewResultForCopy formats view tool results for clipboard.
1261func (t *baseToolMessageItem) formatViewResultForCopy() string {
1262	if t.result == nil {
1263		return ""
1264	}
1265
1266	var meta tools.ViewResponseMetadata
1267	if t.result.Metadata != "" {
1268		json.Unmarshal([]byte(t.result.Metadata), &meta)
1269	}
1270
1271	if meta.Content == "" {
1272		return t.result.Content
1273	}
1274
1275	lang := ""
1276	if meta.FilePath != "" {
1277		ext := strings.ToLower(filepath.Ext(meta.FilePath))
1278		switch ext {
1279		case ".go":
1280			lang = "go"
1281		case ".js", ".mjs":
1282			lang = "javascript"
1283		case ".ts":
1284			lang = "typescript"
1285		case ".py":
1286			lang = "python"
1287		case ".rs":
1288			lang = "rust"
1289		case ".java":
1290			lang = "java"
1291		case ".c":
1292			lang = "c"
1293		case ".cpp", ".cc", ".cxx":
1294			lang = "cpp"
1295		case ".sh", ".bash":
1296			lang = "bash"
1297		case ".json":
1298			lang = "json"
1299		case ".yaml", ".yml":
1300			lang = "yaml"
1301		case ".xml":
1302			lang = "xml"
1303		case ".html":
1304			lang = "html"
1305		case ".css":
1306			lang = "css"
1307		case ".md":
1308			lang = "markdown"
1309		}
1310	}
1311
1312	var result strings.Builder
1313	if lang != "" {
1314		fmt.Fprintf(&result, "```%s\n", lang)
1315	} else {
1316		result.WriteString("```\n")
1317	}
1318	result.WriteString(meta.Content)
1319	result.WriteString("\n```")
1320
1321	return result.String()
1322}
1323
1324// formatEditResultForCopy formats edit tool results for clipboard.
1325func (t *baseToolMessageItem) formatEditResultForCopy() string {
1326	if t.result == nil || t.result.Metadata == "" {
1327		if t.result != nil {
1328			return t.result.Content
1329		}
1330		return ""
1331	}
1332
1333	var meta tools.EditResponseMetadata
1334	if json.Unmarshal([]byte(t.result.Metadata), &meta) != nil {
1335		return t.result.Content
1336	}
1337
1338	var params tools.EditParams
1339	json.Unmarshal([]byte(t.toolCall.Input), &params)
1340
1341	var result strings.Builder
1342
1343	if meta.OldContent != "" || meta.NewContent != "" {
1344		fileName := params.FilePath
1345		if fileName != "" {
1346			fileName = fsext.PrettyPath(fileName)
1347		}
1348		diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName)
1349
1350		fmt.Fprintf(&result, "Changes: +%d -%d\n", additions, removals)
1351		result.WriteString("```diff\n")
1352		result.WriteString(diffContent)
1353		result.WriteString("\n```")
1354	}
1355
1356	return result.String()
1357}
1358
1359// formatMultiEditResultForCopy formats multi-edit tool results for clipboard.
1360func (t *baseToolMessageItem) formatMultiEditResultForCopy() string {
1361	if t.result == nil || t.result.Metadata == "" {
1362		if t.result != nil {
1363			return t.result.Content
1364		}
1365		return ""
1366	}
1367
1368	var meta tools.MultiEditResponseMetadata
1369	if json.Unmarshal([]byte(t.result.Metadata), &meta) != nil {
1370		return t.result.Content
1371	}
1372
1373	var params tools.MultiEditParams
1374	json.Unmarshal([]byte(t.toolCall.Input), &params)
1375
1376	var result strings.Builder
1377	if meta.OldContent != "" || meta.NewContent != "" {
1378		fileName := params.FilePath
1379		if fileName != "" {
1380			fileName = fsext.PrettyPath(fileName)
1381		}
1382		diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName)
1383
1384		fmt.Fprintf(&result, "Changes: +%d -%d\n", additions, removals)
1385		result.WriteString("```diff\n")
1386		result.WriteString(diffContent)
1387		result.WriteString("\n```")
1388	}
1389
1390	return result.String()
1391}
1392
1393// formatWriteResultForCopy formats write tool results for clipboard.
1394func (t *baseToolMessageItem) formatWriteResultForCopy() string {
1395	if t.result == nil {
1396		return ""
1397	}
1398
1399	var params tools.WriteParams
1400	if json.Unmarshal([]byte(t.toolCall.Input), &params) != nil {
1401		return t.result.Content
1402	}
1403
1404	lang := ""
1405	if params.FilePath != "" {
1406		ext := strings.ToLower(filepath.Ext(params.FilePath))
1407		switch ext {
1408		case ".go":
1409			lang = "go"
1410		case ".js", ".mjs":
1411			lang = "javascript"
1412		case ".ts":
1413			lang = "typescript"
1414		case ".py":
1415			lang = "python"
1416		case ".rs":
1417			lang = "rust"
1418		case ".java":
1419			lang = "java"
1420		case ".c":
1421			lang = "c"
1422		case ".cpp", ".cc", ".cxx":
1423			lang = "cpp"
1424		case ".sh", ".bash":
1425			lang = "bash"
1426		case ".json":
1427			lang = "json"
1428		case ".yaml", ".yml":
1429			lang = "yaml"
1430		case ".xml":
1431			lang = "xml"
1432		case ".html":
1433			lang = "html"
1434		case ".css":
1435			lang = "css"
1436		case ".md":
1437			lang = "markdown"
1438		}
1439	}
1440
1441	var result strings.Builder
1442	fmt.Fprintf(&result, "File: %s\n", fsext.PrettyPath(params.FilePath))
1443	if lang != "" {
1444		fmt.Fprintf(&result, "```%s\n", lang)
1445	} else {
1446		result.WriteString("```\n")
1447	}
1448	result.WriteString(params.Content)
1449	result.WriteString("\n```")
1450
1451	return result.String()
1452}
1453
1454// formatFetchResultForCopy formats fetch tool results for clipboard.
1455func (t *baseToolMessageItem) formatFetchResultForCopy() string {
1456	if t.result == nil {
1457		return ""
1458	}
1459
1460	var params tools.FetchParams
1461	if json.Unmarshal([]byte(t.toolCall.Input), &params) != nil {
1462		return t.result.Content
1463	}
1464
1465	var result strings.Builder
1466	if params.URL != "" {
1467		fmt.Fprintf(&result, "URL: %s\n", params.URL)
1468	}
1469	if params.Format != "" {
1470		fmt.Fprintf(&result, "Format: %s\n", params.Format)
1471	}
1472	if params.Timeout > 0 {
1473		fmt.Fprintf(&result, "Timeout: %ds\n", params.Timeout)
1474	}
1475	result.WriteString("\n")
1476
1477	result.WriteString(t.result.Content)
1478
1479	return result.String()
1480}
1481
1482// formatAgenticFetchResultForCopy formats agentic fetch tool results for clipboard.
1483func (t *baseToolMessageItem) formatAgenticFetchResultForCopy() string {
1484	if t.result == nil {
1485		return ""
1486	}
1487
1488	var params tools.AgenticFetchParams
1489	if json.Unmarshal([]byte(t.toolCall.Input), &params) != nil {
1490		return t.result.Content
1491	}
1492
1493	var result strings.Builder
1494	if params.URL != "" {
1495		fmt.Fprintf(&result, "URL: %s\n", params.URL)
1496	}
1497	if params.Prompt != "" {
1498		fmt.Fprintf(&result, "Prompt: %s\n\n", params.Prompt)
1499	}
1500
1501	result.WriteString("```markdown\n")
1502	result.WriteString(t.result.Content)
1503	result.WriteString("\n```")
1504
1505	return result.String()
1506}
1507
1508// formatWebFetchResultForCopy formats web fetch tool results for clipboard.
1509func (t *baseToolMessageItem) formatWebFetchResultForCopy() string {
1510	if t.result == nil {
1511		return ""
1512	}
1513
1514	var params tools.WebFetchParams
1515	if json.Unmarshal([]byte(t.toolCall.Input), &params) != nil {
1516		return t.result.Content
1517	}
1518
1519	var result strings.Builder
1520	fmt.Fprintf(&result, "URL: %s\n\n", params.URL)
1521	result.WriteString("```markdown\n")
1522	result.WriteString(t.result.Content)
1523	result.WriteString("\n```")
1524
1525	return result.String()
1526}
1527
1528// formatAgentResultForCopy formats agent tool results for clipboard.
1529func (t *baseToolMessageItem) formatAgentResultForCopy() string {
1530	if t.result == nil {
1531		return ""
1532	}
1533
1534	var result strings.Builder
1535
1536	if t.result.Content != "" {
1537		fmt.Fprintf(&result, "```markdown\n%s\n```", t.result.Content)
1538	}
1539
1540	return result.String()
1541}
1542
1543// prettifyToolName returns a human-readable name for tool names.
1544func prettifyToolName(name string) string {
1545	switch name {
1546	case agent.AgentToolName:
1547		return "Agent"
1548	case tools.BashToolName:
1549		return "Bash"
1550	case tools.JobOutputToolName:
1551		return "Job: Output"
1552	case tools.JobKillToolName:
1553		return "Job: Kill"
1554	case tools.DownloadToolName:
1555		return "Download"
1556	case tools.EditToolName:
1557		return "Edit"
1558	case tools.MultiEditToolName:
1559		return "Multi-Edit"
1560	case tools.FetchToolName:
1561		return "Fetch"
1562	case tools.AgenticFetchToolName:
1563		return "Agentic Fetch"
1564	case tools.WebFetchToolName:
1565		return "Fetch"
1566	case tools.WebSearchToolName:
1567		return "Search"
1568	case tools.GlobToolName:
1569		return "Glob"
1570	case tools.GrepToolName:
1571		return "Grep"
1572	case tools.LSToolName:
1573		return "List"
1574	case tools.SourcegraphToolName:
1575		return "Sourcegraph"
1576	case tools.TodosToolName:
1577		return "To-Do"
1578	case tools.ViewToolName:
1579		return "View"
1580	case tools.WriteToolName:
1581		return "Write"
1582	default:
1583		return humanizedToolName(name)
1584	}
1585}