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