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