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