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/attachments"
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// ExtractMessageItems extracts [MessageItem]s from a [message.Message]. It
155// returns 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 ExtractMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[string]message.ToolResult) []MessageItem {
160 switch msg.Role {
161 case message.User:
162 r := attachments.NewRenderer(
163 sty.Attachments.Normal,
164 sty.Attachments.Deleting,
165 sty.Attachments.Image,
166 sty.Attachments.Text,
167 )
168 return []MessageItem{NewUserMessageItem(sty, msg, r)}
169 case message.Assistant:
170 var items []MessageItem
171 if ShouldRenderAssistantMessage(msg) {
172 items = append(items, NewAssistantMessageItem(sty, msg))
173 }
174 for _, tc := range msg.ToolCalls() {
175 var result *message.ToolResult
176 if tr, ok := toolResults[tc.ID]; ok {
177 result = &tr
178 }
179 items = append(items, NewToolMessageItem(
180 sty,
181 msg.ID,
182 tc,
183 result,
184 msg.FinishReason() == message.FinishReasonCanceled,
185 ))
186 }
187 return items
188 }
189 return []MessageItem{}
190}
191
192// ShouldRenderAssistantMessage determines if an assistant message should be rendered
193//
194// In some cases the assistant message only has tools so we do not want to render an
195// empty message.
196func ShouldRenderAssistantMessage(msg *message.Message) bool {
197 content := strings.TrimSpace(msg.Content().Text)
198 thinking := strings.TrimSpace(msg.ReasoningContent().Thinking)
199 isError := msg.FinishReason() == message.FinishReasonError
200 isCancelled := msg.FinishReason() == message.FinishReasonCanceled
201 hasToolCalls := len(msg.ToolCalls()) > 0
202 return !hasToolCalls || content != "" || thinking != "" || msg.IsThinking() || isError || isCancelled
203}
204
205// BuildToolResultMap creates a map of tool call IDs to their results from a list of messages.
206// Tool result messages (role == message.Tool) contain the results that should be linked
207// to tool calls in assistant messages.
208func BuildToolResultMap(messages []*message.Message) map[string]message.ToolResult {
209 resultMap := make(map[string]message.ToolResult)
210 for _, msg := range messages {
211 if msg.Role == message.Tool {
212 for _, result := range msg.ToolResults() {
213 if result.ToolCallID != "" {
214 resultMap[result.ToolCallID] = result
215 }
216 }
217 }
218 }
219 return resultMap
220}