user.go

  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}