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