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	focused := a.sty.Messages.AssistantFocused.Render()
118	blurred := a.sty.Messages.AssistantBlurred.Render()
119	rendered := a.RawRender(width)
120	lines := strings.Split(rendered, "\n")
121	for i, line := range lines {
122		if a.focused {
123			lines[i] = focused + line
124		} else {
125			lines[i] = blurred + line
126		}
127	}
128	return strings.Join(lines, "\n")
129}
130
131// renderMessageContent renders the message content including thinking, main content, and finish reason.
132func (a *AssistantMessageItem) renderMessageContent(width int) string {
133	var messageParts []string
134	thinking := strings.TrimSpace(a.message.ReasoningContent().Thinking)
135	content := strings.TrimSpace(a.message.Content().Text)
136	// if the massage has reasoning content add that first
137	if thinking != "" {
138		messageParts = append(messageParts, a.renderThinking(a.message.ReasoningContent().Thinking, width))
139	}
140
141	// then add the main content
142	if content != "" {
143		// add a spacer between thinking and content
144		if thinking != "" {
145			messageParts = append(messageParts, "")
146		}
147		messageParts = append(messageParts, a.renderMarkdown(content, width))
148	}
149
150	// finally add any finish reason info
151	if a.message.IsFinished() {
152		switch a.message.FinishReason() {
153		case message.FinishReasonCanceled:
154			messageParts = append(messageParts, a.sty.Messages.AssistantCanceled.Render("Canceled"))
155		case message.FinishReasonError:
156			messageParts = append(messageParts, a.renderError(width))
157		}
158	}
159
160	return strings.Join(messageParts, "\n")
161}
162
163// renderThinking renders the thinking/reasoning content with footer.
164func (a *AssistantMessageItem) renderThinking(thinking string, width int) string {
165	renderer := common.QuietMarkdownRenderer(a.sty, width)
166	rendered, err := renderer.Render(thinking)
167	if err != nil {
168		rendered = thinking
169	}
170	rendered = strings.TrimSpace(rendered)
171
172	lines := strings.Split(rendered, "\n")
173	totalLines := len(lines)
174
175	isTruncated := totalLines > maxCollapsedThinkingHeight
176	if !a.thinkingExpanded && isTruncated {
177		lines = lines[totalLines-maxCollapsedThinkingHeight:]
178		hint := a.sty.Messages.ThinkingTruncationHint.Render(
179			fmt.Sprintf(assistantMessageTruncateFormat, totalLines-maxCollapsedThinkingHeight),
180		)
181		lines = append([]string{hint, ""}, lines...)
182	}
183
184	thinkingStyle := a.sty.Messages.ThinkingBox.Width(width)
185	result := thinkingStyle.Render(strings.Join(lines, "\n"))
186	a.thinkingBoxHeight = lipgloss.Height(result)
187
188	var footer string
189	// if thinking is done add the thought for footer
190	if !a.message.IsThinking() || len(a.message.ToolCalls()) > 0 {
191		duration := a.message.ThinkingDuration()
192		if duration.String() != "0s" {
193			footer = a.sty.Messages.ThinkingFooterTitle.Render("Thought for ") +
194				a.sty.Messages.ThinkingFooterDuration.Render(duration.String())
195		}
196	}
197
198	if footer != "" {
199		result += "\n\n" + footer
200	}
201
202	return result
203}
204
205// renderMarkdown renders content as markdown.
206func (a *AssistantMessageItem) renderMarkdown(content string, width int) string {
207	renderer := common.MarkdownRenderer(a.sty, width)
208	result, err := renderer.Render(content)
209	if err != nil {
210		return content
211	}
212	return strings.TrimSuffix(result, "\n")
213}
214
215func (a *AssistantMessageItem) renderSpinning() string {
216	if a.message.IsThinking() {
217		a.anim.SetLabel("Thinking")
218	} else if a.message.IsSummaryMessage {
219		a.anim.SetLabel("Summarizing")
220	}
221	return a.anim.Render()
222}
223
224// renderError renders an error message.
225func (a *AssistantMessageItem) renderError(width int) string {
226	finishPart := a.message.FinishPart()
227	errTag := a.sty.Messages.ErrorTag.Render("ERROR")
228	truncated := ansi.Truncate(finishPart.Message, width-2-lipgloss.Width(errTag), "...")
229	title := fmt.Sprintf("%s %s", errTag, a.sty.Messages.ErrorTitle.Render(truncated))
230	details := a.sty.Messages.ErrorDetails.Width(width - 2).Render(finishPart.Details)
231	return fmt.Sprintf("%s\n\n%s", title, details)
232}
233
234// isSpinning returns true if the assistant message is still generating.
235func (a *AssistantMessageItem) isSpinning() bool {
236	isThinking := a.message.IsThinking()
237	isFinished := a.message.IsFinished()
238	hasContent := strings.TrimSpace(a.message.Content().Text) != ""
239	hasToolCalls := len(a.message.ToolCalls()) > 0
240	return (isThinking || !isFinished) && !hasContent && !hasToolCalls
241}
242
243// SetMessage is used to update the underlying message.
244func (a *AssistantMessageItem) SetMessage(message *message.Message) tea.Cmd {
245	wasSpinning := a.isSpinning()
246	a.message = message
247	a.clearCache()
248	if !wasSpinning && a.isSpinning() {
249		return a.StartAnimation()
250	}
251	return nil
252}
253
254// ToggleExpanded toggles the expanded state of the thinking box and returns
255// whether the item is now expanded.
256func (a *AssistantMessageItem) ToggleExpanded() bool {
257	a.thinkingExpanded = !a.thinkingExpanded
258	a.clearCache()
259	return a.thinkingExpanded
260}
261
262// HandleMouseClick implements MouseClickable. It signals (via a true return)
263// that the click lies on the thinking box so the caller can invoke
264// [AssistantMessageItem.ToggleExpanded] through the generic [Expandable]
265// path. Toggling here directly would double-toggle because the caller always
266// runs the generic path after a handled click.
267func (a *AssistantMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
268	if btn != ansi.MouseLeft {
269		return false
270	}
271	// Only the thinking box is clickable; other regions of the assistant
272	// message should not trigger expansion.
273	return a.thinkingBoxHeight > 0 && y < a.thinkingBoxHeight
274}
275
276// HandleKeyEvent implements KeyEventHandler.
277func (a *AssistantMessageItem) HandleKeyEvent(key tea.KeyMsg) (bool, tea.Cmd) {
278	if k := key.String(); k == "c" || k == "y" {
279		text := a.message.Content().Text
280		return true, common.CopyToClipboard(text, "Message copied to clipboard")
281	}
282	return false, nil
283}