assistant.go

  1package chat
  2
  3import (
  4	"fmt"
  5	"strings"
  6
  7	"charm.land/bubbles/v2/key"
  8	tea "charm.land/bubbletea/v2"
  9	"charm.land/lipgloss/v2"
 10	"github.com/charmbracelet/crush/internal/message"
 11	"github.com/charmbracelet/crush/internal/ui/common"
 12	"github.com/charmbracelet/crush/internal/ui/styles"
 13	"github.com/charmbracelet/x/ansi"
 14)
 15
 16const maxCollapsedThinkingHeight = 10
 17
 18// AssistantMessageItem represents an assistant message that can be displayed
 19// in the chat UI.
 20type AssistantMessageItem struct {
 21	id                string
 22	content           string
 23	thinking          string
 24	finished          bool
 25	finish            message.Finish
 26	sty               *styles.Styles
 27	thinkingExpanded  bool
 28	thinkingBoxHeight int // Tracks the rendered thinking box height for click detection.
 29}
 30
 31// NewAssistantMessage creates a new assistant message item.
 32func NewAssistantMessage(id, content, thinking string, finished bool, finish message.Finish, sty *styles.Styles) *AssistantMessageItem {
 33	return &AssistantMessageItem{
 34		id:       id,
 35		content:  content,
 36		thinking: thinking,
 37		finished: finished,
 38		finish:   finish,
 39		sty:      sty,
 40	}
 41}
 42
 43// ID implements Identifiable.
 44func (m *AssistantMessageItem) ID() string {
 45	return m.id
 46}
 47
 48// FocusStyle returns the focus style.
 49func (m *AssistantMessageItem) FocusStyle() lipgloss.Style {
 50	return m.sty.Chat.Message.AssistantFocused
 51}
 52
 53// BlurStyle returns the blur style.
 54func (m *AssistantMessageItem) BlurStyle() lipgloss.Style {
 55	return m.sty.Chat.Message.AssistantBlurred
 56}
 57
 58// HighlightStyle returns the highlight style.
 59func (m *AssistantMessageItem) HighlightStyle() lipgloss.Style {
 60	return m.sty.TextSelection
 61}
 62
 63// Render implements list.Item.
 64func (m *AssistantMessageItem) Render(width int) string {
 65	cappedWidth := min(width, maxTextWidth)
 66	content := strings.TrimSpace(m.content)
 67	thinking := strings.TrimSpace(m.thinking)
 68
 69	// Handle empty finished messages.
 70	if m.finished && content == "" {
 71		switch m.finish.Reason {
 72		case message.FinishReasonEndTurn:
 73			return ""
 74		case message.FinishReasonCanceled:
 75			return m.renderMarkdown("*Canceled*", cappedWidth)
 76		case message.FinishReasonError:
 77			return m.renderError(cappedWidth)
 78		}
 79	}
 80
 81	var parts []string
 82
 83	// Render thinking content if present.
 84	if thinking != "" {
 85		parts = append(parts, m.renderThinking(thinking, cappedWidth))
 86	}
 87
 88	// Render main content.
 89	if content != "" {
 90		if len(parts) > 0 {
 91			parts = append(parts, "")
 92		}
 93		parts = append(parts, m.renderMarkdown(content, cappedWidth))
 94	}
 95
 96	return lipgloss.JoinVertical(lipgloss.Left, parts...)
 97}
 98
 99// renderMarkdown renders content as markdown.
100func (m *AssistantMessageItem) renderMarkdown(content string, width int) string {
101	renderer := common.MarkdownRenderer(m.sty, width)
102	result, err := renderer.Render(content)
103	if err != nil {
104		return content
105	}
106	return strings.TrimSuffix(result, "\n")
107}
108
109// renderThinking renders the thinking/reasoning content.
110func (m *AssistantMessageItem) renderThinking(thinking string, width int) string {
111	renderer := common.PlainMarkdownRenderer(m.sty, width-2)
112	rendered, err := renderer.Render(thinking)
113	if err != nil {
114		rendered = thinking
115	}
116	rendered = strings.TrimSpace(rendered)
117
118	lines := strings.Split(rendered, "\n")
119	totalLines := len(lines)
120
121	// Collapse if not expanded and exceeds max height.
122	isTruncated := totalLines > maxCollapsedThinkingHeight
123	if !m.thinkingExpanded && isTruncated {
124		lines = lines[totalLines-maxCollapsedThinkingHeight:]
125	}
126
127	// Add hint if truncated and not expanded.
128	if !m.thinkingExpanded && isTruncated {
129		hint := m.sty.Muted.Render(fmt.Sprintf("… (%d lines hidden) [click or space to expand]", totalLines-maxCollapsedThinkingHeight))
130		lines = append([]string{hint}, lines...)
131	}
132
133	thinkingStyle := m.sty.Subtle.Background(m.sty.BgBaseLighter).Width(width)
134	result := thinkingStyle.Render(strings.Join(lines, "\n"))
135
136	// Track the rendered height for click detection.
137	m.thinkingBoxHeight = lipgloss.Height(result)
138
139	return result
140}
141
142// HandleMouseClick implements list.MouseClickable.
143func (m *AssistantMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
144	// Only handle left clicks.
145	if btn != ansi.MouseLeft {
146		return false
147	}
148
149	// Check if click is within the thinking box area.
150	if m.thinking != "" && y < m.thinkingBoxHeight {
151		m.thinkingExpanded = !m.thinkingExpanded
152		return true
153	}
154
155	return false
156}
157
158// HandleKeyPress implements list.KeyPressable.
159func (m *AssistantMessageItem) HandleKeyPress(msg tea.KeyPressMsg) bool {
160	// Only handle space key on thinking content.
161	if m.thinking == "" {
162		return false
163	}
164
165	if key.Matches(msg, key.NewBinding(key.WithKeys("space"))) {
166		// Toggle thinking expansion.
167		m.thinkingExpanded = !m.thinkingExpanded
168		return true
169	}
170
171	return false
172}
173
174// renderError renders an error message.
175func (m *AssistantMessageItem) renderError(width int) string {
176	errTag := m.sty.Chat.Message.ErrorTag.Render("ERROR")
177	truncated := ansi.Truncate(m.finish.Message, width-2-lipgloss.Width(errTag), "...")
178	title := fmt.Sprintf("%s %s", errTag, m.sty.Chat.Message.ErrorTitle.Render(truncated))
179	details := m.sty.Chat.Message.ErrorDetails.Width(width - 2).Render(m.finish.Details)
180	return fmt.Sprintf("%s\n\n%s", title, details)
181}