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