1package chat
2
3import (
4 "encoding/json"
5 "fmt"
6 "strings"
7
8 "github.com/charmbracelet/lipgloss/v2"
9 "github.com/charmbracelet/x/ansi"
10 "github.com/opencode-ai/opencode/internal/config"
11 "github.com/opencode-ai/opencode/internal/diff"
12 "github.com/opencode-ai/opencode/internal/highlight"
13 "github.com/opencode-ai/opencode/internal/llm/agent"
14 "github.com/opencode-ai/opencode/internal/llm/tools"
15 "github.com/opencode-ai/opencode/internal/tui/styles"
16 "github.com/opencode-ai/opencode/internal/tui/theme"
17)
18
19const responseContextHeight = 10
20
21func (m *messageCmp) renderUnfinishedToolCall() string {
22 toolName := m.toolName()
23 toolAction := m.getToolAction()
24 return fmt.Sprintf("%s: %s", toolName, toolAction)
25}
26
27func (m *messageCmp) renderToolError() string {
28 t := theme.CurrentTheme()
29 baseStyle := styles.BaseStyle()
30 err := strings.ReplaceAll(m.toolResult.Content, "\n", " ")
31 err = fmt.Sprintf("Error: %s", err)
32 return baseStyle.Foreground(t.Error()).Render(m.fit(err))
33}
34
35func (m *messageCmp) renderBashTool() string {
36 name := m.toolName()
37 prefix := fmt.Sprintf("%s: ", name)
38 var params tools.BashParams
39 json.Unmarshal([]byte(m.toolCall.Input), ¶ms)
40 command := strings.ReplaceAll(params.Command, "\n", " ")
41 header := prefix + renderParams(m.textWidth()-lipgloss.Width(prefix), command)
42
43 if result, ok := m.toolResultErrorOrMissing(header); ok {
44 return result
45 }
46 return m.renderTool(header, m.renderPlainContent(m.toolResult.Content))
47}
48
49func (m *messageCmp) renderViewTool() string {
50 name := m.toolName()
51 prefix := fmt.Sprintf("%s: ", name)
52 var params tools.ViewParams
53 json.Unmarshal([]byte(m.toolCall.Input), ¶ms)
54 filePath := removeWorkingDirPrefix(params.FilePath)
55 toolParams := []string{
56 filePath,
57 }
58 if params.Limit != 0 {
59 toolParams = append(toolParams, "limit", fmt.Sprintf("%d", params.Limit))
60 }
61 if params.Offset != 0 {
62 toolParams = append(toolParams, "offset", fmt.Sprintf("%d", params.Offset))
63 }
64 header := prefix + renderParams(m.textWidth()-lipgloss.Width(prefix), toolParams...)
65
66 if result, ok := m.toolResultErrorOrMissing(header); ok {
67 return result
68 }
69
70 metadata := tools.ViewResponseMetadata{}
71 json.Unmarshal([]byte(m.toolResult.Metadata), &metadata)
72
73 return m.renderTool(header, m.renderCodeContent(metadata.FilePath, metadata.Content, params.Offset))
74}
75
76func (m *messageCmp) renderCodeContent(path, content string, offset int) string {
77 t := theme.CurrentTheme()
78 originalHeight := lipgloss.Height(content)
79 fileContent := truncateHeight(content, responseContextHeight)
80
81 highlighted, _ := highlight.SyntaxHighlight(fileContent, path, t.BackgroundSecondary())
82
83 lines := strings.Split(highlighted, "\n")
84
85 if originalHeight > responseContextHeight {
86 lines = append(lines,
87 lipgloss.NewStyle().Background(t.BackgroundSecondary()).
88 Foreground(t.TextMuted()).
89 Render(
90 fmt.Sprintf("... (%d lines)", originalHeight-responseContextHeight),
91 ),
92 )
93 }
94 for i, line := range lines {
95 lineNumber := lipgloss.NewStyle().
96 PaddingLeft(4).
97 PaddingRight(2).
98 Background(t.BackgroundSecondary()).
99 Foreground(t.TextMuted()).
100 Render(fmt.Sprintf("%d", i+1+offset))
101 formattedLine := lipgloss.NewStyle().
102 Width(m.textWidth() - lipgloss.Width(lineNumber)).
103 Background(t.BackgroundSecondary()).Render(line)
104 lines[i] = lipgloss.JoinHorizontal(lipgloss.Left, lineNumber, formattedLine)
105 }
106 return lipgloss.NewStyle().Render(
107 lipgloss.JoinVertical(
108 lipgloss.Left,
109 lines...,
110 ),
111 )
112}
113
114func (m *messageCmp) renderPlainContent(content string) string {
115 t := theme.CurrentTheme()
116 content = strings.TrimSuffix(content, "\n")
117 content = strings.TrimPrefix(content, "\n")
118 lines := strings.Split(fmt.Sprintf("\n%s\n", content), "\n")
119
120 for i, line := range lines {
121 line = " " + line // add padding
122 if len(line) > m.textWidth() {
123 line = m.fit(line)
124 }
125 lines[i] = lipgloss.NewStyle().
126 Width(m.textWidth()).
127 Background(t.BackgroundSecondary()).
128 Foreground(t.TextMuted()).
129 Render(line)
130 }
131 if len(lines) > responseContextHeight {
132 lines = lines[:responseContextHeight]
133 lines = append(lines,
134 lipgloss.NewStyle().Background(t.BackgroundSecondary()).
135 Foreground(t.TextMuted()).
136 Render(
137 fmt.Sprintf("... (%d lines)", len(lines)-responseContextHeight),
138 ),
139 )
140 }
141 return strings.Join(lines, "\n")
142}
143
144func (m *messageCmp) renderGenericTool() string {
145 // Tool params
146 name := m.toolName()
147 prefix := fmt.Sprintf("%s: ", name)
148 input := strings.ReplaceAll(m.toolCall.Input, "\n", " ")
149 params := renderParams(m.textWidth()-lipgloss.Width(prefix), input)
150 header := prefix + params
151
152 if result, ok := m.toolResultErrorOrMissing(header); ok {
153 return result
154 }
155 return m.renderTool(header, m.renderPlainContent(m.toolResult.Content))
156}
157
158func (m *messageCmp) renderEditTool() string {
159 // Tool params
160 name := m.toolName()
161 prefix := fmt.Sprintf("%s: ", name)
162 var params tools.EditParams
163 json.Unmarshal([]byte(m.toolCall.Input), ¶ms)
164 filePath := removeWorkingDirPrefix(params.FilePath)
165 header := prefix + renderParams(m.textWidth()-lipgloss.Width(prefix), filePath)
166
167 if result, ok := m.toolResultErrorOrMissing(header); ok {
168 return result
169 }
170 metadata := tools.EditResponseMetadata{}
171 json.Unmarshal([]byte(m.toolResult.Metadata), &metadata)
172 truncDiff := truncateHeight(metadata.Diff, maxResultHeight)
173 formattedDiff, _ := diff.FormatDiff(truncDiff, diff.WithTotalWidth(m.textWidth()))
174 return m.renderTool(header, formattedDiff)
175}
176
177func (m *messageCmp) renderWriteTool() string {
178 // Tool params
179 name := m.toolName()
180 prefix := fmt.Sprintf("%s: ", name)
181 var params tools.WriteParams
182 json.Unmarshal([]byte(m.toolCall.Input), ¶ms)
183 filePath := removeWorkingDirPrefix(params.FilePath)
184 header := prefix + renderParams(m.textWidth()-lipgloss.Width(prefix), filePath)
185 if result, ok := m.toolResultErrorOrMissing(header); ok {
186 return result
187 }
188 return m.renderTool(header, m.renderCodeContent(filePath, params.Content, 0))
189}
190
191func (m *messageCmp) renderToolCallMessage() string {
192 if !m.toolCall.Finished && !m.cancelledToolCall {
193 return m.renderUnfinishedToolCall()
194 }
195 content := ""
196 switch m.toolCall.Name {
197 case tools.ViewToolName:
198 content = m.renderViewTool()
199 case tools.BashToolName:
200 content = m.renderBashTool()
201 case tools.EditToolName:
202 content = m.renderEditTool()
203 case tools.WriteToolName:
204 content = m.renderWriteTool()
205 default:
206 content = m.renderGenericTool()
207 }
208 return m.style().PaddingLeft(1).Render(content)
209}
210
211func (m *messageCmp) toolResultErrorOrMissing(header string) (string, bool) {
212 result := "Waiting for tool to finish..."
213 if m.toolResult.IsError {
214 result = m.renderToolError()
215 return lipgloss.JoinVertical(
216 lipgloss.Left,
217 header,
218 result,
219 ), true
220 } else if m.cancelledToolCall {
221 result = "Cancelled"
222 return lipgloss.JoinVertical(
223 lipgloss.Left,
224 header,
225 result,
226 ), true
227 } else if m.toolResult.ToolCallID == "" {
228 return lipgloss.JoinVertical(
229 lipgloss.Left,
230 header,
231 result,
232 ), true
233 }
234
235 return "", false
236}
237
238func (m *messageCmp) renderTool(header, result string) string {
239 return lipgloss.JoinVertical(
240 lipgloss.Left,
241 header,
242 "",
243 result,
244 "",
245 )
246}
247
248func removeWorkingDirPrefix(path string) string {
249 wd := config.WorkingDirectory()
250 path = strings.TrimPrefix(path, wd)
251 return path
252}
253
254func truncateHeight(content string, height int) string {
255 lines := strings.Split(content, "\n")
256 if len(lines) > height {
257 return strings.Join(lines[:height], "\n")
258 }
259 return content
260}
261
262func (m *messageCmp) fit(content string) string {
263 return ansi.Truncate(content, m.textWidth(), "...")
264}
265
266func (m *messageCmp) toolName() string {
267 switch m.toolCall.Name {
268 case agent.AgentToolName:
269 return "Task"
270 case tools.BashToolName:
271 return "Bash"
272 case tools.EditToolName:
273 return "Edit"
274 case tools.FetchToolName:
275 return "Fetch"
276 case tools.GlobToolName:
277 return "Glob"
278 case tools.GrepToolName:
279 return "Grep"
280 case tools.LSToolName:
281 return "List"
282 case tools.SourcegraphToolName:
283 return "Sourcegraph"
284 case tools.ViewToolName:
285 return "View"
286 case tools.WriteToolName:
287 return "Write"
288 case tools.PatchToolName:
289 return "Patch"
290 default:
291 return m.toolCall.Name
292 }
293}
294
295func (m *messageCmp) getToolAction() string {
296 switch m.toolCall.Name {
297 case agent.AgentToolName:
298 return "Preparing prompt..."
299 case tools.BashToolName:
300 return "Building command..."
301 case tools.EditToolName:
302 return "Preparing edit..."
303 case tools.FetchToolName:
304 return "Writing fetch..."
305 case tools.GlobToolName:
306 return "Finding files..."
307 case tools.GrepToolName:
308 return "Searching content..."
309 case tools.LSToolName:
310 return "Listing directory..."
311 case tools.SourcegraphToolName:
312 return "Searching code..."
313 case tools.ViewToolName:
314 return "Reading file..."
315 case tools.WriteToolName:
316 return "Preparing write..."
317 case tools.PatchToolName:
318 return "Preparing patch..."
319 default:
320 return "Working..."
321 }
322}
323
324// renders params, params[0] (params[1]=params[2] ....)
325func renderParams(paramsWidth int, params ...string) string {
326 if len(params) == 0 {
327 return ""
328 }
329 mainParam := params[0]
330 if len(mainParam) > paramsWidth {
331 mainParam = mainParam[:paramsWidth-3] + "..."
332 }
333
334 if len(params) == 1 {
335 return mainParam
336 }
337 otherParams := params[1:]
338 // create pairs of key/value
339 // if odd number of params, the last one is a key without value
340 if len(otherParams)%2 != 0 {
341 otherParams = append(otherParams, "")
342 }
343 parts := make([]string, 0, len(otherParams)/2)
344 for i := 0; i < len(otherParams); i += 2 {
345 key := otherParams[i]
346 value := otherParams[i+1]
347 if value == "" {
348 continue
349 }
350 parts = append(parts, fmt.Sprintf("%s=%s", key, value))
351 }
352
353 partsRendered := strings.Join(parts, ", ")
354 remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 3 // count for " ()"
355 if remainingWidth < 30 {
356 // No space for the params, just show the main
357 return mainParam
358 }
359
360 if len(parts) > 0 {
361 mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
362 }
363
364 return ansi.Truncate(mainParam, paramsWidth, "...")
365}