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