@@ -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")
+ }
+ })
+}
@@ -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)
}
@@ -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.