on main help sign in
perf(chat): show only the tail of long reasoning blocks when expanded
43b986cChristian Rocha
internal/ui/chat/assistant.go+112−25
@@ -16,12 +16,41 @@ import (
16)
16)
17
17
18// assistantMessageTruncateFormat is the text shown when an assistant message is
18// assistantMessageTruncateFormat is the text shown when an assistant message is
19−// truncated.
19+// truncated in the collapsed state.
20const assistantMessageTruncateFormat = "… (%d lines hidden) [click or space to expand]"
20const assistantMessageTruncateFormat = "… (%d lines hidden) [click or space to expand]"
21
21
22+// assistantMessageTailWindowFormat is shown above a tail-windowed thinking
23+// block to advertise that earlier lines exist and that the user can
24+// promote the view to a full expansion. The promotion is wired through
25+// the existing ToggleExpanded path (click / space) — F5 deliberately
26+// does not add a new keybinding.
27+const assistantMessageTailWindowFormat = "… %d earlier lines hidden [click or space for full view]"
28+
22// maxCollapsedThinkingHeight defines the maximum height of the thinking
29// maxCollapsedThinkingHeight defines the maximum height of the thinking
23const maxCollapsedThinkingHeight = 10
30const maxCollapsedThinkingHeight = 10
24
31
32+// maxExpandedThinkingTailLines is the F5 tail-window cap. When the user
33+// expands a thinking block whose post-glamour line count exceeds this
34+// threshold, only the last N lines are shown with an affordance line
35+// indicating how many earlier lines are hidden. Clicking / pressing
36+// space again promotes the view to a full expansion. The slice is
37+// taken AFTER glamour render (not before) so fenced code blocks,
38+// lists, and tables are not torn at arbitrary boundaries.
39+const maxExpandedThinkingTailLines = 200
40+
41+// thinkingViewMode is the F5 three-state view machine for the thinking
42+// block. ToggleExpanded cycles
43+// collapsed → tail-window → full-expanded → collapsed, skipping the
44+// tail-window step when the rendered thinking fits within the cap so
45+// short blocks still toggle in two clicks.
46+type thinkingViewMode uint8
47+
48+const (
49+ thinkingCollapsed thinkingViewMode = iota
50+ thinkingTailWindow
51+ thinkingFullExpanded
52+)
53+
25// assistantSection is a per-section render cache for AssistantMessageItem.
54// assistantSection is a per-section render cache for AssistantMessageItem.
26// Each section (thinking, content, error) carries its own keys so that
55// Each section (thinking, content, error) carries its own keys so that
27// streaming a section does not invalidate a different — often more
56// streaming a section does not invalidate a different — often more
@@ -97,7 +126,7 @@ type AssistantMessageItem struct {
97 message *message.Message
126 message *message.Message
98 sty *styles.Styles
127 sty *styles.Styles
99 anim *anim.Anim
128 anim *anim.Anim
100− thinkingExpanded bool
129+ thinkingViewMode thinkingViewMode
101 thinkingBoxHeight int // Tracks the rendered thinking box height for click detection.
130 thinkingBoxHeight int // Tracks the rendered thinking box height for click detection.
102
131
103 // Per-section render caches. Splitting these out means content
132 // Per-section render caches. Splitting these out means content
@@ -304,9 +333,9 @@ func (a *AssistantMessageItem) renderMessageContent(width int) (string, int) {
304
333
305// thinkingKey returns the (srcHash, extra) cache key components for the
334// thinkingKey returns the (srcHash, extra) cache key components for the
306// thinking section. extra folds in everything other than the raw
335// thinking section. extra folds in everything other than the raw
307−// thinking text that affects the rendered output: the expanded flag
336+// thinking text that affects the rendered output: the view mode
337+// (collapsed / tail-window / full) and the footer state (which
308−// and the footer state (which depends on IsThinking, ToolCalls, and
338+// depends on IsThinking, ToolCalls, and ThinkingDuration).
309−// ThinkingDuration).
310func (a *AssistantMessageItem) thinkingKey() (uint64, uint64) {
339func (a *AssistantMessageItem) thinkingKey() (uint64, uint64) {
311 thinking := a.message.ReasoningContent().Thinking
340 thinking := a.message.ReasoningContent().Thinking
312 srcHash := fnv64(thinking)
341 srcHash := fnv64(thinking)
@@ -319,17 +348,15 @@ func (a *AssistantMessageItem) thinkingKey() (uint64, uint64) {
319 durationStr = duration.String()
348 durationStr = duration.String()
320 }
349 }
321 }
350 }
322− var expanded byte
323− if a.thinkingExpanded {
324− expanded = 1
325− }
326 var footer byte
351 var footer byte
327 if showFooter {
352 if showFooter {
328 footer = 1
353 footer = 1
329 }
354 }
330 // Length-prefixed framing avoids any delimiter collision between
355 // Length-prefixed framing avoids any delimiter collision between
331− // the flag bytes and the duration string.
356+ // the flag bytes and the duration string. The view mode is folded
357+ // in so that toggling collapsed ↔ tail-window ↔ full invalidates
358+ // only the thinking section, not content/error.
332− extra := fnvFields([]byte{expanded, footer}, []byte(durationStr))
359+ extra := fnvFields([]byte{byte(a.thinkingViewMode), footer}, []byte(durationStr))
333 return srcHash, extra
360 return srcHash, extra
334}
361}
335
362
@@ -394,6 +421,12 @@ func (a *AssistantMessageItem) cachedError(width int) string {
394}
421}
395
422
396// renderThinking renders the thinking/reasoning content with footer.
423// renderThinking renders the thinking/reasoning content with footer.
424+//
425+// Slicing happens AFTER glamour rendering so fenced code blocks, list
426+// continuations, and tables are not split mid-block — the same
427+// boundary problem §4.4 of the design note flags. The bordered
428+// ThinkingBox style is applied on top of the (already-windowed)
429+// lines so the visual box matches what the user sees today.
397func (a *AssistantMessageItem) renderThinking(thinking string, width int) string {
430func (a *AssistantMessageItem) renderThinking(thinking string, width int) string {
398 renderer := common.QuietMarkdownRenderer(a.sty, width)
431 renderer := common.QuietMarkdownRenderer(a.sty, width)
399 rendered, err := renderer.Render(thinking)
432 rendered, err := renderer.Render(thinking)
@@ -405,13 +438,23 @@ func (a *AssistantMessageItem) renderThinking(thinking string, width int) string
405 lines := strings.Split(rendered, "\n")
438 lines := strings.Split(rendered, "\n")
406 totalLines := len(lines)
439 totalLines := len(lines)
407
440
408− isTruncated := totalLines > maxCollapsedThinkingHeight
441+ switch a.thinkingViewMode {
409− if !a.thinkingExpanded && isTruncated {
442+ case thinkingCollapsed:
443+ if totalLines > maxCollapsedThinkingHeight {
410− lines = lines[totalLines-maxCollapsedThinkingHeight:]
444+ lines = lines[totalLines-maxCollapsedThinkingHeight:]
411− hint := a.sty.Messages.ThinkingTruncationHint.Render(
445+ hint := a.sty.Messages.ThinkingTruncationHint.Render(
412− fmt.Sprintf(assistantMessageTruncateFormat, totalLines-maxCollapsedThinkingHeight),
446+ fmt.Sprintf(assistantMessageTruncateFormat, totalLines-maxCollapsedThinkingHeight),
447+ )
448+ lines = append([]string{hint, ""}, lines...)
449+ }
450+ case thinkingTailWindow:
451+ if totalLines > maxExpandedThinkingTailLines {
452+ lines = lines[totalLines-maxExpandedThinkingTailLines:]
453+ hint := a.sty.Messages.ThinkingTruncationHint.Render(
454+ fmt.Sprintf(assistantMessageTailWindowFormat, totalLines-maxExpandedThinkingTailLines),
413− )
455+ )
414− lines = append([]string{hint, ""}, lines...)
456+ lines = append([]string{hint, ""}, lines...)
457+ }
415 }
458 }
416
459
417 thinkingStyle := a.sty.Messages.ThinkingBox.Width(width)
460 thinkingStyle := a.sty.Messages.ThinkingBox.Width(width)
@@ -501,14 +544,58 @@ func (a *AssistantMessageItem) clearCache() {
501 a.errorSec.reset()
544 a.errorSec.reset()
502}
545}
503
546
504−// ToggleExpanded toggles the expanded state of the thinking box and returns
547+// ToggleExpanded advances the F5 thinking view-mode cycle and returns
505−// whether the item is now expanded. Both the thinking section cache and
548+// whether the item is now in any expanded state (tail-window or full).
506−// the F3 prefix cache key fold in thinkingExpanded (via the section's
549+// The cycle is collapsed → tail-window → full → collapsed, with the
507−// extra hash and the prefix cache fingerprint respectively), so no
550+// tail-window step skipped when the rendered thinking fits within
551+// maxExpandedThinkingTailLines so short blocks remain a two-click
552+// toggle. Both the thinking section cache and the F3 prefix cache
508−// explicit invalidation is required.
553+// fold thinkingViewMode into their keys, so no explicit invalidation
554+// is required here.
555+//
556+// When the message carries no thinking text the toggle is a no-op:
557+// there is nothing to expand, and mutating the view mode would
558+// thrash the thinking-section cache key for no visible benefit.
509func (a *AssistantMessageItem) ToggleExpanded() bool {
559func (a *AssistantMessageItem) ToggleExpanded() bool {
510− a.thinkingExpanded = !a.thinkingExpanded
560+ if strings.TrimSpace(a.message.ReasoningContent().Thinking) == "" {
511− return a.thinkingExpanded
561+ return a.thinkingViewMode != thinkingCollapsed
562+ }
563+ switch a.thinkingViewMode {
564+ case thinkingCollapsed:
565+ if a.tailWindowWouldTruncate() {
566+ a.thinkingViewMode = thinkingTailWindow
567+ } else {
568+ a.thinkingViewMode = thinkingFullExpanded
569+ }
570+ case thinkingTailWindow:
571+ a.thinkingViewMode = thinkingFullExpanded
572+ case thinkingFullExpanded:
573+ a.thinkingViewMode = thinkingCollapsed
574+ }
575+ return a.thinkingViewMode != thinkingCollapsed
576+}
577+
578+// tailWindowWouldTruncate reports whether the current thinking text
579+// is long enough that the tail-window step is worth inserting into
580+// the toggle cycle. We use a cheap source-text logical-line count
581+// as the heuristic rather than peeking into the cache: the cache
582+// may be populated in collapsed state (where its height is bounded
583+// by maxCollapsedThinkingHeight and tells us nothing about the
584+// underlying length), and re-running glamour just to count lines
585+// would defeat the cache. The heuristic can over-trigger (a source
586+// with many short lines may wrap to fewer than N lines), in which
587+// case the tail-window render is visually identical to full and
588+// the cycle costs the user one extra toggle — preferred over the
589+// alternative of failing to show the affordance on a genuinely
590+// long block.
591+//
592+// Logical line count is `1 + newlineCount` (a string with no
593+// newlines is one line). Comparing newline count alone introduced
594+// an off-by-one that let a source whose post-newline-split length
595+// equalled the cap skip the tail-window step.
596+func (a *AssistantMessageItem) tailWindowWouldTruncate() bool {
597+ lineCount := 1 + strings.Count(a.message.ReasoningContent().Thinking, "\n")
598+ return lineCount > maxExpandedThinkingTailLines
512}
599}
513
600
514// HandleMouseClick implements MouseClickable. It signals (via a true return)
601// HandleMouseClick implements MouseClickable. It signals (via a true return)
internal/ui/chat/assistant_section_cache_test.go+1−1
@@ -441,7 +441,7 @@ func TestAssistantSectionCache_ThinkingBoxHeightSurvivesCacheHit(t *testing.T) {
441 }, "\n")
441 }, "\n")
442 msg := thinkingMessage("hbox", thinking, "initial answer")
442 msg := thinkingMessage("hbox", thinking, "initial answer")
443 item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem)
443 item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem)
444− item.thinkingExpanded = true
444+ item.thinkingViewMode = thinkingFullExpanded
445
445
446 _ = item.RawRender(width)
446 _ = item.RawRender(width)
447 originalHeight := item.thinkingBoxHeight
447 originalHeight := item.thinkingBoxHeight
internal/ui/chat/assistant_test.go+100−12
@@ -10,21 +10,108 @@ import (
10)
10)
11
11
12// TestAssistantMessageItemExpandable guards the Expandable contract on
12// TestAssistantMessageItemExpandable guards the Expandable contract on
13+// AssistantMessageItem along the keyboard-driven expand path. The earlier
13−// AssistantMessageItem. The earlier implementation returned no value, which
14+// implementation returned no value, which meant the type silently did
14−// meant the type silently did not satisfy chat.Expandable and the
15+// not satisfy chat.Expandable and the keyboard-driven expand path in
15−// keyboard-driven expand path in model/chat.go skipped thinking blocks.
16+// model/chat.go skipped thinking blocks.
17+//
18+// We exercise the contract through the bare Expandable interface (the
19+// same dispatch site model.Chat.ToggleExpandedSelectedItem uses), which
20+// proves both that AssistantMessageItem still satisfies the interface
21+// and that the bool return reports the right semantic state at every
22+// point in the cycle.
16func TestAssistantMessageItemExpandable(t *testing.T) {
23func TestAssistantMessageItemExpandable(t *testing.T) {
17 t.Parallel()
24 t.Parallel()
18
25
19 sty := styles.CharmtonePantera()
26 sty := styles.CharmtonePantera()
20− msg := &message.Message{ID: "m1", Role: message.Assistant}
27+ // Short thinking: under the tail-window cap, so the cycle is
28+ // collapsed -> full -> collapsed (tail-window is skipped).
29+ msg := thinkingMessage("m1", "step one\nstep two\nstep three", "")
21− item := NewAssistantMessageItem(&sty, msg)
30+ item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem)
22
31
23− exp, ok := item.(Expandable)
32+ exp, ok := any(item).(Expandable)
24 require.True(t, ok, "AssistantMessageItem must satisfy Expandable")
33 require.True(t, ok, "AssistantMessageItem must satisfy Expandable")
25
34
35+ require.Equal(t, thinkingCollapsed, item.thinkingViewMode,
36+ "new items must start in the collapsed view-mode")
26− require.True(t, exp.ToggleExpanded(), "first toggle should report expanded")
37+ require.True(t, exp.ToggleExpanded(),
38+ "first toggle of a non-empty thinking block must report expanded")
39+ require.Equal(t, thinkingFullExpanded, item.thinkingViewMode,
40+ "short blocks must skip tail-window and land in full expansion")
41+ require.False(t, exp.ToggleExpanded(),
27− require.False(t, exp.ToggleExpanded(), "second toggle should report collapsed")
42+ "second toggle must report collapsed (cycle closed)")
43+ require.Equal(t, thinkingCollapsed, item.thinkingViewMode)
44+}
45+
46+// TestAssistantMessageItemExpandableEmptyThinkingNoOp guards the B2
47+// fix: a message with no thinking text must treat ToggleExpanded as a
48+// no-op. Mutating the view mode in that case would thrash the
49+// thinking-section cache key for no visible benefit and would surprise
50+// the caller (model.Chat.ToggleExpandedSelectedItem would treat a
51+// "now collapsed" return as a real state change and re-scroll on it).
52+func TestAssistantMessageItemExpandableEmptyThinkingNoOp(t *testing.T) {
53+ t.Parallel()
54+
55+ sty := styles.CharmtonePantera()
56+ msg := &message.Message{ID: "m1-empty", Role: message.Assistant}
57+ item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem)
58+
59+ exp, ok := any(item).(Expandable)
60+ require.True(t, ok, "AssistantMessageItem must satisfy Expandable")
61+
62+ require.Equal(t, thinkingCollapsed, item.thinkingViewMode)
63+ require.False(t, exp.ToggleExpanded(),
64+ "empty thinking must report current (collapsed) state without flipping")
65+ require.Equal(t, thinkingCollapsed, item.thinkingViewMode,
66+ "empty-thinking toggle must not mutate thinkingViewMode")
67+
68+ // Whitespace-only thinking is still effectively empty.
69+ item.message.Parts = []message.ContentPart{
70+ message.ReasoningContent{Thinking: " \n\n\t ", StartedAt: testStartedAt},
71+ }
72+ require.False(t, exp.ToggleExpanded())
73+ require.Equal(t, thinkingCollapsed, item.thinkingViewMode)
74+}
75+
76+// TestAssistantMessageItemTailWindowBoundary guards the B1 fix: the
77+// tail-window heuristic must compare logical line counts (1 +
78+// newline count) against the cap, not raw newline counts. A source
79+// whose logical line count exactly equals the cap must NOT trip the
80+// tail-window step (full render still fits cleanly under the cap),
81+// while one logical line over the cap must trip it.
82+func TestAssistantMessageItemTailWindowBoundary(t *testing.T) {
83+ t.Parallel()
84+
85+ sty := styles.CharmtonePantera()
86+
87+ atCap := buildLines(maxExpandedThinkingTailLines)
88+ overCap := buildLines(maxExpandedThinkingTailLines + 1)
89+
90+ atItem := NewAssistantMessageItem(&sty, thinkingMessage("at-cap", atCap, "")).(*AssistantMessageItem)
91+ require.False(t, atItem.tailWindowWouldTruncate(),
92+ "a source with exactly N logical lines must not trip the tail-window step")
93+
94+ overItem := NewAssistantMessageItem(&sty, thinkingMessage("over-cap", overCap, "")).(*AssistantMessageItem)
95+ require.True(t, overItem.tailWindowWouldTruncate(),
96+ "a source with N+1 logical lines must trip the tail-window step")
97+}
98+
99+// buildLines returns a string of n logical lines (n-1 newlines). Each
100+// line is a unique short token so callers can distinguish head from
101+// tail in rendered output if they need to.
102+func buildLines(n int) string {
103+ if n <= 0 {
104+ return ""
105+ }
106+ var b []byte
107+ for i := 1; i <= n; i++ {
108+ if i > 1 {
109+ b = append(b, '\n')
110+ }
111+ b = append(b, 'l', 'n')
112+ b = append(b, []byte(itoa(i))...)
113+ }
114+ return string(b)
28}
115}
29
116
30// TestAssistantMessageItemHandleMouseClick ensures HandleMouseClick does not
117// TestAssistantMessageItemHandleMouseClick ensures HandleMouseClick does not
@@ -40,15 +127,16 @@ func TestAssistantMessageItemHandleMouseClick(t *testing.T) {
40 item.thinkingBoxHeight = 5
127 item.thinkingBoxHeight = 5
41
128
42 // Click inside the thinking box signals handled but must not mutate
129 // Click inside the thinking box signals handled but must not mutate
43− // the expanded state.
130+ // the view-mode state.
44 require.True(t, item.HandleMouseClick(ansi.MouseLeft, 0, 2))
131 require.True(t, item.HandleMouseClick(ansi.MouseLeft, 0, 2))
132+ require.Equal(t, thinkingCollapsed, item.thinkingViewMode,
45− require.False(t, item.thinkingExpanded, "HandleMouseClick must not toggle expansion on its own")
133+ "HandleMouseClick must not toggle expansion on its own")
46
134
47 // Click outside the thinking box is ignored entirely.
135 // Click outside the thinking box is ignored entirely.
48 require.False(t, item.HandleMouseClick(ansi.MouseLeft, 0, 10))
136 require.False(t, item.HandleMouseClick(ansi.MouseLeft, 0, 10))
49− require.False(t, item.thinkingExpanded)
137+ require.Equal(t, thinkingCollapsed, item.thinkingViewMode)
50
138
51 // Non-left button is ignored.
139 // Non-left button is ignored.
52 require.False(t, item.HandleMouseClick(ansi.MouseRight, 0, 2))
140 require.False(t, item.HandleMouseClick(ansi.MouseRight, 0, 2))
53− require.False(t, item.thinkingExpanded)
141+ require.Equal(t, thinkingCollapsed, item.thinkingViewMode)
54}
142}
internal/ui/chat/assistant_thinking_window_test.gonew file+500
@@ -0,0 +1,500 @@
1+package chat
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "charm.land/lipgloss/v2"
8+ "github.com/charmbracelet/crush/internal/message"
9+ "github.com/charmbracelet/crush/internal/ui/styles"
10+ "github.com/charmbracelet/x/ansi"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+// thinkingMessageWithLines builds a still-thinking assistant message
15+// whose reasoning content is `count` short paragraphs separated by
16+// blank lines. The blank-line separation is what matters: glamour
17+// renders paragraph blocks one-per-line in the output (with a
18+// trailing blank line between paragraphs) instead of reflowing the
19+// entire input into one big wrapped paragraph. That gives us a
20+// post-glamour line count we can drive past the tail-window
21+// threshold deterministically. Each paragraph is tagged with its
22+// (1-based) index so the test can identify head vs tail in the
23+// rendered output.
24+//
25+// The message has no text content and no Finish part, so
26+// IsThinking() returns true and the render path skips the
27+// "Thought for" footer — keeping the rendered height computation
28+// simple.
29+func thinkingMessageWithLines(id string, count int) *message.Message {
30+ var b strings.Builder
31+ for i := 1; i <= count; i++ {
32+ b.WriteString("ln")
33+ b.WriteString(itoa(i))
34+ if i < count {
35+ // Blank line between paragraphs: glamour preserves the
36+ // per-paragraph structure rather than reflowing into one
37+ // wrapped block, so totalLines tracks count predictably.
38+ b.WriteString("\n\n")
39+ }
40+ }
41+ return &message.Message{
42+ ID: id,
43+ Role: message.Assistant,
44+ Parts: []message.ContentPart{
45+ message.ReasoningContent{
46+ Thinking: b.String(),
47+ StartedAt: testStartedAt,
48+ },
49+ },
50+ }
51+}
52+
53+// itoa is a local stdlib-free integer formatter used only by these
54+// tests; pulling fmt in just for %d would be wasteful when the test
55+// fixtures already churn 5000+ short strings.
56+func itoa(n int) string {
57+ if n == 0 {
58+ return "0"
59+ }
60+ var buf [20]byte
61+ i := len(buf)
62+ for n > 0 {
63+ i--
64+ buf[i] = byte('0' + n%10)
65+ n /= 10
66+ }
67+ return string(buf[i:])
68+}
69+
70+// renderedThinkingHeight returns the line count of the cached
71+// thinking section render only (not the full RawRender, which also
72+// includes the content and error sections). Drives a render at
73+// `width` first to populate the cache.
74+func renderedThinkingHeight(t *testing.T, item *AssistantMessageItem, width int) int {
75+ t.Helper()
76+ _ = item.RawRender(width)
77+ require.NotEmpty(t, item.thinkingSec.out,
78+ "thinking section must be populated after RawRender")
79+ return lipgloss.Height(item.thinkingSec.out)
80+}
81+
82+// TestThinkingWindow_CollapsedCapPreserved guards that F5 did not
83+// regress the existing collapsed-mode behaviour: a 5000-line
84+// thinking block in the default (collapsed) state still renders at
85+// most a small bounded height — the last `maxCollapsedThinkingHeight`
86+// lines plus the truncation hint. The thinking message keeps
87+// IsThinking() == true, so the optional "Thought for" footer is
88+// suppressed and the section height equals the box height.
89+func TestThinkingWindow_CollapsedCapPreserved(t *testing.T) {
90+ t.Parallel()
91+
92+ sty := styles.CharmtonePantera()
93+ msg := thinkingMessageWithLines("collapsed", 5000)
94+ item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem)
95+
96+ // Default state must be collapsed.
97+ require.Equal(t, thinkingCollapsed, item.thinkingViewMode)
98+
99+ // Unique odd width avoids sharing the glamour renderer cache with
100+ // any other parallel test (the renderer instance is memoized per
101+ // width and is not safe for concurrent Render calls).
102+ const width = 91
103+ height := renderedThinkingHeight(t, item, width)
104+
105+ // Collapsed mode keeps the existing cap: last 10 lines + a
106+ // 2-line hint prefix (hint + blank). Allow a small slack for
107+ // any future style-driven padding so the test is robust to
108+ // cosmetic tweaks while still being orders of magnitude below
109+ // the 5000-line source.
110+ const collapsedUpperBound = maxCollapsedThinkingHeight + 5
111+ require.LessOrEqual(t, height, collapsedUpperBound,
112+ "collapsed mode must remain bounded by the small cap; got %d", height)
113+}
114+
115+// TestThinkingWindow_ExpandedShortSkipsTailWindow guards that a
116+// short thinking block (well under the tail-window cap) still
117+// toggles directly to full expansion without an intermediate
118+// tail-window step and shows no affordance footer. The cycle is
119+// collapsed -> full -> collapsed for short blocks; tail-window is
120+// only inserted when it would actually elide content.
121+func TestThinkingWindow_ExpandedShortSkipsTailWindow(t *testing.T) {
122+ t.Parallel()
123+
124+ sty := styles.CharmtonePantera()
125+ const lines = 50
126+ require.Less(t, lines, maxExpandedThinkingTailLines,
127+ "this test relies on the source being well under the tail cap")
128+ msg := thinkingMessageWithLines("short", lines)
129+ item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem)
130+
131+ require.True(t, item.ToggleExpanded(),
132+ "first toggle should report expanded")
133+ require.Equal(t, thinkingFullExpanded, item.thinkingViewMode,
134+ "short blocks must skip tail-window and go straight to full expansion")
135+
136+ const width = 93
137+ _ = item.RawRender(width)
138+ out := item.thinkingSec.out
139+ plain := ansi.Strip(out)
140+
141+ require.NotContains(t, plain, "earlier lines hidden",
142+ "short blocks must not show the tail-window affordance")
143+ require.NotContains(t, plain, "lines hidden",
144+ "short expanded blocks must not show any truncation hint")
145+ require.Contains(t, plain, "ln1 ",
146+ "a fully expanded short block must include the very first source paragraph")
147+ require.Contains(t, plain, "ln50 ",
148+ "a fully expanded short block must include the last source paragraph")
149+}
150+
151+// TestThinkingWindow_TailWindowed asserts the central F5 behaviour:
152+// expanding a long thinking block produces a tail window of size
153+// `maxExpandedThinkingTailLines` plus the affordance footer, with
154+// the LAST source line present (i.e. we tailed, not headed) and
155+// earlier lines elided.
156+//
157+// Beyond presence/absence of sentinels, this test verifies a true
158+// `tail -K` relationship between the tail-windowed render and the
159+// fully-expanded render of the same source at the same width: the
160+// last K plain-ANSI lines of the windowed render must byte-equal
161+// the last K lines of the unwindowed render.
162+//
163+// K is sized below the cap to absorb the affordance prefix (hint +
164+// blank line) and any small framing differences introduced by the
165+// bordered ThinkingBox. The cap minus 5 leaves a comfortable margin
166+// for padding/footer rows while still asserting that the bulk of
167+// the rendered tail is identical.
168+func TestThinkingWindow_TailWindowed(t *testing.T) {
169+ t.Parallel()
170+
171+ sty := styles.CharmtonePantera()
172+ const total = 5000
173+ const width = 95
174+
175+ // Tail-windowed render.
176+ tailMsg := thinkingMessageWithLines("tail", total)
177+ tailItem := NewAssistantMessageItem(&sty, tailMsg).(*AssistantMessageItem)
178+ require.True(t, tailItem.ToggleExpanded(), "first toggle should report expanded")
179+ require.Equal(t, thinkingTailWindow, tailItem.thinkingViewMode,
180+ "a long block must enter tail-window after the first toggle")
181+
182+ height := renderedThinkingHeight(t, tailItem, width)
183+
184+ // The visible window is N tail lines plus an affordance line
185+ // and a blank-line spacer (matching the existing collapsed-mode
186+ // hint structure). Allow a small slack for style-driven
187+ // padding.
188+ const expectedFloor = maxExpandedThinkingTailLines + 1
189+ const expectedCeil = maxExpandedThinkingTailLines + 5
190+ require.GreaterOrEqual(t, height, expectedFloor,
191+ "tail-window must include at least N + affordance lines; got %d", height)
192+ require.LessOrEqual(t, height, expectedCeil,
193+ "tail-window must not exceed N + a small padding budget; got %d", height)
194+
195+ tailPlain := ansi.Strip(tailItem.thinkingSec.out)
196+
197+ require.Contains(t, tailPlain, "earlier lines hidden",
198+ "tail-windowed render must include the affordance footer")
199+ require.Contains(t, tailPlain, "ln5000",
200+ "tail-windowed render must include the LAST source paragraph — we tailed, not headed")
201+ require.NotContains(t, tailPlain, "ln1 ",
202+ "tail-windowed render must elide early source paragraphs")
203+
204+ // Independent reference render: same source, same width, full
205+ // expansion (no tail slice). The tail-windowed output's last K
206+ // lines must byte-equal the unwindowed output's last K lines.
207+ fullMsg := thinkingMessageWithLines("tail-full-ref", total)
208+ fullItem := NewAssistantMessageItem(&sty, fullMsg).(*AssistantMessageItem)
209+ fullItem.thinkingViewMode = thinkingFullExpanded
210+ _ = fullItem.RawRender(width)
211+ fullPlain := ansi.Strip(fullItem.thinkingSec.out)
212+
213+ tailLines := strings.Split(tailPlain, "\n")
214+ fullLines := strings.Split(fullPlain, "\n")
215+
216+ // K is the cap minus a small budget that covers the affordance
217+ // prefix (hint line + blank line) and any framing differences
218+ // the bordered ThinkingBox style may introduce around the
219+ // edges. Documented inline because going much larger lets the
220+ // affordance row leak into the comparison; going much smaller
221+ // dilutes the assertion.
222+ const K = maxExpandedThinkingTailLines - 5
223+ require.GreaterOrEqual(t, len(tailLines), K,
224+ "tail render must contain at least K lines; got %d", len(tailLines))
225+ require.GreaterOrEqual(t, len(fullLines), K,
226+ "full render must contain at least K lines; got %d", len(fullLines))
227+
228+ tailTail := tailLines[len(tailLines)-K:]
229+ fullTail := fullLines[len(fullLines)-K:]
230+ require.Equal(t, fullTail, tailTail,
231+ "tail-windowed render's last %d lines must byte-equal the unwindowed render's last %d lines (true tail -K relationship)",
232+ K, K)
233+}
234+
235+// TestThinkingWindow_PromoteToFull verifies the cycle continues from
236+// tail-window to full expansion: the second toggle drops the
237+// affordance, removes the tail slice, and produces a render that
238+// matches a fresh item rendered directly in the full-expanded
239+// state.
240+func TestThinkingWindow_PromoteToFull(t *testing.T) {
241+ t.Parallel()
242+
243+ sty := styles.CharmtonePantera()
244+ const total = 1500
245+ msg := thinkingMessageWithLines("promote", total)
246+ item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem)
247+
248+ const width = 97
249+
250+ require.True(t, item.ToggleExpanded())
251+ require.Equal(t, thinkingTailWindow, item.thinkingViewMode)
252+ _ = item.RawRender(width)
253+ tailOut := item.thinkingSec.out
254+ require.Contains(t, ansi.Strip(tailOut), "earlier lines hidden")
255+
256+ require.True(t, item.ToggleExpanded(), "second toggle stays expanded (full)")
257+ require.Equal(t, thinkingFullExpanded, item.thinkingViewMode)
258+ _ = item.RawRender(width)
259+ fullOut := item.thinkingSec.out
260+ fullPlain := ansi.Strip(fullOut)
261+
262+ require.NotContains(t, fullPlain, "earlier lines hidden",
263+ "full expansion must drop the tail-window affordance")
264+ require.Contains(t, fullPlain, "ln1 ",
265+ "full expansion must include the first source paragraph")
266+ require.Contains(t, fullPlain, "ln1500 ",
267+ "full expansion must include the last source paragraph")
268+
269+ // Independent reference: a fresh item, rendered straight into
270+ // the full-expanded state, must produce byte-equal output.
271+ freshMsg := thinkingMessageWithLines("promote-fresh", total)
272+ fresh := NewAssistantMessageItem(&sty, freshMsg).(*AssistantMessageItem)
273+ fresh.thinkingViewMode = thinkingFullExpanded
274+ _ = fresh.RawRender(width)
275+ require.Equal(t, fresh.thinkingSec.out, fullOut,
276+ "cached full-expanded output must match a fresh full-expanded render")
277+
278+ // And the cycle closes back to collapsed.
279+ require.False(t, item.ToggleExpanded(), "third toggle must report collapsed")
280+ require.Equal(t, thinkingCollapsed, item.thinkingViewMode)
281+}
282+
283+// sectionKey is the tuple that defines a cache-hit identity for an
284+// assistantSection: (width, srcHash, extra). Comparing this tuple
285+// across mutations is a stronger invariant than byte-equality of
286+// rendered output: byte-equality could in principle hold even if
287+// the cache invalidated and re-rendered identical bytes, while
288+// tuple-equality proves the lookup key never moved.
289+type sectionKey struct {
290+ width int
291+ srcHash uint64
292+ extra uint64
293+}
294+
295+func keyOf(s assistantSection) sectionKey {
296+ return sectionKey{width: s.width, srcHash: s.srcHash, extra: s.extra}
297+}
298+
299+// TestThinkingWindow_ContentChangeKeepsThinkingCacheInTailWindow
300+// guards the F4/F5 boundary: streaming the main content while the
301+// thinking block sits in tail-window mode must NOT invalidate the
302+// thinking section cache. Tail-window state is folded into
303+// thinkingKey()'s extra hash, so changing only the content text
304+// keeps thinking's (srcHash, extra) tuple identical and the cache
305+// hits.
306+//
307+// The assertion is on the cache key tuple, not just rendered bytes:
308+// equal output could in principle survive a re-render with
309+// identical inputs, but identical (width, srcHash, extra) tuples
310+// across the SetMessage cycle prove the thinking cache was never
311+// invalidated to begin with. The mirror tuple on the content
312+// section MUST move (the source text changed), or the test isn't
313+// exercising what it claims to.
314+func TestThinkingWindow_ContentChangeKeepsThinkingCacheInTailWindow(t *testing.T) {
315+ t.Parallel()
316+
317+ sty := styles.CharmtonePantera()
318+ const total = 1000
319+
320+ build := func(content string) *message.Message {
321+ var b strings.Builder
322+ for i := 1; i <= total; i++ {
323+ b.WriteString("ln")
324+ b.WriteString(itoa(i))
325+ if i < total {
326+ b.WriteString("\n\n")
327+ }
328+ }
329+ parts := []message.ContentPart{
330+ message.ReasoningContent{
331+ Thinking: b.String(),
332+ StartedAt: testStartedAt,
333+ FinishedAt: testFinishedAt,
334+ },
335+ }
336+ if content != "" {
337+ parts = append(parts, message.TextContent{Text: content})
338+ }
339+ return &message.Message{ID: "tail-stream", Role: message.Assistant, Parts: parts}
340+ }
341+
342+ item := NewAssistantMessageItem(&sty, build("first answer")).(*AssistantMessageItem)
343+ item.thinkingViewMode = thinkingTailWindow
344+
345+ const width = 99
346+ _ = item.RawRender(width)
347+ first := snapshot(item)
348+ firstThinkingKey := keyOf(item.thinkingSec)
349+ firstContentKey := keyOf(item.contentSec)
350+ require.NotEmpty(t, first.thinking)
351+
352+ item.SetMessage(build("first answer with more streaming text"))
353+ _ = item.RawRender(width)
354+ second := snapshot(item)
355+ secondThinkingKey := keyOf(item.thinkingSec)
356+ secondContentKey := keyOf(item.contentSec)
357+
358+ require.Equal(t, firstThinkingKey, secondThinkingKey,
359+ "thinking section's (width, srcHash, extra) tuple must not move "+
360+ "across a content-only update — proves the cache key never invalidated")
361+ require.Equal(t, first.thinking, second.thinking,
362+ "content streaming must not invalidate the tail-windowed thinking cache")
363+ require.NotEqual(t, firstContentKey, secondContentKey,
364+ "content section's tuple MUST move; otherwise this test isn't exercising a real content change")
365+ require.NotEqual(t, first.content, second.content,
366+ "content section must have re-rendered")
367+}
368+
369+// TestThinkingWindow_ToggleInvalidatesOnlyThinking verifies that
370+// cycling thinkingViewMode invalidates the thinking section cache
371+// alone — content and error caches survive across the toggle.
372+//
373+// Like TestThinkingWindow_ContentChangeKeepsThinkingCacheInTailWindow,
374+// the assertion is on the cache key tuple (width, srcHash, extra)
375+// at each section, not just on rendered bytes:
376+// - thinking's tuple MUST move (extra folds in thinkingViewMode)
377+// - content's and error's tuples MUST NOT move (their keys depend
378+// only on their own source text, untouched by the toggle).
379+func TestThinkingWindow_ToggleInvalidatesOnlyThinking(t *testing.T) {
380+ t.Parallel()
381+
382+ sty := styles.CharmtonePantera()
383+ const total = 1500
384+ build := func() *message.Message {
385+ var b strings.Builder
386+ for i := 1; i <= total; i++ {
387+ b.WriteString("ln")
388+ b.WriteString(itoa(i))
389+ if i < total {
390+ b.WriteString("\n\n")
391+ }
392+ }
393+ return &message.Message{
394+ ID: "toggle-iso",
395+ Role: message.Assistant,
396+ Parts: []message.ContentPart{
397+ message.ReasoningContent{
398+ Thinking: b.String(),
399+ StartedAt: testStartedAt,
400+ FinishedAt: testFinishedAt,
401+ },
402+ message.TextContent{Text: "answer text"},
403+ message.Finish{
404+ Reason: message.FinishReasonError,
405+ Message: "boom",
406+ Details: "details",
407+ Time: testFinishTime,
408+ },
409+ },
410+ }
411+ }
412+
413+ item := NewAssistantMessageItem(&sty, build()).(*AssistantMessageItem)
414+
415+ const width = 101
416+ _ = item.RawRender(width)
417+ first := snapshot(item)
418+ firstThink := keyOf(item.thinkingSec)
419+ firstContent := keyOf(item.contentSec)
420+ firstErr := keyOf(item.errorSec)
421+ require.NotEmpty(t, first.thinking)
422+ require.NotEmpty(t, first.content)
423+ require.NotEmpty(t, first.errSec)
424+
425+ // Cycle: collapsed -> tail-window. Only thinking should change.
426+ require.True(t, item.ToggleExpanded())
427+ require.Equal(t, thinkingTailWindow, item.thinkingViewMode)
428+ _ = item.RawRender(width)
429+ second := snapshot(item)
430+ secondThink := keyOf(item.thinkingSec)
431+ secondContent := keyOf(item.contentSec)
432+ secondErr := keyOf(item.errorSec)
433+
434+ require.NotEqual(t, firstThink, secondThink,
435+ "thinking section's tuple MUST move on toggle (extra folds in thinkingViewMode)")
436+ require.Equal(t, firstContent, secondContent,
437+ "content section's tuple must not move on a thinking toggle")
438+ require.Equal(t, firstErr, secondErr,
439+ "error section's tuple must not move on a thinking toggle")
440+ require.NotEqual(t, first.thinking, second.thinking,
441+ "toggling into tail-window must re-render the thinking section")
442+ require.Equal(t, first.content, second.content,
443+ "toggling thinking view-mode must not invalidate the content section")
444+ require.Equal(t, first.errSec, second.errSec,
445+ "toggling thinking view-mode must not invalidate the error section")
446+
447+ // Cycle: tail-window -> full. Same expectation.
448+ require.True(t, item.ToggleExpanded())
449+ require.Equal(t, thinkingFullExpanded, item.thinkingViewMode)
450+ _ = item.RawRender(width)
451+ third := snapshot(item)
452+ thirdThink := keyOf(item.thinkingSec)
453+ thirdContent := keyOf(item.contentSec)
454+ thirdErr := keyOf(item.errorSec)
455+
456+ require.NotEqual(t, secondThink, thirdThink,
457+ "thinking section's tuple MUST move on the second toggle as well")
458+ require.Equal(t, secondContent, thirdContent,
459+ "content section's tuple must remain stable across the second toggle")
460+ require.Equal(t, secondErr, thirdErr,
461+ "error section's tuple must remain stable across the second toggle")
462+ require.NotEqual(t, second.thinking, third.thinking,
463+ "toggling into full expansion must re-render the thinking section")
464+ require.Equal(t, second.content, third.content)
465+ require.Equal(t, second.errSec, third.errSec)
466+}
467+
468+// TestThinkingWindow_BoxHeightTracksWindow asserts that
469+// thinkingBoxHeight reflects the WINDOWED render's height in
470+// tail-window mode, not the (much larger) full thinking height.
471+// This is what HandleMouseClick uses to detect whether a click
472+// landed on the thinking box, so getting it wrong would make
473+// click detection extend off the bottom of the visible box.
474+func TestThinkingWindow_BoxHeightTracksWindow(t *testing.T) {
475+ t.Parallel()
476+
477+ sty := styles.CharmtonePantera()
478+ const total = 5000
479+ msg := thinkingMessageWithLines("box-height", total)
480+ item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem)
481+
482+ const width = 103
483+
484+ // Tail-window: height should be roughly the cap.
485+ item.thinkingViewMode = thinkingTailWindow
486+ _ = item.RawRender(width)
487+ tailHeight := item.thinkingBoxHeight
488+ require.Greater(t, tailHeight, 0)
489+ require.LessOrEqual(t, tailHeight, maxExpandedThinkingTailLines+5,
490+ "tail-window box height must reflect the windowed render, not the full thinking height; got %d",
491+ tailHeight)
492+
493+ // Full expansion: height should grow well past the tail cap.
494+ item.thinkingViewMode = thinkingFullExpanded
495+ _ = item.RawRender(width)
496+ fullHeight := item.thinkingBoxHeight
497+ require.Greater(t, fullHeight, maxExpandedThinkingTailLines*2,
498+ "full expansion box height must reflect the full thinking render; got %d",
499+ fullHeight)
500+}
internal/ui/model/chat_expand_test.go+7−1
@@ -20,7 +20,13 @@ func TestChatToggleExpandedSelectedItem_AssistantMessage(t *testing.T) {
20
20
21 u := newTestUI()
21 u := newTestUI()
22
22
23+ msg := &message.Message{
24+ ID: "m-assist",
23− msg := &message.Message{ID: "m-assist", Role: message.Assistant}
25+ Role: message.Assistant,
26+ Parts: []message.ContentPart{
27+ message.ReasoningContent{Thinking: "thinking about it"},
28+ },
29+ }
24 item := chat.NewAssistantMessageItem(u.com.Styles, msg)
30 item := chat.NewAssistantMessageItem(u.com.Styles, msg)
25
31
26 // The keyboard expand path uses the generic Expandable interface;
32 // The keyboard expand path uses the generic Expandable interface;