messages.go

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