messages.go

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