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