feat(ui): auto-expand pills when terminal height is sufficient

Andrey Nering created

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

Change summary

internal/ui/model/layout_test.go | 107 ++++++++++++++++++++++++++++++++++
internal/ui/model/pills.go       |  35 ++++++++++
internal/ui/model/ui.go          |   4 +
3 files changed, 145 insertions(+), 1 deletion(-)

Detailed changes

internal/ui/model/layout_test.go 🔗

@@ -6,6 +6,7 @@ import (
 	"testing"
 
 	"charm.land/bubbles/v2/textarea"
+	"github.com/charmbracelet/crush/internal/session"
 	"github.com/charmbracelet/crush/internal/ui/chat"
 	"github.com/charmbracelet/crush/internal/ui/common"
 )
@@ -118,3 +119,109 @@ func TestHandleTextareaHeightChange_FollowModeStaysAtBottom(t *testing.T) {
 		t.Fatal("expected chat to remain at bottom after editor resize in follow mode")
 	}
 }
+
+func TestAutoExpandPillsIfReasonable(t *testing.T) {
+	t.Parallel()
+
+	t.Run("expands when terminal is tall enough and todos exist", func(t *testing.T) {
+		t.Parallel()
+
+		u := newTestUI()
+		u.height = 50
+		u.session = &session.Session{ID: "s1", Todos: []session.Todo{
+			{Status: session.TodoStatusInProgress, Content: "do work"},
+			{Status: session.TodoStatusPending, Content: "do more"},
+		}}
+
+		u.autoExpandPillsIfReasonable()
+
+		if !u.pillsExpanded {
+			t.Fatal("expected pillsExpanded to be true")
+		}
+		if u.focusedPillSection != pillSectionTodos {
+			t.Fatalf("expected focusedPillSection to be pillSectionTodos, got %d", u.focusedPillSection)
+		}
+	})
+
+	t.Run("does not expand when terminal is too short", func(t *testing.T) {
+		t.Parallel()
+
+		u := newTestUI()
+		u.height = 30
+		u.session = &session.Session{ID: "s1", Todos: []session.Todo{
+			{Status: session.TodoStatusInProgress, Content: "do work"},
+		}}
+
+		u.autoExpandPillsIfReasonable()
+
+		if u.pillsExpanded {
+			t.Fatal("expected pillsExpanded to be false when terminal height is below threshold")
+		}
+	})
+
+	t.Run("does not expand when all todos are completed", func(t *testing.T) {
+		t.Parallel()
+
+		u := newTestUI()
+		u.height = 50
+		u.session = &session.Session{ID: "s1", Todos: []session.Todo{
+			{Status: session.TodoStatusCompleted, Content: "done"},
+		}}
+
+		u.autoExpandPillsIfReasonable()
+
+		if u.pillsExpanded {
+			t.Fatal("expected pillsExpanded to be false when all todos are completed")
+		}
+	})
+
+	t.Run("does not expand when already expanded", func(t *testing.T) {
+		t.Parallel()
+
+		u := newTestUI()
+		u.height = 50
+		u.pillsExpanded = true
+		u.session = &session.Session{ID: "s1", Todos: []session.Todo{
+			{Status: session.TodoStatusInProgress, Content: "do work"},
+		}}
+		u.updateLayoutAndSize()
+
+		u.autoExpandPillsIfReasonable()
+
+		if !u.pillsExpanded {
+			t.Fatal("expected pillsExpanded to stay true")
+		}
+	})
+
+	t.Run("expands for prompt queue when no todos", func(t *testing.T) {
+		t.Parallel()
+
+		u := newTestUI()
+		u.height = 50
+		u.session = &session.Session{ID: "s1", Todos: []session.Todo{}}
+		u.promptQueue = 2
+
+		u.autoExpandPillsIfReasonable()
+
+		if !u.pillsExpanded {
+			t.Fatal("expected pillsExpanded to be true for prompt queue")
+		}
+		if u.focusedPillSection != pillSectionQueue {
+			t.Fatalf("expected focusedPillSection to be pillSectionQueue, got %d", u.focusedPillSection)
+		}
+	})
+
+	t.Run("does not expand when no session", func(t *testing.T) {
+		t.Parallel()
+
+		u := newTestUI()
+		u.height = 50
+		u.session = nil
+
+		u.autoExpandPillsIfReasonable()
+
+		if u.pillsExpanded {
+			t.Fatal("expected pillsExpanded to be false when there is no session")
+		}
+	})
+}

internal/ui/model/pills.go 🔗

@@ -134,6 +134,39 @@ func queueList(queueItems []string, t *styles.Styles) string {
 	return strings.Join(lines, "\n")
 }
 
+// pillsHeightReasonableTerminalHeight is the minimum terminal height at which
+// we auto-expand pills when there are incomplete todos.
+const pillsHeightReasonableTerminalHeight = 40
+
+// autoExpandPillsIfReasonable expands the pills panel if the terminal has
+// enough vertical space to show the expanded list comfortably.
+func (m *UI) autoExpandPillsIfReasonable() tea.Cmd {
+	if !m.hasSession() {
+		return nil
+	}
+	if m.height < pillsHeightReasonableTerminalHeight {
+		return nil
+	}
+	hasPills := hasIncompleteTodos(m.session.Todos) || m.promptQueue > 0
+	if !hasPills {
+		return nil
+	}
+	if m.pillsExpanded {
+		return nil
+	}
+	m.pillsExpanded = true
+	if hasIncompleteTodos(m.session.Todos) {
+		m.focusedPillSection = pillSectionTodos
+	} else {
+		m.focusedPillSection = pillSectionQueue
+	}
+	m.updateLayoutAndSize()
+	if m.chat.Follow() {
+		m.chat.ScrollToBottom()
+	}
+	return nil
+}
+
 // togglePillsExpanded toggles the pills panel expansion state.
 func (m *UI) togglePillsExpanded() tea.Cmd {
 	if !m.hasSession() {
@@ -249,7 +282,7 @@ func (m *UI) renderPills() {
 		if todosFocused && hasIncomplete {
 			expandedList = todoList(m.session.Todos, inProgressIcon, t, contentWidth)
 		} else if queueFocused && hasQueue {
-			if m.com.Workspace.AgentIsReady() {
+			if m.com != nil && m.com.Workspace != nil && m.com.Workspace.AgentIsReady() {
 				queueItems := m.com.Workspace.AgentQueuedPromptsList(m.session.ID)
 				expandedList = queueList(queueItems, t)
 			}

internal/ui/model/ui.go 🔗

@@ -537,6 +537,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		if cmd := m.setSessionMessages(msgs); cmd != nil {
 			cmds = append(cmds, cmd)
 		}
+		if cmd := m.autoExpandPillsIfReasonable(); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
 		if hasInProgressTodo(m.session.Todos) {
 			// only start spinner if there is an in-progress todo
 			if m.isAgentBusy() {
@@ -612,6 +615,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				cmds = append(cmds, m.todoSpinner.Tick)
 				m.updateLayoutAndSize()
 			}
+			m.autoExpandPillsIfReasonable()
 		}
 	case pubsub.Event[message.Message]:
 		// Check if this is a child session message for an agent tool.