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