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