@@ -0,0 +1,889 @@
1+package toolrender
2+
3+import (
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.
22+const responseContextHeight = 10
23+
24+// RenderContext provides the context needed for rendering a tool call.
25+type 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.
35+func (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.
43+func (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.
50+func 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+
93+func unmarshalParams(input string, target any) error {
94+ return json.Unmarshal([]byte(input), target)
95+}
96+
97+type paramBuilder struct {
98+ args []string
99+}
100+
101+func newParamBuilder() *paramBuilder {
102+ return ¶mBuilder{args: make([]string, 0)}
103+}
104+
105+func (pb *paramBuilder) addMain(value string) *paramBuilder {
106+ if value != "" {
107+ pb.args = append(pb.args, value)
108+ }
109+ return pb
110+}
111+
112+func (pb *paramBuilder) addKeyValue(key, value string) *paramBuilder {
113+ if value != "" {
114+ pb.args = append(pb.args, key, value)
115+ }
116+ return pb
117+}
118+
119+func (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+
126+func (pb *paramBuilder) build() []string {
127+ return pb.args
128+}
129+
130+func 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+
138+func 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+
160+func 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+
179+func 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+
219+func 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+
237+func 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+
248+func 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+
257+func 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+
269+func 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+
277+func 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+
305+func 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+
340+func 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+
377+func 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+
394+func 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+
402+func 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+
453+func 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+
459+func 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+
481+func 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+
515+func 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+
561+func 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+
575+func 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+
626+func 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+
652+func 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+
678+func 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+
704+func 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+
722+func renderAgenticFetch(ctx *RenderContext) string {
723+ // TODO: Implement nested tool call rendering with tree.
724+ return renderGeneric(ctx)
725+}
726+
727+func 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+
740+func 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+
757+func 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+
773+func 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+
790+func 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+
804+func 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+
821+func 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+
829+func 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+
882+func 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+}