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		}
108	}
109	return m, nil
110}
111
112// View renders the message component based on its current state.
113// Returns different views for spinning, user, and assistant messages.
114func (m *messageCmp) View() string {
115	if m.spinning && m.message.ReasoningContent().Thinking == "" {
116		return m.style().PaddingLeft(1).Render(m.anim.View())
117	}
118	if m.message.ID != "" {
119		// this is a user or assistant message
120		switch m.message.Role {
121		case message.User:
122			return m.renderUserMessage()
123		default:
124			return m.renderAssistantMessage()
125		}
126	}
127	return m.style().Render("No message content")
128}
129
130// GetMessage returns the underlying message data
131func (m *messageCmp) GetMessage() message.Message {
132	return m.message
133}
134
135func (m *messageCmp) SetMessage(msg message.Message) {
136	m.message = msg
137}
138
139// textWidth calculates the available width for text content,
140// accounting for borders and padding
141func (m *messageCmp) textWidth() int {
142	return m.width - 2 // take into account the border and/or padding
143}
144
145// style returns the lipgloss style for the message component.
146// Applies different border colors and styles based on message role and focus state.
147func (msg *messageCmp) style() lipgloss.Style {
148	t := styles.CurrentTheme()
149	borderStyle := lipgloss.NormalBorder()
150	if msg.focused {
151		borderStyle = focusedMessageBorder
152	}
153
154	style := t.S().Text
155	if msg.message.Role == message.User {
156		style = style.PaddingLeft(1).BorderLeft(true).BorderStyle(borderStyle).BorderForeground(t.Primary)
157	} else {
158		if msg.focused {
159			style = style.PaddingLeft(1).BorderLeft(true).BorderStyle(borderStyle).BorderForeground(t.GreenDark)
160		} else {
161			style = style.PaddingLeft(2)
162		}
163	}
164	return style
165}
166
167// renderAssistantMessage renders assistant messages with optional footer information.
168// Shows model name, response time, and finish reason when the message is complete.
169func (m *messageCmp) renderAssistantMessage() string {
170	t := styles.CurrentTheme()
171	parts := []string{}
172	content := m.message.Content().String()
173	thinking := m.message.IsThinking()
174	finished := m.message.IsFinished()
175	finishedData := m.message.FinishPart()
176	thinkingContent := ""
177
178	if thinking || m.message.ReasoningContent().Thinking != "" {
179		m.anim.SetLabel("Thinking")
180		thinkingContent = m.renderThinkingContent()
181	} else if finished && content == "" && finishedData.Reason == message.FinishReasonEndTurn {
182		content = ""
183	} else if finished && content == "" && finishedData.Reason == message.FinishReasonCanceled {
184		content = "*Canceled*"
185	} else if finished && content == "" && finishedData.Reason == message.FinishReasonError {
186		errTag := t.S().Base.Padding(0, 1).Background(t.Red).Foreground(t.White).Render("ERROR")
187		truncated := ansi.Truncate(finishedData.Message, m.textWidth()-2-lipgloss.Width(errTag), "...")
188		title := fmt.Sprintf("%s %s", errTag, t.S().Base.Foreground(t.FgHalfMuted).Render(truncated))
189		details := t.S().Base.Foreground(t.FgSubtle).Width(m.textWidth() - 2).Render(finishedData.Details)
190		// Handle error messages differently
191		return fmt.Sprintf("%s\n\n%s", title, details)
192	}
193
194	if thinkingContent != "" {
195		parts = append(parts, thinkingContent)
196	}
197
198	if content != "" {
199		if thinkingContent != "" {
200			parts = append(parts, "")
201		}
202		parts = append(parts, m.toMarkdown(content))
203	}
204
205	joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
206	return m.style().Render(joined)
207}
208
209// renderUserMessage renders user messages with file attachments. It displays
210// message content and any attached files with appropriate icons.
211func (m *messageCmp) renderUserMessage() string {
212	t := styles.CurrentTheme()
213	parts := []string{
214		m.toMarkdown(m.message.Content().String()),
215	}
216
217	attachmentStyles := t.S().Text.
218		MarginLeft(1).
219		Background(t.BgSubtle)
220
221	attachments := make([]string, len(m.message.BinaryContent()))
222	for i, attachment := range m.message.BinaryContent() {
223		const maxFilenameWidth = 10
224		filename := filepath.Base(attachment.Path)
225		attachments[i] = attachmentStyles.Render(fmt.Sprintf(
226			" %s %s ",
227			styles.DocumentIcon,
228			ansi.Truncate(filename, maxFilenameWidth, "..."),
229		))
230	}
231
232	if len(attachments) > 0 {
233		parts = append(parts, "", strings.Join(attachments, ""))
234	}
235
236	joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
237	return m.style().Render(joined)
238}
239
240// toMarkdown converts text content to rendered markdown using the configured renderer
241func (m *messageCmp) toMarkdown(content string) string {
242	r := styles.GetMarkdownRenderer(m.textWidth())
243	rendered, _ := r.Render(content)
244	return strings.TrimSuffix(rendered, "\n")
245}
246
247func (m *messageCmp) renderThinkingContent() string {
248	t := styles.CurrentTheme()
249	reasoningContent := m.message.ReasoningContent()
250	if reasoningContent.Thinking == "" {
251		return ""
252	}
253	lines := strings.Split(reasoningContent.Thinking, "\n")
254	var content strings.Builder
255	lineStyle := t.S().Subtle.Background(t.BgBaseLighter)
256	for i, line := range lines {
257		if line == "" {
258			continue
259		}
260		content.WriteString(lineStyle.Width(m.textWidth() - 2).Render(line))
261		if i < len(lines)-1 {
262			content.WriteString("\n")
263		}
264	}
265	fullContent := content.String()
266	height := util.Clamp(lipgloss.Height(fullContent), 1, 10)
267	m.thinkingViewport.SetHeight(height)
268	m.thinkingViewport.SetWidth(m.textWidth())
269	m.thinkingViewport.SetContent(fullContent)
270	m.thinkingViewport.GotoBottom()
271	finishReason := m.message.FinishPart()
272	var footer string
273	if reasoningContent.StartedAt > 0 {
274		duration := m.message.ThinkingDuration()
275		if reasoningContent.FinishedAt > 0 {
276			m.anim.SetLabel("")
277			opts := core.StatusOpts{
278				Title:       "Thought for",
279				Description: duration.String(),
280				NoIcon:      true,
281			}
282			return t.S().Base.PaddingLeft(1).Render(core.Status(opts, m.textWidth()-1))
283		} else if finishReason != nil && finishReason.Reason == message.FinishReasonCanceled {
284			footer = t.S().Base.PaddingLeft(1).Render(m.toMarkdown("*Canceled*"))
285		} else {
286			footer = m.anim.View()
287		}
288	}
289	return lineStyle.Width(m.textWidth()).Padding(0, 1).Render(m.thinkingViewport.View()) + "\n\n" + footer
290}
291
292// shouldSpin determines whether the message should show a loading animation.
293// Only assistant messages without content that aren't finished should spin.
294func (m *messageCmp) shouldSpin() bool {
295	if m.message.Role != message.Assistant {
296		return false
297	}
298
299	if m.message.IsFinished() {
300		return false
301	}
302
303	if m.message.Content().Text != "" {
304		return false
305	}
306	if len(m.message.ToolCalls()) > 0 {
307		return false
308	}
309	return true
310}
311
312// Blur removes focus from the message component
313func (m *messageCmp) Blur() tea.Cmd {
314	m.focused = false
315	return nil
316}
317
318// Focus sets focus on the message component
319func (m *messageCmp) Focus() tea.Cmd {
320	m.focused = true
321	return nil
322}
323
324// IsFocused returns whether the message component is currently focused
325func (m *messageCmp) IsFocused() bool {
326	return m.focused
327}
328
329// Size management methods
330
331// GetSize returns the current dimensions of the message component
332func (m *messageCmp) GetSize() (int, int) {
333	return m.width, 0
334}
335
336// SetSize updates the width of the message component for text wrapping
337func (m *messageCmp) SetSize(width int, height int) tea.Cmd {
338	m.width = util.Clamp(width, 1, 120)
339	m.thinkingViewport.SetWidth(m.width - 4)
340	return nil
341}
342
343// Spinning returns whether the message is currently showing a loading animation
344func (m *messageCmp) Spinning() bool {
345	return m.spinning
346}
347
348type AssistantSection interface {
349	list.Item
350	layout.Sizeable
351}
352type assistantSectionModel struct {
353	width               int
354	id                  string
355	message             message.Message
356	lastUserMessageTime time.Time
357}
358
359// ID implements AssistantSection.
360func (m *assistantSectionModel) ID() string {
361	return m.id
362}
363
364func NewAssistantSection(message message.Message, lastUserMessageTime time.Time) AssistantSection {
365	return &assistantSectionModel{
366		width:               0,
367		id:                  uuid.NewString(),
368		message:             message,
369		lastUserMessageTime: lastUserMessageTime,
370	}
371}
372
373func (m *assistantSectionModel) Init() tea.Cmd {
374	return nil
375}
376
377func (m *assistantSectionModel) Update(tea.Msg) (tea.Model, tea.Cmd) {
378	return m, nil
379}
380
381func (m *assistantSectionModel) View() string {
382	t := styles.CurrentTheme()
383	finishData := m.message.FinishPart()
384	finishTime := time.Unix(finishData.Time, 0)
385	duration := finishTime.Sub(m.lastUserMessageTime)
386	infoMsg := t.S().Subtle.Render(duration.String())
387	icon := t.S().Subtle.Render(styles.ModelIcon)
388	model := config.Get().GetModel(m.message.Provider, m.message.Model)
389	if model == nil {
390		// This means the model is not configured anymore
391		model = &catwalk.Model{
392			Name: "Unknown Model",
393		}
394	}
395	modelFormatted := t.S().Muted.Render(model.Name)
396	assistant := fmt.Sprintf("%s %s %s", icon, modelFormatted, infoMsg)
397	return t.S().Base.PaddingLeft(2).Render(
398		core.Section(assistant, m.width-2),
399	)
400}
401
402func (m *assistantSectionModel) GetSize() (int, int) {
403	return m.width, 1
404}
405
406func (m *assistantSectionModel) SetSize(width int, height int) tea.Cmd {
407	m.width = width
408	return nil
409}
410
411func (m *assistantSectionModel) IsSectionHeader() bool {
412	return true
413}
414
415func (m *messageCmp) ID() string {
416	return m.message.ID
417}