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	switch hi.Decision {
 801	case "deny":
 802		if hi.Reason != "" {
 803			return sty.Tool.HookDenied.Render("Denied") + " " + sty.Tool.HookDeniedReason.Render(hi.Reason)
 804		}
 805		return sty.Tool.HookDenied.Render("Denied")
 806	case "allow":
 807		result := sty.Tool.HookOK.Render("OK")
 808		if hi.InputRewrite {
 809			result += " " + sty.Tool.HookRewrote.Render("Rewrote Input")
 810		}
 811		return result
 812	default:
 813		result := sty.Tool.HookOK.Render("OK")
 814		if hi.InputRewrite {
 815			result += " " + sty.Tool.HookRewrote.Render("Rewrote Input")
 816		}
 817		return result
 818	}
 819}
 820
 821// getDigits returns the number of digits in a number.
 822func getDigits(n int) int {
 823	if n == 0 {
 824		return 1
 825	}
 826	if n < 0 {
 827		n = -n
 828	}
 829	digits := 0
 830	for n > 0 {
 831		n /= 10
 832		digits++
 833	}
 834	return digits
 835}
 836
 837// formatSize formats byte size into human readable format.
 838func formatSize(bytes int) string {
 839	const (
 840		kb = 1024
 841		mb = kb * 1024
 842	)
 843	switch {
 844	case bytes >= mb:
 845		return fmt.Sprintf("%.1f MB", float64(bytes)/float64(mb))
 846	case bytes >= kb:
 847		return fmt.Sprintf("%.1f KB", float64(bytes)/float64(kb))
 848	default:
 849		return fmt.Sprintf("%d B", bytes)
 850	}
 851}
 852
 853// toolOutputDiffContent renders a diff between old and new content.
 854func toolOutputDiffContent(sty *styles.Styles, file, oldContent, newContent string, width int, expanded bool) string {
 855	bodyWidth := width - toolBodyLeftPaddingTotal
 856
 857	formatter := common.DiffFormatter(sty).
 858		Before(file, oldContent).
 859		After(file, newContent).
 860		Width(bodyWidth)
 861
 862	// Use split view for wide terminals.
 863	if width > maxTextWidth {
 864		formatter = formatter.Split()
 865	}
 866
 867	formatted := formatter.String()
 868	lines := strings.Split(formatted, "\n")
 869
 870	// Truncate if needed.
 871	maxLines := responseContextHeight
 872	if expanded {
 873		maxLines = len(lines)
 874	}
 875
 876	if len(lines) > maxLines && !expanded {
 877		truncMsg := sty.Tool.DiffTruncation.
 878			Width(bodyWidth).
 879			Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines))
 880		formatted = strings.Join(lines[:maxLines], "\n") + "\n" + truncMsg
 881	}
 882
 883	return sty.Tool.Body.Render(formatted)
 884}
 885
 886// formatTimeout converts timeout seconds to a duration string (e.g., "30s").
 887// Returns empty string if timeout is 0.
 888func formatTimeout(timeout int) string {
 889	if timeout == 0 {
 890		return ""
 891	}
 892	return fmt.Sprintf("%ds", timeout)
 893}
 894
 895// formatNonZero returns string representation of non-zero integers, empty string for zero.
 896func formatNonZero(value int) string {
 897	if value == 0 {
 898		return ""
 899	}
 900	return fmt.Sprintf("%d", value)
 901}
 902
 903// toolOutputMultiEditDiffContent renders a diff with optional failed edits note.
 904func toolOutputMultiEditDiffContent(sty *styles.Styles, file string, meta tools.MultiEditResponseMetadata, totalEdits, width int, expanded bool) string {
 905	bodyWidth := width - toolBodyLeftPaddingTotal
 906
 907	formatter := common.DiffFormatter(sty).
 908		Before(file, meta.OldContent).
 909		After(file, meta.NewContent).
 910		Width(bodyWidth)
 911
 912	// Use split view for wide terminals.
 913	if width > maxTextWidth {
 914		formatter = formatter.Split()
 915	}
 916
 917	formatted := formatter.String()
 918	lines := strings.Split(formatted, "\n")
 919
 920	// Truncate if needed.
 921	maxLines := responseContextHeight
 922	if expanded {
 923		maxLines = len(lines)
 924	}
 925
 926	if len(lines) > maxLines && !expanded {
 927		truncMsg := sty.Tool.DiffTruncation.
 928			Width(bodyWidth).
 929			Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines))
 930		formatted = truncMsg + "\n" + strings.Join(lines[:maxLines], "\n")
 931	}
 932
 933	// Add failed edits note if any exist.
 934	if len(meta.EditsFailed) > 0 {
 935		noteTag := sty.Tool.NoteTag.Render("Note")
 936		noteMsg := fmt.Sprintf("%d of %d edits succeeded", meta.EditsApplied, totalEdits)
 937		note := fmt.Sprintf("%s %s", noteTag, sty.Tool.NoteMessage.Render(noteMsg))
 938		formatted = formatted + "\n\n" + note
 939	}
 940
 941	return sty.Tool.Body.Render(formatted)
 942}
 943
 944// roundedEnumerator creates a tree enumerator with rounded corners.
 945func roundedEnumerator(lPadding, width int) tree.Enumerator {
 946	if width == 0 {
 947		width = 2
 948	}
 949	if lPadding == 0 {
 950		lPadding = 1
 951	}
 952	return func(children tree.Children, index int) string {
 953		line := strings.Repeat("─", width)
 954		padding := strings.Repeat(" ", lPadding)
 955		if children.Length()-1 == index {
 956			return padding + "╰" + line
 957		}
 958		return padding + "├" + line
 959	}
 960}
 961
 962// toolOutputMarkdownContent renders markdown content with optional truncation.
 963func toolOutputMarkdownContent(sty *styles.Styles, content string, width int, expanded bool) string {
 964	content = stringext.NormalizeSpace(content)
 965
 966	// Cap width for readability.
 967	if width > maxTextWidth {
 968		width = maxTextWidth
 969	}
 970
 971	renderer := common.QuietMarkdownRenderer(sty, width)
 972	rendered, err := renderer.Render(content)
 973	if err != nil {
 974		return toolOutputPlainContent(sty, content, width, expanded)
 975	}
 976
 977	lines := strings.Split(rendered, "\n")
 978	maxLines := responseContextHeight
 979	if expanded {
 980		maxLines = len(lines)
 981	}
 982
 983	var out []string
 984	for i, ln := range lines {
 985		if i >= maxLines {
 986			break
 987		}
 988		out = append(out, ln)
 989	}
 990
 991	if len(lines) > maxLines && !expanded {
 992		out = append(out, sty.Tool.ContentTruncation.
 993			Width(width).
 994			Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)),
 995		)
 996	}
 997
 998	return sty.Tool.Body.Render(strings.Join(out, "\n"))
 999}
1000
1001// formatToolForCopy formats the tool call for clipboard copying.
1002func (t *baseToolMessageItem) formatToolForCopy() string {
1003	var parts []string
1004
1005	toolName := prettifyToolName(t.toolCall.Name)
1006	parts = append(parts, fmt.Sprintf("## %s Tool Call", toolName))
1007
1008	if t.toolCall.Input != "" {
1009		params := t.formatParametersForCopy()
1010		if params != "" {
1011			parts = append(parts, "### Parameters:")
1012			parts = append(parts, params)
1013		}
1014	}
1015
1016	if t.result != nil && t.result.ToolCallID != "" {
1017		if t.result.IsError {
1018			parts = append(parts, "### Error:")
1019			parts = append(parts, t.result.Content)
1020		} else {
1021			parts = append(parts, "### Result:")
1022			content := t.formatResultForCopy()
1023			if content != "" {
1024				parts = append(parts, content)
1025			}
1026		}
1027	} else if t.status == ToolStatusCanceled {
1028		parts = append(parts, "### Status:")
1029		parts = append(parts, "Cancelled")
1030	} else {
1031		parts = append(parts, "### Status:")
1032		parts = append(parts, "Pending...")
1033	}
1034
1035	return strings.Join(parts, "\n\n")
1036}
1037
1038// formatParametersForCopy formats tool parameters for clipboard copying.
1039func (t *baseToolMessageItem) formatParametersForCopy() string {
1040	switch t.toolCall.Name {
1041	case tools.BashToolName:
1042		var params tools.BashParams
1043		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
1044			cmd := strings.ReplaceAll(params.Command, "\n", " ")
1045			cmd = strings.ReplaceAll(cmd, "\t", "    ")
1046			return fmt.Sprintf("**Command:** %s", cmd)
1047		}
1048	case tools.ViewToolName:
1049		var params tools.ViewParams
1050		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
1051			var parts []string
1052			parts = append(parts, fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)))
1053			if params.Limit > 0 {
1054				parts = append(parts, fmt.Sprintf("**Limit:** %d", params.Limit))
1055			}
1056			if params.Offset > 0 {
1057				parts = append(parts, fmt.Sprintf("**Offset:** %d", params.Offset))
1058			}
1059			return strings.Join(parts, "\n")
1060		}
1061	case tools.EditToolName:
1062		var params tools.EditParams
1063		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
1064			return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath))
1065		}
1066	case tools.MultiEditToolName:
1067		var params tools.MultiEditParams
1068		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
1069			var parts []string
1070			parts = append(parts, fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)))
1071			parts = append(parts, fmt.Sprintf("**Edits:** %d", len(params.Edits)))
1072			return strings.Join(parts, "\n")
1073		}
1074	case tools.WriteToolName:
1075		var params tools.WriteParams
1076		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
1077			return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath))
1078		}
1079	case tools.FetchToolName:
1080		var params tools.FetchParams
1081		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
1082			var parts []string
1083			parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
1084			if params.Format != "" {
1085				parts = append(parts, fmt.Sprintf("**Format:** %s", params.Format))
1086			}
1087			if params.Timeout > 0 {
1088				parts = append(parts, fmt.Sprintf("**Timeout:** %ds", params.Timeout))
1089			}
1090			return strings.Join(parts, "\n")
1091		}
1092	case tools.AgenticFetchToolName:
1093		var params tools.AgenticFetchParams
1094		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
1095			var parts []string
1096			if params.URL != "" {
1097				parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
1098			}
1099			if params.Prompt != "" {
1100				parts = append(parts, fmt.Sprintf("**Prompt:** %s", params.Prompt))
1101			}
1102			return strings.Join(parts, "\n")
1103		}
1104	case tools.WebFetchToolName:
1105		var params tools.WebFetchParams
1106		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
1107			return fmt.Sprintf("**URL:** %s", params.URL)
1108		}
1109	case tools.GrepToolName:
1110		var params tools.GrepParams
1111		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
1112			var parts []string
1113			parts = append(parts, fmt.Sprintf("**Pattern:** %s", params.Pattern))
1114			if params.Path != "" {
1115				parts = append(parts, fmt.Sprintf("**Path:** %s", params.Path))
1116			}
1117			if params.Include != "" {
1118				parts = append(parts, fmt.Sprintf("**Include:** %s", params.Include))
1119			}
1120			if params.LiteralText {
1121				parts = append(parts, "**Literal:** true")
1122			}
1123			return strings.Join(parts, "\n")
1124		}
1125	case tools.GlobToolName:
1126		var params tools.GlobParams
1127		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
1128			var parts []string
1129			parts = append(parts, fmt.Sprintf("**Pattern:** %s", params.Pattern))
1130			if params.Path != "" {
1131				parts = append(parts, fmt.Sprintf("**Path:** %s", params.Path))
1132			}
1133			return strings.Join(parts, "\n")
1134		}
1135	case tools.LSToolName:
1136		var params tools.LSParams
1137		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
1138			path := params.Path
1139			if path == "" {
1140				path = "."
1141			}
1142			return fmt.Sprintf("**Path:** %s", fsext.PrettyPath(path))
1143		}
1144	case tools.DownloadToolName:
1145		var params tools.DownloadParams
1146		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
1147			var parts []string
1148			parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
1149			parts = append(parts, fmt.Sprintf("**File Path:** %s", fsext.PrettyPath(params.FilePath)))
1150			if params.Timeout > 0 {
1151				parts = append(parts, fmt.Sprintf("**Timeout:** %s", (time.Duration(params.Timeout)*time.Second).String()))
1152			}
1153			return strings.Join(parts, "\n")
1154		}
1155	case tools.SourcegraphToolName:
1156		var params tools.SourcegraphParams
1157		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
1158			var parts []string
1159			parts = append(parts, fmt.Sprintf("**Query:** %s", params.Query))
1160			if params.Count > 0 {
1161				parts = append(parts, fmt.Sprintf("**Count:** %d", params.Count))
1162			}
1163			if params.ContextWindow > 0 {
1164				parts = append(parts, fmt.Sprintf("**Context:** %d", params.ContextWindow))
1165			}
1166			return strings.Join(parts, "\n")
1167		}
1168	case tools.DiagnosticsToolName:
1169		return "**Project:** diagnostics"
1170	case agent.AgentToolName:
1171		var params agent.AgentParams
1172		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
1173			return fmt.Sprintf("**Task:**\n%s", params.Prompt)
1174		}
1175	}
1176
1177	var params map[string]any
1178	if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
1179		var parts []string
1180		for key, value := range params {
1181			displayKey := strings.ReplaceAll(key, "_", " ")
1182			if len(displayKey) > 0 {
1183				displayKey = strings.ToUpper(displayKey[:1]) + displayKey[1:]
1184			}
1185			parts = append(parts, fmt.Sprintf("**%s:** %v", displayKey, value))
1186		}
1187		return strings.Join(parts, "\n")
1188	}
1189
1190	return ""
1191}
1192
1193// formatResultForCopy formats tool results for clipboard copying.
1194func (t *baseToolMessageItem) formatResultForCopy() string {
1195	if t.result == nil {
1196		return ""
1197	}
1198
1199	if t.result.Data != "" {
1200		if strings.HasPrefix(t.result.MIMEType, "image/") {
1201			return fmt.Sprintf("[Image: %s]", t.result.MIMEType)
1202		}
1203		return fmt.Sprintf("[Media: %s]", t.result.MIMEType)
1204	}
1205
1206	switch t.toolCall.Name {
1207	case tools.BashToolName:
1208		return t.formatBashResultForCopy()
1209	case tools.ViewToolName:
1210		return t.formatViewResultForCopy()
1211	case tools.EditToolName:
1212		return t.formatEditResultForCopy()
1213	case tools.MultiEditToolName:
1214		return t.formatMultiEditResultForCopy()
1215	case tools.WriteToolName:
1216		return t.formatWriteResultForCopy()
1217	case tools.FetchToolName:
1218		return t.formatFetchResultForCopy()
1219	case tools.AgenticFetchToolName:
1220		return t.formatAgenticFetchResultForCopy()
1221	case tools.WebFetchToolName:
1222		return t.formatWebFetchResultForCopy()
1223	case agent.AgentToolName:
1224		return t.formatAgentResultForCopy()
1225	case tools.DownloadToolName, tools.GrepToolName, tools.GlobToolName, tools.LSToolName, tools.SourcegraphToolName, tools.DiagnosticsToolName, tools.TodosToolName:
1226		return fmt.Sprintf("```\n%s\n```", t.result.Content)
1227	default:
1228		return t.result.Content
1229	}
1230}
1231
1232// formatBashResultForCopy formats bash tool results for clipboard.
1233func (t *baseToolMessageItem) formatBashResultForCopy() string {
1234	if t.result == nil {
1235		return ""
1236	}
1237
1238	var meta tools.BashResponseMetadata
1239	if t.result.Metadata != "" {
1240		json.Unmarshal([]byte(t.result.Metadata), &meta)
1241	}
1242
1243	output := meta.Output
1244	if output == "" && t.result.Content != tools.BashNoOutput {
1245		output = t.result.Content
1246	}
1247
1248	if output == "" {
1249		return ""
1250	}
1251
1252	return fmt.Sprintf("```bash\n%s\n```", output)
1253}
1254
1255// formatViewResultForCopy formats view tool results for clipboard.
1256func (t *baseToolMessageItem) formatViewResultForCopy() string {
1257	if t.result == nil {
1258		return ""
1259	}
1260
1261	var meta tools.ViewResponseMetadata
1262	if t.result.Metadata != "" {
1263		json.Unmarshal([]byte(t.result.Metadata), &meta)
1264	}
1265
1266	if meta.Content == "" {
1267		return t.result.Content
1268	}
1269
1270	lang := ""
1271	if meta.FilePath != "" {
1272		ext := strings.ToLower(filepath.Ext(meta.FilePath))
1273		switch ext {
1274		case ".go":
1275			lang = "go"
1276		case ".js", ".mjs":
1277			lang = "javascript"
1278		case ".ts":
1279			lang = "typescript"
1280		case ".py":
1281			lang = "python"
1282		case ".rs":
1283			lang = "rust"
1284		case ".java":
1285			lang = "java"
1286		case ".c":
1287			lang = "c"
1288		case ".cpp", ".cc", ".cxx":
1289			lang = "cpp"
1290		case ".sh", ".bash":
1291			lang = "bash"
1292		case ".json":
1293			lang = "json"
1294		case ".yaml", ".yml":
1295			lang = "yaml"
1296		case ".xml":
1297			lang = "xml"
1298		case ".html":
1299			lang = "html"
1300		case ".css":
1301			lang = "css"
1302		case ".md":
1303			lang = "markdown"
1304		}
1305	}
1306
1307	var result strings.Builder
1308	if lang != "" {
1309		fmt.Fprintf(&result, "```%s\n", lang)
1310	} else {
1311		result.WriteString("```\n")
1312	}
1313	result.WriteString(meta.Content)
1314	result.WriteString("\n```")
1315
1316	return result.String()
1317}
1318
1319// formatEditResultForCopy formats edit tool results for clipboard.
1320func (t *baseToolMessageItem) formatEditResultForCopy() string {
1321	if t.result == nil || t.result.Metadata == "" {
1322		if t.result != nil {
1323			return t.result.Content
1324		}
1325		return ""
1326	}
1327
1328	var meta tools.EditResponseMetadata
1329	if json.Unmarshal([]byte(t.result.Metadata), &meta) != nil {
1330		return t.result.Content
1331	}
1332
1333	var params tools.EditParams
1334	json.Unmarshal([]byte(t.toolCall.Input), &params)
1335
1336	var result strings.Builder
1337
1338	if meta.OldContent != "" || meta.NewContent != "" {
1339		fileName := params.FilePath
1340		if fileName != "" {
1341			fileName = fsext.PrettyPath(fileName)
1342		}
1343		diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName)
1344
1345		fmt.Fprintf(&result, "Changes: +%d -%d\n", additions, removals)
1346		result.WriteString("```diff\n")
1347		result.WriteString(diffContent)
1348		result.WriteString("\n```")
1349	}
1350
1351	return result.String()
1352}
1353
1354// formatMultiEditResultForCopy formats multi-edit tool results for clipboard.
1355func (t *baseToolMessageItem) formatMultiEditResultForCopy() string {
1356	if t.result == nil || t.result.Metadata == "" {
1357		if t.result != nil {
1358			return t.result.Content
1359		}
1360		return ""
1361	}
1362
1363	var meta tools.MultiEditResponseMetadata
1364	if json.Unmarshal([]byte(t.result.Metadata), &meta) != nil {
1365		return t.result.Content
1366	}
1367
1368	var params tools.MultiEditParams
1369	json.Unmarshal([]byte(t.toolCall.Input), &params)
1370
1371	var result strings.Builder
1372	if meta.OldContent != "" || meta.NewContent != "" {
1373		fileName := params.FilePath
1374		if fileName != "" {
1375			fileName = fsext.PrettyPath(fileName)
1376		}
1377		diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName)
1378
1379		fmt.Fprintf(&result, "Changes: +%d -%d\n", additions, removals)
1380		result.WriteString("```diff\n")
1381		result.WriteString(diffContent)
1382		result.WriteString("\n```")
1383	}
1384
1385	return result.String()
1386}
1387
1388// formatWriteResultForCopy formats write tool results for clipboard.
1389func (t *baseToolMessageItem) formatWriteResultForCopy() string {
1390	if t.result == nil {
1391		return ""
1392	}
1393
1394	var params tools.WriteParams
1395	if json.Unmarshal([]byte(t.toolCall.Input), &params) != nil {
1396		return t.result.Content
1397	}
1398
1399	lang := ""
1400	if params.FilePath != "" {
1401		ext := strings.ToLower(filepath.Ext(params.FilePath))
1402		switch ext {
1403		case ".go":
1404			lang = "go"
1405		case ".js", ".mjs":
1406			lang = "javascript"
1407		case ".ts":
1408			lang = "typescript"
1409		case ".py":
1410			lang = "python"
1411		case ".rs":
1412			lang = "rust"
1413		case ".java":
1414			lang = "java"
1415		case ".c":
1416			lang = "c"
1417		case ".cpp", ".cc", ".cxx":
1418			lang = "cpp"
1419		case ".sh", ".bash":
1420			lang = "bash"
1421		case ".json":
1422			lang = "json"
1423		case ".yaml", ".yml":
1424			lang = "yaml"
1425		case ".xml":
1426			lang = "xml"
1427		case ".html":
1428			lang = "html"
1429		case ".css":
1430			lang = "css"
1431		case ".md":
1432			lang = "markdown"
1433		}
1434	}
1435
1436	var result strings.Builder
1437	fmt.Fprintf(&result, "File: %s\n", fsext.PrettyPath(params.FilePath))
1438	if lang != "" {
1439		fmt.Fprintf(&result, "```%s\n", lang)
1440	} else {
1441		result.WriteString("```\n")
1442	}
1443	result.WriteString(params.Content)
1444	result.WriteString("\n```")
1445
1446	return result.String()
1447}
1448
1449// formatFetchResultForCopy formats fetch tool results for clipboard.
1450func (t *baseToolMessageItem) formatFetchResultForCopy() string {
1451	if t.result == nil {
1452		return ""
1453	}
1454
1455	var params tools.FetchParams
1456	if json.Unmarshal([]byte(t.toolCall.Input), &params) != nil {
1457		return t.result.Content
1458	}
1459
1460	var result strings.Builder
1461	if params.URL != "" {
1462		fmt.Fprintf(&result, "URL: %s\n", params.URL)
1463	}
1464	if params.Format != "" {
1465		fmt.Fprintf(&result, "Format: %s\n", params.Format)
1466	}
1467	if params.Timeout > 0 {
1468		fmt.Fprintf(&result, "Timeout: %ds\n", params.Timeout)
1469	}
1470	result.WriteString("\n")
1471
1472	result.WriteString(t.result.Content)
1473
1474	return result.String()
1475}
1476
1477// formatAgenticFetchResultForCopy formats agentic fetch tool results for clipboard.
1478func (t *baseToolMessageItem) formatAgenticFetchResultForCopy() string {
1479	if t.result == nil {
1480		return ""
1481	}
1482
1483	var params tools.AgenticFetchParams
1484	if json.Unmarshal([]byte(t.toolCall.Input), &params) != nil {
1485		return t.result.Content
1486	}
1487
1488	var result strings.Builder
1489	if params.URL != "" {
1490		fmt.Fprintf(&result, "URL: %s\n", params.URL)
1491	}
1492	if params.Prompt != "" {
1493		fmt.Fprintf(&result, "Prompt: %s\n\n", params.Prompt)
1494	}
1495
1496	result.WriteString("```markdown\n")
1497	result.WriteString(t.result.Content)
1498	result.WriteString("\n```")
1499
1500	return result.String()
1501}
1502
1503// formatWebFetchResultForCopy formats web fetch tool results for clipboard.
1504func (t *baseToolMessageItem) formatWebFetchResultForCopy() string {
1505	if t.result == nil {
1506		return ""
1507	}
1508
1509	var params tools.WebFetchParams
1510	if json.Unmarshal([]byte(t.toolCall.Input), &params) != nil {
1511		return t.result.Content
1512	}
1513
1514	var result strings.Builder
1515	fmt.Fprintf(&result, "URL: %s\n\n", params.URL)
1516	result.WriteString("```markdown\n")
1517	result.WriteString(t.result.Content)
1518	result.WriteString("\n```")
1519
1520	return result.String()
1521}
1522
1523// formatAgentResultForCopy formats agent tool results for clipboard.
1524func (t *baseToolMessageItem) formatAgentResultForCopy() string {
1525	if t.result == nil {
1526		return ""
1527	}
1528
1529	var result strings.Builder
1530
1531	if t.result.Content != "" {
1532		fmt.Fprintf(&result, "```markdown\n%s\n```", t.result.Content)
1533	}
1534
1535	return result.String()
1536}
1537
1538// prettifyToolName returns a human-readable name for tool names.
1539func prettifyToolName(name string) string {
1540	switch name {
1541	case agent.AgentToolName:
1542		return "Agent"
1543	case tools.BashToolName:
1544		return "Bash"
1545	case tools.JobOutputToolName:
1546		return "Job: Output"
1547	case tools.JobKillToolName:
1548		return "Job: Kill"
1549	case tools.DownloadToolName:
1550		return "Download"
1551	case tools.EditToolName:
1552		return "Edit"
1553	case tools.MultiEditToolName:
1554		return "Multi-Edit"
1555	case tools.FetchToolName:
1556		return "Fetch"
1557	case tools.AgenticFetchToolName:
1558		return "Agentic Fetch"
1559	case tools.WebFetchToolName:
1560		return "Fetch"
1561	case tools.WebSearchToolName:
1562		return "Search"
1563	case tools.GlobToolName:
1564		return "Glob"
1565	case tools.GrepToolName:
1566		return "Grep"
1567	case tools.LSToolName:
1568		return "List"
1569	case tools.SourcegraphToolName:
1570		return "Sourcegraph"
1571	case tools.TodosToolName:
1572		return "To-Do"
1573	case tools.ViewToolName:
1574		return "View"
1575	case tools.WriteToolName:
1576		return "Write"
1577	default:
1578		return humanizedToolName(name)
1579	}
1580}