1package chat
2
3import (
4 "fmt"
5 "image"
6 "strings"
7 "time"
8
9 tea "charm.land/bubbletea/v2"
10 "charm.land/catwalk/pkg/catwalk"
11 "charm.land/lipgloss/v2"
12 "github.com/charmbracelet/crush/internal/config"
13 "github.com/charmbracelet/crush/internal/message"
14 "github.com/charmbracelet/crush/internal/ui/anim"
15 "github.com/charmbracelet/crush/internal/ui/attachments"
16 "github.com/charmbracelet/crush/internal/ui/common"
17 "github.com/charmbracelet/crush/internal/ui/list"
18 "github.com/charmbracelet/crush/internal/ui/styles"
19)
20
21// MessageLeftPaddingTotal is the total width that is taken up by the border +
22// padding. We also cap the width so text is readable to the maxTextWidth(120).
23const MessageLeftPaddingTotal = 2
24
25// maxTextWidth is the maximum width text messages can be
26const maxTextWidth = 120
27
28// Identifiable is an interface for items that can provide a unique identifier.
29type Identifiable interface {
30 ID() string
31}
32
33// Animatable is an interface for items that support animation.
34type Animatable interface {
35 StartAnimation() tea.Cmd
36 Animate(msg anim.StepMsg) tea.Cmd
37}
38
39// Expandable is an interface for items that can be expanded or collapsed.
40type Expandable interface {
41 // ToggleExpanded toggles the expanded state of the item. It returns
42 // whether the item is now expanded.
43 ToggleExpanded() bool
44}
45
46// KeyEventHandler is an interface for items that can handle key events.
47type KeyEventHandler interface {
48 HandleKeyEvent(key tea.KeyMsg) (bool, tea.Cmd)
49}
50
51// MessageItem represents a [message.Message] item that can be displayed in the
52// UI and be part of a [list.List] identifiable by a unique ID.
53type MessageItem interface {
54 list.Item
55 list.RawRenderable
56 Identifiable
57}
58
59// HighlightableMessageItem is a message item that supports highlighting.
60type HighlightableMessageItem interface {
61 MessageItem
62 list.Highlightable
63}
64
65// FocusableMessageItem is a message item that supports focus.
66type FocusableMessageItem interface {
67 MessageItem
68 list.Focusable
69}
70
71// SendMsg represents a message to send a chat message.
72type SendMsg struct {
73 Text string
74 Attachments []message.Attachment
75}
76
77type highlightableMessageItem struct {
78 startLine int
79 startCol int
80 endLine int
81 endCol int
82 highlighter list.Highlighter
83}
84
85var _ list.Highlightable = (*highlightableMessageItem)(nil)
86
87// isHighlighted returns true if the item has a highlight range set.
88func (h *highlightableMessageItem) isHighlighted() bool {
89 return h.startLine != -1 || h.endLine != -1
90}
91
92// renderHighlighted highlights the content if necessary.
93func (h *highlightableMessageItem) renderHighlighted(content string, width, height int) string {
94 if !h.isHighlighted() {
95 return content
96 }
97 area := image.Rect(0, 0, width, height)
98 return list.Highlight(content, area, h.startLine, h.startCol, h.endLine, h.endCol, h.highlighter)
99}
100
101// SetHighlight implements list.Highlightable.
102func (h *highlightableMessageItem) SetHighlight(startLine int, startCol int, endLine int, endCol int) {
103 // Adjust columns for the style's left inset (border + padding) since we
104 // highlight the content only.
105 offset := MessageLeftPaddingTotal
106 h.startLine = startLine
107 h.startCol = max(0, startCol-offset)
108 h.endLine = endLine
109 if endCol >= 0 {
110 h.endCol = max(0, endCol-offset)
111 } else {
112 h.endCol = endCol
113 }
114}
115
116// Highlight implements list.Highlightable.
117func (h *highlightableMessageItem) Highlight() (startLine int, startCol int, endLine int, endCol int) {
118 return h.startLine, h.startCol, h.endLine, h.endCol
119}
120
121func defaultHighlighter(sty *styles.Styles) *highlightableMessageItem {
122 return &highlightableMessageItem{
123 startLine: -1,
124 startCol: -1,
125 endLine: -1,
126 endCol: -1,
127 highlighter: list.ToHighlighter(sty.TextSelection),
128 }
129}
130
131// cacheClearable is implemented by message items that cache rendered
132// output and can be asked to drop the cache.
133type cacheClearable interface {
134 clearCache()
135}
136
137// ClearItemCaches drops any cached rendered output on each item so the
138// next render uses the current styles.
139func ClearItemCaches(items []MessageItem) {
140 for _, item := range items {
141 if cc, ok := item.(cacheClearable); ok {
142 cc.clearCache()
143 }
144 }
145}
146
147// cachedMessageItem caches rendered message content to avoid re-rendering.
148//
149// This should be used by any message that can store a cached version of its render. e.x user,assistant... and so on
150//
151// THOUGHT(kujtim): we should consider if its efficient to store the render for different widths
152// the issue with that could be memory usage
153type cachedMessageItem struct {
154 // rendered is the cached rendered string
155 rendered string
156 // width and height are the dimensions of the cached render
157 width int
158 height int
159}
160
161// getCachedRender returns the cached render if it exists for the given width.
162func (c *cachedMessageItem) getCachedRender(width int) (string, int, bool) {
163 if c.width == width && c.rendered != "" {
164 return c.rendered, c.height, true
165 }
166 return "", 0, false
167}
168
169// setCachedRender sets the cached render.
170func (c *cachedMessageItem) setCachedRender(rendered string, width, height int) {
171 c.rendered = rendered
172 c.width = width
173 c.height = height
174}
175
176// clearCache clears the cached render.
177func (c *cachedMessageItem) clearCache() {
178 c.rendered = ""
179 c.width = 0
180 c.height = 0
181}
182
183// focusableMessageItem is a base struct for message items that can be focused.
184type focusableMessageItem struct {
185 focused bool
186}
187
188// SetFocused implements MessageItem.
189func (f *focusableMessageItem) SetFocused(focused bool) {
190 f.focused = focused
191}
192
193// AssistantInfoID returns a stable ID for assistant info items.
194func AssistantInfoID(messageID string) string {
195 return fmt.Sprintf("%s:assistant-info", messageID)
196}
197
198// AssistantInfoItem renders model info and response time after assistant completes.
199type AssistantInfoItem struct {
200 *cachedMessageItem
201
202 id string
203 message *message.Message
204 sty *styles.Styles
205 cfg *config.Config
206 lastUserMessageTime time.Time
207}
208
209// NewAssistantInfoItem creates a new AssistantInfoItem.
210func NewAssistantInfoItem(sty *styles.Styles, message *message.Message, cfg *config.Config, lastUserMessageTime time.Time) MessageItem {
211 return &AssistantInfoItem{
212 cachedMessageItem: &cachedMessageItem{},
213 id: AssistantInfoID(message.ID),
214 message: message,
215 sty: sty,
216 cfg: cfg,
217 lastUserMessageTime: lastUserMessageTime,
218 }
219}
220
221// ID implements MessageItem.
222func (a *AssistantInfoItem) ID() string {
223 return a.id
224}
225
226// RawRender implements MessageItem.
227func (a *AssistantInfoItem) RawRender(width int) string {
228 innerWidth := max(0, width-MessageLeftPaddingTotal)
229 content, _, ok := a.getCachedRender(innerWidth)
230 if !ok {
231 content = a.renderContent(innerWidth)
232 height := lipgloss.Height(content)
233 a.setCachedRender(content, innerWidth, height)
234 }
235 return content
236}
237
238// Render implements MessageItem.
239func (a *AssistantInfoItem) Render(width int) string {
240 prefix := a.sty.Messages.SectionHeader.Render()
241 lines := strings.Split(a.RawRender(width), "\n")
242 for i, line := range lines {
243 lines[i] = prefix + line
244 }
245 return strings.Join(lines, "\n")
246}
247
248func (a *AssistantInfoItem) renderContent(width int) string {
249 finishData := a.message.FinishPart()
250 if finishData == nil {
251 return ""
252 }
253 finishTime := time.Unix(finishData.Time, 0)
254 duration := finishTime.Sub(a.lastUserMessageTime)
255 infoMsg := a.sty.Messages.AssistantInfoDuration.Render(duration.String())
256 icon := a.sty.Messages.AssistantInfoIcon.Render(styles.ModelIcon)
257 model := a.cfg.GetModel(a.message.Provider, a.message.Model)
258 if model == nil {
259 model = &catwalk.Model{Name: "Unknown Model"}
260 }
261 modelFormatted := a.sty.Messages.AssistantInfoModel.Render(model.Name)
262 providerName := a.message.Provider
263 if providerConfig, ok := a.cfg.Providers.Get(a.message.Provider); ok {
264 providerName = providerConfig.Name
265 }
266 provider := a.sty.Messages.AssistantInfoProvider.Render(fmt.Sprintf("via %s", providerName))
267 assistant := fmt.Sprintf("%s %s %s %s", icon, modelFormatted, provider, infoMsg)
268 return common.Section(a.sty, assistant, width)
269}
270
271// cappedMessageWidth returns the maximum width for message content for readability.
272func cappedMessageWidth(availableWidth int) int {
273 return min(availableWidth-MessageLeftPaddingTotal, maxTextWidth)
274}
275
276// ExtractMessageItems extracts [MessageItem]s from a [message.Message]. It
277// returns all parts of the message as [MessageItem]s.
278//
279// For assistant messages with tool calls, pass a toolResults map to link results.
280// Use BuildToolResultMap to create this map from all messages in a session.
281func ExtractMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[string]message.ToolResult) []MessageItem {
282 switch msg.Role {
283 case message.User:
284 r := attachments.NewRenderer(
285 sty.Attachments.Normal,
286 sty.Attachments.Deleting,
287 sty.Attachments.Image,
288 sty.Attachments.Text,
289 )
290 return []MessageItem{NewUserMessageItem(sty, msg, r)}
291 case message.Assistant:
292 var items []MessageItem
293 if ShouldRenderAssistantMessage(msg) {
294 items = append(items, NewAssistantMessageItem(sty, msg))
295 }
296 for _, tc := range msg.ToolCalls() {
297 var result *message.ToolResult
298 if tr, ok := toolResults[tc.ID]; ok {
299 result = &tr
300 }
301 items = append(items, NewToolMessageItem(
302 sty,
303 msg.ID,
304 tc,
305 result,
306 msg.FinishReason() == message.FinishReasonCanceled,
307 ))
308 }
309 return items
310 }
311 return []MessageItem{}
312}
313
314// ShouldRenderAssistantMessage determines if an assistant message should be rendered
315//
316// In some cases the assistant message only has tools so we do not want to render an
317// empty message.
318func ShouldRenderAssistantMessage(msg *message.Message) bool {
319 content := strings.TrimSpace(msg.Content().Text)
320 thinking := strings.TrimSpace(msg.ReasoningContent().Thinking)
321 isError := msg.FinishReason() == message.FinishReasonError
322 isCancelled := msg.FinishReason() == message.FinishReasonCanceled
323 hasToolCalls := len(msg.ToolCalls()) > 0
324 return !hasToolCalls || content != "" || thinking != "" || msg.IsThinking() || isError || isCancelled
325}
326
327// BuildToolResultMap creates a map of tool call IDs to their results from a list of messages.
328// Tool result messages (role == message.Tool) contain the results that should be linked
329// to tool calls in assistant messages.
330func BuildToolResultMap(messages []*message.Message) map[string]message.ToolResult {
331 resultMap := make(map[string]message.ToolResult)
332 for _, msg := range messages {
333 if msg.Role == message.Tool {
334 for _, result := range msg.ToolResults() {
335 if result.ToolCallID != "" {
336 resultMap[result.ToolCallID] = result
337 }
338 }
339 }
340 }
341 return resultMap
342}