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
 370		// Add failed edits warning if any exist
 371		if len(meta.EditsFailed) > 0 {
 372			noteTag := t.S().Base.Padding(0, 2).Background(t.Info).Foreground(t.White).Render("Note")
 373			noteMsg := fmt.Sprintf("%d of %d edits succeeded", meta.EditsApplied, len(params.Edits))
 374			note := t.S().Base.
 375				Width(v.textWidth() - 2).
 376				Render(fmt.Sprintf("%s %s", noteTag, t.S().Muted.Render(noteMsg)))
 377			formatted = lipgloss.JoinVertical(lipgloss.Left, formatted, "", note)
 378		}
 379
 380		return formatted
 381	})
 382}
 383
 384// -----------------------------------------------------------------------------
 385//  Write renderer
 386// -----------------------------------------------------------------------------
 387
 388// writeRenderer handles file writing with syntax-highlighted content preview
 389type writeRenderer struct {
 390	baseRenderer
 391}
 392
 393// Render displays the file being written with syntax highlighting
 394func (wr writeRenderer) Render(v *toolCallCmp) string {
 395	var params tools.WriteParams
 396	var args []string
 397	var file string
 398	if err := wr.unmarshalParams(v.call.Input, &params); err == nil {
 399		file = fsext.PrettyPath(params.FilePath)
 400		args = newParamBuilder().addMain(file).build()
 401	}
 402
 403	return wr.renderWithParams(v, "Write", args, func() string {
 404		return renderCodeContent(v, file, params.Content, 0)
 405	})
 406}
 407
 408// -----------------------------------------------------------------------------
 409//  Fetch renderer
 410// -----------------------------------------------------------------------------
 411
 412// simpleFetchRenderer handles URL fetching with format-specific content display
 413type simpleFetchRenderer struct {
 414	baseRenderer
 415}
 416
 417// Render displays the fetched URL with format and timeout parameters
 418func (fr simpleFetchRenderer) Render(v *toolCallCmp) string {
 419	var params tools.FetchParams
 420	var args []string
 421	if err := fr.unmarshalParams(v.call.Input, &params); err == nil {
 422		args = newParamBuilder().
 423			addMain(params.URL).
 424			addKeyValue("format", params.Format).
 425			addKeyValue("timeout", formatTimeout(params.Timeout)).
 426			build()
 427	}
 428
 429	return fr.renderWithParams(v, "Fetch", args, func() string {
 430		file := fr.getFileExtension(params.Format)
 431		return renderCodeContent(v, file, v.result.Content, 0)
 432	})
 433}
 434
 435// getFileExtension returns appropriate file extension for syntax highlighting
 436func (fr simpleFetchRenderer) getFileExtension(format string) string {
 437	switch format {
 438	case "text":
 439		return "fetch.txt"
 440	case "html":
 441		return "fetch.html"
 442	default:
 443		return "fetch.md"
 444	}
 445}
 446
 447// -----------------------------------------------------------------------------
 448//  Agentic fetch renderer
 449// -----------------------------------------------------------------------------
 450
 451// agenticFetchRenderer handles URL fetching with prompt parameter and nested tool calls
 452type agenticFetchRenderer struct {
 453	baseRenderer
 454}
 455
 456// Render displays the fetched URL with prompt parameter and nested tool calls
 457func (fr agenticFetchRenderer) Render(v *toolCallCmp) string {
 458	t := styles.CurrentTheme()
 459	var params tools.AgenticFetchParams
 460	var args []string
 461	if err := fr.unmarshalParams(v.call.Input, &params); err == nil {
 462		args = newParamBuilder().
 463			addMain(params.URL).
 464			build()
 465	}
 466
 467	prompt := params.Prompt
 468	prompt = strings.ReplaceAll(prompt, "\n", " ")
 469
 470	header := fr.makeHeader(v, "Agentic Fetch", v.textWidth(), args...)
 471	if res, done := earlyState(header, v); v.cancelled && done {
 472		return res
 473	}
 474
 475	taskTag := t.S().Base.Bold(true).Padding(0, 1).MarginLeft(2).Background(t.GreenLight).Foreground(t.Border).Render("Prompt")
 476	remainingWidth := v.textWidth() - (lipgloss.Width(taskTag) + 1)
 477	remainingWidth = min(remainingWidth, 120-(lipgloss.Width(taskTag)+1))
 478	prompt = t.S().Base.Width(remainingWidth).Render(prompt)
 479	header = lipgloss.JoinVertical(
 480		lipgloss.Left,
 481		header,
 482		"",
 483		lipgloss.JoinHorizontal(
 484			lipgloss.Left,
 485			taskTag,
 486			" ",
 487			prompt,
 488		),
 489	)
 490	childTools := tree.Root(header)
 491
 492	for _, call := range v.nestedToolCalls {
 493		call.SetSize(remainingWidth, 1)
 494		childTools.Child(call.View())
 495	}
 496	parts := []string{
 497		childTools.Enumerator(RoundedEnumeratorWithWidth(2, lipgloss.Width(taskTag)-5)).String(),
 498	}
 499
 500	if v.result.ToolCallID == "" {
 501		v.spinning = true
 502		parts = append(parts, "", v.anim.View())
 503	} else {
 504		v.spinning = false
 505	}
 506
 507	header = lipgloss.JoinVertical(
 508		lipgloss.Left,
 509		parts...,
 510	)
 511
 512	if v.result.ToolCallID == "" {
 513		return header
 514	}
 515	body := renderMarkdownContent(v, v.result.Content)
 516	return joinHeaderBody(header, body)
 517}
 518
 519// formatTimeout converts timeout seconds to duration string
 520func formatTimeout(timeout int) string {
 521	if timeout == 0 {
 522		return ""
 523	}
 524	return (time.Duration(timeout) * time.Second).String()
 525}
 526
 527// -----------------------------------------------------------------------------
 528//  Web fetch renderer
 529// -----------------------------------------------------------------------------
 530
 531// webFetchRenderer handles web page fetching with simplified URL display
 532type webFetchRenderer struct {
 533	baseRenderer
 534}
 535
 536// Render displays a compact view of web_fetch with just the URL in a link style
 537func (wfr webFetchRenderer) Render(v *toolCallCmp) string {
 538	var params tools.WebFetchParams
 539	var args []string
 540	if err := wfr.unmarshalParams(v.call.Input, &params); err == nil {
 541		args = newParamBuilder().
 542			addMain(params.URL).
 543			build()
 544	}
 545
 546	return wfr.renderWithParams(v, "Fetch", args, func() string {
 547		return renderMarkdownContent(v, v.result.Content)
 548	})
 549}
 550
 551// -----------------------------------------------------------------------------
 552//  Download renderer
 553// -----------------------------------------------------------------------------
 554
 555// downloadRenderer handles file downloading with URL and file path display
 556type downloadRenderer struct {
 557	baseRenderer
 558}
 559
 560// Render displays the download URL and destination file path with timeout parameter
 561func (dr downloadRenderer) Render(v *toolCallCmp) string {
 562	var params tools.DownloadParams
 563	var args []string
 564	if err := dr.unmarshalParams(v.call.Input, &params); err == nil {
 565		args = newParamBuilder().
 566			addMain(params.URL).
 567			addKeyValue("file_path", fsext.PrettyPath(params.FilePath)).
 568			addKeyValue("timeout", formatTimeout(params.Timeout)).
 569			build()
 570	}
 571
 572	return dr.renderWithParams(v, "Download", args, func() string {
 573		return renderPlainContent(v, v.result.Content)
 574	})
 575}
 576
 577// -----------------------------------------------------------------------------
 578//  Glob renderer
 579// -----------------------------------------------------------------------------
 580
 581// globRenderer handles file pattern matching with path filtering
 582type globRenderer struct {
 583	baseRenderer
 584}
 585
 586// Render displays the glob pattern with optional path parameter
 587func (gr globRenderer) Render(v *toolCallCmp) string {
 588	var params tools.GlobParams
 589	var args []string
 590	if err := gr.unmarshalParams(v.call.Input, &params); err == nil {
 591		args = newParamBuilder().
 592			addMain(params.Pattern).
 593			addKeyValue("path", params.Path).
 594			build()
 595	}
 596
 597	return gr.renderWithParams(v, "Glob", args, func() string {
 598		return renderPlainContent(v, v.result.Content)
 599	})
 600}
 601
 602// -----------------------------------------------------------------------------
 603//  Grep renderer
 604// -----------------------------------------------------------------------------
 605
 606// grepRenderer handles content searching with pattern matching options
 607type grepRenderer struct {
 608	baseRenderer
 609}
 610
 611// Render displays the search pattern with path, include, and literal text options
 612func (gr grepRenderer) Render(v *toolCallCmp) string {
 613	var params tools.GrepParams
 614	var args []string
 615	if err := gr.unmarshalParams(v.call.Input, &params); err == nil {
 616		args = newParamBuilder().
 617			addMain(params.Pattern).
 618			addKeyValue("path", params.Path).
 619			addKeyValue("include", params.Include).
 620			addFlag("literal", params.LiteralText).
 621			build()
 622	}
 623
 624	return gr.renderWithParams(v, "Grep", args, func() string {
 625		return renderPlainContent(v, v.result.Content)
 626	})
 627}
 628
 629// -----------------------------------------------------------------------------
 630//  LS renderer
 631// -----------------------------------------------------------------------------
 632
 633// lsRenderer handles directory listing with default path handling
 634type lsRenderer struct {
 635	baseRenderer
 636}
 637
 638// Render displays the directory path, defaulting to current directory
 639func (lr lsRenderer) Render(v *toolCallCmp) string {
 640	var params tools.LSParams
 641	var args []string
 642	if err := lr.unmarshalParams(v.call.Input, &params); err == nil {
 643		path := params.Path
 644		if path == "" {
 645			path = "."
 646		}
 647		path = fsext.PrettyPath(path)
 648
 649		args = newParamBuilder().addMain(path).build()
 650	}
 651
 652	return lr.renderWithParams(v, "List", args, func() string {
 653		return renderPlainContent(v, v.result.Content)
 654	})
 655}
 656
 657// -----------------------------------------------------------------------------
 658//  Sourcegraph renderer
 659// -----------------------------------------------------------------------------
 660
 661// sourcegraphRenderer handles code search with count and context options
 662type sourcegraphRenderer struct {
 663	baseRenderer
 664}
 665
 666// Render displays the search query with optional count and context window parameters
 667func (sr sourcegraphRenderer) Render(v *toolCallCmp) string {
 668	var params tools.SourcegraphParams
 669	var args []string
 670	if err := sr.unmarshalParams(v.call.Input, &params); err == nil {
 671		args = newParamBuilder().
 672			addMain(params.Query).
 673			addKeyValue("count", formatNonZero(params.Count)).
 674			addKeyValue("context", formatNonZero(params.ContextWindow)).
 675			build()
 676	}
 677
 678	return sr.renderWithParams(v, "Sourcegraph", args, func() string {
 679		return renderPlainContent(v, v.result.Content)
 680	})
 681}
 682
 683// -----------------------------------------------------------------------------
 684//  Diagnostics renderer
 685// -----------------------------------------------------------------------------
 686
 687// diagnosticsRenderer handles project-wide diagnostic information
 688type diagnosticsRenderer struct {
 689	baseRenderer
 690}
 691
 692// Render displays project diagnostics with plain content formatting
 693func (dr diagnosticsRenderer) Render(v *toolCallCmp) string {
 694	args := newParamBuilder().addMain("project").build()
 695
 696	return dr.renderWithParams(v, "Diagnostics", args, func() string {
 697		return renderPlainContent(v, v.result.Content)
 698	})
 699}
 700
 701// -----------------------------------------------------------------------------
 702//  Task renderer
 703// -----------------------------------------------------------------------------
 704
 705// agentRenderer handles project-wide diagnostic information
 706type agentRenderer struct {
 707	baseRenderer
 708}
 709
 710func RoundedEnumeratorWithWidth(lPadding, width int) tree.Enumerator {
 711	if width == 0 {
 712		width = 2
 713	}
 714	if lPadding == 0 {
 715		lPadding = 1
 716	}
 717	return func(children tree.Children, index int) string {
 718		line := strings.Repeat("─", width)
 719		padding := strings.Repeat(" ", lPadding)
 720		if children.Length()-1 == index {
 721			return padding + "╰" + line
 722		}
 723		return padding + "├" + line
 724	}
 725}
 726
 727// Render displays agent task parameters and result content
 728func (tr agentRenderer) Render(v *toolCallCmp) string {
 729	t := styles.CurrentTheme()
 730	var params agent.AgentParams
 731	tr.unmarshalParams(v.call.Input, &params)
 732
 733	prompt := params.Prompt
 734	prompt = strings.ReplaceAll(prompt, "\n", " ")
 735
 736	header := tr.makeHeader(v, "Agent", v.textWidth())
 737	if res, done := earlyState(header, v); v.cancelled && done {
 738		return res
 739	}
 740	taskTag := t.S().Base.Bold(true).Padding(0, 1).MarginLeft(2).Background(t.BlueLight).Foreground(t.White).Render("Task")
 741	remainingWidth := v.textWidth() - lipgloss.Width(header) - lipgloss.Width(taskTag) - 2
 742	remainingWidth = min(remainingWidth, 120-lipgloss.Width(taskTag)-2)
 743	prompt = t.S().Muted.Width(remainingWidth).Render(prompt)
 744	header = lipgloss.JoinVertical(
 745		lipgloss.Left,
 746		header,
 747		"",
 748		lipgloss.JoinHorizontal(
 749			lipgloss.Left,
 750			taskTag,
 751			" ",
 752			prompt,
 753		),
 754	)
 755	childTools := tree.Root(header)
 756
 757	for _, call := range v.nestedToolCalls {
 758		call.SetSize(remainingWidth, 1)
 759		childTools.Child(call.View())
 760	}
 761	parts := []string{
 762		childTools.Enumerator(RoundedEnumeratorWithWidth(2, lipgloss.Width(taskTag)-5)).String(),
 763	}
 764
 765	if v.result.ToolCallID == "" {
 766		v.spinning = true
 767		parts = append(parts, "", v.anim.View())
 768	} else {
 769		v.spinning = false
 770	}
 771
 772	header = lipgloss.JoinVertical(
 773		lipgloss.Left,
 774		parts...,
 775	)
 776
 777	if v.result.ToolCallID == "" {
 778		return header
 779	}
 780
 781	body := renderMarkdownContent(v, v.result.Content)
 782	return joinHeaderBody(header, body)
 783}
 784
 785// renderParamList renders params, params[0] (params[1]=params[2] ....)
 786func renderParamList(nested bool, paramsWidth int, params ...string) string {
 787	t := styles.CurrentTheme()
 788	if len(params) == 0 {
 789		return ""
 790	}
 791	mainParam := params[0]
 792	if paramsWidth >= 0 && lipgloss.Width(mainParam) > paramsWidth {
 793		mainParam = ansi.Truncate(mainParam, paramsWidth, "…")
 794	}
 795
 796	if len(params) == 1 {
 797		return t.S().Subtle.Render(mainParam)
 798	}
 799	otherParams := params[1:]
 800	// create pairs of key/value
 801	// if odd number of params, the last one is a key without value
 802	if len(otherParams)%2 != 0 {
 803		otherParams = append(otherParams, "")
 804	}
 805	parts := make([]string, 0, len(otherParams)/2)
 806	for i := 0; i < len(otherParams); i += 2 {
 807		key := otherParams[i]
 808		value := otherParams[i+1]
 809		if value == "" {
 810			continue
 811		}
 812		parts = append(parts, fmt.Sprintf("%s=%s", key, value))
 813	}
 814
 815	partsRendered := strings.Join(parts, ", ")
 816	remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 3 // count for " ()"
 817	if remainingWidth < 30 {
 818		// No space for the params, just show the main
 819		return t.S().Subtle.Render(mainParam)
 820	}
 821
 822	if len(parts) > 0 {
 823		mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
 824	}
 825
 826	return t.S().Subtle.Render(ansi.Truncate(mainParam, paramsWidth, "…"))
 827}
 828
 829// earlyState returns immediately‑rendered error/cancelled/ongoing states.
 830func earlyState(header string, v *toolCallCmp) (string, bool) {
 831	t := styles.CurrentTheme()
 832	message := ""
 833	switch {
 834	case v.result.IsError:
 835		message = v.renderToolError()
 836	case v.cancelled:
 837		message = t.S().Base.Foreground(t.FgSubtle).Render("Canceled.")
 838	case v.result.ToolCallID == "":
 839		if v.permissionRequested && !v.permissionGranted {
 840			message = t.S().Base.Foreground(t.FgSubtle).Render("Requesting for permission...")
 841		} else {
 842			message = t.S().Base.Foreground(t.FgSubtle).Render("Waiting for tool response...")
 843		}
 844	default:
 845		return "", false
 846	}
 847
 848	message = t.S().Base.PaddingLeft(2).Render(message)
 849	return lipgloss.JoinVertical(lipgloss.Left, header, "", message), true
 850}
 851
 852func joinHeaderBody(header, body string) string {
 853	t := styles.CurrentTheme()
 854	if body == "" {
 855		return header
 856	}
 857	body = t.S().Base.PaddingLeft(2).Render(body)
 858	return lipgloss.JoinVertical(lipgloss.Left, header, "", body)
 859}
 860
 861func renderPlainContent(v *toolCallCmp, content string) string {
 862	t := styles.CurrentTheme()
 863	content = strings.ReplaceAll(content, "\r\n", "\n") // Normalize line endings
 864	content = strings.ReplaceAll(content, "\t", "    ") // Replace tabs with spaces
 865	content = strings.TrimSpace(content)
 866	lines := strings.Split(content, "\n")
 867
 868	width := v.textWidth() - 2
 869	var out []string
 870	for i, ln := range lines {
 871		if i >= responseContextHeight {
 872			break
 873		}
 874		ln = ansiext.Escape(ln)
 875		ln = " " + ln
 876		if len(ln) > width {
 877			ln = v.fit(ln, width)
 878		}
 879		out = append(out, t.S().Muted.
 880			Width(width).
 881			Background(t.BgBaseLighter).
 882			Render(ln))
 883	}
 884
 885	if len(lines) > responseContextHeight {
 886		out = append(out, t.S().Muted.
 887			Background(t.BgBaseLighter).
 888			Width(width).
 889			Render(fmt.Sprintf("… (%d lines)", len(lines)-responseContextHeight)))
 890	}
 891
 892	return strings.Join(out, "\n")
 893}
 894
 895func renderMarkdownContent(v *toolCallCmp, content string) string {
 896	t := styles.CurrentTheme()
 897	content = strings.ReplaceAll(content, "\r\n", "\n")
 898	content = strings.ReplaceAll(content, "\t", "    ")
 899	content = strings.TrimSpace(content)
 900
 901	width := v.textWidth() - 2
 902	width = min(width, 120)
 903
 904	renderer := styles.GetPlainMarkdownRenderer(width)
 905	rendered, err := renderer.Render(content)
 906	if err != nil {
 907		return renderPlainContent(v, content)
 908	}
 909
 910	lines := strings.Split(rendered, "\n")
 911
 912	var out []string
 913	for i, ln := range lines {
 914		if i >= responseContextHeight {
 915			break
 916		}
 917		out = append(out, ln)
 918	}
 919
 920	style := t.S().Muted.Background(t.BgBaseLighter)
 921	if len(lines) > responseContextHeight {
 922		out = append(out, style.
 923			Width(width-2).
 924			Render(fmt.Sprintf("… (%d lines)", len(lines)-responseContextHeight)))
 925	}
 926
 927	return style.Render(strings.Join(out, "\n"))
 928}
 929
 930func getDigits(n int) int {
 931	if n == 0 {
 932		return 1
 933	}
 934	if n < 0 {
 935		n = -n
 936	}
 937
 938	digits := 0
 939	for n > 0 {
 940		n /= 10
 941		digits++
 942	}
 943
 944	return digits
 945}
 946
 947func renderCodeContent(v *toolCallCmp, path, content string, offset int) string {
 948	t := styles.CurrentTheme()
 949	content = strings.ReplaceAll(content, "\r\n", "\n") // Normalize line endings
 950	content = strings.ReplaceAll(content, "\t", "    ") // Replace tabs with spaces
 951	truncated := truncateHeight(content, responseContextHeight)
 952
 953	lines := strings.Split(truncated, "\n")
 954	for i, ln := range lines {
 955		lines[i] = ansiext.Escape(ln)
 956	}
 957
 958	bg := t.BgBase
 959	highlighted, _ := highlight.SyntaxHighlight(strings.Join(lines, "\n"), path, bg)
 960	lines = strings.Split(highlighted, "\n")
 961
 962	if len(strings.Split(content, "\n")) > responseContextHeight {
 963		lines = append(lines, t.S().Muted.
 964			Background(bg).
 965			Render(fmt.Sprintf(" …(%d lines)", len(strings.Split(content, "\n"))-responseContextHeight)))
 966	}
 967
 968	maxLineNumber := len(lines) + offset
 969	maxDigits := getDigits(maxLineNumber)
 970	numFmt := fmt.Sprintf("%%%dd", maxDigits)
 971	const numPR, numPL, codePR, codePL = 1, 1, 1, 2
 972	w := v.textWidth() - maxDigits - numPL - numPR - 2 // -2 for left padding
 973	for i, ln := range lines {
 974		num := t.S().Base.
 975			Foreground(t.FgMuted).
 976			Background(t.BgBase).
 977			PaddingRight(1).
 978			PaddingLeft(1).
 979			Render(fmt.Sprintf(numFmt, i+1+offset))
 980		lines[i] = lipgloss.JoinHorizontal(lipgloss.Left,
 981			num,
 982			t.S().Base.
 983				Width(w).
 984				Background(bg).
 985				PaddingRight(1).
 986				PaddingLeft(2).
 987				Render(v.fit(ln, w-codePL-codePR)),
 988		)
 989	}
 990
 991	return lipgloss.JoinVertical(lipgloss.Left, lines...)
 992}
 993
 994func (v *toolCallCmp) renderToolError() string {
 995	t := styles.CurrentTheme()
 996	err := strings.ReplaceAll(v.result.Content, "\n", " ")
 997	errTag := t.S().Base.Padding(0, 1).Background(t.Red).Foreground(t.White).Render("ERROR")
 998	err = fmt.Sprintf("%s %s", errTag, t.S().Base.Foreground(t.FgHalfMuted).Render(v.fit(err, v.textWidth()-2-lipgloss.Width(errTag))))
 999	return err
1000}
1001
1002func truncateHeight(s string, h int) string {
1003	lines := strings.Split(s, "\n")
1004	if len(lines) > h {
1005		return strings.Join(lines[:h], "\n")
1006	}
1007	return s
1008}
1009
1010func prettifyToolName(name string) string {
1011	switch name {
1012	case agent.AgentToolName:
1013		return "Agent"
1014	case tools.BashToolName:
1015		return "Bash"
1016	case tools.DownloadToolName:
1017		return "Download"
1018	case tools.EditToolName:
1019		return "Edit"
1020	case tools.MultiEditToolName:
1021		return "Multi-Edit"
1022	case tools.FetchToolName:
1023		return "Fetch"
1024	case tools.AgenticFetchToolName:
1025		return "Agentic Fetch"
1026	case tools.WebFetchToolName:
1027		return "Fetching"
1028	case tools.GlobToolName:
1029		return "Glob"
1030	case tools.GrepToolName:
1031		return "Grep"
1032	case tools.LSToolName:
1033		return "List"
1034	case tools.SourcegraphToolName:
1035		return "Sourcegraph"
1036	case tools.ViewToolName:
1037		return "View"
1038	case tools.WriteToolName:
1039		return "Write"
1040	default:
1041		return name
1042	}
1043}