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
22// MessageCmp defines the interface for message components in the chat interface.
23// It combines standard UI model interfaces with message-specific functionality.
24type MessageCmp interface {
25 util.Model // Basic Bubble Tea model interface
26 layout.Sizeable // Width/height management
27 layout.Focusable // Focus state management
28 GetMessage() message.Message // Access to underlying message data
29 Spinning() bool // Animation state for loading messages
30}
31
32// messageCmp implements the MessageCmp interface for displaying chat messages.
33// It handles rendering of user and assistant messages with proper styling,
34// animations, and state management.
35type messageCmp struct {
36 width int // Component width for text wrapping
37 focused bool // Focus state for border styling
38
39 // Core message data and state
40 message message.Message // The underlying message content
41 spinning bool // Whether to show loading animation
42 anim util.Model // Animation component for loading states
43 lastUserMessageTime time.Time // Used for calculating response duration
44}
45
46// MessageOption provides functional options for configuring message components
47type MessageOption func(*messageCmp)
48
49// WithLastUserMessageTime sets the timestamp of the last user message
50// for calculating assistant response duration
51func WithLastUserMessageTime(t time.Time) MessageOption {
52 return func(m *messageCmp) {
53 m.lastUserMessageTime = t
54 }
55}
56
57// NewMessageCmp creates a new message component with the given message and options
58func NewMessageCmp(msg message.Message, opts ...MessageOption) MessageCmp {
59 m := &messageCmp{
60 message: msg,
61 anim: anim.New(15, ""),
62 }
63 for _, opt := range opts {
64 opt(m)
65 }
66 return m
67}
68
69// Init initializes the message component and starts animations if needed.
70// Returns a command to start the animation for spinning messages.
71func (m *messageCmp) Init() tea.Cmd {
72 m.spinning = m.shouldSpin()
73 if m.spinning {
74 return m.anim.Init()
75 }
76 return nil
77}
78
79// Update handles incoming messages and updates the component state.
80// Manages animation updates for spinning messages and stops animation when appropriate.
81func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
82 switch msg := msg.(type) {
83 case anim.ColorCycleMsg, anim.StepCharsMsg:
84 m.spinning = m.shouldSpin()
85 if m.spinning {
86 u, cmd := m.anim.Update(msg)
87 m.anim = u.(util.Model)
88 return m, cmd
89 }
90 }
91 return m, nil
92}
93
94// View renders the message component based on its current state.
95// Returns different views for spinning, user, and assistant messages.
96func (m *messageCmp) View() string {
97 if m.spinning {
98 return m.style().PaddingLeft(1).Render(m.anim.View())
99 }
100 if m.message.ID != "" {
101 // this is a user or assistant message
102 switch m.message.Role {
103 case message.User:
104 return m.renderUserMessage()
105 default:
106 return m.renderAssistantMessage()
107 }
108 }
109 return "Unknown Message"
110}
111
112// GetMessage returns the underlying message data
113func (m *messageCmp) GetMessage() message.Message {
114 return m.message
115}
116
117// textWidth calculates the available width for text content,
118// accounting for borders and padding
119func (m *messageCmp) textWidth() int {
120 return m.width - 1 // take into account the border
121}
122
123// style returns the lipgloss style for the message component.
124// Applies different border colors and styles based on message role and focus state.
125func (msg *messageCmp) style() lipgloss.Style {
126 t := theme.CurrentTheme()
127 var borderColor color.Color
128 borderStyle := lipgloss.NormalBorder()
129 if msg.focused {
130 borderStyle = lipgloss.DoubleBorder()
131 }
132
133 switch msg.message.Role {
134 case message.User:
135 borderColor = t.Secondary()
136 case message.Assistant:
137 borderColor = t.Primary()
138 default:
139 // Tool call
140 borderColor = t.TextMuted()
141 }
142
143 return styles.BaseStyle().
144 BorderLeft(true).
145 Foreground(t.TextMuted()).
146 BorderForeground(borderColor).
147 BorderStyle(borderStyle)
148}
149
150// renderAssistantMessage renders assistant messages with optional footer information.
151// Shows model name, response time, and finish reason when the message is complete.
152func (m *messageCmp) renderAssistantMessage() string {
153 parts := []string{
154 m.markdownContent(),
155 }
156
157 finished := m.message.IsFinished()
158 finishData := m.message.FinishPart()
159 // Only show the footer if the message is not a tool call
160 if finished && finishData.Reason != message.FinishReasonToolUse {
161 infoMsg := ""
162 switch finishData.Reason {
163 case message.FinishReasonEndTurn:
164 finishTime := time.Unix(finishData.Time, 0)
165 duration := finishTime.Sub(m.lastUserMessageTime)
166 infoMsg = duration.String()
167 case message.FinishReasonCanceled:
168 infoMsg = "canceled"
169 case message.FinishReasonError:
170 infoMsg = "error"
171 case message.FinishReasonPermissionDenied:
172 infoMsg = "permission denied"
173 }
174 parts = append(parts, fmt.Sprintf(" %s (%s)", models.SupportedModels[m.message.Model].Name, infoMsg))
175 }
176
177 joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
178 return m.style().Render(joined)
179}
180
181// renderUserMessage renders user messages with file attachments.
182// Displays message content and any attached files with appropriate icons.
183func (m *messageCmp) renderUserMessage() string {
184 t := theme.CurrentTheme()
185 parts := []string{
186 m.markdownContent(),
187 }
188 attachmentStyles := styles.BaseStyle().
189 MarginLeft(1).
190 Background(t.BackgroundSecondary()).
191 Foreground(t.Text())
192 attachments := []string{}
193 for _, attachment := range m.message.BinaryContent() {
194 file := filepath.Base(attachment.Path)
195 var filename string
196 if len(file) > 10 {
197 filename = fmt.Sprintf(" %s %s... ", styles.DocumentIcon, file[0:7])
198 } else {
199 filename = fmt.Sprintf(" %s %s ", styles.DocumentIcon, file)
200 }
201 attachments = append(attachments, attachmentStyles.Render(filename))
202 }
203 if len(attachments) > 0 {
204 parts = append(parts, "", strings.Join(attachments, ""))
205 }
206 joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
207 return m.style().Render(joined)
208}
209
210// toMarkdown converts text content to rendered markdown using the configured renderer
211func (m *messageCmp) toMarkdown(content string) string {
212 r := styles.GetMarkdownRenderer(m.textWidth())
213 rendered, _ := r.Render(content)
214 return strings.TrimSuffix(rendered, "\n")
215}
216
217// markdownContent processes the message content and handles special states.
218// Returns appropriate content for thinking, finished, and error states.
219func (m *messageCmp) markdownContent() string {
220 content := m.message.Content().String()
221 if m.message.Role == message.Assistant {
222 thinking := m.message.IsThinking()
223 finished := m.message.IsFinished()
224 finishedData := m.message.FinishPart()
225 if thinking {
226 // Handle the thinking state
227 // TODO: maybe add the thinking content if available later.
228 content = fmt.Sprintf("**%s %s**", styles.LoadingIcon, "Thinking...")
229 } else if finished && content == "" && finishedData.Reason == message.FinishReasonEndTurn {
230 // Sometimes the LLMs respond with no content when they think the previous tool result
231 // provides the requested question
232 content = "*Finished without output*"
233 } else if finished && content == "" && finishedData.Reason == message.FinishReasonCanceled {
234 content = "*Canceled*"
235 }
236 }
237 return m.toMarkdown(content)
238}
239
240// shouldSpin determines whether the message should show a loading animation.
241// Only assistant messages without content that aren't finished should spin.
242func (m *messageCmp) shouldSpin() bool {
243 if m.message.Role != message.Assistant {
244 return false
245 }
246
247 if m.message.IsFinished() {
248 return false
249 }
250
251 if m.message.Content().Text != "" {
252 return false
253 }
254 return true
255}
256
257// Focus management methods
258
259// Blur removes focus from the message component
260func (m *messageCmp) Blur() tea.Cmd {
261 m.focused = false
262 return nil
263}
264
265// Focus sets focus on the message component
266func (m *messageCmp) Focus() tea.Cmd {
267 m.focused = true
268 return nil
269}
270
271// IsFocused returns whether the message component is currently focused
272func (m *messageCmp) IsFocused() bool {
273 return m.focused
274}
275
276// Size management methods
277
278// GetSize returns the current dimensions of the message component
279func (m *messageCmp) GetSize() (int, int) {
280 return m.width, 0
281}
282
283// SetSize updates the width of the message component for text wrapping
284func (m *messageCmp) SetSize(width int, height int) tea.Cmd {
285 m.width = width
286 return nil
287}
288
289// Spinning returns whether the message is currently showing a loading animation
290func (m *messageCmp) Spinning() bool {
291 return m.spinning
292}