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