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/opencode-ai/opencode/internal/config"
16 "github.com/opencode-ai/opencode/internal/diff"
17 "github.com/opencode-ai/opencode/internal/llm/agent"
18 "github.com/opencode-ai/opencode/internal/llm/models"
19 "github.com/opencode-ai/opencode/internal/llm/tools"
20 "github.com/opencode-ai/opencode/internal/message"
21 "github.com/opencode-ai/opencode/internal/tui/styles"
22)
23
24type uiMessageType int
25
26const (
27 userMessageType uiMessageType = iota
28 assistantMessageType
29 toolMessageType
30
31 maxResultHeight = 10
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 messages := []uiMessage{}
117 content := msg.Content().String()
118 thinking := msg.IsThinking()
119 thinkingContent := msg.ReasoningContent().Thinking
120 finished := msg.IsFinished()
121 finishData := msg.FinishPart()
122 info := []string{}
123
124 // Add finish info if available
125 if finished {
126 switch finishData.Reason {
127 case message.FinishReasonEndTurn:
128 took := formatTimeDifference(msg.CreatedAt, finishData.Time)
129 info = append(info, styles.BaseStyle.Width(width-1).Foreground(styles.ForgroundDim).Render(
130 fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, took),
131 ))
132 case message.FinishReasonCanceled:
133 info = append(info, styles.BaseStyle.Width(width-1).Foreground(styles.ForgroundDim).Render(
134 fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "canceled"),
135 ))
136 case message.FinishReasonError:
137 info = append(info, styles.BaseStyle.Width(width-1).Foreground(styles.ForgroundDim).Render(
138 fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "error"),
139 ))
140 case message.FinishReasonPermissionDenied:
141 info = append(info, styles.BaseStyle.Width(width-1).Foreground(styles.ForgroundDim).Render(
142 fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "permission denied"),
143 ))
144 }
145 }
146 if content != "" || (finished && finishData.Reason == message.FinishReasonEndTurn) {
147 if content == "" {
148 content = "*Finished without output*"
149 }
150
151 content = renderMessage(content, false, true, width, info...)
152 messages = append(messages, uiMessage{
153 ID: msg.ID,
154 messageType: assistantMessageType,
155 position: position,
156 height: lipgloss.Height(content),
157 content: content,
158 })
159 position += messages[0].height
160 position++ // for the space
161 } else if thinking && thinkingContent != "" {
162 // Render the thinking content
163 content = renderMessage(thinkingContent, false, msg.ID == focusedUIMessageId, width)
164 }
165
166 for i, toolCall := range msg.ToolCalls() {
167 toolCallContent := renderToolMessage(
168 toolCall,
169 allMessages,
170 messagesService,
171 focusedUIMessageId,
172 false,
173 width,
174 i+1,
175 )
176 messages = append(messages, toolCallContent)
177 position += toolCallContent.height
178 position++ // for the space
179 }
180 return messages
181}
182
183func findToolResponse(toolCallID string, futureMessages []message.Message) *message.ToolResult {
184 for _, msg := range futureMessages {
185 for _, result := range msg.ToolResults() {
186 if result.ToolCallID == toolCallID {
187 return &result
188 }
189 }
190 }
191 return nil
192}
193
194func toolName(name string) string {
195 switch name {
196 case agent.AgentToolName:
197 return "Task"
198 case tools.BashToolName:
199 return "Bash"
200 case tools.EditToolName:
201 return "Edit"
202 case tools.FetchToolName:
203 return "Fetch"
204 case tools.GlobToolName:
205 return "Glob"
206 case tools.GrepToolName:
207 return "Grep"
208 case tools.LSToolName:
209 return "List"
210 case tools.SourcegraphToolName:
211 return "Sourcegraph"
212 case tools.ViewToolName:
213 return "View"
214 case tools.WriteToolName:
215 return "Write"
216 case tools.PatchToolName:
217 return "Patch"
218 }
219 return name
220}
221
222func getToolAction(name string) string {
223 switch name {
224 case agent.AgentToolName:
225 return "Preparing prompt..."
226 case tools.BashToolName:
227 return "Building command..."
228 case tools.EditToolName:
229 return "Preparing edit..."
230 case tools.FetchToolName:
231 return "Writing fetch..."
232 case tools.GlobToolName:
233 return "Finding files..."
234 case tools.GrepToolName:
235 return "Searching content..."
236 case tools.LSToolName:
237 return "Listing directory..."
238 case tools.SourcegraphToolName:
239 return "Searching code..."
240 case tools.ViewToolName:
241 return "Reading file..."
242 case tools.WriteToolName:
243 return "Preparing write..."
244 case tools.PatchToolName:
245 return "Preparing patch..."
246 }
247 return "Working..."
248}
249
250// renders params, params[0] (params[1]=params[2] ....)
251func renderParams(paramsWidth int, params ...string) string {
252 if len(params) == 0 {
253 return ""
254 }
255 mainParam := params[0]
256 if len(mainParam) > paramsWidth {
257 mainParam = mainParam[:paramsWidth-3] + "..."
258 }
259
260 if len(params) == 1 {
261 return mainParam
262 }
263 otherParams := params[1:]
264 // create pairs of key/value
265 // if odd number of params, the last one is a key without value
266 if len(otherParams)%2 != 0 {
267 otherParams = append(otherParams, "")
268 }
269 parts := make([]string, 0, len(otherParams)/2)
270 for i := 0; i < len(otherParams); i += 2 {
271 key := otherParams[i]
272 value := otherParams[i+1]
273 if value == "" {
274 continue
275 }
276 parts = append(parts, fmt.Sprintf("%s=%s", key, value))
277 }
278
279 partsRendered := strings.Join(parts, ", ")
280 remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 5 // for the space
281 if remainingWidth < 30 {
282 // No space for the params, just show the main
283 return mainParam
284 }
285
286 if len(parts) > 0 {
287 mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
288 }
289
290 return ansi.Truncate(mainParam, paramsWidth, "...")
291}
292
293func removeWorkingDirPrefix(path string) string {
294 wd := config.WorkingDirectory()
295 if strings.HasPrefix(path, wd) {
296 path = strings.TrimPrefix(path, wd)
297 }
298 if strings.HasPrefix(path, "/") {
299 path = strings.TrimPrefix(path, "/")
300 }
301 if strings.HasPrefix(path, "./") {
302 path = strings.TrimPrefix(path, "./")
303 }
304 if strings.HasPrefix(path, "../") {
305 path = strings.TrimPrefix(path, "../")
306 }
307 return path
308}
309
310func renderToolParams(paramWidth int, toolCall message.ToolCall) string {
311 params := ""
312 switch toolCall.Name {
313 case agent.AgentToolName:
314 var params agent.AgentParams
315 json.Unmarshal([]byte(toolCall.Input), ¶ms)
316 prompt := strings.ReplaceAll(params.Prompt, "\n", " ")
317 return renderParams(paramWidth, prompt)
318 case tools.BashToolName:
319 var params tools.BashParams
320 json.Unmarshal([]byte(toolCall.Input), ¶ms)
321 command := strings.ReplaceAll(params.Command, "\n", " ")
322 return renderParams(paramWidth, command)
323 case tools.EditToolName:
324 var params tools.EditParams
325 json.Unmarshal([]byte(toolCall.Input), ¶ms)
326 filePath := removeWorkingDirPrefix(params.FilePath)
327 return renderParams(paramWidth, filePath)
328 case tools.FetchToolName:
329 var params tools.FetchParams
330 json.Unmarshal([]byte(toolCall.Input), ¶ms)
331 url := params.URL
332 toolParams := []string{
333 url,
334 }
335 if params.Format != "" {
336 toolParams = append(toolParams, "format", params.Format)
337 }
338 if params.Timeout != 0 {
339 toolParams = append(toolParams, "timeout", (time.Duration(params.Timeout) * time.Second).String())
340 }
341 return renderParams(paramWidth, toolParams...)
342 case tools.GlobToolName:
343 var params tools.GlobParams
344 json.Unmarshal([]byte(toolCall.Input), ¶ms)
345 pattern := params.Pattern
346 toolParams := []string{
347 pattern,
348 }
349 if params.Path != "" {
350 toolParams = append(toolParams, "path", params.Path)
351 }
352 return renderParams(paramWidth, toolParams...)
353 case tools.GrepToolName:
354 var params tools.GrepParams
355 json.Unmarshal([]byte(toolCall.Input), ¶ms)
356 pattern := params.Pattern
357 toolParams := []string{
358 pattern,
359 }
360 if params.Path != "" {
361 toolParams = append(toolParams, "path", params.Path)
362 }
363 if params.Include != "" {
364 toolParams = append(toolParams, "include", params.Include)
365 }
366 if params.LiteralText {
367 toolParams = append(toolParams, "literal", "true")
368 }
369 return renderParams(paramWidth, toolParams...)
370 case tools.LSToolName:
371 var params tools.LSParams
372 json.Unmarshal([]byte(toolCall.Input), ¶ms)
373 path := params.Path
374 if path == "" {
375 path = "."
376 }
377 return renderParams(paramWidth, path)
378 case tools.SourcegraphToolName:
379 var params tools.SourcegraphParams
380 json.Unmarshal([]byte(toolCall.Input), ¶ms)
381 return renderParams(paramWidth, params.Query)
382 case tools.ViewToolName:
383 var params tools.ViewParams
384 json.Unmarshal([]byte(toolCall.Input), ¶ms)
385 filePath := removeWorkingDirPrefix(params.FilePath)
386 toolParams := []string{
387 filePath,
388 }
389 if params.Limit != 0 {
390 toolParams = append(toolParams, "limit", fmt.Sprintf("%d", params.Limit))
391 }
392 if params.Offset != 0 {
393 toolParams = append(toolParams, "offset", fmt.Sprintf("%d", params.Offset))
394 }
395 return renderParams(paramWidth, toolParams...)
396 case tools.WriteToolName:
397 var params tools.WriteParams
398 json.Unmarshal([]byte(toolCall.Input), ¶ms)
399 filePath := removeWorkingDirPrefix(params.FilePath)
400 return renderParams(paramWidth, filePath)
401 default:
402 input := strings.ReplaceAll(toolCall.Input, "\n", " ")
403 params = renderParams(paramWidth, input)
404 }
405 return params
406}
407
408func truncateHeight(content string, height int) string {
409 lines := strings.Split(content, "\n")
410 if len(lines) > height {
411 return strings.Join(lines[:height], "\n")
412 }
413 return content
414}
415
416func renderToolResponse(toolCall message.ToolCall, response message.ToolResult, width int) string {
417 if response.IsError {
418 errContent := fmt.Sprintf("Error: %s", strings.ReplaceAll(response.Content, "\n", " "))
419 errContent = ansi.Truncate(errContent, width-1, "...")
420 return styles.BaseStyle.
421 Width(width).
422 Foreground(styles.Error).
423 Render(errContent)
424 }
425 resultContent := truncateHeight(response.Content, maxResultHeight)
426 switch toolCall.Name {
427 case agent.AgentToolName:
428 return styles.ForceReplaceBackgroundWithLipgloss(
429 toMarkdown(resultContent, false, width),
430 styles.Background,
431 )
432 case tools.BashToolName:
433 resultContent = fmt.Sprintf("```bash\n%s\n```", resultContent)
434 return styles.ForceReplaceBackgroundWithLipgloss(
435 toMarkdown(resultContent, true, width),
436 styles.Background,
437 )
438 case tools.EditToolName:
439 metadata := tools.EditResponseMetadata{}
440 json.Unmarshal([]byte(response.Metadata), &metadata)
441 truncDiff := truncateHeight(metadata.Diff, maxResultHeight)
442 formattedDiff, _ := diff.FormatDiff(truncDiff, diff.WithTotalWidth(width), diff.WithStyle(diffStyle))
443 return formattedDiff
444 case tools.FetchToolName:
445 var params tools.FetchParams
446 json.Unmarshal([]byte(toolCall.Input), ¶ms)
447 mdFormat := "markdown"
448 switch params.Format {
449 case "text":
450 mdFormat = "text"
451 case "html":
452 mdFormat = "html"
453 }
454 resultContent = fmt.Sprintf("```%s\n%s\n```", mdFormat, resultContent)
455 return styles.ForceReplaceBackgroundWithLipgloss(
456 toMarkdown(resultContent, true, width),
457 styles.Background,
458 )
459 case tools.GlobToolName:
460 return styles.BaseStyle.Width(width).Foreground(styles.ForgroundMid).Render(resultContent)
461 case tools.GrepToolName:
462 return styles.BaseStyle.Width(width).Foreground(styles.ForgroundMid).Render(resultContent)
463 case tools.LSToolName:
464 return styles.BaseStyle.Width(width).Foreground(styles.ForgroundMid).Render(resultContent)
465 case tools.SourcegraphToolName:
466 return styles.BaseStyle.Width(width).Foreground(styles.ForgroundMid).Render(resultContent)
467 case tools.ViewToolName:
468 metadata := tools.ViewResponseMetadata{}
469 json.Unmarshal([]byte(response.Metadata), &metadata)
470 ext := filepath.Ext(metadata.FilePath)
471 if ext == "" {
472 ext = ""
473 } else {
474 ext = strings.ToLower(ext[1:])
475 }
476 resultContent = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(metadata.Content, maxResultHeight))
477 return styles.ForceReplaceBackgroundWithLipgloss(
478 toMarkdown(resultContent, true, width),
479 styles.Background,
480 )
481 case tools.WriteToolName:
482 params := tools.WriteParams{}
483 json.Unmarshal([]byte(toolCall.Input), ¶ms)
484 metadata := tools.WriteResponseMetadata{}
485 json.Unmarshal([]byte(response.Metadata), &metadata)
486 ext := filepath.Ext(params.FilePath)
487 if ext == "" {
488 ext = ""
489 } else {
490 ext = strings.ToLower(ext[1:])
491 }
492 resultContent = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(params.Content, maxResultHeight))
493 return styles.ForceReplaceBackgroundWithLipgloss(
494 toMarkdown(resultContent, true, width),
495 styles.Background,
496 )
497 default:
498 resultContent = fmt.Sprintf("```text\n%s\n```", resultContent)
499 return styles.ForceReplaceBackgroundWithLipgloss(
500 toMarkdown(resultContent, true, width),
501 styles.Background,
502 )
503 }
504}
505
506func renderToolMessage(
507 toolCall message.ToolCall,
508 allMessages []message.Message,
509 messagesService message.Service,
510 focusedUIMessageId string,
511 nested bool,
512 width int,
513 position int,
514) uiMessage {
515 if nested {
516 width = width - 3
517 }
518 style := styles.BaseStyle.
519 Width(width - 1).
520 BorderLeft(true).
521 BorderStyle(lipgloss.ThickBorder()).
522 PaddingLeft(1).
523 BorderForeground(styles.ForgroundDim)
524
525 response := findToolResponse(toolCall.ID, allMessages)
526 toolName := styles.BaseStyle.Foreground(styles.ForgroundDim).Render(fmt.Sprintf("%s: ", toolName(toolCall.Name)))
527
528 if !toolCall.Finished {
529 // Get a brief description of what the tool is doing
530 toolAction := getToolAction(toolCall.Name)
531
532 // toolInput := strings.ReplaceAll(toolCall.Input, "\n", " ")
533 // truncatedInput := toolInput
534 // if len(truncatedInput) > 10 {
535 // truncatedInput = truncatedInput[len(truncatedInput)-10:]
536 // }
537 //
538 // truncatedInput = styles.BaseStyle.
539 // Italic(true).
540 // Width(width - 2 - lipgloss.Width(toolName)).
541 // Background(styles.BackgroundDim).
542 // Foreground(styles.ForgroundMid).
543 // Render(truncatedInput)
544
545 progressText := styles.BaseStyle.
546 Width(width - 2 - lipgloss.Width(toolName)).
547 Foreground(styles.ForgroundDim).
548 Render(fmt.Sprintf("%s", toolAction))
549
550 content := style.Render(lipgloss.JoinHorizontal(lipgloss.Left, toolName, progressText))
551 toolMsg := uiMessage{
552 messageType: toolMessageType,
553 position: position,
554 height: lipgloss.Height(content),
555 content: content,
556 }
557 return toolMsg
558 }
559 params := renderToolParams(width-2-lipgloss.Width(toolName), toolCall)
560 responseContent := ""
561 if response != nil {
562 responseContent = renderToolResponse(toolCall, *response, width-2)
563 responseContent = strings.TrimSuffix(responseContent, "\n")
564 } else {
565 responseContent = styles.BaseStyle.
566 Italic(true).
567 Width(width - 2).
568 Foreground(styles.ForgroundDim).
569 Render("Waiting for response...")
570 }
571
572 parts := []string{}
573 if !nested {
574 params := styles.BaseStyle.
575 Width(width - 2 - lipgloss.Width(toolName)).
576 Foreground(styles.ForgroundDim).
577 Render(params)
578
579 parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, toolName, params))
580 } else {
581 prefix := styles.BaseStyle.
582 Foreground(styles.ForgroundDim).
583 Render(" └ ")
584 params := styles.BaseStyle.
585 Width(width - 2 - lipgloss.Width(toolName)).
586 Foreground(styles.ForgroundMid).
587 Render(params)
588 parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, prefix, toolName, params))
589 }
590 if toolCall.Name == agent.AgentToolName {
591 taskMessages, _ := messagesService.List(context.Background(), toolCall.ID)
592 toolCalls := []message.ToolCall{}
593 for _, v := range taskMessages {
594 toolCalls = append(toolCalls, v.ToolCalls()...)
595 }
596 for _, call := range toolCalls {
597 rendered := renderToolMessage(call, []message.Message{}, messagesService, focusedUIMessageId, true, width, 0)
598 parts = append(parts, rendered.content)
599 }
600 }
601 if responseContent != "" && !nested {
602 parts = append(parts, responseContent)
603 }
604
605 content := style.Render(
606 lipgloss.JoinVertical(
607 lipgloss.Left,
608 parts...,
609 ),
610 )
611 if nested {
612 content = lipgloss.JoinVertical(
613 lipgloss.Left,
614 parts...,
615 )
616 }
617 toolMsg := uiMessage{
618 messageType: toolMessageType,
619 position: position,
620 height: lipgloss.Height(content),
621 content: content,
622 }
623 return toolMsg
624}