Files
Christian RochaandCharm Crush 6b101f3860 perf(chat): skip re-rendering chat list items that have not changed
The chat list now remembers what each item rendered last frame and
reuses it when nothing about that item has changed. Once a message
turn is fully done, its rendered output is reused verbatim on every
subsequent frame for as long as it stays unchanged. Resizing the
window, focusing or selecting an item, dragging a selection over it,
toggling a thinking block, and any other state change still triggers
a fresh render. The visible output is identical to before; the work
to produce it is much less.

Co-Authored-By: Charm Crush <crush@charm.land>
2026-05-12 16:09:15 -04:00

121 lines
3.3 KiB
Go

package model
import (
"strconv"
"strings"
"testing"
"charm.land/bubbles/v2/textarea"
"github.com/charmbracelet/crush/internal/ui/chat"
"github.com/charmbracelet/crush/internal/ui/common"
)
// testMessageItem is a minimal chat item used to populate the chat list
// without pulling in full message rendering machinery.
type testMessageItem struct {
id string
text string
}
func (m testMessageItem) ID() string { return m.id }
func (m testMessageItem) Render(int) string { return m.text }
func (m testMessageItem) RawRender(int) string { return m.text }
func (m testMessageItem) Version() uint64 { return 0 }
func (m testMessageItem) Finished() bool { return true }
var _ chat.MessageItem = testMessageItem{}
// newTestUI builds a focused uiChat model with dynamic textarea sizing enabled.
// It intentionally keeps dependencies minimal so layout behavior can be tested
// in isolation.
func newTestUI() *UI {
com := common.DefaultCommon(nil)
ta := textarea.New()
ta.SetStyles(com.Styles.Editor.Textarea)
ta.ShowLineNumbers = false
ta.CharLimit = -1
ta.SetVirtualCursor(false)
ta.DynamicHeight = true
ta.MinHeight = TextareaMinHeight
ta.MaxHeight = TextareaMaxHeight
ta.Focus()
u := &UI{
com: com,
status: NewStatus(com, nil),
chat: NewChat(com),
textarea: ta,
state: uiChat,
focus: uiFocusEditor,
width: 140,
height: 45,
}
return u
}
func TestUpdateLayoutAndSize_EditorGrowthShrinksChat(t *testing.T) {
t.Parallel()
// Baseline layout at min textarea height.
u := newTestUI()
u.updateLayoutAndSize()
initialEditorHeight := u.layout.editor.Dy()
initialChatHeight := u.layout.main.Dy()
// Increase textarea content enough to trigger growth, then run the
// same resize hook used in the real update path.
prevHeight := u.textarea.Height()
u.textarea.SetValue(strings.Repeat("line\n", 8))
u.textarea.MoveToEnd()
_ = u.handleTextareaHeightChange(prevHeight)
if got := u.layout.editor.Dy(); got <= initialEditorHeight {
t.Fatalf("expected editor to grow: got %d, want > %d", got, initialEditorHeight)
}
if got := u.layout.main.Dy(); got >= initialChatHeight {
t.Fatalf("expected chat to shrink: got %d, want < %d", got, initialChatHeight)
}
}
func TestHandleTextareaHeightChange_FollowModeStaysAtBottom(t *testing.T) {
t.Parallel()
// Use enough messages to make the chat scrollable so AtBottom/Follow
// assertions are meaningful.
u := newTestUI()
msgs := make([]chat.MessageItem, 0, 60)
for i := range 60 {
msgs = append(msgs, testMessageItem{
id: "m-" + strconv.Itoa(i),
text: "message " + strconv.Itoa(i),
})
}
u.chat.SetMessages(msgs...)
u.updateLayoutAndSize()
// Enter follow mode and verify we're anchored at the bottom first.
u.chat.ScrollToBottom()
if !u.chat.AtBottom() {
t.Fatal("expected chat to start at bottom")
}
// Grow the editor; follow mode should keep the chat pinned to the end
// even as the chat viewport shrinks.
prevHeight := u.textarea.Height()
u.textarea.SetValue(strings.Repeat("line\n", 10))
u.textarea.MoveToEnd()
_ = u.handleTextareaHeightChange(prevHeight)
if !u.chat.Follow() {
t.Fatal("expected follow mode to remain enabled")
}
if !u.chat.AtBottom() {
t.Fatal("expected chat to remain at bottom after editor resize in follow mode")
}
}