1package messages
  2
  3import (
  4	"encoding/json"
  5	"fmt"
  6	"strings"
  7	"time"
  8
  9	"github.com/charmbracelet/crush/internal/ansiext"
 10	"github.com/charmbracelet/crush/internal/fsext"
 11	"github.com/charmbracelet/crush/internal/llm/agent"
 12	"github.com/charmbracelet/crush/internal/llm/tools"
 13	"github.com/charmbracelet/crush/internal/tui/components/core"
 14	"github.com/charmbracelet/crush/internal/tui/highlight"
 15	"github.com/charmbracelet/crush/internal/tui/styles"
 16	"github.com/charmbracelet/lipgloss/v2"
 17	"github.com/charmbracelet/lipgloss/v2/tree"
 18	"github.com/charmbracelet/x/ansi"
 19)
 20
 21// responseContextHeight limits the number of lines displayed in tool output
 22const responseContextHeight = 10
 23
 24// renderer defines the interface for tool-specific rendering implementations
 25type renderer interface {
 26	// Render returns the complete (already styled) tool‑call view, not
 27	// including the outer border.
 28	Render(v *toolCallCmp) string
 29}
 30
 31// rendererFactory creates new renderer instances
 32type rendererFactory func() renderer
 33
 34// renderRegistry manages the mapping of tool names to their renderers
 35type renderRegistry map[string]rendererFactory
 36
 37// register adds a new renderer factory to the registry
 38func (rr renderRegistry) register(name string, f rendererFactory) { rr[name] = f }
 39
 40// lookup retrieves a renderer for the given tool name, falling back to generic renderer
 41func (rr renderRegistry) lookup(name string) renderer {
 42	if f, ok := rr[name]; ok {
 43		return f()
 44	}
 45	return genericRenderer{} // sensible fallback
 46}
 47
 48// registry holds all registered tool renderers
 49var registry = renderRegistry{}
 50
 51// baseRenderer provides common functionality for all tool renderers
 52type baseRenderer struct{}
 53
 54// paramBuilder helps construct parameter lists for tool headers
 55type paramBuilder struct {
 56	args []string
 57}
 58
 59// newParamBuilder creates a new parameter builder
 60func newParamBuilder() *paramBuilder {
 61	return ¶mBuilder{args: make([]string, 0)}
 62}
 63
 64// addMain adds the main parameter (first argument)
 65func (pb *paramBuilder) addMain(value string) *paramBuilder {
 66	if value != "" {
 67		pb.args = append(pb.args, value)
 68	}
 69	return pb
 70}
 71
 72// addKeyValue adds a key-value pair parameter
 73func (pb *paramBuilder) addKeyValue(key, value string) *paramBuilder {
 74	if value != "" {
 75		pb.args = append(pb.args, key, value)
 76	}
 77	return pb
 78}
 79
 80// addFlag adds a boolean flag parameter
 81func (pb *paramBuilder) addFlag(key string, value bool) *paramBuilder {
 82	if value {
 83		pb.args = append(pb.args, key, "true")
 84	}
 85	return pb
 86}
 87
 88// build returns the final parameter list
 89func (pb *paramBuilder) build() []string {
 90	return pb.args
 91}
 92
 93// renderWithParams provides a common rendering pattern for tools with parameters
 94func (br baseRenderer) renderWithParams(v *toolCallCmp, toolName string, args []string, contentRenderer func() string) string {
 95	width := v.textWidth()
 96	if v.isNested {
 97		width -= 4 // Adjust for nested tool call indentation
 98	}
 99	header := br.makeHeader(v, toolName, width, args...)
100	if v.isNested {
101		return v.style().Render(header)
102	}
103	if res, done := earlyState(header, v); done {
104		return res
105	}
106	body := contentRenderer()
107	return joinHeaderBody(header, body)
108}
109
110// unmarshalParams safely unmarshal JSON parameters
111func (br baseRenderer) unmarshalParams(input string, target any) error {
112	return json.Unmarshal([]byte(input), target)
113}
114
115// makeHeader builds the tool call header with status icon and parameters for a nested tool call.
116func (br baseRenderer) makeNestedHeader(v *toolCallCmp, tool string, width int, params ...string) string {
117	t := styles.CurrentTheme()
118	icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
119	if v.result.ToolCallID != "" {
120		if v.result.IsError {
121			icon = t.S().Base.Foreground(t.RedDark).Render(styles.ToolError)
122		} else {
123			icon = t.S().Base.Foreground(t.Green).Render(styles.ToolSuccess)
124		}
125	} else if v.cancelled {
126		icon = t.S().Muted.Render(styles.ToolPending)
127	}
128	tool = t.S().Base.Foreground(t.FgHalfMuted).Render(tool)
129	prefix := fmt.Sprintf("%s %s ", icon, tool)
130	return prefix + renderParamList(true, width-lipgloss.Width(prefix), params...)
131}
132
133// makeHeader builds "<Tool>: param (key=value)" and truncates as needed.
134func (br baseRenderer) makeHeader(v *toolCallCmp, tool string, width int, params ...string) string {
135	if v.isNested {
136		return br.makeNestedHeader(v, tool, width, params...)
137	}
138	t := styles.CurrentTheme()
139	icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
140	if v.result.ToolCallID != "" {
141		if v.result.IsError {
142			icon = t.S().Base.Foreground(t.RedDark).Render(styles.ToolError)
143		} else {
144			icon = t.S().Base.Foreground(t.Green).Render(styles.ToolSuccess)
145		}
146	} else if v.cancelled {
147		icon = t.S().Muted.Render(styles.ToolPending)
148	}
149	tool = t.S().Base.Foreground(t.Blue).Render(tool)
150	prefix := fmt.Sprintf("%s %s ", icon, tool)
151	return prefix + renderParamList(false, width-lipgloss.Width(prefix), params...)
152}
153
154// renderError provides consistent error rendering
155func (br baseRenderer) renderError(v *toolCallCmp, message string) string {
156	t := styles.CurrentTheme()
157	header := br.makeHeader(v, prettifyToolName(v.call.Name), v.textWidth(), "")
158	errorTag := t.S().Base.Padding(0, 1).Background(t.Red).Foreground(t.White).Render("ERROR")
159	message = t.S().Base.Foreground(t.FgHalfMuted).Render(v.fit(message, v.textWidth()-3-lipgloss.Width(errorTag))) // -2 for padding and space
160	return joinHeaderBody(header, errorTag+" "+message)
161}
162
163// Register tool renderers
164func init() {
165	registry.register(tools.BashToolName, func() renderer { return bashRenderer{} })
166	registry.register(tools.DownloadToolName, func() renderer { return downloadRenderer{} })
167	registry.register(tools.ViewToolName, func() renderer { return viewRenderer{} })
168	registry.register(tools.EditToolName, func() renderer { return editRenderer{} })
169	registry.register(tools.WriteToolName, func() renderer { return writeRenderer{} })
170	registry.register(tools.FetchToolName, func() renderer { return fetchRenderer{} })
171	registry.register(tools.GlobToolName, func() renderer { return globRenderer{} })
172	registry.register(tools.GrepToolName, func() renderer { return grepRenderer{} })
173	registry.register(tools.LSToolName, func() renderer { return lsRenderer{} })
174	registry.register(tools.SourcegraphToolName, func() renderer { return sourcegraphRenderer{} })
175	registry.register(tools.DiagnosticsToolName, func() renderer { return diagnosticsRenderer{} })
176	registry.register(agent.AgentToolName, func() renderer { return agentRenderer{} })
177}
178
179// -----------------------------------------------------------------------------
180//  Generic renderer
181// -----------------------------------------------------------------------------
182
183// genericRenderer handles unknown tool types with basic parameter display
184type genericRenderer struct {
185	baseRenderer
186}
187
188// Render displays the tool call with its raw input and plain content output
189func (gr genericRenderer) Render(v *toolCallCmp) string {
190	return gr.renderWithParams(v, prettifyToolName(v.call.Name), []string{v.call.Input}, func() string {
191		return renderPlainContent(v, v.result.Content)
192	})
193}
194
195// -----------------------------------------------------------------------------
196//  Bash renderer
197// -----------------------------------------------------------------------------
198
199// bashRenderer handles bash command execution display
200type bashRenderer struct {
201	baseRenderer
202}
203
204// Render displays the bash command with sanitized newlines and plain output
205func (br bashRenderer) Render(v *toolCallCmp) string {
206	var params tools.BashParams
207	if err := br.unmarshalParams(v.call.Input, ¶ms); err != nil {
208		return br.renderError(v, "Invalid bash parameters")
209	}
210
211	cmd := strings.ReplaceAll(params.Command, "\n", " ")
212	cmd = strings.ReplaceAll(cmd, "\t", "    ")
213	args := newParamBuilder().addMain(cmd).build()
214
215	return br.renderWithParams(v, "Bash", args, func() string {
216		var meta tools.BashResponseMetadata
217		if err := br.unmarshalParams(v.result.Metadata, &meta); err != nil {
218			return renderPlainContent(v, v.result.Content)
219		}
220		// for backwards compatibility with older tool calls.
221		if meta.Output == "" && v.result.Content != tools.BashNoOutput {
222			meta.Output = v.result.Content
223		}
224
225		if meta.Output == "" {
226			return ""
227		}
228		return renderPlainContent(v, meta.Output)
229	})
230}
231
232// -----------------------------------------------------------------------------
233//  View renderer
234// -----------------------------------------------------------------------------
235
236// viewRenderer handles file viewing with syntax highlighting and line numbers
237type viewRenderer struct {
238	baseRenderer
239}
240
241// Render displays file content with optional limit and offset parameters
242func (vr viewRenderer) Render(v *toolCallCmp) string {
243	var params tools.ViewParams
244	if err := vr.unmarshalParams(v.call.Input, ¶ms); err != nil {
245		return vr.renderError(v, "Invalid view parameters")
246	}
247
248	file := fsext.PrettyPath(params.FilePath)
249	args := newParamBuilder().
250		addMain(file).
251		addKeyValue("limit", formatNonZero(params.Limit)).
252		addKeyValue("offset", formatNonZero(params.Offset)).
253		build()
254
255	return vr.renderWithParams(v, "View", args, func() string {
256		var meta tools.ViewResponseMetadata
257		if err := vr.unmarshalParams(v.result.Metadata, &meta); err != nil {
258			return renderPlainContent(v, v.result.Content)
259		}
260		return renderCodeContent(v, meta.FilePath, meta.Content, params.Offset)
261	})
262}
263
264// formatNonZero returns string representation of non-zero integers, empty string for zero
265func formatNonZero(value int) string {
266	if value == 0 {
267		return ""
268	}
269	return fmt.Sprintf("%d", value)
270}
271
272// -----------------------------------------------------------------------------
273//  Edit renderer
274// -----------------------------------------------------------------------------
275
276// editRenderer handles file editing with diff visualization
277type editRenderer struct {
278	baseRenderer
279}
280
281// Render displays the edited file with a formatted diff of changes
282func (er editRenderer) Render(v *toolCallCmp) string {
283	t := styles.CurrentTheme()
284	var params tools.EditParams
285	var args []string
286	if err := er.unmarshalParams(v.call.Input, ¶ms); err == nil {
287		file := fsext.PrettyPath(params.FilePath)
288		args = newParamBuilder().addMain(file).build()
289	}
290
291	return er.renderWithParams(v, "Edit", args, func() string {
292		var meta tools.EditResponseMetadata
293		if err := er.unmarshalParams(v.result.Metadata, &meta); err != nil {
294			return renderPlainContent(v, v.result.Content)
295		}
296
297		formatter := core.DiffFormatter().
298			Before(fsext.PrettyPath(params.FilePath), meta.OldContent).
299			After(fsext.PrettyPath(params.FilePath), meta.NewContent).
300			Width(v.textWidth() - 2) // -2 for padding
301		if v.textWidth() > 120 {
302			formatter = formatter.Split()
303		}
304		// add a message to the bottom if the content was truncated
305		formatted := formatter.String()
306		if lipgloss.Height(formatted) > responseContextHeight {
307			contentLines := strings.Split(formatted, "\n")
308			truncateMessage := t.S().Muted.
309				Background(t.BgBaseLighter).
310				PaddingLeft(2).
311				Width(v.textWidth() - 4).
312				Render(fmt.Sprintf("… (%d lines)", len(contentLines)-responseContextHeight))
313			formatted = strings.Join(contentLines[:responseContextHeight], "\n") + "\n" + truncateMessage
314		}
315		return formatted
316	})
317}
318
319// -----------------------------------------------------------------------------
320//  Write renderer
321// -----------------------------------------------------------------------------
322
323// writeRenderer handles file writing with syntax-highlighted content preview
324type writeRenderer struct {
325	baseRenderer
326}
327
328// Render displays the file being written with syntax highlighting
329func (wr writeRenderer) Render(v *toolCallCmp) string {
330	var params tools.WriteParams
331	var args []string
332	var file string
333	if err := wr.unmarshalParams(v.call.Input, ¶ms); err == nil {
334		file = fsext.PrettyPath(params.FilePath)
335		args = newParamBuilder().addMain(file).build()
336	}
337
338	return wr.renderWithParams(v, "Write", args, func() string {
339		return renderCodeContent(v, file, params.Content, 0)
340	})
341}
342
343// -----------------------------------------------------------------------------
344//  Fetch renderer
345// -----------------------------------------------------------------------------
346
347// fetchRenderer handles URL fetching with format-specific content display
348type fetchRenderer struct {
349	baseRenderer
350}
351
352// Render displays the fetched URL with format and timeout parameters
353func (fr fetchRenderer) Render(v *toolCallCmp) string {
354	var params tools.FetchParams
355	var args []string
356	if err := fr.unmarshalParams(v.call.Input, ¶ms); err == nil {
357		args = newParamBuilder().
358			addMain(params.URL).
359			addKeyValue("format", params.Format).
360			addKeyValue("timeout", formatTimeout(params.Timeout)).
361			build()
362	}
363
364	return fr.renderWithParams(v, "Fetch", args, func() string {
365		file := fr.getFileExtension(params.Format)
366		return renderCodeContent(v, file, v.result.Content, 0)
367	})
368}
369
370// getFileExtension returns appropriate file extension for syntax highlighting
371func (fr fetchRenderer) getFileExtension(format string) string {
372	switch format {
373	case "text":
374		return "fetch.txt"
375	case "html":
376		return "fetch.html"
377	default:
378		return "fetch.md"
379	}
380}
381
382// formatTimeout converts timeout seconds to duration string
383func formatTimeout(timeout int) string {
384	if timeout == 0 {
385		return ""
386	}
387	return (time.Duration(timeout) * time.Second).String()
388}
389
390// -----------------------------------------------------------------------------
391//  Download renderer
392// -----------------------------------------------------------------------------
393
394// downloadRenderer handles file downloading with URL and file path display
395type downloadRenderer struct {
396	baseRenderer
397}
398
399// Render displays the download URL and destination file path with timeout parameter
400func (dr downloadRenderer) Render(v *toolCallCmp) string {
401	var params tools.DownloadParams
402	var args []string
403	if err := dr.unmarshalParams(v.call.Input, ¶ms); err == nil {
404		args = newParamBuilder().
405			addMain(params.URL).
406			addKeyValue("file_path", fsext.PrettyPath(params.FilePath)).
407			addKeyValue("timeout", formatTimeout(params.Timeout)).
408			build()
409	}
410
411	return dr.renderWithParams(v, "Download", args, func() string {
412		return renderPlainContent(v, v.result.Content)
413	})
414}
415
416// -----------------------------------------------------------------------------
417//  Glob renderer
418// -----------------------------------------------------------------------------
419
420// globRenderer handles file pattern matching with path filtering
421type globRenderer struct {
422	baseRenderer
423}
424
425// Render displays the glob pattern with optional path parameter
426func (gr globRenderer) Render(v *toolCallCmp) string {
427	var params tools.GlobParams
428	var args []string
429	if err := gr.unmarshalParams(v.call.Input, ¶ms); err == nil {
430		args = newParamBuilder().
431			addMain(params.Pattern).
432			addKeyValue("path", params.Path).
433			build()
434	}
435
436	return gr.renderWithParams(v, "Glob", args, func() string {
437		return renderPlainContent(v, v.result.Content)
438	})
439}
440
441// -----------------------------------------------------------------------------
442//  Grep renderer
443// -----------------------------------------------------------------------------
444
445// grepRenderer handles content searching with pattern matching options
446type grepRenderer struct {
447	baseRenderer
448}
449
450// Render displays the search pattern with path, include, and literal text options
451func (gr grepRenderer) Render(v *toolCallCmp) string {
452	var params tools.GrepParams
453	var args []string
454	if err := gr.unmarshalParams(v.call.Input, ¶ms); err == nil {
455		args = newParamBuilder().
456			addMain(params.Pattern).
457			addKeyValue("path", params.Path).
458			addKeyValue("include", params.Include).
459			addFlag("literal", params.LiteralText).
460			build()
461	}
462
463	return gr.renderWithParams(v, "Grep", args, func() string {
464		return renderPlainContent(v, v.result.Content)
465	})
466}
467
468// -----------------------------------------------------------------------------
469//  LS renderer
470// -----------------------------------------------------------------------------
471
472// lsRenderer handles directory listing with default path handling
473type lsRenderer struct {
474	baseRenderer
475}
476
477// Render displays the directory path, defaulting to current directory
478func (lr lsRenderer) Render(v *toolCallCmp) string {
479	var params tools.LSParams
480	var args []string
481	if err := lr.unmarshalParams(v.call.Input, ¶ms); err == nil {
482		path := params.Path
483		if path == "" {
484			path = "."
485		}
486		path = fsext.PrettyPath(path)
487
488		args = newParamBuilder().addMain(path).build()
489	}
490
491	return lr.renderWithParams(v, "List", args, func() string {
492		return renderPlainContent(v, v.result.Content)
493	})
494}
495
496// -----------------------------------------------------------------------------
497//  Sourcegraph renderer
498// -----------------------------------------------------------------------------
499
500// sourcegraphRenderer handles code search with count and context options
501type sourcegraphRenderer struct {
502	baseRenderer
503}
504
505// Render displays the search query with optional count and context window parameters
506func (sr sourcegraphRenderer) Render(v *toolCallCmp) string {
507	var params tools.SourcegraphParams
508	var args []string
509	if err := sr.unmarshalParams(v.call.Input, ¶ms); err == nil {
510		args = newParamBuilder().
511			addMain(params.Query).
512			addKeyValue("count", formatNonZero(params.Count)).
513			addKeyValue("context", formatNonZero(params.ContextWindow)).
514			build()
515	}
516
517	return sr.renderWithParams(v, "Sourcegraph", args, func() string {
518		return renderPlainContent(v, v.result.Content)
519	})
520}
521
522// -----------------------------------------------------------------------------
523//  Diagnostics renderer
524// -----------------------------------------------------------------------------
525
526// diagnosticsRenderer handles project-wide diagnostic information
527type diagnosticsRenderer struct {
528	baseRenderer
529}
530
531// Render displays project diagnostics with plain content formatting
532func (dr diagnosticsRenderer) Render(v *toolCallCmp) string {
533	args := newParamBuilder().addMain("project").build()
534
535	return dr.renderWithParams(v, "Diagnostics", args, func() string {
536		return renderPlainContent(v, v.result.Content)
537	})
538}
539
540// -----------------------------------------------------------------------------
541//  Task renderer
542// -----------------------------------------------------------------------------
543
544// agentRenderer handles project-wide diagnostic information
545type agentRenderer struct {
546	baseRenderer
547}
548
549func RoundedEnumerator(children tree.Children, index int) string {
550	if children.Length()-1 == index {
551		return " ╰──"
552	}
553	return " ├──"
554}
555
556// Render displays agent task parameters and result content
557func (tr agentRenderer) Render(v *toolCallCmp) string {
558	t := styles.CurrentTheme()
559	var params agent.AgentParams
560	tr.unmarshalParams(v.call.Input, ¶ms)
561
562	prompt := params.Prompt
563	prompt = strings.ReplaceAll(prompt, "\n", " ")
564
565	header := tr.makeHeader(v, "Agent", v.textWidth())
566	if res, done := earlyState(header, v); v.cancelled && done {
567		return res
568	}
569	taskTag := t.S().Base.Padding(0, 1).MarginLeft(1).Background(t.BlueLight).Foreground(t.White).Render("Task")
570	remainingWidth := v.textWidth() - lipgloss.Width(header) - lipgloss.Width(taskTag) - 2 // -2 for padding
571	prompt = t.S().Muted.Width(remainingWidth).Render(prompt)
572	header = lipgloss.JoinVertical(
573		lipgloss.Left,
574		header,
575		"",
576		lipgloss.JoinHorizontal(
577			lipgloss.Left,
578			taskTag,
579			" ",
580			prompt,
581		),
582	)
583	childTools := tree.Root(header)
584
585	for _, call := range v.nestedToolCalls {
586		childTools.Child(call.View())
587	}
588	parts := []string{
589		childTools.Enumerator(RoundedEnumerator).String(),
590	}
591
592	if v.result.ToolCallID == "" {
593		v.spinning = true
594		parts = append(parts, "", v.anim.View())
595	} else {
596		v.spinning = false
597	}
598
599	header = lipgloss.JoinVertical(
600		lipgloss.Left,
601		parts...,
602	)
603
604	if v.result.ToolCallID == "" {
605		return header
606	}
607
608	body := renderPlainContent(v, v.result.Content)
609	return joinHeaderBody(header, body)
610}
611
612// renderParamList renders params, params[0] (params[1]=params[2] ....)
613func renderParamList(nested bool, paramsWidth int, params ...string) string {
614	t := styles.CurrentTheme()
615	if len(params) == 0 {
616		return ""
617	}
618	mainParam := params[0]
619	if paramsWidth >= 0 && lipgloss.Width(mainParam) > paramsWidth {
620		mainParam = ansi.Truncate(mainParam, paramsWidth, "…")
621	}
622
623	if len(params) == 1 {
624		if nested {
625			return t.S().Muted.Render(mainParam)
626		}
627		return t.S().Subtle.Render(mainParam)
628	}
629	otherParams := params[1:]
630	// create pairs of key/value
631	// if odd number of params, the last one is a key without value
632	if len(otherParams)%2 != 0 {
633		otherParams = append(otherParams, "")
634	}
635	parts := make([]string, 0, len(otherParams)/2)
636	for i := 0; i < len(otherParams); i += 2 {
637		key := otherParams[i]
638		value := otherParams[i+1]
639		if value == "" {
640			continue
641		}
642		parts = append(parts, fmt.Sprintf("%s=%s", key, value))
643	}
644
645	partsRendered := strings.Join(parts, ", ")
646	remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 3 // count for " ()"
647	if remainingWidth < 30 {
648		if nested {
649			return t.S().Muted.Render(mainParam)
650		}
651		// No space for the params, just show the main
652		return t.S().Subtle.Render(mainParam)
653	}
654
655	if len(parts) > 0 {
656		mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
657	}
658
659	if nested {
660		return t.S().Muted.Render(ansi.Truncate(mainParam, paramsWidth, "…"))
661	}
662	return t.S().Subtle.Render(ansi.Truncate(mainParam, paramsWidth, "…"))
663}
664
665// earlyState returns immediately‑rendered error/cancelled/ongoing states.
666func earlyState(header string, v *toolCallCmp) (string, bool) {
667	t := styles.CurrentTheme()
668	message := ""
669	switch {
670	case v.result.IsError:
671		message = v.renderToolError()
672	case v.cancelled:
673		message = t.S().Base.Foreground(t.FgSubtle).Render("Canceled.")
674	case v.result.ToolCallID == "":
675		message = t.S().Base.Foreground(t.FgSubtle).Render("Waiting for tool to start...")
676	default:
677		return "", false
678	}
679
680	message = t.S().Base.PaddingLeft(2).Render(message)
681	return lipgloss.JoinVertical(lipgloss.Left, header, "", message), true
682}
683
684func joinHeaderBody(header, body string) string {
685	t := styles.CurrentTheme()
686	if body == "" {
687		return header
688	}
689	body = t.S().Base.PaddingLeft(2).Render(body)
690	return lipgloss.JoinVertical(lipgloss.Left, header, "", body)
691}
692
693func renderPlainContent(v *toolCallCmp, content string) string {
694	t := styles.CurrentTheme()
695	content = strings.ReplaceAll(content, "\r\n", "\n") // Normalize line endings
696	content = strings.ReplaceAll(content, "\t", "    ") // Replace tabs with spaces
697	content = strings.TrimSpace(content)
698	lines := strings.Split(content, "\n")
699
700	width := v.textWidth() - 2 // -2 for left padding
701	var out []string
702	for i, ln := range lines {
703		if i >= responseContextHeight {
704			break
705		}
706		ln = ansiext.Escape(ln)
707		ln = " " + ln // left padding
708		if len(ln) > width {
709			ln = v.fit(ln, width)
710		}
711		out = append(out, t.S().Muted.
712			Width(width).
713			Background(t.BgBaseLighter).
714			Render(ln))
715	}
716
717	if len(lines) > responseContextHeight {
718		out = append(out, t.S().Muted.
719			Background(t.BgBaseLighter).
720			Width(width).
721			Render(fmt.Sprintf("… (%d lines)", len(lines)-responseContextHeight)))
722	}
723
724	return strings.Join(out, "\n")
725}
726
727func pad(v any, width int) string {
728	s := fmt.Sprintf("%v", v)
729	w := ansi.StringWidth(s)
730	if w >= width {
731		return s
732	}
733	return strings.Repeat(" ", width-w) + s
734}
735
736func renderCodeContent(v *toolCallCmp, path, content string, offset int) string {
737	t := styles.CurrentTheme()
738	content = strings.ReplaceAll(content, "\r\n", "\n") // Normalize line endings
739	content = strings.ReplaceAll(content, "\t", "    ") // Replace tabs with spaces
740	truncated := truncateHeight(content, responseContextHeight)
741
742	lines := strings.Split(truncated, "\n")
743	for i, ln := range lines {
744		lines[i] = ansiext.Escape(ln)
745	}
746
747	highlighted, _ := highlight.SyntaxHighlight(strings.Join(lines, "\n"), path, t.BgBase)
748	lines = strings.Split(highlighted, "\n")
749
750	if len(strings.Split(content, "\n")) > responseContextHeight {
751		lines = append(lines, t.S().Muted.
752			Background(t.BgBase).
753			Render(fmt.Sprintf(" …(%d lines)", len(strings.Split(content, "\n"))-responseContextHeight)))
754	}
755
756	maxLineNumber := len(lines) + offset
757	padding := lipgloss.Width(fmt.Sprintf("%d", maxLineNumber))
758	for i, ln := range lines {
759		num := t.S().Base.
760			Foreground(t.FgMuted).
761			Background(t.BgBase).
762			PaddingRight(1).
763			PaddingLeft(1).
764			Render(pad(i+1+offset, padding))
765		w := v.textWidth() - 10 - lipgloss.Width(num) // -4 for left padding
766		lines[i] = lipgloss.JoinHorizontal(lipgloss.Left,
767			num,
768			t.S().Base.
769				PaddingLeft(1).
770				Render(v.fit(ln, w-1)))
771	}
772
773	return lipgloss.JoinVertical(lipgloss.Left, lines...)
774}
775
776func (v *toolCallCmp) renderToolError() string {
777	t := styles.CurrentTheme()
778	err := strings.ReplaceAll(v.result.Content, "\n", " ")
779	errTag := t.S().Base.Padding(0, 1).Background(t.Red).Foreground(t.White).Render("ERROR")
780	err = fmt.Sprintf("%s %s", errTag, t.S().Base.Foreground(t.FgHalfMuted).Render(v.fit(err, v.textWidth()-2-lipgloss.Width(errTag))))
781	return err
782}
783
784func truncateHeight(s string, h int) string {
785	lines := strings.Split(s, "\n")
786	if len(lines) > h {
787		return strings.Join(lines[:h], "\n")
788	}
789	return s
790}
791
792func prettifyToolName(name string) string {
793	switch name {
794	case agent.AgentToolName:
795		return "Agent"
796	case tools.BashToolName:
797		return "Bash"
798	case tools.DownloadToolName:
799		return "Download"
800	case tools.EditToolName:
801		return "Edit"
802	case tools.FetchToolName:
803		return "Fetch"
804	case tools.GlobToolName:
805		return "Glob"
806	case tools.GrepToolName:
807		return "Grep"
808	case tools.LSToolName:
809		return "List"
810	case tools.SourcegraphToolName:
811		return "Sourcegraph"
812	case tools.ViewToolName:
813		return "View"
814	case tools.WriteToolName:
815		return "Write"
816	default:
817		return name
818	}
819}