on main help sign in
feat(ui): auto-expand pills when terminal height is sufficient
ba692ccAndrey Nering
internal/ui/model/layout_test.go+107
@@ -6,6 +6,7 @@ import (
6 "testing"
6 "testing"
7
7
8 "charm.land/bubbles/v2/textarea"
8 "charm.land/bubbles/v2/textarea"
9+ "github.com/charmbracelet/crush/internal/session"
9 "github.com/charmbracelet/crush/internal/ui/chat"
10 "github.com/charmbracelet/crush/internal/ui/chat"
10 "github.com/charmbracelet/crush/internal/ui/common"
11 "github.com/charmbracelet/crush/internal/ui/common"
11)
12)
@@ -118,3 +119,109 @@ func TestHandleTextareaHeightChange_FollowModeStaysAtBottom(t *testing.T) {
118 t.Fatal("expected chat to remain at bottom after editor resize in follow mode")
119 t.Fatal("expected chat to remain at bottom after editor resize in follow mode")
119 }
120 }
120}
121}
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 {
134 return strings.Join(lines, "\n")
134 return strings.Join(lines, "\n")
135}
135}
136
136
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+
137// togglePillsExpanded toggles the pills panel expansion state.
170// togglePillsExpanded toggles the pills panel expansion state.
138func (m *UI) togglePillsExpanded() tea.Cmd {
171func (m *UI) togglePillsExpanded() tea.Cmd {
139 if !m.hasSession() {
172 if !m.hasSession() {
@@ -249,7 +282,7 @@ func (m *UI) renderPills() {
249 if todosFocused && hasIncomplete {
282 if todosFocused && hasIncomplete {
250 expandedList = todoList(m.session.Todos, inProgressIcon, t, contentWidth)
283 expandedList = todoList(m.session.Todos, inProgressIcon, t, contentWidth)
251 } else if queueFocused && hasQueue {
284 } else if queueFocused && hasQueue {
252− if m.com.Workspace.AgentIsReady() {
285+ if m.com != nil && m.com.Workspace != nil && m.com.Workspace.AgentIsReady() {
253 queueItems := m.com.Workspace.AgentQueuedPromptsList(m.session.ID)
286 queueItems := m.com.Workspace.AgentQueuedPromptsList(m.session.ID)
254 expandedList = queueList(queueItems, t)
287 expandedList = queueList(queueItems, t)
255 }
288 }
internal/ui/model/ui.go+4
@@ -537,6 +537,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
537 if cmd := m.setSessionMessages(msgs); cmd != nil {
537 if cmd := m.setSessionMessages(msgs); cmd != nil {
538 cmds = append(cmds, cmd)
538 cmds = append(cmds, cmd)
539 }
539 }
540+ if cmd := m.autoExpandPillsIfReasonable(); cmd != nil {
541+ cmds = append(cmds, cmd)
542+ }
540 if hasInProgressTodo(m.session.Todos) {
543 if hasInProgressTodo(m.session.Todos) {
541 // only start spinner if there is an in-progress todo
544 // only start spinner if there is an in-progress todo
542 if m.isAgentBusy() {
545 if m.isAgentBusy() {
@@ -612,6 +615,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
612 cmds = append(cmds, m.todoSpinner.Tick)
615 cmds = append(cmds, m.todoSpinner.Tick)
613 m.updateLayoutAndSize()
616 m.updateLayoutAndSize()
614 }
617 }
618+ m.autoExpandPillsIfReasonable()
615 }
619 }
616 case pubsub.Event[message.Message]:
620 case pubsub.Event[message.Message]:
617 // Check if this is a child session message for an agent tool.
621 // Check if this is a child session message for an agent tool.