messages.go

  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}