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 ¶mBuilder{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, ¶ms); 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, ¶ms); 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, ¶ms); 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, ¶ms); 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, ¶ms); 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, ¶ms); 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, ¶ms); 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, ¶ms); 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, ¶ms); 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, ¶ms); 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}