1package chat
2
3import (
4 "strings"
5
6 tea "charm.land/bubbletea/v2"
7 "charm.land/lipgloss/v2"
8 "github.com/charmbracelet/crush/internal/message"
9 "github.com/charmbracelet/crush/internal/ui/attachments"
10 "github.com/charmbracelet/crush/internal/ui/common"
11 "github.com/charmbracelet/crush/internal/ui/list"
12 "github.com/charmbracelet/crush/internal/ui/styles"
13)
14
15// UserMessageItem represents a user message in the chat UI.
16type UserMessageItem struct {
17 *list.Versioned
18 *highlightableMessageItem
19 *cachedMessageItem
20 *focusableMessageItem
21
22 attachments *attachments.Renderer
23 message *message.Message
24 sty *styles.Styles
25}
26
27// NewUserMessageItem creates a new UserMessageItem.
28func NewUserMessageItem(sty *styles.Styles, message *message.Message, attachments *attachments.Renderer) MessageItem {
29 v := list.NewVersioned()
30 return &UserMessageItem{
31 Versioned: v,
32 highlightableMessageItem: defaultHighlighter(sty, v),
33 cachedMessageItem: &cachedMessageItem{},
34 focusableMessageItem: newFocusableMessageItem(v),
35 attachments: attachments,
36 message: message,
37 sty: sty,
38 }
39}
40
41// Finished implements list.Item. User messages are immutable once
42// submitted, so the entry is always safe to freeze.
43func (m *UserMessageItem) Finished() bool {
44 return true
45}
46
47// RawRender implements [MessageItem].
48func (m *UserMessageItem) RawRender(width int) string {
49 cappedWidth := cappedMessageWidth(width)
50
51 content, height, ok := m.getCachedRender(cappedWidth)
52 // cache hit
53 if ok {
54 return m.renderHighlighted(content, cappedWidth, height)
55 }
56
57 renderer := common.MarkdownRenderer(m.sty, cappedWidth)
58
59 msgContent := strings.TrimSpace(m.message.Content().Text)
60 result, err := renderer.Render(msgContent)
61 if err != nil {
62 content = msgContent
63 } else {
64 content = strings.TrimSuffix(result, "\n")
65 }
66
67 if len(m.message.BinaryContent()) > 0 {
68 attachmentsStr := m.renderAttachments(cappedWidth)
69 if content == "" {
70 content = attachmentsStr
71 } else {
72 content = strings.Join([]string{content, "", attachmentsStr}, "\n")
73 }
74 }
75
76 height = lipgloss.Height(content)
77 m.setCachedRender(content, cappedWidth, height)
78 return m.renderHighlighted(content, cappedWidth, height)
79}
80
81// Render implements MessageItem.
82func (m *UserMessageItem) Render(width int) string {
83 // Bypass the prefix cache while a highlight range is active so
84 // selection drags reflect immediately without invalidating the
85 // cache. Highlight changes are intentionally applied "above" the
86 // prefix cache.
87 useCache := !m.isHighlighted()
88 var key uint64
89 if m.focused {
90 key = 1
91 }
92 if useCache {
93 if cached, ok := m.getCachedPrefixedRender(width, key); ok {
94 return cached
95 }
96 }
97 var prefix string
98 if m.focused {
99 prefix = m.sty.Messages.UserFocused.Render()
100 } else {
101 prefix = m.sty.Messages.UserBlurred.Render()
102 }
103 lines := strings.Split(m.RawRender(width), "\n")
104 for i, line := range lines {
105 lines[i] = prefix + line
106 }
107 out := strings.Join(lines, "\n")
108 if useCache {
109 m.setCachedPrefixedRender(out, width, key)
110 }
111 return out
112}
113
114// ID implements MessageItem.
115func (m *UserMessageItem) ID() string {
116 return m.message.ID
117}
118
119// renderAttachments renders attachments.
120func (m *UserMessageItem) renderAttachments(width int) string {
121 var attachments []message.Attachment
122 for _, at := range m.message.BinaryContent() {
123 attachments = append(attachments, message.Attachment{
124 FileName: at.Path,
125 MimeType: at.MIMEType,
126 })
127 }
128 return m.attachments.Render(attachments, false, width)
129}
130
131// HandleKeyEvent implements KeyEventHandler.
132func (m *UserMessageItem) HandleKeyEvent(key tea.KeyMsg) (bool, tea.Cmd) {
133 if k := key.String(); k == "c" || k == "y" {
134 text := m.message.Content().Text
135 return true, common.CopyToClipboard(text, "Message copied to clipboard")
136 }
137 return false, nil
138}