render.go

  1package toolrender
  2
  3import (
  4	"cmp"
  5	"encoding/json"
  6	"fmt"
  7	"strings"
  8
  9	"charm.land/lipgloss/v2"
 10	"charm.land/lipgloss/v2/tree"
 11	"github.com/charmbracelet/crush/internal/agent"
 12	"github.com/charmbracelet/crush/internal/agent/tools"
 13	"github.com/charmbracelet/crush/internal/ansiext"
 14	"github.com/charmbracelet/crush/internal/fsext"
 15	"github.com/charmbracelet/crush/internal/message"
 16	"github.com/charmbracelet/crush/internal/ui/common"
 17	"github.com/charmbracelet/crush/internal/ui/styles"
 18	"github.com/charmbracelet/x/ansi"
 19)
 20
 21// responseContextHeight limits the number of lines displayed in tool output.
 22const responseContextHeight = 10
 23
 24// RenderContext provides the context needed for rendering a tool call.
 25type RenderContext struct {
 26	Call      message.ToolCall
 27	Result    message.ToolResult
 28	Cancelled bool
 29	IsNested  bool
 30	Width     int
 31	Styles    *styles.Styles
 32}
 33
 34// TextWidth returns the available width for content accounting for borders.
 35func (rc *RenderContext) TextWidth() int {
 36	if rc.IsNested {
 37		return rc.Width - 6
 38	}
 39	return rc.Width - 5
 40}
 41
 42// Fit truncates content to fit within the specified width with ellipsis.
 43func (rc *RenderContext) Fit(content string, width int) string {
 44	lineStyle := rc.Styles.Muted
 45	dots := lineStyle.Render("…")
 46	return ansi.Truncate(content, width, dots)
 47}
 48
 49// Render renders a tool call using the appropriate renderer based on tool name.
 50func Render(ctx *RenderContext) string {
 51	switch ctx.Call.Name {
 52	case tools.ViewToolName:
 53		return renderView(ctx)
 54	case tools.EditToolName:
 55		return renderEdit(ctx)
 56	case tools.MultiEditToolName:
 57		return renderMultiEdit(ctx)
 58	case tools.WriteToolName:
 59		return renderWrite(ctx)
 60	case tools.BashToolName:
 61		return renderBash(ctx)
 62	case tools.JobOutputToolName:
 63		return renderJobOutput(ctx)
 64	case tools.JobKillToolName:
 65		return renderJobKill(ctx)
 66	case tools.FetchToolName:
 67		return renderSimpleFetch(ctx)
 68	case tools.AgenticFetchToolName:
 69		return renderAgenticFetch(ctx)
 70	case tools.WebFetchToolName:
 71		return renderWebFetch(ctx)
 72	case tools.DownloadToolName:
 73		return renderDownload(ctx)
 74	case tools.GlobToolName:
 75		return renderGlob(ctx)
 76	case tools.GrepToolName:
 77		return renderGrep(ctx)
 78	case tools.LSToolName:
 79		return renderLS(ctx)
 80	case tools.SourcegraphToolName:
 81		return renderSourcegraph(ctx)
 82	case tools.DiagnosticsToolName:
 83		return renderDiagnostics(ctx)
 84	case agent.AgentToolName:
 85		return renderAgent(ctx)
 86	default:
 87		return renderGeneric(ctx)
 88	}
 89}
 90
 91// Helper functions
 92
 93func unmarshalParams(input string, target any) error {
 94	return json.Unmarshal([]byte(input), target)
 95}
 96
 97type paramBuilder struct {
 98	args []string
 99}
100
101func newParamBuilder() *paramBuilder {
102	return &paramBuilder{args: make([]string, 0)}
103}
104
105func (pb *paramBuilder) addMain(value string) *paramBuilder {
106	if value != "" {
107		pb.args = append(pb.args, value)
108	}
109	return pb
110}
111
112func (pb *paramBuilder) addKeyValue(key, value string) *paramBuilder {
113	if value != "" {
114		pb.args = append(pb.args, key, value)
115	}
116	return pb
117}
118
119func (pb *paramBuilder) addFlag(key string, value bool) *paramBuilder {
120	if value {
121		pb.args = append(pb.args, key, "true")
122	}
123	return pb
124}
125
126func (pb *paramBuilder) build() []string {
127	return pb.args
128}
129
130func formatNonZero[T comparable](value T) string {
131	var zero T
132	if value == zero {
133		return ""
134	}
135	return fmt.Sprintf("%v", value)
136}
137
138func makeHeader(ctx *RenderContext, toolName string, args []string) string {
139	if ctx.IsNested {
140		return makeNestedHeader(ctx, toolName, args)
141	}
142	s := ctx.Styles
143	var icon string
144	if ctx.Result.ToolCallID != "" {
145		if ctx.Result.IsError {
146			icon = s.Tool.IconError.Render()
147		} else {
148			icon = s.Tool.IconSuccess.Render()
149		}
150	} else if ctx.Cancelled {
151		icon = s.Tool.IconCancelled.Render()
152	} else {
153		icon = s.Tool.IconPending.Render()
154	}
155	tool := s.Tool.NameNormal.Render(toolName)
156	prefix := fmt.Sprintf("%s %s ", icon, tool)
157	return prefix + renderParamList(ctx, false, ctx.TextWidth()-lipgloss.Width(prefix), args...)
158}
159
160func makeNestedHeader(ctx *RenderContext, toolName string, args []string) string {
161	s := ctx.Styles
162	var icon string
163	if ctx.Result.ToolCallID != "" {
164		if ctx.Result.IsError {
165			icon = s.Tool.IconError.Render()
166		} else {
167			icon = s.Tool.IconSuccess.Render()
168		}
169	} else if ctx.Cancelled {
170		icon = s.Tool.IconCancelled.Render()
171	} else {
172		icon = s.Tool.IconPending.Render()
173	}
174	tool := s.Tool.NameNested.Render(toolName)
175	prefix := fmt.Sprintf("%s %s ", icon, tool)
176	return prefix + renderParamList(ctx, true, ctx.TextWidth()-lipgloss.Width(prefix), args...)
177}
178
179func renderParamList(ctx *RenderContext, nested bool, paramsWidth int, params ...string) string {
180	s := ctx.Styles
181	if len(params) == 0 {
182		return ""
183	}
184	mainParam := params[0]
185	if paramsWidth >= 0 && lipgloss.Width(mainParam) > paramsWidth {
186		mainParam = ansi.Truncate(mainParam, paramsWidth, "…")
187	}
188
189	if len(params) == 1 {
190		return s.Tool.ParamMain.Render(mainParam)
191	}
192	otherParams := params[1:]
193	if len(otherParams)%2 != 0 {
194		otherParams = append(otherParams, "")
195	}
196	parts := make([]string, 0, len(otherParams)/2)
197	for i := 0; i < len(otherParams); i += 2 {
198		key := otherParams[i]
199		value := otherParams[i+1]
200		if value == "" {
201			continue
202		}
203		parts = append(parts, fmt.Sprintf("%s=%s", key, value))
204	}
205
206	partsRendered := strings.Join(parts, ", ")
207	remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 3
208	if remainingWidth < 30 {
209		return s.Tool.ParamMain.Render(mainParam)
210	}
211
212	if len(parts) > 0 {
213		mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
214	}
215
216	return s.Tool.ParamMain.Render(ansi.Truncate(mainParam, paramsWidth, "…"))
217}
218
219func earlyState(ctx *RenderContext, header string) (string, bool) {
220	s := ctx.Styles
221	message := ""
222	switch {
223	case ctx.Result.IsError:
224		message = renderToolError(ctx)
225	case ctx.Cancelled:
226		message = s.Tool.StateCancelled.Render("Canceled.")
227	case ctx.Result.ToolCallID == "":
228		message = s.Tool.StateWaiting.Render("Waiting for tool response...")
229	default:
230		return "", false
231	}
232
233	message = s.Tool.BodyPadding.Render(message)
234	return lipgloss.JoinVertical(lipgloss.Left, header, "", message), true
235}
236
237func renderToolError(ctx *RenderContext) string {
238	s := ctx.Styles
239	errTag := s.Tool.ErrorTag.Render("ERROR")
240	msg := ctx.Result.Content
241	if msg == "" {
242		msg = "An error occurred"
243	}
244	truncated := ansi.Truncate(msg, ctx.TextWidth()-3-lipgloss.Width(errTag), "…")
245	return errTag + " " + s.Tool.ErrorMessage.Render(truncated)
246}
247
248func joinHeaderBody(ctx *RenderContext, header, body string) string {
249	s := ctx.Styles
250	if body == "" {
251		return header
252	}
253	body = s.Tool.BodyPadding.Render(body)
254	return lipgloss.JoinVertical(lipgloss.Left, header, "", body)
255}
256
257func renderWithParams(ctx *RenderContext, toolName string, args []string, contentRenderer func() string) string {
258	header := makeHeader(ctx, toolName, args)
259	if ctx.IsNested {
260		return header
261	}
262	if res, done := earlyState(ctx, header); done {
263		return res
264	}
265	body := contentRenderer()
266	return joinHeaderBody(ctx, header, body)
267}
268
269func renderError(ctx *RenderContext, message string) string {
270	s := ctx.Styles
271	header := makeHeader(ctx, prettifyToolName(ctx.Call.Name), []string{})
272	errorTag := s.Tool.ErrorTag.Render("ERROR")
273	message = s.Tool.ErrorMessage.Render(ctx.Fit(message, ctx.TextWidth()-3-lipgloss.Width(errorTag)))
274	return joinHeaderBody(ctx, header, errorTag+" "+message)
275}
276
277func renderPlainContent(ctx *RenderContext, content string) string {
278	s := ctx.Styles
279	content = strings.ReplaceAll(content, "\r\n", "\n")
280	content = strings.ReplaceAll(content, "\t", "    ")
281	content = strings.TrimSpace(content)
282	lines := strings.Split(content, "\n")
283
284	width := ctx.TextWidth() - 2
285	var out []string
286	for i, ln := range lines {
287		if i >= responseContextHeight {
288			break
289		}
290		ln = ansiext.Escape(ln)
291		ln = " " + ln
292		if len(ln) > width {
293			ln = ctx.Fit(ln, width)
294		}
295		out = append(out, s.Tool.ContentLine.Width(width).Render(ln))
296	}
297
298	if len(lines) > responseContextHeight {
299		out = append(out, s.Tool.ContentTruncation.Width(width).Render(fmt.Sprintf("… (%d lines)", len(lines)-responseContextHeight)))
300	}
301
302	return strings.Join(out, "\n")
303}
304
305func renderMarkdownContent(ctx *RenderContext, content string) string {
306	s := ctx.Styles
307	content = strings.ReplaceAll(content, "\r\n", "\n")
308	content = strings.ReplaceAll(content, "\t", "    ")
309	content = strings.TrimSpace(content)
310
311	width := ctx.TextWidth() - 2
312	width = min(width, 120)
313
314	renderer := common.PlainMarkdownRenderer(width)
315	rendered, err := renderer.Render(content)
316	if err != nil {
317		return renderPlainContent(ctx, content)
318	}
319
320	lines := strings.Split(rendered, "\n")
321
322	var out []string
323	for i, ln := range lines {
324		if i >= responseContextHeight {
325			break
326		}
327		out = append(out, ln)
328	}
329
330	style := s.Tool.ContentLine
331	if len(lines) > responseContextHeight {
332		out = append(out, s.Tool.ContentTruncation.
333			Width(width-2).
334			Render(fmt.Sprintf("… (%d lines)", len(lines)-responseContextHeight)))
335	}
336
337	return style.Render(strings.Join(out, "\n"))
338}
339
340func renderCodeContent(ctx *RenderContext, path, content string, offset int) string {
341	s := ctx.Styles
342	content = strings.ReplaceAll(content, "\r\n", "\n")
343	content = strings.ReplaceAll(content, "\t", "    ")
344	truncated := truncateHeight(content, responseContextHeight)
345
346	lines := strings.Split(truncated, "\n")
347	for i, ln := range lines {
348		lines[i] = ansiext.Escape(ln)
349	}
350
351	bg := s.Tool.ContentCodeBg
352	highlighted, _ := common.SyntaxHighlight(ctx.Styles, strings.Join(lines, "\n"), path, bg)
353	lines = strings.Split(highlighted, "\n")
354
355	width := ctx.TextWidth() - 2
356	gutterWidth := getDigits(offset+len(lines)) + 1
357
358	var out []string
359	for i, ln := range lines {
360		lineNum := fmt.Sprintf("%*d", gutterWidth, offset+i+1)
361		gutter := s.Subtle.Render(lineNum + " ")
362		ln = " " + ln
363		if lipgloss.Width(gutter+ln) > width {
364			ln = ctx.Fit(ln, width-lipgloss.Width(gutter))
365		}
366		out = append(out, s.Tool.ContentCodeLine.Width(width).Render(gutter+ln))
367	}
368
369	contentLines := strings.Split(content, "\n")
370	if len(contentLines) > responseContextHeight {
371		out = append(out, s.Tool.ContentTruncation.Width(width).Render(fmt.Sprintf("… (%d lines)", len(contentLines)-responseContextHeight)))
372	}
373
374	return strings.Join(out, "\n")
375}
376
377func getDigits(n int) int {
378	if n == 0 {
379		return 1
380	}
381	if n < 0 {
382		n = -n
383	}
384
385	digits := 0
386	for n > 0 {
387		n /= 10
388		digits++
389	}
390
391	return digits
392}
393
394func truncateHeight(content string, maxLines int) string {
395	lines := strings.Split(content, "\n")
396	if len(lines) <= maxLines {
397		return content
398	}
399	return strings.Join(lines[:maxLines], "\n")
400}
401
402func prettifyToolName(name string) string {
403	switch name {
404	case "agent":
405		return "Agent"
406	case "bash":
407		return "Bash"
408	case "job_output":
409		return "Job: Output"
410	case "job_kill":
411		return "Job: Kill"
412	case "download":
413		return "Download"
414	case "edit":
415		return "Edit"
416	case "multiedit":
417		return "Multi-Edit"
418	case "fetch":
419		return "Fetch"
420	case "agentic_fetch":
421		return "Agentic Fetch"
422	case "web_fetch":
423		return "Fetching"
424	case "glob":
425		return "Glob"
426	case "grep":
427		return "Grep"
428	case "ls":
429		return "List"
430	case "sourcegraph":
431		return "Sourcegraph"
432	case "view":
433		return "View"
434	case "write":
435		return "Write"
436	case "lsp_references":
437		return "Find References"
438	case "lsp_diagnostics":
439		return "Diagnostics"
440	default:
441		parts := strings.Split(name, "_")
442		for i := range parts {
443			if len(parts[i]) > 0 {
444				parts[i] = strings.ToUpper(parts[i][:1]) + parts[i][1:]
445			}
446		}
447		return strings.Join(parts, " ")
448	}
449}
450
451// Tool-specific renderers
452
453func renderGeneric(ctx *RenderContext) string {
454	return renderWithParams(ctx, prettifyToolName(ctx.Call.Name), []string{ctx.Call.Input}, func() string {
455		return renderPlainContent(ctx, ctx.Result.Content)
456	})
457}
458
459func renderView(ctx *RenderContext) string {
460	var params tools.ViewParams
461	if err := unmarshalParams(ctx.Call.Input, &params); err != nil {
462		return renderError(ctx, "Invalid view parameters")
463	}
464
465	file := fsext.PrettyPath(params.FilePath)
466	args := newParamBuilder().
467		addMain(file).
468		addKeyValue("limit", formatNonZero(params.Limit)).
469		addKeyValue("offset", formatNonZero(params.Offset)).
470		build()
471
472	return renderWithParams(ctx, "View", args, func() string {
473		var meta tools.ViewResponseMetadata
474		if err := unmarshalParams(ctx.Result.Metadata, &meta); err != nil {
475			return renderPlainContent(ctx, ctx.Result.Content)
476		}
477		return renderCodeContent(ctx, meta.FilePath, meta.Content, params.Offset)
478	})
479}
480
481func renderEdit(ctx *RenderContext) string {
482	s := ctx.Styles
483	var params tools.EditParams
484	var args []string
485	if err := unmarshalParams(ctx.Call.Input, &params); err == nil {
486		file := fsext.PrettyPath(params.FilePath)
487		args = newParamBuilder().addMain(file).build()
488	}
489
490	return renderWithParams(ctx, "Edit", args, func() string {
491		var meta tools.EditResponseMetadata
492		if err := unmarshalParams(ctx.Result.Metadata, &meta); err != nil {
493			return renderPlainContent(ctx, ctx.Result.Content)
494		}
495
496		formatter := common.DiffFormatter(ctx.Styles).
497			Before(fsext.PrettyPath(params.FilePath), meta.OldContent).
498			After(fsext.PrettyPath(params.FilePath), meta.NewContent).
499			Width(ctx.TextWidth() - 2)
500		if ctx.TextWidth() > 120 {
501			formatter = formatter.Split()
502		}
503		formatted := formatter.String()
504		if lipgloss.Height(formatted) > responseContextHeight {
505			contentLines := strings.Split(formatted, "\n")
506			truncateMessage := s.Tool.DiffTruncation.
507				Width(ctx.TextWidth() - 2).
508				Render(fmt.Sprintf("… (%d lines)", len(contentLines)-responseContextHeight))
509			formatted = strings.Join(contentLines[:responseContextHeight], "\n") + "\n" + truncateMessage
510		}
511		return formatted
512	})
513}
514
515func renderMultiEdit(ctx *RenderContext) string {
516	s := ctx.Styles
517	var params tools.MultiEditParams
518	var args []string
519	if err := unmarshalParams(ctx.Call.Input, &params); err == nil {
520		file := fsext.PrettyPath(params.FilePath)
521		args = newParamBuilder().
522			addMain(file).
523			addKeyValue("edits", fmt.Sprintf("%d", len(params.Edits))).
524			build()
525	}
526
527	return renderWithParams(ctx, "Multi-Edit", args, func() string {
528		var meta tools.MultiEditResponseMetadata
529		if err := unmarshalParams(ctx.Result.Metadata, &meta); err != nil {
530			return renderPlainContent(ctx, ctx.Result.Content)
531		}
532
533		formatter := common.DiffFormatter(ctx.Styles).
534			Before(fsext.PrettyPath(params.FilePath), meta.OldContent).
535			After(fsext.PrettyPath(params.FilePath), meta.NewContent).
536			Width(ctx.TextWidth() - 2)
537		if ctx.TextWidth() > 120 {
538			formatter = formatter.Split()
539		}
540		formatted := formatter.String()
541		if lipgloss.Height(formatted) > responseContextHeight {
542			contentLines := strings.Split(formatted, "\n")
543			truncateMessage := s.Tool.DiffTruncation.
544				Width(ctx.TextWidth() - 2).
545				Render(fmt.Sprintf("… (%d lines)", len(contentLines)-responseContextHeight))
546			formatted = strings.Join(contentLines[:responseContextHeight], "\n") + "\n" + truncateMessage
547		}
548
549		// Add note about failed edits if any.
550		if len(meta.EditsFailed) > 0 {
551			noteTag := s.Tool.NoteTag.Render("NOTE")
552			noteMsg := s.Tool.NoteMessage.Render(
553				fmt.Sprintf("%d of %d edits failed", len(meta.EditsFailed), len(params.Edits)))
554			formatted = formatted + "\n\n" + noteTag + " " + noteMsg
555		}
556
557		return formatted
558	})
559}
560
561func renderWrite(ctx *RenderContext) string {
562	var params tools.WriteParams
563	if err := unmarshalParams(ctx.Call.Input, &params); err != nil {
564		return renderError(ctx, "Invalid write parameters")
565	}
566
567	file := fsext.PrettyPath(params.FilePath)
568	args := newParamBuilder().addMain(file).build()
569
570	return renderWithParams(ctx, "Write", args, func() string {
571		return renderCodeContent(ctx, params.FilePath, params.Content, 0)
572	})
573}
574
575func renderBash(ctx *RenderContext) string {
576	var params tools.BashParams
577	if err := unmarshalParams(ctx.Call.Input, &params); err != nil {
578		return renderError(ctx, "Invalid bash parameters")
579	}
580
581	cmd := strings.ReplaceAll(params.Command, "\n", " ")
582	cmd = strings.ReplaceAll(cmd, "\t", "    ")
583	args := newParamBuilder().
584		addMain(cmd).
585		addFlag("background", params.RunInBackground).
586		build()
587
588	if ctx.Call.Finished {
589		var meta tools.BashResponseMetadata
590		_ = unmarshalParams(ctx.Result.Metadata, &meta)
591		if meta.Background {
592			description := cmp.Or(meta.Description, params.Command)
593			width := ctx.TextWidth()
594			if ctx.IsNested {
595				width -= 4
596			}
597			header := makeJobHeader(ctx, "Start", fmt.Sprintf("PID %s", meta.ShellID), description, width)
598			if ctx.IsNested {
599				return header
600			}
601			if res, done := earlyState(ctx, header); done {
602				return res
603			}
604			content := "Command: " + params.Command + "\n" + ctx.Result.Content
605			body := renderPlainContent(ctx, content)
606			return joinHeaderBody(ctx, header, body)
607		}
608	}
609
610	return renderWithParams(ctx, "Bash", args, func() string {
611		var meta tools.BashResponseMetadata
612		if err := unmarshalParams(ctx.Result.Metadata, &meta); err != nil {
613			return renderPlainContent(ctx, ctx.Result.Content)
614		}
615		if meta.Output == "" && ctx.Result.Content != tools.BashNoOutput {
616			meta.Output = ctx.Result.Content
617		}
618
619		if meta.Output == "" {
620			return ""
621		}
622		return renderPlainContent(ctx, meta.Output)
623	})
624}
625
626func makeJobHeader(ctx *RenderContext, action, pid, description string, width int) string {
627	s := ctx.Styles
628	icon := s.Tool.JobIconPending.Render(styles.ToolPending)
629	if ctx.Result.ToolCallID != "" {
630		if ctx.Result.IsError {
631			icon = s.Tool.JobIconError.Render(styles.ToolError)
632		} else {
633			icon = s.Tool.JobIconSuccess.Render(styles.ToolSuccess)
634		}
635	} else if ctx.Cancelled {
636		icon = s.Muted.Render(styles.ToolPending)
637	}
638
639	toolName := s.Tool.JobToolName.Render("Bash")
640	actionPart := s.Tool.JobAction.Render(action)
641	pidPart := s.Tool.JobPID.Render(pid)
642
643	prefix := fmt.Sprintf("%s %s %s %s ", icon, toolName, actionPart, pidPart)
644	remainingWidth := width - lipgloss.Width(prefix)
645
646	descDisplay := ansi.Truncate(description, remainingWidth, "…")
647	descDisplay = s.Tool.JobDescription.Render(descDisplay)
648
649	return prefix + descDisplay
650}
651
652func renderJobOutput(ctx *RenderContext) string {
653	var params tools.JobOutputParams
654	if err := unmarshalParams(ctx.Call.Input, &params); err != nil {
655		return renderError(ctx, "Invalid job output parameters")
656	}
657
658	width := ctx.TextWidth()
659	if ctx.IsNested {
660		width -= 4
661	}
662
663	var meta tools.JobOutputResponseMetadata
664	_ = unmarshalParams(ctx.Result.Metadata, &meta)
665	description := cmp.Or(meta.Description, meta.Command)
666
667	header := makeJobHeader(ctx, "Output", fmt.Sprintf("PID %s", params.ShellID), description, width)
668	if ctx.IsNested {
669		return header
670	}
671	if res, done := earlyState(ctx, header); done {
672		return res
673	}
674	body := renderPlainContent(ctx, ctx.Result.Content)
675	return joinHeaderBody(ctx, header, body)
676}
677
678func renderJobKill(ctx *RenderContext) string {
679	var params tools.JobKillParams
680	if err := unmarshalParams(ctx.Call.Input, &params); err != nil {
681		return renderError(ctx, "Invalid job kill parameters")
682	}
683
684	width := ctx.TextWidth()
685	if ctx.IsNested {
686		width -= 4
687	}
688
689	var meta tools.JobKillResponseMetadata
690	_ = unmarshalParams(ctx.Result.Metadata, &meta)
691	description := cmp.Or(meta.Description, meta.Command)
692
693	header := makeJobHeader(ctx, "Kill", fmt.Sprintf("PID %s", params.ShellID), description, width)
694	if ctx.IsNested {
695		return header
696	}
697	if res, done := earlyState(ctx, header); done {
698		return res
699	}
700	body := renderPlainContent(ctx, ctx.Result.Content)
701	return joinHeaderBody(ctx, header, body)
702}
703
704func renderSimpleFetch(ctx *RenderContext) string {
705	var params tools.FetchParams
706	if err := unmarshalParams(ctx.Call.Input, &params); err != nil {
707		return renderError(ctx, "Invalid fetch parameters")
708	}
709
710	args := newParamBuilder().
711		addMain(params.URL).
712		addKeyValue("format", params.Format).
713		addKeyValue("timeout", formatNonZero(params.Timeout)).
714		build()
715
716	return renderWithParams(ctx, "Fetch", args, func() string {
717		path := "file." + params.Format
718		return renderCodeContent(ctx, path, ctx.Result.Content, 0)
719	})
720}
721
722func renderAgenticFetch(ctx *RenderContext) string {
723	// TODO: Implement nested tool call rendering with tree.
724	return renderGeneric(ctx)
725}
726
727func renderWebFetch(ctx *RenderContext) string {
728	var params tools.WebFetchParams
729	if err := unmarshalParams(ctx.Call.Input, &params); err != nil {
730		return renderError(ctx, "Invalid web fetch parameters")
731	}
732
733	args := newParamBuilder().addMain(params.URL).build()
734
735	return renderWithParams(ctx, "Fetching", args, func() string {
736		return renderMarkdownContent(ctx, ctx.Result.Content)
737	})
738}
739
740func renderDownload(ctx *RenderContext) string {
741	var params tools.DownloadParams
742	if err := unmarshalParams(ctx.Call.Input, &params); err != nil {
743		return renderError(ctx, "Invalid download parameters")
744	}
745
746	args := newParamBuilder().
747		addMain(params.URL).
748		addKeyValue("file", fsext.PrettyPath(params.FilePath)).
749		addKeyValue("timeout", formatNonZero(params.Timeout)).
750		build()
751
752	return renderWithParams(ctx, "Download", args, func() string {
753		return renderPlainContent(ctx, ctx.Result.Content)
754	})
755}
756
757func renderGlob(ctx *RenderContext) string {
758	var params tools.GlobParams
759	if err := unmarshalParams(ctx.Call.Input, &params); err != nil {
760		return renderError(ctx, "Invalid glob parameters")
761	}
762
763	args := newParamBuilder().
764		addMain(params.Pattern).
765		addKeyValue("path", params.Path).
766		build()
767
768	return renderWithParams(ctx, "Glob", args, func() string {
769		return renderPlainContent(ctx, ctx.Result.Content)
770	})
771}
772
773func renderGrep(ctx *RenderContext) string {
774	var params tools.GrepParams
775	var args []string
776	if err := unmarshalParams(ctx.Call.Input, &params); err == nil {
777		args = newParamBuilder().
778			addMain(params.Pattern).
779			addKeyValue("path", params.Path).
780			addKeyValue("include", params.Include).
781			addFlag("literal", params.LiteralText).
782			build()
783	}
784
785	return renderWithParams(ctx, "Grep", args, func() string {
786		return renderPlainContent(ctx, ctx.Result.Content)
787	})
788}
789
790func renderLS(ctx *RenderContext) string {
791	var params tools.LSParams
792	path := cmp.Or(params.Path, ".")
793	args := newParamBuilder().addMain(path).build()
794
795	if err := unmarshalParams(ctx.Call.Input, &params); err == nil && params.Path != "" {
796		args = newParamBuilder().addMain(params.Path).build()
797	}
798
799	return renderWithParams(ctx, "List", args, func() string {
800		return renderPlainContent(ctx, ctx.Result.Content)
801	})
802}
803
804func renderSourcegraph(ctx *RenderContext) string {
805	var params tools.SourcegraphParams
806	if err := unmarshalParams(ctx.Call.Input, &params); err != nil {
807		return renderError(ctx, "Invalid sourcegraph parameters")
808	}
809
810	args := newParamBuilder().
811		addMain(params.Query).
812		addKeyValue("count", formatNonZero(params.Count)).
813		addKeyValue("context", formatNonZero(params.ContextWindow)).
814		build()
815
816	return renderWithParams(ctx, "Sourcegraph", args, func() string {
817		return renderPlainContent(ctx, ctx.Result.Content)
818	})
819}
820
821func renderDiagnostics(ctx *RenderContext) string {
822	args := newParamBuilder().addMain("project").build()
823
824	return renderWithParams(ctx, "Diagnostics", args, func() string {
825		return renderPlainContent(ctx, ctx.Result.Content)
826	})
827}
828
829func renderAgent(ctx *RenderContext) string {
830	s := ctx.Styles
831	var params agent.AgentParams
832	unmarshalParams(ctx.Call.Input, &params)
833
834	prompt := params.Prompt
835	prompt = strings.ReplaceAll(prompt, "\n", " ")
836
837	header := makeHeader(ctx, "Agent", []string{})
838	if res, done := earlyState(ctx, header); ctx.Cancelled && done {
839		return res
840	}
841	taskTag := s.Tool.AgentTaskTag.Render("Task")
842	remainingWidth := ctx.TextWidth() - lipgloss.Width(header) - lipgloss.Width(taskTag) - 2
843	remainingWidth = min(remainingWidth, 120-lipgloss.Width(taskTag)-2)
844	prompt = s.Tool.AgentPrompt.Width(remainingWidth).Render(prompt)
845	header = lipgloss.JoinVertical(
846		lipgloss.Left,
847		header,
848		"",
849		lipgloss.JoinHorizontal(
850			lipgloss.Left,
851			taskTag,
852			" ",
853			prompt,
854		),
855	)
856	childTools := tree.Root(header)
857
858	// TODO: Render nested tool calls when available.
859
860	parts := []string{
861		childTools.Enumerator(roundedEnumeratorWithWidth(2, lipgloss.Width(taskTag)-5)).String(),
862	}
863
864	if ctx.Result.ToolCallID == "" {
865		// Pending state - would show animation in TUI.
866		parts = append(parts, "", s.Subtle.Render("Working..."))
867	}
868
869	header = lipgloss.JoinVertical(
870		lipgloss.Left,
871		parts...,
872	)
873
874	if ctx.Result.ToolCallID == "" {
875		return header
876	}
877
878	body := renderMarkdownContent(ctx, ctx.Result.Content)
879	return joinHeaderBody(ctx, header, body)
880}
881
882func roundedEnumeratorWithWidth(width int, offset int) func(tree.Children, int) string {
883	return func(children tree.Children, i int) string {
884		if children.Length()-1 == i {
885			return strings.Repeat(" ", offset) + "└" + strings.Repeat("─", width-1) + " "
886		}
887		return strings.Repeat(" ", offset) + "├" + strings.Repeat("─", width-1) + " "
888	}
889}