renderer.go

  1package messages
  2
  3import (
  4	"encoding/json"
  5	"fmt"
  6	"strings"
  7	"time"
  8
  9	"github.com/charmbracelet/lipgloss/v2"
 10	"github.com/charmbracelet/x/ansi"
 11	"github.com/opencode-ai/opencode/internal/config"
 12	"github.com/opencode-ai/opencode/internal/diff"
 13	"github.com/opencode-ai/opencode/internal/highlight"
 14	"github.com/opencode-ai/opencode/internal/llm/agent"
 15	"github.com/opencode-ai/opencode/internal/llm/tools"
 16	"github.com/opencode-ai/opencode/internal/tui/styles"
 17	"github.com/opencode-ai/opencode/internal/tui/theme"
 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	header := makeHeader(toolName, v.textWidth(), args...)
 95	if res, done := earlyState(header, v); done {
 96		return res
 97	}
 98	body := contentRenderer()
 99	return joinHeaderBody(header, body)
100}
101
102// unmarshalParams safely unmarshals JSON parameters
103func (br baseRenderer) unmarshalParams(input string, target any) error {
104	return json.Unmarshal([]byte(input), target)
105}
106
107// Register tool renderers
108func init() {
109	registry.register(tools.BashToolName, func() renderer { return bashRenderer{} })
110	registry.register(tools.ViewToolName, func() renderer { return viewRenderer{} })
111	registry.register(tools.EditToolName, func() renderer { return editRenderer{} })
112	registry.register(tools.WriteToolName, func() renderer { return writeRenderer{} })
113	registry.register(tools.FetchToolName, func() renderer { return fetchRenderer{} })
114	registry.register(tools.GlobToolName, func() renderer { return globRenderer{} })
115	registry.register(tools.GrepToolName, func() renderer { return grepRenderer{} })
116	registry.register(tools.LSToolName, func() renderer { return lsRenderer{} })
117	registry.register(tools.SourcegraphToolName, func() renderer { return sourcegraphRenderer{} })
118	registry.register(tools.PatchToolName, func() renderer { return patchRenderer{} })
119	registry.register(tools.DiagnosticsToolName, func() renderer { return diagnosticsRenderer{} })
120}
121
122// -----------------------------------------------------------------------------
123//  Generic renderer
124// -----------------------------------------------------------------------------
125
126// genericRenderer handles unknown tool types with basic parameter display
127type genericRenderer struct {
128	baseRenderer
129}
130
131// Render displays the tool call with its raw input and plain content output
132func (gr genericRenderer) Render(v *toolCallCmp) string {
133	return gr.renderWithParams(v, prettifyToolName(v.call.Name), []string{v.call.Input}, func() string {
134		return renderPlainContent(v, v.result.Content)
135	})
136}
137
138// -----------------------------------------------------------------------------
139//  Bash renderer
140// -----------------------------------------------------------------------------
141
142// bashRenderer handles bash command execution display
143type bashRenderer struct {
144	baseRenderer
145}
146
147// Render displays the bash command with sanitized newlines and plain output
148func (br bashRenderer) Render(v *toolCallCmp) string {
149	var params tools.BashParams
150	if err := br.unmarshalParams(v.call.Input, &params); err != nil {
151		return br.renderError(v, "Invalid bash parameters")
152	}
153
154	cmd := strings.ReplaceAll(params.Command, "\n", " ")
155	args := newParamBuilder().addMain(cmd).build()
156
157	return br.renderWithParams(v, "Bash", args, func() string {
158		return renderPlainContent(v, v.result.Content)
159	})
160}
161
162// renderError provides consistent error rendering
163func (br baseRenderer) renderError(v *toolCallCmp, message string) string {
164	header := makeHeader("Error", v.textWidth(), message)
165	return joinHeaderBody(header, "")
166}
167
168// -----------------------------------------------------------------------------
169//  View renderer
170// -----------------------------------------------------------------------------
171
172// viewRenderer handles file viewing with syntax highlighting and line numbers
173type viewRenderer struct {
174	baseRenderer
175}
176
177// Render displays file content with optional limit and offset parameters
178func (vr viewRenderer) Render(v *toolCallCmp) string {
179	var params tools.ViewParams
180	if err := vr.unmarshalParams(v.call.Input, &params); err != nil {
181		return vr.renderError(v, "Invalid view parameters")
182	}
183
184	file := removeWorkingDirPrefix(params.FilePath)
185	args := newParamBuilder().
186		addMain(file).
187		addKeyValue("limit", formatNonZero(params.Limit)).
188		addKeyValue("offset", formatNonZero(params.Offset)).
189		build()
190
191	return vr.renderWithParams(v, "View", args, func() string {
192		var meta tools.ViewResponseMetadata
193		if err := vr.unmarshalParams(v.result.Metadata, &meta); err != nil {
194			return renderPlainContent(v, v.result.Content)
195		}
196		return renderCodeContent(v, meta.FilePath, meta.Content, params.Offset)
197	})
198}
199
200// formatNonZero returns string representation of non-zero integers, empty string for zero
201func formatNonZero(value int) string {
202	if value == 0 {
203		return ""
204	}
205	return fmt.Sprintf("%d", value)
206}
207
208// -----------------------------------------------------------------------------
209//  Edit renderer
210// -----------------------------------------------------------------------------
211
212// editRenderer handles file editing with diff visualization
213type editRenderer struct {
214	baseRenderer
215}
216
217// Render displays the edited file with a formatted diff of changes
218func (er editRenderer) Render(v *toolCallCmp) string {
219	var params tools.EditParams
220	if err := er.unmarshalParams(v.call.Input, &params); err != nil {
221		return er.renderError(v, "Invalid edit parameters")
222	}
223
224	file := removeWorkingDirPrefix(params.FilePath)
225	args := newParamBuilder().addMain(file).build()
226
227	return er.renderWithParams(v, "Edit", args, func() string {
228		var meta tools.EditResponseMetadata
229		if err := er.unmarshalParams(v.result.Metadata, &meta); err != nil {
230			return renderPlainContent(v, v.result.Content)
231		}
232
233		trunc := truncateHeight(meta.Diff, responseContextHeight)
234		diffView, _ := diff.FormatDiff(trunc, diff.WithTotalWidth(v.textWidth()))
235		return diffView
236	})
237}
238
239// -----------------------------------------------------------------------------
240//  Write renderer
241// -----------------------------------------------------------------------------
242
243// writeRenderer handles file writing with syntax-highlighted content preview
244type writeRenderer struct {
245	baseRenderer
246}
247
248// Render displays the file being written with syntax highlighting
249func (wr writeRenderer) Render(v *toolCallCmp) string {
250	var params tools.WriteParams
251	if err := wr.unmarshalParams(v.call.Input, &params); err != nil {
252		return wr.renderError(v, "Invalid write parameters")
253	}
254
255	file := removeWorkingDirPrefix(params.FilePath)
256	args := newParamBuilder().addMain(file).build()
257
258	return wr.renderWithParams(v, "Write", args, func() string {
259		return renderCodeContent(v, file, params.Content, 0)
260	})
261}
262
263// -----------------------------------------------------------------------------
264//  Fetch renderer
265// -----------------------------------------------------------------------------
266
267// fetchRenderer handles URL fetching with format-specific content display
268type fetchRenderer struct {
269	baseRenderer
270}
271
272// Render displays the fetched URL with format and timeout parameters
273func (fr fetchRenderer) Render(v *toolCallCmp) string {
274	var params tools.FetchParams
275	if err := fr.unmarshalParams(v.call.Input, &params); err != nil {
276		return fr.renderError(v, "Invalid fetch parameters")
277	}
278
279	args := newParamBuilder().
280		addMain(params.URL).
281		addKeyValue("format", params.Format).
282		addKeyValue("timeout", formatTimeout(params.Timeout)).
283		build()
284
285	return fr.renderWithParams(v, "Fetch", args, func() string {
286		file := fr.getFileExtension(params.Format)
287		return renderCodeContent(v, file, v.result.Content, 0)
288	})
289}
290
291// getFileExtension returns appropriate file extension for syntax highlighting
292func (fr fetchRenderer) getFileExtension(format string) string {
293	switch format {
294	case "text":
295		return "fetch.txt"
296	case "html":
297		return "fetch.html"
298	default:
299		return "fetch.md"
300	}
301}
302
303// formatTimeout converts timeout seconds to duration string
304func formatTimeout(timeout int) string {
305	if timeout == 0 {
306		return ""
307	}
308	return (time.Duration(timeout) * time.Second).String()
309}
310
311// -----------------------------------------------------------------------------
312//  Glob renderer
313// -----------------------------------------------------------------------------
314
315// globRenderer handles file pattern matching with path filtering
316type globRenderer struct {
317	baseRenderer
318}
319
320// Render displays the glob pattern with optional path parameter
321func (gr globRenderer) Render(v *toolCallCmp) string {
322	var params tools.GlobParams
323	if err := gr.unmarshalParams(v.call.Input, &params); err != nil {
324		return gr.renderError(v, "Invalid glob parameters")
325	}
326
327	args := newParamBuilder().
328		addMain(params.Pattern).
329		addKeyValue("path", params.Path).
330		build()
331
332	return gr.renderWithParams(v, "Glob", args, func() string {
333		return renderPlainContent(v, v.result.Content)
334	})
335}
336
337// -----------------------------------------------------------------------------
338//  Grep renderer
339// -----------------------------------------------------------------------------
340
341// grepRenderer handles content searching with pattern matching options
342type grepRenderer struct {
343	baseRenderer
344}
345
346// Render displays the search pattern with path, include, and literal text options
347func (gr grepRenderer) Render(v *toolCallCmp) string {
348	var params tools.GrepParams
349	if err := gr.unmarshalParams(v.call.Input, &params); err != nil {
350		return gr.renderError(v, "Invalid grep parameters")
351	}
352
353	args := newParamBuilder().
354		addMain(params.Pattern).
355		addKeyValue("path", params.Path).
356		addKeyValue("include", params.Include).
357		addFlag("literal", params.LiteralText).
358		build()
359
360	return gr.renderWithParams(v, "Grep", args, func() string {
361		return renderPlainContent(v, v.result.Content)
362	})
363}
364
365// -----------------------------------------------------------------------------
366//  LS renderer
367// -----------------------------------------------------------------------------
368
369// lsRenderer handles directory listing with default path handling
370type lsRenderer struct {
371	baseRenderer
372}
373
374// Render displays the directory path, defaulting to current directory
375func (lr lsRenderer) Render(v *toolCallCmp) string {
376	var params tools.LSParams
377	if err := lr.unmarshalParams(v.call.Input, &params); err != nil {
378		return lr.renderError(v, "Invalid ls parameters")
379	}
380
381	path := params.Path
382	if path == "" {
383		path = "."
384	}
385
386	args := newParamBuilder().addMain(path).build()
387
388	return lr.renderWithParams(v, "List", args, func() string {
389		return renderPlainContent(v, v.result.Content)
390	})
391}
392
393// -----------------------------------------------------------------------------
394//  Sourcegraph renderer
395// -----------------------------------------------------------------------------
396
397// sourcegraphRenderer handles code search with count and context options
398type sourcegraphRenderer struct {
399	baseRenderer
400}
401
402// Render displays the search query with optional count and context window parameters
403func (sr sourcegraphRenderer) Render(v *toolCallCmp) string {
404	var params tools.SourcegraphParams
405	if err := sr.unmarshalParams(v.call.Input, &params); err != nil {
406		return sr.renderError(v, "Invalid sourcegraph parameters")
407	}
408
409	args := newParamBuilder().
410		addMain(params.Query).
411		addKeyValue("count", formatNonZero(params.Count)).
412		addKeyValue("context", formatNonZero(params.ContextWindow)).
413		build()
414
415	return sr.renderWithParams(v, "Sourcegraph", args, func() string {
416		return renderPlainContent(v, v.result.Content)
417	})
418}
419
420// -----------------------------------------------------------------------------
421//  Patch renderer
422// -----------------------------------------------------------------------------
423
424// patchRenderer handles multi-file patches with change summaries
425type patchRenderer struct {
426	baseRenderer
427}
428
429// Render displays patch summary with file count and change statistics
430func (pr patchRenderer) Render(v *toolCallCmp) string {
431	var params tools.PatchParams
432	if err := pr.unmarshalParams(v.call.Input, &params); err != nil {
433		return pr.renderError(v, "Invalid patch parameters")
434	}
435
436	args := newParamBuilder().addMain("multiple files").build()
437
438	return pr.renderWithParams(v, "Patch", args, func() string {
439		var meta tools.PatchResponseMetadata
440		if err := pr.unmarshalParams(v.result.Metadata, &meta); err != nil {
441			return renderPlainContent(v, v.result.Content)
442		}
443
444		summary := fmt.Sprintf("Changed %d files (%d+ %d-)",
445			len(meta.FilesChanged), meta.Additions, meta.Removals)
446		filesList := strings.Join(meta.FilesChanged, "\n")
447
448		return renderPlainContent(v, summary+"\n\n"+filesList)
449	})
450}
451
452// -----------------------------------------------------------------------------
453//  Diagnostics renderer
454// -----------------------------------------------------------------------------
455
456// diagnosticsRenderer handles project-wide diagnostic information
457type diagnosticsRenderer struct {
458	baseRenderer
459}
460
461// Render displays project diagnostics with plain content formatting
462func (dr diagnosticsRenderer) Render(v *toolCallCmp) string {
463	args := newParamBuilder().addMain("project").build()
464
465	return dr.renderWithParams(v, "Diagnostics", args, func() string {
466		return renderPlainContent(v, v.result.Content)
467	})
468}
469
470// makeHeader builds "<Tool>: param (key=value)" and truncates as needed.
471func makeHeader(tool string, width int, params ...string) string {
472	prefix := tool + ": "
473	return prefix + renderParamList(width-lipgloss.Width(prefix), params...)
474}
475
476// renderParamList renders params, params[0] (params[1]=params[2] ....)
477func renderParamList(paramsWidth int, params ...string) string {
478	if len(params) == 0 {
479		return ""
480	}
481	mainParam := params[0]
482	if len(mainParam) > paramsWidth {
483		mainParam = mainParam[:paramsWidth-3] + "..."
484	}
485
486	if len(params) == 1 {
487		return mainParam
488	}
489	otherParams := params[1:]
490	// create pairs of key/value
491	// if odd number of params, the last one is a key without value
492	if len(otherParams)%2 != 0 {
493		otherParams = append(otherParams, "")
494	}
495	parts := make([]string, 0, len(otherParams)/2)
496	for i := 0; i < len(otherParams); i += 2 {
497		key := otherParams[i]
498		value := otherParams[i+1]
499		if value == "" {
500			continue
501		}
502		parts = append(parts, fmt.Sprintf("%s=%s", key, value))
503	}
504
505	partsRendered := strings.Join(parts, ", ")
506	remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 3 // count for " ()"
507	if remainingWidth < 30 {
508		// No space for the params, just show the main
509		return mainParam
510	}
511
512	if len(parts) > 0 {
513		mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
514	}
515
516	return ansi.Truncate(mainParam, paramsWidth, "...")
517}
518
519// earlyState returns immediately‑rendered error/cancelled/ongoing states.
520func earlyState(header string, v *toolCallCmp) (string, bool) {
521	switch {
522	case v.result.IsError:
523		return lipgloss.JoinVertical(lipgloss.Left, header, v.renderToolError()), true
524	case v.cancelled:
525		return lipgloss.JoinVertical(lipgloss.Left, header, "Cancelled"), true
526	case v.result.ToolCallID == "":
527		return lipgloss.JoinVertical(lipgloss.Left, header, "Waiting for tool to finish..."), true
528	default:
529		return "", false
530	}
531}
532
533func joinHeaderBody(header, body string) string {
534	return lipgloss.JoinVertical(lipgloss.Left, header, "", body, "")
535}
536
537func renderPlainContent(v *toolCallCmp, content string) string {
538	t := theme.CurrentTheme()
539	content = strings.TrimSpace(content)
540	lines := strings.Split(content, "\n")
541
542	var out []string
543	for i, ln := range lines {
544		if i >= responseContextHeight {
545			break
546		}
547		ln = " " + ln // left padding
548		if len(ln) > v.textWidth() {
549			ln = v.fit(ln, v.textWidth())
550		}
551		out = append(out, lipgloss.NewStyle().
552			Width(v.textWidth()).
553			Background(t.BackgroundSecondary()).
554			Foreground(t.TextMuted()).
555			Render(ln))
556	}
557
558	if len(lines) > responseContextHeight {
559		out = append(out, lipgloss.NewStyle().
560			Background(t.BackgroundSecondary()).
561			Foreground(t.TextMuted()).
562			Render(fmt.Sprintf("... (%d lines)", len(lines)-responseContextHeight)))
563	}
564	return strings.Join(out, "\n")
565}
566
567func renderCodeContent(v *toolCallCmp, path, content string, offset int) string {
568	t := theme.CurrentTheme()
569	truncated := truncateHeight(content, responseContextHeight)
570
571	highlighted, _ := highlight.SyntaxHighlight(truncated, path, t.BackgroundSecondary())
572	lines := strings.Split(highlighted, "\n")
573
574	if len(strings.Split(content, "\n")) > responseContextHeight {
575		lines = append(lines, lipgloss.NewStyle().
576			Background(t.BackgroundSecondary()).
577			Foreground(t.TextMuted()).
578			Render(fmt.Sprintf("... (%d lines)", len(strings.Split(content, "\n"))-responseContextHeight)))
579	}
580
581	for i, ln := range lines {
582		num := lipgloss.NewStyle().
583			PaddingLeft(4).PaddingRight(2).
584			Background(t.BackgroundSecondary()).
585			Foreground(t.TextMuted()).
586			Render(fmt.Sprintf("%d", i+1+offset))
587		w := v.textWidth() - lipgloss.Width(num)
588		lines[i] = lipgloss.JoinHorizontal(lipgloss.Left,
589			num,
590			lipgloss.NewStyle().
591				Width(w).
592				Background(t.BackgroundSecondary()).
593				Render(v.fit(ln, w)))
594	}
595	return lipgloss.JoinVertical(lipgloss.Left, lines...)
596}
597
598func (v *toolCallCmp) renderToolError() string {
599	t := theme.CurrentTheme()
600	err := strings.ReplaceAll(v.result.Content, "\n", " ")
601	err = fmt.Sprintf("Error: %s", err)
602	return styles.BaseStyle().Foreground(t.Error()).Render(v.fit(err, v.textWidth()))
603}
604
605func removeWorkingDirPrefix(path string) string {
606	wd := config.WorkingDirectory()
607	return strings.TrimPrefix(path, wd)
608}
609
610func truncateHeight(s string, h int) string {
611	lines := strings.Split(s, "\n")
612	if len(lines) > h {
613		return strings.Join(lines[:h], "\n")
614	}
615	return s
616}
617
618func prettifyToolName(name string) string {
619	switch name {
620	case agent.AgentToolName:
621		return "Task"
622	case tools.BashToolName:
623		return "Bash"
624	case tools.EditToolName:
625		return "Edit"
626	case tools.FetchToolName:
627		return "Fetch"
628	case tools.GlobToolName:
629		return "Glob"
630	case tools.GrepToolName:
631		return "Grep"
632	case tools.LSToolName:
633		return "List"
634	case tools.SourcegraphToolName:
635		return "Sourcegraph"
636	case tools.ViewToolName:
637		return "View"
638	case tools.WriteToolName:
639		return "Write"
640	case tools.PatchToolName:
641		return "Patch"
642	default:
643		return name
644	}
645}