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}