1package chat
2
3import (
4 "encoding/xml"
5 "strings"
6
7 tea "charm.land/bubbletea/v2"
8 "charm.land/lipgloss/v2"
9 "github.com/charmbracelet/crush/internal/message"
10 "github.com/charmbracelet/crush/internal/ui/attachments"
11 "github.com/charmbracelet/crush/internal/ui/common"
12 "github.com/charmbracelet/crush/internal/ui/list"
13 "github.com/charmbracelet/crush/internal/ui/styles"
14)
15
16// skillInvocation represents the XML structure for a loaded skill.
17type skillInvocation struct {
18 Name string `xml:"name"`
19 Description string `xml:"description"`
20 Location string `xml:"location"`
21 Instructions string `xml:"instructions"`
22}
23
24// UserMessageItem represents a user message in the chat UI.
25type UserMessageItem struct {
26 *list.Versioned
27 *highlightableMessageItem
28 *cachedMessageItem
29 *focusableMessageItem
30
31 attachments *attachments.Renderer
32 message *message.Message
33 sty *styles.Styles
34}
35
36// NewUserMessageItem creates a new UserMessageItem.
37func NewUserMessageItem(sty *styles.Styles, message *message.Message, attachments *attachments.Renderer) MessageItem {
38 v := list.NewVersioned()
39 return &UserMessageItem{
40 Versioned: v,
41 highlightableMessageItem: defaultHighlighter(sty, v),
42 cachedMessageItem: &cachedMessageItem{},
43 focusableMessageItem: newFocusableMessageItem(v),
44 attachments: attachments,
45 message: message,
46 sty: sty,
47 }
48}
49
50// Finished implements list.Item. User messages are immutable once
51// submitted, so the entry is always safe to freeze.
52func (m *UserMessageItem) Finished() bool {
53 return true
54}
55
56// RawRender implements [MessageItem].
57func (m *UserMessageItem) RawRender(width int) string {
58 cappedWidth := cappedMessageWidth(width)
59
60 content, height, ok := m.getCachedRender(cappedWidth)
61 // cache hit
62 if ok {
63 return m.renderHighlighted(content, cappedWidth, height)
64 }
65
66 msgContent := strings.TrimSpace(m.message.Content().Text)
67
68 // Check if this is a skill invocation (loaded_skill XML)
69 if strings.HasPrefix(msgContent, "<loaded_skill>") {
70 content = m.renderSkillInvocation(msgContent, cappedWidth)
71 height = lipgloss.Height(content)
72 m.setCachedRender(content, cappedWidth, height)
73 return m.renderHighlighted(content, cappedWidth, height)
74 }
75
76 renderer := common.MarkdownRenderer(m.sty, cappedWidth)
77 mu := common.LockMarkdownRenderer(renderer)
78
79 mu.Lock()
80 result, err := renderer.Render(msgContent)
81 mu.Unlock()
82
83 if err != nil {
84 content = msgContent
85 } else {
86 content = strings.TrimSuffix(result, "\n")
87 }
88
89 if len(m.message.BinaryContent()) > 0 {
90 attachmentsStr := m.renderAttachments(cappedWidth)
91 if content == "" {
92 content = attachmentsStr
93 } else {
94 content = strings.Join([]string{content, "", attachmentsStr}, "\n")
95 }
96 }
97
98 height = lipgloss.Height(content)
99 m.setCachedRender(content, cappedWidth, height)
100 return m.renderHighlighted(content, cappedWidth, height)
101}
102
103// renderSkillInvocation renders a loaded_skill XML as a special UI element.
104func (m *UserMessageItem) renderSkillInvocation(content string, width int) string {
105 var skill skillInvocation
106 if err := xml.Unmarshal([]byte(content), &skill); err != nil {
107 // If parsing fails, just render as markdown
108 renderer := common.MarkdownRenderer(m.sty, width)
109 mu := common.LockMarkdownRenderer(renderer)
110
111 mu.Lock()
112 result, err := renderer.Render(content)
113 mu.Unlock()
114
115 if err != nil {
116 return content
117 }
118 return strings.TrimSuffix(result, "\n")
119 }
120
121 return toolOutputSkillContent(m.sty, skill.Name, skill.Description)
122}
123
124// Render implements MessageItem.
125func (m *UserMessageItem) Render(width int) string {
126 // Bypass the prefix cache while a highlight range is active so
127 // selection drags reflect immediately without invalidating the
128 // cache. Highlight changes are intentionally applied "above" the
129 // prefix cache.
130 useCache := !m.isHighlighted()
131 var key uint64
132 if m.focused {
133 key = 1
134 }
135 if useCache {
136 if cached, ok := m.getCachedPrefixedRender(width, key); ok {
137 return cached
138 }
139 }
140 var prefix string
141 if m.focused {
142 prefix = m.sty.Messages.UserFocused.Render()
143 } else {
144 prefix = m.sty.Messages.UserBlurred.Render()
145 }
146 lines := strings.Split(m.RawRender(width), "\n")
147 for i, line := range lines {
148 lines[i] = prefix + line
149 }
150 out := strings.Join(lines, "\n")
151 if useCache {
152 m.setCachedPrefixedRender(out, width, key)
153 }
154 return out
155}
156
157// ID implements MessageItem.
158func (m *UserMessageItem) ID() string {
159 return m.message.ID
160}
161
162// renderAttachments renders attachments.
163func (m *UserMessageItem) renderAttachments(width int) string {
164 var attachments []message.Attachment
165 for _, at := range m.message.BinaryContent() {
166 attachments = append(attachments, message.Attachment{
167 FileName: at.Path,
168 MimeType: at.MIMEType,
169 })
170 }
171 return m.attachments.Render(attachments, false, width)
172}
173
174// HandleKeyEvent implements KeyEventHandler.
175func (m *UserMessageItem) HandleKeyEvent(key tea.KeyMsg) (bool, tea.Cmd) {
176 if k := key.String(); k == "c" || k == "y" {
177 text := m.message.Content().Text
178 return true, common.CopyToClipboard(text, "Message copied to clipboard")
179 }
180 return false, nil
181}