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