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
 38// NewAssistantMessageItem creates a new AssistantMessageItem.
 39func NewAssistantMessageItem(sty *styles.Styles, message *message.Message) MessageItem {
 40	a := &AssistantMessageItem{
 41		highlightableMessageItem: defaultHighlighter(sty),
 42		cachedMessageItem:        &cachedMessageItem{},
 43		focusableMessageItem:     &focusableMessageItem{},
 44		message:                  message,
 45		sty:                      sty,
 46	}
 47
 48	a.anim = anim.New(anim.Settings{
 49		ID:          a.ID(),
 50		Size:        15,
 51		GradColorA:  sty.Primary,
 52		GradColorB:  sty.Secondary,
 53		LabelColor:  sty.FgBase,
 54		CycleColors: true,
 55	})
 56	return a
 57}
 58
 59// StartAnimation starts the assistant message animation if it should be spinning.
 60func (a *AssistantMessageItem) StartAnimation() tea.Cmd {
 61	if !a.isSpinning() {
 62		return nil
 63	}
 64	return a.anim.Start()
 65}
 66
 67// Animate progresses the assistant message animation if it should be spinning.
 68func (a *AssistantMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
 69	if !a.isSpinning() {
 70		return nil
 71	}
 72	return a.anim.Animate(msg)
 73}
 74
 75// ID implements MessageItem.
 76func (a *AssistantMessageItem) ID() string {
 77	return a.message.ID
 78}
 79
 80// RawRender implements [MessageItem].
 81func (a *AssistantMessageItem) RawRender(width int) string {
 82	cappedWidth := cappedMessageWidth(width)
 83
 84	var spinner string
 85	if a.isSpinning() {
 86		spinner = a.renderSpinning()
 87	}
 88
 89	content, height, ok := a.getCachedRender(cappedWidth)
 90	if !ok {
 91		content = a.renderMessageContent(cappedWidth)
 92		height = lipgloss.Height(content)
 93		// cache the rendered content
 94		a.setCachedRender(content, cappedWidth, height)
 95	}
 96
 97	highlightedContent := a.renderHighlighted(content, cappedWidth, height)
 98	if spinner != "" {
 99		if highlightedContent != "" {
100			highlightedContent += "\n\n"
101		}
102		return highlightedContent + spinner
103	}
104
105	return highlightedContent
106}
107
108// Render implements MessageItem.
109func (a *AssistantMessageItem) Render(width int) string {
110	// XXX: Here, we're manually applying the focused/blurred styles because
111	// using lipgloss.Render can degrade performance for long messages due to
112	// it's wrapping logic.
113	// We already know that the content is wrapped to the correct width in
114	// RawRender, so we can just apply the styles directly to each line.
115	focused := a.sty.Chat.Message.AssistantFocused.Render()
116	blurred := a.sty.Chat.Message.AssistantBlurred.Render()
117	rendered := a.RawRender(width)
118	lines := strings.Split(rendered, "\n")
119	for i, line := range lines {
120		if a.focused {
121			lines[i] = focused + line
122		} else {
123			lines[i] = blurred + line
124		}
125	}
126	return strings.Join(lines, "\n")
127}
128
129// renderMessageContent renders the message content including thinking, main content, and finish reason.
130func (a *AssistantMessageItem) renderMessageContent(width int) string {
131	var messageParts []string
132	thinking := strings.TrimSpace(a.message.ReasoningContent().Thinking)
133	content := strings.TrimSpace(a.message.Content().Text)
134	// if the massage has reasoning content add that first
135	if thinking != "" {
136		messageParts = append(messageParts, a.renderThinking(a.message.ReasoningContent().Thinking, width))
137	}
138
139	// then add the main content
140	if content != "" {
141		// add a spacer between thinking and content
142		if thinking != "" {
143			messageParts = append(messageParts, "")
144		}
145		messageParts = append(messageParts, a.renderMarkdown(content, width))
146	}
147
148	// finally add any finish reason info
149	if a.message.IsFinished() {
150		switch a.message.FinishReason() {
151		case message.FinishReasonCanceled:
152			messageParts = append(messageParts, a.sty.Base.Italic(true).Render("Canceled"))
153		case message.FinishReasonError:
154			messageParts = append(messageParts, a.renderError(width))
155		}
156	}
157
158	return strings.Join(messageParts, "\n")
159}
160
161// renderThinking renders the thinking/reasoning content with footer.
162func (a *AssistantMessageItem) renderThinking(thinking string, width int) string {
163	renderer := common.PlainMarkdownRenderer(a.sty, width)
164	rendered, err := renderer.Render(thinking)
165	if err != nil {
166		rendered = thinking
167	}
168	rendered = strings.TrimSpace(rendered)
169
170	lines := strings.Split(rendered, "\n")
171	totalLines := len(lines)
172
173	isTruncated := totalLines > maxCollapsedThinkingHeight
174	if !a.thinkingExpanded && isTruncated {
175		lines = lines[totalLines-maxCollapsedThinkingHeight:]
176		hint := a.sty.Chat.Message.ThinkingTruncationHint.Render(
177			fmt.Sprintf(assistantMessageTruncateFormat, totalLines-maxCollapsedThinkingHeight),
178		)
179		lines = append([]string{hint, ""}, lines...)
180	}
181
182	thinkingStyle := a.sty.Chat.Message.ThinkingBox.Width(width)
183	result := thinkingStyle.Render(strings.Join(lines, "\n"))
184	a.thinkingBoxHeight = lipgloss.Height(result)
185
186	var footer string
187	// if thinking is done add the thought for footer
188	if !a.message.IsThinking() || len(a.message.ToolCalls()) > 0 {
189		duration := a.message.ThinkingDuration()
190		if duration.String() != "0s" {
191			footer = a.sty.Chat.Message.ThinkingFooterTitle.Render("Thought for ") +
192				a.sty.Chat.Message.ThinkingFooterDuration.Render(duration.String())
193		}
194	}
195
196	if footer != "" {
197		result += "\n\n" + footer
198	}
199
200	return result
201}
202
203// renderMarkdown renders content as markdown.
204func (a *AssistantMessageItem) renderMarkdown(content string, width int) string {
205	renderer := common.MarkdownRenderer(a.sty, width)
206	result, err := renderer.Render(content)
207	if err != nil {
208		return content
209	}
210	return strings.TrimSuffix(result, "\n")
211}
212
213func (a *AssistantMessageItem) renderSpinning() string {
214	if a.message.IsThinking() {
215		a.anim.SetLabel("Thinking")
216	} else if a.message.IsSummaryMessage {
217		a.anim.SetLabel("Summarizing")
218	}
219	return a.anim.Render()
220}
221
222// renderError renders an error message.
223func (a *AssistantMessageItem) renderError(width int) string {
224	finishPart := a.message.FinishPart()
225	errTag := a.sty.Chat.Message.ErrorTag.Render("ERROR")
226	truncated := ansi.Truncate(finishPart.Message, width-2-lipgloss.Width(errTag), "...")
227	title := fmt.Sprintf("%s %s", errTag, a.sty.Chat.Message.ErrorTitle.Render(truncated))
228	details := a.sty.Chat.Message.ErrorDetails.Width(width - 2).Render(finishPart.Details)
229	return fmt.Sprintf("%s\n\n%s", title, details)
230}
231
232// isSpinning returns true if the assistant message is still generating.
233func (a *AssistantMessageItem) isSpinning() bool {
234	isThinking := a.message.IsThinking()
235	isFinished := a.message.IsFinished()
236	hasContent := strings.TrimSpace(a.message.Content().Text) != ""
237	hasToolCalls := len(a.message.ToolCalls()) > 0
238	return (isThinking || !isFinished) && !hasContent && !hasToolCalls
239}
240
241// SetMessage is used to update the underlying message.
242func (a *AssistantMessageItem) SetMessage(message *message.Message) tea.Cmd {
243	wasSpinning := a.isSpinning()
244	a.message = message
245	a.clearCache()
246	if !wasSpinning && a.isSpinning() {
247		return a.StartAnimation()
248	}
249	return nil
250}
251
252// ToggleExpanded toggles the expanded state of the thinking box.
253func (a *AssistantMessageItem) ToggleExpanded() {
254	a.thinkingExpanded = !a.thinkingExpanded
255	a.clearCache()
256}
257
258// HandleMouseClick implements MouseClickable.
259func (a *AssistantMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
260	if btn != ansi.MouseLeft {
261		return false
262	}
263	// check if the click is within the thinking box
264	if a.thinkingBoxHeight > 0 && y < a.thinkingBoxHeight {
265		a.ToggleExpanded()
266		return true
267	}
268	return false
269}
270
271// HandleKeyEvent implements KeyEventHandler.
272func (a *AssistantMessageItem) HandleKeyEvent(key tea.KeyMsg) (bool, tea.Cmd) {
273	if k := key.String(); k == "c" || k == "y" {
274		text := a.message.Content().Text
275		return true, common.CopyToClipboard(text, "Message copied to clipboard")
276	}
277	return false, nil
278}