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