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// maxCollapsedThinkingHeight defines the maximum height of the thinking
 17const maxCollapsedThinkingHeight = 10
 18
 19// AssistantMessageItem represents an assistant message in the chat UI.
 20//
 21// This item includes thinking, and the content but does not include the tool calls.
 22type AssistantMessageItem struct {
 23	*highlightableMessageItem
 24	*cachedMessageItem
 25	*focusableMessageItem
 26
 27	message           *message.Message
 28	sty               *styles.Styles
 29	anim              *anim.Anim
 30	thinkingExpanded  bool
 31	thinkingBoxHeight int // Tracks the rendered thinking box height for click detection.
 32}
 33
 34// NewAssistantMessageItem creates a new AssistantMessageItem.
 35func NewAssistantMessageItem(sty *styles.Styles, message *message.Message) MessageItem {
 36	a := &AssistantMessageItem{
 37		highlightableMessageItem: defaultHighlighter(sty),
 38		cachedMessageItem:        &cachedMessageItem{},
 39		focusableMessageItem:     &focusableMessageItem{},
 40		message:                  message,
 41		sty:                      sty,
 42	}
 43
 44	a.anim = anim.New(anim.Settings{
 45		ID:          a.ID(),
 46		Size:        15,
 47		GradColorA:  sty.Primary,
 48		GradColorB:  sty.Secondary,
 49		LabelColor:  sty.FgBase,
 50		CycleColors: true,
 51	})
 52	return a
 53}
 54
 55// StartAnimation starts the assistant message animation if it should be spinning.
 56func (a *AssistantMessageItem) StartAnimation() tea.Cmd {
 57	if !a.isSpinning() {
 58		return nil
 59	}
 60	return a.anim.Start()
 61}
 62
 63// Animate progresses the assistant message animation if it should be spinning.
 64func (a *AssistantMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
 65	if !a.isSpinning() {
 66		return nil
 67	}
 68	return a.anim.Animate(msg)
 69}
 70
 71// ID implements MessageItem.
 72func (a *AssistantMessageItem) ID() string {
 73	return a.message.ID
 74}
 75
 76// Render implements MessageItem.
 77func (a *AssistantMessageItem) Render(width int) string {
 78	cappedWidth := cappedMessageWidth(width)
 79	style := a.sty.Chat.Message.AssistantBlurred
 80	if a.focused {
 81		style = a.sty.Chat.Message.AssistantFocused
 82	}
 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 style.Render(highlightedContent + spinner)
103	}
104
105	return style.Render(highlightedContent)
106}
107
108// renderMessageContent renders the message content including thinking, main content, and finish reason.
109func (a *AssistantMessageItem) renderMessageContent(width int) string {
110	var messageParts []string
111	thinking := strings.TrimSpace(a.message.ReasoningContent().Thinking)
112	content := strings.TrimSpace(a.message.Content().Text)
113	// if the massage has reasoning content add that first
114	if thinking != "" {
115		messageParts = append(messageParts, a.renderThinking(a.message.ReasoningContent().Thinking, width))
116	}
117
118	// then add the main content
119	if content != "" {
120		// add a spacer between thinking and content
121		if thinking != "" {
122			messageParts = append(messageParts, "")
123		}
124		messageParts = append(messageParts, a.renderMarkdown(content, width))
125	}
126
127	// finally add any finish reason info
128	if a.message.IsFinished() {
129		switch a.message.FinishReason() {
130		case message.FinishReasonCanceled:
131			messageParts = append(messageParts, a.sty.Base.Italic(true).Render("Canceled"))
132		case message.FinishReasonError:
133			messageParts = append(messageParts, a.renderError(width))
134		}
135	}
136
137	return strings.Join(messageParts, "\n")
138}
139
140// renderThinking renders the thinking/reasoning content with footer.
141func (a *AssistantMessageItem) renderThinking(thinking string, width int) string {
142	renderer := common.PlainMarkdownRenderer(a.sty, width)
143	rendered, err := renderer.Render(thinking)
144	if err != nil {
145		rendered = thinking
146	}
147	rendered = strings.TrimSpace(rendered)
148
149	lines := strings.Split(rendered, "\n")
150	totalLines := len(lines)
151
152	isTruncated := totalLines > maxCollapsedThinkingHeight
153	if !a.thinkingExpanded && isTruncated {
154		lines = lines[totalLines-maxCollapsedThinkingHeight:]
155		hint := a.sty.Chat.Message.ThinkingTruncationHint.Render(
156			fmt.Sprintf("… (%d lines hidden) [click or space to expand]", totalLines-maxCollapsedThinkingHeight),
157		)
158		lines = append(lines, "", hint)
159	}
160
161	thinkingStyle := a.sty.Chat.Message.ThinkingBox.Width(width)
162	result := thinkingStyle.Render(strings.Join(lines, "\n"))
163	a.thinkingBoxHeight = lipgloss.Height(result)
164
165	var footer string
166	// if thinking is done add the thought for footer
167	if !a.message.IsThinking() || len(a.message.ToolCalls()) > 0 {
168		duration := a.message.ThinkingDuration()
169		if duration.String() != "0s" {
170			footer = a.sty.Chat.Message.ThinkingFooterTitle.Render("Thought for ") +
171				a.sty.Chat.Message.ThinkingFooterDuration.Render(duration.String())
172		}
173	}
174
175	if footer != "" {
176		result += "\n\n" + footer
177	}
178
179	return result
180}
181
182// renderMarkdown renders content as markdown.
183func (a *AssistantMessageItem) renderMarkdown(content string, width int) string {
184	renderer := common.MarkdownRenderer(a.sty, width)
185	result, err := renderer.Render(content)
186	if err != nil {
187		return content
188	}
189	return strings.TrimSuffix(result, "\n")
190}
191
192func (a *AssistantMessageItem) renderSpinning() string {
193	if a.message.IsThinking() {
194		a.anim.SetLabel("Thinking")
195	} else if a.message.IsSummaryMessage {
196		a.anim.SetLabel("Summarizing")
197	}
198	return a.anim.Render()
199}
200
201// renderError renders an error message.
202func (a *AssistantMessageItem) renderError(width int) string {
203	finishPart := a.message.FinishPart()
204	errTag := a.sty.Chat.Message.ErrorTag.Render("ERROR")
205	truncated := ansi.Truncate(finishPart.Message, width-2-lipgloss.Width(errTag), "...")
206	title := fmt.Sprintf("%s %s", errTag, a.sty.Chat.Message.ErrorTitle.Render(truncated))
207	details := a.sty.Chat.Message.ErrorDetails.Width(width - 2).Render(finishPart.Details)
208	return fmt.Sprintf("%s\n\n%s", title, details)
209}
210
211// isSpinning returns true if the assistant message is still generating.
212func (a *AssistantMessageItem) isSpinning() bool {
213	isThinking := a.message.IsThinking()
214	isFinished := a.message.IsFinished()
215	hasContent := strings.TrimSpace(a.message.Content().Text) != ""
216	hasToolCalls := len(a.message.ToolCalls()) > 0
217	return (isThinking || !isFinished) && !hasContent && !hasToolCalls
218}
219
220// SetMessage is used to update the underlying message.
221func (a *AssistantMessageItem) SetMessage(message *message.Message) tea.Cmd {
222	wasSpinning := a.isSpinning()
223	a.message = message
224	a.clearCache()
225	if !wasSpinning && a.isSpinning() {
226		return a.StartAnimation()
227	}
228	return nil
229}
230
231// ToggleExpanded toggles the expanded state of the thinking box.
232func (a *AssistantMessageItem) ToggleExpanded() {
233	a.thinkingExpanded = !a.thinkingExpanded
234	a.clearCache()
235}
236
237// HandleMouseClick implements MouseClickable.
238func (a *AssistantMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
239	if btn != ansi.MouseLeft {
240		return false
241	}
242	// check if the click is within the thinking box
243	if a.thinkingBoxHeight > 0 && y < a.thinkingBoxHeight {
244		a.ToggleExpanded()
245		return true
246	}
247	return false
248}