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
  13	"git.secluded.site/crush/internal/agent"
  14	"git.secluded.site/crush/internal/agent/tools"
  15	"git.secluded.site/crush/internal/ansiext"
  16	"git.secluded.site/crush/internal/fsext"
  17	"git.secluded.site/crush/internal/tui/components/chat/todos"
  18	"git.secluded.site/crush/internal/tui/components/core"
  19	"git.secluded.site/crush/internal/tui/highlight"
  20	"git.secluded.site/crush/internal/tui/styles"
  21	"github.com/charmbracelet/x/ansi"
  22)
  23
  24// responseContextHeight limits the number of lines displayed in tool output
  25const responseContextHeight = 10
  26
  27// renderer defines the interface for tool-specific rendering implementations
  28type renderer interface {
  29	// Render returns the complete (already styled) tool‑call view, not
  30	// including the outer border.
  31	Render(v *toolCallCmp) string
  32}
  33
  34// rendererFactory creates new renderer instances
  35type rendererFactory func() renderer
  36
  37// renderRegistry manages the mapping of tool names to their renderers
  38type renderRegistry map[string]rendererFactory
  39
  40// register adds a new renderer factory to the registry
  41func (rr renderRegistry) register(name string, f rendererFactory) { rr[name] = f }
  42
  43// lookup retrieves a renderer for the given tool name, falling back to generic renderer
  44func (rr renderRegistry) lookup(name string) renderer {
  45	if f, ok := rr[name]; ok {
  46		return f()
  47	}
  48	return genericRenderer{} // sensible fallback
  49}
  50
  51// registry holds all registered tool renderers
  52var registry = renderRegistry{}
  53
  54// baseRenderer provides common functionality for all tool renderers
  55type baseRenderer struct{}
  56
  57func (br baseRenderer) Render(v *toolCallCmp) string {
  58	if v.result.Data != "" {
  59		if strings.HasPrefix(v.result.MIMEType, "image/") {
  60			return br.renderWithParams(v, v.call.Name, nil, func() string {
  61				return renderImageContent(v, v.result.Data, v.result.MIMEType, v.result.Content)
  62			})
  63		}
  64		return br.renderWithParams(v, v.call.Name, nil, func() string {
  65			return renderMediaContent(v, v.result.MIMEType, v.result.Content)
  66		})
  67	}
  68
  69	return br.renderWithParams(v, v.call.Name, nil, func() string {
  70		return renderPlainContent(v, v.result.Content)
  71	})
  72}
  73
  74// paramBuilder helps construct parameter lists for tool headers
  75type paramBuilder struct {
  76	args []string
  77}
  78
  79// newParamBuilder creates a new parameter builder
  80func newParamBuilder() *paramBuilder {
  81	return &paramBuilder{args: make([]string, 0)}
  82}
  83
  84// addMain adds the main parameter (first argument)
  85func (pb *paramBuilder) addMain(value string) *paramBuilder {
  86	if value != "" {
  87		pb.args = append(pb.args, value)
  88	}
  89	return pb
  90}
  91
  92// addKeyValue adds a key-value pair parameter
  93func (pb *paramBuilder) addKeyValue(key, value string) *paramBuilder {
  94	if value != "" {
  95		pb.args = append(pb.args, key, value)
  96	}
  97	return pb
  98}
  99
 100// addFlag adds a boolean flag parameter
 101func (pb *paramBuilder) addFlag(key string, value bool) *paramBuilder {
 102	if value {
 103		pb.args = append(pb.args, key, "true")
 104	}
 105	return pb
 106}
 107
 108// build returns the final parameter list
 109func (pb *paramBuilder) build() []string {
 110	return pb.args
 111}
 112
 113// renderWithParams provides a common rendering pattern for tools with parameters
 114func (br baseRenderer) renderWithParams(v *toolCallCmp, toolName string, args []string, contentRenderer func() string) string {
 115	width := v.textWidth()
 116	if v.isNested {
 117		width -= 4 // Adjust for nested tool call indentation
 118	}
 119	header := br.makeHeader(v, toolName, width, args...)
 120	if v.isNested {
 121		return v.style().Render(header)
 122	}
 123	if res, done := earlyState(header, v); done {
 124		return res
 125	}
 126	body := contentRenderer()
 127	return joinHeaderBody(header, body)
 128}
 129
 130// unmarshalParams safely unmarshal JSON parameters
 131func (br baseRenderer) unmarshalParams(input string, target any) error {
 132	return json.Unmarshal([]byte(input), target)
 133}
 134
 135// makeHeader builds the tool call header with status icon and parameters for a nested tool call.
 136func (br baseRenderer) makeNestedHeader(v *toolCallCmp, tool string, width int, params ...string) string {
 137	t := styles.CurrentTheme()
 138	icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
 139	if v.result.ToolCallID != "" {
 140		if v.result.IsError {
 141			icon = t.S().Base.Foreground(t.RedDark).Render(styles.ToolError)
 142		} else {
 143			icon = t.S().Base.Foreground(t.Green).Render(styles.ToolSuccess)
 144		}
 145	} else if v.cancelled {
 146		icon = t.S().Muted.Render(styles.ToolPending)
 147	}
 148	tool = t.S().Base.Foreground(t.FgHalfMuted).Render(tool)
 149	prefix := fmt.Sprintf("%s %s ", icon, tool)
 150	return prefix + renderParamList(true, width-lipgloss.Width(prefix), params...)
 151}
 152
 153// makeHeader builds "<Tool>: param (key=value)" and truncates as needed.
 154func (br baseRenderer) makeHeader(v *toolCallCmp, tool string, width int, params ...string) string {
 155	if v.isNested {
 156		return br.makeNestedHeader(v, tool, width, params...)
 157	}
 158	t := styles.CurrentTheme()
 159	icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
 160	if v.result.ToolCallID != "" {
 161		if v.result.IsError {
 162			icon = t.S().Base.Foreground(t.RedDark).Render(styles.ToolError)
 163		} else {
 164			icon = t.S().Base.Foreground(t.Green).Render(styles.ToolSuccess)
 165		}
 166	} else if v.cancelled {
 167		icon = t.S().Muted.Render(styles.ToolPending)
 168	}
 169	tool = t.S().Base.Foreground(t.Blue).Render(tool)
 170	prefix := fmt.Sprintf("%s %s ", icon, tool)
 171	return prefix + renderParamList(false, width-lipgloss.Width(prefix), params...)
 172}
 173
 174// renderError provides consistent error rendering
 175func (br baseRenderer) renderError(v *toolCallCmp, message string) string {
 176	t := styles.CurrentTheme()
 177	header := br.makeHeader(v, prettifyToolName(v.call.Name), v.textWidth(), "")
 178	errorTag := t.S().Base.Padding(0, 1).Background(t.Red).Foreground(t.White).Render("ERROR")
 179	message = t.S().Base.Foreground(t.FgHalfMuted).Render(v.fit(message, v.textWidth()-3-lipgloss.Width(errorTag))) // -2 for padding and space
 180	return joinHeaderBody(header, errorTag+" "+message)
 181}
 182
 183// Register tool renderers
 184func init() {
 185	registry.register(tools.BashToolName, func() renderer { return bashRenderer{} })
 186	registry.register(tools.JobOutputToolName, func() renderer { return bashOutputRenderer{} })
 187	registry.register(tools.JobKillToolName, func() renderer { return bashKillRenderer{} })
 188	registry.register(tools.DownloadToolName, func() renderer { return downloadRenderer{} })
 189	registry.register(tools.ViewToolName, func() renderer { return viewRenderer{} })
 190	registry.register(tools.EditToolName, func() renderer { return editRenderer{} })
 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//  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// formatTimeout converts timeout seconds to duration string
 709func formatTimeout(timeout int) string {
 710	if timeout == 0 {
 711		return ""
 712	}
 713	return (time.Duration(timeout) * time.Second).String()
 714}
 715
 716// -----------------------------------------------------------------------------
 717//  Web fetch renderer
 718// -----------------------------------------------------------------------------
 719
 720// webFetchRenderer handles web page fetching with simplified URL display
 721type webFetchRenderer struct {
 722	baseRenderer
 723}
 724
 725// Render displays a compact view of web_fetch with just the URL in a link style
 726func (wfr webFetchRenderer) Render(v *toolCallCmp) string {
 727	var params tools.WebFetchParams
 728	var args []string
 729	if err := wfr.unmarshalParams(v.call.Input, &params); err == nil {
 730		args = newParamBuilder().
 731			addMain(params.URL).
 732			build()
 733	}
 734
 735	return wfr.renderWithParams(v, "Fetch", args, func() string {
 736		return renderMarkdownContent(v, v.result.Content)
 737	})
 738}
 739
 740// -----------------------------------------------------------------------------
 741//  Web search renderer
 742// -----------------------------------------------------------------------------
 743
 744// webSearchRenderer handles web search with query display
 745type webSearchRenderer struct {
 746	baseRenderer
 747}
 748
 749// Render displays a compact view of web_search with just the query
 750func (wsr webSearchRenderer) Render(v *toolCallCmp) string {
 751	var params tools.WebSearchParams
 752	var args []string
 753	if err := wsr.unmarshalParams(v.call.Input, &params); err == nil {
 754		args = newParamBuilder().
 755			addMain(params.Query).
 756			build()
 757	}
 758
 759	return wsr.renderWithParams(v, "Search", args, func() string {
 760		return renderMarkdownContent(v, v.result.Content)
 761	})
 762}
 763
 764// -----------------------------------------------------------------------------
 765//  Download renderer
 766// -----------------------------------------------------------------------------
 767
 768// downloadRenderer handles file downloading with URL and file path display
 769type downloadRenderer struct {
 770	baseRenderer
 771}
 772
 773// Render displays the download URL and destination file path with timeout parameter
 774func (dr downloadRenderer) Render(v *toolCallCmp) string {
 775	var params tools.DownloadParams
 776	var args []string
 777	if err := dr.unmarshalParams(v.call.Input, &params); err == nil {
 778		args = newParamBuilder().
 779			addMain(params.URL).
 780			addKeyValue("file_path", fsext.PrettyPath(params.FilePath)).
 781			addKeyValue("timeout", formatTimeout(params.Timeout)).
 782			build()
 783	}
 784
 785	return dr.renderWithParams(v, "Download", args, func() string {
 786		return renderPlainContent(v, v.result.Content)
 787	})
 788}
 789
 790// -----------------------------------------------------------------------------
 791//  Glob renderer
 792// -----------------------------------------------------------------------------
 793
 794// globRenderer handles file pattern matching with path filtering
 795type globRenderer struct {
 796	baseRenderer
 797}
 798
 799// Render displays the glob pattern with optional path parameter
 800func (gr globRenderer) Render(v *toolCallCmp) string {
 801	var params tools.GlobParams
 802	var args []string
 803	if err := gr.unmarshalParams(v.call.Input, &params); err == nil {
 804		args = newParamBuilder().
 805			addMain(params.Pattern).
 806			addKeyValue("path", params.Path).
 807			build()
 808	}
 809
 810	return gr.renderWithParams(v, "Glob", args, func() string {
 811		return renderPlainContent(v, v.result.Content)
 812	})
 813}
 814
 815// -----------------------------------------------------------------------------
 816//  Grep renderer
 817// -----------------------------------------------------------------------------
 818
 819// grepRenderer handles content searching with pattern matching options
 820type grepRenderer struct {
 821	baseRenderer
 822}
 823
 824// Render displays the search pattern with path, include, and literal text options
 825func (gr grepRenderer) Render(v *toolCallCmp) string {
 826	var params tools.GrepParams
 827	var args []string
 828	if err := gr.unmarshalParams(v.call.Input, &params); err == nil {
 829		args = newParamBuilder().
 830			addMain(params.Pattern).
 831			addKeyValue("path", params.Path).
 832			addKeyValue("include", params.Include).
 833			addFlag("literal", params.LiteralText).
 834			build()
 835	}
 836
 837	return gr.renderWithParams(v, "Grep", args, func() string {
 838		return renderPlainContent(v, v.result.Content)
 839	})
 840}
 841
 842// -----------------------------------------------------------------------------
 843//  LS renderer
 844// -----------------------------------------------------------------------------
 845
 846// lsRenderer handles directory listing with default path handling
 847type lsRenderer struct {
 848	baseRenderer
 849}
 850
 851// Render displays the directory path, defaulting to current directory
 852func (lr lsRenderer) Render(v *toolCallCmp) string {
 853	var params tools.LSParams
 854	var args []string
 855	if err := lr.unmarshalParams(v.call.Input, &params); err == nil {
 856		path := params.Path
 857		if path == "" {
 858			path = "."
 859		}
 860		path = fsext.PrettyPath(path)
 861
 862		args = newParamBuilder().addMain(path).build()
 863	}
 864
 865	return lr.renderWithParams(v, "List", args, func() string {
 866		return renderPlainContent(v, v.result.Content)
 867	})
 868}
 869
 870// -----------------------------------------------------------------------------
 871//  Sourcegraph renderer
 872// -----------------------------------------------------------------------------
 873
 874// sourcegraphRenderer handles code search with count and context options
 875type sourcegraphRenderer struct {
 876	baseRenderer
 877}
 878
 879// Render displays the search query with optional count and context window parameters
 880func (sr sourcegraphRenderer) Render(v *toolCallCmp) string {
 881	var params tools.SourcegraphParams
 882	var args []string
 883	if err := sr.unmarshalParams(v.call.Input, &params); err == nil {
 884		args = newParamBuilder().
 885			addMain(params.Query).
 886			addKeyValue("count", formatNonZero(params.Count)).
 887			addKeyValue("context", formatNonZero(params.ContextWindow)).
 888			build()
 889	}
 890
 891	return sr.renderWithParams(v, "Sourcegraph", args, func() string {
 892		return renderPlainContent(v, v.result.Content)
 893	})
 894}
 895
 896// -----------------------------------------------------------------------------
 897//  Diagnostics renderer
 898// -----------------------------------------------------------------------------
 899
 900// diagnosticsRenderer handles project-wide diagnostic information
 901type diagnosticsRenderer struct {
 902	baseRenderer
 903}
 904
 905// Render displays project diagnostics with plain content formatting
 906func (dr diagnosticsRenderer) Render(v *toolCallCmp) string {
 907	args := newParamBuilder().addMain("project").build()
 908
 909	return dr.renderWithParams(v, "Diagnostics", args, func() string {
 910		return renderPlainContent(v, v.result.Content)
 911	})
 912}
 913
 914// -----------------------------------------------------------------------------
 915//  Task renderer
 916// -----------------------------------------------------------------------------
 917
 918// agentRenderer handles project-wide diagnostic information
 919type agentRenderer struct {
 920	baseRenderer
 921}
 922
 923func RoundedEnumeratorWithWidth(lPadding, width int) tree.Enumerator {
 924	if width == 0 {
 925		width = 2
 926	}
 927	if lPadding == 0 {
 928		lPadding = 1
 929	}
 930	return func(children tree.Children, index int) string {
 931		line := strings.Repeat("─", width)
 932		padding := strings.Repeat(" ", lPadding)
 933		if children.Length()-1 == index {
 934			return padding + "╰" + line
 935		}
 936		return padding + "├" + line
 937	}
 938}
 939
 940// Render displays agent task parameters and result content
 941func (tr agentRenderer) Render(v *toolCallCmp) string {
 942	t := styles.CurrentTheme()
 943	var params agent.AgentParams
 944	tr.unmarshalParams(v.call.Input, &params)
 945
 946	prompt := params.Prompt
 947	prompt = strings.ReplaceAll(prompt, "\n", " ")
 948
 949	header := tr.makeHeader(v, "Agent", v.textWidth())
 950	if res, done := earlyState(header, v); v.cancelled && done {
 951		return res
 952	}
 953	taskTag := t.S().Base.Bold(true).Padding(0, 1).MarginLeft(2).Background(t.BlueLight).Foreground(t.White).Render("Task")
 954	remainingWidth := v.textWidth() - lipgloss.Width(header) - lipgloss.Width(taskTag) - 2
 955	remainingWidth = min(remainingWidth, 120-lipgloss.Width(taskTag)-2)
 956	prompt = t.S().Muted.Width(remainingWidth).Render(prompt)
 957	header = lipgloss.JoinVertical(
 958		lipgloss.Left,
 959		header,
 960		"",
 961		lipgloss.JoinHorizontal(
 962			lipgloss.Left,
 963			taskTag,
 964			" ",
 965			prompt,
 966		),
 967	)
 968	childTools := tree.Root(header)
 969
 970	for _, call := range v.nestedToolCalls {
 971		call.SetSize(remainingWidth, 1)
 972		childTools.Child(call.View())
 973	}
 974	parts := []string{
 975		childTools.Enumerator(RoundedEnumeratorWithWidth(2, lipgloss.Width(taskTag)-5)).String(),
 976	}
 977
 978	if v.result.ToolCallID == "" {
 979		v.spinning = true
 980		parts = append(parts, "", v.anim.View())
 981	} else {
 982		v.spinning = false
 983	}
 984
 985	header = lipgloss.JoinVertical(
 986		lipgloss.Left,
 987		parts...,
 988	)
 989
 990	if v.result.ToolCallID == "" {
 991		return header
 992	}
 993
 994	body := renderMarkdownContent(v, v.result.Content)
 995	return joinHeaderBody(header, body)
 996}
 997
 998// renderParamList renders params, params[0] (params[1]=params[2] ....)
 999func renderParamList(nested bool, paramsWidth int, params ...string) string {
1000	t := styles.CurrentTheme()
1001	if len(params) == 0 {
1002		return ""
1003	}
1004	mainParam := params[0]
1005	if paramsWidth >= 0 && lipgloss.Width(mainParam) > paramsWidth {
1006		mainParam = ansi.Truncate(mainParam, paramsWidth, "…")
1007	}
1008
1009	if len(params) == 1 {
1010		return t.S().Subtle.Render(mainParam)
1011	}
1012	otherParams := params[1:]
1013	// create pairs of key/value
1014	// if odd number of params, the last one is a key without value
1015	if len(otherParams)%2 != 0 {
1016		otherParams = append(otherParams, "")
1017	}
1018	parts := make([]string, 0, len(otherParams)/2)
1019	for i := 0; i < len(otherParams); i += 2 {
1020		key := otherParams[i]
1021		value := otherParams[i+1]
1022		if value == "" {
1023			continue
1024		}
1025		parts = append(parts, fmt.Sprintf("%s=%s", key, value))
1026	}
1027
1028	partsRendered := strings.Join(parts, ", ")
1029	remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 3 // count for " ()"
1030	if remainingWidth < 30 {
1031		// No space for the params, just show the main
1032		return t.S().Subtle.Render(mainParam)
1033	}
1034
1035	if len(parts) > 0 {
1036		mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
1037	}
1038
1039	return t.S().Subtle.Render(ansi.Truncate(mainParam, paramsWidth, "…"))
1040}
1041
1042// earlyState returns immediately‑rendered error/cancelled/ongoing states.
1043func earlyState(header string, v *toolCallCmp) (string, bool) {
1044	t := styles.CurrentTheme()
1045	message := ""
1046	switch {
1047	case v.result.IsError:
1048		message = v.renderToolError()
1049	case v.cancelled:
1050		message = t.S().Base.Foreground(t.FgSubtle).Render("Canceled.")
1051	case v.result.ToolCallID == "":
1052		if v.permissionRequested && !v.permissionGranted {
1053			message = t.S().Base.Foreground(t.FgSubtle).Render("Requesting permission...")
1054		} else {
1055			message = t.S().Base.Foreground(t.FgSubtle).Render("Waiting for tool response...")
1056		}
1057	default:
1058		return "", false
1059	}
1060
1061	message = t.S().Base.PaddingLeft(2).Render(message)
1062	return lipgloss.JoinVertical(lipgloss.Left, header, "", message), true
1063}
1064
1065func joinHeaderBody(header, body string) string {
1066	t := styles.CurrentTheme()
1067	if body == "" {
1068		return header
1069	}
1070	body = t.S().Base.PaddingLeft(2).Render(body)
1071	return lipgloss.JoinVertical(lipgloss.Left, header, "", body)
1072}
1073
1074func renderPlainContent(v *toolCallCmp, content string) string {
1075	t := styles.CurrentTheme()
1076	content = strings.ReplaceAll(content, "\r\n", "\n") // Normalize line endings
1077	content = strings.ReplaceAll(content, "\t", "    ") // Replace tabs with spaces
1078	content = strings.TrimSpace(content)
1079	lines := strings.Split(content, "\n")
1080
1081	width := v.textWidth() - 2
1082	var out []string
1083	for i, ln := range lines {
1084		if i >= responseContextHeight {
1085			break
1086		}
1087		ln = ansiext.Escape(ln)
1088		ln = " " + ln
1089		if lipgloss.Width(ln) > width {
1090			ln = v.fit(ln, width)
1091		}
1092		out = append(out, t.S().Muted.
1093			Width(width).
1094			Background(t.BgBaseLighter).
1095			Render(ln))
1096	}
1097
1098	if len(lines) > responseContextHeight {
1099		out = append(out, t.S().Muted.
1100			Background(t.BgBaseLighter).
1101			Width(width).
1102			Render(fmt.Sprintf("… (%d lines)", len(lines)-responseContextHeight)))
1103	}
1104
1105	return strings.Join(out, "\n")
1106}
1107
1108func renderMarkdownContent(v *toolCallCmp, content string) string {
1109	t := styles.CurrentTheme()
1110	content = strings.ReplaceAll(content, "\r\n", "\n")
1111	content = strings.ReplaceAll(content, "\t", "    ")
1112	content = strings.TrimSpace(content)
1113
1114	width := v.textWidth() - 2
1115	width = min(width, 120)
1116
1117	renderer := styles.GetPlainMarkdownRenderer(width)
1118	rendered, err := renderer.Render(content)
1119	if err != nil {
1120		return renderPlainContent(v, content)
1121	}
1122
1123	lines := strings.Split(rendered, "\n")
1124
1125	var out []string
1126	for i, ln := range lines {
1127		if i >= responseContextHeight {
1128			break
1129		}
1130		out = append(out, ln)
1131	}
1132
1133	style := t.S().Muted.Background(t.BgBaseLighter)
1134	if len(lines) > responseContextHeight {
1135		out = append(out, style.
1136			Width(width-2).
1137			Render(fmt.Sprintf("… (%d lines)", len(lines)-responseContextHeight)))
1138	}
1139
1140	return style.Render(strings.Join(out, "\n"))
1141}
1142
1143func getDigits(n int) int {
1144	if n == 0 {
1145		return 1
1146	}
1147	if n < 0 {
1148		n = -n
1149	}
1150
1151	digits := 0
1152	for n > 0 {
1153		n /= 10
1154		digits++
1155	}
1156
1157	return digits
1158}
1159
1160func renderCodeContent(v *toolCallCmp, path, content string, offset int) string {
1161	t := styles.CurrentTheme()
1162	content = strings.ReplaceAll(content, "\r\n", "\n") // Normalize line endings
1163	content = strings.ReplaceAll(content, "\t", "    ") // Replace tabs with spaces
1164	truncated := truncateHeight(content, responseContextHeight)
1165
1166	lines := strings.Split(truncated, "\n")
1167	for i, ln := range lines {
1168		lines[i] = ansiext.Escape(ln)
1169	}
1170
1171	bg := t.BgBase
1172	highlighted, _ := highlight.SyntaxHighlight(strings.Join(lines, "\n"), path, bg)
1173	lines = strings.Split(highlighted, "\n")
1174
1175	if len(strings.Split(content, "\n")) > responseContextHeight {
1176		lines = append(lines, t.S().Muted.
1177			Background(bg).
1178			Render(fmt.Sprintf(" …(%d lines)", len(strings.Split(content, "\n"))-responseContextHeight)))
1179	}
1180
1181	maxLineNumber := len(lines) + offset
1182	maxDigits := getDigits(maxLineNumber)
1183	numFmt := fmt.Sprintf("%%%dd", maxDigits)
1184	const numPR, numPL, codePR, codePL = 1, 1, 1, 2
1185	w := v.textWidth() - maxDigits - numPL - numPR - 2 // -2 for left padding
1186	for i, ln := range lines {
1187		num := t.S().Base.
1188			Foreground(t.FgMuted).
1189			Background(t.BgBase).
1190			PaddingRight(1).
1191			PaddingLeft(1).
1192			Render(fmt.Sprintf(numFmt, i+1+offset))
1193		lines[i] = lipgloss.JoinHorizontal(lipgloss.Left,
1194			num,
1195			t.S().Base.
1196				Width(w).
1197				Background(bg).
1198				PaddingRight(1).
1199				PaddingLeft(2).
1200				Render(v.fit(ln, w-codePL-codePR)),
1201		)
1202	}
1203
1204	return lipgloss.JoinVertical(lipgloss.Left, lines...)
1205}
1206
1207// renderImageContent renders image data with optional text content (for MCP tools).
1208func renderImageContent(v *toolCallCmp, data, mediaType, textContent string) string {
1209	t := styles.CurrentTheme()
1210
1211	dataSize := len(data) * 3 / 4
1212	sizeStr := formatSize(dataSize)
1213
1214	loaded := t.S().Base.Foreground(t.Green).Render("Loaded")
1215	arrow := t.S().Base.Foreground(t.GreenDark).Render("→")
1216	typeStyled := t.S().Base.Render(mediaType)
1217	sizeStyled := t.S().Subtle.Render(sizeStr)
1218
1219	imageDisplay := fmt.Sprintf("%s %s %s %s", loaded, arrow, typeStyled, sizeStyled)
1220	if strings.TrimSpace(textContent) != "" {
1221		textDisplay := renderPlainContent(v, textContent)
1222		return lipgloss.JoinVertical(lipgloss.Left, textDisplay, "", imageDisplay)
1223	}
1224
1225	return imageDisplay
1226}
1227
1228// renderMediaContent renders non-image media content.
1229func renderMediaContent(v *toolCallCmp, mediaType, textContent string) string {
1230	t := styles.CurrentTheme()
1231
1232	loaded := t.S().Base.Foreground(t.Green).Render("Loaded")
1233	arrow := t.S().Base.Foreground(t.GreenDark).Render("→")
1234	typeStyled := t.S().Base.Render(mediaType)
1235	mediaDisplay := fmt.Sprintf("%s %s %s", loaded, arrow, typeStyled)
1236
1237	if strings.TrimSpace(textContent) != "" {
1238		textDisplay := renderPlainContent(v, textContent)
1239		return lipgloss.JoinVertical(lipgloss.Left, textDisplay, "", mediaDisplay)
1240	}
1241
1242	return mediaDisplay
1243}
1244
1245// formatSize formats byte count as human-readable size.
1246func formatSize(bytes int) string {
1247	if bytes < 1024 {
1248		return fmt.Sprintf("%d B", bytes)
1249	}
1250	if bytes < 1024*1024 {
1251		return fmt.Sprintf("%.1f KB", float64(bytes)/1024)
1252	}
1253	return fmt.Sprintf("%.1f MB", float64(bytes)/(1024*1024))
1254}
1255
1256func (v *toolCallCmp) renderToolError() string {
1257	t := styles.CurrentTheme()
1258	err := strings.ReplaceAll(v.result.Content, "\n", " ")
1259	errTag := t.S().Base.Padding(0, 1).Background(t.Red).Foreground(t.White).Render("ERROR")
1260	err = fmt.Sprintf("%s %s", errTag, t.S().Base.Foreground(t.FgHalfMuted).Render(v.fit(err, v.textWidth()-2-lipgloss.Width(errTag))))
1261	return err
1262}
1263
1264func truncateHeight(s string, h int) string {
1265	lines := strings.Split(s, "\n")
1266	if len(lines) > h {
1267		return strings.Join(lines[:h], "\n")
1268	}
1269	return s
1270}
1271
1272func prettifyToolName(name string) string {
1273	switch name {
1274	case agent.AgentToolName:
1275		return "Agent"
1276	case tools.BashToolName:
1277		return "Bash"
1278	case tools.JobOutputToolName:
1279		return "Job: Output"
1280	case tools.JobKillToolName:
1281		return "Job: Kill"
1282	case tools.DownloadToolName:
1283		return "Download"
1284	case tools.EditToolName:
1285		return "Edit"
1286	case tools.MultiEditToolName:
1287		return "Multi-Edit"
1288	case tools.FetchToolName:
1289		return "Fetch"
1290	case tools.AgenticFetchToolName:
1291		return "Agentic Fetch"
1292	case tools.WebFetchToolName:
1293		return "Fetch"
1294	case tools.WebSearchToolName:
1295		return "Search"
1296	case tools.GlobToolName:
1297		return "Glob"
1298	case tools.GrepToolName:
1299		return "Grep"
1300	case tools.LSToolName:
1301		return "List"
1302	case tools.SourcegraphToolName:
1303		return "Sourcegraph"
1304	case tools.TodosToolName:
1305		return "To-Do"
1306	case tools.ViewToolName:
1307		return "View"
1308	case tools.WriteToolName:
1309		return "Write"
1310	default:
1311		return name
1312	}
1313}
1314
1315// -----------------------------------------------------------------------------
1316//  Todos renderer
1317// -----------------------------------------------------------------------------
1318
1319type todosRenderer struct {
1320	baseRenderer
1321}
1322
1323func (tr todosRenderer) Render(v *toolCallCmp) string {
1324	t := styles.CurrentTheme()
1325	var params tools.TodosParams
1326	var meta tools.TodosResponseMetadata
1327	var headerText string
1328	var body string
1329
1330	// Parse params for pending state (before result is available).
1331	if err := tr.unmarshalParams(v.call.Input, &params); err == nil {
1332		completedCount := 0
1333		inProgressTask := ""
1334		for _, todo := range params.Todos {
1335			if todo.Status == "completed" {
1336				completedCount++
1337			}
1338			if todo.Status == "in_progress" {
1339				if todo.ActiveForm != "" {
1340					inProgressTask = todo.ActiveForm
1341				} else {
1342					inProgressTask = todo.Content
1343				}
1344			}
1345		}
1346
1347		// Default display from params (used when pending or no metadata).
1348		ratio := t.S().Base.Foreground(t.BlueDark).Render(fmt.Sprintf("%d/%d", completedCount, len(params.Todos)))
1349		headerText = ratio
1350		if inProgressTask != "" {
1351			headerText = fmt.Sprintf("%s · %s", ratio, inProgressTask)
1352		}
1353
1354		// If we have metadata, use it for richer display.
1355		if v.result.Metadata != "" {
1356			if err := tr.unmarshalParams(v.result.Metadata, &meta); err == nil {
1357				if meta.IsNew {
1358					if meta.JustStarted != "" {
1359						headerText = fmt.Sprintf("created %d todos, starting first", meta.Total)
1360					} else {
1361						headerText = fmt.Sprintf("created %d todos", meta.Total)
1362					}
1363					body = todos.FormatTodosList(meta.Todos, styles.ArrowRightIcon, t, v.textWidth())
1364				} else {
1365					// Build header based on what changed.
1366					hasCompleted := len(meta.JustCompleted) > 0
1367					hasStarted := meta.JustStarted != ""
1368					allCompleted := meta.Completed == meta.Total
1369
1370					ratio := t.S().Base.Foreground(t.BlueDark).Render(fmt.Sprintf("%d/%d", meta.Completed, meta.Total))
1371					if hasCompleted && hasStarted {
1372						text := t.S().Subtle.Render(fmt.Sprintf(" · completed %d, starting next", len(meta.JustCompleted)))
1373						headerText = fmt.Sprintf("%s%s", ratio, text)
1374					} else if hasCompleted {
1375						text := t.S().Subtle.Render(fmt.Sprintf(" · completed %d", len(meta.JustCompleted)))
1376						if allCompleted {
1377							text = t.S().Subtle.Render(" · completed all")
1378						}
1379						headerText = fmt.Sprintf("%s%s", ratio, text)
1380					} else if hasStarted {
1381						headerText = fmt.Sprintf("%s%s", ratio, t.S().Subtle.Render(" · starting task"))
1382					} else {
1383						headerText = ratio
1384					}
1385
1386					// Build body with details.
1387					if allCompleted {
1388						// Show all todos when all are completed, like when created
1389						body = todos.FormatTodosList(meta.Todos, styles.ArrowRightIcon, t, v.textWidth())
1390					} else if meta.JustStarted != "" {
1391						body = t.S().Base.Foreground(t.GreenDark).Render(styles.ArrowRightIcon+" ") +
1392							t.S().Base.Foreground(t.FgBase).Render(meta.JustStarted)
1393					}
1394				}
1395			}
1396		}
1397	}
1398
1399	args := newParamBuilder().addMain(headerText).build()
1400
1401	return tr.renderWithParams(v, "To-Do", args, func() string {
1402		return body
1403	})
1404}