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