1package messages
2
3import (
4 "fmt"
5 "path/filepath"
6 "strings"
7 "time"
8
9 tea "github.com/charmbracelet/bubbletea/v2"
10 "github.com/charmbracelet/lipgloss/v2"
11
12 "github.com/charmbracelet/crush/internal/config"
13 "github.com/charmbracelet/crush/internal/fur/provider"
14 "github.com/charmbracelet/crush/internal/message"
15 "github.com/charmbracelet/crush/internal/tui/components/anim"
16 "github.com/charmbracelet/crush/internal/tui/components/core"
17 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
18 "github.com/charmbracelet/crush/internal/tui/components/core/list"
19 "github.com/charmbracelet/crush/internal/tui/styles"
20 "github.com/charmbracelet/crush/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}
45
46var focusedMessageBorder = lipgloss.Border{
47 Left: "▌",
48}
49
50// NewMessageCmp creates a new message component with the given message and options
51func NewMessageCmp(msg message.Message) MessageCmp {
52 t := styles.CurrentTheme()
53 m := &messageCmp{
54 message: msg,
55 anim: anim.New(anim.Settings{
56 Size: 15,
57 GradColorA: t.Primary,
58 GradColorB: t.Secondary,
59 CycleColors: true,
60 }),
61 }
62 return m
63}
64
65// Init initializes the message component and starts animations if needed.
66// Returns a command to start the animation for spinning messages.
67func (m *messageCmp) Init() tea.Cmd {
68 m.spinning = m.shouldSpin()
69 if m.spinning {
70 return m.anim.Init()
71 }
72 return nil
73}
74
75// Update handles incoming messages and updates the component state.
76// Manages animation updates for spinning messages and stops animation when appropriate.
77func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
78 switch msg := msg.(type) {
79 case anim.StepMsg:
80 m.spinning = m.shouldSpin()
81 if m.spinning {
82 u, cmd := m.anim.Update(msg)
83 m.anim = u.(util.Model)
84 return m, cmd
85 }
86 }
87 return m, nil
88}
89
90// View renders the message component based on its current state.
91// Returns different views for spinning, user, and assistant messages.
92func (m *messageCmp) View() string {
93 if m.spinning {
94 return m.style().PaddingLeft(1).Render(m.anim.View())
95 }
96 if m.message.ID != "" {
97 // this is a user or assistant message
98 switch m.message.Role {
99 case message.User:
100 return m.renderUserMessage()
101 default:
102 return m.renderAssistantMessage()
103 }
104 }
105 return m.style().Render("No message content")
106}
107
108// GetMessage returns the underlying message data
109func (m *messageCmp) GetMessage() message.Message {
110 return m.message
111}
112
113// textWidth calculates the available width for text content,
114// accounting for borders and padding
115func (m *messageCmp) textWidth() int {
116 return m.width - 2 // take into account the border and/or padding
117}
118
119// style returns the lipgloss style for the message component.
120// Applies different border colors and styles based on message role and focus state.
121func (msg *messageCmp) style() lipgloss.Style {
122 t := styles.CurrentTheme()
123 borderStyle := lipgloss.NormalBorder()
124 if msg.focused {
125 borderStyle = focusedMessageBorder
126 }
127
128 style := t.S().Text
129 if msg.message.Role == message.User {
130 style = style.PaddingLeft(1).BorderLeft(true).BorderStyle(borderStyle).BorderForeground(t.Primary)
131 } else {
132 if msg.focused {
133 style = style.PaddingLeft(1).BorderLeft(true).BorderStyle(borderStyle).BorderForeground(t.GreenDark)
134 } else {
135 style = style.PaddingLeft(2)
136 }
137 }
138 return style
139}
140
141// renderAssistantMessage renders assistant messages with optional footer information.
142// Shows model name, response time, and finish reason when the message is complete.
143func (m *messageCmp) renderAssistantMessage() string {
144 parts := []string{
145 m.markdownContent(),
146 }
147
148 joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
149 return m.style().Render(joined)
150}
151
152// renderUserMessage renders user messages with file attachments.
153// Displays message content and any attached files with appropriate icons.
154func (m *messageCmp) renderUserMessage() string {
155 t := styles.CurrentTheme()
156 parts := []string{
157 m.markdownContent(),
158 }
159 attachmentStyles := t.S().Text.
160 MarginLeft(1).
161 Background(t.BgSubtle)
162 attachments := []string{}
163 for _, attachment := range m.message.BinaryContent() {
164 file := filepath.Base(attachment.Path)
165 var filename string
166 if len(file) > 10 {
167 filename = fmt.Sprintf(" %s %s... ", styles.DocumentIcon, file[0:7])
168 } else {
169 filename = fmt.Sprintf(" %s %s ", styles.DocumentIcon, file)
170 }
171 attachments = append(attachments, attachmentStyles.Render(filename))
172 }
173 if len(attachments) > 0 {
174 parts = append(parts, "", strings.Join(attachments, ""))
175 }
176 joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
177 return m.style().Render(joined)
178}
179
180// toMarkdown converts text content to rendered markdown using the configured renderer
181func (m *messageCmp) toMarkdown(content string) string {
182 r := styles.GetMarkdownRenderer(m.textWidth())
183 rendered, _ := r.Render(content)
184 return strings.TrimSuffix(rendered, "\n")
185}
186
187// markdownContent processes the message content and handles special states.
188// Returns appropriate content for thinking, finished, and error states.
189func (m *messageCmp) markdownContent() string {
190 content := m.message.Content().String()
191 if m.message.Role == message.Assistant {
192 thinking := m.message.IsThinking()
193 finished := m.message.IsFinished()
194 finishedData := m.message.FinishPart()
195 if thinking {
196 // Handle the thinking state
197 // TODO: maybe add the thinking content if available later.
198 content = fmt.Sprintf("**%s %s**", styles.LoadingIcon, "Thinking...")
199 } else if finished && content == "" && finishedData.Reason == message.FinishReasonEndTurn {
200 // Sometimes the LLMs respond with no content when they think the previous tool result
201 // provides the requested question
202 content = ""
203 } else if finished && content == "" && finishedData.Reason == message.FinishReasonCanceled {
204 content = "*Canceled*"
205 }
206 }
207 return m.toMarkdown(content)
208}
209
210// shouldSpin determines whether the message should show a loading animation.
211// Only assistant messages without content that aren't finished should spin.
212func (m *messageCmp) shouldSpin() bool {
213 if m.message.Role != message.Assistant {
214 return false
215 }
216
217 if m.message.IsFinished() {
218 return false
219 }
220
221 if m.message.Content().Text != "" {
222 return false
223 }
224 return true
225}
226
227// Focus management methods
228
229// Blur removes focus from the message component
230func (m *messageCmp) Blur() tea.Cmd {
231 m.focused = false
232 return nil
233}
234
235// Focus sets focus on the message component
236func (m *messageCmp) Focus() tea.Cmd {
237 m.focused = true
238 return nil
239}
240
241// IsFocused returns whether the message component is currently focused
242func (m *messageCmp) IsFocused() bool {
243 return m.focused
244}
245
246// Size management methods
247
248// GetSize returns the current dimensions of the message component
249func (m *messageCmp) GetSize() (int, int) {
250 return m.width, 0
251}
252
253// SetSize updates the width of the message component for text wrapping
254func (m *messageCmp) SetSize(width int, height int) tea.Cmd {
255 // For better readability, we limit the width to a maximum of 120 characters
256 m.width = min(width, 120)
257 return nil
258}
259
260// Spinning returns whether the message is currently showing a loading animation
261func (m *messageCmp) Spinning() bool {
262 return m.spinning
263}
264
265type AssistantSection interface {
266 util.Model
267 layout.Sizeable
268 list.SectionHeader
269}
270type assistantSectionModel struct {
271 width int
272 message message.Message
273 lastUserMessageTime time.Time
274}
275
276func NewAssistantSection(message message.Message, lastUserMessageTime time.Time) AssistantSection {
277 return &assistantSectionModel{
278 width: 0,
279 message: message,
280 lastUserMessageTime: lastUserMessageTime,
281 }
282}
283
284func (m *assistantSectionModel) Init() tea.Cmd {
285 return nil
286}
287
288func (m *assistantSectionModel) Update(tea.Msg) (tea.Model, tea.Cmd) {
289 return m, nil
290}
291
292func (m *assistantSectionModel) View() string {
293 t := styles.CurrentTheme()
294 finishData := m.message.FinishPart()
295 finishTime := time.Unix(finishData.Time, 0)
296 duration := finishTime.Sub(m.lastUserMessageTime)
297 infoMsg := t.S().Subtle.Render(duration.String())
298 icon := t.S().Subtle.Render(styles.ModelIcon)
299 model := config.GetProviderModel(provider.InferenceProvider(m.message.Provider), m.message.Model)
300 modelFormatted := t.S().Muted.Render(model.Name)
301 assistant := fmt.Sprintf("%s %s %s", icon, modelFormatted, infoMsg)
302 return t.S().Base.PaddingLeft(2).Render(
303 core.Section(assistant, m.width-2),
304 )
305}
306
307func (m *assistantSectionModel) GetSize() (int, int) {
308 return m.width, 1
309}
310
311func (m *assistantSectionModel) SetSize(width int, height int) tea.Cmd {
312 m.width = width
313 return nil
314}
315
316func (m *assistantSectionModel) IsSectionHeader() bool {
317 return true
318}