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 != "" || (finished && finishData.Reason == message.FinishReasonEndTurn) {
155 if content == "" {
156 content = "*Finished without output*"
157 }
158
159 content = renderMessage(content, false, msg.ID == focusedUIMessageId, width, info...)
160 messages = append(messages, uiMessage{
161 ID: msg.ID,
162 messageType: assistantMessageType,
163 position: position,
164 height: lipgloss.Height(content),
165 content: content,
166 })
167 position += messages[0].height
168 position++ // for the space
169 }
170
171 for i, toolCall := range msg.ToolCalls() {
172 toolCallContent := renderToolMessage(
173 toolCall,
174 allMessages,
175 messagesService,
176 focusedUIMessageId,
177 false,
178 width,
179 i+1,
180 )
181 messages = append(messages, toolCallContent)
182 position += toolCallContent.height
183 position++ // for the space
184 }
185 return messages
186}
187
188func findToolResponse(toolCallID string, futureMessages []message.Message) *message.ToolResult {
189 for _, msg := range futureMessages {
190 for _, result := range msg.ToolResults() {
191 if result.ToolCallID == toolCallID {
192 return &result
193 }
194 }
195 }
196 return nil
197}
198
199func toolName(name string) string {
200 switch name {
201 case agent.AgentToolName:
202 return "Task"
203 case tools.BashToolName:
204 return "Bash"
205 case tools.EditToolName:
206 return "Edit"
207 case tools.FetchToolName:
208 return "Fetch"
209 case tools.GlobToolName:
210 return "Glob"
211 case tools.GrepToolName:
212 return "Grep"
213 case tools.LSToolName:
214 return "List"
215 case tools.SourcegraphToolName:
216 return "Sourcegraph"
217 case tools.ViewToolName:
218 return "View"
219 case tools.WriteToolName:
220 return "Write"
221 }
222 return name
223}
224
225// renders params, params[0] (params[1]=params[2] ....)
226func renderParams(paramsWidth int, params ...string) string {
227 if len(params) == 0 {
228 return ""
229 }
230 mainParam := params[0]
231 if len(mainParam) > paramsWidth {
232 mainParam = mainParam[:paramsWidth-3] + "..."
233 }
234
235 if len(params) == 1 {
236 return mainParam
237 }
238 otherParams := params[1:]
239 // create pairs of key/value
240 // if odd number of params, the last one is a key without value
241 if len(otherParams)%2 != 0 {
242 otherParams = append(otherParams, "")
243 }
244 parts := make([]string, 0, len(otherParams)/2)
245 for i := 0; i < len(otherParams); i += 2 {
246 key := otherParams[i]
247 value := otherParams[i+1]
248 if value == "" {
249 continue
250 }
251 parts = append(parts, fmt.Sprintf("%s=%s", key, value))
252 }
253
254 partsRendered := strings.Join(parts, ", ")
255 remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 5 // for the space
256 if remainingWidth < 30 {
257 // No space for the params, just show the main
258 return mainParam
259 }
260
261 if len(parts) > 0 {
262 mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
263 }
264
265 return ansi.Truncate(mainParam, paramsWidth, "...")
266}
267
268func removeWorkingDirPrefix(path string) string {
269 wd := config.WorkingDirectory()
270 if strings.HasPrefix(path, wd) {
271 path = strings.TrimPrefix(path, wd)
272 }
273 if strings.HasPrefix(path, "/") {
274 path = strings.TrimPrefix(path, "/")
275 }
276 if strings.HasPrefix(path, "./") {
277 path = strings.TrimPrefix(path, "./")
278 }
279 if strings.HasPrefix(path, "../") {
280 path = strings.TrimPrefix(path, "../")
281 }
282 return path
283}
284
285func renderToolParams(paramWidth int, toolCall message.ToolCall) string {
286 params := ""
287 switch toolCall.Name {
288 case agent.AgentToolName:
289 var params agent.AgentParams
290 json.Unmarshal([]byte(toolCall.Input), ¶ms)
291 prompt := strings.ReplaceAll(params.Prompt, "\n", " ")
292 return renderParams(paramWidth, prompt)
293 case tools.BashToolName:
294 var params tools.BashParams
295 json.Unmarshal([]byte(toolCall.Input), ¶ms)
296 command := strings.ReplaceAll(params.Command, "\n", " ")
297 return renderParams(paramWidth, command)
298 case tools.EditToolName:
299 var params tools.EditParams
300 json.Unmarshal([]byte(toolCall.Input), ¶ms)
301 filePath := removeWorkingDirPrefix(params.FilePath)
302 return renderParams(paramWidth, filePath)
303 case tools.FetchToolName:
304 var params tools.FetchParams
305 json.Unmarshal([]byte(toolCall.Input), ¶ms)
306 url := params.URL
307 toolParams := []string{
308 url,
309 }
310 if params.Format != "" {
311 toolParams = append(toolParams, "format", params.Format)
312 }
313 if params.Timeout != 0 {
314 toolParams = append(toolParams, "timeout", (time.Duration(params.Timeout) * time.Second).String())
315 }
316 return renderParams(paramWidth, toolParams...)
317 case tools.GlobToolName:
318 var params tools.GlobParams
319 json.Unmarshal([]byte(toolCall.Input), ¶ms)
320 pattern := params.Pattern
321 toolParams := []string{
322 pattern,
323 }
324 if params.Path != "" {
325 toolParams = append(toolParams, "path", params.Path)
326 }
327 return renderParams(paramWidth, toolParams...)
328 case tools.GrepToolName:
329 var params tools.GrepParams
330 json.Unmarshal([]byte(toolCall.Input), ¶ms)
331 pattern := params.Pattern
332 toolParams := []string{
333 pattern,
334 }
335 if params.Path != "" {
336 toolParams = append(toolParams, "path", params.Path)
337 }
338 if params.Include != "" {
339 toolParams = append(toolParams, "include", params.Include)
340 }
341 if params.LiteralText {
342 toolParams = append(toolParams, "literal", "true")
343 }
344 return renderParams(paramWidth, toolParams...)
345 case tools.LSToolName:
346 var params tools.LSParams
347 json.Unmarshal([]byte(toolCall.Input), ¶ms)
348 path := params.Path
349 if path == "" {
350 path = "."
351 }
352 return renderParams(paramWidth, path)
353 case tools.SourcegraphToolName:
354 var params tools.SourcegraphParams
355 json.Unmarshal([]byte(toolCall.Input), ¶ms)
356 return renderParams(paramWidth, params.Query)
357 case tools.ViewToolName:
358 var params tools.ViewParams
359 json.Unmarshal([]byte(toolCall.Input), ¶ms)
360 filePath := removeWorkingDirPrefix(params.FilePath)
361 toolParams := []string{
362 filePath,
363 }
364 if params.Limit != 0 {
365 toolParams = append(toolParams, "limit", fmt.Sprintf("%d", params.Limit))
366 }
367 if params.Offset != 0 {
368 toolParams = append(toolParams, "offset", fmt.Sprintf("%d", params.Offset))
369 }
370 return renderParams(paramWidth, toolParams...)
371 case tools.WriteToolName:
372 var params tools.WriteParams
373 json.Unmarshal([]byte(toolCall.Input), ¶ms)
374 filePath := removeWorkingDirPrefix(params.FilePath)
375 return renderParams(paramWidth, filePath)
376 default:
377 input := strings.ReplaceAll(toolCall.Input, "\n", " ")
378 params = renderParams(paramWidth, input)
379 }
380 return params
381}
382
383func truncateHeight(content string, height int) string {
384 lines := strings.Split(content, "\n")
385 if len(lines) > height {
386 return strings.Join(lines[:height], "\n")
387 }
388 return content
389}
390
391func renderToolResponse(toolCall message.ToolCall, response message.ToolResult, width int) string {
392 if response.IsError {
393 errContent := fmt.Sprintf("Error: %s", strings.ReplaceAll(response.Content, "\n", " "))
394 errContent = ansi.Truncate(errContent, width-1, "...")
395 return styles.BaseStyle.
396 Width(width).
397 Foreground(styles.Error).
398 Render(errContent)
399 }
400 resultContent := truncateHeight(response.Content, maxResultHeight)
401 switch toolCall.Name {
402 case agent.AgentToolName:
403 return styles.ForceReplaceBackgroundWithLipgloss(
404 toMarkdown(resultContent, false, width),
405 styles.Background,
406 )
407 case tools.BashToolName:
408 resultContent = fmt.Sprintf("```bash\n%s\n```", resultContent)
409 return styles.ForceReplaceBackgroundWithLipgloss(
410 toMarkdown(resultContent, true, width),
411 styles.Background,
412 )
413 case tools.EditToolName:
414 metadata := tools.EditResponseMetadata{}
415 json.Unmarshal([]byte(response.Metadata), &metadata)
416 truncDiff := truncateHeight(metadata.Diff, maxResultHeight)
417 formattedDiff, _ := diff.FormatDiff(truncDiff, diff.WithTotalWidth(width), diff.WithStyle(diffStyle))
418 return formattedDiff
419 case tools.FetchToolName:
420 var params tools.FetchParams
421 json.Unmarshal([]byte(toolCall.Input), ¶ms)
422 mdFormat := "markdown"
423 switch params.Format {
424 case "text":
425 mdFormat = "text"
426 case "html":
427 mdFormat = "html"
428 }
429 resultContent = fmt.Sprintf("```%s\n%s\n```", mdFormat, resultContent)
430 return styles.ForceReplaceBackgroundWithLipgloss(
431 toMarkdown(resultContent, true, width),
432 styles.Background,
433 )
434 case tools.GlobToolName:
435 return styles.BaseStyle.Width(width).Foreground(styles.ForgroundMid).Render(resultContent)
436 case tools.GrepToolName:
437 return styles.BaseStyle.Width(width).Foreground(styles.ForgroundMid).Render(resultContent)
438 case tools.LSToolName:
439 return styles.BaseStyle.Width(width).Foreground(styles.ForgroundMid).Render(resultContent)
440 case tools.SourcegraphToolName:
441 return styles.BaseStyle.Width(width).Foreground(styles.ForgroundMid).Render(resultContent)
442 case tools.ViewToolName:
443 metadata := tools.ViewResponseMetadata{}
444 json.Unmarshal([]byte(response.Metadata), &metadata)
445 ext := filepath.Ext(metadata.FilePath)
446 if ext == "" {
447 ext = ""
448 } else {
449 ext = strings.ToLower(ext[1:])
450 }
451 resultContent = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(metadata.Content, maxResultHeight))
452 return styles.ForceReplaceBackgroundWithLipgloss(
453 toMarkdown(resultContent, true, width),
454 styles.Background,
455 )
456 case tools.WriteToolName:
457 params := tools.WriteParams{}
458 json.Unmarshal([]byte(toolCall.Input), ¶ms)
459 metadata := tools.WriteResponseMetadata{}
460 json.Unmarshal([]byte(response.Metadata), &metadata)
461 ext := filepath.Ext(params.FilePath)
462 if ext == "" {
463 ext = ""
464 } else {
465 ext = strings.ToLower(ext[1:])
466 }
467 resultContent = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(params.Content, maxResultHeight))
468 return styles.ForceReplaceBackgroundWithLipgloss(
469 toMarkdown(resultContent, true, width),
470 styles.Background,
471 )
472 default:
473 resultContent = fmt.Sprintf("```text\n%s\n```", resultContent)
474 return styles.ForceReplaceBackgroundWithLipgloss(
475 toMarkdown(resultContent, true, width),
476 styles.Background,
477 )
478 }
479}
480
481func renderToolMessage(
482 toolCall message.ToolCall,
483 allMessages []message.Message,
484 messagesService message.Service,
485 focusedUIMessageId string,
486 nested bool,
487 width int,
488 position int,
489) uiMessage {
490 if nested {
491 width = width - 3
492 }
493 response := findToolResponse(toolCall.ID, allMessages)
494 toolName := styles.BaseStyle.Foreground(styles.ForgroundDim).Render(fmt.Sprintf("%s: ", toolName(toolCall.Name)))
495 params := renderToolParams(width-2-lipgloss.Width(toolName), toolCall)
496 responseContent := ""
497 if response != nil {
498 responseContent = renderToolResponse(toolCall, *response, width-2)
499 responseContent = strings.TrimSuffix(responseContent, "\n")
500 } else {
501 responseContent = styles.BaseStyle.
502 Italic(true).
503 Width(width - 2).
504 Foreground(styles.ForgroundDim).
505 Render("Waiting for response...")
506 }
507 style := styles.BaseStyle.
508 Width(width - 1).
509 BorderLeft(true).
510 BorderStyle(lipgloss.ThickBorder()).
511 PaddingLeft(1).
512 BorderForeground(styles.ForgroundDim)
513
514 parts := []string{}
515 if !nested {
516 params := styles.BaseStyle.
517 Width(width - 2 - lipgloss.Width(toolName)).
518 Foreground(styles.ForgroundDim).
519 Render(params)
520
521 parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, toolName, params))
522 } else {
523 prefix := styles.BaseStyle.
524 Foreground(styles.ForgroundDim).
525 Render(" └ ")
526 params := styles.BaseStyle.
527 Width(width - 2 - lipgloss.Width(toolName)).
528 Foreground(styles.ForgroundMid).
529 Render(params)
530 parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, prefix, toolName, params))
531 }
532 if toolCall.Name == agent.AgentToolName {
533 taskMessages, _ := messagesService.List(context.Background(), toolCall.ID)
534 toolCalls := []message.ToolCall{}
535 for _, v := range taskMessages {
536 toolCalls = append(toolCalls, v.ToolCalls()...)
537 }
538 for _, call := range toolCalls {
539 rendered := renderToolMessage(call, []message.Message{}, messagesService, focusedUIMessageId, true, width, 0)
540 parts = append(parts, rendered.content)
541 }
542 }
543 if responseContent != "" && !nested {
544 parts = append(parts, responseContent)
545 }
546
547 content := style.Render(
548 lipgloss.JoinVertical(
549 lipgloss.Left,
550 parts...,
551 ),
552 )
553 if nested {
554 content = lipgloss.JoinVertical(
555 lipgloss.Left,
556 parts...,
557 )
558 }
559 toolMsg := uiMessage{
560 messageType: toolMessageType,
561 position: position,
562 height: lipgloss.Height(content),
563 content: content,
564 }
565 return toolMsg
566}