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