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	"fmt"
  8	"image"
  9	"strings"
 10	"time"
 11
 12	tea "charm.land/bubbletea/v2"
 13	"charm.land/lipgloss/v2"
 14	"github.com/charmbracelet/catwalk/pkg/catwalk"
 15	"github.com/charmbracelet/crush/internal/config"
 16	"github.com/charmbracelet/crush/internal/message"
 17	"github.com/charmbracelet/crush/internal/ui/anim"
 18	"github.com/charmbracelet/crush/internal/ui/attachments"
 19	"github.com/charmbracelet/crush/internal/ui/common"
 20	"github.com/charmbracelet/crush/internal/ui/list"
 21	"github.com/charmbracelet/crush/internal/ui/styles"
 22)
 23
 24// this is the total width that is taken up by the border + padding
 25// we also cap the width so text is readable to the maxTextWidth(120)
 26const messageLeftPaddingTotal = 2
 27
 28// maxTextWidth is the maximum width text messages can be
 29const maxTextWidth = 120
 30
 31// Identifiable is an interface for items that can provide a unique identifier.
 32type Identifiable interface {
 33	ID() string
 34}
 35
 36// Animatable is an interface for items that support animation.
 37type Animatable interface {
 38	StartAnimation() tea.Cmd
 39	Animate(msg anim.StepMsg) tea.Cmd
 40}
 41
 42// Expandable is an interface for items that can be expanded or collapsed.
 43type Expandable interface {
 44	ToggleExpanded()
 45}
 46
 47// MessageItem represents a [message.Message] item that can be displayed in the
 48// UI and be part of a [list.List] identifiable by a unique ID.
 49type MessageItem interface {
 50	list.Item
 51	Identifiable
 52}
 53
 54// HighlightableMessageItem is a message item that supports highlighting.
 55type HighlightableMessageItem interface {
 56	MessageItem
 57	list.Highlightable
 58}
 59
 60// FocusableMessageItem is a message item that supports focus.
 61type FocusableMessageItem interface {
 62	MessageItem
 63	list.Focusable
 64}
 65
 66// SendMsg represents a message to send a chat message.
 67type SendMsg struct {
 68	Text        string
 69	Attachments []message.Attachment
 70}
 71
 72type highlightableMessageItem struct {
 73	startLine   int
 74	startCol    int
 75	endLine     int
 76	endCol      int
 77	highlighter list.Highlighter
 78}
 79
 80var _ list.Highlightable = (*highlightableMessageItem)(nil)
 81
 82// isHighlighted returns true if the item has a highlight range set.
 83func (h *highlightableMessageItem) isHighlighted() bool {
 84	return h.startLine != -1 || h.endLine != -1
 85}
 86
 87// renderHighlighted highlights the content if necessary.
 88func (h *highlightableMessageItem) renderHighlighted(content string, width, height int) string {
 89	if !h.isHighlighted() {
 90		return content
 91	}
 92	area := image.Rect(0, 0, width, height)
 93	return list.Highlight(content, area, h.startLine, h.startCol, h.endLine, h.endCol, h.highlighter)
 94}
 95
 96// SetHighlight implements [MessageItem].
 97func (h *highlightableMessageItem) SetHighlight(startLine int, startCol int, endLine int, endCol int) {
 98	// Adjust columns for the style's left inset (border + padding) since we
 99	// highlight the content only.
100	offset := messageLeftPaddingTotal
101	h.startLine = startLine
102	h.startCol = max(0, startCol-offset)
103	h.endLine = endLine
104	if endCol >= 0 {
105		h.endCol = max(0, endCol-offset)
106	} else {
107		h.endCol = endCol
108	}
109}
110
111// Highlight implements [MessageItem].
112func (h *highlightableMessageItem) Highlight() (startLine int, startCol int, endLine int, endCol int) {
113	return h.startLine, h.startCol, h.endLine, h.endCol
114}
115
116func defaultHighlighter(sty *styles.Styles) *highlightableMessageItem {
117	return &highlightableMessageItem{
118		startLine:   -1,
119		startCol:    -1,
120		endLine:     -1,
121		endCol:      -1,
122		highlighter: list.ToHighlighter(sty.TextSelection),
123	}
124}
125
126// cachedMessageItem caches rendered message content to avoid re-rendering.
127//
128// This should be used by any message that can store a cached version of its render. e.x user,assistant... and so on
129//
130// THOUGHT(kujtim): we should consider if its efficient to store the render for different widths
131// the issue with that could be memory usage
132type cachedMessageItem struct {
133	// rendered is the cached rendered string
134	rendered string
135	// width and height are the dimensions of the cached render
136	width  int
137	height int
138}
139
140// getCachedRender returns the cached render if it exists for the given width.
141func (c *cachedMessageItem) getCachedRender(width int) (string, int, bool) {
142	if c.width == width && c.rendered != "" {
143		return c.rendered, c.height, true
144	}
145	return "", 0, false
146}
147
148// setCachedRender sets the cached render.
149func (c *cachedMessageItem) setCachedRender(rendered string, width, height int) {
150	c.rendered = rendered
151	c.width = width
152	c.height = height
153}
154
155// clearCache clears the cached render.
156func (c *cachedMessageItem) clearCache() {
157	c.rendered = ""
158	c.width = 0
159	c.height = 0
160}
161
162// focusableMessageItem is a base struct for message items that can be focused.
163type focusableMessageItem struct {
164	focused bool
165}
166
167// SetFocused implements MessageItem.
168func (f *focusableMessageItem) SetFocused(focused bool) {
169	f.focused = focused
170}
171
172// AssistantInfoID returns a stable ID for assistant info items.
173func AssistantInfoID(messageID string) string {
174	return fmt.Sprintf("%s:assistant-info", messageID)
175}
176
177// AssistantInfoItem renders model info and response time after assistant completes.
178type AssistantInfoItem struct {
179	*cachedMessageItem
180
181	id                  string
182	message             *message.Message
183	sty                 *styles.Styles
184	lastUserMessageTime time.Time
185}
186
187// NewAssistantInfoItem creates a new AssistantInfoItem.
188func NewAssistantInfoItem(sty *styles.Styles, message *message.Message, lastUserMessageTime time.Time) MessageItem {
189	return &AssistantInfoItem{
190		cachedMessageItem:   &cachedMessageItem{},
191		id:                  AssistantInfoID(message.ID),
192		message:             message,
193		sty:                 sty,
194		lastUserMessageTime: lastUserMessageTime,
195	}
196}
197
198// ID implements MessageItem.
199func (a *AssistantInfoItem) ID() string {
200	return a.id
201}
202
203// Render implements MessageItem.
204func (a *AssistantInfoItem) Render(width int) string {
205	innerWidth := max(0, width-messageLeftPaddingTotal)
206	content, _, ok := a.getCachedRender(innerWidth)
207	if !ok {
208		content = a.renderContent(innerWidth)
209		height := lipgloss.Height(content)
210		a.setCachedRender(content, innerWidth, height)
211	}
212
213	return a.sty.Chat.Message.SectionHeader.Render(content)
214}
215
216func (a *AssistantInfoItem) renderContent(width int) string {
217	finishData := a.message.FinishPart()
218	if finishData == nil {
219		return ""
220	}
221	finishTime := time.Unix(finishData.Time, 0)
222	duration := finishTime.Sub(a.lastUserMessageTime)
223	infoMsg := a.sty.Chat.Message.AssistantInfoDuration.Render(duration.String())
224	icon := a.sty.Chat.Message.AssistantInfoIcon.Render(styles.ModelIcon)
225	model := config.Get().GetModel(a.message.Provider, a.message.Model)
226	if model == nil {
227		model = &catwalk.Model{Name: "Unknown Model"}
228	}
229	modelFormatted := a.sty.Chat.Message.AssistantInfoModel.Render(model.Name)
230	providerName := a.message.Provider
231	if providerConfig, ok := config.Get().Providers.Get(a.message.Provider); ok {
232		providerName = providerConfig.Name
233	}
234	provider := a.sty.Chat.Message.AssistantInfoProvider.Render(fmt.Sprintf("via %s", providerName))
235	assistant := fmt.Sprintf("%s %s %s %s", icon, modelFormatted, provider, infoMsg)
236	return common.Section(a.sty, assistant, width)
237}
238
239// cappedMessageWidth returns the maximum width for message content for readability.
240func cappedMessageWidth(availableWidth int) int {
241	return min(availableWidth-messageLeftPaddingTotal, maxTextWidth)
242}
243
244// ExtractMessageItems extracts [MessageItem]s from a [message.Message]. It
245// returns all parts of the message as [MessageItem]s.
246//
247// For assistant messages with tool calls, pass a toolResults map to link results.
248// Use BuildToolResultMap to create this map from all messages in a session.
249func ExtractMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[string]message.ToolResult) []MessageItem {
250	switch msg.Role {
251	case message.User:
252		r := attachments.NewRenderer(
253			sty.Attachments.Normal,
254			sty.Attachments.Deleting,
255			sty.Attachments.Image,
256			sty.Attachments.Text,
257		)
258		return []MessageItem{NewUserMessageItem(sty, msg, r)}
259	case message.Assistant:
260		var items []MessageItem
261		if ShouldRenderAssistantMessage(msg) {
262			items = append(items, NewAssistantMessageItem(sty, msg))
263		}
264		for _, tc := range msg.ToolCalls() {
265			var result *message.ToolResult
266			if tr, ok := toolResults[tc.ID]; ok {
267				result = &tr
268			}
269			items = append(items, NewToolMessageItem(
270				sty,
271				msg.ID,
272				tc,
273				result,
274				msg.FinishReason() == message.FinishReasonCanceled,
275			))
276		}
277		return items
278	}
279	return []MessageItem{}
280}
281
282// ShouldRenderAssistantMessage determines if an assistant message should be rendered
283//
284// In some cases the assistant message only has tools so we do not want to render an
285// empty message.
286func ShouldRenderAssistantMessage(msg *message.Message) bool {
287	content := strings.TrimSpace(msg.Content().Text)
288	thinking := strings.TrimSpace(msg.ReasoningContent().Thinking)
289	isError := msg.FinishReason() == message.FinishReasonError
290	isCancelled := msg.FinishReason() == message.FinishReasonCanceled
291	hasToolCalls := len(msg.ToolCalls()) > 0
292	return !hasToolCalls || content != "" || thinking != "" || msg.IsThinking() || isError || isCancelled
293}
294
295// BuildToolResultMap creates a map of tool call IDs to their results from a list of messages.
296// Tool result messages (role == message.Tool) contain the results that should be linked
297// to tool calls in assistant messages.
298func BuildToolResultMap(messages []*message.Message) map[string]message.ToolResult {
299	resultMap := make(map[string]message.ToolResult)
300	for _, msg := range messages {
301		if msg.Role == message.Tool {
302			for _, result := range msg.ToolResults() {
303				if result.ToolCallID != "" {
304					resultMap[result.ToolCallID] = result
305				}
306			}
307		}
308	}
309	return resultMap
310}