1package model
2
3import (
4 "strconv"
5 "strings"
6 "testing"
7
8 "charm.land/bubbles/v2/textarea"
9 "github.com/charmbracelet/crush/internal/session"
10 "github.com/charmbracelet/crush/internal/ui/chat"
11 "github.com/charmbracelet/crush/internal/ui/common"
12)
13
14// testMessageItem is a minimal chat item used to populate the chat list
15// without pulling in full message rendering machinery.
16type testMessageItem struct {
17 id string
18 text string
19}
20
21func (m testMessageItem) ID() string { return m.id }
22func (m testMessageItem) Render(int) string { return m.text }
23func (m testMessageItem) RawRender(int) string { return m.text }
24func (m testMessageItem) Version() uint64 { return 0 }
25func (m testMessageItem) Finished() bool { return true }
26
27var _ chat.MessageItem = testMessageItem{}
28
29// newTestUI builds a focused uiChat model with dynamic textarea sizing enabled.
30// It intentionally keeps dependencies minimal so layout behavior can be tested
31// in isolation.
32func newTestUI() *UI {
33 com := common.DefaultCommon(nil)
34
35 ta := textarea.New()
36 ta.SetStyles(com.Styles.Editor.Textarea)
37 ta.ShowLineNumbers = false
38 ta.CharLimit = -1
39 ta.SetVirtualCursor(false)
40 ta.DynamicHeight = true
41 ta.MinHeight = TextareaMinHeight
42 ta.MaxHeight = TextareaMaxHeight
43 ta.Focus()
44
45 u := &UI{
46 com: com,
47 status: NewStatus(com, nil),
48 chat: NewChat(com),
49 textarea: ta,
50 state: uiChat,
51 focus: uiFocusEditor,
52 width: 140,
53 height: 45,
54 }
55
56 return u
57}
58
59func TestUpdateLayoutAndSize_EditorGrowthShrinksChat(t *testing.T) {
60 t.Parallel()
61
62 // Baseline layout at min textarea height.
63 u := newTestUI()
64 u.updateLayoutAndSize()
65
66 initialEditorHeight := u.layout.editor.Dy()
67 initialChatHeight := u.layout.main.Dy()
68
69 // Increase textarea content enough to trigger growth, then run the
70 // same resize hook used in the real update path.
71 prevHeight := u.textarea.Height()
72 u.textarea.SetValue(strings.Repeat("line\n", 8))
73 u.textarea.MoveToEnd()
74 _ = u.handleTextareaHeightChange(prevHeight)
75
76 if got := u.layout.editor.Dy(); got <= initialEditorHeight {
77 t.Fatalf("expected editor to grow: got %d, want > %d", got, initialEditorHeight)
78 }
79
80 if got := u.layout.main.Dy(); got >= initialChatHeight {
81 t.Fatalf("expected chat to shrink: got %d, want < %d", got, initialChatHeight)
82 }
83}
84
85func TestHandleTextareaHeightChange_FollowModeStaysAtBottom(t *testing.T) {
86 t.Parallel()
87
88 // Use enough messages to make the chat scrollable so AtBottom/Follow
89 // assertions are meaningful.
90 u := newTestUI()
91
92 msgs := make([]chat.MessageItem, 0, 60)
93 for i := range 60 {
94 msgs = append(msgs, testMessageItem{
95 id: "m-" + strconv.Itoa(i),
96 text: "message " + strconv.Itoa(i),
97 })
98 }
99 u.chat.SetMessages(msgs...)
100 u.updateLayoutAndSize()
101
102 // Enter follow mode and verify we're anchored at the bottom first.
103 u.chat.ScrollToBottom()
104 if !u.chat.AtBottom() {
105 t.Fatal("expected chat to start at bottom")
106 }
107
108 // Grow the editor; follow mode should keep the chat pinned to the end
109 // even as the chat viewport shrinks.
110 prevHeight := u.textarea.Height()
111 u.textarea.SetValue(strings.Repeat("line\n", 10))
112 u.textarea.MoveToEnd()
113 _ = u.handleTextareaHeightChange(prevHeight)
114
115 if !u.chat.Follow() {
116 t.Fatal("expected follow mode to remain enabled")
117 }
118 if !u.chat.AtBottom() {
119 t.Fatal("expected chat to remain at bottom after editor resize in follow mode")
120 }
121}
122
123func TestAutoExpandPillsIfReasonable(t *testing.T) {
124 t.Parallel()
125
126 t.Run("expands when terminal is tall enough and todos exist", func(t *testing.T) {
127 t.Parallel()
128
129 u := newTestUI()
130 u.height = 50
131 u.session = &session.Session{ID: "s1", Todos: []session.Todo{
132 {Status: session.TodoStatusInProgress, Content: "do work"},
133 {Status: session.TodoStatusPending, Content: "do more"},
134 }}
135
136 u.autoExpandPillsIfReasonable()
137
138 if !u.pillsExpanded {
139 t.Fatal("expected pillsExpanded to be true")
140 }
141 if u.focusedPillSection != pillSectionTodos {
142 t.Fatalf("expected focusedPillSection to be pillSectionTodos, got %d", u.focusedPillSection)
143 }
144 })
145
146 t.Run("does not expand when terminal is too short", func(t *testing.T) {
147 t.Parallel()
148
149 u := newTestUI()
150 u.height = 30
151 u.session = &session.Session{ID: "s1", Todos: []session.Todo{
152 {Status: session.TodoStatusInProgress, Content: "do work"},
153 }}
154
155 u.autoExpandPillsIfReasonable()
156
157 if u.pillsExpanded {
158 t.Fatal("expected pillsExpanded to be false when terminal height is below threshold")
159 }
160 })
161
162 t.Run("does not expand when all todos are completed", func(t *testing.T) {
163 t.Parallel()
164
165 u := newTestUI()
166 u.height = 50
167 u.session = &session.Session{ID: "s1", Todos: []session.Todo{
168 {Status: session.TodoStatusCompleted, Content: "done"},
169 }}
170
171 u.autoExpandPillsIfReasonable()
172
173 if u.pillsExpanded {
174 t.Fatal("expected pillsExpanded to be false when all todos are completed")
175 }
176 })
177
178 t.Run("does not expand when already expanded", func(t *testing.T) {
179 t.Parallel()
180
181 u := newTestUI()
182 u.height = 50
183 u.pillsExpanded = true
184 u.session = &session.Session{ID: "s1", Todos: []session.Todo{
185 {Status: session.TodoStatusInProgress, Content: "do work"},
186 }}
187 u.updateLayoutAndSize()
188
189 u.autoExpandPillsIfReasonable()
190
191 if !u.pillsExpanded {
192 t.Fatal("expected pillsExpanded to stay true")
193 }
194 })
195
196 t.Run("expands for prompt queue when no todos", func(t *testing.T) {
197 t.Parallel()
198
199 u := newTestUI()
200 u.height = 50
201 u.session = &session.Session{ID: "s1", Todos: []session.Todo{}}
202 u.promptQueue = 2
203
204 u.autoExpandPillsIfReasonable()
205
206 if !u.pillsExpanded {
207 t.Fatal("expected pillsExpanded to be true for prompt queue")
208 }
209 if u.focusedPillSection != pillSectionQueue {
210 t.Fatalf("expected focusedPillSection to be pillSectionQueue, got %d", u.focusedPillSection)
211 }
212 })
213
214 t.Run("does not expand when no session", func(t *testing.T) {
215 t.Parallel()
216
217 u := newTestUI()
218 u.height = 50
219 u.session = nil
220
221 u.autoExpandPillsIfReasonable()
222
223 if u.pillsExpanded {
224 t.Fatal("expected pillsExpanded to be false when there is no session")
225 }
226 })
227}