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
80// isHighlighted returns true if the item has a highlight range set.
81func (h *highlightableMessageItem) isHighlighted() bool {
82 return h.startLine != -1 || h.endLine != -1
83}
84
85// renderHighlighted highlights the content if necessary.
86func (h *highlightableMessageItem) renderHighlighted(content string, width, height int) string {
87 if !h.isHighlighted() {
88 return content
89 }
90 area := image.Rect(0, 0, width, height)
91 return list.Highlight(content, area, h.startLine, h.startCol, h.endLine, h.endCol, h.highlighter)
92}
93
94// Highlight implements MessageItem.
95func (h *highlightableMessageItem) Highlight(startLine int, startCol int, endLine int, endCol int) {
96 // Adjust columns for the style's left inset (border + padding) since we
97 // highlight the content only.
98 offset := messageLeftPaddingTotal
99 h.startLine = startLine
100 h.startCol = max(0, startCol-offset)
101 h.endLine = endLine
102 if endCol >= 0 {
103 h.endCol = max(0, endCol-offset)
104 } else {
105 h.endCol = endCol
106 }
107}
108
109func defaultHighlighter(sty *styles.Styles) *highlightableMessageItem {
110 return &highlightableMessageItem{
111 startLine: -1,
112 startCol: -1,
113 endLine: -1,
114 endCol: -1,
115 highlighter: list.ToHighlighter(sty.TextSelection),
116 }
117}
118
119// cachedMessageItem caches rendered message content to avoid re-rendering.
120//
121// This should be used by any message that can store a cached version of its render. e.x user,assistant... and so on
122//
123// THOUGHT(kujtim): we should consider if its efficient to store the render for different widths
124// the issue with that could be memory usage
125type cachedMessageItem struct {
126 // rendered is the cached rendered string
127 rendered string
128 // width and height are the dimensions of the cached render
129 width int
130 height int
131}
132
133// getCachedRender returns the cached render if it exists for the given width.
134func (c *cachedMessageItem) getCachedRender(width int) (string, int, bool) {
135 if c.width == width && c.rendered != "" {
136 return c.rendered, c.height, true
137 }
138 return "", 0, false
139}
140
141// setCachedRender sets the cached render.
142func (c *cachedMessageItem) setCachedRender(rendered string, width, height int) {
143 c.rendered = rendered
144 c.width = width
145 c.height = height
146}
147
148// clearCache clears the cached render.
149func (c *cachedMessageItem) clearCache() {
150 c.rendered = ""
151 c.width = 0
152 c.height = 0
153}
154
155// focusableMessageItem is a base struct for message items that can be focused.
156type focusableMessageItem struct {
157 focused bool
158}
159
160// SetFocused implements MessageItem.
161func (f *focusableMessageItem) SetFocused(focused bool) {
162 f.focused = focused
163}
164
165// AssistantInfoID returns a stable ID for assistant info items.
166func AssistantInfoID(messageID string) string {
167 return fmt.Sprintf("%s:assistant-info", messageID)
168}
169
170// AssistantInfoItem renders model info and response time after assistant completes.
171type AssistantInfoItem struct {
172 *cachedMessageItem
173
174 id string
175 message *message.Message
176 sty *styles.Styles
177 lastUserMessageTime time.Time
178}
179
180// NewAssistantInfoItem creates a new AssistantInfoItem.
181func NewAssistantInfoItem(sty *styles.Styles, message *message.Message, lastUserMessageTime time.Time) MessageItem {
182 return &AssistantInfoItem{
183 cachedMessageItem: &cachedMessageItem{},
184 id: AssistantInfoID(message.ID),
185 message: message,
186 sty: sty,
187 lastUserMessageTime: lastUserMessageTime,
188 }
189}
190
191// ID implements MessageItem.
192func (a *AssistantInfoItem) ID() string {
193 return a.id
194}
195
196// Render implements MessageItem.
197func (a *AssistantInfoItem) Render(width int) string {
198 innerWidth := max(0, width-messageLeftPaddingTotal)
199 content, _, ok := a.getCachedRender(innerWidth)
200 if !ok {
201 content = a.renderContent(innerWidth)
202 height := lipgloss.Height(content)
203 a.setCachedRender(content, innerWidth, height)
204 }
205
206 return a.sty.Chat.Message.SectionHeader.Render(content)
207}
208
209func (a *AssistantInfoItem) renderContent(width int) string {
210 finishData := a.message.FinishPart()
211 if finishData == nil {
212 return ""
213 }
214 finishTime := time.Unix(finishData.Time, 0)
215 duration := finishTime.Sub(a.lastUserMessageTime)
216 infoMsg := a.sty.Chat.Message.AssistantInfoDuration.Render(duration.String())
217 icon := a.sty.Chat.Message.AssistantInfoIcon.Render(styles.ModelIcon)
218 model := config.Get().GetModel(a.message.Provider, a.message.Model)
219 if model == nil {
220 model = &catwalk.Model{Name: "Unknown Model"}
221 }
222 modelFormatted := a.sty.Chat.Message.AssistantInfoModel.Render(model.Name)
223 providerName := a.message.Provider
224 if providerConfig, ok := config.Get().Providers.Get(a.message.Provider); ok {
225 providerName = providerConfig.Name
226 }
227 provider := a.sty.Chat.Message.AssistantInfoProvider.Render(fmt.Sprintf("via %s", providerName))
228 assistant := fmt.Sprintf("%s %s %s %s", icon, modelFormatted, provider, infoMsg)
229 return common.Section(a.sty, assistant, width)
230}
231
232// cappedMessageWidth returns the maximum width for message content for readability.
233func cappedMessageWidth(availableWidth int) int {
234 return min(availableWidth-messageLeftPaddingTotal, maxTextWidth)
235}
236
237// ExtractMessageItems extracts [MessageItem]s from a [message.Message]. It
238// returns all parts of the message as [MessageItem]s.
239//
240// For assistant messages with tool calls, pass a toolResults map to link results.
241// Use BuildToolResultMap to create this map from all messages in a session.
242func ExtractMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[string]message.ToolResult) []MessageItem {
243 switch msg.Role {
244 case message.User:
245 r := attachments.NewRenderer(
246 sty.Attachments.Normal,
247 sty.Attachments.Deleting,
248 sty.Attachments.Image,
249 sty.Attachments.Text,
250 )
251 return []MessageItem{NewUserMessageItem(sty, msg, r)}
252 case message.Assistant:
253 var items []MessageItem
254 if ShouldRenderAssistantMessage(msg) {
255 items = append(items, NewAssistantMessageItem(sty, msg))
256 }
257 for _, tc := range msg.ToolCalls() {
258 var result *message.ToolResult
259 if tr, ok := toolResults[tc.ID]; ok {
260 result = &tr
261 }
262 items = append(items, NewToolMessageItem(
263 sty,
264 msg.ID,
265 tc,
266 result,
267 msg.FinishReason() == message.FinishReasonCanceled,
268 ))
269 }
270 return items
271 }
272 return []MessageItem{}
273}
274
275// ShouldRenderAssistantMessage determines if an assistant message should be rendered
276//
277// In some cases the assistant message only has tools so we do not want to render an
278// empty message.
279func ShouldRenderAssistantMessage(msg *message.Message) bool {
280 content := strings.TrimSpace(msg.Content().Text)
281 thinking := strings.TrimSpace(msg.ReasoningContent().Thinking)
282 isError := msg.FinishReason() == message.FinishReasonError
283 isCancelled := msg.FinishReason() == message.FinishReasonCanceled
284 hasToolCalls := len(msg.ToolCalls()) > 0
285 return !hasToolCalls || content != "" || thinking != "" || msg.IsThinking() || isError || isCancelled
286}
287
288// BuildToolResultMap creates a map of tool call IDs to their results from a list of messages.
289// Tool result messages (role == message.Tool) contain the results that should be linked
290// to tool calls in assistant messages.
291func BuildToolResultMap(messages []*message.Message) map[string]message.ToolResult {
292 resultMap := make(map[string]message.ToolResult)
293 for _, msg := range messages {
294 if msg.Role == message.Tool {
295 for _, result := range msg.ToolResults() {
296 if result.ToolCallID != "" {
297 resultMap[result.ToolCallID] = result
298 }
299 }
300 }
301 }
302 return resultMap
303}