on main help sign in
feat(ui): auto-expand pills when terminal height is sufficient

The todo/queue panel now opens automatically when the terminal is tall enough (โ‰ฅ40 rows) and there are active items, eliminating the need for a manual ctrl+t toggle in typical desktop use. ๐Ÿ’˜ Generated with Crush Assisted-by: Crush:kimi-k2.6

commitba692cc55a73e027b9ea5805bac63fd8b3936400 authorAndrey Nering <andreynering@users.noreply.github.com> dateFri 15 May 2026, 17:05 (5 months ago) parent092ac3e
3 files changed+145โˆ’1
internal/ui/model/layout_test.go+107 internal/ui/model/pills.go+34โˆ’1 internal/ui/model/ui.go+4
internal/ui/model/layout_test.go+107
@@ -6,6 +6,7 @@ import (
66 "testing"
77
88 "charm.land/bubbles/v2/textarea"
9+ "github.com/charmbracelet/crush/internal/session"
910 "github.com/charmbracelet/crush/internal/ui/chat"
1011 "github.com/charmbracelet/crush/internal/ui/common"
1112)
@@ -118,3 +119,109 @@ func TestHandleTextareaHeightChange_FollowModeStaysAtBottom(t *testing.T) {
118119 t.Fatal("expected chat to remain at bottom after editor resize in follow mode")
119120 }
120121}
122+
123+func 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+}
internal/ui/model/pills.go+34โˆ’1
@@ -134,6 +134,39 @@ func queueList(queueItems []string, t *styles.Styles) string {
134134 return strings.Join(lines, "\n")
135135}
136136
137+// pillsHeightReasonableTerminalHeight is the minimum terminal height at which
138+// we auto-expand pills when there are incomplete todos.
139+const pillsHeightReasonableTerminalHeight = 40
140+
141+// autoExpandPillsIfReasonable expands the pills panel if the terminal has
142+// enough vertical space to show the expanded list comfortably.
143+func (m *UI) autoExpandPillsIfReasonable() tea.Cmd {
144+ if !m.hasSession() {
145+ return nil
146+ }
147+ if m.height < pillsHeightReasonableTerminalHeight {
148+ return nil
149+ }
150+ hasPills := hasIncompleteTodos(m.session.Todos) || m.promptQueue > 0
151+ if !hasPills {
152+ return nil
153+ }
154+ if m.pillsExpanded {
155+ return nil
156+ }
157+ m.pillsExpanded = true
158+ if hasIncompleteTodos(m.session.Todos) {
159+ m.focusedPillSection = pillSectionTodos
160+ } else {
161+ m.focusedPillSection = pillSectionQueue
162+ }
163+ m.updateLayoutAndSize()
164+ if m.chat.Follow() {
165+ m.chat.ScrollToBottom()
166+ }
167+ return nil
168+}
169+
137170// togglePillsExpanded toggles the pills panel expansion state.
138171func (m *UI) togglePillsExpanded() tea.Cmd {
139172 if !m.hasSession() {
@@ -249,7 +282,7 @@ func (m *UI) renderPills() {
249282 if todosFocused && hasIncomplete {
250283 expandedList = todoList(m.session.Todos, inProgressIcon, t, contentWidth)
251284 } else if queueFocused && hasQueue {
252โˆ’ if m.com.Workspace.AgentIsReady() {
285+ if m.com != nil && m.com.Workspace != nil && m.com.Workspace.AgentIsReady() {
253286 queueItems := m.com.Workspace.AgentQueuedPromptsList(m.session.ID)
254287 expandedList = queueList(queueItems, t)
255288 }
internal/ui/model/ui.go+4
@@ -537,6 +537,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
537537 if cmd := m.setSessionMessages(msgs); cmd != nil {
538538 cmds = append(cmds, cmd)
539539 }
540+ if cmd := m.autoExpandPillsIfReasonable(); cmd != nil {
541+ cmds = append(cmds, cmd)
542+ }
540543 if hasInProgressTodo(m.session.Todos) {
541544 // only start spinner if there is an in-progress todo
542545 if m.isAgentBusy() {
@@ -612,6 +615,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
612615 cmds = append(cmds, m.todoSpinner.Tick)
613616 m.updateLayoutAndSize()
614617 }
618+ m.autoExpandPillsIfReasonable()
615619 }
616620 case pubsub.Event[message.Message]:
617621 // Check if this is a child session message for an agent tool.