assistant.go

  1package chat
  2
  3import (
  4	"fmt"
  5	"strings"
  6	"time"
  7
  8	"charm.land/bubbles/v2/key"
  9	tea "charm.land/bubbletea/v2"
 10	"charm.land/lipgloss/v2"
 11	"github.com/charmbracelet/crush/internal/message"
 12	"github.com/charmbracelet/crush/internal/ui/common"
 13	"github.com/charmbracelet/crush/internal/ui/common/anim"
 14	"github.com/charmbracelet/crush/internal/ui/list"
 15	"github.com/charmbracelet/crush/internal/ui/styles"
 16	"github.com/charmbracelet/x/ansi"
 17)
 18
 19const maxCollapsedThinkingHeight = 10
 20
 21// AssistantMessageItem represents an assistant message that can be displayed
 22// in the chat UI.
 23type AssistantMessageItem struct {
 24	id                string
 25	content           string
 26	thinking          string
 27	finished          bool
 28	finish            message.Finish
 29	sty               *styles.Styles
 30	thinkingExpanded  bool
 31	thinkingBoxHeight int // Tracks the rendered thinking box height for click detection.
 32
 33	spinning         bool
 34	anim             *anim.Anim
 35	hasToolCalls     bool
 36	isSummaryMessage bool
 37
 38	thinkingStartedAt  int64
 39	thinkingFinishedAt int64
 40}
 41
 42// NewAssistantMessage creates a new assistant message item.
 43func NewAssistantMessage(id, content, thinking string, finished bool, finish message.Finish, hasToolCalls, isSummaryMessage bool, thinkingStartedAt, thinkingFinishedAt int64, sty *styles.Styles) *AssistantMessageItem {
 44	m := &AssistantMessageItem{
 45		id:                 id,
 46		content:            content,
 47		thinking:           thinking,
 48		finished:           finished,
 49		finish:             finish,
 50		hasToolCalls:       hasToolCalls,
 51		isSummaryMessage:   isSummaryMessage,
 52		thinkingStartedAt:  thinkingStartedAt,
 53		thinkingFinishedAt: thinkingFinishedAt,
 54		sty:                sty,
 55	}
 56
 57	m.anim = anim.New(anim.Settings{
 58		Size:        15,
 59		GradColorA:  sty.Primary,
 60		GradColorB:  sty.Secondary,
 61		LabelColor:  sty.FgBase,
 62		CycleColors: true,
 63	})
 64	m.spinning = m.shouldSpin()
 65
 66	return m
 67}
 68
 69// shouldSpin returns true if the message should show loading animation.
 70func (m *AssistantMessageItem) shouldSpin() bool {
 71	if m.finished {
 72		return false
 73	}
 74	if strings.TrimSpace(m.content) != "" {
 75		return false
 76	}
 77	if m.hasToolCalls {
 78		return false
 79	}
 80	return true
 81}
 82
 83// ID implements Identifiable.
 84func (m *AssistantMessageItem) ID() string {
 85	return m.id
 86}
 87
 88// FocusStyle returns the focus style.
 89func (m *AssistantMessageItem) FocusStyle() lipgloss.Style {
 90	return m.sty.Chat.Message.AssistantFocused
 91}
 92
 93// BlurStyle returns the blur style.
 94func (m *AssistantMessageItem) BlurStyle() lipgloss.Style {
 95	return m.sty.Chat.Message.AssistantBlurred
 96}
 97
 98// HighlightStyle returns the highlight style.
 99func (m *AssistantMessageItem) HighlightStyle() lipgloss.Style {
100	return m.sty.TextSelection
101}
102
103// Render implements list.Item.
104func (m *AssistantMessageItem) Render(width int) string {
105	if m.spinning && m.thinking == "" {
106		if m.isSummaryMessage {
107			m.anim.SetLabel("Summarizing")
108		}
109		return m.anim.View()
110	}
111
112	cappedWidth := min(width, maxTextWidth)
113	content := strings.TrimSpace(m.content)
114	thinking := strings.TrimSpace(m.thinking)
115
116	if m.finished && content == "" {
117		switch m.finish.Reason {
118		case message.FinishReasonEndTurn:
119			return ""
120		case message.FinishReasonCanceled:
121			return m.renderMarkdown("*Canceled*", cappedWidth)
122		case message.FinishReasonError:
123			return m.renderError(cappedWidth)
124		}
125	}
126
127	var parts []string
128	if thinking != "" {
129		parts = append(parts, m.renderThinking(thinking, cappedWidth))
130	}
131
132	if content != "" {
133		if len(parts) > 0 {
134			parts = append(parts, "")
135		}
136		parts = append(parts, m.renderMarkdown(content, cappedWidth))
137	}
138
139	return lipgloss.JoinVertical(lipgloss.Left, parts...)
140}
141
142// Update implements list.Updatable for handling animation updates.
143func (m *AssistantMessageItem) Update(msg tea.Msg) (list.Item, tea.Cmd) {
144	switch msg.(type) {
145	case anim.StepMsg:
146		m.spinning = m.shouldSpin()
147		if !m.spinning {
148			return m, nil
149		}
150		updatedAnim, cmd := m.anim.Update(msg)
151		m.anim = updatedAnim
152		if cmd != nil {
153			return m, cmd
154		}
155	}
156
157	return m, nil
158}
159
160// InitAnimation initializes and starts the animation.
161func (m *AssistantMessageItem) InitAnimation() tea.Cmd {
162	m.spinning = m.shouldSpin()
163	return m.anim.Init()
164}
165
166// SetContent updates the assistant message with new content.
167func (m *AssistantMessageItem) SetContent(content, thinking string, finished bool, finish *message.Finish, hasToolCalls, isSummaryMessage bool, reasoning message.ReasoningContent) {
168	m.content = content
169	m.thinking = thinking
170	m.finished = finished
171	if finish != nil {
172		m.finish = *finish
173	}
174	m.hasToolCalls = hasToolCalls
175	m.isSummaryMessage = isSummaryMessage
176	m.thinkingStartedAt = reasoning.StartedAt
177	m.thinkingFinishedAt = reasoning.FinishedAt
178	m.spinning = m.shouldSpin()
179}
180
181// renderMarkdown renders content as markdown.
182func (m *AssistantMessageItem) renderMarkdown(content string, width int) string {
183	renderer := common.MarkdownRenderer(m.sty, width)
184	result, err := renderer.Render(content)
185	if err != nil {
186		return content
187	}
188	return strings.TrimSuffix(result, "\n")
189}
190
191// renderThinking renders the thinking/reasoning content with footer.
192func (m *AssistantMessageItem) renderThinking(thinking string, width int) string {
193	renderer := common.PlainMarkdownRenderer(m.sty, width-2)
194	rendered, err := renderer.Render(thinking)
195	if err != nil {
196		rendered = thinking
197	}
198	rendered = strings.TrimSpace(rendered)
199
200	lines := strings.Split(rendered, "\n")
201	totalLines := len(lines)
202
203	isTruncated := totalLines > maxCollapsedThinkingHeight
204	if !m.thinkingExpanded && isTruncated {
205		lines = lines[totalLines-maxCollapsedThinkingHeight:]
206	}
207
208	if !m.thinkingExpanded && isTruncated {
209		hint := m.sty.Chat.Message.ThinkingTruncationHint.Render(
210			fmt.Sprintf("… (%d lines hidden) [click or space to expand]", totalLines-maxCollapsedThinkingHeight),
211		)
212		lines = append([]string{hint}, lines...)
213	}
214
215	thinkingStyle := m.sty.Chat.Message.ThinkingBox.Width(width)
216	result := thinkingStyle.Render(strings.Join(lines, "\n"))
217	m.thinkingBoxHeight = lipgloss.Height(result)
218
219	var footer string
220	if m.thinkingStartedAt > 0 {
221		if m.thinkingFinishedAt > 0 {
222			duration := time.Duration(m.thinkingFinishedAt-m.thinkingStartedAt) * time.Second
223			if duration.String() != "0s" {
224				footer = m.sty.Chat.Message.ThinkingFooterTitle.Render("Thought for ") +
225					m.sty.Chat.Message.ThinkingFooterDuration.Render(duration.String())
226			}
227		} else if m.finish.Reason == message.FinishReasonCanceled {
228			footer = m.sty.Chat.Message.ThinkingFooterCancelled.Render("Canceled")
229		} else {
230			m.anim.SetLabel("Thinking")
231			footer = m.anim.View()
232		}
233	}
234
235	if footer != "" {
236		result += "\n\n" + footer
237	}
238
239	return result
240}
241
242// HandleMouseClick implements list.MouseClickable.
243func (m *AssistantMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
244	if btn != ansi.MouseLeft {
245		return false
246	}
247
248	if m.thinking != "" && y < m.thinkingBoxHeight {
249		m.thinkingExpanded = !m.thinkingExpanded
250		return true
251	}
252
253	return false
254}
255
256// HandleKeyPress implements list.KeyPressable.
257func (m *AssistantMessageItem) HandleKeyPress(msg tea.KeyPressMsg) bool {
258	if m.thinking == "" {
259		return false
260	}
261
262	if key.Matches(msg, key.NewBinding(key.WithKeys("space"))) {
263		m.thinkingExpanded = !m.thinkingExpanded
264		return true
265	}
266
267	return false
268}
269
270// renderError renders an error message.
271func (m *AssistantMessageItem) renderError(width int) string {
272	errTag := m.sty.Chat.Message.ErrorTag.Render("ERROR")
273	truncated := ansi.Truncate(m.finish.Message, width-2-lipgloss.Width(errTag), "...")
274	title := fmt.Sprintf("%s %s", errTag, m.sty.Chat.Message.ErrorTitle.Render(truncated))
275	details := m.sty.Chat.Message.ErrorDetails.Width(width - 2).Render(m.finish.Details)
276	return fmt.Sprintf("%s\n\n%s", title, details)
277}