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.DeleteToolName, func() renderer { return deleteRenderer{} })
 191	registry.register(tools.MultiEditToolName, func() renderer { return multiEditRenderer{} })
 192	registry.register(tools.WriteToolName, func() renderer { return writeRenderer{} })
 193	registry.register(tools.FetchToolName, func() renderer { return simpleFetchRenderer{} })
 194	registry.register(tools.AgenticFetchToolName, func() renderer { return agenticFetchRenderer{} })
 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//  Delete renderer
 573// -----------------------------------------------------------------------------
 574
 575// deleteRenderer handles file/directory deletion display
 576type deleteRenderer struct {
 577	baseRenderer
 578}
 579
 580// Render displays the delete operation with file path and recursive flag
 581func (dr deleteRenderer) Render(v *toolCallCmp) string {
 582	var params tools.DeleteParams
 583	var args []string
 584	if err := dr.unmarshalParams(v.call.Input, &params); err == nil {
 585		file := fsext.PrettyPath(params.FilePath)
 586		pb := newParamBuilder().addMain(file)
 587		if params.Recursive {
 588			pb.addFlag("recursive", true)
 589		}
 590		args = pb.build()
 591	}
 592
 593	return dr.renderWithParams(v, "Delete", args, func() string {
 594		return renderPlainContent(v, v.result.Content)
 595	})
 596}
 597
 598// -----------------------------------------------------------------------------
 599//  Write renderer
 600// -----------------------------------------------------------------------------
 601
 602// writeRenderer handles file writing with syntax-highlighted content preview
 603type writeRenderer struct {
 604	baseRenderer
 605}
 606
 607// Render displays the file being written with syntax highlighting
 608func (wr writeRenderer) Render(v *toolCallCmp) string {
 609	var params tools.WriteParams
 610	var args []string
 611	var file string
 612	if err := wr.unmarshalParams(v.call.Input, &params); err == nil {
 613		file = fsext.PrettyPath(params.FilePath)
 614		args = newParamBuilder().addMain(file).build()
 615	}
 616
 617	return wr.renderWithParams(v, "Write", args, func() string {
 618		return renderCodeContent(v, file, params.Content, 0)
 619	})
 620}
 621
 622// -----------------------------------------------------------------------------
 623//  Fetch renderer
 624// -----------------------------------------------------------------------------
 625
 626// simpleFetchRenderer handles URL fetching with format-specific content display
 627type simpleFetchRenderer struct {
 628	baseRenderer
 629}
 630
 631// Render displays the fetched URL with format and timeout parameters
 632func (fr simpleFetchRenderer) Render(v *toolCallCmp) string {
 633	var params tools.FetchParams
 634	var args []string
 635	if err := fr.unmarshalParams(v.call.Input, &params); err == nil {
 636		args = newParamBuilder().
 637			addMain(params.URL).
 638			addKeyValue("format", params.Format).
 639			addKeyValue("timeout", formatTimeout(params.Timeout)).
 640			build()
 641	}
 642
 643	return fr.renderWithParams(v, "Fetch", args, func() string {
 644		file := fr.getFileExtension(params.Format)
 645		return renderCodeContent(v, file, v.result.Content, 0)
 646	})
 647}
 648
 649// getFileExtension returns appropriate file extension for syntax highlighting
 650func (fr simpleFetchRenderer) getFileExtension(format string) string {
 651	switch format {
 652	case "text":
 653		return "fetch.txt"
 654	case "html":
 655		return "fetch.html"
 656	default:
 657		return "fetch.md"
 658	}
 659}
 660
 661// -----------------------------------------------------------------------------
 662//  Agentic fetch renderer
 663// -----------------------------------------------------------------------------
 664
 665// agenticFetchRenderer handles URL fetching with prompt parameter and nested tool calls
 666type agenticFetchRenderer struct {
 667	baseRenderer
 668}
 669
 670// Render displays the fetched URL or web search with prompt parameter and nested tool calls
 671func (fr agenticFetchRenderer) Render(v *toolCallCmp) string {
 672	t := styles.CurrentTheme()
 673	var params tools.AgenticFetchParams
 674	var args []string
 675	if err := fr.unmarshalParams(v.call.Input, &params); err == nil {
 676		if params.URL != "" {
 677			args = newParamBuilder().
 678				addMain(params.URL).
 679				build()
 680		}
 681	}
 682
 683	prompt := params.Prompt
 684	prompt = strings.ReplaceAll(prompt, "\n", " ")
 685
 686	header := fr.makeHeader(v, "Agentic Fetch", v.textWidth(), args...)
 687	if res, done := earlyState(header, v); v.cancelled && done {
 688		return res
 689	}
 690
 691	taskTag := t.S().Base.Bold(true).Padding(0, 1).MarginLeft(2).Background(t.GreenLight).Foreground(t.Border).Render("Prompt")
 692	remainingWidth := v.textWidth() - (lipgloss.Width(taskTag) + 1)
 693	remainingWidth = min(remainingWidth, 120-(lipgloss.Width(taskTag)+1))
 694	prompt = t.S().Base.Width(remainingWidth).Render(prompt)
 695	header = lipgloss.JoinVertical(
 696		lipgloss.Left,
 697		header,
 698		"",
 699		lipgloss.JoinHorizontal(
 700			lipgloss.Left,
 701			taskTag,
 702			" ",
 703			prompt,
 704		),
 705	)
 706	childTools := tree.Root(header)
 707
 708	for _, call := range v.nestedToolCalls {
 709		call.SetSize(remainingWidth, 1)
 710		childTools.Child(call.View())
 711	}
 712	parts := []string{
 713		childTools.Enumerator(RoundedEnumeratorWithWidth(2, lipgloss.Width(taskTag)-5)).String(),
 714	}
 715
 716	if v.result.ToolCallID == "" {
 717		v.spinning = true
 718		parts = append(parts, "", v.anim.View())
 719	} else {
 720		v.spinning = false
 721	}
 722
 723	header = lipgloss.JoinVertical(
 724		lipgloss.Left,
 725		parts...,
 726	)
 727
 728	if v.result.ToolCallID == "" {
 729		return header
 730	}
 731	body := renderMarkdownContent(v, v.result.Content)
 732	return joinHeaderBody(header, body)
 733}
 734
 735// formatTimeout converts timeout seconds to duration string
 736func formatTimeout(timeout int) string {
 737	if timeout == 0 {
 738		return ""
 739	}
 740	return (time.Duration(timeout) * time.Second).String()
 741}
 742
 743// -----------------------------------------------------------------------------
 744//  Web fetch renderer
 745// -----------------------------------------------------------------------------
 746
 747// webFetchRenderer handles web page fetching with simplified URL display
 748type webFetchRenderer struct {
 749	baseRenderer
 750}
 751
 752// Render displays a compact view of web_fetch with just the URL in a link style
 753func (wfr webFetchRenderer) Render(v *toolCallCmp) string {
 754	var params tools.WebFetchParams
 755	var args []string
 756	if err := wfr.unmarshalParams(v.call.Input, &params); err == nil {
 757		args = newParamBuilder().
 758			addMain(params.URL).
 759			build()
 760	}
 761
 762	return wfr.renderWithParams(v, "Fetch", args, func() string {
 763		return renderMarkdownContent(v, v.result.Content)
 764	})
 765}
 766
 767// -----------------------------------------------------------------------------
 768//  Web search renderer
 769// -----------------------------------------------------------------------------
 770
 771// webSearchRenderer handles web search with query display
 772type webSearchRenderer struct {
 773	baseRenderer
 774}
 775
 776// Render displays a compact view of web_search with just the query
 777func (wsr webSearchRenderer) Render(v *toolCallCmp) string {
 778	var params tools.WebSearchParams
 779	var args []string
 780	if err := wsr.unmarshalParams(v.call.Input, &params); err == nil {
 781		args = newParamBuilder().
 782			addMain(params.Query).
 783			build()
 784	}
 785
 786	return wsr.renderWithParams(v, "Search", args, func() string {
 787		return renderMarkdownContent(v, v.result.Content)
 788	})
 789}
 790
 791// -----------------------------------------------------------------------------
 792//  Download renderer
 793// -----------------------------------------------------------------------------
 794
 795// downloadRenderer handles file downloading with URL and file path display
 796type downloadRenderer struct {
 797	baseRenderer
 798}
 799
 800// Render displays the download URL and destination file path with timeout parameter
 801func (dr downloadRenderer) Render(v *toolCallCmp) string {
 802	var params tools.DownloadParams
 803	var args []string
 804	if err := dr.unmarshalParams(v.call.Input, &params); err == nil {
 805		args = newParamBuilder().
 806			addMain(params.URL).
 807			addKeyValue("file_path", fsext.PrettyPath(params.FilePath)).
 808			addKeyValue("timeout", formatTimeout(params.Timeout)).
 809			build()
 810	}
 811
 812	return dr.renderWithParams(v, "Download", args, func() string {
 813		return renderPlainContent(v, v.result.Content)
 814	})
 815}
 816
 817// -----------------------------------------------------------------------------
 818//  Glob renderer
 819// -----------------------------------------------------------------------------
 820
 821// globRenderer handles file pattern matching with path filtering
 822type globRenderer struct {
 823	baseRenderer
 824}
 825
 826// Render displays the glob pattern with optional path parameter
 827func (gr globRenderer) Render(v *toolCallCmp) string {
 828	var params tools.GlobParams
 829	var args []string
 830	if err := gr.unmarshalParams(v.call.Input, &params); err == nil {
 831		args = newParamBuilder().
 832			addMain(params.Pattern).
 833			addKeyValue("path", params.Path).
 834			build()
 835	}
 836
 837	return gr.renderWithParams(v, "Glob", args, func() string {
 838		return renderPlainContent(v, v.result.Content)
 839	})
 840}
 841
 842// -----------------------------------------------------------------------------
 843//  Grep renderer
 844// -----------------------------------------------------------------------------
 845
 846// grepRenderer handles content searching with pattern matching options
 847type grepRenderer struct {
 848	baseRenderer
 849}
 850
 851// Render displays the search pattern with path, include, and literal text options
 852func (gr grepRenderer) Render(v *toolCallCmp) string {
 853	var params tools.GrepParams
 854	var args []string
 855	if err := gr.unmarshalParams(v.call.Input, &params); err == nil {
 856		args = newParamBuilder().
 857			addMain(params.Pattern).
 858			addKeyValue("path", params.Path).
 859			addKeyValue("include", params.Include).
 860			addFlag("literal", params.LiteralText).
 861			build()
 862	}
 863
 864	return gr.renderWithParams(v, "Grep", args, func() string {
 865		return renderPlainContent(v, v.result.Content)
 866	})
 867}
 868
 869// -----------------------------------------------------------------------------
 870//  LS renderer
 871// -----------------------------------------------------------------------------
 872
 873// lsRenderer handles directory listing with default path handling
 874type lsRenderer struct {
 875	baseRenderer
 876}
 877
 878// Render displays the directory path, defaulting to current directory
 879func (lr lsRenderer) Render(v *toolCallCmp) string {
 880	var params tools.LSParams
 881	var args []string
 882	if err := lr.unmarshalParams(v.call.Input, &params); err == nil {
 883		path := params.Path
 884		if path == "" {
 885			path = "."
 886		}
 887		path = fsext.PrettyPath(path)
 888
 889		args = newParamBuilder().addMain(path).build()
 890	}
 891
 892	return lr.renderWithParams(v, "List", args, func() string {
 893		return renderPlainContent(v, v.result.Content)
 894	})
 895}
 896
 897// -----------------------------------------------------------------------------
 898//  Sourcegraph renderer
 899// -----------------------------------------------------------------------------
 900
 901// sourcegraphRenderer handles code search with count and context options
 902type sourcegraphRenderer struct {
 903	baseRenderer
 904}
 905
 906// Render displays the search query with optional count and context window parameters
 907func (sr sourcegraphRenderer) Render(v *toolCallCmp) string {
 908	var params tools.SourcegraphParams
 909	var args []string
 910	if err := sr.unmarshalParams(v.call.Input, &params); err == nil {
 911		args = newParamBuilder().
 912			addMain(params.Query).
 913			addKeyValue("count", formatNonZero(params.Count)).
 914			addKeyValue("context", formatNonZero(params.ContextWindow)).
 915			build()
 916	}
 917
 918	return sr.renderWithParams(v, "Sourcegraph", args, func() string {
 919		return renderPlainContent(v, v.result.Content)
 920	})
 921}
 922
 923// -----------------------------------------------------------------------------
 924//  Diagnostics renderer
 925// -----------------------------------------------------------------------------
 926
 927// diagnosticsRenderer handles project-wide diagnostic information
 928type diagnosticsRenderer struct {
 929	baseRenderer
 930}
 931
 932// Render displays project diagnostics with plain content formatting
 933func (dr diagnosticsRenderer) Render(v *toolCallCmp) string {
 934	args := newParamBuilder().addMain("project").build()
 935
 936	return dr.renderWithParams(v, "Diagnostics", args, func() string {
 937		return renderPlainContent(v, v.result.Content)
 938	})
 939}
 940
 941// -----------------------------------------------------------------------------
 942//  Task renderer
 943// -----------------------------------------------------------------------------
 944
 945// agentRenderer handles project-wide diagnostic information
 946type agentRenderer struct {
 947	baseRenderer
 948}
 949
 950func RoundedEnumeratorWithWidth(lPadding, width int) tree.Enumerator {
 951	if width == 0 {
 952		width = 2
 953	}
 954	if lPadding == 0 {
 955		lPadding = 1
 956	}
 957	return func(children tree.Children, index int) string {
 958		line := strings.Repeat("─", width)
 959		padding := strings.Repeat(" ", lPadding)
 960		if children.Length()-1 == index {
 961			return padding + "╰" + line
 962		}
 963		return padding + "├" + line
 964	}
 965}
 966
 967// Render displays agent task parameters and result content
 968func (tr agentRenderer) Render(v *toolCallCmp) string {
 969	t := styles.CurrentTheme()
 970	var params agent.AgentParams
 971	tr.unmarshalParams(v.call.Input, &params)
 972
 973	prompt := params.Prompt
 974	prompt = strings.ReplaceAll(prompt, "\n", " ")
 975
 976	header := tr.makeHeader(v, "Agent", v.textWidth())
 977	if res, done := earlyState(header, v); v.cancelled && done {
 978		return res
 979	}
 980	taskTag := t.S().Base.Bold(true).Padding(0, 1).MarginLeft(2).Background(t.BlueLight).Foreground(t.White).Render("Task")
 981	remainingWidth := v.textWidth() - lipgloss.Width(header) - lipgloss.Width(taskTag) - 2
 982	remainingWidth = min(remainingWidth, 120-lipgloss.Width(taskTag)-2)
 983	prompt = t.S().Muted.Width(remainingWidth).Render(prompt)
 984	header = lipgloss.JoinVertical(
 985		lipgloss.Left,
 986		header,
 987		"",
 988		lipgloss.JoinHorizontal(
 989			lipgloss.Left,
 990			taskTag,
 991			" ",
 992			prompt,
 993		),
 994	)
 995	childTools := tree.Root(header)
 996
 997	for _, call := range v.nestedToolCalls {
 998		call.SetSize(remainingWidth, 1)
 999		childTools.Child(call.View())
1000	}
1001	parts := []string{
1002		childTools.Enumerator(RoundedEnumeratorWithWidth(2, lipgloss.Width(taskTag)-5)).String(),
1003	}
1004
1005	if v.result.ToolCallID == "" {
1006		v.spinning = true
1007		parts = append(parts, "", v.anim.View())
1008	} else {
1009		v.spinning = false
1010	}
1011
1012	header = lipgloss.JoinVertical(
1013		lipgloss.Left,
1014		parts...,
1015	)
1016
1017	if v.result.ToolCallID == "" {
1018		return header
1019	}
1020
1021	body := renderMarkdownContent(v, v.result.Content)
1022	return joinHeaderBody(header, body)
1023}
1024
1025// renderParamList renders params, params[0] (params[1]=params[2] ....)
1026func renderParamList(nested bool, paramsWidth int, params ...string) string {
1027	t := styles.CurrentTheme()
1028	if len(params) == 0 {
1029		return ""
1030	}
1031	mainParam := params[0]
1032	if paramsWidth >= 0 && lipgloss.Width(mainParam) > paramsWidth {
1033		mainParam = ansi.Truncate(mainParam, paramsWidth, "…")
1034	}
1035
1036	if len(params) == 1 {
1037		return t.S().Subtle.Render(mainParam)
1038	}
1039	otherParams := params[1:]
1040	// create pairs of key/value
1041	// if odd number of params, the last one is a key without value
1042	if len(otherParams)%2 != 0 {
1043		otherParams = append(otherParams, "")
1044	}
1045	parts := make([]string, 0, len(otherParams)/2)
1046	for i := 0; i < len(otherParams); i += 2 {
1047		key := otherParams[i]
1048		value := otherParams[i+1]
1049		if value == "" {
1050			continue
1051		}
1052		parts = append(parts, fmt.Sprintf("%s=%s", key, value))
1053	}
1054
1055	partsRendered := strings.Join(parts, ", ")
1056	remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 3 // count for " ()"
1057	if remainingWidth < 30 {
1058		// No space for the params, just show the main
1059		return t.S().Subtle.Render(mainParam)
1060	}
1061
1062	if len(parts) > 0 {
1063		mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
1064	}
1065
1066	return t.S().Subtle.Render(ansi.Truncate(mainParam, paramsWidth, "…"))
1067}
1068
1069// earlyState returns immediately‑rendered error/cancelled/ongoing states.
1070func earlyState(header string, v *toolCallCmp) (string, bool) {
1071	t := styles.CurrentTheme()
1072	message := ""
1073	switch {
1074	case v.result.IsError:
1075		message = v.renderToolError()
1076	case v.cancelled:
1077		message = t.S().Base.Foreground(t.FgSubtle).Render("Canceled.")
1078	case v.result.ToolCallID == "":
1079		if v.permissionRequested && !v.permissionGranted {
1080			message = t.S().Base.Foreground(t.FgSubtle).Render("Requesting permission...")
1081		} else {
1082			message = t.S().Base.Foreground(t.FgSubtle).Render("Waiting for tool response...")
1083		}
1084	default:
1085		return "", false
1086	}
1087
1088	message = t.S().Base.PaddingLeft(2).Render(message)
1089	return lipgloss.JoinVertical(lipgloss.Left, header, "", message), true
1090}
1091
1092func joinHeaderBody(header, body string) string {
1093	t := styles.CurrentTheme()
1094	if body == "" {
1095		return header
1096	}
1097	body = t.S().Base.PaddingLeft(2).Render(body)
1098	return lipgloss.JoinVertical(lipgloss.Left, header, "", body)
1099}
1100
1101func renderPlainContent(v *toolCallCmp, content string) string {
1102	t := styles.CurrentTheme()
1103	content = strings.ReplaceAll(content, "\r\n", "\n") // Normalize line endings
1104	content = strings.ReplaceAll(content, "\t", "    ") // Replace tabs with spaces
1105	content = strings.TrimSpace(content)
1106	lines := strings.Split(content, "\n")
1107
1108	width := v.textWidth() - 2
1109	var out []string
1110	for i, ln := range lines {
1111		if i >= responseContextHeight {
1112			break
1113		}
1114		ln = ansiext.Escape(ln)
1115		ln = " " + ln
1116		if lipgloss.Width(ln) > width {
1117			ln = v.fit(ln, width)
1118		}
1119		out = append(out, t.S().Muted.
1120			Width(width).
1121			Background(t.BgBaseLighter).
1122			Render(ln))
1123	}
1124
1125	if len(lines) > responseContextHeight {
1126		out = append(out, t.S().Muted.
1127			Background(t.BgBaseLighter).
1128			Width(width).
1129			Render(fmt.Sprintf("… (%d lines)", len(lines)-responseContextHeight)))
1130	}
1131
1132	return strings.Join(out, "\n")
1133}
1134
1135func renderMarkdownContent(v *toolCallCmp, content string) string {
1136	t := styles.CurrentTheme()
1137	content = strings.ReplaceAll(content, "\r\n", "\n")
1138	content = strings.ReplaceAll(content, "\t", "    ")
1139	content = strings.TrimSpace(content)
1140
1141	width := v.textWidth() - 2
1142	width = min(width, 120)
1143
1144	renderer := styles.GetPlainMarkdownRenderer(width)
1145	rendered, err := renderer.Render(content)
1146	if err != nil {
1147		return renderPlainContent(v, content)
1148	}
1149
1150	lines := strings.Split(rendered, "\n")
1151
1152	var out []string
1153	for i, ln := range lines {
1154		if i >= responseContextHeight {
1155			break
1156		}
1157		out = append(out, ln)
1158	}
1159
1160	style := t.S().Muted.Background(t.BgBaseLighter)
1161	if len(lines) > responseContextHeight {
1162		out = append(out, style.
1163			Width(width-2).
1164			Render(fmt.Sprintf("… (%d lines)", len(lines)-responseContextHeight)))
1165	}
1166
1167	return style.Render(strings.Join(out, "\n"))
1168}
1169
1170func getDigits(n int) int {
1171	if n == 0 {
1172		return 1
1173	}
1174	if n < 0 {
1175		n = -n
1176	}
1177
1178	digits := 0
1179	for n > 0 {
1180		n /= 10
1181		digits++
1182	}
1183
1184	return digits
1185}
1186
1187func renderCodeContent(v *toolCallCmp, path, content string, offset int) string {
1188	t := styles.CurrentTheme()
1189	content = strings.ReplaceAll(content, "\r\n", "\n") // Normalize line endings
1190	content = strings.ReplaceAll(content, "\t", "    ") // Replace tabs with spaces
1191	truncated := truncateHeight(content, responseContextHeight)
1192
1193	lines := strings.Split(truncated, "\n")
1194	for i, ln := range lines {
1195		lines[i] = ansiext.Escape(ln)
1196	}
1197
1198	bg := t.BgBase
1199	highlighted, _ := highlight.SyntaxHighlight(strings.Join(lines, "\n"), path, bg)
1200	lines = strings.Split(highlighted, "\n")
1201
1202	if len(strings.Split(content, "\n")) > responseContextHeight {
1203		lines = append(lines, t.S().Muted.
1204			Background(bg).
1205			Render(fmt.Sprintf(" …(%d lines)", len(strings.Split(content, "\n"))-responseContextHeight)))
1206	}
1207
1208	maxLineNumber := len(lines) + offset
1209	maxDigits := getDigits(maxLineNumber)
1210	numFmt := fmt.Sprintf("%%%dd", maxDigits)
1211	const numPR, numPL, codePR, codePL = 1, 1, 1, 2
1212	w := v.textWidth() - maxDigits - numPL - numPR - 2 // -2 for left padding
1213	for i, ln := range lines {
1214		num := t.S().Base.
1215			Foreground(t.FgMuted).
1216			Background(t.BgBase).
1217			PaddingRight(1).
1218			PaddingLeft(1).
1219			Render(fmt.Sprintf(numFmt, i+1+offset))
1220		lines[i] = lipgloss.JoinHorizontal(lipgloss.Left,
1221			num,
1222			t.S().Base.
1223				Width(w).
1224				Background(bg).
1225				PaddingRight(1).
1226				PaddingLeft(2).
1227				Render(v.fit(ln, w-codePL-codePR)),
1228		)
1229	}
1230
1231	return lipgloss.JoinVertical(lipgloss.Left, lines...)
1232}
1233
1234// renderImageContent renders image data with optional text content (for MCP tools).
1235func renderImageContent(v *toolCallCmp, data, mediaType, textContent string) string {
1236	t := styles.CurrentTheme()
1237
1238	dataSize := len(data) * 3 / 4
1239	sizeStr := formatSize(dataSize)
1240
1241	loaded := t.S().Base.Foreground(t.Green).Render("Loaded")
1242	arrow := t.S().Base.Foreground(t.GreenDark).Render("→")
1243	typeStyled := t.S().Base.Render(mediaType)
1244	sizeStyled := t.S().Subtle.Render(sizeStr)
1245
1246	imageDisplay := fmt.Sprintf("%s %s %s %s", loaded, arrow, typeStyled, sizeStyled)
1247	if strings.TrimSpace(textContent) != "" {
1248		textDisplay := renderPlainContent(v, textContent)
1249		return lipgloss.JoinVertical(lipgloss.Left, textDisplay, "", imageDisplay)
1250	}
1251
1252	return imageDisplay
1253}
1254
1255// renderMediaContent renders non-image media content.
1256func renderMediaContent(v *toolCallCmp, mediaType, textContent string) string {
1257	t := styles.CurrentTheme()
1258
1259	loaded := t.S().Base.Foreground(t.Green).Render("Loaded")
1260	arrow := t.S().Base.Foreground(t.GreenDark).Render("→")
1261	typeStyled := t.S().Base.Render(mediaType)
1262	mediaDisplay := fmt.Sprintf("%s %s %s", loaded, arrow, typeStyled)
1263
1264	if strings.TrimSpace(textContent) != "" {
1265		textDisplay := renderPlainContent(v, textContent)
1266		return lipgloss.JoinVertical(lipgloss.Left, textDisplay, "", mediaDisplay)
1267	}
1268
1269	return mediaDisplay
1270}
1271
1272// formatSize formats byte count as human-readable size.
1273func formatSize(bytes int) string {
1274	if bytes < 1024 {
1275		return fmt.Sprintf("%d B", bytes)
1276	}
1277	if bytes < 1024*1024 {
1278		return fmt.Sprintf("%.1f KB", float64(bytes)/1024)
1279	}
1280	return fmt.Sprintf("%.1f MB", float64(bytes)/(1024*1024))
1281}
1282
1283func (v *toolCallCmp) renderToolError() string {
1284	t := styles.CurrentTheme()
1285	err := strings.ReplaceAll(v.result.Content, "\n", " ")
1286	errTag := t.S().Base.Padding(0, 1).Background(t.Red).Foreground(t.White).Render("ERROR")
1287	err = fmt.Sprintf("%s %s", errTag, t.S().Base.Foreground(t.FgHalfMuted).Render(v.fit(err, v.textWidth()-2-lipgloss.Width(errTag))))
1288	return err
1289}
1290
1291func truncateHeight(s string, h int) string {
1292	lines := strings.Split(s, "\n")
1293	if len(lines) > h {
1294		return strings.Join(lines[:h], "\n")
1295	}
1296	return s
1297}
1298
1299func prettifyToolName(name string) string {
1300	switch name {
1301	case agent.AgentToolName:
1302		return "Agent"
1303	case tools.BashToolName:
1304		return "Bash"
1305	case tools.JobOutputToolName:
1306		return "Job: Output"
1307	case tools.JobKillToolName:
1308		return "Job: Kill"
1309	case tools.DownloadToolName:
1310		return "Download"
1311	case tools.EditToolName:
1312		return "Edit"
1313	case tools.MultiEditToolName:
1314		return "Multi-Edit"
1315	case tools.FetchToolName:
1316		return "Fetch"
1317	case tools.AgenticFetchToolName:
1318		return "Agentic Fetch"
1319	case tools.WebFetchToolName:
1320		return "Fetch"
1321	case tools.WebSearchToolName:
1322		return "Search"
1323	case tools.GlobToolName:
1324		return "Glob"
1325	case tools.GrepToolName:
1326		return "Grep"
1327	case tools.LSToolName:
1328		return "List"
1329	case tools.SourcegraphToolName:
1330		return "Sourcegraph"
1331	case tools.TodosToolName:
1332		return "To-Do"
1333	case tools.ViewToolName:
1334		return "View"
1335	case tools.WriteToolName:
1336		return "Write"
1337	default:
1338		return name
1339	}
1340}
1341
1342// -----------------------------------------------------------------------------
1343//  Todos renderer
1344// -----------------------------------------------------------------------------
1345
1346type todosRenderer struct {
1347	baseRenderer
1348}
1349
1350func (tr todosRenderer) Render(v *toolCallCmp) string {
1351	t := styles.CurrentTheme()
1352	var params tools.TodosParams
1353	var meta tools.TodosResponseMetadata
1354	var headerText string
1355	var body string
1356
1357	// Parse params for pending state (before result is available).
1358	if err := tr.unmarshalParams(v.call.Input, &params); err == nil {
1359		completedCount := 0
1360		inProgressTask := ""
1361		for _, todo := range params.Todos {
1362			if todo.Status == "completed" {
1363				completedCount++
1364			}
1365			if todo.Status == "in_progress" {
1366				if todo.ActiveForm != "" {
1367					inProgressTask = todo.ActiveForm
1368				} else {
1369					inProgressTask = todo.Content
1370				}
1371			}
1372		}
1373
1374		// Default display from params (used when pending or no metadata).
1375		ratio := t.S().Base.Foreground(t.BlueDark).Render(fmt.Sprintf("%d/%d", completedCount, len(params.Todos)))
1376		headerText = ratio
1377		if inProgressTask != "" {
1378			headerText = fmt.Sprintf("%s · %s", ratio, inProgressTask)
1379		}
1380
1381		// If we have metadata, use it for richer display.
1382		if v.result.Metadata != "" {
1383			if err := tr.unmarshalParams(v.result.Metadata, &meta); err == nil {
1384				if meta.IsNew {
1385					if meta.JustStarted != "" {
1386						headerText = fmt.Sprintf("created %d todos, starting first", meta.Total)
1387					} else {
1388						headerText = fmt.Sprintf("created %d todos", meta.Total)
1389					}
1390					body = todos.FormatTodosList(meta.Todos, styles.ArrowRightIcon, t, v.textWidth())
1391				} else {
1392					// Build header based on what changed.
1393					hasCompleted := len(meta.JustCompleted) > 0
1394					hasStarted := meta.JustStarted != ""
1395					allCompleted := meta.Completed == meta.Total
1396
1397					ratio := t.S().Base.Foreground(t.BlueDark).Render(fmt.Sprintf("%d/%d", meta.Completed, meta.Total))
1398					if hasCompleted && hasStarted {
1399						text := t.S().Subtle.Render(fmt.Sprintf(" · completed %d, starting next", len(meta.JustCompleted)))
1400						headerText = fmt.Sprintf("%s%s", ratio, text)
1401					} else if hasCompleted {
1402						text := t.S().Subtle.Render(fmt.Sprintf(" · completed %d", len(meta.JustCompleted)))
1403						if allCompleted {
1404							text = t.S().Subtle.Render(" · completed all")
1405						}
1406						headerText = fmt.Sprintf("%s%s", ratio, text)
1407					} else if hasStarted {
1408						headerText = fmt.Sprintf("%s%s", ratio, t.S().Subtle.Render(" · starting task"))
1409					} else {
1410						headerText = ratio
1411					}
1412
1413					// Build body with details.
1414					if allCompleted {
1415						// Show all todos when all are completed, like when created
1416						body = todos.FormatTodosList(meta.Todos, styles.ArrowRightIcon, t, v.textWidth())
1417					} else if meta.JustStarted != "" {
1418						body = t.S().Base.Foreground(t.GreenDark).Render(styles.ArrowRightIcon+" ") +
1419							t.S().Base.Foreground(t.FgBase).Render(meta.JustStarted)
1420					}
1421				}
1422			}
1423		}
1424	}
1425
1426	args := newParamBuilder().addMain(headerText).build()
1427
1428	return tr.renderWithParams(v, "To-Do", args, func() string {
1429		return body
1430	})
1431}