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