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