renderer.go

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