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