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 "git.secluded.site/crush/internal/message"
12 "git.secluded.site/crush/internal/ui/anim"
13 "git.secluded.site/crush/internal/ui/common"
14 "git.secluded.site/crush/internal/ui/list"
15 "git.secluded.site/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}