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), ¶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
126type editRenderer struct{}
127
128func (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
150type writeRenderer struct{}
151
152func (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
170type fetchRenderer struct{}
171
172func (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
205type globRenderer struct{}
206
207func (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
229type grepRenderer struct{}
230
231func (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
259type lsRenderer struct{}
260
261func (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
283type sourcegraphRenderer struct{}
284
285func (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
310type patchRenderer struct{}
311
312func (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
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}