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