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