diff --git a/internal/ui/model/layout_test.go b/internal/ui/model/layout_test.go index ea4c33f1c3ce459054b01e66a1e32f4d8cc031de..36e25485691069ddc33c6691426959ee67eee00f 100644 --- a/internal/ui/model/layout_test.go +++ b/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") + } + }) +} diff --git a/internal/ui/model/pills.go b/internal/ui/model/pills.go index bd0ac9d0294cd215bd4b388728a36b1c1f015f63..6bbfc34463a20658411fb74e847e87e4a7c2841b 100644 --- a/internal/ui/model/pills.go +++ b/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) } diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 0b103b266e607a367d105cf9404132d9ffdac524..078f051ce1c11adfbe76de103933a200078ee153 100644 --- a/internal/ui/model/ui.go +++ b/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.