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
 20const responseContextHeight = 10
 21
 22type renderer interface {
 23	// Render returns the complete (already styled) tool‑call view, not
 24	// including the outer border.
 25	Render(v *toolCallCmp) string
 26}
 27
 28type rendererFactory func() renderer
 29
 30type renderRegistry map[string]rendererFactory
 31
 32func (rr renderRegistry) register(name string, f rendererFactory) { rr[name] = f }
 33func (rr renderRegistry) lookup(name string) renderer {
 34	if f, ok := rr[name]; ok {
 35		return f()
 36	}
 37	return genericRenderer{} // sensible fallback
 38}
 39
 40var registry = renderRegistry{}
 41
 42// Registger tool renderers
 43func init() {
 44	registry.register(tools.BashToolName, func() renderer { return bashRenderer{} })
 45	registry.register(tools.ViewToolName, func() renderer { return viewRenderer{} })
 46	registry.register(tools.EditToolName, func() renderer { return editRenderer{} })
 47	registry.register(tools.WriteToolName, func() renderer { return writeRenderer{} })
 48	registry.register(tools.FetchToolName, func() renderer { return fetchRenderer{} })
 49	registry.register(tools.GlobToolName, func() renderer { return globRenderer{} })
 50	registry.register(tools.GrepToolName, func() renderer { return grepRenderer{} })
 51	registry.register(tools.LSToolName, func() renderer { return lsRenderer{} })
 52	registry.register(tools.SourcegraphToolName, func() renderer { return sourcegraphRenderer{} })
 53	registry.register(tools.PatchToolName, func() renderer { return patchRenderer{} })
 54	registry.register(tools.DiagnosticsToolName, func() renderer { return diagnosticsRenderer{} })
 55}
 56
 57// -----------------------------------------------------------------------------
 58//  Generic renderer
 59// -----------------------------------------------------------------------------
 60
 61type genericRenderer struct{}
 62
 63func (genericRenderer) Render(v *toolCallCmp) string {
 64	header := makeHeader(prettifyToolName(v.call.Name), v.textWidth(), v.call.Input)
 65	if res, done := earlyState(header, v); done {
 66		return res
 67	}
 68	body := renderPlainContent(v, v.result.Content)
 69	return joinHeaderBody(header, body)
 70}
 71
 72// -----------------------------------------------------------------------------
 73//  Bash renderer
 74// -----------------------------------------------------------------------------
 75
 76type bashRenderer struct{}
 77
 78func (bashRenderer) Render(v *toolCallCmp) string {
 79	var p tools.BashParams
 80	_ = json.Unmarshal([]byte(v.call.Input), &p)
 81
 82	cmd := strings.ReplaceAll(p.Command, "\n", " ")
 83	header := makeHeader("Bash", v.textWidth(), cmd)
 84	if res, done := earlyState(header, v); done {
 85		return res
 86	}
 87	body := renderPlainContent(v, v.result.Content)
 88	return joinHeaderBody(header, body)
 89}
 90
 91// -----------------------------------------------------------------------------
 92//  View renderer
 93// -----------------------------------------------------------------------------
 94
 95type viewRenderer struct{}
 96
 97func (viewRenderer) Render(v *toolCallCmp) string {
 98	var params tools.ViewParams
 99	_ = json.Unmarshal([]byte(v.call.Input), &params)
100
101	file := removeWorkingDirPrefix(params.FilePath)
102	args := []string{file}
103	if params.Limit != 0 {
104		args = append(args, "limit", fmt.Sprintf("%d", params.Limit))
105	}
106	if params.Offset != 0 {
107		args = append(args, "offset", fmt.Sprintf("%d", params.Offset))
108	}
109
110	header := makeHeader("View", v.textWidth(), args...)
111	if res, done := earlyState(header, v); done {
112		return res
113	}
114
115	var meta tools.ViewResponseMetadata
116	_ = json.Unmarshal([]byte(v.result.Metadata), &meta)
117
118	body := renderCodeContent(v, meta.FilePath, meta.Content, params.Offset)
119	return joinHeaderBody(header, body)
120}
121
122// -----------------------------------------------------------------------------
123//  Edit renderer
124// -----------------------------------------------------------------------------
125
126type editRenderer struct{}
127
128func (editRenderer) Render(v *toolCallCmp) string {
129	var params tools.EditParams
130	_ = json.Unmarshal([]byte(v.call.Input), &params)
131
132	file := removeWorkingDirPrefix(params.FilePath)
133	header := makeHeader("Edit", v.textWidth(), file)
134	if res, done := earlyState(header, v); done {
135		return res
136	}
137
138	var meta tools.EditResponseMetadata
139	_ = json.Unmarshal([]byte(v.result.Metadata), &meta)
140
141	trunc := truncateHeight(meta.Diff, responseContextHeight)
142	diffView, _ := diff.FormatDiff(trunc, diff.WithTotalWidth(v.textWidth()))
143	return joinHeaderBody(header, diffView)
144}
145
146// -----------------------------------------------------------------------------
147//  Write renderer
148// -----------------------------------------------------------------------------
149
150type writeRenderer struct{}
151
152func (writeRenderer) Render(v *toolCallCmp) string {
153	var params tools.WriteParams
154	_ = json.Unmarshal([]byte(v.call.Input), &params)
155
156	file := removeWorkingDirPrefix(params.FilePath)
157	header := makeHeader("Write", v.textWidth(), file)
158	if res, done := earlyState(header, v); done {
159		return res
160	}
161
162	body := renderCodeContent(v, file, params.Content, 0)
163	return joinHeaderBody(header, body)
164}
165
166// -----------------------------------------------------------------------------
167//  Fetch renderer
168// -----------------------------------------------------------------------------
169
170type fetchRenderer struct{}
171
172func (fetchRenderer) Render(v *toolCallCmp) string {
173	var params tools.FetchParams
174	_ = json.Unmarshal([]byte(v.call.Input), &params)
175
176	args := []string{params.URL}
177	if params.Format != "" {
178		args = append(args, "format", params.Format)
179	}
180	if params.Timeout != 0 {
181		args = append(args, "timeout", (time.Duration(params.Timeout) * time.Second).String())
182	}
183
184	header := makeHeader("Fetch", v.textWidth(), args...)
185	if res, done := earlyState(header, v); done {
186		return res
187	}
188
189	file := "fetch.md"
190	switch params.Format {
191	case "text":
192		file = "fetch.txt"
193	case "html":
194		file = "fetch.html"
195	}
196
197	body := renderCodeContent(v, file, v.result.Content, 0)
198	return joinHeaderBody(header, body)
199}
200
201// -----------------------------------------------------------------------------
202//  Glob renderer
203// -----------------------------------------------------------------------------
204
205type globRenderer struct{}
206
207func (globRenderer) Render(v *toolCallCmp) string {
208	var params tools.GlobParams
209	_ = json.Unmarshal([]byte(v.call.Input), &params)
210
211	args := []string{params.Pattern}
212	if params.Path != "" {
213		args = append(args, "path", params.Path)
214	}
215
216	header := makeHeader("Glob", v.textWidth(), args...)
217	if res, done := earlyState(header, v); done {
218		return res
219	}
220
221	body := renderPlainContent(v, v.result.Content)
222	return joinHeaderBody(header, body)
223}
224
225// -----------------------------------------------------------------------------
226//  Grep renderer
227// -----------------------------------------------------------------------------
228
229type grepRenderer struct{}
230
231func (grepRenderer) Render(v *toolCallCmp) string {
232	var params tools.GrepParams
233	_ = json.Unmarshal([]byte(v.call.Input), &params)
234
235	args := []string{params.Pattern}
236	if params.Path != "" {
237		args = append(args, "path", params.Path)
238	}
239	if params.Include != "" {
240		args = append(args, "include", params.Include)
241	}
242	if params.LiteralText {
243		args = append(args, "literal", "true")
244	}
245
246	header := makeHeader("Grep", v.textWidth(), args...)
247	if res, done := earlyState(header, v); done {
248		return res
249	}
250
251	body := renderPlainContent(v, v.result.Content)
252	return joinHeaderBody(header, body)
253}
254
255// -----------------------------------------------------------------------------
256//  LS renderer
257// -----------------------------------------------------------------------------
258
259type lsRenderer struct{}
260
261func (lsRenderer) Render(v *toolCallCmp) string {
262	var params tools.LSParams
263	_ = json.Unmarshal([]byte(v.call.Input), &params)
264
265	path := params.Path
266	if path == "" {
267		path = "."
268	}
269
270	header := makeHeader("List", v.textWidth(), path)
271	if res, done := earlyState(header, v); done {
272		return res
273	}
274
275	body := renderPlainContent(v, v.result.Content)
276	return joinHeaderBody(header, body)
277}
278
279// -----------------------------------------------------------------------------
280//  Sourcegraph renderer
281// -----------------------------------------------------------------------------
282
283type sourcegraphRenderer struct{}
284
285func (sourcegraphRenderer) Render(v *toolCallCmp) string {
286	var params tools.SourcegraphParams
287	_ = json.Unmarshal([]byte(v.call.Input), &params)
288
289	args := []string{params.Query}
290	if params.Count != 0 {
291		args = append(args, "count", fmt.Sprintf("%d", params.Count))
292	}
293	if params.ContextWindow != 0 {
294		args = append(args, "context", fmt.Sprintf("%d", params.ContextWindow))
295	}
296
297	header := makeHeader("Sourcegraph", v.textWidth(), args...)
298	if res, done := earlyState(header, v); done {
299		return res
300	}
301
302	body := renderPlainContent(v, v.result.Content)
303	return joinHeaderBody(header, body)
304}
305
306// -----------------------------------------------------------------------------
307//  Patch renderer
308// -----------------------------------------------------------------------------
309
310type patchRenderer struct{}
311
312func (patchRenderer) Render(v *toolCallCmp) string {
313	var params tools.PatchParams
314	_ = json.Unmarshal([]byte(v.call.Input), &params)
315
316	header := makeHeader("Patch", v.textWidth(), "multiple files")
317	if res, done := earlyState(header, v); done {
318		return res
319	}
320
321	var meta tools.PatchResponseMetadata
322	_ = json.Unmarshal([]byte(v.result.Metadata), &meta)
323
324	// Format the result as a summary of changes
325	summary := fmt.Sprintf("Changed %d files (%d+ %d-)",
326		len(meta.FilesChanged), meta.Additions, meta.Removals)
327
328	// List the changed files
329	filesList := strings.Join(meta.FilesChanged, "\n")
330
331	body := renderPlainContent(v, summary+"\n\n"+filesList)
332	return joinHeaderBody(header, body)
333}
334
335// -----------------------------------------------------------------------------
336//  Diagnostics renderer
337// -----------------------------------------------------------------------------
338
339type diagnosticsRenderer struct{}
340
341func (diagnosticsRenderer) Render(v *toolCallCmp) string {
342	header := makeHeader("Diagnostics", v.textWidth(), "project")
343	if res, done := earlyState(header, v); done {
344		return res
345	}
346
347	body := renderPlainContent(v, v.result.Content)
348	return joinHeaderBody(header, body)
349}
350
351// makeHeader builds "<Tool>: param (key=value)" and truncates as needed.
352func makeHeader(tool string, width int, params ...string) string {
353	prefix := tool + ": "
354	return prefix + renderParams(width-lipgloss.Width(prefix), params...)
355}
356
357// renders params, params[0] (params[1]=params[2] ....)
358func renderParams(paramsWidth int, params ...string) string {
359	if len(params) == 0 {
360		return ""
361	}
362	mainParam := params[0]
363	if len(mainParam) > paramsWidth {
364		mainParam = mainParam[:paramsWidth-3] + "..."
365	}
366
367	if len(params) == 1 {
368		return mainParam
369	}
370	otherParams := params[1:]
371	// create pairs of key/value
372	// if odd number of params, the last one is a key without value
373	if len(otherParams)%2 != 0 {
374		otherParams = append(otherParams, "")
375	}
376	parts := make([]string, 0, len(otherParams)/2)
377	for i := 0; i < len(otherParams); i += 2 {
378		key := otherParams[i]
379		value := otherParams[i+1]
380		if value == "" {
381			continue
382		}
383		parts = append(parts, fmt.Sprintf("%s=%s", key, value))
384	}
385
386	partsRendered := strings.Join(parts, ", ")
387	remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 3 // count for " ()"
388	if remainingWidth < 30 {
389		// No space for the params, just show the main
390		return mainParam
391	}
392
393	if len(parts) > 0 {
394		mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
395	}
396
397	return ansi.Truncate(mainParam, paramsWidth, "...")
398}
399
400// earlyState returns immediately‑rendered error/cancelled/ongoing states.
401func earlyState(header string, v *toolCallCmp) (string, bool) {
402	switch {
403	case v.result.IsError:
404		return lipgloss.JoinVertical(lipgloss.Left, header, v.renderToolError()), true
405	case v.cancelled:
406		return lipgloss.JoinVertical(lipgloss.Left, header, "Cancelled"), true
407	case v.result.ToolCallID == "":
408		return lipgloss.JoinVertical(lipgloss.Left, header, "Waiting for tool to finish..."), true
409	default:
410		return "", false
411	}
412}
413
414func joinHeaderBody(header, body string) string {
415	return lipgloss.JoinVertical(lipgloss.Left, header, "", body, "")
416}
417
418func renderPlainContent(v *toolCallCmp, content string) string {
419	t := theme.CurrentTheme()
420	content = strings.TrimSpace(content)
421	lines := strings.Split(content, "\n")
422
423	var out []string
424	for i, ln := range lines {
425		if i >= responseContextHeight {
426			break
427		}
428		ln = " " + ln // left padding
429		if len(ln) > v.textWidth() {
430			ln = v.fit(ln, v.textWidth())
431		}
432		out = append(out, lipgloss.NewStyle().
433			Width(v.textWidth()).
434			Background(t.BackgroundSecondary()).
435			Foreground(t.TextMuted()).
436			Render(ln))
437	}
438
439	if len(lines) > responseContextHeight {
440		out = append(out, lipgloss.NewStyle().
441			Background(t.BackgroundSecondary()).
442			Foreground(t.TextMuted()).
443			Render(fmt.Sprintf("... (%d lines)", len(lines)-responseContextHeight)))
444	}
445	return strings.Join(out, "\n")
446}
447
448func renderCodeContent(v *toolCallCmp, path, content string, offset int) string {
449	t := theme.CurrentTheme()
450	truncated := truncateHeight(content, responseContextHeight)
451
452	highlighted, _ := highlight.SyntaxHighlight(truncated, path, t.BackgroundSecondary())
453	lines := strings.Split(highlighted, "\n")
454
455	if len(strings.Split(content, "\n")) > responseContextHeight {
456		lines = append(lines, lipgloss.NewStyle().
457			Background(t.BackgroundSecondary()).
458			Foreground(t.TextMuted()).
459			Render(fmt.Sprintf("... (%d lines)", len(strings.Split(content, "\n"))-responseContextHeight)))
460	}
461
462	for i, ln := range lines {
463		num := lipgloss.NewStyle().
464			PaddingLeft(4).PaddingRight(2).
465			Background(t.BackgroundSecondary()).
466			Foreground(t.TextMuted()).
467			Render(fmt.Sprintf("%d", i+1+offset))
468		w := v.textWidth() - lipgloss.Width(num)
469		lines[i] = lipgloss.JoinHorizontal(lipgloss.Left,
470			num,
471			lipgloss.NewStyle().
472				Width(w).
473				Background(t.BackgroundSecondary()).
474				Render(v.fit(ln, w)))
475	}
476	return lipgloss.JoinVertical(lipgloss.Left, lines...)
477}
478
479func (v *toolCallCmp) renderToolError() string {
480	t := theme.CurrentTheme()
481	err := strings.ReplaceAll(v.result.Content, "\n", " ")
482	err = fmt.Sprintf("Error: %s", err)
483	return styles.BaseStyle().Foreground(t.Error()).Render(v.fit(err, v.textWidth()))
484}
485
486func removeWorkingDirPrefix(path string) string {
487	wd := config.WorkingDirectory()
488	return strings.TrimPrefix(path, wd)
489}
490
491func truncateHeight(s string, h int) string {
492	lines := strings.Split(s, "\n")
493	if len(lines) > h {
494		return strings.Join(lines[:h], "\n")
495	}
496	return s
497}
498
499func prettifyToolName(name string) string {
500	switch name {
501	case agent.AgentToolName:
502		return "Task"
503	case tools.BashToolName:
504		return "Bash"
505	case tools.EditToolName:
506		return "Edit"
507	case tools.FetchToolName:
508		return "Fetch"
509	case tools.GlobToolName:
510		return "Glob"
511	case tools.GrepToolName:
512		return "Grep"
513	case tools.LSToolName:
514		return "List"
515	case tools.SourcegraphToolName:
516		return "Sourcegraph"
517	case tools.ViewToolName:
518		return "View"
519	case tools.WriteToolName:
520		return "Write"
521	case tools.PatchToolName:
522		return "Patch"
523	default:
524		return name
525	}
526}
527
528func toolAction(name string) string {
529	switch name {
530	case agent.AgentToolName:
531		return "Preparing prompt..."
532	case tools.BashToolName:
533		return "Building command..."
534	case tools.EditToolName:
535		return "Preparing edit..."
536	case tools.FetchToolName:
537		return "Writing fetch..."
538	case tools.GlobToolName:
539		return "Finding files..."
540	case tools.GrepToolName:
541		return "Searching content..."
542	case tools.LSToolName:
543		return "Listing directory..."
544	case tools.SourcegraphToolName:
545		return "Searching code..."
546	case tools.ViewToolName:
547		return "Reading file..."
548	case tools.WriteToolName:
549		return "Preparing write..."
550	case tools.PatchToolName:
551		return "Preparing patch..."
552	default:
553		return "Working..."
554	}
555}