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