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