1package messages
2
3import (
4 "fmt"
5 "image/color"
6 "path/filepath"
7 "strings"
8 "time"
9
10 tea "github.com/charmbracelet/bubbletea/v2"
11 "github.com/charmbracelet/lipgloss/v2"
12 "github.com/opencode-ai/opencode/internal/llm/models"
13
14 "github.com/opencode-ai/opencode/internal/message"
15 "github.com/opencode-ai/opencode/internal/tui/layout"
16 "github.com/opencode-ai/opencode/internal/tui/styles"
17 "github.com/opencode-ai/opencode/internal/tui/theme"
18 "github.com/opencode-ai/opencode/internal/tui/util"
19)
20
21type MessageCmp interface {
22 util.Model
23 layout.Sizeable
24 layout.Focusable
25}
26
27type messageCmp struct {
28 width int
29 focused bool
30
31 // Used for agent and user messages
32 message message.Message
33 lastUserMessageTime time.Time
34}
35
36type MessageOption func(*messageCmp)
37
38func WithLastUserMessageTime(t time.Time) MessageOption {
39 return func(m *messageCmp) {
40 m.lastUserMessageTime = t
41 }
42}
43
44func NewMessageCmp(msg message.Message, opts ...MessageOption) MessageCmp {
45 m := &messageCmp{
46 message: msg,
47 }
48 for _, opt := range opts {
49 opt(m)
50 }
51 return m
52}
53
54func (m *messageCmp) Init() tea.Cmd {
55 return nil
56}
57
58func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
59 return m, nil
60}
61
62func (m *messageCmp) View() string {
63 if m.message.ID != "" {
64 // this is a user or assistant message
65 switch m.message.Role {
66 case message.User:
67 return m.renderUserMessage()
68 default:
69 return m.renderAssistantMessage()
70 }
71 }
72 return "Unknown Message"
73}
74
75func (m *messageCmp) textWidth() int {
76 return m.width - 1 // take into account the border
77}
78
79func (msg *messageCmp) style() lipgloss.Style {
80 t := theme.CurrentTheme()
81 var borderColor color.Color
82 borderStyle := lipgloss.NormalBorder()
83 if msg.focused {
84 borderStyle = lipgloss.DoubleBorder()
85 }
86
87 switch msg.message.Role {
88 case message.User:
89 borderColor = t.Secondary()
90 case message.Assistant:
91 borderColor = t.Primary()
92 default:
93 // Tool call
94 borderColor = t.TextMuted()
95 }
96
97 return styles.BaseStyle().
98 BorderLeft(true).
99 Foreground(t.TextMuted()).
100 BorderForeground(borderColor).
101 BorderStyle(borderStyle)
102}
103
104func (m *messageCmp) renderAssistantMessage() string {
105 parts := []string{
106 m.markdownContent(),
107 }
108
109 finished := m.message.IsFinished()
110 finishData := m.message.FinishPart()
111 // Only show the footer if the message is not a tool call
112 if finished && finishData.Reason != message.FinishReasonToolUse {
113 infoMsg := ""
114 switch finishData.Reason {
115 case message.FinishReasonEndTurn:
116 finishTime := time.Unix(finishData.Time, 0)
117 duration := finishTime.Sub(m.lastUserMessageTime)
118 infoMsg = duration.String()
119 case message.FinishReasonCanceled:
120 infoMsg = "canceled"
121 case message.FinishReasonError:
122 infoMsg = "error"
123 case message.FinishReasonPermissionDenied:
124 infoMsg = "permission denied"
125 }
126 parts = append(parts, fmt.Sprintf(" %s (%s)", models.SupportedModels[m.message.Model].Name, infoMsg))
127 }
128
129 joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
130 return m.style().Render(joined)
131}
132
133func (m *messageCmp) renderUserMessage() string {
134 t := theme.CurrentTheme()
135 parts := []string{
136 m.markdownContent(),
137 }
138 attachmentStyles := styles.BaseStyle().
139 MarginLeft(1).
140 Background(t.BackgroundSecondary()).
141 Foreground(t.Text())
142 attachments := []string{}
143 for _, attachment := range m.message.BinaryContent() {
144 file := filepath.Base(attachment.Path)
145 var filename string
146 if len(file) > 10 {
147 filename = fmt.Sprintf(" %s %s... ", styles.DocumentIcon, file[0:7])
148 } else {
149 filename = fmt.Sprintf(" %s %s ", styles.DocumentIcon, file)
150 }
151 attachments = append(attachments, attachmentStyles.Render(filename))
152 }
153 if len(attachments) > 0 {
154 parts = append(parts, "", strings.Join(attachments, ""))
155 }
156 joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
157 return m.style().Render(joined)
158}
159
160func (m *messageCmp) toMarkdown(content string) string {
161 r := styles.GetMarkdownRenderer(m.textWidth())
162 rendered, _ := r.Render(content)
163 return strings.TrimSuffix(rendered, "\n")
164}
165
166func (m *messageCmp) markdownContent() string {
167 content := m.message.Content().String()
168 if m.message.Role == message.Assistant {
169 thinking := m.message.IsThinking()
170 finished := m.message.IsFinished()
171 finishedData := m.message.FinishPart()
172 if thinking {
173 // Handle the thinking state
174 // TODO: maybe add the thinking content if available later.
175 content = fmt.Sprintf("**%s %s**", styles.LoadingIcon, "Thinking...")
176 } else if finished && content == "" && finishedData.Reason == message.FinishReasonEndTurn {
177 // Sometimes the LLMs respond with no content when they think the previous tool result
178 // provides the requested question
179 content = "*Finished without output*"
180 } else if finished && content == "" && finishedData.Reason == message.FinishReasonCanceled {
181 content = "*Canceled*"
182 }
183 }
184 return m.toMarkdown(content)
185}
186
187// Blur implements MessageModel.
188func (m *messageCmp) Blur() tea.Cmd {
189 m.focused = false
190 return nil
191}
192
193// Focus implements MessageModel.
194func (m *messageCmp) Focus() tea.Cmd {
195 m.focused = true
196 return nil
197}
198
199// IsFocused implements MessageModel.
200func (m *messageCmp) IsFocused() bool {
201 return m.focused
202}
203
204func (m *messageCmp) GetSize() (int, int) {
205 return m.width, 0
206}
207
208func (m *messageCmp) SetSize(width int, height int) tea.Cmd {
209 m.width = width
210 return nil
211}