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