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/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, spinner.TickMsg:
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() tea.View {
97 if m.spinning {
98 return tea.NewView(m.style().PaddingLeft(1).Render(m.anim.View().String()))
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 tea.NewView(m.renderUserMessage())
105 default:
106 return tea.NewView(m.renderAssistantMessage())
107 }
108 }
109 return tea.NewView(m.style().Render("No message content"))
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 := styles.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.BgSubtle
141 }
142
143 return t.S().Muted.
144 BorderLeft(true).
145 BorderForeground(borderColor).
146 BorderStyle(borderStyle)
147}
148
149// renderAssistantMessage renders assistant messages with optional footer information.
150// Shows model name, response time, and finish reason when the message is complete.
151func (m *messageCmp) renderAssistantMessage() string {
152 parts := []string{
153 m.markdownContent(),
154 }
155
156 finished := m.message.IsFinished()
157 finishData := m.message.FinishPart()
158 // Only show the footer if the message is not a tool call
159 if finished && finishData.Reason != message.FinishReasonToolUse {
160 infoMsg := ""
161 switch finishData.Reason {
162 case message.FinishReasonEndTurn:
163 finishTime := time.Unix(finishData.Time, 0)
164 duration := finishTime.Sub(m.lastUserMessageTime)
165 infoMsg = duration.String()
166 case message.FinishReasonCanceled:
167 infoMsg = "canceled"
168 case message.FinishReasonError:
169 infoMsg = "error"
170 case message.FinishReasonPermissionDenied:
171 infoMsg = "permission denied"
172 }
173 parts = append(parts, fmt.Sprintf(" %s (%s)", models.SupportedModels[m.message.Model].Name, infoMsg))
174 }
175
176 joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
177 return m.style().Render(joined)
178}
179
180// renderUserMessage renders user messages with file attachments.
181// Displays message content and any attached files with appropriate icons.
182func (m *messageCmp) renderUserMessage() string {
183 t := styles.CurrentTheme()
184 parts := []string{
185 m.markdownContent(),
186 }
187 attachmentStyles := t.S().Text.
188 MarginLeft(1).
189 Background(t.BgSubtle)
190 attachments := []string{}
191 for _, attachment := range m.message.BinaryContent() {
192 file := filepath.Base(attachment.Path)
193 var filename string
194 if len(file) > 10 {
195 filename = fmt.Sprintf(" %s %s... ", styles.DocumentIcon, file[0:7])
196 } else {
197 filename = fmt.Sprintf(" %s %s ", styles.DocumentIcon, file)
198 }
199 attachments = append(attachments, attachmentStyles.Render(filename))
200 }
201 if len(attachments) > 0 {
202 parts = append(parts, "", strings.Join(attachments, ""))
203 }
204 joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
205 return m.style().Render(joined)
206}
207
208// toMarkdown converts text content to rendered markdown using the configured renderer
209func (m *messageCmp) toMarkdown(content string) string {
210 r := styles.GetMarkdownRenderer(m.textWidth())
211 rendered, _ := r.Render(content)
212 return strings.TrimSuffix(rendered, "\n")
213}
214
215// markdownContent processes the message content and handles special states.
216// Returns appropriate content for thinking, finished, and error states.
217func (m *messageCmp) markdownContent() string {
218 content := m.message.Content().String()
219 if m.message.Role == message.Assistant {
220 thinking := m.message.IsThinking()
221 finished := m.message.IsFinished()
222 finishedData := m.message.FinishPart()
223 if thinking {
224 // Handle the thinking state
225 // TODO: maybe add the thinking content if available later.
226 content = fmt.Sprintf("**%s %s**", styles.LoadingIcon, "Thinking...")
227 } else if finished && content == "" && finishedData.Reason == message.FinishReasonEndTurn {
228 // Sometimes the LLMs respond with no content when they think the previous tool result
229 // provides the requested question
230 content = "*Finished without output*"
231 } else if finished && content == "" && finishedData.Reason == message.FinishReasonCanceled {
232 content = "*Canceled*"
233 }
234 }
235 return m.toMarkdown(content)
236}
237
238// shouldSpin determines whether the message should show a loading animation.
239// Only assistant messages without content that aren't finished should spin.
240func (m *messageCmp) shouldSpin() bool {
241 if m.message.Role != message.Assistant {
242 return false
243 }
244
245 if m.message.IsFinished() {
246 return false
247 }
248
249 if m.message.Content().Text != "" {
250 return false
251 }
252 return true
253}
254
255// Focus management methods
256
257// Blur removes focus from the message component
258func (m *messageCmp) Blur() tea.Cmd {
259 m.focused = false
260 return nil
261}
262
263// Focus sets focus on the message component
264func (m *messageCmp) Focus() tea.Cmd {
265 m.focused = true
266 return nil
267}
268
269// IsFocused returns whether the message component is currently focused
270func (m *messageCmp) IsFocused() bool {
271 return m.focused
272}
273
274// Size management methods
275
276// GetSize returns the current dimensions of the message component
277func (m *messageCmp) GetSize() (int, int) {
278 return m.width, 0
279}
280
281// SetSize updates the width of the message component for text wrapping
282func (m *messageCmp) SetSize(width int, height int) tea.Cmd {
283 m.width = width
284 return nil
285}
286
287// Spinning returns whether the message is currently showing a loading animation
288func (m *messageCmp) Spinning() bool {
289 return m.spinning
290}