renderer.go

   1package messages
   2
   3import (
   4	"cmp"
   5	"encoding/json"
   6	"fmt"
   7	"strings"
   8	"time"
   9
  10	"charm.land/lipgloss/v2"
  11	"charm.land/lipgloss/v2/tree"
  12	"github.com/charmbracelet/crush/internal/agent"
  13	"github.com/charmbracelet/crush/internal/agent/tools"
  14	"github.com/charmbracelet/crush/internal/ansiext"
  15	"github.com/charmbracelet/crush/internal/fsext"
  16	"github.com/charmbracelet/crush/internal/tui/components/core"
  17	"github.com/charmbracelet/crush/internal/tui/highlight"
  18	"github.com/charmbracelet/crush/internal/tui/styles"
  19	"github.com/charmbracelet/x/ansi"
  20)
  21
  22// responseContextHeight limits the number of lines displayed in tool output
  23const responseContextHeight = 10
  24
  25// renderer defines the interface for tool-specific rendering implementations
  26type renderer interface {
  27	// Render returns the complete (already styled) tool‑call view, not
  28	// including the outer border.
  29	Render(v *toolCallCmp) string
  30}
  31
  32// rendererFactory creates new renderer instances
  33type rendererFactory func() renderer
  34
  35// renderRegistry manages the mapping of tool names to their renderers
  36type renderRegistry map[string]rendererFactory
  37
  38// register adds a new renderer factory to the registry
  39func (rr renderRegistry) register(name string, f rendererFactory) { rr[name] = f }
  40
  41// lookup retrieves a renderer for the given tool name, falling back to generic renderer
  42func (rr renderRegistry) lookup(name string) renderer {
  43	if f, ok := rr[name]; ok {
  44		return f()
  45	}
  46	return genericRenderer{} // sensible fallback
  47}
  48
  49// registry holds all registered tool renderers
  50var registry = renderRegistry{}
  51
  52// baseRenderer provides common functionality for all tool renderers
  53type baseRenderer struct{}
  54
  55func (br baseRenderer) Render(v *toolCallCmp) string {
  56	if v.result.Data != "" {
  57		if strings.HasPrefix(v.result.MIMEType, "image/") {
  58			return br.renderWithParams(v, v.call.Name, nil, func() string {
  59				return renderImageContent(v, v.result.Data, v.result.MIMEType, v.result.Content)
  60			})
  61		}
  62		return br.renderWithParams(v, v.call.Name, nil, func() string {
  63			return renderMediaContent(v, v.result.MIMEType, v.result.Content)
  64		})
  65	}
  66
  67	return br.renderWithParams(v, v.call.Name, nil, func() string {
  68		return renderPlainContent(v, v.result.Content)
  69	})
  70}
  71
  72// paramBuilder helps construct parameter lists for tool headers
  73type paramBuilder struct {
  74	args []string
  75}
  76
  77// newParamBuilder creates a new parameter builder
  78func newParamBuilder() *paramBuilder {
  79	return &paramBuilder{args: make([]string, 0)}
  80}
  81
  82// addMain adds the main parameter (first argument)
  83func (pb *paramBuilder) addMain(value string) *paramBuilder {
  84	if value != "" {
  85		pb.args = append(pb.args, value)
  86	}
  87	return pb
  88}
  89
  90// addKeyValue adds a key-value pair parameter
  91func (pb *paramBuilder) addKeyValue(key, value string) *paramBuilder {
  92	if value != "" {
  93		pb.args = append(pb.args, key, value)
  94	}
  95	return pb
  96}
  97
  98// addFlag adds a boolean flag parameter
  99func (pb *paramBuilder) addFlag(key string, value bool) *paramBuilder {
 100	if value {
 101		pb.args = append(pb.args, key, "true")
 102	}
 103	return pb
 104}
 105
 106// build returns the final parameter list
 107func (pb *paramBuilder) build() []string {
 108	return pb.args
 109}
 110
 111// renderWithParams provides a common rendering pattern for tools with parameters
 112func (br baseRenderer) renderWithParams(v *toolCallCmp, toolName string, args []string, contentRenderer func() string) string {
 113	width := v.textWidth()
 114	if v.isNested {
 115		width -= 4 // Adjust for nested tool call indentation
 116	}
 117	header := br.makeHeader(v, toolName, width, args...)
 118	if v.isNested {
 119		return v.style().Render(header)
 120	}
 121	if res, done := earlyState(header, v); done {
 122		return res
 123	}
 124	body := contentRenderer()
 125	return joinHeaderBody(header, body)
 126}
 127
 128// unmarshalParams safely unmarshal JSON parameters
 129func (br baseRenderer) unmarshalParams(input string, target any) error {
 130	return json.Unmarshal([]byte(input), target)
 131}
 132
 133// makeHeader builds the tool call header with status icon and parameters for a nested tool call.
 134func (br baseRenderer) makeNestedHeader(v *toolCallCmp, tool string, width int, params ...string) string {
 135	t := styles.CurrentTheme()
 136	icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
 137	if v.result.ToolCallID != "" {
 138		if v.result.IsError {
 139			icon = t.S().Base.Foreground(t.RedDark).Render(styles.ToolError)
 140		} else {
 141			icon = t.S().Base.Foreground(t.Green).Render(styles.ToolSuccess)
 142		}
 143	} else if v.cancelled {
 144		icon = t.S().Muted.Render(styles.ToolPending)
 145	}
 146	tool = t.S().Base.Foreground(t.FgHalfMuted).Render(tool)
 147	prefix := fmt.Sprintf("%s %s ", icon, tool)
 148	return prefix + renderParamList(true, width-lipgloss.Width(prefix), params...)
 149}
 150
 151// makeHeader builds "<Tool>: param (key=value)" and truncates as needed.
 152func (br baseRenderer) makeHeader(v *toolCallCmp, tool string, width int, params ...string) string {
 153	if v.isNested {
 154		return br.makeNestedHeader(v, tool, width, params...)
 155	}
 156	t := styles.CurrentTheme()
 157	icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
 158	if v.result.ToolCallID != "" {
 159		if v.result.IsError {
 160			icon = t.S().Base.Foreground(t.RedDark).Render(styles.ToolError)
 161		} else {
 162			icon = t.S().Base.Foreground(t.Green).Render(styles.ToolSuccess)
 163		}
 164	} else if v.cancelled {
 165		icon = t.S().Muted.Render(styles.ToolPending)
 166	}
 167	tool = t.S().Base.Foreground(t.Blue).Render(tool)
 168	prefix := fmt.Sprintf("%s %s ", icon, tool)
 169	return prefix + renderParamList(false, width-lipgloss.Width(prefix), params...)
 170}
 171
 172// renderError provides consistent error rendering
 173func (br baseRenderer) renderError(v *toolCallCmp, message string) string {
 174	t := styles.CurrentTheme()
 175	header := br.makeHeader(v, prettifyToolName(v.call.Name), v.textWidth(), "")
 176	errorTag := t.S().Base.Padding(0, 1).Background(t.Red).Foreground(t.White).Render("ERROR")
 177	message = t.S().Base.Foreground(t.FgHalfMuted).Render(v.fit(message, v.textWidth()-3-lipgloss.Width(errorTag))) // -2 for padding and space
 178	return joinHeaderBody(header, errorTag+" "+message)
 179}
 180
 181// Register tool renderers
 182func init() {
 183	registry.register(tools.BashToolName, func() renderer { return bashRenderer{} })
 184	registry.register(tools.JobOutputToolName, func() renderer { return bashOutputRenderer{} })
 185	registry.register(tools.JobKillToolName, func() renderer { return bashKillRenderer{} })
 186	registry.register(tools.DownloadToolName, func() renderer { return downloadRenderer{} })
 187	registry.register(tools.ViewToolName, func() renderer { return viewRenderer{} })
 188	registry.register(tools.EditToolName, func() renderer { return editRenderer{} })
 189	registry.register(tools.MultiEditToolName, func() renderer { return multiEditRenderer{} })
 190	registry.register(tools.WriteToolName, func() renderer { return writeRenderer{} })
 191	registry.register(tools.FetchToolName, func() renderer { return simpleFetchRenderer{} })
 192	registry.register(tools.AgenticFetchToolName, func() renderer { return agenticFetchRenderer{} })
 193	registry.register(tools.WebFetchToolName, func() renderer { return webFetchRenderer{} })
 194	registry.register(tools.WebSearchToolName, func() renderer { return webSearchRenderer{} })
 195	registry.register(tools.GlobToolName, func() renderer { return globRenderer{} })
 196	registry.register(tools.GrepToolName, func() renderer { return grepRenderer{} })
 197	registry.register(tools.LSToolName, func() renderer { return lsRenderer{} })
 198	registry.register(tools.SourcegraphToolName, func() renderer { return sourcegraphRenderer{} })
 199	registry.register(tools.DiagnosticsToolName, func() renderer { return diagnosticsRenderer{} })
 200	registry.register(agent.AgentToolName, func() renderer { return agentRenderer{} })
 201}
 202
 203// -----------------------------------------------------------------------------
 204//  Generic renderer
 205// -----------------------------------------------------------------------------
 206
 207// genericRenderer handles unknown tool types with basic parameter display
 208type genericRenderer struct {
 209	baseRenderer
 210}
 211
 212func (gr genericRenderer) Render(v *toolCallCmp) string {
 213	if v.result.Data != "" {
 214		if strings.HasPrefix(v.result.MIMEType, "image/") {
 215			return gr.renderWithParams(v, prettifyToolName(v.call.Name), []string{v.call.Input}, func() string {
 216				return renderImageContent(v, v.result.Data, v.result.MIMEType, v.result.Content)
 217			})
 218		}
 219		return gr.renderWithParams(v, prettifyToolName(v.call.Name), []string{v.call.Input}, func() string {
 220			return renderMediaContent(v, v.result.MIMEType, v.result.Content)
 221		})
 222	}
 223
 224	return gr.renderWithParams(v, prettifyToolName(v.call.Name), []string{v.call.Input}, func() string {
 225		return renderPlainContent(v, v.result.Content)
 226	})
 227}
 228
 229// -----------------------------------------------------------------------------
 230//  Bash renderer
 231// -----------------------------------------------------------------------------
 232
 233// bashRenderer handles bash command execution display
 234type bashRenderer struct {
 235	baseRenderer
 236}
 237
 238// Render displays the bash command with sanitized newlines and plain output
 239func (br bashRenderer) Render(v *toolCallCmp) string {
 240	var params tools.BashParams
 241	if err := br.unmarshalParams(v.call.Input, &params); err != nil {
 242		return br.renderError(v, "Invalid bash parameters")
 243	}
 244
 245	cmd := strings.ReplaceAll(params.Command, "\n", " ")
 246	cmd = strings.ReplaceAll(cmd, "\t", "    ")
 247	args := newParamBuilder().
 248		addMain(cmd).
 249		addFlag("background", params.RunInBackground).
 250		build()
 251	if v.call.Finished {
 252		var meta tools.BashResponseMetadata
 253		_ = br.unmarshalParams(v.result.Metadata, &meta)
 254		if meta.Background {
 255			description := cmp.Or(meta.Description, params.Command)
 256			width := v.textWidth()
 257			if v.isNested {
 258				width -= 4 // Adjust for nested tool call indentation
 259			}
 260			header := makeJobHeader(v, "Start", fmt.Sprintf("PID %s", meta.ShellID), description, width)
 261			if v.isNested {
 262				return v.style().Render(header)
 263			}
 264			if res, done := earlyState(header, v); done {
 265				return res
 266			}
 267			content := "Command: " + params.Command + "\n" + v.result.Content
 268			body := renderPlainContent(v, content)
 269			return joinHeaderBody(header, body)
 270		}
 271	}
 272
 273	return br.renderWithParams(v, "Bash", args, func() string {
 274		var meta tools.BashResponseMetadata
 275		if err := br.unmarshalParams(v.result.Metadata, &meta); err != nil {
 276			return renderPlainContent(v, v.result.Content)
 277		}
 278		// for backwards compatibility with older tool calls.
 279		if meta.Output == "" && v.result.Content != tools.BashNoOutput {
 280			meta.Output = v.result.Content
 281		}
 282
 283		if meta.Output == "" {
 284			return ""
 285		}
 286		return renderPlainContent(v, meta.Output)
 287	})
 288}
 289
 290// -----------------------------------------------------------------------------
 291//  Bash Output renderer
 292// -----------------------------------------------------------------------------
 293
 294func makeJobHeader(v *toolCallCmp, subcommand, pid, description string, width int) string {
 295	t := styles.CurrentTheme()
 296	icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
 297	if v.result.ToolCallID != "" {
 298		if v.result.IsError {
 299			icon = t.S().Base.Foreground(t.RedDark).Render(styles.ToolError)
 300		} else {
 301			icon = t.S().Base.Foreground(t.Green).Render(styles.ToolSuccess)
 302		}
 303	} else if v.cancelled {
 304		icon = t.S().Muted.Render(styles.ToolPending)
 305	}
 306
 307	jobPart := t.S().Base.Foreground(t.Blue).Render("Job")
 308	subcommandPart := t.S().Base.Foreground(t.BlueDark).Render("(" + subcommand + ")")
 309	pidPart := t.S().Muted.Render(pid)
 310	descPart := ""
 311	if description != "" {
 312		descPart = " " + t.S().Subtle.Render(description)
 313	}
 314
 315	// Build the complete header
 316	prefix := fmt.Sprintf("%s %s %s %s", icon, jobPart, subcommandPart, pidPart)
 317	fullHeader := prefix + descPart
 318
 319	// Truncate if needed
 320	if lipgloss.Width(fullHeader) > width {
 321		availableWidth := width - lipgloss.Width(prefix) - 1 // -1 for space
 322		if availableWidth < 10 {
 323			// Not enough space for description, just show prefix
 324			return prefix
 325		}
 326		descPart = " " + t.S().Subtle.Render(ansi.Truncate(description, availableWidth, "…"))
 327		fullHeader = prefix + descPart
 328	}
 329
 330	return fullHeader
 331}
 332
 333// bashOutputRenderer handles bash output retrieval display
 334type bashOutputRenderer struct {
 335	baseRenderer
 336}
 337
 338// Render displays the shell ID and output from a background shell
 339func (bor bashOutputRenderer) Render(v *toolCallCmp) string {
 340	var params tools.JobOutputParams
 341	if err := bor.unmarshalParams(v.call.Input, &params); err != nil {
 342		return bor.renderError(v, "Invalid job_output parameters")
 343	}
 344
 345	var meta tools.JobOutputResponseMetadata
 346	var description string
 347	if v.result.Metadata != "" {
 348		if err := bor.unmarshalParams(v.result.Metadata, &meta); err == nil {
 349			if meta.Description != "" {
 350				description = meta.Description
 351			} else {
 352				description = meta.Command
 353			}
 354		}
 355	}
 356
 357	width := v.textWidth()
 358	if v.isNested {
 359		width -= 4 // Adjust for nested tool call indentation
 360	}
 361	header := makeJobHeader(v, "Output", fmt.Sprintf("PID %s", params.ShellID), description, width)
 362	if v.isNested {
 363		return v.style().Render(header)
 364	}
 365	if res, done := earlyState(header, v); done {
 366		return res
 367	}
 368	body := renderPlainContent(v, v.result.Content)
 369	return joinHeaderBody(header, body)
 370}
 371
 372// -----------------------------------------------------------------------------
 373//  Bash Kill renderer
 374// -----------------------------------------------------------------------------
 375
 376// bashKillRenderer handles bash process termination display
 377type bashKillRenderer struct {
 378	baseRenderer
 379}
 380
 381// Render displays the shell ID being terminated
 382func (bkr bashKillRenderer) Render(v *toolCallCmp) string {
 383	var params tools.JobKillParams
 384	if err := bkr.unmarshalParams(v.call.Input, &params); err != nil {
 385		return bkr.renderError(v, "Invalid job_kill parameters")
 386	}
 387
 388	var meta tools.JobKillResponseMetadata
 389	var description string
 390	if v.result.Metadata != "" {
 391		if err := bkr.unmarshalParams(v.result.Metadata, &meta); err == nil {
 392			if meta.Description != "" {
 393				description = meta.Description
 394			} else {
 395				description = meta.Command
 396			}
 397		}
 398	}
 399
 400	width := v.textWidth()
 401	if v.isNested {
 402		width -= 4 // Adjust for nested tool call indentation
 403	}
 404	header := makeJobHeader(v, "Kill", fmt.Sprintf("PID %s", params.ShellID), description, width)
 405	if v.isNested {
 406		return v.style().Render(header)
 407	}
 408	if res, done := earlyState(header, v); done {
 409		return res
 410	}
 411	body := renderPlainContent(v, v.result.Content)
 412	return joinHeaderBody(header, body)
 413}
 414
 415// -----------------------------------------------------------------------------
 416//  View renderer
 417// -----------------------------------------------------------------------------
 418
 419// viewRenderer handles file viewing with syntax highlighting and line numbers
 420type viewRenderer struct {
 421	baseRenderer
 422}
 423
 424// Render displays file content with optional limit and offset parameters
 425func (vr viewRenderer) Render(v *toolCallCmp) string {
 426	var params tools.ViewParams
 427	if err := vr.unmarshalParams(v.call.Input, &params); err != nil {
 428		return vr.renderError(v, "Invalid view parameters")
 429	}
 430
 431	file := fsext.PrettyPath(params.FilePath)
 432	args := newParamBuilder().
 433		addMain(file).
 434		addKeyValue("limit", formatNonZero(params.Limit)).
 435		addKeyValue("offset", formatNonZero(params.Offset)).
 436		build()
 437
 438	return vr.renderWithParams(v, "View", args, func() string {
 439		if v.result.Data != "" && strings.HasPrefix(v.result.MIMEType, "image/") {
 440			return renderImageContent(v, v.result.Data, v.result.MIMEType, "")
 441		}
 442
 443		var meta tools.ViewResponseMetadata
 444		if err := vr.unmarshalParams(v.result.Metadata, &meta); err != nil {
 445			return renderPlainContent(v, v.result.Content)
 446		}
 447		return renderCodeContent(v, meta.FilePath, meta.Content, params.Offset)
 448	})
 449}
 450
 451// formatNonZero returns string representation of non-zero integers, empty string for zero
 452func formatNonZero(value int) string {
 453	if value == 0 {
 454		return ""
 455	}
 456	return fmt.Sprintf("%d", value)
 457}
 458
 459// -----------------------------------------------------------------------------
 460//  Edit renderer
 461// -----------------------------------------------------------------------------
 462
 463// editRenderer handles file editing with diff visualization
 464type editRenderer struct {
 465	baseRenderer
 466}
 467
 468// Render displays the edited file with a formatted diff of changes
 469func (er editRenderer) Render(v *toolCallCmp) string {
 470	t := styles.CurrentTheme()
 471	var params tools.EditParams
 472	var args []string
 473	if err := er.unmarshalParams(v.call.Input, &params); err == nil {
 474		file := fsext.PrettyPath(params.FilePath)
 475		args = newParamBuilder().addMain(file).build()
 476	}
 477
 478	return er.renderWithParams(v, "Edit", args, func() string {
 479		var meta tools.EditResponseMetadata
 480		if err := er.unmarshalParams(v.result.Metadata, &meta); err != nil {
 481			return renderPlainContent(v, v.result.Content)
 482		}
 483
 484		formatter := core.DiffFormatter().
 485			Before(fsext.PrettyPath(params.FilePath), meta.OldContent).
 486			After(fsext.PrettyPath(params.FilePath), meta.NewContent).
 487			Width(v.textWidth() - 2) // -2 for padding
 488		if v.textWidth() > 120 {
 489			formatter = formatter.Split()
 490		}
 491		// add a message to the bottom if the content was truncated
 492		formatted := formatter.String()
 493		if lipgloss.Height(formatted) > responseContextHeight {
 494			contentLines := strings.Split(formatted, "\n")
 495			truncateMessage := t.S().Muted.
 496				Background(t.BgBaseLighter).
 497				PaddingLeft(2).
 498				Width(v.textWidth() - 2).
 499				Render(fmt.Sprintf("… (%d lines)", len(contentLines)-responseContextHeight))
 500			formatted = strings.Join(contentLines[:responseContextHeight], "\n") + "\n" + truncateMessage
 501		}
 502		return formatted
 503	})
 504}
 505
 506// -----------------------------------------------------------------------------
 507//  Multi-Edit renderer
 508// -----------------------------------------------------------------------------
 509
 510// multiEditRenderer handles multiple file edits with diff visualization
 511type multiEditRenderer struct {
 512	baseRenderer
 513}
 514
 515// Render displays the multi-edited file with a formatted diff of changes
 516func (mer multiEditRenderer) Render(v *toolCallCmp) string {
 517	t := styles.CurrentTheme()
 518	var params tools.MultiEditParams
 519	var args []string
 520	if err := mer.unmarshalParams(v.call.Input, &params); err == nil {
 521		file := fsext.PrettyPath(params.FilePath)
 522		editsCount := len(params.Edits)
 523		args = newParamBuilder().
 524			addMain(file).
 525			addKeyValue("edits", fmt.Sprintf("%d", editsCount)).
 526			build()
 527	}
 528
 529	return mer.renderWithParams(v, "Multi-Edit", args, func() string {
 530		var meta tools.MultiEditResponseMetadata
 531		if err := mer.unmarshalParams(v.result.Metadata, &meta); err != nil {
 532			return renderPlainContent(v, v.result.Content)
 533		}
 534
 535		formatter := core.DiffFormatter().
 536			Before(fsext.PrettyPath(params.FilePath), meta.OldContent).
 537			After(fsext.PrettyPath(params.FilePath), meta.NewContent).
 538			Width(v.textWidth() - 2) // -2 for padding
 539		if v.textWidth() > 120 {
 540			formatter = formatter.Split()
 541		}
 542		// add a message to the bottom if the content was truncated
 543		formatted := formatter.String()
 544		if lipgloss.Height(formatted) > responseContextHeight {
 545			contentLines := strings.Split(formatted, "\n")
 546			truncateMessage := t.S().Muted.
 547				Background(t.BgBaseLighter).
 548				PaddingLeft(2).
 549				Width(v.textWidth() - 4).
 550				Render(fmt.Sprintf("… (%d lines)", len(contentLines)-responseContextHeight))
 551			formatted = strings.Join(contentLines[:responseContextHeight], "\n") + "\n" + truncateMessage
 552		}
 553
 554		// Add failed edits warning if any exist
 555		if len(meta.EditsFailed) > 0 {
 556			noteTag := t.S().Base.Padding(0, 2).Background(t.Info).Foreground(t.White).Render("Note")
 557			noteMsg := fmt.Sprintf("%d of %d edits succeeded", meta.EditsApplied, len(params.Edits))
 558			note := t.S().Base.
 559				Width(v.textWidth() - 2).
 560				Render(fmt.Sprintf("%s %s", noteTag, t.S().Muted.Render(noteMsg)))
 561			formatted = lipgloss.JoinVertical(lipgloss.Left, formatted, "", note)
 562		}
 563
 564		return formatted
 565	})
 566}
 567
 568// -----------------------------------------------------------------------------
 569//  Write renderer
 570// -----------------------------------------------------------------------------
 571
 572// writeRenderer handles file writing with syntax-highlighted content preview
 573type writeRenderer struct {
 574	baseRenderer
 575}
 576
 577// Render displays the file being written with syntax highlighting
 578func (wr writeRenderer) Render(v *toolCallCmp) string {
 579	var params tools.WriteParams
 580	var args []string
 581	var file string
 582	if err := wr.unmarshalParams(v.call.Input, &params); err == nil {
 583		file = fsext.PrettyPath(params.FilePath)
 584		args = newParamBuilder().addMain(file).build()
 585	}
 586
 587	return wr.renderWithParams(v, "Write", args, func() string {
 588		return renderCodeContent(v, file, params.Content, 0)
 589	})
 590}
 591
 592// -----------------------------------------------------------------------------
 593//  Fetch renderer
 594// -----------------------------------------------------------------------------
 595
 596// simpleFetchRenderer handles URL fetching with format-specific content display
 597type simpleFetchRenderer struct {
 598	baseRenderer
 599}
 600
 601// Render displays the fetched URL with format and timeout parameters
 602func (fr simpleFetchRenderer) Render(v *toolCallCmp) string {
 603	var params tools.FetchParams
 604	var args []string
 605	if err := fr.unmarshalParams(v.call.Input, &params); err == nil {
 606		args = newParamBuilder().
 607			addMain(params.URL).
 608			addKeyValue("format", params.Format).
 609			addKeyValue("timeout", formatTimeout(params.Timeout)).
 610			build()
 611	}
 612
 613	return fr.renderWithParams(v, "Fetch", args, func() string {
 614		file := fr.getFileExtension(params.Format)
 615		return renderCodeContent(v, file, v.result.Content, 0)
 616	})
 617}
 618
 619// getFileExtension returns appropriate file extension for syntax highlighting
 620func (fr simpleFetchRenderer) getFileExtension(format string) string {
 621	switch format {
 622	case "text":
 623		return "fetch.txt"
 624	case "html":
 625		return "fetch.html"
 626	default:
 627		return "fetch.md"
 628	}
 629}
 630
 631// -----------------------------------------------------------------------------
 632//  Agentic fetch renderer
 633// -----------------------------------------------------------------------------
 634
 635// agenticFetchRenderer handles URL fetching with prompt parameter and nested tool calls
 636type agenticFetchRenderer struct {
 637	baseRenderer
 638}
 639
 640// Render displays the fetched URL or web search with prompt parameter and nested tool calls
 641func (fr agenticFetchRenderer) Render(v *toolCallCmp) string {
 642	t := styles.CurrentTheme()
 643	var params tools.AgenticFetchParams
 644	var args []string
 645	if err := fr.unmarshalParams(v.call.Input, &params); err == nil {
 646		if params.URL != "" {
 647			args = newParamBuilder().
 648				addMain(params.URL).
 649				build()
 650		}
 651	}
 652
 653	prompt := params.Prompt
 654	prompt = strings.ReplaceAll(prompt, "\n", " ")
 655
 656	header := fr.makeHeader(v, "Agentic Fetch", v.textWidth(), args...)
 657	if res, done := earlyState(header, v); v.cancelled && done {
 658		return res
 659	}
 660
 661	taskTag := t.S().Base.Bold(true).Padding(0, 1).MarginLeft(2).Background(t.GreenLight).Foreground(t.Border).Render("Prompt")
 662	remainingWidth := v.textWidth() - (lipgloss.Width(taskTag) + 1)
 663	remainingWidth = min(remainingWidth, 120-(lipgloss.Width(taskTag)+1))
 664	prompt = t.S().Base.Width(remainingWidth).Render(prompt)
 665	header = lipgloss.JoinVertical(
 666		lipgloss.Left,
 667		header,
 668		"",
 669		lipgloss.JoinHorizontal(
 670			lipgloss.Left,
 671			taskTag,
 672			" ",
 673			prompt,
 674		),
 675	)
 676	childTools := tree.Root(header)
 677
 678	for _, call := range v.nestedToolCalls {
 679		call.SetSize(remainingWidth, 1)
 680		childTools.Child(call.View())
 681	}
 682	parts := []string{
 683		childTools.Enumerator(RoundedEnumeratorWithWidth(2, lipgloss.Width(taskTag)-5)).String(),
 684	}
 685
 686	if v.result.ToolCallID == "" {
 687		v.spinning = true
 688		parts = append(parts, "", v.anim.View())
 689	} else {
 690		v.spinning = false
 691	}
 692
 693	header = lipgloss.JoinVertical(
 694		lipgloss.Left,
 695		parts...,
 696	)
 697
 698	if v.result.ToolCallID == "" {
 699		return header
 700	}
 701	body := renderMarkdownContent(v, v.result.Content)
 702	return joinHeaderBody(header, body)
 703}
 704
 705// formatTimeout converts timeout seconds to duration string
 706func formatTimeout(timeout int) string {
 707	if timeout == 0 {
 708		return ""
 709	}
 710	return (time.Duration(timeout) * time.Second).String()
 711}
 712
 713// -----------------------------------------------------------------------------
 714//  Web fetch renderer
 715// -----------------------------------------------------------------------------
 716
 717// webFetchRenderer handles web page fetching with simplified URL display
 718type webFetchRenderer struct {
 719	baseRenderer
 720}
 721
 722// Render displays a compact view of web_fetch with just the URL in a link style
 723func (wfr webFetchRenderer) Render(v *toolCallCmp) string {
 724	var params tools.WebFetchParams
 725	var args []string
 726	if err := wfr.unmarshalParams(v.call.Input, &params); err == nil {
 727		args = newParamBuilder().
 728			addMain(params.URL).
 729			build()
 730	}
 731
 732	return wfr.renderWithParams(v, "Fetch", args, func() string {
 733		return renderMarkdownContent(v, v.result.Content)
 734	})
 735}
 736
 737// -----------------------------------------------------------------------------
 738//  Web search renderer
 739// -----------------------------------------------------------------------------
 740
 741// webSearchRenderer handles web search with query display
 742type webSearchRenderer struct {
 743	baseRenderer
 744}
 745
 746// Render displays a compact view of web_search with just the query
 747func (wsr webSearchRenderer) Render(v *toolCallCmp) string {
 748	var params tools.WebSearchParams
 749	var args []string
 750	if err := wsr.unmarshalParams(v.call.Input, &params); err == nil {
 751		args = newParamBuilder().
 752			addMain(params.Query).
 753			build()
 754	}
 755
 756	return wsr.renderWithParams(v, "Search", args, func() string {
 757		return renderMarkdownContent(v, v.result.Content)
 758	})
 759}
 760
 761// -----------------------------------------------------------------------------
 762//  Download renderer
 763// -----------------------------------------------------------------------------
 764
 765// downloadRenderer handles file downloading with URL and file path display
 766type downloadRenderer struct {
 767	baseRenderer
 768}
 769
 770// Render displays the download URL and destination file path with timeout parameter
 771func (dr downloadRenderer) Render(v *toolCallCmp) string {
 772	var params tools.DownloadParams
 773	var args []string
 774	if err := dr.unmarshalParams(v.call.Input, &params); err == nil {
 775		args = newParamBuilder().
 776			addMain(params.URL).
 777			addKeyValue("file_path", fsext.PrettyPath(params.FilePath)).
 778			addKeyValue("timeout", formatTimeout(params.Timeout)).
 779			build()
 780	}
 781
 782	return dr.renderWithParams(v, "Download", args, func() string {
 783		return renderPlainContent(v, v.result.Content)
 784	})
 785}
 786
 787// -----------------------------------------------------------------------------
 788//  Glob renderer
 789// -----------------------------------------------------------------------------
 790
 791// globRenderer handles file pattern matching with path filtering
 792type globRenderer struct {
 793	baseRenderer
 794}
 795
 796// Render displays the glob pattern with optional path parameter
 797func (gr globRenderer) Render(v *toolCallCmp) string {
 798	var params tools.GlobParams
 799	var args []string
 800	if err := gr.unmarshalParams(v.call.Input, &params); err == nil {
 801		args = newParamBuilder().
 802			addMain(params.Pattern).
 803			addKeyValue("path", params.Path).
 804			build()
 805	}
 806
 807	return gr.renderWithParams(v, "Glob", args, func() string {
 808		return renderPlainContent(v, v.result.Content)
 809	})
 810}
 811
 812// -----------------------------------------------------------------------------
 813//  Grep renderer
 814// -----------------------------------------------------------------------------
 815
 816// grepRenderer handles content searching with pattern matching options
 817type grepRenderer struct {
 818	baseRenderer
 819}
 820
 821// Render displays the search pattern with path, include, and literal text options
 822func (gr grepRenderer) Render(v *toolCallCmp) string {
 823	var params tools.GrepParams
 824	var args []string
 825	if err := gr.unmarshalParams(v.call.Input, &params); err == nil {
 826		args = newParamBuilder().
 827			addMain(params.Pattern).
 828			addKeyValue("path", params.Path).
 829			addKeyValue("include", params.Include).
 830			addFlag("literal", params.LiteralText).
 831			build()
 832	}
 833
 834	return gr.renderWithParams(v, "Grep", args, func() string {
 835		return renderPlainContent(v, v.result.Content)
 836	})
 837}
 838
 839// -----------------------------------------------------------------------------
 840//  LS renderer
 841// -----------------------------------------------------------------------------
 842
 843// lsRenderer handles directory listing with default path handling
 844type lsRenderer struct {
 845	baseRenderer
 846}
 847
 848// Render displays the directory path, defaulting to current directory
 849func (lr lsRenderer) Render(v *toolCallCmp) string {
 850	var params tools.LSParams
 851	var args []string
 852	if err := lr.unmarshalParams(v.call.Input, &params); err == nil {
 853		path := params.Path
 854		if path == "" {
 855			path = "."
 856		}
 857		path = fsext.PrettyPath(path)
 858
 859		args = newParamBuilder().addMain(path).build()
 860	}
 861
 862	return lr.renderWithParams(v, "List", args, func() string {
 863		return renderPlainContent(v, v.result.Content)
 864	})
 865}
 866
 867// -----------------------------------------------------------------------------
 868//  Sourcegraph renderer
 869// -----------------------------------------------------------------------------
 870
 871// sourcegraphRenderer handles code search with count and context options
 872type sourcegraphRenderer struct {
 873	baseRenderer
 874}
 875
 876// Render displays the search query with optional count and context window parameters
 877func (sr sourcegraphRenderer) Render(v *toolCallCmp) string {
 878	var params tools.SourcegraphParams
 879	var args []string
 880	if err := sr.unmarshalParams(v.call.Input, &params); err == nil {
 881		args = newParamBuilder().
 882			addMain(params.Query).
 883			addKeyValue("count", formatNonZero(params.Count)).
 884			addKeyValue("context", formatNonZero(params.ContextWindow)).
 885			build()
 886	}
 887
 888	return sr.renderWithParams(v, "Sourcegraph", args, func() string {
 889		return renderPlainContent(v, v.result.Content)
 890	})
 891}
 892
 893// -----------------------------------------------------------------------------
 894//  Diagnostics renderer
 895// -----------------------------------------------------------------------------
 896
 897// diagnosticsRenderer handles project-wide diagnostic information
 898type diagnosticsRenderer struct {
 899	baseRenderer
 900}
 901
 902// Render displays project diagnostics with plain content formatting
 903func (dr diagnosticsRenderer) Render(v *toolCallCmp) string {
 904	args := newParamBuilder().addMain("project").build()
 905
 906	return dr.renderWithParams(v, "Diagnostics", args, func() string {
 907		return renderPlainContent(v, v.result.Content)
 908	})
 909}
 910
 911// -----------------------------------------------------------------------------
 912//  Task renderer
 913// -----------------------------------------------------------------------------
 914
 915// agentRenderer handles project-wide diagnostic information
 916type agentRenderer struct {
 917	baseRenderer
 918}
 919
 920func RoundedEnumeratorWithWidth(lPadding, width int) tree.Enumerator {
 921	if width == 0 {
 922		width = 2
 923	}
 924	if lPadding == 0 {
 925		lPadding = 1
 926	}
 927	return func(children tree.Children, index int) string {
 928		line := strings.Repeat("─", width)
 929		padding := strings.Repeat(" ", lPadding)
 930		if children.Length()-1 == index {
 931			return padding + "β•°" + line
 932		}
 933		return padding + "β”œ" + line
 934	}
 935}
 936
 937// Render displays agent task parameters and result content
 938func (tr agentRenderer) Render(v *toolCallCmp) string {
 939	t := styles.CurrentTheme()
 940	var params agent.AgentParams
 941	tr.unmarshalParams(v.call.Input, &params)
 942
 943	prompt := params.Prompt
 944	prompt = strings.ReplaceAll(prompt, "\n", " ")
 945
 946	header := tr.makeHeader(v, "Agent", v.textWidth())
 947	if res, done := earlyState(header, v); v.cancelled && done {
 948		return res
 949	}
 950	taskTag := t.S().Base.Bold(true).Padding(0, 1).MarginLeft(2).Background(t.BlueLight).Foreground(t.White).Render("Task")
 951	remainingWidth := v.textWidth() - lipgloss.Width(header) - lipgloss.Width(taskTag) - 2
 952	remainingWidth = min(remainingWidth, 120-lipgloss.Width(taskTag)-2)
 953	prompt = t.S().Muted.Width(remainingWidth).Render(prompt)
 954	header = lipgloss.JoinVertical(
 955		lipgloss.Left,
 956		header,
 957		"",
 958		lipgloss.JoinHorizontal(
 959			lipgloss.Left,
 960			taskTag,
 961			" ",
 962			prompt,
 963		),
 964	)
 965	childTools := tree.Root(header)
 966
 967	for _, call := range v.nestedToolCalls {
 968		call.SetSize(remainingWidth, 1)
 969		childTools.Child(call.View())
 970	}
 971	parts := []string{
 972		childTools.Enumerator(RoundedEnumeratorWithWidth(2, lipgloss.Width(taskTag)-5)).String(),
 973	}
 974
 975	if v.result.ToolCallID == "" {
 976		v.spinning = true
 977		parts = append(parts, "", v.anim.View())
 978	} else {
 979		v.spinning = false
 980	}
 981
 982	header = lipgloss.JoinVertical(
 983		lipgloss.Left,
 984		parts...,
 985	)
 986
 987	if v.result.ToolCallID == "" {
 988		return header
 989	}
 990
 991	body := renderMarkdownContent(v, v.result.Content)
 992	return joinHeaderBody(header, body)
 993}
 994
 995// renderParamList renders params, params[0] (params[1]=params[2] ....)
 996func renderParamList(nested bool, paramsWidth int, params ...string) string {
 997	t := styles.CurrentTheme()
 998	if len(params) == 0 {
 999		return ""
1000	}
1001	mainParam := params[0]
1002	if paramsWidth >= 0 && lipgloss.Width(mainParam) > paramsWidth {
1003		mainParam = ansi.Truncate(mainParam, paramsWidth, "…")
1004	}
1005
1006	if len(params) == 1 {
1007		return t.S().Subtle.Render(mainParam)
1008	}
1009	otherParams := params[1:]
1010	// create pairs of key/value
1011	// if odd number of params, the last one is a key without value
1012	if len(otherParams)%2 != 0 {
1013		otherParams = append(otherParams, "")
1014	}
1015	parts := make([]string, 0, len(otherParams)/2)
1016	for i := 0; i < len(otherParams); i += 2 {
1017		key := otherParams[i]
1018		value := otherParams[i+1]
1019		if value == "" {
1020			continue
1021		}
1022		parts = append(parts, fmt.Sprintf("%s=%s", key, value))
1023	}
1024
1025	partsRendered := strings.Join(parts, ", ")
1026	remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 3 // count for " ()"
1027	if remainingWidth < 30 {
1028		// No space for the params, just show the main
1029		return t.S().Subtle.Render(mainParam)
1030	}
1031
1032	if len(parts) > 0 {
1033		mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
1034	}
1035
1036	return t.S().Subtle.Render(ansi.Truncate(mainParam, paramsWidth, "…"))
1037}
1038
1039// earlyState returns immediately‑rendered error/cancelled/ongoing states.
1040func earlyState(header string, v *toolCallCmp) (string, bool) {
1041	t := styles.CurrentTheme()
1042	message := ""
1043	switch {
1044	case v.result.IsError:
1045		message = v.renderToolError()
1046	case v.cancelled:
1047		message = t.S().Base.Foreground(t.FgSubtle).Render("Canceled.")
1048	case v.result.ToolCallID == "":
1049		if v.permissionRequested && !v.permissionGranted {
1050			message = t.S().Base.Foreground(t.FgSubtle).Render("Requesting permission...")
1051		} else {
1052			message = t.S().Base.Foreground(t.FgSubtle).Render("Waiting for tool response...")
1053		}
1054	default:
1055		return "", false
1056	}
1057
1058	message = t.S().Base.PaddingLeft(2).Render(message)
1059	return lipgloss.JoinVertical(lipgloss.Left, header, "", message), true
1060}
1061
1062func joinHeaderBody(header, body string) string {
1063	t := styles.CurrentTheme()
1064	if body == "" {
1065		return header
1066	}
1067	body = t.S().Base.PaddingLeft(2).Render(body)
1068	return lipgloss.JoinVertical(lipgloss.Left, header, "", body)
1069}
1070
1071func renderPlainContent(v *toolCallCmp, content string) string {
1072	t := styles.CurrentTheme()
1073	content = strings.ReplaceAll(content, "\r\n", "\n") // Normalize line endings
1074	content = strings.ReplaceAll(content, "\t", "    ") // Replace tabs with spaces
1075	content = strings.TrimSpace(content)
1076	lines := strings.Split(content, "\n")
1077
1078	width := v.textWidth() - 2
1079	var out []string
1080	for i, ln := range lines {
1081		if i >= responseContextHeight {
1082			break
1083		}
1084		ln = ansiext.Escape(ln)
1085		ln = " " + ln
1086		if lipgloss.Width(ln) > width {
1087			ln = v.fit(ln, width)
1088		}
1089		out = append(out, t.S().Muted.
1090			Width(width).
1091			Background(t.BgBaseLighter).
1092			Render(ln))
1093	}
1094
1095	if len(lines) > responseContextHeight {
1096		out = append(out, t.S().Muted.
1097			Background(t.BgBaseLighter).
1098			Width(width).
1099			Render(fmt.Sprintf("… (%d lines)", len(lines)-responseContextHeight)))
1100	}
1101
1102	return strings.Join(out, "\n")
1103}
1104
1105func renderMarkdownContent(v *toolCallCmp, content string) string {
1106	t := styles.CurrentTheme()
1107	content = strings.ReplaceAll(content, "\r\n", "\n")
1108	content = strings.ReplaceAll(content, "\t", "    ")
1109	content = strings.TrimSpace(content)
1110
1111	width := v.textWidth() - 2
1112	width = min(width, 120)
1113
1114	renderer := styles.GetPlainMarkdownRenderer(width)
1115	rendered, err := renderer.Render(content)
1116	if err != nil {
1117		return renderPlainContent(v, content)
1118	}
1119
1120	lines := strings.Split(rendered, "\n")
1121
1122	var out []string
1123	for i, ln := range lines {
1124		if i >= responseContextHeight {
1125			break
1126		}
1127		out = append(out, ln)
1128	}
1129
1130	style := t.S().Muted.Background(t.BgBaseLighter)
1131	if len(lines) > responseContextHeight {
1132		out = append(out, style.
1133			Width(width-2).
1134			Render(fmt.Sprintf("… (%d lines)", len(lines)-responseContextHeight)))
1135	}
1136
1137	return style.Render(strings.Join(out, "\n"))
1138}
1139
1140func getDigits(n int) int {
1141	if n == 0 {
1142		return 1
1143	}
1144	if n < 0 {
1145		n = -n
1146	}
1147
1148	digits := 0
1149	for n > 0 {
1150		n /= 10
1151		digits++
1152	}
1153
1154	return digits
1155}
1156
1157func renderCodeContent(v *toolCallCmp, path, content string, offset int) string {
1158	t := styles.CurrentTheme()
1159	content = strings.ReplaceAll(content, "\r\n", "\n") // Normalize line endings
1160	content = strings.ReplaceAll(content, "\t", "    ") // Replace tabs with spaces
1161	truncated := truncateHeight(content, responseContextHeight)
1162
1163	lines := strings.Split(truncated, "\n")
1164	for i, ln := range lines {
1165		lines[i] = ansiext.Escape(ln)
1166	}
1167
1168	bg := t.BgBase
1169	highlighted, _ := highlight.SyntaxHighlight(strings.Join(lines, "\n"), path, bg)
1170	lines = strings.Split(highlighted, "\n")
1171
1172	if len(strings.Split(content, "\n")) > responseContextHeight {
1173		lines = append(lines, t.S().Muted.
1174			Background(bg).
1175			Render(fmt.Sprintf(" …(%d lines)", len(strings.Split(content, "\n"))-responseContextHeight)))
1176	}
1177
1178	maxLineNumber := len(lines) + offset
1179	maxDigits := getDigits(maxLineNumber)
1180	numFmt := fmt.Sprintf("%%%dd", maxDigits)
1181	const numPR, numPL, codePR, codePL = 1, 1, 1, 2
1182	w := v.textWidth() - maxDigits - numPL - numPR - 2 // -2 for left padding
1183	for i, ln := range lines {
1184		num := t.S().Base.
1185			Foreground(t.FgMuted).
1186			Background(t.BgBase).
1187			PaddingRight(1).
1188			PaddingLeft(1).
1189			Render(fmt.Sprintf(numFmt, i+1+offset))
1190		lines[i] = lipgloss.JoinHorizontal(lipgloss.Left,
1191			num,
1192			t.S().Base.
1193				Width(w).
1194				Background(bg).
1195				PaddingRight(1).
1196				PaddingLeft(2).
1197				Render(v.fit(ln, w-codePL-codePR)),
1198		)
1199	}
1200
1201	return lipgloss.JoinVertical(lipgloss.Left, lines...)
1202}
1203
1204// renderImageContent renders image data with optional text content (for MCP tools).
1205func renderImageContent(v *toolCallCmp, data, mediaType, textContent string) string {
1206	t := styles.CurrentTheme()
1207
1208	dataSize := len(data) * 3 / 4
1209	sizeStr := formatSize(dataSize)
1210
1211	loaded := t.S().Base.Foreground(t.Green).Render("Loaded")
1212	arrow := t.S().Base.Foreground(t.GreenDark).Render("β†’")
1213	typeStyled := t.S().Base.Render(mediaType)
1214	sizeStyled := t.S().Subtle.Render(sizeStr)
1215
1216	imageDisplay := fmt.Sprintf("%s %s %s %s", loaded, arrow, typeStyled, sizeStyled)
1217	if strings.TrimSpace(textContent) != "" {
1218		textDisplay := renderPlainContent(v, textContent)
1219		return lipgloss.JoinVertical(lipgloss.Left, textDisplay, "", imageDisplay)
1220	}
1221
1222	return imageDisplay
1223}
1224
1225// renderMediaContent renders non-image media content.
1226func renderMediaContent(v *toolCallCmp, mediaType, textContent string) string {
1227	t := styles.CurrentTheme()
1228
1229	loaded := t.S().Base.Foreground(t.Green).Render("Loaded")
1230	arrow := t.S().Base.Foreground(t.GreenDark).Render("β†’")
1231	typeStyled := t.S().Base.Render(mediaType)
1232	mediaDisplay := fmt.Sprintf("%s %s %s", loaded, arrow, typeStyled)
1233
1234	if strings.TrimSpace(textContent) != "" {
1235		textDisplay := renderPlainContent(v, textContent)
1236		return lipgloss.JoinVertical(lipgloss.Left, textDisplay, "", mediaDisplay)
1237	}
1238
1239	return mediaDisplay
1240}
1241
1242// formatSize formats byte count as human-readable size.
1243func formatSize(bytes int) string {
1244	if bytes < 1024 {
1245		return fmt.Sprintf("%d B", bytes)
1246	}
1247	if bytes < 1024*1024 {
1248		return fmt.Sprintf("%.1f KB", float64(bytes)/1024)
1249	}
1250	return fmt.Sprintf("%.1f MB", float64(bytes)/(1024*1024))
1251}
1252
1253func (v *toolCallCmp) renderToolError() string {
1254	t := styles.CurrentTheme()
1255	err := strings.ReplaceAll(v.result.Content, "\n", " ")
1256	errTag := t.S().Base.Padding(0, 1).Background(t.Red).Foreground(t.White).Render("ERROR")
1257	err = fmt.Sprintf("%s %s", errTag, t.S().Base.Foreground(t.FgHalfMuted).Render(v.fit(err, v.textWidth()-2-lipgloss.Width(errTag))))
1258	return err
1259}
1260
1261func truncateHeight(s string, h int) string {
1262	lines := strings.Split(s, "\n")
1263	if len(lines) > h {
1264		return strings.Join(lines[:h], "\n")
1265	}
1266	return s
1267}
1268
1269func prettifyToolName(name string) string {
1270	switch name {
1271	case agent.AgentToolName:
1272		return "Agent"
1273	case tools.BashToolName:
1274		return "Bash"
1275	case tools.JobOutputToolName:
1276		return "Job: Output"
1277	case tools.JobKillToolName:
1278		return "Job: Kill"
1279	case tools.DownloadToolName:
1280		return "Download"
1281	case tools.EditToolName:
1282		return "Edit"
1283	case tools.MultiEditToolName:
1284		return "Multi-Edit"
1285	case tools.FetchToolName:
1286		return "Fetch"
1287	case tools.AgenticFetchToolName:
1288		return "Agentic Fetch"
1289	case tools.WebFetchToolName:
1290		return "Fetch"
1291	case tools.WebSearchToolName:
1292		return "Search"
1293	case tools.GlobToolName:
1294		return "Glob"
1295	case tools.GrepToolName:
1296		return "Grep"
1297	case tools.LSToolName:
1298		return "List"
1299	case tools.SourcegraphToolName:
1300		return "Sourcegraph"
1301	case tools.ViewToolName:
1302		return "View"
1303	case tools.WriteToolName:
1304		return "Write"
1305	default:
1306		return name
1307	}
1308}