messages.go

  1// Package chat provides UI components for displaying and managing chat messages.
  2// It defines message item types that can be rendered in a list view, including
  3// support for highlighting, focusing, and caching rendered content.
  4package chat
  5
  6import (
  7	"image"
  8	"strings"
  9
 10	tea "charm.land/bubbletea/v2"
 11	"github.com/charmbracelet/crush/internal/message"
 12	"github.com/charmbracelet/crush/internal/ui/anim"
 13	"github.com/charmbracelet/crush/internal/ui/list"
 14	"github.com/charmbracelet/crush/internal/ui/styles"
 15)
 16
 17// this is the total width that is taken up by the border + padding
 18// we also cap the width so text is readable to the maxTextWidth(120)
 19const messageLeftPaddingTotal = 2
 20
 21// maxTextWidth is the maximum width text messages can be
 22const maxTextWidth = 120
 23
 24// Identifiable is an interface for items that can provide a unique identifier.
 25type Identifiable interface {
 26	ID() string
 27}
 28
 29// Animatable is an interface for items that support animation.
 30type Animatable interface {
 31	StartAnimation() tea.Cmd
 32	Animate(msg anim.StepMsg) tea.Cmd
 33}
 34
 35// Expandable is an interface for items that can be expanded or collapsed.
 36type Expandable interface {
 37	ToggleExpanded()
 38}
 39
 40// MessageItem represents a [message.Message] item that can be displayed in the
 41// UI and be part of a [list.List] identifiable by a unique ID.
 42type MessageItem interface {
 43	list.Item
 44	list.Highlightable
 45	list.Focusable
 46	Identifiable
 47}
 48
 49// SendMsg represents a message to send a chat message.
 50type SendMsg struct {
 51	Text        string
 52	Attachments []message.Attachment
 53}
 54
 55type highlightableMessageItem struct {
 56	startLine   int
 57	startCol    int
 58	endLine     int
 59	endCol      int
 60	highlighter list.Highlighter
 61}
 62
 63// isHighlighted returns true if the item has a highlight range set.
 64func (h *highlightableMessageItem) isHighlighted() bool {
 65	return h.startLine != -1 || h.endLine != -1
 66}
 67
 68// renderHighlighted highlights the content if necessary.
 69func (h *highlightableMessageItem) renderHighlighted(content string, width, height int) string {
 70	if !h.isHighlighted() {
 71		return content
 72	}
 73	area := image.Rect(0, 0, width, height)
 74	return list.Highlight(content, area, h.startLine, h.startCol, h.endLine, h.endCol, h.highlighter)
 75}
 76
 77// Highlight implements MessageItem.
 78func (h *highlightableMessageItem) Highlight(startLine int, startCol int, endLine int, endCol int) {
 79	// Adjust columns for the style's left inset (border + padding) since we
 80	// highlight the content only.
 81	offset := messageLeftPaddingTotal
 82	h.startLine = startLine
 83	h.startCol = max(0, startCol-offset)
 84	h.endLine = endLine
 85	if endCol >= 0 {
 86		h.endCol = max(0, endCol-offset)
 87	} else {
 88		h.endCol = endCol
 89	}
 90}
 91
 92func defaultHighlighter(sty *styles.Styles) *highlightableMessageItem {
 93	return &highlightableMessageItem{
 94		startLine:   -1,
 95		startCol:    -1,
 96		endLine:     -1,
 97		endCol:      -1,
 98		highlighter: list.ToHighlighter(sty.TextSelection),
 99	}
100}
101
102// cachedMessageItem caches rendered message content to avoid re-rendering.
103//
104// This should be used by any message that can store a cached version of its render. e.x user,assistant... and so on
105//
106// THOUGHT(kujtim): we should consider if its efficient to store the render for different widths
107// the issue with that could be memory usage
108type cachedMessageItem struct {
109	// rendered is the cached rendered string
110	rendered string
111	// width and height are the dimensions of the cached render
112	width  int
113	height int
114}
115
116// getCachedRender returns the cached render if it exists for the given width.
117func (c *cachedMessageItem) getCachedRender(width int) (string, int, bool) {
118	if c.width == width && c.rendered != "" {
119		return c.rendered, c.height, true
120	}
121	return "", 0, false
122}
123
124// setCachedRender sets the cached render.
125func (c *cachedMessageItem) setCachedRender(rendered string, width, height int) {
126	c.rendered = rendered
127	c.width = width
128	c.height = height
129}
130
131// clearCache clears the cached render.
132func (c *cachedMessageItem) clearCache() {
133	c.rendered = ""
134	c.width = 0
135	c.height = 0
136}
137
138// focusableMessageItem is a base struct for message items that can be focused.
139type focusableMessageItem struct {
140	focused bool
141}
142
143// SetFocused implements MessageItem.
144func (f *focusableMessageItem) SetFocused(focused bool) {
145	f.focused = focused
146}
147
148// cappedMessageWidth returns the maximum width for message content for readability.
149func cappedMessageWidth(availableWidth int) int {
150	return min(availableWidth-messageLeftPaddingTotal, maxTextWidth)
151}
152
153// GetMessageItems extracts [MessageItem]s from a [message.Message]. It returns
154// all parts of the message as [MessageItem]s.
155//
156// For assistant messages with tool calls, pass a toolResults map to link results.
157// Use BuildToolResultMap to create this map from all messages in a session.
158func GetMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[string]message.ToolResult) []MessageItem {
159	switch msg.Role {
160	case message.User:
161		return []MessageItem{NewUserMessageItem(sty, msg)}
162	case message.Assistant:
163		var items []MessageItem
164		if shouldRenderAssistantMessage(msg) {
165			items = append(items, NewAssistantMessageItem(sty, msg))
166		}
167		return items
168	}
169	return []MessageItem{}
170}
171
172// shouldRenderAssistantMessage determines if an assistant message should be rendered
173//
174// In some cases the assistant message only has tools so we do not want to render an
175// empty message.
176func shouldRenderAssistantMessage(msg *message.Message) bool {
177	content := strings.TrimSpace(msg.Content().Text)
178	thinking := strings.TrimSpace(msg.ReasoningContent().Thinking)
179	isError := msg.FinishReason() == message.FinishReasonError
180	isCancelled := msg.FinishReason() == message.FinishReasonCanceled
181	hasToolCalls := len(msg.ToolCalls()) > 0
182	return !hasToolCalls || content != "" || thinking != "" || msg.IsThinking() || isError || isCancelled
183}
184
185// BuildToolResultMap creates a map of tool call IDs to their results from a list of messages.
186// Tool result messages (role == message.Tool) contain the results that should be linked
187// to tool calls in assistant messages.
188func BuildToolResultMap(messages []*message.Message) map[string]message.ToolResult {
189	resultMap := make(map[string]message.ToolResult)
190	for _, msg := range messages {
191		if msg.Role == message.Tool {
192			for _, result := range msg.ToolResults() {
193				if result.ToolCallID != "" {
194					resultMap[result.ToolCallID] = result
195				}
196			}
197		}
198	}
199	return resultMap
200}