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