assistant.go

  1package chat
  2
  3import (
  4	"encoding/binary"
  5	"fmt"
  6	"hash/fnv"
  7	"strings"
  8
  9	tea "charm.land/bubbletea/v2"
 10	"charm.land/lipgloss/v2"
 11	"github.com/charmbracelet/crush/internal/message"
 12	"github.com/charmbracelet/crush/internal/ui/anim"
 13	"github.com/charmbracelet/crush/internal/ui/common"
 14	"github.com/charmbracelet/crush/internal/ui/list"
 15	"github.com/charmbracelet/crush/internal/ui/styles"
 16	"github.com/charmbracelet/x/ansi"
 17)
 18
 19// assistantMessageTruncateFormat is the text shown when an assistant message is
 20// truncated in the collapsed state.
 21const assistantMessageTruncateFormat = "… (%d lines hidden) [click or space to expand]"
 22
 23// assistantMessageTailWindowFormat is shown above a tail-windowed thinking
 24// block to advertise that earlier lines exist and that the user can
 25// promote the view to a full expansion. The promotion is wired through
 26// the existing ToggleExpanded path (click / space) — F5 deliberately
 27// does not add a new keybinding.
 28const assistantMessageTailWindowFormat = "… %d earlier lines hidden [click or space for full view]"
 29
 30// maxCollapsedThinkingHeight defines the maximum height of the thinking
 31const maxCollapsedThinkingHeight = 10
 32
 33// maxExpandedThinkingTailLines is the F5 tail-window cap. When the user
 34// expands a thinking block whose post-glamour line count exceeds this
 35// threshold, only the last N lines are shown with an affordance line
 36// indicating how many earlier lines are hidden. Clicking / pressing
 37// space again promotes the view to a full expansion. The slice is
 38// taken AFTER glamour render (not before) so fenced code blocks,
 39// lists, and tables are not torn at arbitrary boundaries.
 40const maxExpandedThinkingTailLines = 200
 41
 42// thinkingViewMode is the F5 three-state view machine for the thinking
 43// block. ToggleExpanded cycles
 44// collapsed → tail-window → full-expanded → collapsed, skipping the
 45// tail-window step when the rendered thinking fits within the cap so
 46// short blocks still toggle in two clicks.
 47type thinkingViewMode uint8
 48
 49const (
 50	thinkingCollapsed thinkingViewMode = iota
 51	thinkingTailWindow
 52	thinkingFullExpanded
 53)
 54
 55// assistantSection is a per-section render cache for AssistantMessageItem.
 56// Each section (thinking, content, error) carries its own keys so that
 57// streaming a section does not invalidate a different — often more
 58// expensive — section's cached render. srcHash is an FNV-64 of the
 59// section's source text; extra captures any other state that changes
 60// the rendered output (e.g. thinkingExpanded, the thinking footer
 61// inputs). valid disambiguates a real cache hit from the zero value
 62// when both source text and extras hash to zero. aux carries any
 63// per-section side data that the caller needs to recover on a hit
 64// (e.g. the thinking box height for click detection).
 65type assistantSection struct {
 66	width   int
 67	srcHash uint64
 68	extra   uint64
 69	out     string
 70	h       int
 71	aux     int
 72	valid   bool
 73}
 74
 75// hit reports whether the cache entry matches the requested key.
 76func (s *assistantSection) hit(width int, srcHash, extra uint64) bool {
 77	return s.valid && s.width == width && s.srcHash == srcHash && s.extra == extra
 78}
 79
 80// store records the rendered output under the given key.
 81func (s *assistantSection) store(width int, srcHash, extra uint64, out string, aux int) {
 82	s.width = width
 83	s.srcHash = srcHash
 84	s.extra = extra
 85	s.out = out
 86	s.h = lipgloss.Height(out)
 87	s.aux = aux
 88	s.valid = true
 89}
 90
 91// reset drops the cached output.
 92func (s *assistantSection) reset() {
 93	*s = assistantSection{}
 94}
 95
 96// fnv64 hashes a single string with FNV-64.
 97func fnv64(s string) uint64 {
 98	h := fnv.New64a()
 99	_, _ = h.Write([]byte(s))
100	return h.Sum64()
101}
102
103// fnvFields hashes a list of byte fields with length-prefix framing
104// so that no concatenation collision can occur between distinct
105// field tuples (a NUL inside one field cannot impersonate a
106// boundary between two fields). Each field is preceded by its
107// length encoded as 8 bytes little-endian.
108func fnvFields(fields ...[]byte) uint64 {
109	h := fnv.New64a()
110	var lenBuf [8]byte
111	for _, f := range fields {
112		binary.LittleEndian.PutUint64(lenBuf[:], uint64(len(f)))
113		_, _ = h.Write(lenBuf[:])
114		_, _ = h.Write(f)
115	}
116	return h.Sum64()
117}
118
119// AssistantMessageItem represents an assistant message in the chat UI.
120//
121// This item includes thinking, and the content but does not include the tool calls.
122type AssistantMessageItem struct {
123	*list.Versioned
124	*highlightableMessageItem
125	*cachedMessageItem
126	*focusableMessageItem
127
128	message           *message.Message
129	sty               *styles.Styles
130	anim              *anim.Anim
131	thinkingViewMode  thinkingViewMode
132	thinkingBoxHeight int // Tracks the rendered thinking box height for click detection.
133
134	// Per-section render caches. Splitting these out means content
135	// streaming does not invalidate the (often expensive) thinking
136	// render, and vice versa.
137	thinkingSec assistantSection
138	contentSec  assistantSection
139	errorSec    assistantSection
140}
141
142var _ Expandable = (*AssistantMessageItem)(nil)
143
144// NewAssistantMessageItem creates a new AssistantMessageItem.
145func NewAssistantMessageItem(sty *styles.Styles, message *message.Message) MessageItem {
146	v := list.NewVersioned()
147	a := &AssistantMessageItem{
148		Versioned:                v,
149		highlightableMessageItem: defaultHighlighter(sty, v),
150		cachedMessageItem:        &cachedMessageItem{},
151		focusableMessageItem:     newFocusableMessageItem(v),
152		message:                  message,
153		sty:                      sty,
154	}
155
156	a.anim = anim.New(anim.Settings{
157		ID:          a.ID(),
158		Size:        15,
159		GradColorA:  sty.WorkingGradFromColor,
160		GradColorB:  sty.WorkingGradToColor,
161		LabelColor:  sty.WorkingLabelColor,
162		CycleColors: true,
163	})
164	return a
165}
166
167// StartAnimation starts the assistant message animation if it should be spinning.
168func (a *AssistantMessageItem) StartAnimation() tea.Cmd {
169	if !a.isSpinning() {
170		return nil
171	}
172	return a.anim.Start()
173}
174
175// Animate progresses the assistant message animation if it should be spinning.
176func (a *AssistantMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
177	if !a.isSpinning() {
178		return nil
179	}
180	// Bump the F6 list-cache version so the next draw re-renders
181	// this item: a spinner tick mutates anim's internal frame
182	// counter, which changes the rendered output but is invisible
183	// to the per-section content hashes. Without the bump the
184	// list cache would serve the previously rendered frame
185	// indefinitely and the spinner would appear frozen.
186	a.Bump()
187	return a.anim.Animate(msg)
188}
189
190// ID implements MessageItem.
191func (a *AssistantMessageItem) ID() string {
192	return a.message.ID
193}
194
195// RawRender implements [MessageItem].
196func (a *AssistantMessageItem) RawRender(width int) string {
197	cappedWidth := cappedMessageWidth(width)
198
199	var spinner string
200	if a.isSpinning() {
201		spinner = a.renderSpinning()
202	}
203
204	content, height := a.renderMessageContent(cappedWidth)
205	highlightedContent := a.renderHighlighted(content, cappedWidth, height)
206	if spinner != "" {
207		if highlightedContent != "" {
208			highlightedContent += "\n\n"
209		}
210		return highlightedContent + spinner
211	}
212
213	return highlightedContent
214}
215
216// Render implements MessageItem.
217func (a *AssistantMessageItem) Render(width int) string {
218	// XXX: Here, we're manually applying the focused/blurred styles because
219	// using lipgloss.Render can degrade performance for long messages due to
220	// it's wrapping logic.
221	// We already know that the content is wrapped to the correct width in
222	// RawRender, so we can just apply the styles directly to each line.
223	//
224	// The split + per-line prefix loop is O(L); cache the result keyed
225	// by (width, focused, sectionsFingerprint) so steady-state Render
226	// becomes a pointer return. The sectionsFingerprint folds in the
227	// per-section srcHash/extra so that any sub-cache change
228	// invalidates this prefix cache without requiring an explicit
229	// drop. Bypass the cache while spinning (RawRender's spinner
230	// suffix changes every animation frame) or while a highlight
231	// range is active (selection drag).
232	useCache := !a.isSpinning() && !a.isHighlighted()
233	cappedWidth := cappedMessageWidth(width)
234	key := a.prefixCacheKey(cappedWidth)
235	if useCache {
236		if cached, ok := a.getCachedPrefixedRender(width, key); ok {
237			return cached
238		}
239	}
240	focused := a.sty.Messages.AssistantFocused.Render()
241	blurred := a.sty.Messages.AssistantBlurred.Render()
242	rendered := a.RawRender(width)
243	lines := strings.Split(rendered, "\n")
244	for i, line := range lines {
245		if a.focused {
246			lines[i] = focused + line
247		} else {
248			lines[i] = blurred + line
249		}
250	}
251	out := strings.Join(lines, "\n")
252	if useCache {
253		a.setCachedPrefixedRender(out, width, key)
254	}
255	return out
256}
257
258// prefixCacheKey builds the F3 prefixed-render cache key. We pack the
259// focus bit into bit 0 and a fingerprint of the section caches into
260// the upper bits, so any change to a sub-section's source text or
261// extras forces the prefix cache to miss without needing an explicit
262// drop. cappedWidth is included so a cached prefix never survives a
263// section-cache miss caused by a width change. The finish reason is
264// folded in too because it controls the composition of
265// renderMessageContent (e.g. appending the constant "Canceled"
266// string) — that decision lives outside any section's own hash.
267func (a *AssistantMessageItem) prefixCacheKey(cappedWidth int) uint64 {
268	thinkSrc, thinkExtra := a.thinkingKey()
269	contentSrc, contentExtra := a.contentKey()
270	errSrc, errExtra := a.errorKey()
271	h := fnv.New64a()
272	var buf [8]byte
273	writeU64 := func(v uint64) {
274		for i := range 8 {
275			buf[i] = byte(v >> (8 * i))
276		}
277		_, _ = h.Write(buf[:])
278	}
279	writeU64(uint64(cappedWidth))
280	writeU64(thinkSrc)
281	writeU64(thinkExtra)
282	writeU64(contentSrc)
283	writeU64(contentExtra)
284	writeU64(errSrc)
285	writeU64(errExtra)
286	writeU64(a.compositionKey())
287	fingerprint := h.Sum64()
288	var focusBit uint64
289	if a.focused {
290		focusBit = 1
291	}
292	return (fingerprint &^ 1) | focusBit
293}
294
295// compositionKey hashes the inputs to renderMessageContent's structural
296// decisions (which sections to include, whether to append the
297// constant "Canceled" footer) so that flipping IsFinished or the
298// finish reason invalidates the prefix cache even when no section's
299// own source text changed.
300func (a *AssistantMessageItem) compositionKey() uint64 {
301	var finishedFlag byte
302	var reason string
303	if a.message.IsFinished() {
304		finishedFlag = 1
305		reason = string(a.message.FinishReason())
306	}
307	// Length-prefixed framing keeps the finished flag and the reason
308	// string from blending into one another.
309	return fnvFields([]byte{finishedFlag}, []byte(reason))
310}
311
312// renderMessageContent renders the message content including thinking, main
313// content, and finish reason. Each section is served from its own cache;
314// only the section whose source text or extras changed since the last
315// render is recomputed.
316func (a *AssistantMessageItem) renderMessageContent(width int) (string, int) {
317	var messageParts []string
318	thinking := strings.TrimSpace(a.message.ReasoningContent().Thinking)
319	content := strings.TrimSpace(a.message.Content().Text)
320
321	if thinking != "" {
322		messageParts = append(messageParts, a.cachedThinking(width))
323	}
324
325	if content != "" {
326		if thinking != "" {
327			messageParts = append(messageParts, "")
328		}
329		messageParts = append(messageParts, a.cachedContent(width))
330	}
331
332	if a.message.IsFinished() {
333		switch a.message.FinishReason() {
334		case message.FinishReasonCanceled:
335			messageParts = append(messageParts, a.sty.Messages.AssistantCanceled.Render("Canceled"))
336		case message.FinishReasonError:
337			messageParts = append(messageParts, a.cachedError(width))
338		}
339	}
340
341	out := strings.Join(messageParts, "\n")
342	return out, lipgloss.Height(out)
343}
344
345// thinkingKey returns the (srcHash, extra) cache key components for the
346// thinking section. extra folds in everything other than the raw
347// thinking text that affects the rendered output: the view mode
348// (collapsed / tail-window / full) and the footer state (which
349// depends on IsThinking, ToolCalls, and ThinkingDuration).
350func (a *AssistantMessageItem) thinkingKey() (uint64, uint64) {
351	thinking := a.message.ReasoningContent().Thinking
352	srcHash := fnv64(thinking)
353
354	showFooter := !a.message.IsThinking() || len(a.message.ToolCalls()) > 0
355	var durationStr string
356	if showFooter {
357		duration := a.message.ThinkingDuration()
358		if duration.String() != "0s" {
359			durationStr = duration.String()
360		}
361	}
362	var footer byte
363	if showFooter {
364		footer = 1
365	}
366	// Length-prefixed framing avoids any delimiter collision between
367	// the flag bytes and the duration string. The view mode is folded
368	// in so that toggling collapsed ↔ tail-window ↔ full invalidates
369	// only the thinking section, not content/error.
370	extra := fnvFields([]byte{byte(a.thinkingViewMode), footer}, []byte(durationStr))
371	return srcHash, extra
372}
373
374// contentKey returns the (srcHash, extra) cache key components for the
375// main content section.
376func (a *AssistantMessageItem) contentKey() (uint64, uint64) {
377	return fnv64(a.message.Content().Text), 0
378}
379
380// errorKey returns the (srcHash, extra) cache key components for the
381// error section. Returns (0, 0) when no error is present so the cache
382// stays a no-op for non-error messages.
383func (a *AssistantMessageItem) errorKey() (uint64, uint64) {
384	if !a.message.IsFinished() || a.message.FinishReason() != message.FinishReasonError {
385		return 0, 0
386	}
387	finishPart := a.message.FinishPart()
388	if finishPart == nil {
389		return 0, 0
390	}
391	// Length-prefixed framing prevents Message+Details collisions
392	// between distinct (Message, Details) tuples that would
393	// otherwise concatenate to the same byte sequence.
394	return fnvFields([]byte(finishPart.Message), []byte(finishPart.Details)), 0
395}
396
397// cachedThinking returns the rendered thinking section, computing and
398// caching it on miss. The thinking-box height (used for click target
399// detection) is preserved across hits via assistantSection.aux so the
400// cached path never desyncs click detection.
401func (a *AssistantMessageItem) cachedThinking(width int) string {
402	srcHash, extra := a.thinkingKey()
403	if a.thinkingSec.hit(width, srcHash, extra) {
404		a.thinkingBoxHeight = a.thinkingSec.aux
405		return a.thinkingSec.out
406	}
407	out := a.renderThinking(a.message.ReasoningContent().Thinking, width)
408	a.thinkingSec.store(width, srcHash, extra, out, a.thinkingBoxHeight)
409	return out
410}
411
412// cachedContent returns the rendered content section.
413func (a *AssistantMessageItem) cachedContent(width int) string {
414	srcHash, extra := a.contentKey()
415	if a.contentSec.hit(width, srcHash, extra) {
416		return a.contentSec.out
417	}
418	out := a.renderMarkdown(a.message.Content().Text, width)
419	a.contentSec.store(width, srcHash, extra, out, 0)
420	return out
421}
422
423// cachedError returns the rendered error section.
424func (a *AssistantMessageItem) cachedError(width int) string {
425	srcHash, extra := a.errorKey()
426	if a.errorSec.hit(width, srcHash, extra) {
427		return a.errorSec.out
428	}
429	out := a.renderError(width)
430	a.errorSec.store(width, srcHash, extra, out, 0)
431	return out
432}
433
434// renderThinking renders the thinking/reasoning content with footer.
435//
436// Slicing happens AFTER glamour rendering so fenced code blocks, list
437// continuations, and tables are not split mid-block — the same
438// boundary problem §4.4 of the design note flags. The bordered
439// ThinkingBox style is applied on top of the (already-windowed)
440// lines so the visual box matches what the user sees today.
441func (a *AssistantMessageItem) renderThinking(thinking string, width int) string {
442	renderer := common.QuietMarkdownRenderer(a.sty, width)
443	rendered, err := renderer.Render(thinking)
444	if err != nil {
445		rendered = thinking
446	}
447	rendered = strings.TrimSpace(rendered)
448
449	lines := strings.Split(rendered, "\n")
450	totalLines := len(lines)
451
452	switch a.thinkingViewMode {
453	case thinkingCollapsed:
454		if totalLines > maxCollapsedThinkingHeight {
455			lines = lines[totalLines-maxCollapsedThinkingHeight:]
456			hint := a.sty.Messages.ThinkingTruncationHint.Render(
457				fmt.Sprintf(assistantMessageTruncateFormat, totalLines-maxCollapsedThinkingHeight),
458			)
459			lines = append([]string{hint, ""}, lines...)
460		}
461	case thinkingTailWindow:
462		if totalLines > maxExpandedThinkingTailLines {
463			lines = lines[totalLines-maxExpandedThinkingTailLines:]
464			hint := a.sty.Messages.ThinkingTruncationHint.Render(
465				fmt.Sprintf(assistantMessageTailWindowFormat, totalLines-maxExpandedThinkingTailLines),
466			)
467			lines = append([]string{hint, ""}, lines...)
468		}
469	}
470
471	thinkingStyle := a.sty.Messages.ThinkingBox.Width(width)
472	result := thinkingStyle.Render(strings.Join(lines, "\n"))
473	a.thinkingBoxHeight = lipgloss.Height(result)
474
475	var footer string
476	// if thinking is done add the thought for footer
477	if !a.message.IsThinking() || len(a.message.ToolCalls()) > 0 {
478		duration := a.message.ThinkingDuration()
479		if duration.String() != "0s" {
480			footer = a.sty.Messages.ThinkingFooterTitle.Render("Thought for ") +
481				a.sty.Messages.ThinkingFooterDuration.Render(duration.String())
482		}
483	}
484
485	if footer != "" {
486		result += "\n\n" + footer
487	}
488
489	return result
490}
491
492// renderMarkdown renders content as markdown.
493func (a *AssistantMessageItem) renderMarkdown(content string, width int) string {
494	renderer := common.MarkdownRenderer(a.sty, width)
495	result, err := renderer.Render(content)
496	if err != nil {
497		return content
498	}
499	return strings.TrimSuffix(result, "\n")
500}
501
502func (a *AssistantMessageItem) renderSpinning() string {
503	if a.message.IsThinking() {
504		a.anim.SetLabel("Thinking")
505	} else if a.message.IsSummaryMessage {
506		a.anim.SetLabel("Summarizing")
507	}
508	return a.anim.Render()
509}
510
511// renderError renders an error message.
512func (a *AssistantMessageItem) renderError(width int) string {
513	finishPart := a.message.FinishPart()
514	errTag := a.sty.Messages.ErrorTag.Render("ERROR")
515	truncated := ansi.Truncate(finishPart.Message, width-2-lipgloss.Width(errTag), "...")
516	title := fmt.Sprintf("%s %s", errTag, a.sty.Messages.ErrorTitle.Render(truncated))
517	details := a.sty.Messages.ErrorDetails.Width(width - 2).Render(finishPart.Details)
518	return fmt.Sprintf("%s\n\n%s", title, details)
519}
520
521// isSpinning returns true if the assistant message is still generating.
522func (a *AssistantMessageItem) isSpinning() bool {
523	isThinking := a.message.IsThinking()
524	isFinished := a.message.IsFinished()
525	hasContent := strings.TrimSpace(a.message.Content().Text) != ""
526	hasToolCalls := len(a.message.ToolCalls()) > 0
527	return (isThinking || !isFinished) && !hasContent && !hasToolCalls
528}
529
530// SetMessage is used to update the underlying message. Only the
531// sub-section caches whose source text or extras changed are
532// invalidated; the others survive and serve cache hits on the next
533// RawRender.
534func (a *AssistantMessageItem) SetMessage(msg *message.Message) tea.Cmd {
535	wasSpinning := a.isSpinning()
536	a.message = msg
537	// Bump the F6 version even if the underlying *message.Message
538	// pointer is identical: callers may have mutated the message in
539	// place (delta append) and we cannot tell from here. The
540	// per-section caches dedupe identical content via FNV-64 hashes,
541	// so a redundant bump only costs one list-cache repopulation.
542	a.Bump()
543	// The prefix cache is keyed by a fingerprint that includes every
544	// section's source hash, so an unchanged section keeps its prefix
545	// cache valid while a changed section forces a miss naturally.
546	// Section caches themselves are content-keyed, so they do not
547	// need an explicit drop here either.
548	if !wasSpinning && a.isSpinning() {
549		return a.StartAnimation()
550	}
551	return nil
552}
553
554// Finished implements list.Item. The assistant message is freezable
555// once the message reports IsFinished() and is no longer spinning
556// (no animation tick remains pending). Streaming tail animation is
557// caught by isSpinning, so freezing only kicks in once the turn is
558// fully terminal. The list cache invalidates the entry on the next
559// version bump if anything (focus, highlight, expansion) changes.
560func (a *AssistantMessageItem) Finished() bool {
561	return a.message.IsFinished() && !a.isSpinning()
562}
563
564// clearCache drops every cached render for this item, including the
565// per-section caches. Shadows the embedded cachedMessageItem.clearCache
566// so ClearItemCaches (style change) wipes the section caches too.
567func (a *AssistantMessageItem) clearCache() {
568	a.cachedMessageItem.clearCache()
569	a.thinkingSec.reset()
570	a.contentSec.reset()
571	a.errorSec.reset()
572}
573
574// ToggleExpanded advances the F5 thinking view-mode cycle and returns
575// whether the item is now in any expanded state (tail-window or full).
576// The cycle is collapsed → tail-window → full → collapsed, with the
577// tail-window step skipped when the rendered thinking fits within
578// maxExpandedThinkingTailLines so short blocks remain a two-click
579// toggle. Both the thinking section cache and the F3 prefix cache
580// fold thinkingViewMode into their keys, so no explicit invalidation
581// is required here.
582//
583// When the message carries no thinking text the toggle is a no-op:
584// there is nothing to expand, and mutating the view mode would
585// thrash the thinking-section cache key for no visible benefit.
586func (a *AssistantMessageItem) ToggleExpanded() bool {
587	if strings.TrimSpace(a.message.ReasoningContent().Thinking) == "" {
588		return a.thinkingViewMode != thinkingCollapsed
589	}
590	switch a.thinkingViewMode {
591	case thinkingCollapsed:
592		if a.tailWindowWouldTruncate() {
593			a.thinkingViewMode = thinkingTailWindow
594		} else {
595			a.thinkingViewMode = thinkingFullExpanded
596		}
597	case thinkingTailWindow:
598		a.thinkingViewMode = thinkingFullExpanded
599	case thinkingFullExpanded:
600		a.thinkingViewMode = thinkingCollapsed
601	}
602	a.Bump()
603	return a.thinkingViewMode != thinkingCollapsed
604}
605
606// tailWindowWouldTruncate reports whether the current thinking text
607// is long enough that the tail-window step is worth inserting into
608// the toggle cycle. We use a cheap source-text logical-line count
609// as the heuristic rather than peeking into the cache: the cache
610// may be populated in collapsed state (where its height is bounded
611// by maxCollapsedThinkingHeight and tells us nothing about the
612// underlying length), and re-running glamour just to count lines
613// would defeat the cache. The heuristic can over-trigger (a source
614// with many short lines may wrap to fewer than N lines), in which
615// case the tail-window render is visually identical to full and
616// the cycle costs the user one extra toggle — preferred over the
617// alternative of failing to show the affordance on a genuinely
618// long block.
619//
620// Logical line count is `1 + newlineCount` (a string with no
621// newlines is one line). Comparing newline count alone introduced
622// an off-by-one that let a source whose post-newline-split length
623// equalled the cap skip the tail-window step.
624func (a *AssistantMessageItem) tailWindowWouldTruncate() bool {
625	lineCount := 1 + strings.Count(a.message.ReasoningContent().Thinking, "\n")
626	return lineCount > maxExpandedThinkingTailLines
627}
628
629// HandleMouseClick implements MouseClickable. It signals (via a true return)
630// that the click lies on the thinking box so the caller can invoke
631// [AssistantMessageItem.ToggleExpanded] through the generic [Expandable]
632// path. Toggling here directly would double-toggle because the caller always
633// runs the generic path after a handled click.
634func (a *AssistantMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
635	if btn != ansi.MouseLeft {
636		return false
637	}
638	// Only the thinking box is clickable; other regions of the assistant
639	// message should not trigger expansion.
640	return a.thinkingBoxHeight > 0 && y < a.thinkingBoxHeight
641}
642
643// HandleKeyEvent implements KeyEventHandler.
644func (a *AssistantMessageItem) HandleKeyEvent(key tea.KeyMsg) (bool, tea.Cmd) {
645	if k := key.String(); k == "c" || k == "y" {
646		text := a.message.Content().Text
647		return true, common.CopyToClipboard(text, "Message copied to clipboard")
648	}
649	return false, nil
650}