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