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