perf(chat): show only the tail of long reasoning blocks when expanded

Christian Rocha and Charm Crush created

When a reasoning block is expanded, show only the most recent few hundred
lines by default with a hint that more lines are hidden, instead of
rendering the entire thing. Clicking again shows the full block.
This keeps the UI responsive when models produce very long reasoning.

Co-Authored-By: Charm Crush <crush@charm.land>

Change summary

internal/ui/chat/assistant.go                      | 137 +++
internal/ui/chat/assistant_section_cache_test.go   |   2 
internal/ui/chat/assistant_test.go                 | 112 +++
internal/ui/chat/assistant_thinking_window_test.go | 500 ++++++++++++++++
internal/ui/model/chat_expand_test.go              |   8 
5 files changed, 720 insertions(+), 39 deletions(-)

Detailed changes

internal/ui/chat/assistant.go ๐Ÿ”—

@@ -16,12 +16,41 @@ import (
 )
 
 // assistantMessageTruncateFormat is the text shown when an assistant message is
-// truncated.
+// truncated in the collapsed state.
 const assistantMessageTruncateFormat = "โ€ฆ (%d lines hidden) [click or space to expand]"
 
+// assistantMessageTailWindowFormat is shown above a tail-windowed thinking
+// block to advertise that earlier lines exist and that the user can
+// promote the view to a full expansion. The promotion is wired through
+// the existing ToggleExpanded path (click / space) โ€” F5 deliberately
+// does not add a new keybinding.
+const assistantMessageTailWindowFormat = "โ€ฆ %d earlier lines hidden [click or space for full view]"
+
 // maxCollapsedThinkingHeight defines the maximum height of the thinking
 const maxCollapsedThinkingHeight = 10
 
+// maxExpandedThinkingTailLines is the F5 tail-window cap. When the user
+// expands a thinking block whose post-glamour line count exceeds this
+// threshold, only the last N lines are shown with an affordance line
+// indicating how many earlier lines are hidden. Clicking / pressing
+// space again promotes the view to a full expansion. The slice is
+// taken AFTER glamour render (not before) so fenced code blocks,
+// lists, and tables are not torn at arbitrary boundaries.
+const maxExpandedThinkingTailLines = 200
+
+// thinkingViewMode is the F5 three-state view machine for the thinking
+// block. ToggleExpanded cycles
+// collapsed โ†’ tail-window โ†’ full-expanded โ†’ collapsed, skipping the
+// tail-window step when the rendered thinking fits within the cap so
+// short blocks still toggle in two clicks.
+type thinkingViewMode uint8
+
+const (
+	thinkingCollapsed thinkingViewMode = iota
+	thinkingTailWindow
+	thinkingFullExpanded
+)
+
 // assistantSection is a per-section render cache for AssistantMessageItem.
 // Each section (thinking, content, error) carries its own keys so that
 // streaming a section does not invalidate a different โ€” often more
@@ -97,7 +126,7 @@ type AssistantMessageItem struct {
 	message           *message.Message
 	sty               *styles.Styles
 	anim              *anim.Anim
-	thinkingExpanded  bool
+	thinkingViewMode  thinkingViewMode
 	thinkingBoxHeight int // Tracks the rendered thinking box height for click detection.
 
 	// Per-section render caches. Splitting these out means content
@@ -304,9 +333,9 @@ func (a *AssistantMessageItem) renderMessageContent(width int) (string, int) {
 
 // thinkingKey returns the (srcHash, extra) cache key components for the
 // thinking section. extra folds in everything other than the raw
-// thinking text that affects the rendered output: the expanded flag
-// and the footer state (which depends on IsThinking, ToolCalls, and
-// ThinkingDuration).
+// thinking text that affects the rendered output: the view mode
+// (collapsed / tail-window / full) and the footer state (which
+// depends on IsThinking, ToolCalls, and ThinkingDuration).
 func (a *AssistantMessageItem) thinkingKey() (uint64, uint64) {
 	thinking := a.message.ReasoningContent().Thinking
 	srcHash := fnv64(thinking)
@@ -319,17 +348,15 @@ func (a *AssistantMessageItem) thinkingKey() (uint64, uint64) {
 			durationStr = duration.String()
 		}
 	}
-	var expanded byte
-	if a.thinkingExpanded {
-		expanded = 1
-	}
 	var footer byte
 	if showFooter {
 		footer = 1
 	}
 	// Length-prefixed framing avoids any delimiter collision between
-	// the flag bytes and the duration string.
-	extra := fnvFields([]byte{expanded, footer}, []byte(durationStr))
+	// the flag bytes and the duration string. The view mode is folded
+	// in so that toggling collapsed โ†” tail-window โ†” full invalidates
+	// only the thinking section, not content/error.
+	extra := fnvFields([]byte{byte(a.thinkingViewMode), footer}, []byte(durationStr))
 	return srcHash, extra
 }
 
@@ -394,6 +421,12 @@ func (a *AssistantMessageItem) cachedError(width int) string {
 }
 
 // renderThinking renders the thinking/reasoning content with footer.
+//
+// Slicing happens AFTER glamour rendering so fenced code blocks, list
+// continuations, and tables are not split mid-block โ€” the same
+// boundary problem ยง4.4 of the design note flags. The bordered
+// ThinkingBox style is applied on top of the (already-windowed)
+// lines so the visual box matches what the user sees today.
 func (a *AssistantMessageItem) renderThinking(thinking string, width int) string {
 	renderer := common.QuietMarkdownRenderer(a.sty, width)
 	rendered, err := renderer.Render(thinking)
@@ -405,13 +438,23 @@ func (a *AssistantMessageItem) renderThinking(thinking string, width int) string
 	lines := strings.Split(rendered, "\n")
 	totalLines := len(lines)
 
-	isTruncated := totalLines > maxCollapsedThinkingHeight
-	if !a.thinkingExpanded && isTruncated {
-		lines = lines[totalLines-maxCollapsedThinkingHeight:]
-		hint := a.sty.Messages.ThinkingTruncationHint.Render(
-			fmt.Sprintf(assistantMessageTruncateFormat, totalLines-maxCollapsedThinkingHeight),
-		)
-		lines = append([]string{hint, ""}, lines...)
+	switch a.thinkingViewMode {
+	case thinkingCollapsed:
+		if totalLines > maxCollapsedThinkingHeight {
+			lines = lines[totalLines-maxCollapsedThinkingHeight:]
+			hint := a.sty.Messages.ThinkingTruncationHint.Render(
+				fmt.Sprintf(assistantMessageTruncateFormat, totalLines-maxCollapsedThinkingHeight),
+			)
+			lines = append([]string{hint, ""}, lines...)
+		}
+	case thinkingTailWindow:
+		if totalLines > maxExpandedThinkingTailLines {
+			lines = lines[totalLines-maxExpandedThinkingTailLines:]
+			hint := a.sty.Messages.ThinkingTruncationHint.Render(
+				fmt.Sprintf(assistantMessageTailWindowFormat, totalLines-maxExpandedThinkingTailLines),
+			)
+			lines = append([]string{hint, ""}, lines...)
+		}
 	}
 
 	thinkingStyle := a.sty.Messages.ThinkingBox.Width(width)
@@ -501,14 +544,58 @@ func (a *AssistantMessageItem) clearCache() {
 	a.errorSec.reset()
 }
 
-// ToggleExpanded toggles the expanded state of the thinking box and returns
-// whether the item is now expanded. Both the thinking section cache and
-// the F3 prefix cache key fold in thinkingExpanded (via the section's
-// extra hash and the prefix cache fingerprint respectively), so no
-// explicit invalidation is required.
+// ToggleExpanded advances the F5 thinking view-mode cycle and returns
+// whether the item is now in any expanded state (tail-window or full).
+// The cycle is collapsed โ†’ tail-window โ†’ full โ†’ collapsed, with the
+// tail-window step skipped when the rendered thinking fits within
+// maxExpandedThinkingTailLines so short blocks remain a two-click
+// toggle. Both the thinking section cache and the F3 prefix cache
+// fold thinkingViewMode into their keys, so no explicit invalidation
+// is required here.
+//
+// When the message carries no thinking text the toggle is a no-op:
+// there is nothing to expand, and mutating the view mode would
+// thrash the thinking-section cache key for no visible benefit.
 func (a *AssistantMessageItem) ToggleExpanded() bool {
-	a.thinkingExpanded = !a.thinkingExpanded
-	return a.thinkingExpanded
+	if strings.TrimSpace(a.message.ReasoningContent().Thinking) == "" {
+		return a.thinkingViewMode != thinkingCollapsed
+	}
+	switch a.thinkingViewMode {
+	case thinkingCollapsed:
+		if a.tailWindowWouldTruncate() {
+			a.thinkingViewMode = thinkingTailWindow
+		} else {
+			a.thinkingViewMode = thinkingFullExpanded
+		}
+	case thinkingTailWindow:
+		a.thinkingViewMode = thinkingFullExpanded
+	case thinkingFullExpanded:
+		a.thinkingViewMode = thinkingCollapsed
+	}
+	return a.thinkingViewMode != thinkingCollapsed
+}
+
+// tailWindowWouldTruncate reports whether the current thinking text
+// is long enough that the tail-window step is worth inserting into
+// the toggle cycle. We use a cheap source-text logical-line count
+// as the heuristic rather than peeking into the cache: the cache
+// may be populated in collapsed state (where its height is bounded
+// by maxCollapsedThinkingHeight and tells us nothing about the
+// underlying length), and re-running glamour just to count lines
+// would defeat the cache. The heuristic can over-trigger (a source
+// with many short lines may wrap to fewer than N lines), in which
+// case the tail-window render is visually identical to full and
+// the cycle costs the user one extra toggle โ€” preferred over the
+// alternative of failing to show the affordance on a genuinely
+// long block.
+//
+// Logical line count is `1 + newlineCount` (a string with no
+// newlines is one line). Comparing newline count alone introduced
+// an off-by-one that let a source whose post-newline-split length
+// equalled the cap skip the tail-window step.
+func (a *AssistantMessageItem) tailWindowWouldTruncate() bool {
+	lineCount := 1 + strings.Count(a.message.ReasoningContent().Thinking, "\n")
+	return lineCount > maxExpandedThinkingTailLines
 }
 
 // HandleMouseClick implements MouseClickable. It signals (via a true return)

internal/ui/chat/assistant_section_cache_test.go ๐Ÿ”—

@@ -441,7 +441,7 @@ func TestAssistantSectionCache_ThinkingBoxHeightSurvivesCacheHit(t *testing.T) {
 	}, "\n")
 	msg := thinkingMessage("hbox", thinking, "initial answer")
 	item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem)
-	item.thinkingExpanded = true
+	item.thinkingViewMode = thinkingFullExpanded
 
 	_ = item.RawRender(width)
 	originalHeight := item.thinkingBoxHeight

internal/ui/chat/assistant_test.go ๐Ÿ”—

@@ -10,21 +10,108 @@ import (
 )
 
 // TestAssistantMessageItemExpandable guards the Expandable contract on
-// AssistantMessageItem. The earlier implementation returned no value, which
-// meant the type silently did not satisfy chat.Expandable and the
-// keyboard-driven expand path in model/chat.go skipped thinking blocks.
+// AssistantMessageItem along the keyboard-driven expand path. The earlier
+// implementation returned no value, which meant the type silently did
+// not satisfy chat.Expandable and the keyboard-driven expand path in
+// model/chat.go skipped thinking blocks.
+//
+// We exercise the contract through the bare Expandable interface (the
+// same dispatch site model.Chat.ToggleExpandedSelectedItem uses), which
+// proves both that AssistantMessageItem still satisfies the interface
+// and that the bool return reports the right semantic state at every
+// point in the cycle.
 func TestAssistantMessageItemExpandable(t *testing.T) {
 	t.Parallel()
 
 	sty := styles.CharmtonePantera()
-	msg := &message.Message{ID: "m1", Role: message.Assistant}
-	item := NewAssistantMessageItem(&sty, msg)
+	// Short thinking: under the tail-window cap, so the cycle is
+	// collapsed -> full -> collapsed (tail-window is skipped).
+	msg := thinkingMessage("m1", "step one\nstep two\nstep three", "")
+	item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem)
 
-	exp, ok := item.(Expandable)
+	exp, ok := any(item).(Expandable)
 	require.True(t, ok, "AssistantMessageItem must satisfy Expandable")
 
-	require.True(t, exp.ToggleExpanded(), "first toggle should report expanded")
-	require.False(t, exp.ToggleExpanded(), "second toggle should report collapsed")
+	require.Equal(t, thinkingCollapsed, item.thinkingViewMode,
+		"new items must start in the collapsed view-mode")
+	require.True(t, exp.ToggleExpanded(),
+		"first toggle of a non-empty thinking block must report expanded")
+	require.Equal(t, thinkingFullExpanded, item.thinkingViewMode,
+		"short blocks must skip tail-window and land in full expansion")
+	require.False(t, exp.ToggleExpanded(),
+		"second toggle must report collapsed (cycle closed)")
+	require.Equal(t, thinkingCollapsed, item.thinkingViewMode)
+}
+
+// TestAssistantMessageItemExpandableEmptyThinkingNoOp guards the B2
+// fix: a message with no thinking text must treat ToggleExpanded as a
+// no-op. Mutating the view mode in that case would thrash the
+// thinking-section cache key for no visible benefit and would surprise
+// the caller (model.Chat.ToggleExpandedSelectedItem would treat a
+// "now collapsed" return as a real state change and re-scroll on it).
+func TestAssistantMessageItemExpandableEmptyThinkingNoOp(t *testing.T) {
+	t.Parallel()
+
+	sty := styles.CharmtonePantera()
+	msg := &message.Message{ID: "m1-empty", Role: message.Assistant}
+	item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem)
+
+	exp, ok := any(item).(Expandable)
+	require.True(t, ok, "AssistantMessageItem must satisfy Expandable")
+
+	require.Equal(t, thinkingCollapsed, item.thinkingViewMode)
+	require.False(t, exp.ToggleExpanded(),
+		"empty thinking must report current (collapsed) state without flipping")
+	require.Equal(t, thinkingCollapsed, item.thinkingViewMode,
+		"empty-thinking toggle must not mutate thinkingViewMode")
+
+	// Whitespace-only thinking is still effectively empty.
+	item.message.Parts = []message.ContentPart{
+		message.ReasoningContent{Thinking: "  \n\n\t  ", StartedAt: testStartedAt},
+	}
+	require.False(t, exp.ToggleExpanded())
+	require.Equal(t, thinkingCollapsed, item.thinkingViewMode)
+}
+
+// TestAssistantMessageItemTailWindowBoundary guards the B1 fix: the
+// tail-window heuristic must compare logical line counts (1 +
+// newline count) against the cap, not raw newline counts. A source
+// whose logical line count exactly equals the cap must NOT trip the
+// tail-window step (full render still fits cleanly under the cap),
+// while one logical line over the cap must trip it.
+func TestAssistantMessageItemTailWindowBoundary(t *testing.T) {
+	t.Parallel()
+
+	sty := styles.CharmtonePantera()
+
+	atCap := buildLines(maxExpandedThinkingTailLines)
+	overCap := buildLines(maxExpandedThinkingTailLines + 1)
+
+	atItem := NewAssistantMessageItem(&sty, thinkingMessage("at-cap", atCap, "")).(*AssistantMessageItem)
+	require.False(t, atItem.tailWindowWouldTruncate(),
+		"a source with exactly N logical lines must not trip the tail-window step")
+
+	overItem := NewAssistantMessageItem(&sty, thinkingMessage("over-cap", overCap, "")).(*AssistantMessageItem)
+	require.True(t, overItem.tailWindowWouldTruncate(),
+		"a source with N+1 logical lines must trip the tail-window step")
+}
+
+// buildLines returns a string of n logical lines (n-1 newlines). Each
+// line is a unique short token so callers can distinguish head from
+// tail in rendered output if they need to.
+func buildLines(n int) string {
+	if n <= 0 {
+		return ""
+	}
+	var b []byte
+	for i := 1; i <= n; i++ {
+		if i > 1 {
+			b = append(b, '\n')
+		}
+		b = append(b, 'l', 'n')
+		b = append(b, []byte(itoa(i))...)
+	}
+	return string(b)
 }
 
 // TestAssistantMessageItemHandleMouseClick ensures HandleMouseClick does not
@@ -40,15 +127,16 @@ func TestAssistantMessageItemHandleMouseClick(t *testing.T) {
 	item.thinkingBoxHeight = 5
 
 	// Click inside the thinking box signals handled but must not mutate
-	// the expanded state.
+	// the view-mode state.
 	require.True(t, item.HandleMouseClick(ansi.MouseLeft, 0, 2))
-	require.False(t, item.thinkingExpanded, "HandleMouseClick must not toggle expansion on its own")
+	require.Equal(t, thinkingCollapsed, item.thinkingViewMode,
+		"HandleMouseClick must not toggle expansion on its own")
 
 	// Click outside the thinking box is ignored entirely.
 	require.False(t, item.HandleMouseClick(ansi.MouseLeft, 0, 10))
-	require.False(t, item.thinkingExpanded)
+	require.Equal(t, thinkingCollapsed, item.thinkingViewMode)
 
 	// Non-left button is ignored.
 	require.False(t, item.HandleMouseClick(ansi.MouseRight, 0, 2))
-	require.False(t, item.thinkingExpanded)
+	require.Equal(t, thinkingCollapsed, item.thinkingViewMode)
 }

internal/ui/chat/assistant_thinking_window_test.go ๐Ÿ”—

@@ -0,0 +1,500 @@
+package chat
+
+import (
+	"strings"
+	"testing"
+
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	"github.com/charmbracelet/x/ansi"
+	"github.com/stretchr/testify/require"
+)
+
+// thinkingMessageWithLines builds a still-thinking assistant message
+// whose reasoning content is `count` short paragraphs separated by
+// blank lines. The blank-line separation is what matters: glamour
+// renders paragraph blocks one-per-line in the output (with a
+// trailing blank line between paragraphs) instead of reflowing the
+// entire input into one big wrapped paragraph. That gives us a
+// post-glamour line count we can drive past the tail-window
+// threshold deterministically. Each paragraph is tagged with its
+// (1-based) index so the test can identify head vs tail in the
+// rendered output.
+//
+// The message has no text content and no Finish part, so
+// IsThinking() returns true and the render path skips the
+// "Thought for" footer โ€” keeping the rendered height computation
+// simple.
+func thinkingMessageWithLines(id string, count int) *message.Message {
+	var b strings.Builder
+	for i := 1; i <= count; i++ {
+		b.WriteString("ln")
+		b.WriteString(itoa(i))
+		if i < count {
+			// Blank line between paragraphs: glamour preserves the
+			// per-paragraph structure rather than reflowing into one
+			// wrapped block, so totalLines tracks count predictably.
+			b.WriteString("\n\n")
+		}
+	}
+	return &message.Message{
+		ID:   id,
+		Role: message.Assistant,
+		Parts: []message.ContentPart{
+			message.ReasoningContent{
+				Thinking:  b.String(),
+				StartedAt: testStartedAt,
+			},
+		},
+	}
+}
+
+// itoa is a local stdlib-free integer formatter used only by these
+// tests; pulling fmt in just for %d would be wasteful when the test
+// fixtures already churn 5000+ short strings.
+func itoa(n int) string {
+	if n == 0 {
+		return "0"
+	}
+	var buf [20]byte
+	i := len(buf)
+	for n > 0 {
+		i--
+		buf[i] = byte('0' + n%10)
+		n /= 10
+	}
+	return string(buf[i:])
+}
+
+// renderedThinkingHeight returns the line count of the cached
+// thinking section render only (not the full RawRender, which also
+// includes the content and error sections). Drives a render at
+// `width` first to populate the cache.
+func renderedThinkingHeight(t *testing.T, item *AssistantMessageItem, width int) int {
+	t.Helper()
+	_ = item.RawRender(width)
+	require.NotEmpty(t, item.thinkingSec.out,
+		"thinking section must be populated after RawRender")
+	return lipgloss.Height(item.thinkingSec.out)
+}
+
+// TestThinkingWindow_CollapsedCapPreserved guards that F5 did not
+// regress the existing collapsed-mode behaviour: a 5000-line
+// thinking block in the default (collapsed) state still renders at
+// most a small bounded height โ€” the last `maxCollapsedThinkingHeight`
+// lines plus the truncation hint. The thinking message keeps
+// IsThinking() == true, so the optional "Thought for" footer is
+// suppressed and the section height equals the box height.
+func TestThinkingWindow_CollapsedCapPreserved(t *testing.T) {
+	t.Parallel()
+
+	sty := styles.CharmtonePantera()
+	msg := thinkingMessageWithLines("collapsed", 5000)
+	item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem)
+
+	// Default state must be collapsed.
+	require.Equal(t, thinkingCollapsed, item.thinkingViewMode)
+
+	// Unique odd width avoids sharing the glamour renderer cache with
+	// any other parallel test (the renderer instance is memoized per
+	// width and is not safe for concurrent Render calls).
+	const width = 91
+	height := renderedThinkingHeight(t, item, width)
+
+	// Collapsed mode keeps the existing cap: last 10 lines + a
+	// 2-line hint prefix (hint + blank). Allow a small slack for
+	// any future style-driven padding so the test is robust to
+	// cosmetic tweaks while still being orders of magnitude below
+	// the 5000-line source.
+	const collapsedUpperBound = maxCollapsedThinkingHeight + 5
+	require.LessOrEqual(t, height, collapsedUpperBound,
+		"collapsed mode must remain bounded by the small cap; got %d", height)
+}
+
+// TestThinkingWindow_ExpandedShortSkipsTailWindow guards that a
+// short thinking block (well under the tail-window cap) still
+// toggles directly to full expansion without an intermediate
+// tail-window step and shows no affordance footer. The cycle is
+// collapsed -> full -> collapsed for short blocks; tail-window is
+// only inserted when it would actually elide content.
+func TestThinkingWindow_ExpandedShortSkipsTailWindow(t *testing.T) {
+	t.Parallel()
+
+	sty := styles.CharmtonePantera()
+	const lines = 50
+	require.Less(t, lines, maxExpandedThinkingTailLines,
+		"this test relies on the source being well under the tail cap")
+	msg := thinkingMessageWithLines("short", lines)
+	item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem)
+
+	require.True(t, item.ToggleExpanded(),
+		"first toggle should report expanded")
+	require.Equal(t, thinkingFullExpanded, item.thinkingViewMode,
+		"short blocks must skip tail-window and go straight to full expansion")
+
+	const width = 93
+	_ = item.RawRender(width)
+	out := item.thinkingSec.out
+	plain := ansi.Strip(out)
+
+	require.NotContains(t, plain, "earlier lines hidden",
+		"short blocks must not show the tail-window affordance")
+	require.NotContains(t, plain, "lines hidden",
+		"short expanded blocks must not show any truncation hint")
+	require.Contains(t, plain, "ln1 ",
+		"a fully expanded short block must include the very first source paragraph")
+	require.Contains(t, plain, "ln50 ",
+		"a fully expanded short block must include the last source paragraph")
+}
+
+// TestThinkingWindow_TailWindowed asserts the central F5 behaviour:
+// expanding a long thinking block produces a tail window of size
+// `maxExpandedThinkingTailLines` plus the affordance footer, with
+// the LAST source line present (i.e. we tailed, not headed) and
+// earlier lines elided.
+//
+// Beyond presence/absence of sentinels, this test verifies a true
+// `tail -K` relationship between the tail-windowed render and the
+// fully-expanded render of the same source at the same width: the
+// last K plain-ANSI lines of the windowed render must byte-equal
+// the last K lines of the unwindowed render.
+//
+// K is sized below the cap to absorb the affordance prefix (hint +
+// blank line) and any small framing differences introduced by the
+// bordered ThinkingBox. The cap minus 5 leaves a comfortable margin
+// for padding/footer rows while still asserting that the bulk of
+// the rendered tail is identical.
+func TestThinkingWindow_TailWindowed(t *testing.T) {
+	t.Parallel()
+
+	sty := styles.CharmtonePantera()
+	const total = 5000
+	const width = 95
+
+	// Tail-windowed render.
+	tailMsg := thinkingMessageWithLines("tail", total)
+	tailItem := NewAssistantMessageItem(&sty, tailMsg).(*AssistantMessageItem)
+	require.True(t, tailItem.ToggleExpanded(), "first toggle should report expanded")
+	require.Equal(t, thinkingTailWindow, tailItem.thinkingViewMode,
+		"a long block must enter tail-window after the first toggle")
+
+	height := renderedThinkingHeight(t, tailItem, width)
+
+	// The visible window is N tail lines plus an affordance line
+	// and a blank-line spacer (matching the existing collapsed-mode
+	// hint structure). Allow a small slack for style-driven
+	// padding.
+	const expectedFloor = maxExpandedThinkingTailLines + 1
+	const expectedCeil = maxExpandedThinkingTailLines + 5
+	require.GreaterOrEqual(t, height, expectedFloor,
+		"tail-window must include at least N + affordance lines; got %d", height)
+	require.LessOrEqual(t, height, expectedCeil,
+		"tail-window must not exceed N + a small padding budget; got %d", height)
+
+	tailPlain := ansi.Strip(tailItem.thinkingSec.out)
+
+	require.Contains(t, tailPlain, "earlier lines hidden",
+		"tail-windowed render must include the affordance footer")
+	require.Contains(t, tailPlain, "ln5000",
+		"tail-windowed render must include the LAST source paragraph โ€” we tailed, not headed")
+	require.NotContains(t, tailPlain, "ln1 ",
+		"tail-windowed render must elide early source paragraphs")
+
+	// Independent reference render: same source, same width, full
+	// expansion (no tail slice). The tail-windowed output's last K
+	// lines must byte-equal the unwindowed output's last K lines.
+	fullMsg := thinkingMessageWithLines("tail-full-ref", total)
+	fullItem := NewAssistantMessageItem(&sty, fullMsg).(*AssistantMessageItem)
+	fullItem.thinkingViewMode = thinkingFullExpanded
+	_ = fullItem.RawRender(width)
+	fullPlain := ansi.Strip(fullItem.thinkingSec.out)
+
+	tailLines := strings.Split(tailPlain, "\n")
+	fullLines := strings.Split(fullPlain, "\n")
+
+	// K is the cap minus a small budget that covers the affordance
+	// prefix (hint line + blank line) and any framing differences
+	// the bordered ThinkingBox style may introduce around the
+	// edges. Documented inline because going much larger lets the
+	// affordance row leak into the comparison; going much smaller
+	// dilutes the assertion.
+	const K = maxExpandedThinkingTailLines - 5
+	require.GreaterOrEqual(t, len(tailLines), K,
+		"tail render must contain at least K lines; got %d", len(tailLines))
+	require.GreaterOrEqual(t, len(fullLines), K,
+		"full render must contain at least K lines; got %d", len(fullLines))
+
+	tailTail := tailLines[len(tailLines)-K:]
+	fullTail := fullLines[len(fullLines)-K:]
+	require.Equal(t, fullTail, tailTail,
+		"tail-windowed render's last %d lines must byte-equal the unwindowed render's last %d lines (true tail -K relationship)",
+		K, K)
+}
+
+// TestThinkingWindow_PromoteToFull verifies the cycle continues from
+// tail-window to full expansion: the second toggle drops the
+// affordance, removes the tail slice, and produces a render that
+// matches a fresh item rendered directly in the full-expanded
+// state.
+func TestThinkingWindow_PromoteToFull(t *testing.T) {
+	t.Parallel()
+
+	sty := styles.CharmtonePantera()
+	const total = 1500
+	msg := thinkingMessageWithLines("promote", total)
+	item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem)
+
+	const width = 97
+
+	require.True(t, item.ToggleExpanded())
+	require.Equal(t, thinkingTailWindow, item.thinkingViewMode)
+	_ = item.RawRender(width)
+	tailOut := item.thinkingSec.out
+	require.Contains(t, ansi.Strip(tailOut), "earlier lines hidden")
+
+	require.True(t, item.ToggleExpanded(), "second toggle stays expanded (full)")
+	require.Equal(t, thinkingFullExpanded, item.thinkingViewMode)
+	_ = item.RawRender(width)
+	fullOut := item.thinkingSec.out
+	fullPlain := ansi.Strip(fullOut)
+
+	require.NotContains(t, fullPlain, "earlier lines hidden",
+		"full expansion must drop the tail-window affordance")
+	require.Contains(t, fullPlain, "ln1 ",
+		"full expansion must include the first source paragraph")
+	require.Contains(t, fullPlain, "ln1500 ",
+		"full expansion must include the last source paragraph")
+
+	// Independent reference: a fresh item, rendered straight into
+	// the full-expanded state, must produce byte-equal output.
+	freshMsg := thinkingMessageWithLines("promote-fresh", total)
+	fresh := NewAssistantMessageItem(&sty, freshMsg).(*AssistantMessageItem)
+	fresh.thinkingViewMode = thinkingFullExpanded
+	_ = fresh.RawRender(width)
+	require.Equal(t, fresh.thinkingSec.out, fullOut,
+		"cached full-expanded output must match a fresh full-expanded render")
+
+	// And the cycle closes back to collapsed.
+	require.False(t, item.ToggleExpanded(), "third toggle must report collapsed")
+	require.Equal(t, thinkingCollapsed, item.thinkingViewMode)
+}
+
+// sectionKey is the tuple that defines a cache-hit identity for an
+// assistantSection: (width, srcHash, extra). Comparing this tuple
+// across mutations is a stronger invariant than byte-equality of
+// rendered output: byte-equality could in principle hold even if
+// the cache invalidated and re-rendered identical bytes, while
+// tuple-equality proves the lookup key never moved.
+type sectionKey struct {
+	width   int
+	srcHash uint64
+	extra   uint64
+}
+
+func keyOf(s assistantSection) sectionKey {
+	return sectionKey{width: s.width, srcHash: s.srcHash, extra: s.extra}
+}
+
+// TestThinkingWindow_ContentChangeKeepsThinkingCacheInTailWindow
+// guards the F4/F5 boundary: streaming the main content while the
+// thinking block sits in tail-window mode must NOT invalidate the
+// thinking section cache. Tail-window state is folded into
+// thinkingKey()'s extra hash, so changing only the content text
+// keeps thinking's (srcHash, extra) tuple identical and the cache
+// hits.
+//
+// The assertion is on the cache key tuple, not just rendered bytes:
+// equal output could in principle survive a re-render with
+// identical inputs, but identical (width, srcHash, extra) tuples
+// across the SetMessage cycle prove the thinking cache was never
+// invalidated to begin with. The mirror tuple on the content
+// section MUST move (the source text changed), or the test isn't
+// exercising what it claims to.
+func TestThinkingWindow_ContentChangeKeepsThinkingCacheInTailWindow(t *testing.T) {
+	t.Parallel()
+
+	sty := styles.CharmtonePantera()
+	const total = 1000
+
+	build := func(content string) *message.Message {
+		var b strings.Builder
+		for i := 1; i <= total; i++ {
+			b.WriteString("ln")
+			b.WriteString(itoa(i))
+			if i < total {
+				b.WriteString("\n\n")
+			}
+		}
+		parts := []message.ContentPart{
+			message.ReasoningContent{
+				Thinking:   b.String(),
+				StartedAt:  testStartedAt,
+				FinishedAt: testFinishedAt,
+			},
+		}
+		if content != "" {
+			parts = append(parts, message.TextContent{Text: content})
+		}
+		return &message.Message{ID: "tail-stream", Role: message.Assistant, Parts: parts}
+	}
+
+	item := NewAssistantMessageItem(&sty, build("first answer")).(*AssistantMessageItem)
+	item.thinkingViewMode = thinkingTailWindow
+
+	const width = 99
+	_ = item.RawRender(width)
+	first := snapshot(item)
+	firstThinkingKey := keyOf(item.thinkingSec)
+	firstContentKey := keyOf(item.contentSec)
+	require.NotEmpty(t, first.thinking)
+
+	item.SetMessage(build("first answer with more streaming text"))
+	_ = item.RawRender(width)
+	second := snapshot(item)
+	secondThinkingKey := keyOf(item.thinkingSec)
+	secondContentKey := keyOf(item.contentSec)
+
+	require.Equal(t, firstThinkingKey, secondThinkingKey,
+		"thinking section's (width, srcHash, extra) tuple must not move "+
+			"across a content-only update โ€” proves the cache key never invalidated")
+	require.Equal(t, first.thinking, second.thinking,
+		"content streaming must not invalidate the tail-windowed thinking cache")
+	require.NotEqual(t, firstContentKey, secondContentKey,
+		"content section's tuple MUST move; otherwise this test isn't exercising a real content change")
+	require.NotEqual(t, first.content, second.content,
+		"content section must have re-rendered")
+}
+
+// TestThinkingWindow_ToggleInvalidatesOnlyThinking verifies that
+// cycling thinkingViewMode invalidates the thinking section cache
+// alone โ€” content and error caches survive across the toggle.
+//
+// Like TestThinkingWindow_ContentChangeKeepsThinkingCacheInTailWindow,
+// the assertion is on the cache key tuple (width, srcHash, extra)
+// at each section, not just on rendered bytes:
+//   - thinking's tuple MUST move (extra folds in thinkingViewMode)
+//   - content's and error's tuples MUST NOT move (their keys depend
+//     only on their own source text, untouched by the toggle).
+func TestThinkingWindow_ToggleInvalidatesOnlyThinking(t *testing.T) {
+	t.Parallel()
+
+	sty := styles.CharmtonePantera()
+	const total = 1500
+	build := func() *message.Message {
+		var b strings.Builder
+		for i := 1; i <= total; i++ {
+			b.WriteString("ln")
+			b.WriteString(itoa(i))
+			if i < total {
+				b.WriteString("\n\n")
+			}
+		}
+		return &message.Message{
+			ID:   "toggle-iso",
+			Role: message.Assistant,
+			Parts: []message.ContentPart{
+				message.ReasoningContent{
+					Thinking:   b.String(),
+					StartedAt:  testStartedAt,
+					FinishedAt: testFinishedAt,
+				},
+				message.TextContent{Text: "answer text"},
+				message.Finish{
+					Reason:  message.FinishReasonError,
+					Message: "boom",
+					Details: "details",
+					Time:    testFinishTime,
+				},
+			},
+		}
+	}
+
+	item := NewAssistantMessageItem(&sty, build()).(*AssistantMessageItem)
+
+	const width = 101
+	_ = item.RawRender(width)
+	first := snapshot(item)
+	firstThink := keyOf(item.thinkingSec)
+	firstContent := keyOf(item.contentSec)
+	firstErr := keyOf(item.errorSec)
+	require.NotEmpty(t, first.thinking)
+	require.NotEmpty(t, first.content)
+	require.NotEmpty(t, first.errSec)
+
+	// Cycle: collapsed -> tail-window. Only thinking should change.
+	require.True(t, item.ToggleExpanded())
+	require.Equal(t, thinkingTailWindow, item.thinkingViewMode)
+	_ = item.RawRender(width)
+	second := snapshot(item)
+	secondThink := keyOf(item.thinkingSec)
+	secondContent := keyOf(item.contentSec)
+	secondErr := keyOf(item.errorSec)
+
+	require.NotEqual(t, firstThink, secondThink,
+		"thinking section's tuple MUST move on toggle (extra folds in thinkingViewMode)")
+	require.Equal(t, firstContent, secondContent,
+		"content section's tuple must not move on a thinking toggle")
+	require.Equal(t, firstErr, secondErr,
+		"error section's tuple must not move on a thinking toggle")
+	require.NotEqual(t, first.thinking, second.thinking,
+		"toggling into tail-window must re-render the thinking section")
+	require.Equal(t, first.content, second.content,
+		"toggling thinking view-mode must not invalidate the content section")
+	require.Equal(t, first.errSec, second.errSec,
+		"toggling thinking view-mode must not invalidate the error section")
+
+	// Cycle: tail-window -> full. Same expectation.
+	require.True(t, item.ToggleExpanded())
+	require.Equal(t, thinkingFullExpanded, item.thinkingViewMode)
+	_ = item.RawRender(width)
+	third := snapshot(item)
+	thirdThink := keyOf(item.thinkingSec)
+	thirdContent := keyOf(item.contentSec)
+	thirdErr := keyOf(item.errorSec)
+
+	require.NotEqual(t, secondThink, thirdThink,
+		"thinking section's tuple MUST move on the second toggle as well")
+	require.Equal(t, secondContent, thirdContent,
+		"content section's tuple must remain stable across the second toggle")
+	require.Equal(t, secondErr, thirdErr,
+		"error section's tuple must remain stable across the second toggle")
+	require.NotEqual(t, second.thinking, third.thinking,
+		"toggling into full expansion must re-render the thinking section")
+	require.Equal(t, second.content, third.content)
+	require.Equal(t, second.errSec, third.errSec)
+}
+
+// TestThinkingWindow_BoxHeightTracksWindow asserts that
+// thinkingBoxHeight reflects the WINDOWED render's height in
+// tail-window mode, not the (much larger) full thinking height.
+// This is what HandleMouseClick uses to detect whether a click
+// landed on the thinking box, so getting it wrong would make
+// click detection extend off the bottom of the visible box.
+func TestThinkingWindow_BoxHeightTracksWindow(t *testing.T) {
+	t.Parallel()
+
+	sty := styles.CharmtonePantera()
+	const total = 5000
+	msg := thinkingMessageWithLines("box-height", total)
+	item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem)
+
+	const width = 103
+
+	// Tail-window: height should be roughly the cap.
+	item.thinkingViewMode = thinkingTailWindow
+	_ = item.RawRender(width)
+	tailHeight := item.thinkingBoxHeight
+	require.Greater(t, tailHeight, 0)
+	require.LessOrEqual(t, tailHeight, maxExpandedThinkingTailLines+5,
+		"tail-window box height must reflect the windowed render, not the full thinking height; got %d",
+		tailHeight)
+
+	// Full expansion: height should grow well past the tail cap.
+	item.thinkingViewMode = thinkingFullExpanded
+	_ = item.RawRender(width)
+	fullHeight := item.thinkingBoxHeight
+	require.Greater(t, fullHeight, maxExpandedThinkingTailLines*2,
+		"full expansion box height must reflect the full thinking render; got %d",
+		fullHeight)
+}

internal/ui/model/chat_expand_test.go ๐Ÿ”—

@@ -20,7 +20,13 @@ func TestChatToggleExpandedSelectedItem_AssistantMessage(t *testing.T) {
 
 	u := newTestUI()
 
-	msg := &message.Message{ID: "m-assist", Role: message.Assistant}
+	msg := &message.Message{
+		ID:   "m-assist",
+		Role: message.Assistant,
+		Parts: []message.ContentPart{
+			message.ReasoningContent{Thinking: "thinking about it"},
+		},
+	}
 	item := chat.NewAssistantMessageItem(u.com.Styles, msg)
 
 	// The keyboard expand path uses the generic Expandable interface;