renderer.go

   1package messages
   2
   3import (
   4	"encoding/json"
   5	"fmt"
   6	"strings"
   7	"time"
   8
   9	"github.com/charmbracelet/crush/internal/agent"
  10	"github.com/charmbracelet/crush/internal/agent/tools"
  11	"github.com/charmbracelet/crush/internal/ansiext"
  12	"github.com/charmbracelet/crush/internal/fsext"
  13	"github.com/charmbracelet/crush/internal/tui/components/core"
  14	"github.com/charmbracelet/crush/internal/tui/highlight"
  15	"github.com/charmbracelet/crush/internal/tui/styles"
  16	"github.com/charmbracelet/lipgloss/v2"
  17	"github.com/charmbracelet/lipgloss/v2/tree"
  18	"github.com/charmbracelet/x/ansi"
  19)
  20
  21// responseContextHeight limits the number of lines displayed in tool output
  22const responseContextHeight = 10
  23
  24// renderer defines the interface for tool-specific rendering implementations
  25type renderer interface {
  26	// Render returns the complete (already styled) tool‑call view, not
  27	// including the outer border.
  28	Render(v *toolCallCmp) string
  29}
  30
  31// rendererFactory creates new renderer instances
  32type rendererFactory func() renderer
  33
  34// renderRegistry manages the mapping of tool names to their renderers
  35type renderRegistry map[string]rendererFactory
  36
  37// register adds a new renderer factory to the registry
  38func (rr renderRegistry) register(name string, f rendererFactory) { rr[name] = f }
  39
  40// lookup retrieves a renderer for the given tool name, falling back to generic renderer
  41func (rr renderRegistry) lookup(name string) renderer {
  42	if f, ok := rr[name]; ok {
  43		return f()
  44	}
  45	return genericRenderer{} // sensible fallback
  46}
  47
  48// registry holds all registered tool renderers
  49var registry = renderRegistry{}
  50
  51// baseRenderer provides common functionality for all tool renderers
  52type baseRenderer struct{}
  53
  54// paramBuilder helps construct parameter lists for tool headers
  55type paramBuilder struct {
  56	args []string
  57}
  58
  59// newParamBuilder creates a new parameter builder
  60func newParamBuilder() *paramBuilder {
  61	return &paramBuilder{args: make([]string, 0)}
  62}
  63
  64// addMain adds the main parameter (first argument)
  65func (pb *paramBuilder) addMain(value string) *paramBuilder {
  66	if value != "" {
  67		pb.args = append(pb.args, value)
  68	}
  69	return pb
  70}
  71
  72// addKeyValue adds a key-value pair parameter
  73func (pb *paramBuilder) addKeyValue(key, value string) *paramBuilder {
  74	if value != "" {
  75		pb.args = append(pb.args, key, value)
  76	}
  77	return pb
  78}
  79
  80// addFlag adds a boolean flag parameter
  81func (pb *paramBuilder) addFlag(key string, value bool) *paramBuilder {
  82	if value {
  83		pb.args = append(pb.args, key, "true")
  84	}
  85	return pb
  86}
  87
  88// build returns the final parameter list
  89func (pb *paramBuilder) build() []string {
  90	return pb.args
  91}
  92
  93// renderWithParams provides a common rendering pattern for tools with parameters
  94func (br baseRenderer) renderWithParams(v *toolCallCmp, toolName string, args []string, contentRenderer func() string) string {
  95	width := v.textWidth()
  96	if v.isNested {
  97		width -= 4 // Adjust for nested tool call indentation
  98	}
  99	header := br.makeHeader(v, toolName, width, args...)
 100	if v.isNested {
 101		return v.style().Render(header)
 102	}
 103	if res, done := earlyState(header, v); done {
 104		return res
 105	}
 106	body := contentRenderer()
 107	return joinHeaderBody(header, body)
 108}
 109
 110// unmarshalParams safely unmarshal JSON parameters
 111func (br baseRenderer) unmarshalParams(input string, target any) error {
 112	return json.Unmarshal([]byte(input), target)
 113}
 114
 115// makeHeader builds the tool call header with status icon and parameters for a nested tool call.
 116func (br baseRenderer) makeNestedHeader(v *toolCallCmp, tool string, width int, params ...string) string {
 117	t := styles.CurrentTheme()
 118	icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
 119	if v.result.ToolCallID != "" {
 120		if v.result.IsError {
 121			icon = t.S().Base.Foreground(t.RedDark).Render(styles.ToolError)
 122		} else {
 123			icon = t.S().Base.Foreground(t.Green).Render(styles.ToolSuccess)
 124		}
 125	} else if v.cancelled {
 126		icon = t.S().Muted.Render(styles.ToolPending)
 127	}
 128	tool = t.S().Base.Foreground(t.FgHalfMuted).Render(tool)
 129	prefix := fmt.Sprintf("%s %s ", icon, tool)
 130	return prefix + renderParamList(true, width-lipgloss.Width(prefix), params...)
 131}
 132
 133// makeHeader builds "<Tool>: param (key=value)" and truncates as needed.
 134func (br baseRenderer) makeHeader(v *toolCallCmp, tool string, width int, params ...string) string {
 135	if v.isNested {
 136		return br.makeNestedHeader(v, tool, width, params...)
 137	}
 138	t := styles.CurrentTheme()
 139	icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
 140	if v.result.ToolCallID != "" {
 141		if v.result.IsError {
 142			icon = t.S().Base.Foreground(t.RedDark).Render(styles.ToolError)
 143		} else {
 144			icon = t.S().Base.Foreground(t.Green).Render(styles.ToolSuccess)
 145		}
 146	} else if v.cancelled {
 147		icon = t.S().Muted.Render(styles.ToolPending)
 148	}
 149	tool = t.S().Base.Foreground(t.Blue).Render(tool)
 150	prefix := fmt.Sprintf("%s %s ", icon, tool)
 151	return prefix + renderParamList(false, width-lipgloss.Width(prefix), params...)
 152}
 153
 154// renderError provides consistent error rendering
 155func (br baseRenderer) renderError(v *toolCallCmp, message string) string {
 156	t := styles.CurrentTheme()
 157	header := br.makeHeader(v, prettifyToolName(v.call.Name), v.textWidth(), "")
 158	errorTag := t.S().Base.Padding(0, 1).Background(t.Red).Foreground(t.White).Render("ERROR")
 159	message = t.S().Base.Foreground(t.FgHalfMuted).Render(v.fit(message, v.textWidth()-3-lipgloss.Width(errorTag))) // -2 for padding and space
 160	return joinHeaderBody(header, errorTag+" "+message)
 161}
 162
 163// Register tool renderers
 164func init() {
 165	registry.register(tools.BashToolName, func() renderer { return bashRenderer{} })
 166	registry.register(tools.DownloadToolName, func() renderer { return downloadRenderer{} })
 167	registry.register(tools.ViewToolName, func() renderer { return viewRenderer{} })
 168	registry.register(tools.EditToolName, func() renderer { return editRenderer{} })
 169	registry.register(tools.MultiEditToolName, func() renderer { return multiEditRenderer{} })
 170	registry.register(tools.WriteToolName, func() renderer { return writeRenderer{} })
 171	registry.register(tools.FetchToolName, func() renderer { return fetchRenderer{} })
 172	registry.register(tools.WebFetchToolName, func() renderer { return webFetchRenderer{} })
 173	registry.register(tools.GlobToolName, func() renderer { return globRenderer{} })
 174	registry.register(tools.GrepToolName, func() renderer { return grepRenderer{} })
 175	registry.register(tools.LSToolName, func() renderer { return lsRenderer{} })
 176	registry.register(tools.SourcegraphToolName, func() renderer { return sourcegraphRenderer{} })
 177	registry.register(tools.DiagnosticsToolName, func() renderer { return diagnosticsRenderer{} })
 178	registry.register(agent.AgentToolName, func() renderer { return agentRenderer{} })
 179}
 180
 181// -----------------------------------------------------------------------------
 182//  Generic renderer
 183// -----------------------------------------------------------------------------
 184
 185// genericRenderer handles unknown tool types with basic parameter display
 186type genericRenderer struct {
 187	baseRenderer
 188}
 189
 190// Render displays the tool call with its raw input and plain content output
 191func (gr genericRenderer) Render(v *toolCallCmp) string {
 192	return gr.renderWithParams(v, prettifyToolName(v.call.Name), []string{v.call.Input}, func() string {
 193		return renderPlainContent(v, v.result.Content)
 194	})
 195}
 196
 197// -----------------------------------------------------------------------------
 198//  Bash renderer
 199// -----------------------------------------------------------------------------
 200
 201// bashRenderer handles bash command execution display
 202type bashRenderer struct {
 203	baseRenderer
 204}
 205
 206// Render displays the bash command with sanitized newlines and plain output
 207func (br bashRenderer) Render(v *toolCallCmp) string {
 208	var params tools.BashParams
 209	if err := br.unmarshalParams(v.call.Input, &params); err != nil {
 210		return br.renderError(v, "Invalid bash parameters")
 211	}
 212
 213	cmd := strings.ReplaceAll(params.Command, "\n", " ")
 214	cmd = strings.ReplaceAll(cmd, "\t", "    ")
 215	args := newParamBuilder().addMain(cmd).build()
 216
 217	return br.renderWithParams(v, "Bash", args, func() string {
 218		var meta tools.BashResponseMetadata
 219		if err := br.unmarshalParams(v.result.Metadata, &meta); err != nil {
 220			return renderPlainContent(v, v.result.Content)
 221		}
 222		// for backwards compatibility with older tool calls.
 223		if meta.Output == "" && v.result.Content != tools.BashNoOutput {
 224			meta.Output = v.result.Content
 225		}
 226
 227		if meta.Output == "" {
 228			return ""
 229		}
 230		return renderPlainContent(v, meta.Output)
 231	})
 232}
 233
 234// -----------------------------------------------------------------------------
 235//  View renderer
 236// -----------------------------------------------------------------------------
 237
 238// viewRenderer handles file viewing with syntax highlighting and line numbers
 239type viewRenderer struct {
 240	baseRenderer
 241}
 242
 243// Render displays file content with optional limit and offset parameters
 244func (vr viewRenderer) Render(v *toolCallCmp) string {
 245	var params tools.ViewParams
 246	if err := vr.unmarshalParams(v.call.Input, &params); err != nil {
 247		return vr.renderError(v, "Invalid view parameters")
 248	}
 249
 250	file := fsext.PrettyPath(params.FilePath)
 251	args := newParamBuilder().
 252		addMain(file).
 253		addKeyValue("limit", formatNonZero(params.Limit)).
 254		addKeyValue("offset", formatNonZero(params.Offset)).
 255		build()
 256
 257	return vr.renderWithParams(v, "View", args, func() string {
 258		var meta tools.ViewResponseMetadata
 259		if err := vr.unmarshalParams(v.result.Metadata, &meta); err != nil {
 260			return renderPlainContent(v, v.result.Content)
 261		}
 262		return renderCodeContent(v, meta.FilePath, meta.Content, params.Offset)
 263	})
 264}
 265
 266// formatNonZero returns string representation of non-zero integers, empty string for zero
 267func formatNonZero(value int) string {
 268	if value == 0 {
 269		return ""
 270	}
 271	return fmt.Sprintf("%d", value)
 272}
 273
 274// -----------------------------------------------------------------------------
 275//  Edit renderer
 276// -----------------------------------------------------------------------------
 277
 278// editRenderer handles file editing with diff visualization
 279type editRenderer struct {
 280	baseRenderer
 281}
 282
 283// Render displays the edited file with a formatted diff of changes
 284func (er editRenderer) Render(v *toolCallCmp) string {
 285	t := styles.CurrentTheme()
 286	var params tools.EditParams
 287	var args []string
 288	if err := er.unmarshalParams(v.call.Input, &params); err == nil {
 289		file := fsext.PrettyPath(params.FilePath)
 290		args = newParamBuilder().addMain(file).build()
 291	}
 292
 293	return er.renderWithParams(v, "Edit", args, func() string {
 294		var meta tools.EditResponseMetadata
 295		if err := er.unmarshalParams(v.result.Metadata, &meta); err != nil {
 296			return renderPlainContent(v, v.result.Content)
 297		}
 298
 299		formatter := core.DiffFormatter().
 300			Before(fsext.PrettyPath(params.FilePath), meta.OldContent).
 301			After(fsext.PrettyPath(params.FilePath), meta.NewContent).
 302			Width(v.textWidth() - 2) // -2 for padding
 303		if v.textWidth() > 120 {
 304			formatter = formatter.Split()
 305		}
 306		// add a message to the bottom if the content was truncated
 307		formatted := formatter.String()
 308		if lipgloss.Height(formatted) > responseContextHeight {
 309			contentLines := strings.Split(formatted, "\n")
 310			truncateMessage := t.S().Muted.
 311				Background(t.BgBaseLighter).
 312				PaddingLeft(2).
 313				Width(v.textWidth() - 2).
 314				Render(fmt.Sprintf("… (%d lines)", len(contentLines)-responseContextHeight))
 315			formatted = strings.Join(contentLines[:responseContextHeight], "\n") + "\n" + truncateMessage
 316		}
 317		return formatted
 318	})
 319}
 320
 321// -----------------------------------------------------------------------------
 322//  Multi-Edit renderer
 323// -----------------------------------------------------------------------------
 324
 325// multiEditRenderer handles multiple file edits with diff visualization
 326type multiEditRenderer struct {
 327	baseRenderer
 328}
 329
 330// Render displays the multi-edited file with a formatted diff of changes
 331func (mer multiEditRenderer) Render(v *toolCallCmp) string {
 332	t := styles.CurrentTheme()
 333	var params tools.MultiEditParams
 334	var args []string
 335	if err := mer.unmarshalParams(v.call.Input, &params); err == nil {
 336		file := fsext.PrettyPath(params.FilePath)
 337		editsCount := len(params.Edits)
 338		args = newParamBuilder().
 339			addMain(file).
 340			addKeyValue("edits", fmt.Sprintf("%d", editsCount)).
 341			build()
 342	}
 343
 344	return mer.renderWithParams(v, "Multi-Edit", args, func() string {
 345		var meta tools.MultiEditResponseMetadata
 346		if err := mer.unmarshalParams(v.result.Metadata, &meta); err != nil {
 347			return renderPlainContent(v, v.result.Content)
 348		}
 349
 350		formatter := core.DiffFormatter().
 351			Before(fsext.PrettyPath(params.FilePath), meta.OldContent).
 352			After(fsext.PrettyPath(params.FilePath), meta.NewContent).
 353			Width(v.textWidth() - 2) // -2 for padding
 354		if v.textWidth() > 120 {
 355			formatter = formatter.Split()
 356		}
 357		// add a message to the bottom if the content was truncated
 358		formatted := formatter.String()
 359		if lipgloss.Height(formatted) > responseContextHeight {
 360			contentLines := strings.Split(formatted, "\n")
 361			truncateMessage := t.S().Muted.
 362				Background(t.BgBaseLighter).
 363				PaddingLeft(2).
 364				Width(v.textWidth() - 4).
 365				Render(fmt.Sprintf("… (%d lines)", len(contentLines)-responseContextHeight))
 366			formatted = strings.Join(contentLines[:responseContextHeight], "\n") + "\n" + truncateMessage
 367		}
 368		return formatted
 369	})
 370}
 371
 372// -----------------------------------------------------------------------------
 373//  Write renderer
 374// -----------------------------------------------------------------------------
 375
 376// writeRenderer handles file writing with syntax-highlighted content preview
 377type writeRenderer struct {
 378	baseRenderer
 379}
 380
 381// Render displays the file being written with syntax highlighting
 382func (wr writeRenderer) Render(v *toolCallCmp) string {
 383	var params tools.WriteParams
 384	var args []string
 385	var file string
 386	if err := wr.unmarshalParams(v.call.Input, &params); err == nil {
 387		file = fsext.PrettyPath(params.FilePath)
 388		args = newParamBuilder().addMain(file).build()
 389	}
 390
 391	return wr.renderWithParams(v, "Write", args, func() string {
 392		return renderCodeContent(v, file, params.Content, 0)
 393	})
 394}
 395
 396// -----------------------------------------------------------------------------
 397//  Fetch renderer
 398// -----------------------------------------------------------------------------
 399
 400// fetchRenderer handles URL fetching with format-specific content display
 401type fetchRenderer struct {
 402	baseRenderer
 403}
 404
 405// Render displays the fetched URL with prompt parameter and nested tool calls
 406func (fr fetchRenderer) Render(v *toolCallCmp) string {
 407	t := styles.CurrentTheme()
 408	var params tools.FetchParams
 409	fr.unmarshalParams(v.call.Input, &params)
 410
 411	prompt := params.Prompt
 412	prompt = strings.ReplaceAll(prompt, "\n", " ")
 413
 414	header := fr.makeHeader(v, "Fetch", v.textWidth())
 415
 416	// Check for error or cancelled states
 417	if v.result.IsError {
 418		message := v.renderToolError()
 419		message = t.S().Base.PaddingLeft(2).Render(message)
 420		return lipgloss.JoinVertical(lipgloss.Left, header, "", message)
 421	}
 422	if v.cancelled {
 423		message := t.S().Base.Foreground(t.FgSubtle).Render("Canceled.")
 424		message = t.S().Base.PaddingLeft(2).Render(message)
 425		return lipgloss.JoinVertical(lipgloss.Left, header, "", message)
 426	}
 427
 428	if v.result.ToolCallID == "" && v.permissionRequested && !v.permissionGranted {
 429		message := t.S().Base.Foreground(t.FgSubtle).Render("Requesting for permission...")
 430		message = t.S().Base.PaddingLeft(2).Render(message)
 431		return lipgloss.JoinVertical(lipgloss.Left, header, "", message)
 432	}
 433
 434	// Show URL and prompt like agent tool shows task
 435	urlTag := t.S().Base.Padding(0, 1).MarginLeft(1).Background(t.BlueLight).Foreground(t.White).Render("URL")
 436	promptTag := t.S().Base.Padding(0, 1).MarginLeft(1).Background(t.Green).Foreground(t.White).Render("Prompt")
 437
 438	// Calculate left gutter width (icon + spacing)
 439	leftGutterWidth := lipgloss.Width(urlTag) + 2 // +2 for " " spacing
 440
 441	// Cap at 120 cols minus left gutter
 442	maxTextWidth := 120 - leftGutterWidth
 443	if v.textWidth()-leftGutterWidth < maxTextWidth {
 444		maxTextWidth = v.textWidth() - leftGutterWidth
 445	}
 446
 447	urlText := t.S().Muted.Width(maxTextWidth).Render(params.URL)
 448	promptText := t.S().Muted.Width(maxTextWidth).Render(prompt)
 449
 450	header = lipgloss.JoinVertical(
 451		lipgloss.Left,
 452		header,
 453		"",
 454		lipgloss.JoinHorizontal(lipgloss.Left, urlTag, " ", urlText),
 455		"",
 456		lipgloss.JoinHorizontal(lipgloss.Left, promptTag, " ", promptText),
 457	)
 458
 459	// Show nested tool calls (from sub-agent) in a tree
 460	childTools := tree.Root(header)
 461	for _, call := range v.nestedToolCalls {
 462		childTools.Child(call.View())
 463	}
 464
 465	parts := []string{
 466		childTools.Enumerator(RoundedEnumerator).String(),
 467	}
 468
 469	if v.result.ToolCallID == "" {
 470		v.spinning = true
 471		parts = append(parts, "", v.anim.View())
 472	} else {
 473		v.spinning = false
 474	}
 475
 476	header = lipgloss.JoinVertical(
 477		lipgloss.Left,
 478		parts...,
 479	)
 480
 481	if v.result.ToolCallID == "" {
 482		return header
 483	}
 484
 485	body := renderMarkdownContent(v, v.result.Content)
 486	return joinHeaderBody(header, body)
 487}
 488
 489// formatTimeout converts timeout seconds to duration string
 490func formatTimeout(timeout int) string {
 491	if timeout == 0 {
 492		return ""
 493	}
 494	return (time.Duration(timeout) * time.Second).String()
 495}
 496
 497// -----------------------------------------------------------------------------
 498//  Web fetch renderer
 499// -----------------------------------------------------------------------------
 500
 501// webFetchRenderer handles web page fetching with simplified URL display
 502type webFetchRenderer struct {
 503	baseRenderer
 504}
 505
 506// Render displays a compact view of web_fetch with just the URL in a link style
 507func (wfr webFetchRenderer) Render(v *toolCallCmp) string {
 508	t := styles.CurrentTheme()
 509	var params tools.WebFetchParams
 510	wfr.unmarshalParams(v.call.Input, &params)
 511
 512	width := v.textWidth()
 513	if v.isNested {
 514		width -= 4 // Adjust for nested tool call indentation
 515	}
 516
 517	header := wfr.makeHeader(v, "Fetch", width)
 518	if res, done := earlyState(header, v); v.cancelled && done {
 519		return res
 520	}
 521
 522	// Display URL in a subtle, link-like style
 523	urlStyle := t.S().Muted.Foreground(t.Blue).Underline(true)
 524	urlText := urlStyle.Render(params.URL)
 525
 526	header = lipgloss.JoinHorizontal(lipgloss.Left, header, " ", urlText)
 527
 528	// If nested, return header only (no body content)
 529	if v.isNested {
 530		return v.style().Render(header)
 531	}
 532
 533	if v.result.ToolCallID == "" {
 534		v.spinning = true
 535		return lipgloss.JoinHorizontal(lipgloss.Left, header, " ", v.anim.View())
 536	}
 537
 538	v.spinning = false
 539	body := renderMarkdownContent(v, v.result.Content)
 540	return joinHeaderBody(header, body)
 541}
 542
 543// -----------------------------------------------------------------------------
 544//  Download renderer
 545// -----------------------------------------------------------------------------
 546
 547// downloadRenderer handles file downloading with URL and file path display
 548type downloadRenderer struct {
 549	baseRenderer
 550}
 551
 552// Render displays the download URL and destination file path with timeout parameter
 553func (dr downloadRenderer) Render(v *toolCallCmp) string {
 554	var params tools.DownloadParams
 555	var args []string
 556	if err := dr.unmarshalParams(v.call.Input, &params); err == nil {
 557		args = newParamBuilder().
 558			addMain(params.URL).
 559			addKeyValue("file_path", fsext.PrettyPath(params.FilePath)).
 560			addKeyValue("timeout", formatTimeout(params.Timeout)).
 561			build()
 562	}
 563
 564	return dr.renderWithParams(v, "Download", args, func() string {
 565		return renderPlainContent(v, v.result.Content)
 566	})
 567}
 568
 569// -----------------------------------------------------------------------------
 570//  Glob renderer
 571// -----------------------------------------------------------------------------
 572
 573// globRenderer handles file pattern matching with path filtering
 574type globRenderer struct {
 575	baseRenderer
 576}
 577
 578// Render displays the glob pattern with optional path parameter
 579func (gr globRenderer) Render(v *toolCallCmp) string {
 580	var params tools.GlobParams
 581	var args []string
 582	if err := gr.unmarshalParams(v.call.Input, &params); err == nil {
 583		args = newParamBuilder().
 584			addMain(params.Pattern).
 585			addKeyValue("path", params.Path).
 586			build()
 587	}
 588
 589	return gr.renderWithParams(v, "Glob", args, func() string {
 590		return renderPlainContent(v, v.result.Content)
 591	})
 592}
 593
 594// -----------------------------------------------------------------------------
 595//  Grep renderer
 596// -----------------------------------------------------------------------------
 597
 598// grepRenderer handles content searching with pattern matching options
 599type grepRenderer struct {
 600	baseRenderer
 601}
 602
 603// Render displays the search pattern with path, include, and literal text options
 604func (gr grepRenderer) Render(v *toolCallCmp) string {
 605	var params tools.GrepParams
 606	var args []string
 607	if err := gr.unmarshalParams(v.call.Input, &params); err == nil {
 608		args = newParamBuilder().
 609			addMain(params.Pattern).
 610			addKeyValue("path", params.Path).
 611			addKeyValue("include", params.Include).
 612			addFlag("literal", params.LiteralText).
 613			build()
 614	}
 615
 616	return gr.renderWithParams(v, "Grep", args, func() string {
 617		return renderPlainContent(v, v.result.Content)
 618	})
 619}
 620
 621// -----------------------------------------------------------------------------
 622//  LS renderer
 623// -----------------------------------------------------------------------------
 624
 625// lsRenderer handles directory listing with default path handling
 626type lsRenderer struct {
 627	baseRenderer
 628}
 629
 630// Render displays the directory path, defaulting to current directory
 631func (lr lsRenderer) Render(v *toolCallCmp) string {
 632	var params tools.LSParams
 633	var args []string
 634	if err := lr.unmarshalParams(v.call.Input, &params); err == nil {
 635		path := params.Path
 636		if path == "" {
 637			path = "."
 638		}
 639		path = fsext.PrettyPath(path)
 640
 641		args = newParamBuilder().addMain(path).build()
 642	}
 643
 644	return lr.renderWithParams(v, "List", args, func() string {
 645		return renderPlainContent(v, v.result.Content)
 646	})
 647}
 648
 649// -----------------------------------------------------------------------------
 650//  Sourcegraph renderer
 651// -----------------------------------------------------------------------------
 652
 653// sourcegraphRenderer handles code search with count and context options
 654type sourcegraphRenderer struct {
 655	baseRenderer
 656}
 657
 658// Render displays the search query with optional count and context window parameters
 659func (sr sourcegraphRenderer) Render(v *toolCallCmp) string {
 660	var params tools.SourcegraphParams
 661	var args []string
 662	if err := sr.unmarshalParams(v.call.Input, &params); err == nil {
 663		args = newParamBuilder().
 664			addMain(params.Query).
 665			addKeyValue("count", formatNonZero(params.Count)).
 666			addKeyValue("context", formatNonZero(params.ContextWindow)).
 667			build()
 668	}
 669
 670	return sr.renderWithParams(v, "Sourcegraph", args, func() string {
 671		return renderPlainContent(v, v.result.Content)
 672	})
 673}
 674
 675// -----------------------------------------------------------------------------
 676//  Diagnostics renderer
 677// -----------------------------------------------------------------------------
 678
 679// diagnosticsRenderer handles project-wide diagnostic information
 680type diagnosticsRenderer struct {
 681	baseRenderer
 682}
 683
 684// Render displays project diagnostics with plain content formatting
 685func (dr diagnosticsRenderer) Render(v *toolCallCmp) string {
 686	args := newParamBuilder().addMain("project").build()
 687
 688	return dr.renderWithParams(v, "Diagnostics", args, func() string {
 689		return renderPlainContent(v, v.result.Content)
 690	})
 691}
 692
 693// -----------------------------------------------------------------------------
 694//  Task renderer
 695// -----------------------------------------------------------------------------
 696
 697// agentRenderer handles project-wide diagnostic information
 698type agentRenderer struct {
 699	baseRenderer
 700}
 701
 702func RoundedEnumerator(children tree.Children, index int) string {
 703	if children.Length()-1 == index {
 704		return " ╰──"
 705	}
 706	return " ├──"
 707}
 708
 709// Render displays agent task parameters and result content
 710func (tr agentRenderer) Render(v *toolCallCmp) string {
 711	t := styles.CurrentTheme()
 712	var params agent.AgentParams
 713	tr.unmarshalParams(v.call.Input, &params)
 714
 715	prompt := params.Prompt
 716	prompt = strings.ReplaceAll(prompt, "\n", " ")
 717
 718	header := tr.makeHeader(v, "Agent", v.textWidth())
 719	if res, done := earlyState(header, v); v.cancelled && done {
 720		return res
 721	}
 722	taskTag := t.S().Base.Padding(0, 1).MarginLeft(1).Background(t.BlueLight).Foreground(t.White).Render("Task")
 723	remainingWidth := v.textWidth() - lipgloss.Width(header) - lipgloss.Width(taskTag) - 2
 724	remainingWidth = min(remainingWidth, 120-lipgloss.Width(header)-lipgloss.Width(taskTag)-2)
 725	prompt = t.S().Muted.Width(remainingWidth).Render(prompt)
 726	header = lipgloss.JoinVertical(
 727		lipgloss.Left,
 728		header,
 729		"",
 730		lipgloss.JoinHorizontal(
 731			lipgloss.Left,
 732			taskTag,
 733			" ",
 734			prompt,
 735		),
 736	)
 737	childTools := tree.Root(header)
 738
 739	for _, call := range v.nestedToolCalls {
 740		childTools.Child(call.View())
 741	}
 742	parts := []string{
 743		childTools.Enumerator(RoundedEnumerator).String(),
 744	}
 745
 746	if v.result.ToolCallID == "" {
 747		v.spinning = true
 748		parts = append(parts, "", v.anim.View())
 749	} else {
 750		v.spinning = false
 751	}
 752
 753	header = lipgloss.JoinVertical(
 754		lipgloss.Left,
 755		parts...,
 756	)
 757
 758	if v.result.ToolCallID == "" {
 759		return header
 760	}
 761
 762	body := renderMarkdownContent(v, v.result.Content)
 763	return joinHeaderBody(header, body)
 764}
 765
 766// renderParamList renders params, params[0] (params[1]=params[2] ....)
 767func renderParamList(nested bool, paramsWidth int, params ...string) string {
 768	t := styles.CurrentTheme()
 769	if len(params) == 0 {
 770		return ""
 771	}
 772	mainParam := params[0]
 773	if paramsWidth >= 0 && lipgloss.Width(mainParam) > paramsWidth {
 774		mainParam = ansi.Truncate(mainParam, paramsWidth, "…")
 775	}
 776
 777	if len(params) == 1 {
 778		if nested {
 779			return t.S().Muted.Render(mainParam)
 780		}
 781		return t.S().Subtle.Render(mainParam)
 782	}
 783	otherParams := params[1:]
 784	// create pairs of key/value
 785	// if odd number of params, the last one is a key without value
 786	if len(otherParams)%2 != 0 {
 787		otherParams = append(otherParams, "")
 788	}
 789	parts := make([]string, 0, len(otherParams)/2)
 790	for i := 0; i < len(otherParams); i += 2 {
 791		key := otherParams[i]
 792		value := otherParams[i+1]
 793		if value == "" {
 794			continue
 795		}
 796		parts = append(parts, fmt.Sprintf("%s=%s", key, value))
 797	}
 798
 799	partsRendered := strings.Join(parts, ", ")
 800	remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 3 // count for " ()"
 801	if remainingWidth < 30 {
 802		if nested {
 803			return t.S().Muted.Render(mainParam)
 804		}
 805		// No space for the params, just show the main
 806		return t.S().Subtle.Render(mainParam)
 807	}
 808
 809	if len(parts) > 0 {
 810		mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
 811	}
 812
 813	if nested {
 814		return t.S().Muted.Render(ansi.Truncate(mainParam, paramsWidth, "…"))
 815	}
 816	return t.S().Subtle.Render(ansi.Truncate(mainParam, paramsWidth, "…"))
 817}
 818
 819// earlyState returns immediately‑rendered error/cancelled/ongoing states.
 820func earlyState(header string, v *toolCallCmp) (string, bool) {
 821	t := styles.CurrentTheme()
 822	message := ""
 823	switch {
 824	case v.result.IsError:
 825		message = v.renderToolError()
 826	case v.cancelled:
 827		message = t.S().Base.Foreground(t.FgSubtle).Render("Canceled.")
 828	case v.result.ToolCallID == "":
 829		if v.permissionRequested && !v.permissionGranted {
 830			message = t.S().Base.Foreground(t.FgSubtle).Render("Requesting for permission...")
 831		} else {
 832			message = t.S().Base.Foreground(t.FgSubtle).Render("Waiting for tool response...")
 833		}
 834	default:
 835		return "", false
 836	}
 837
 838	message = t.S().Base.PaddingLeft(2).Render(message)
 839	return lipgloss.JoinVertical(lipgloss.Left, header, "", message), true
 840}
 841
 842func joinHeaderBody(header, body string) string {
 843	t := styles.CurrentTheme()
 844	if body == "" {
 845		return header
 846	}
 847	body = t.S().Base.PaddingLeft(2).Render(body)
 848	return lipgloss.JoinVertical(lipgloss.Left, header, "", body)
 849}
 850
 851func renderPlainContent(v *toolCallCmp, content string) string {
 852	t := styles.CurrentTheme()
 853	content = strings.ReplaceAll(content, "\r\n", "\n") // Normalize line endings
 854	content = strings.ReplaceAll(content, "\t", "    ") // Replace tabs with spaces
 855	content = strings.TrimSpace(content)
 856	lines := strings.Split(content, "\n")
 857
 858	width := v.textWidth() - 2
 859	var out []string
 860	for i, ln := range lines {
 861		if i >= responseContextHeight {
 862			break
 863		}
 864		ln = ansiext.Escape(ln)
 865		ln = " " + ln
 866		if len(ln) > width {
 867			ln = v.fit(ln, width)
 868		}
 869		out = append(out, t.S().Muted.
 870			Width(width).
 871			Background(t.BgBaseLighter).
 872			Render(ln))
 873	}
 874
 875	if len(lines) > responseContextHeight {
 876		out = append(out, t.S().Muted.
 877			Background(t.BgBaseLighter).
 878			Width(width).
 879			Render(fmt.Sprintf("… (%d lines)", len(lines)-responseContextHeight)))
 880	}
 881
 882	return strings.Join(out, "\n")
 883}
 884
 885func renderMarkdownContent(v *toolCallCmp, content string) string {
 886	t := styles.CurrentTheme()
 887	content = strings.ReplaceAll(content, "\r\n", "\n")
 888	content = strings.ReplaceAll(content, "\t", "    ")
 889	content = strings.TrimSpace(content)
 890
 891	width := v.textWidth() - 2
 892	width = min(width, 120)
 893
 894	renderer := styles.GetPlainMarkdownRenderer(width - 2)
 895	rendered, err := renderer.Render(content)
 896	if err != nil {
 897		return renderPlainContent(v, content)
 898	}
 899
 900	lines := strings.Split(rendered, "\n")
 901
 902	var out []string
 903	for i, ln := range lines {
 904		if i >= responseContextHeight {
 905			break
 906		}
 907		out = append(out, ln)
 908	}
 909
 910	style := t.S().Muted.Background(t.BgBaseLighter)
 911	if len(lines) > responseContextHeight {
 912		out = append(out, style.
 913			Width(width-2).
 914			Render(fmt.Sprintf("… (%d lines)", len(lines)-responseContextHeight)))
 915	}
 916
 917	return style.PaddingLeft(1).PaddingRight(1).Render(strings.Join(out, "\n"))
 918}
 919
 920func getDigits(n int) int {
 921	if n == 0 {
 922		return 1
 923	}
 924	if n < 0 {
 925		n = -n
 926	}
 927
 928	digits := 0
 929	for n > 0 {
 930		n /= 10
 931		digits++
 932	}
 933
 934	return digits
 935}
 936
 937func renderCodeContent(v *toolCallCmp, path, content string, offset int) string {
 938	t := styles.CurrentTheme()
 939	content = strings.ReplaceAll(content, "\r\n", "\n") // Normalize line endings
 940	content = strings.ReplaceAll(content, "\t", "    ") // Replace tabs with spaces
 941	truncated := truncateHeight(content, responseContextHeight)
 942
 943	lines := strings.Split(truncated, "\n")
 944	for i, ln := range lines {
 945		lines[i] = ansiext.Escape(ln)
 946	}
 947
 948	bg := t.BgBase
 949	highlighted, _ := highlight.SyntaxHighlight(strings.Join(lines, "\n"), path, bg)
 950	lines = strings.Split(highlighted, "\n")
 951
 952	if len(strings.Split(content, "\n")) > responseContextHeight {
 953		lines = append(lines, t.S().Muted.
 954			Background(bg).
 955			Render(fmt.Sprintf(" …(%d lines)", len(strings.Split(content, "\n"))-responseContextHeight)))
 956	}
 957
 958	maxLineNumber := len(lines) + offset
 959	maxDigits := getDigits(maxLineNumber)
 960	numFmt := fmt.Sprintf("%%%dd", maxDigits)
 961	const numPR, numPL, codePR, codePL = 1, 1, 1, 2
 962	w := v.textWidth() - maxDigits - numPL - numPR - 2 // -2 for left padding
 963	for i, ln := range lines {
 964		num := t.S().Base.
 965			Foreground(t.FgMuted).
 966			Background(t.BgBase).
 967			PaddingRight(1).
 968			PaddingLeft(1).
 969			Render(fmt.Sprintf(numFmt, i+1+offset))
 970		lines[i] = lipgloss.JoinHorizontal(lipgloss.Left,
 971			num,
 972			t.S().Base.
 973				Width(w).
 974				Background(bg).
 975				PaddingRight(1).
 976				PaddingLeft(2).
 977				Render(v.fit(ln, w-codePL-codePR)),
 978		)
 979	}
 980
 981	return lipgloss.JoinVertical(lipgloss.Left, lines...)
 982}
 983
 984func (v *toolCallCmp) renderToolError() string {
 985	t := styles.CurrentTheme()
 986	err := strings.ReplaceAll(v.result.Content, "\n", " ")
 987	errTag := t.S().Base.Padding(0, 1).Background(t.Red).Foreground(t.White).Render("ERROR")
 988	err = fmt.Sprintf("%s %s", errTag, t.S().Base.Foreground(t.FgHalfMuted).Render(v.fit(err, v.textWidth()-2-lipgloss.Width(errTag))))
 989	return err
 990}
 991
 992func truncateHeight(s string, h int) string {
 993	lines := strings.Split(s, "\n")
 994	if len(lines) > h {
 995		return strings.Join(lines[:h], "\n")
 996	}
 997	return s
 998}
 999
1000func prettifyToolName(name string) string {
1001	switch name {
1002	case agent.AgentToolName:
1003		return "Agent"
1004	case tools.BashToolName:
1005		return "Bash"
1006	case tools.DownloadToolName:
1007		return "Download"
1008	case tools.EditToolName:
1009		return "Edit"
1010	case tools.MultiEditToolName:
1011		return "Multi-Edit"
1012	case tools.FetchToolName:
1013		return "Fetch"
1014	case tools.WebFetchToolName:
1015		return "Fetching"
1016	case tools.GlobToolName:
1017		return "Glob"
1018	case tools.GrepToolName:
1019		return "Grep"
1020	case tools.LSToolName:
1021		return "List"
1022	case tools.SourcegraphToolName:
1023		return "Sourcegraph"
1024	case tools.ViewToolName:
1025		return "View"
1026	case tools.WriteToolName:
1027		return "Write"
1028	default:
1029		return name
1030	}
1031}