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	// streamingContent caches a "stable prefix" glamour render of
142	// the assistant content body so each streaming flush only
143	// re-renders the trailing partial. F8 of
144	// docs/notes/2026-05-12-chat-rendering-perf.md. See
145	// streaming_markdown.go for the full algorithm.
146	streamingContent streamingMarkdown
147}
148
149var _ Expandable = (*AssistantMessageItem)(nil)
150
151// NewAssistantMessageItem creates a new AssistantMessageItem.
152func NewAssistantMessageItem(sty *styles.Styles, message *message.Message) MessageItem {
153	v := list.NewVersioned()
154	a := &AssistantMessageItem{
155		Versioned:                v,
156		highlightableMessageItem: defaultHighlighter(sty, v),
157		cachedMessageItem:        &cachedMessageItem{},
158		focusableMessageItem:     newFocusableMessageItem(v),
159		message:                  message,
160		sty:                      sty,
161	}
162
163	a.anim = anim.New(anim.Settings{
164		ID:          a.ID(),
165		Size:        15,
166		GradColorA:  sty.WorkingGradFromColor,
167		GradColorB:  sty.WorkingGradToColor,
168		LabelColor:  sty.WorkingLabelColor,
169		CycleColors: true,
170	})
171	return a
172}
173
174// StartAnimation starts the assistant message animation if it should be spinning.
175func (a *AssistantMessageItem) StartAnimation() tea.Cmd {
176	if !a.isSpinning() {
177		return nil
178	}
179	return a.anim.Start()
180}
181
182// Animate progresses the assistant message animation if it should be spinning.
183func (a *AssistantMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
184	if !a.isSpinning() {
185		return nil
186	}
187	// Bump the F6 list-cache version so the next draw re-renders
188	// this item: a spinner tick mutates anim's internal frame
189	// counter, which changes the rendered output but is invisible
190	// to the per-section content hashes. Without the bump the
191	// list cache would serve the previously rendered frame
192	// indefinitely and the spinner would appear frozen.
193	a.Bump()
194	return a.anim.Animate(msg)
195}
196
197// ID implements MessageItem.
198func (a *AssistantMessageItem) ID() string {
199	return a.message.ID
200}
201
202// RawRender implements [MessageItem].
203func (a *AssistantMessageItem) RawRender(width int) string {
204	cappedWidth := cappedMessageWidth(width)
205
206	var spinner string
207	if a.isSpinning() {
208		spinner = a.renderSpinning()
209	}
210
211	content, height := a.renderMessageContent(cappedWidth)
212	highlightedContent := a.renderHighlighted(content, cappedWidth, height)
213	if spinner != "" {
214		if highlightedContent != "" {
215			highlightedContent += "\n\n"
216		}
217		return highlightedContent + spinner
218	}
219
220	return highlightedContent
221}
222
223// Render implements MessageItem.
224func (a *AssistantMessageItem) Render(width int) string {
225	// XXX: Here, we're manually applying the focused/blurred styles because
226	// using lipgloss.Render can degrade performance for long messages due to
227	// it's wrapping logic.
228	// We already know that the content is wrapped to the correct width in
229	// RawRender, so we can just apply the styles directly to each line.
230	//
231	// The split + per-line prefix loop is O(L); cache the result keyed
232	// by (width, focused, sectionsFingerprint) so steady-state Render
233	// becomes a pointer return. The sectionsFingerprint folds in the
234	// per-section srcHash/extra so that any sub-cache change
235	// invalidates this prefix cache without requiring an explicit
236	// drop. Bypass the cache while spinning (RawRender's spinner
237	// suffix changes every animation frame) or while a highlight
238	// range is active (selection drag).
239	useCache := !a.isSpinning() && !a.isHighlighted()
240	cappedWidth := cappedMessageWidth(width)
241	key := a.prefixCacheKey(cappedWidth)
242	if useCache {
243		if cached, ok := a.getCachedPrefixedRender(width, key); ok {
244			return cached
245		}
246	}
247	focused := a.sty.Messages.AssistantFocused.Render()
248	blurred := a.sty.Messages.AssistantBlurred.Render()
249	rendered := a.RawRender(width)
250	lines := strings.Split(rendered, "\n")
251	for i, line := range lines {
252		if a.focused {
253			lines[i] = focused + line
254		} else {
255			lines[i] = blurred + line
256		}
257	}
258	out := strings.Join(lines, "\n")
259	if useCache {
260		a.setCachedPrefixedRender(out, width, key)
261	}
262	return out
263}
264
265// prefixCacheKey builds the F3 prefixed-render cache key. We pack the
266// focus bit into bit 0 and a fingerprint of the section caches into
267// the upper bits, so any change to a sub-section's source text or
268// extras forces the prefix cache to miss without needing an explicit
269// drop. cappedWidth is included so a cached prefix never survives a
270// section-cache miss caused by a width change. The finish reason is
271// folded in too because it controls the composition of
272// renderMessageContent (e.g. appending the constant "Canceled"
273// string) — that decision lives outside any section's own hash.
274func (a *AssistantMessageItem) prefixCacheKey(cappedWidth int) uint64 {
275	thinkSrc, thinkExtra := a.thinkingKey()
276	contentSrc, contentExtra := a.contentKey()
277	errSrc, errExtra := a.errorKey()
278	h := fnv.New64a()
279	var buf [8]byte
280	writeU64 := func(v uint64) {
281		for i := range 8 {
282			buf[i] = byte(v >> (8 * i))
283		}
284		_, _ = h.Write(buf[:])
285	}
286	writeU64(uint64(cappedWidth))
287	writeU64(thinkSrc)
288	writeU64(thinkExtra)
289	writeU64(contentSrc)
290	writeU64(contentExtra)
291	writeU64(errSrc)
292	writeU64(errExtra)
293	writeU64(a.compositionKey())
294	fingerprint := h.Sum64()
295	var focusBit uint64
296	if a.focused {
297		focusBit = 1
298	}
299	return (fingerprint &^ 1) | focusBit
300}
301
302// compositionKey hashes the inputs to renderMessageContent's structural
303// decisions (which sections to include, whether to append the
304// constant "Canceled" footer) so that flipping IsFinished or the
305// finish reason invalidates the prefix cache even when no section's
306// own source text changed.
307func (a *AssistantMessageItem) compositionKey() uint64 {
308	var finishedFlag byte
309	var reason string
310	if a.message.IsFinished() {
311		finishedFlag = 1
312		reason = string(a.message.FinishReason())
313	}
314	// Length-prefixed framing keeps the finished flag and the reason
315	// string from blending into one another.
316	return fnvFields([]byte{finishedFlag}, []byte(reason))
317}
318
319// renderMessageContent renders the message content including thinking, main
320// content, and finish reason. Each section is served from its own cache;
321// only the section whose source text or extras changed since the last
322// render is recomputed.
323func (a *AssistantMessageItem) renderMessageContent(width int) (string, int) {
324	var messageParts []string
325	thinking := strings.TrimSpace(a.message.ReasoningContent().Thinking)
326	content := strings.TrimSpace(a.message.Content().Text)
327
328	if thinking != "" {
329		messageParts = append(messageParts, a.cachedThinking(width))
330	}
331
332	if content != "" {
333		if thinking != "" {
334			messageParts = append(messageParts, "")
335		}
336		messageParts = append(messageParts, a.cachedContent(width))
337	}
338
339	if a.message.IsFinished() {
340		switch a.message.FinishReason() {
341		case message.FinishReasonCanceled:
342			messageParts = append(messageParts, a.sty.Messages.AssistantCanceled.Render("Canceled"))
343		case message.FinishReasonError:
344			messageParts = append(messageParts, a.cachedError(width))
345		}
346	}
347
348	out := strings.Join(messageParts, "\n")
349	return out, lipgloss.Height(out)
350}
351
352// thinkingKey returns the (srcHash, extra) cache key components for the
353// thinking section. extra folds in everything other than the raw
354// thinking text that affects the rendered output: the view mode
355// (collapsed / tail-window / full) and the footer state (which
356// depends on IsThinking, ToolCalls, and ThinkingDuration).
357func (a *AssistantMessageItem) thinkingKey() (uint64, uint64) {
358	thinking := a.message.ReasoningContent().Thinking
359	srcHash := fnv64(thinking)
360
361	showFooter := !a.message.IsThinking() || len(a.message.ToolCalls()) > 0
362	var durationStr string
363	if showFooter {
364		duration := a.message.ThinkingDuration()
365		if duration.String() != "0s" {
366			durationStr = duration.String()
367		}
368	}
369	var footer byte
370	if showFooter {
371		footer = 1
372	}
373	// Length-prefixed framing avoids any delimiter collision between
374	// the flag bytes and the duration string. The view mode is folded
375	// in so that toggling collapsed ↔ tail-window ↔ full invalidates
376	// only the thinking section, not content/error.
377	extra := fnvFields([]byte{byte(a.thinkingViewMode), footer}, []byte(durationStr))
378	return srcHash, extra
379}
380
381// contentKey returns the (srcHash, extra) cache key components for the
382// main content section.
383func (a *AssistantMessageItem) contentKey() (uint64, uint64) {
384	return fnv64(a.message.Content().Text), 0
385}
386
387// errorKey returns the (srcHash, extra) cache key components for the
388// error section. Returns (0, 0) when no error is present so the cache
389// stays a no-op for non-error messages.
390func (a *AssistantMessageItem) errorKey() (uint64, uint64) {
391	if !a.message.IsFinished() || a.message.FinishReason() != message.FinishReasonError {
392		return 0, 0
393	}
394	finishPart := a.message.FinishPart()
395	if finishPart == nil {
396		return 0, 0
397	}
398	// Length-prefixed framing prevents Message+Details collisions
399	// between distinct (Message, Details) tuples that would
400	// otherwise concatenate to the same byte sequence.
401	return fnvFields([]byte(finishPart.Message), []byte(finishPart.Details)), 0
402}
403
404// cachedThinking returns the rendered thinking section, computing and
405// caching it on miss. The thinking-box height (used for click target
406// detection) is preserved across hits via assistantSection.aux so the
407// cached path never desyncs click detection.
408func (a *AssistantMessageItem) cachedThinking(width int) string {
409	srcHash, extra := a.thinkingKey()
410	if a.thinkingSec.hit(width, srcHash, extra) {
411		a.thinkingBoxHeight = a.thinkingSec.aux
412		return a.thinkingSec.out
413	}
414	out := a.renderThinking(a.message.ReasoningContent().Thinking, width)
415	a.thinkingSec.store(width, srcHash, extra, out, a.thinkingBoxHeight)
416	return out
417}
418
419// cachedContent returns the rendered content section.
420func (a *AssistantMessageItem) cachedContent(width int) string {
421	srcHash, extra := a.contentKey()
422	if a.contentSec.hit(width, srcHash, extra) {
423		return a.contentSec.out
424	}
425	out := a.renderMarkdown(a.message.Content().Text, width)
426	a.contentSec.store(width, srcHash, extra, out, 0)
427	return out
428}
429
430// cachedError returns the rendered error section.
431func (a *AssistantMessageItem) cachedError(width int) string {
432	srcHash, extra := a.errorKey()
433	if a.errorSec.hit(width, srcHash, extra) {
434		return a.errorSec.out
435	}
436	out := a.renderError(width)
437	a.errorSec.store(width, srcHash, extra, out, 0)
438	return out
439}
440
441// renderThinking renders the thinking/reasoning content with footer.
442//
443// Slicing happens AFTER glamour rendering so fenced code blocks, list
444// continuations, and tables are not split mid-block — the same
445// boundary problem §4.4 of the design note flags. The bordered
446// ThinkingBox style is applied on top of the (already-windowed)
447// lines so the visual box matches what the user sees today.
448func (a *AssistantMessageItem) renderThinking(thinking string, width int) string {
449	renderer := common.QuietMarkdownRenderer(a.sty, width)
450	mu := common.LockMarkdownRenderer(renderer)
451	mu.Lock()
452	rendered, err := renderer.Render(thinking)
453	mu.Unlock()
454	if err != nil {
455		rendered = thinking
456	}
457	rendered = strings.TrimSpace(rendered)
458
459	lines := strings.Split(rendered, "\n")
460	totalLines := len(lines)
461
462	switch a.thinkingViewMode {
463	case thinkingCollapsed:
464		if totalLines > maxCollapsedThinkingHeight {
465			lines = lines[totalLines-maxCollapsedThinkingHeight:]
466			hint := a.sty.Messages.ThinkingTruncationHint.Render(
467				fmt.Sprintf(assistantMessageTruncateFormat, totalLines-maxCollapsedThinkingHeight),
468			)
469			lines = append([]string{hint, ""}, lines...)
470		}
471	case thinkingTailWindow:
472		if totalLines > maxExpandedThinkingTailLines {
473			lines = lines[totalLines-maxExpandedThinkingTailLines:]
474			hint := a.sty.Messages.ThinkingTruncationHint.Render(
475				fmt.Sprintf(assistantMessageTailWindowFormat, totalLines-maxExpandedThinkingTailLines),
476			)
477			lines = append([]string{hint, ""}, lines...)
478		}
479	}
480
481	thinkingStyle := a.sty.Messages.ThinkingBox.Width(width)
482	result := thinkingStyle.Render(strings.Join(lines, "\n"))
483	a.thinkingBoxHeight = lipgloss.Height(result)
484
485	var footer string
486	// if thinking is done add the thought for footer
487	if !a.message.IsThinking() || len(a.message.ToolCalls()) > 0 {
488		duration := a.message.ThinkingDuration()
489		if duration.String() != "0s" {
490			footer = a.sty.Messages.ThinkingFooterTitle.Render("Thought for ") +
491				a.sty.Messages.ThinkingFooterDuration.Render(duration.String())
492		}
493	}
494
495	if footer != "" {
496		result += "\n\n" + footer
497	}
498
499	return result
500}
501
502// renderMarkdown renders content as markdown. F8 routes the call
503// through streamingContent, which caches the glamour render of a
504// "stable prefix" so each streaming flush only re-renders the
505// trailing partial. The streaming cache invalidates itself on
506// width change and on any content that is not a prefix-extension
507// of the previously rendered content (e.g. user retried the
508// turn), and falls back to a full render whenever boundary
509// detection has the slightest doubt — see
510// findSafeMarkdownBoundary.
511func (a *AssistantMessageItem) renderMarkdown(content string, width int) string {
512	renderer := common.MarkdownRenderer(a.sty, width)
513	return a.streamingContent.Render(content, width, renderer)
514}
515
516func (a *AssistantMessageItem) renderSpinning() string {
517	if a.message.IsThinking() {
518		a.anim.SetLabel("Thinking")
519	} else if a.message.IsSummaryMessage {
520		a.anim.SetLabel("Summarizing")
521	}
522	return a.anim.Render()
523}
524
525// renderError renders an error message.
526func (a *AssistantMessageItem) renderError(width int) string {
527	finishPart := a.message.FinishPart()
528	errTag := a.sty.Messages.ErrorTag.Render("ERROR")
529	truncated := ansi.Truncate(finishPart.Message, width-2-lipgloss.Width(errTag), "...")
530	title := fmt.Sprintf("%s %s", errTag, a.sty.Messages.ErrorTitle.Render(truncated))
531	details := a.sty.Messages.ErrorDetails.Width(width - 2).Render(finishPart.Details)
532	return fmt.Sprintf("%s\n\n%s", title, details)
533}
534
535// isSpinning returns true if the assistant message is still generating.
536func (a *AssistantMessageItem) isSpinning() bool {
537	isThinking := a.message.IsThinking()
538	isFinished := a.message.IsFinished()
539	hasContent := strings.TrimSpace(a.message.Content().Text) != ""
540	hasToolCalls := len(a.message.ToolCalls()) > 0
541	return (isThinking || !isFinished) && !hasContent && !hasToolCalls
542}
543
544// SetMessage is used to update the underlying message. Only the
545// sub-section caches whose source text or extras changed are
546// invalidated; the others survive and serve cache hits on the next
547// RawRender.
548func (a *AssistantMessageItem) SetMessage(msg *message.Message) tea.Cmd {
549	wasSpinning := a.isSpinning()
550	a.message = msg
551	// Bump the F6 version even if the underlying *message.Message
552	// pointer is identical: callers may have mutated the message in
553	// place (delta append) and we cannot tell from here. The
554	// per-section caches dedupe identical content via FNV-64 hashes,
555	// so a redundant bump only costs one list-cache repopulation.
556	a.Bump()
557	// The prefix cache is keyed by a fingerprint that includes every
558	// section's source hash, so an unchanged section keeps its prefix
559	// cache valid while a changed section forces a miss naturally.
560	// Section caches themselves are content-keyed, so they do not
561	// need an explicit drop here either.
562	if !wasSpinning && a.isSpinning() {
563		return a.StartAnimation()
564	}
565	return nil
566}
567
568// Finished implements list.Item. The assistant message is freezable
569// once the message reports IsFinished() and is no longer spinning
570// (no animation tick remains pending). Streaming tail animation is
571// caught by isSpinning, so freezing only kicks in once the turn is
572// fully terminal. The list cache invalidates the entry on the next
573// version bump if anything (focus, highlight, expansion) changes.
574func (a *AssistantMessageItem) Finished() bool {
575	return a.message.IsFinished() && !a.isSpinning()
576}
577
578// clearCache drops every cached render for this item, including the
579// per-section caches. Shadows the embedded cachedMessageItem.clearCache
580// so ClearItemCaches (style change) wipes the section caches too.
581// F8: also drop the streaming-markdown stable-prefix cache because
582// the cached glamour render embeds the OLD style's ANSI sequences
583// and is no longer visually consistent with the new style.
584func (a *AssistantMessageItem) clearCache() {
585	a.cachedMessageItem.clearCache()
586	a.thinkingSec.reset()
587	a.contentSec.reset()
588	a.errorSec.reset()
589	a.streamingContent.Reset()
590}
591
592// ToggleExpanded advances the F5 thinking view-mode cycle and returns
593// whether the item is now in any expanded state (tail-window or full).
594// The cycle is collapsed → tail-window → full → collapsed, with the
595// tail-window step skipped when the rendered thinking fits within
596// maxExpandedThinkingTailLines so short blocks remain a two-click
597// toggle. Both the thinking section cache and the F3 prefix cache
598// fold thinkingViewMode into their keys, so no explicit invalidation
599// is required here.
600//
601// When the message carries no thinking text the toggle is a no-op:
602// there is nothing to expand, and mutating the view mode would
603// thrash the thinking-section cache key for no visible benefit.
604func (a *AssistantMessageItem) ToggleExpanded() bool {
605	if strings.TrimSpace(a.message.ReasoningContent().Thinking) == "" {
606		return a.thinkingViewMode != thinkingCollapsed
607	}
608	switch a.thinkingViewMode {
609	case thinkingCollapsed:
610		if a.tailWindowWouldTruncate() {
611			a.thinkingViewMode = thinkingTailWindow
612		} else {
613			a.thinkingViewMode = thinkingFullExpanded
614		}
615	case thinkingTailWindow:
616		a.thinkingViewMode = thinkingFullExpanded
617	case thinkingFullExpanded:
618		a.thinkingViewMode = thinkingCollapsed
619	}
620	a.Bump()
621	return a.thinkingViewMode != thinkingCollapsed
622}
623
624// tailWindowWouldTruncate reports whether the current thinking text
625// is long enough that the tail-window step is worth inserting into
626// the toggle cycle. We use a cheap source-text logical-line count
627// as the heuristic rather than peeking into the cache: the cache
628// may be populated in collapsed state (where its height is bounded
629// by maxCollapsedThinkingHeight and tells us nothing about the
630// underlying length), and re-running glamour just to count lines
631// would defeat the cache. The heuristic can over-trigger (a source
632// with many short lines may wrap to fewer than N lines), in which
633// case the tail-window render is visually identical to full and
634// the cycle costs the user one extra toggle — preferred over the
635// alternative of failing to show the affordance on a genuinely
636// long block.
637//
638// Logical line count is `1 + newlineCount` (a string with no
639// newlines is one line). Comparing newline count alone introduced
640// an off-by-one that let a source whose post-newline-split length
641// equalled the cap skip the tail-window step.
642func (a *AssistantMessageItem) tailWindowWouldTruncate() bool {
643	lineCount := 1 + strings.Count(a.message.ReasoningContent().Thinking, "\n")
644	return lineCount > maxExpandedThinkingTailLines
645}
646
647// HandleMouseClick implements MouseClickable. It signals (via a true return)
648// that the click lies on the thinking box so the caller can invoke
649// [AssistantMessageItem.ToggleExpanded] through the generic [Expandable]
650// path. Toggling here directly would double-toggle because the caller always
651// runs the generic path after a handled click.
652func (a *AssistantMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
653	if btn != ansi.MouseLeft {
654		return false
655	}
656	// Only the thinking box is clickable; other regions of the assistant
657	// message should not trigger expansion.
658	return a.thinkingBoxHeight > 0 && y < a.thinkingBoxHeight
659}
660
661// HandleKeyEvent implements KeyEventHandler.
662func (a *AssistantMessageItem) HandleKeyEvent(key tea.KeyMsg) (bool, tea.Cmd) {
663	if k := key.String(); k == "c" || k == "y" {
664		text := a.message.Content().Text
665		return true, common.CopyToClipboard(text, "Message copied to clipboard")
666	}
667	return false, nil
668}