messages.go

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