1package messages
2
3import (
4 "fmt"
5 "path/filepath"
6 "strings"
7 "time"
8
9 "github.com/charmbracelet/bubbles/v2/viewport"
10 tea "github.com/charmbracelet/bubbletea/v2"
11 "github.com/charmbracelet/lipgloss/v2"
12 "github.com/charmbracelet/x/ansi"
13
14 "github.com/charmbracelet/crush/internal/config"
15 "github.com/charmbracelet/crush/internal/fur/provider"
16 "github.com/charmbracelet/crush/internal/message"
17 "github.com/charmbracelet/crush/internal/tui/components/anim"
18 "github.com/charmbracelet/crush/internal/tui/components/core"
19 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
20 "github.com/charmbracelet/crush/internal/tui/components/core/list"
21 "github.com/charmbracelet/crush/internal/tui/styles"
22 "github.com/charmbracelet/crush/internal/tui/util"
23)
24
25// MessageCmp defines the interface for message components in the chat interface.
26// It combines standard UI model interfaces with message-specific functionality.
27type MessageCmp interface {
28 util.Model // Basic Bubble Tea model interface
29 layout.Sizeable // Width/height management
30 layout.Focusable // Focus state management
31 GetMessage() message.Message // Access to underlying message data
32 Spinning() bool // Animation state for loading messages
33}
34
35// messageCmp implements the MessageCmp interface for displaying chat messages.
36// It handles rendering of user and assistant messages with proper styling,
37// animations, and state management.
38type messageCmp struct {
39 width int // Component width for text wrapping
40 focused bool // Focus state for border styling
41
42 // Core message data and state
43 message message.Message // The underlying message content
44 spinning bool // Whether to show loading animation
45 anim util.Model // Animation component for loading states
46
47 // Thinking viewport for displaying reasoning content
48 thinkingViewport viewport.Model
49}
50
51var focusedMessageBorder = lipgloss.Border{
52 Left: "▌",
53}
54
55// NewMessageCmp creates a new message component with the given message and options
56func NewMessageCmp(msg message.Message) MessageCmp {
57 t := styles.CurrentTheme()
58
59 thinkingViewport := viewport.New()
60 thinkingViewport.SetHeight(1)
61 thinkingViewport.KeyMap = viewport.KeyMap{}
62
63 m := &messageCmp{
64 message: msg,
65 anim: anim.New(anim.Settings{
66 Size: 15,
67 GradColorA: t.Primary,
68 GradColorB: t.Secondary,
69 CycleColors: true,
70 }),
71 thinkingViewport: thinkingViewport,
72 }
73 return m
74}
75
76// Init initializes the message component and starts animations if needed.
77// Returns a command to start the animation for spinning messages.
78func (m *messageCmp) Init() tea.Cmd {
79 m.spinning = m.shouldSpin()
80 return m.anim.Init()
81}
82
83// Update handles incoming messages and updates the component state.
84// Manages animation updates for spinning messages and stops animation when appropriate.
85func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
86 switch msg := msg.(type) {
87 case anim.StepMsg:
88 m.spinning = m.shouldSpin()
89 if m.spinning {
90 u, cmd := m.anim.Update(msg)
91 m.anim = u.(util.Model)
92 return m, cmd
93 }
94 }
95 return m, nil
96}
97
98// View renders the message component based on its current state.
99// Returns different views for spinning, user, and assistant messages.
100func (m *messageCmp) View() string {
101 if m.spinning {
102 return m.style().PaddingLeft(1).Render(m.anim.View())
103 }
104 if m.message.ID != "" {
105 // this is a user or assistant message
106 switch m.message.Role {
107 case message.User:
108 return m.renderUserMessage()
109 default:
110 return m.renderAssistantMessage()
111 }
112 }
113 return m.style().Render("No message content")
114}
115
116// GetMessage returns the underlying message data
117func (m *messageCmp) GetMessage() message.Message {
118 return m.message
119}
120
121// textWidth calculates the available width for text content,
122// accounting for borders and padding
123func (m *messageCmp) textWidth() int {
124 return m.width - 2 // take into account the border and/or padding
125}
126
127// style returns the lipgloss style for the message component.
128// Applies different border colors and styles based on message role and focus state.
129func (msg *messageCmp) style() lipgloss.Style {
130 t := styles.CurrentTheme()
131 borderStyle := lipgloss.NormalBorder()
132 if msg.focused {
133 borderStyle = focusedMessageBorder
134 }
135
136 style := t.S().Text
137 if msg.message.Role == message.User {
138 style = style.PaddingLeft(1).BorderLeft(true).BorderStyle(borderStyle).BorderForeground(t.Primary)
139 } else {
140 if msg.focused {
141 style = style.PaddingLeft(1).BorderLeft(true).BorderStyle(borderStyle).BorderForeground(t.GreenDark)
142 } else {
143 style = style.PaddingLeft(2)
144 }
145 }
146 return style
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 t := styles.CurrentTheme()
153 parts := []string{}
154 content := m.message.Content().String()
155 thinking := m.message.IsThinking()
156 finished := m.message.IsFinished()
157 finishedData := m.message.FinishPart()
158 thinkingContent := ""
159
160 if thinking || m.message.ReasoningContent().Thinking != "" {
161 thinkingContent = m.renderThinkingContent()
162 } else if finished && content == "" && finishedData.Reason == message.FinishReasonEndTurn {
163 content = ""
164 } else if finished && content == "" && finishedData.Reason == message.FinishReasonCanceled {
165 content = "*Canceled*"
166 } else if finished && content == "" && finishedData.Reason == message.FinishReasonError {
167 errTag := t.S().Base.Padding(0, 1).Background(t.Red).Foreground(t.White).Render("ERROR")
168 truncated := ansi.Truncate(finishedData.Message, m.textWidth()-2-lipgloss.Width(errTag), "...")
169 title := fmt.Sprintf("%s %s", errTag, t.S().Base.Foreground(t.FgHalfMuted).Render(truncated))
170 details := t.S().Base.Foreground(t.FgSubtle).Width(m.textWidth() - 2).Render(finishedData.Details)
171 // Handle error messages differently
172 return fmt.Sprintf("%s\n\n%s", title, details)
173 }
174
175 if thinkingContent != "" {
176 parts = append(parts, thinkingContent)
177 }
178
179 if content != "" {
180 if thinkingContent != "" {
181 parts = append(parts, "")
182 }
183 parts = append(parts, m.toMarkdown(content))
184 }
185
186 joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
187 return m.style().Render(joined)
188}
189
190// renderUserMessage renders user messages with file attachments.
191// Displays message content and any attached files with appropriate icons.
192func (m *messageCmp) renderUserMessage() string {
193 t := styles.CurrentTheme()
194 parts := []string{
195 m.toMarkdown(m.message.Content().String()),
196 }
197 attachmentStyles := t.S().Text.
198 MarginLeft(1).
199 Background(t.BgSubtle)
200 attachments := []string{}
201 for _, attachment := range m.message.BinaryContent() {
202 file := filepath.Base(attachment.Path)
203 var filename string
204 if len(file) > 10 {
205 filename = fmt.Sprintf(" %s %s... ", styles.DocumentIcon, file[0:7])
206 } else {
207 filename = fmt.Sprintf(" %s %s ", styles.DocumentIcon, file)
208 }
209 attachments = append(attachments, attachmentStyles.Render(filename))
210 }
211 if len(attachments) > 0 {
212 parts = append(parts, "", strings.Join(attachments, ""))
213 }
214 joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
215 return m.style().Render(joined)
216}
217
218// toMarkdown converts text content to rendered markdown using the configured renderer
219func (m *messageCmp) toMarkdown(content string) string {
220 r := styles.GetMarkdownRenderer(m.textWidth())
221 rendered, _ := r.Render(content)
222 return strings.TrimSuffix(rendered, "\n")
223}
224
225func (m *messageCmp) renderThinkingContent() string {
226 t := styles.CurrentTheme()
227 reasoningContent := m.message.ReasoningContent()
228 if reasoningContent.Thinking == "" {
229 return ""
230 }
231 lines := strings.Split(reasoningContent.Thinking, "\n")
232 var content strings.Builder
233 lineStyle := t.S().Muted.Background(t.BgBaseLighter)
234 for _, line := range lines {
235 if line == "" {
236 continue
237 }
238 content.WriteString(lineStyle.Width(m.textWidth()-2).Render(line) + "\n")
239 }
240 fullContent := content.String()
241 height := util.Clamp(lipgloss.Height(fullContent), 1, 10)
242 m.thinkingViewport.SetHeight(height)
243 m.thinkingViewport.SetWidth(m.textWidth())
244 m.thinkingViewport.SetContent(fullContent)
245 m.thinkingViewport.GotoBottom()
246 var footer string
247 if reasoningContent.StartedAt > 0 {
248 duration := m.message.ThinkingDuration()
249 opts := core.StatusOpts{
250 Title: "Thinking...",
251 Description: duration.String(),
252 }
253 if reasoningContent.FinishedAt > 0 {
254 opts.NoIcon = true
255 opts.Title = "Thought for"
256 }
257 footer = t.S().Base.PaddingLeft(1).Render(core.Status(opts, m.textWidth()-1))
258 }
259 return lineStyle.Width(m.textWidth()).Padding(0, 1).Render(m.thinkingViewport.View()) + "\n\n" + footer
260}
261
262// shouldSpin determines whether the message should show a loading animation.
263// Only assistant messages without content that aren't finished should spin.
264func (m *messageCmp) shouldSpin() bool {
265 if m.message.Role != message.Assistant {
266 return false
267 }
268
269 if m.message.IsFinished() {
270 return false
271 }
272
273 if m.message.Content().Text != "" {
274 return false
275 }
276 return true
277}
278
279// Focus management methods
280
281// Blur removes focus from the message component
282func (m *messageCmp) Blur() tea.Cmd {
283 m.focused = false
284 return nil
285}
286
287// Focus sets focus on the message component
288func (m *messageCmp) Focus() tea.Cmd {
289 m.focused = true
290 return nil
291}
292
293// IsFocused returns whether the message component is currently focused
294func (m *messageCmp) IsFocused() bool {
295 return m.focused
296}
297
298// Size management methods
299
300// GetSize returns the current dimensions of the message component
301func (m *messageCmp) GetSize() (int, int) {
302 return m.width, 0
303}
304
305// SetSize updates the width of the message component for text wrapping
306func (m *messageCmp) SetSize(width int, height int) tea.Cmd {
307 m.width = util.Clamp(width, 1, 120)
308 m.thinkingViewport.SetWidth(m.width - 4)
309 return nil
310}
311
312// Spinning returns whether the message is currently showing a loading animation
313func (m *messageCmp) Spinning() bool {
314 return m.spinning
315}
316
317type AssistantSection interface {
318 util.Model
319 layout.Sizeable
320 list.SectionHeader
321}
322type assistantSectionModel struct {
323 width int
324 message message.Message
325 lastUserMessageTime time.Time
326}
327
328func NewAssistantSection(message message.Message, lastUserMessageTime time.Time) AssistantSection {
329 return &assistantSectionModel{
330 width: 0,
331 message: message,
332 lastUserMessageTime: lastUserMessageTime,
333 }
334}
335
336func (m *assistantSectionModel) Init() tea.Cmd {
337 return nil
338}
339
340func (m *assistantSectionModel) Update(tea.Msg) (tea.Model, tea.Cmd) {
341 return m, nil
342}
343
344func (m *assistantSectionModel) View() string {
345 t := styles.CurrentTheme()
346 finishData := m.message.FinishPart()
347 finishTime := time.Unix(finishData.Time, 0)
348 duration := finishTime.Sub(m.lastUserMessageTime)
349 infoMsg := t.S().Subtle.Render(duration.String())
350 icon := t.S().Subtle.Render(styles.ModelIcon)
351 model := config.Get().GetModel(m.message.Provider, m.message.Model)
352 if model == nil {
353 // This means the model is not configured anymore
354 model = &provider.Model{
355 Model: "Unknown Model",
356 }
357 }
358 modelFormatted := t.S().Muted.Render(model.Model)
359 assistant := fmt.Sprintf("%s %s %s", icon, modelFormatted, infoMsg)
360 return t.S().Base.PaddingLeft(2).Render(
361 core.Section(assistant, m.width-2),
362 )
363}
364
365func (m *assistantSectionModel) GetSize() (int, int) {
366 return m.width, 1
367}
368
369func (m *assistantSectionModel) SetSize(width int, height int) tea.Cmd {
370 m.width = width
371 return nil
372}
373
374func (m *assistantSectionModel) IsSectionHeader() bool {
375 return true
376}