1package chat
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "path/filepath"
8 "strings"
9 "sync"
10 "time"
11
12 "github.com/charmbracelet/glamour"
13 "github.com/charmbracelet/lipgloss"
14 "github.com/charmbracelet/x/ansi"
15 "github.com/kujtimiihoxha/opencode/internal/config"
16 "github.com/kujtimiihoxha/opencode/internal/diff"
17 "github.com/kujtimiihoxha/opencode/internal/llm/agent"
18 "github.com/kujtimiihoxha/opencode/internal/llm/models"
19 "github.com/kujtimiihoxha/opencode/internal/llm/tools"
20 "github.com/kujtimiihoxha/opencode/internal/message"
21 "github.com/kujtimiihoxha/opencode/internal/tui/styles"
22)
23
24type uiMessageType int
25
26const (
27 userMessageType uiMessageType = iota
28 assistantMessageType
29 toolMessageType
30
31 maxResultHeight = 15
32)
33
34var diffStyle = diff.NewStyleConfig(diff.WithShowHeader(false), diff.WithShowHunkHeader(false))
35
36type uiMessage struct {
37 ID string
38 messageType uiMessageType
39 position int
40 height int
41 content string
42}
43
44type renderCache struct {
45 mutex sync.Mutex
46 cache map[string][]uiMessage
47}
48
49func toMarkdown(content string, focused bool, width int) string {
50 r, _ := glamour.NewTermRenderer(
51 glamour.WithStyles(styles.MarkdownTheme(false)),
52 glamour.WithWordWrap(width),
53 )
54 if focused {
55 r, _ = glamour.NewTermRenderer(
56 glamour.WithStyles(styles.MarkdownTheme(true)),
57 glamour.WithWordWrap(width),
58 )
59 }
60 rendered, _ := r.Render(content)
61 return rendered
62}
63
64func renderMessage(msg string, isUser bool, isFocused bool, width int, info ...string) string {
65 style := styles.BaseStyle.
66 Width(width - 1).
67 BorderLeft(true).
68 Foreground(styles.ForgroundDim).
69 BorderForeground(styles.PrimaryColor).
70 BorderStyle(lipgloss.ThickBorder())
71 if isUser {
72 style = style.
73 BorderForeground(styles.Blue)
74 }
75 parts := []string{
76 styles.ForceReplaceBackgroundWithLipgloss(toMarkdown(msg, isFocused, width), styles.Background),
77 }
78
79 // remove newline at the end
80 parts[0] = strings.TrimSuffix(parts[0], "\n")
81 if len(info) > 0 {
82 parts = append(parts, info...)
83 }
84 rendered := style.Render(
85 lipgloss.JoinVertical(
86 lipgloss.Left,
87 parts...,
88 ),
89 )
90
91 return rendered
92}
93
94func renderUserMessage(msg message.Message, isFocused bool, width int, position int) uiMessage {
95 content := renderMessage(msg.Content().String(), true, isFocused, width)
96 userMsg := uiMessage{
97 ID: msg.ID,
98 messageType: userMessageType,
99 position: position,
100 height: lipgloss.Height(content),
101 content: content,
102 }
103 return userMsg
104}
105
106// Returns multiple uiMessages because of the tool calls
107func renderAssistantMessage(
108 msg message.Message,
109 msgIndex int,
110 allMessages []message.Message, // we need this to get tool results and the user message
111 messagesService message.Service, // We need this to get the task tool messages
112 focusedUIMessageId string,
113 width int,
114 position int,
115) []uiMessage {
116 // find the user message that is before this assistant message
117 var userMsg message.Message
118 for i := msgIndex - 1; i >= 0; i-- {
119 msg := allMessages[i]
120 if msg.Role == message.User {
121 userMsg = allMessages[i]
122 break
123 }
124 }
125
126 messages := []uiMessage{}
127 content := msg.Content().String()
128 finished := msg.IsFinished()
129 finishData := msg.FinishPart()
130 info := []string{}
131
132 // Add finish info if available
133 if finished {
134 switch finishData.Reason {
135 case message.FinishReasonEndTurn:
136 took := formatTimeDifference(userMsg.CreatedAt, finishData.Time)
137 info = append(info, styles.BaseStyle.Width(width-1).Foreground(styles.ForgroundDim).Render(
138 fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, took),
139 ))
140 case message.FinishReasonCanceled:
141 info = append(info, styles.BaseStyle.Width(width-1).Foreground(styles.ForgroundDim).Render(
142 fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "canceled"),
143 ))
144 case message.FinishReasonError:
145 info = append(info, styles.BaseStyle.Width(width-1).Foreground(styles.ForgroundDim).Render(
146 fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "error"),
147 ))
148 case message.FinishReasonPermissionDenied:
149 info = append(info, styles.BaseStyle.Width(width-1).Foreground(styles.ForgroundDim).Render(
150 fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "permission denied"),
151 ))
152 }
153 }
154 if content != "" {
155 content = renderMessage(content, false, msg.ID == focusedUIMessageId, width, info...)
156 messages = append(messages, uiMessage{
157 ID: msg.ID,
158 messageType: assistantMessageType,
159 position: position,
160 height: lipgloss.Height(content),
161 content: content,
162 })
163 position += messages[0].height
164 position++ // for the space
165 }
166
167 for i, toolCall := range msg.ToolCalls() {
168 toolCallContent := renderToolMessage(
169 toolCall,
170 allMessages,
171 messagesService,
172 focusedUIMessageId,
173 false,
174 width,
175 i+1,
176 )
177 messages = append(messages, toolCallContent)
178 position += toolCallContent.height
179 position++ // for the space
180 }
181 return messages
182}
183
184func findToolResponse(toolCallID string, futureMessages []message.Message) *message.ToolResult {
185 for _, msg := range futureMessages {
186 for _, result := range msg.ToolResults() {
187 if result.ToolCallID == toolCallID {
188 return &result
189 }
190 }
191 }
192 return nil
193}
194
195func toolName(name string) string {
196 switch name {
197 case agent.AgentToolName:
198 return "Task"
199 case tools.BashToolName:
200 return "Bash"
201 case tools.EditToolName:
202 return "Edit"
203 case tools.FetchToolName:
204 return "Fetch"
205 case tools.GlobToolName:
206 return "Glob"
207 case tools.GrepToolName:
208 return "Grep"
209 case tools.LSToolName:
210 return "List"
211 case tools.SourcegraphToolName:
212 return "Sourcegraph"
213 case tools.ViewToolName:
214 return "View"
215 case tools.WriteToolName:
216 return "Write"
217 }
218 return name
219}
220
221// renders params, params[0] (params[1]=params[2] ....)
222func renderParams(paramsWidth int, params ...string) string {
223 if len(params) == 0 {
224 return ""
225 }
226 mainParam := params[0]
227 if len(mainParam) > paramsWidth {
228 mainParam = mainParam[:paramsWidth-3] + "..."
229 }
230
231 if len(params) == 1 {
232 return mainParam
233 }
234 otherParams := params[1:]
235 // create pairs of key/value
236 // if odd number of params, the last one is a key without value
237 if len(otherParams)%2 != 0 {
238 otherParams = append(otherParams, "")
239 }
240 parts := make([]string, 0, len(otherParams)/2)
241 for i := 0; i < len(otherParams); i += 2 {
242 key := otherParams[i]
243 value := otherParams[i+1]
244 if value == "" {
245 continue
246 }
247 parts = append(parts, fmt.Sprintf("%s=%s", key, value))
248 }
249
250 partsRendered := strings.Join(parts, ", ")
251 remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 5 // for the space
252 if remainingWidth < 30 {
253 // No space for the params, just show the main
254 return mainParam
255 }
256
257 if len(parts) > 0 {
258 mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
259 }
260
261 return ansi.Truncate(mainParam, paramsWidth, "...")
262}
263
264func removeWorkingDirPrefix(path string) string {
265 wd := config.WorkingDirectory()
266 if strings.HasPrefix(path, wd) {
267 path = strings.TrimPrefix(path, wd)
268 }
269 if strings.HasPrefix(path, "/") {
270 path = strings.TrimPrefix(path, "/")
271 }
272 if strings.HasPrefix(path, "./") {
273 path = strings.TrimPrefix(path, "./")
274 }
275 if strings.HasPrefix(path, "../") {
276 path = strings.TrimPrefix(path, "../")
277 }
278 return path
279}
280
281func renderToolParams(paramWidth int, toolCall message.ToolCall) string {
282 params := ""
283 switch toolCall.Name {
284 case agent.AgentToolName:
285 var params agent.AgentParams
286 json.Unmarshal([]byte(toolCall.Input), ¶ms)
287 prompt := strings.ReplaceAll(params.Prompt, "\n", " ")
288 return renderParams(paramWidth, prompt)
289 case tools.BashToolName:
290 var params tools.BashParams
291 json.Unmarshal([]byte(toolCall.Input), ¶ms)
292 command := strings.ReplaceAll(params.Command, "\n", " ")
293 return renderParams(paramWidth, command)
294 case tools.EditToolName:
295 var params tools.EditParams
296 json.Unmarshal([]byte(toolCall.Input), ¶ms)
297 filePath := removeWorkingDirPrefix(params.FilePath)
298 return renderParams(paramWidth, filePath)
299 case tools.FetchToolName:
300 var params tools.FetchParams
301 json.Unmarshal([]byte(toolCall.Input), ¶ms)
302 url := params.URL
303 toolParams := []string{
304 url,
305 }
306 if params.Format != "" {
307 toolParams = append(toolParams, "format", params.Format)
308 }
309 if params.Timeout != 0 {
310 toolParams = append(toolParams, "timeout", (time.Duration(params.Timeout) * time.Second).String())
311 }
312 return renderParams(paramWidth, toolParams...)
313 case tools.GlobToolName:
314 var params tools.GlobParams
315 json.Unmarshal([]byte(toolCall.Input), ¶ms)
316 pattern := params.Pattern
317 toolParams := []string{
318 pattern,
319 }
320 if params.Path != "" {
321 toolParams = append(toolParams, "path", params.Path)
322 }
323 return renderParams(paramWidth, toolParams...)
324 case tools.GrepToolName:
325 var params tools.GrepParams
326 json.Unmarshal([]byte(toolCall.Input), ¶ms)
327 pattern := params.Pattern
328 toolParams := []string{
329 pattern,
330 }
331 if params.Path != "" {
332 toolParams = append(toolParams, "path", params.Path)
333 }
334 if params.Include != "" {
335 toolParams = append(toolParams, "include", params.Include)
336 }
337 if params.LiteralText {
338 toolParams = append(toolParams, "literal", "true")
339 }
340 return renderParams(paramWidth, toolParams...)
341 case tools.LSToolName:
342 var params tools.LSParams
343 json.Unmarshal([]byte(toolCall.Input), ¶ms)
344 path := params.Path
345 if path == "" {
346 path = "."
347 }
348 return renderParams(paramWidth, path)
349 case tools.SourcegraphToolName:
350 var params tools.SourcegraphParams
351 json.Unmarshal([]byte(toolCall.Input), ¶ms)
352 return renderParams(paramWidth, params.Query)
353 case tools.ViewToolName:
354 var params tools.ViewParams
355 json.Unmarshal([]byte(toolCall.Input), ¶ms)
356 filePath := removeWorkingDirPrefix(params.FilePath)
357 toolParams := []string{
358 filePath,
359 }
360 if params.Limit != 0 {
361 toolParams = append(toolParams, "limit", fmt.Sprintf("%d", params.Limit))
362 }
363 if params.Offset != 0 {
364 toolParams = append(toolParams, "offset", fmt.Sprintf("%d", params.Offset))
365 }
366 return renderParams(paramWidth, toolParams...)
367 case tools.WriteToolName:
368 var params tools.WriteParams
369 json.Unmarshal([]byte(toolCall.Input), ¶ms)
370 filePath := removeWorkingDirPrefix(params.FilePath)
371 return renderParams(paramWidth, filePath)
372 default:
373 input := strings.ReplaceAll(toolCall.Input, "\n", " ")
374 params = renderParams(paramWidth, input)
375 }
376 return params
377}
378
379func truncateHeight(content string, height int) string {
380 lines := strings.Split(content, "\n")
381 if len(lines) > height {
382 return strings.Join(lines[:height], "\n")
383 }
384 return content
385}
386
387func renderToolResponse(toolCall message.ToolCall, response message.ToolResult, width int) string {
388 if response.IsError {
389 errContent := fmt.Sprintf("Error: %s", strings.ReplaceAll(response.Content, "\n", " "))
390 errContent = ansi.Truncate(errContent, width-1, "...")
391 return styles.BaseStyle.
392 Width(width).
393 Foreground(styles.Error).
394 Render(errContent)
395 }
396 resultContent := truncateHeight(response.Content, maxResultHeight)
397 switch toolCall.Name {
398 case agent.AgentToolName:
399 return styles.ForceReplaceBackgroundWithLipgloss(
400 toMarkdown(resultContent, false, width),
401 styles.Background,
402 )
403 case tools.BashToolName:
404 resultContent = fmt.Sprintf("```bash\n%s\n```", resultContent)
405 return styles.ForceReplaceBackgroundWithLipgloss(
406 toMarkdown(resultContent, true, width),
407 styles.Background,
408 )
409 case tools.EditToolName:
410 metadata := tools.EditResponseMetadata{}
411 json.Unmarshal([]byte(response.Metadata), &metadata)
412 truncDiff := truncateHeight(metadata.Diff, maxResultHeight)
413 formattedDiff, _ := diff.FormatDiff(truncDiff, diff.WithTotalWidth(width), diff.WithStyle(diffStyle))
414 return formattedDiff
415 case tools.FetchToolName:
416 var params tools.FetchParams
417 json.Unmarshal([]byte(toolCall.Input), ¶ms)
418 mdFormat := "markdown"
419 switch params.Format {
420 case "text":
421 mdFormat = "text"
422 case "html":
423 mdFormat = "html"
424 }
425 resultContent = fmt.Sprintf("```%s\n%s\n```", mdFormat, resultContent)
426 return styles.ForceReplaceBackgroundWithLipgloss(
427 toMarkdown(resultContent, true, width),
428 styles.Background,
429 )
430 case tools.GlobToolName:
431 return styles.BaseStyle.Width(width).Foreground(styles.ForgroundMid).Render(resultContent)
432 case tools.GrepToolName:
433 return styles.BaseStyle.Width(width).Foreground(styles.ForgroundMid).Render(resultContent)
434 case tools.LSToolName:
435 return styles.BaseStyle.Width(width).Foreground(styles.ForgroundMid).Render(resultContent)
436 case tools.SourcegraphToolName:
437 return styles.BaseStyle.Width(width).Foreground(styles.ForgroundMid).Render(resultContent)
438 case tools.ViewToolName:
439 metadata := tools.ViewResponseMetadata{}
440 json.Unmarshal([]byte(response.Metadata), &metadata)
441 ext := filepath.Ext(metadata.FilePath)
442 if ext == "" {
443 ext = ""
444 } else {
445 ext = strings.ToLower(ext[1:])
446 }
447 resultContent = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(metadata.Content, maxResultHeight))
448 return styles.ForceReplaceBackgroundWithLipgloss(
449 toMarkdown(resultContent, true, width),
450 styles.Background,
451 )
452 case tools.WriteToolName:
453 params := tools.WriteParams{}
454 json.Unmarshal([]byte(toolCall.Input), ¶ms)
455 metadata := tools.WriteResponseMetadata{}
456 json.Unmarshal([]byte(response.Metadata), &metadata)
457 ext := filepath.Ext(params.FilePath)
458 if ext == "" {
459 ext = ""
460 } else {
461 ext = strings.ToLower(ext[1:])
462 }
463 resultContent = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(params.Content, maxResultHeight))
464 return styles.ForceReplaceBackgroundWithLipgloss(
465 toMarkdown(resultContent, true, width),
466 styles.Background,
467 )
468 default:
469 resultContent = fmt.Sprintf("```text\n%s\n```", resultContent)
470 return styles.ForceReplaceBackgroundWithLipgloss(
471 toMarkdown(resultContent, true, width),
472 styles.Background,
473 )
474 }
475}
476
477func renderToolMessage(
478 toolCall message.ToolCall,
479 allMessages []message.Message,
480 messagesService message.Service,
481 focusedUIMessageId string,
482 nested bool,
483 width int,
484 position int,
485) uiMessage {
486 if nested {
487 width = width - 3
488 }
489 response := findToolResponse(toolCall.ID, allMessages)
490 toolName := styles.BaseStyle.Foreground(styles.ForgroundDim).Render(fmt.Sprintf("%s: ", toolName(toolCall.Name)))
491 params := renderToolParams(width-2-lipgloss.Width(toolName), toolCall)
492 responseContent := ""
493 if response != nil {
494 responseContent = renderToolResponse(toolCall, *response, width-2)
495 responseContent = strings.TrimSuffix(responseContent, "\n")
496 } else {
497 responseContent = styles.BaseStyle.
498 Italic(true).
499 Width(width - 2).
500 Foreground(styles.ForgroundDim).
501 Render("Waiting for response...")
502 }
503 style := styles.BaseStyle.
504 Width(width - 1).
505 BorderLeft(true).
506 BorderStyle(lipgloss.ThickBorder()).
507 PaddingLeft(1).
508 BorderForeground(styles.ForgroundDim)
509
510 parts := []string{}
511 if !nested {
512 params := styles.BaseStyle.
513 Width(width - 2 - lipgloss.Width(toolName)).
514 Foreground(styles.ForgroundDim).
515 Render(params)
516
517 parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, toolName, params))
518 } else {
519 prefix := styles.BaseStyle.
520 Foreground(styles.ForgroundDim).
521 Render(" └ ")
522 params := styles.BaseStyle.
523 Width(width - 2 - lipgloss.Width(toolName)).
524 Foreground(styles.ForgroundMid).
525 Render(params)
526 parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, prefix, toolName, params))
527 }
528 if toolCall.Name == agent.AgentToolName {
529 taskMessages, _ := messagesService.List(context.Background(), toolCall.ID)
530 toolCalls := []message.ToolCall{}
531 for _, v := range taskMessages {
532 toolCalls = append(toolCalls, v.ToolCalls()...)
533 }
534 for _, call := range toolCalls {
535 rendered := renderToolMessage(call, []message.Message{}, messagesService, focusedUIMessageId, true, width, 0)
536 parts = append(parts, rendered.content)
537 }
538 }
539 if responseContent != "" && !nested {
540 parts = append(parts, responseContent)
541 }
542
543 content := style.Render(
544 lipgloss.JoinVertical(
545 lipgloss.Left,
546 parts...,
547 ),
548 )
549 if nested {
550 content = lipgloss.JoinVertical(
551 lipgloss.Left,
552 parts...,
553 )
554 }
555 toolMsg := uiMessage{
556 messageType: toolMessageType,
557 position: position,
558 height: lipgloss.Height(content),
559 content: content,
560 }
561 return toolMsg
562}