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