assistant.go

  1package chat
  2
  3import (
  4	"fmt"
  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/anim"
 11	"github.com/charmbracelet/crush/internal/ui/common"
 12	"github.com/charmbracelet/crush/internal/ui/styles"
 13	"github.com/charmbracelet/x/ansi"
 14)
 15
 16// assistantMessageTruncateFormat is the text shown when an assistant message is
 17// truncated.
 18const assistantMessageTruncateFormat = "… (%d lines hidden) [click or space to expand]"
 19
 20// maxCollapsedThinkingHeight defines the maximum height of the thinking
 21const maxCollapsedThinkingHeight = 10
 22
 23// AssistantMessageItem represents an assistant message in the chat UI.
 24//
 25// This item includes thinking, and the content but does not include the tool calls.
 26type AssistantMessageItem struct {
 27	*highlightableMessageItem
 28	*cachedMessageItem
 29	*focusableMessageItem
 30
 31	message           *message.Message
 32	sty               *styles.Styles
 33	anim              *anim.Anim
 34	thinkingExpanded  bool
 35	thinkingBoxHeight int // Tracks the rendered thinking box height for click detection.
 36}
 37
 38var _ Expandable = (*AssistantMessageItem)(nil)
 39
 40// NewAssistantMessageItem creates a new AssistantMessageItem.
 41func NewAssistantMessageItem(sty *styles.Styles, message *message.Message) MessageItem {
 42	a := &AssistantMessageItem{
 43		highlightableMessageItem: defaultHighlighter(sty),
 44		cachedMessageItem:        &cachedMessageItem{},
 45		focusableMessageItem:     &focusableMessageItem{},
 46		message:                  message,
 47		sty:                      sty,
 48	}
 49
 50	a.anim = anim.New(anim.Settings{
 51		ID:          a.ID(),
 52		Size:        15,
 53		GradColorA:  sty.WorkingGradFromColor,
 54		GradColorB:  sty.WorkingGradToColor,
 55		LabelColor:  sty.WorkingLabelColor,
 56		CycleColors: true,
 57	})
 58	return a
 59}
 60
 61// StartAnimation starts the assistant message animation if it should be spinning.
 62func (a *AssistantMessageItem) StartAnimation() tea.Cmd {
 63	if !a.isSpinning() {
 64		return nil
 65	}
 66	return a.anim.Start()
 67}
 68
 69// Animate progresses the assistant message animation if it should be spinning.
 70func (a *AssistantMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
 71	if !a.isSpinning() {
 72		return nil
 73	}
 74	return a.anim.Animate(msg)
 75}
 76
 77// ID implements MessageItem.
 78func (a *AssistantMessageItem) ID() string {
 79	return a.message.ID
 80}
 81
 82// RawRender implements [MessageItem].
 83func (a *AssistantMessageItem) RawRender(width int) string {
 84	cappedWidth := cappedMessageWidth(width)
 85
 86	var spinner string
 87	if a.isSpinning() {
 88		spinner = a.renderSpinning()
 89	}
 90
 91	content, height, ok := a.getCachedRender(cappedWidth)
 92	if !ok {
 93		content = a.renderMessageContent(cappedWidth)
 94		height = lipgloss.Height(content)
 95		// cache the rendered content
 96		a.setCachedRender(content, cappedWidth, height)
 97	}
 98
 99	highlightedContent := a.renderHighlighted(content, cappedWidth, height)
100	if spinner != "" {
101		if highlightedContent != "" {
102			highlightedContent += "\n\n"
103		}
104		return highlightedContent + spinner
105	}
106
107	return highlightedContent
108}
109
110// Render implements MessageItem.
111func (a *AssistantMessageItem) Render(width int) string {
112	// XXX: Here, we're manually applying the focused/blurred styles because
113	// using lipgloss.Render can degrade performance for long messages due to
114	// it's wrapping logic.
115	// We already know that the content is wrapped to the correct width in
116	// RawRender, so we can just apply the styles directly to each line.
117	//
118	// The split + per-line prefix loop is O(L); cache the result keyed
119	// by (width, focused) so steady-state Render becomes a pointer
120	// return. Bypass the cache while spinning (RawRender's spinner
121	// suffix changes every animation frame) or while a highlight range
122	// is active (selection drag).
123	useCache := !a.isSpinning() && !a.isHighlighted()
124	var key uint64
125	if a.focused {
126		key = 1
127	}
128	if useCache {
129		if cached, ok := a.getCachedPrefixedRender(width, key); ok {
130			return cached
131		}
132	}
133	focused := a.sty.Messages.AssistantFocused.Render()
134	blurred := a.sty.Messages.AssistantBlurred.Render()
135	rendered := a.RawRender(width)
136	lines := strings.Split(rendered, "\n")
137	for i, line := range lines {
138		if a.focused {
139			lines[i] = focused + line
140		} else {
141			lines[i] = blurred + line
142		}
143	}
144	out := strings.Join(lines, "\n")
145	if useCache {
146		a.setCachedPrefixedRender(out, width, key)
147	}
148	return out
149}
150
151// renderMessageContent renders the message content including thinking, main content, and finish reason.
152func (a *AssistantMessageItem) renderMessageContent(width int) string {
153	var messageParts []string
154	thinking := strings.TrimSpace(a.message.ReasoningContent().Thinking)
155	content := strings.TrimSpace(a.message.Content().Text)
156	// if the massage has reasoning content add that first
157	if thinking != "" {
158		messageParts = append(messageParts, a.renderThinking(a.message.ReasoningContent().Thinking, width))
159	}
160
161	// then add the main content
162	if content != "" {
163		// add a spacer between thinking and content
164		if thinking != "" {
165			messageParts = append(messageParts, "")
166		}
167		messageParts = append(messageParts, a.renderMarkdown(content, width))
168	}
169
170	// finally add any finish reason info
171	if a.message.IsFinished() {
172		switch a.message.FinishReason() {
173		case message.FinishReasonCanceled:
174			messageParts = append(messageParts, a.sty.Messages.AssistantCanceled.Render("Canceled"))
175		case message.FinishReasonError:
176			messageParts = append(messageParts, a.renderError(width))
177		}
178	}
179
180	return strings.Join(messageParts, "\n")
181}
182
183// renderThinking renders the thinking/reasoning content with footer.
184func (a *AssistantMessageItem) renderThinking(thinking string, width int) string {
185	renderer := common.QuietMarkdownRenderer(a.sty, width)
186	rendered, err := renderer.Render(thinking)
187	if err != nil {
188		rendered = thinking
189	}
190	rendered = strings.TrimSpace(rendered)
191
192	lines := strings.Split(rendered, "\n")
193	totalLines := len(lines)
194
195	isTruncated := totalLines > maxCollapsedThinkingHeight
196	if !a.thinkingExpanded && isTruncated {
197		lines = lines[totalLines-maxCollapsedThinkingHeight:]
198		hint := a.sty.Messages.ThinkingTruncationHint.Render(
199			fmt.Sprintf(assistantMessageTruncateFormat, totalLines-maxCollapsedThinkingHeight),
200		)
201		lines = append([]string{hint, ""}, lines...)
202	}
203
204	thinkingStyle := a.sty.Messages.ThinkingBox.Width(width)
205	result := thinkingStyle.Render(strings.Join(lines, "\n"))
206	a.thinkingBoxHeight = lipgloss.Height(result)
207
208	var footer string
209	// if thinking is done add the thought for footer
210	if !a.message.IsThinking() || len(a.message.ToolCalls()) > 0 {
211		duration := a.message.ThinkingDuration()
212		if duration.String() != "0s" {
213			footer = a.sty.Messages.ThinkingFooterTitle.Render("Thought for ") +
214				a.sty.Messages.ThinkingFooterDuration.Render(duration.String())
215		}
216	}
217
218	if footer != "" {
219		result += "\n\n" + footer
220	}
221
222	return result
223}
224
225// renderMarkdown renders content as markdown.
226func (a *AssistantMessageItem) renderMarkdown(content string, width int) string {
227	renderer := common.MarkdownRenderer(a.sty, width)
228	result, err := renderer.Render(content)
229	if err != nil {
230		return content
231	}
232	return strings.TrimSuffix(result, "\n")
233}
234
235func (a *AssistantMessageItem) renderSpinning() string {
236	if a.message.IsThinking() {
237		a.anim.SetLabel("Thinking")
238	} else if a.message.IsSummaryMessage {
239		a.anim.SetLabel("Summarizing")
240	}
241	return a.anim.Render()
242}
243
244// renderError renders an error message.
245func (a *AssistantMessageItem) renderError(width int) string {
246	finishPart := a.message.FinishPart()
247	errTag := a.sty.Messages.ErrorTag.Render("ERROR")
248	truncated := ansi.Truncate(finishPart.Message, width-2-lipgloss.Width(errTag), "...")
249	title := fmt.Sprintf("%s %s", errTag, a.sty.Messages.ErrorTitle.Render(truncated))
250	details := a.sty.Messages.ErrorDetails.Width(width - 2).Render(finishPart.Details)
251	return fmt.Sprintf("%s\n\n%s", title, details)
252}
253
254// isSpinning returns true if the assistant message is still generating.
255func (a *AssistantMessageItem) isSpinning() bool {
256	isThinking := a.message.IsThinking()
257	isFinished := a.message.IsFinished()
258	hasContent := strings.TrimSpace(a.message.Content().Text) != ""
259	hasToolCalls := len(a.message.ToolCalls()) > 0
260	return (isThinking || !isFinished) && !hasContent && !hasToolCalls
261}
262
263// SetMessage is used to update the underlying message.
264func (a *AssistantMessageItem) SetMessage(message *message.Message) tea.Cmd {
265	wasSpinning := a.isSpinning()
266	a.message = message
267	a.clearCache()
268	if !wasSpinning && a.isSpinning() {
269		return a.StartAnimation()
270	}
271	return nil
272}
273
274// ToggleExpanded toggles the expanded state of the thinking box and returns
275// whether the item is now expanded.
276func (a *AssistantMessageItem) ToggleExpanded() bool {
277	a.thinkingExpanded = !a.thinkingExpanded
278	a.clearCache()
279	return a.thinkingExpanded
280}
281
282// HandleMouseClick implements MouseClickable. It signals (via a true return)
283// that the click lies on the thinking box so the caller can invoke
284// [AssistantMessageItem.ToggleExpanded] through the generic [Expandable]
285// path. Toggling here directly would double-toggle because the caller always
286// runs the generic path after a handled click.
287func (a *AssistantMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
288	if btn != ansi.MouseLeft {
289		return false
290	}
291	// Only the thinking box is clickable; other regions of the assistant
292	// message should not trigger expansion.
293	return a.thinkingBoxHeight > 0 && y < a.thinkingBoxHeight
294}
295
296// HandleKeyEvent implements KeyEventHandler.
297func (a *AssistantMessageItem) HandleKeyEvent(key tea.KeyMsg) (bool, tea.Cmd) {
298	if k := key.String(); k == "c" || k == "y" {
299		text := a.message.Content().Text
300		return true, common.CopyToClipboard(text, "Message copied to clipboard")
301	}
302	return false, nil
303}