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