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