@@ -0,0 +1,555 @@
1+package messages
2+
3+import (
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+const responseContextHeight = 10
21+
22+type 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+
28+type rendererFactory func() renderer
29+
30+type renderRegistry map[string]rendererFactory
31+
32+func (rr renderRegistry) register(name string, f rendererFactory) { rr[name] = f }
33+func (rr renderRegistry) lookup(name string) renderer {
34+ if f, ok := rr[name]; ok {
35+ return f()
36+ }
37+ return genericRenderer{} // sensible fallback
38+}
39+
40+var registry = renderRegistry{}
41+
42+// Registger tool renderers
43+func 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+
61+type genericRenderer struct{}
62+
63+func (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+
76+type bashRenderer struct{}
77+
78+func (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+
95+type viewRenderer struct{}
96+
97+func (viewRenderer) Render(v *toolCallCmp) string {
98+ var params tools.ViewParams
99+ _ = json.Unmarshal([]byte(v.call.Input), ¶ms)
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+
126+type editRenderer struct{}
127+
128+func (editRenderer) Render(v *toolCallCmp) string {
129+ var params tools.EditParams
130+ _ = json.Unmarshal([]byte(v.call.Input), ¶ms)
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+
150+type writeRenderer struct{}
151+
152+func (writeRenderer) Render(v *toolCallCmp) string {
153+ var params tools.WriteParams
154+ _ = json.Unmarshal([]byte(v.call.Input), ¶ms)
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+
170+type fetchRenderer struct{}
171+
172+func (fetchRenderer) Render(v *toolCallCmp) string {
173+ var params tools.FetchParams
174+ _ = json.Unmarshal([]byte(v.call.Input), ¶ms)
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+
205+type globRenderer struct{}
206+
207+func (globRenderer) Render(v *toolCallCmp) string {
208+ var params tools.GlobParams
209+ _ = json.Unmarshal([]byte(v.call.Input), ¶ms)
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+
229+type grepRenderer struct{}
230+
231+func (grepRenderer) Render(v *toolCallCmp) string {
232+ var params tools.GrepParams
233+ _ = json.Unmarshal([]byte(v.call.Input), ¶ms)
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+
259+type lsRenderer struct{}
260+
261+func (lsRenderer) Render(v *toolCallCmp) string {
262+ var params tools.LSParams
263+ _ = json.Unmarshal([]byte(v.call.Input), ¶ms)
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+
283+type sourcegraphRenderer struct{}
284+
285+func (sourcegraphRenderer) Render(v *toolCallCmp) string {
286+ var params tools.SourcegraphParams
287+ _ = json.Unmarshal([]byte(v.call.Input), ¶ms)
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+
310+type patchRenderer struct{}
311+
312+func (patchRenderer) Render(v *toolCallCmp) string {
313+ var params tools.PatchParams
314+ _ = json.Unmarshal([]byte(v.call.Input), ¶ms)
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+
339+type diagnosticsRenderer struct{}
340+
341+func (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.
352+func 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] ....)
358+func 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.
401+func 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+
414+func joinHeaderBody(header, body string) string {
415+ return lipgloss.JoinVertical(lipgloss.Left, header, "", body, "")
416+}
417+
418+func 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+
448+func 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+
479+func (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+
486+func removeWorkingDirPrefix(path string) string {
487+ wd := config.WorkingDirectory()
488+ return strings.TrimPrefix(path, wd)
489+}
490+
491+func 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+
499+func 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+
528+func 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+}