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 ¶mBuilder{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, ¶ms); 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, ¶ms); 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, ¶ms); 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, ¶ms); 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, ¶ms); 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, ¶ms); 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, ¶ms); 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, ¶ms); 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, ¶ms); 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, ¶ms); 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, ¶ms); 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, ¶ms); 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, ¶ms); 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, ¶ms); 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, ¶ms)
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}