refactor: pills section (#1916)

Kujtim Hoxha created

Change summary

internal/ui/chat/todos.go    |   8 
internal/ui/model/keys.go    |  15 ++
internal/ui/model/pills.go   | 283 ++++++++++++++++++++++++++++++++++++++
internal/ui/model/ui.go      | 179 ++++++++++++++++++++---
internal/ui/styles/styles.go |  24 +++
5 files changed, 479 insertions(+), 30 deletions(-)

Detailed changes

internal/ui/chat/todos.go πŸ”—

@@ -82,7 +82,7 @@ func (t *TodosToolRenderContext) RenderTool(sty *styles.Styles, width int, opts
 					} else {
 						headerText = fmt.Sprintf("created %d todos", meta.Total)
 					}
-					body = formatTodosList(sty, meta.Todos, styles.ArrowRightIcon, cappedWidth)
+					body = FormatTodosList(sty, meta.Todos, styles.ArrowRightIcon, cappedWidth)
 				} else {
 					// Build header based on what changed.
 					hasCompleted := len(meta.JustCompleted) > 0
@@ -108,7 +108,7 @@ func (t *TodosToolRenderContext) RenderTool(sty *styles.Styles, width int, opts
 					// Build body with details.
 					if allCompleted {
 						// Show all todos when all are completed, like when created.
-						body = formatTodosList(sty, meta.Todos, styles.ArrowRightIcon, cappedWidth)
+						body = FormatTodosList(sty, meta.Todos, styles.ArrowRightIcon, cappedWidth)
 					} else if meta.JustStarted != "" {
 						body = sty.Tool.TodoInProgressIcon.Render(styles.ArrowRightIcon+" ") +
 							sty.Base.Render(meta.JustStarted)
@@ -135,8 +135,8 @@ func (t *TodosToolRenderContext) RenderTool(sty *styles.Styles, width int, opts
 	return joinToolParts(header, sty.Tool.Body.Render(body))
 }
 
-// formatTodosList formats a list of todos for display.
-func formatTodosList(sty *styles.Styles, todos []session.Todo, inProgressIcon string, width int) string {
+// FormatTodosList formats a list of todos for display.
+func FormatTodosList(sty *styles.Styles, todos []session.Todo, inProgressIcon string, width int) string {
 	if len(todos) == 0 {
 		return ""
 	}

internal/ui/model/keys.go πŸ”—

@@ -23,6 +23,9 @@ type KeyMap struct {
 		Cancel         key.Binding
 		Tab            key.Binding
 		Details        key.Binding
+		TogglePills    key.Binding
+		PillLeft       key.Binding
+		PillRight      key.Binding
 		Down           key.Binding
 		Up             key.Binding
 		UpDown         key.Binding
@@ -149,6 +152,18 @@ func DefaultKeyMap() KeyMap {
 		key.WithKeys("ctrl+d"),
 		key.WithHelp("ctrl+d", "toggle details"),
 	)
+	km.Chat.TogglePills = key.NewBinding(
+		key.WithKeys("ctrl+space"),
+		key.WithHelp("ctrl+space", "toggle tasks"),
+	)
+	km.Chat.PillLeft = key.NewBinding(
+		key.WithKeys("left"),
+		key.WithHelp("←/β†’", "switch section"),
+	)
+	km.Chat.PillRight = key.NewBinding(
+		key.WithKeys("right"),
+		key.WithHelp("←/β†’", "switch section"),
+	)
 
 	km.Chat.Down = key.NewBinding(
 		key.WithKeys("down", "ctrl+j", "ctrl+n", "j"),

internal/ui/model/pills.go πŸ”—

@@ -0,0 +1,283 @@
+package model
+
+import (
+	"fmt"
+	"strings"
+
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/session"
+	"github.com/charmbracelet/crush/internal/ui/chat"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+)
+
+// pillStyle returns the appropriate style for a pill based on focus state.
+func pillStyle(focused, panelFocused bool, t *styles.Styles) lipgloss.Style {
+	if !panelFocused || focused {
+		return t.Pills.Focused
+	}
+	return t.Pills.Blurred
+}
+
+const (
+	// pillHeightWithBorder is the height of a pill including its border.
+	pillHeightWithBorder = 3
+	// maxTaskDisplayLength is the maximum length of a task name in the pill.
+	maxTaskDisplayLength = 40
+	// maxQueueDisplayLength is the maximum length of a queue item in the list.
+	maxQueueDisplayLength = 60
+)
+
+// pillSection represents which section of the pills panel is focused.
+type pillSection int
+
+const (
+	pillSectionTodos pillSection = iota
+	pillSectionQueue
+)
+
+// hasIncompleteTodos returns true if there are any non-completed todos.
+func hasIncompleteTodos(todos []session.Todo) bool {
+	for _, todo := range todos {
+		if todo.Status != session.TodoStatusCompleted {
+			return true
+		}
+	}
+	return false
+}
+
+// hasInProgressTodo returns true if there is at least one in-progress todo.
+func hasInProgressTodo(todos []session.Todo) bool {
+	for _, todo := range todos {
+		if todo.Status == session.TodoStatusInProgress {
+			return true
+		}
+	}
+	return false
+}
+
+// queuePill renders the queue count pill with gradient triangles.
+func queuePill(queue int, focused, panelFocused bool, t *styles.Styles) string {
+	if queue <= 0 {
+		return ""
+	}
+	triangles := styles.ForegroundGrad(t, "β–Άβ–Άβ–Άβ–Άβ–Άβ–Άβ–Άβ–Άβ–Ά", false, t.RedDark, t.Secondary)
+	if queue < len(triangles) {
+		triangles = triangles[:queue]
+	}
+
+	content := fmt.Sprintf("%s %d Queued", strings.Join(triangles, ""), queue)
+	return pillStyle(focused, panelFocused, t).Render(content)
+}
+
+// todoPill renders the todo progress pill with optional spinner and task name.
+func todoPill(todos []session.Todo, spinnerView string, focused, panelFocused bool, t *styles.Styles) string {
+	if !hasIncompleteTodos(todos) {
+		return ""
+	}
+
+	completed := 0
+	var currentTodo *session.Todo
+	for i := range todos {
+		switch todos[i].Status {
+		case session.TodoStatusCompleted:
+			completed++
+		case session.TodoStatusInProgress:
+			if currentTodo == nil {
+				currentTodo = &todos[i]
+			}
+		}
+	}
+
+	total := len(todos)
+
+	label := t.Base.Render("To-Do")
+	progress := t.Muted.Render(fmt.Sprintf("%d/%d", completed, total))
+
+	var content string
+	if panelFocused {
+		content = fmt.Sprintf("%s %s", label, progress)
+	} else if currentTodo != nil {
+		taskText := currentTodo.Content
+		if currentTodo.ActiveForm != "" {
+			taskText = currentTodo.ActiveForm
+		}
+		if len(taskText) > maxTaskDisplayLength {
+			taskText = taskText[:maxTaskDisplayLength-1] + "…"
+		}
+		task := t.Subtle.Render(taskText)
+		content = fmt.Sprintf("%s %s %s  %s", spinnerView, label, progress, task)
+	} else {
+		content = fmt.Sprintf("%s %s", label, progress)
+	}
+
+	return pillStyle(focused, panelFocused, t).Render(content)
+}
+
+// todoList renders the expanded todo list.
+func todoList(sessionTodos []session.Todo, spinnerView string, t *styles.Styles, width int) string {
+	return chat.FormatTodosList(t, sessionTodos, spinnerView, width)
+}
+
+// queueList renders the expanded queue items list.
+func queueList(queueItems []string, t *styles.Styles) string {
+	if len(queueItems) == 0 {
+		return ""
+	}
+
+	var lines []string
+	for _, item := range queueItems {
+		text := item
+		if len(text) > maxQueueDisplayLength {
+			text = text[:maxQueueDisplayLength-1] + "…"
+		}
+		prefix := t.Pills.QueueItemPrefix.Render() + " "
+		lines = append(lines, prefix+t.Muted.Render(text))
+	}
+
+	return strings.Join(lines, "\n")
+}
+
+// togglePillsExpanded toggles the pills panel expansion state.
+func (m *UI) togglePillsExpanded() tea.Cmd {
+	if !m.hasSession() {
+		return nil
+	}
+	if m.layout.pills.Dy() > 0 {
+		if cmd := m.chat.ScrollByAndAnimate(0); cmd != nil {
+			return cmd
+		}
+	}
+	hasPills := hasIncompleteTodos(m.session.Todos) || m.promptQueue > 0
+	if !hasPills {
+		return nil
+	}
+	m.pillsExpanded = !m.pillsExpanded
+	if m.pillsExpanded {
+		if hasIncompleteTodos(m.session.Todos) {
+			m.focusedPillSection = pillSectionTodos
+		} else {
+			m.focusedPillSection = pillSectionQueue
+		}
+	}
+	m.updateLayoutAndSize()
+	return nil
+}
+
+// switchPillSection changes focus between todo and queue sections.
+func (m *UI) switchPillSection(dir int) tea.Cmd {
+	if !m.pillsExpanded || !m.hasSession() {
+		return nil
+	}
+	hasIncompleteTodos := hasIncompleteTodos(m.session.Todos)
+	hasQueue := m.promptQueue > 0
+
+	if dir < 0 && m.focusedPillSection == pillSectionQueue && hasIncompleteTodos {
+		m.focusedPillSection = pillSectionTodos
+		m.updateLayoutAndSize()
+		return nil
+	}
+	if dir > 0 && m.focusedPillSection == pillSectionTodos && hasQueue {
+		m.focusedPillSection = pillSectionQueue
+		m.updateLayoutAndSize()
+		return nil
+	}
+	return nil
+}
+
+// pillsAreaHeight calculates the total height needed for the pills area.
+func (m *UI) pillsAreaHeight() int {
+	if !m.hasSession() {
+		return 0
+	}
+	hasIncomplete := hasIncompleteTodos(m.session.Todos)
+	hasQueue := m.promptQueue > 0
+	hasPills := hasIncomplete || hasQueue
+	if !hasPills {
+		return 0
+	}
+
+	pillsAreaHeight := pillHeightWithBorder
+	if m.pillsExpanded {
+		if m.focusedPillSection == pillSectionTodos && hasIncomplete {
+			pillsAreaHeight += len(m.session.Todos)
+		} else if m.focusedPillSection == pillSectionQueue && hasQueue {
+			pillsAreaHeight += m.promptQueue
+		}
+	}
+	return pillsAreaHeight
+}
+
+// renderPills renders the pills panel and stores it in m.pillsView.
+func (m *UI) renderPills() {
+	m.pillsView = ""
+	if !m.hasSession() {
+		return
+	}
+
+	width := m.layout.pills.Dx()
+	if width <= 0 {
+		return
+	}
+
+	paddingLeft := 3
+	contentWidth := max(width-paddingLeft, 0)
+
+	hasIncomplete := hasIncompleteTodos(m.session.Todos)
+	hasQueue := m.promptQueue > 0
+
+	if !hasIncomplete && !hasQueue {
+		return
+	}
+
+	t := m.com.Styles
+	todosFocused := m.pillsExpanded && m.focusedPillSection == pillSectionTodos
+	queueFocused := m.pillsExpanded && m.focusedPillSection == pillSectionQueue
+
+	inProgressIcon := t.Tool.TodoInProgressIcon.Render(styles.SpinnerIcon)
+	if m.todoIsSpinning {
+		inProgressIcon = m.todoSpinner.View()
+	}
+
+	var pills []string
+	if hasIncomplete {
+		pills = append(pills, todoPill(m.session.Todos, inProgressIcon, todosFocused, m.pillsExpanded, t))
+	}
+	if hasQueue {
+		pills = append(pills, queuePill(m.promptQueue, queueFocused, m.pillsExpanded, t))
+	}
+
+	var expandedList string
+	if m.pillsExpanded {
+		if todosFocused && hasIncomplete {
+			expandedList = todoList(m.session.Todos, inProgressIcon, t, contentWidth)
+		} else if queueFocused && hasQueue {
+			if m.com.App != nil && m.com.App.AgentCoordinator != nil {
+				queueItems := m.com.App.AgentCoordinator.QueuedPromptsList(m.session.ID)
+				expandedList = queueList(queueItems, t)
+			}
+		}
+	}
+
+	if len(pills) == 0 {
+		return
+	}
+
+	pillsRow := lipgloss.JoinHorizontal(lipgloss.Top, pills...)
+
+	helpDesc := "open"
+	if m.pillsExpanded {
+		helpDesc = "close"
+	}
+	helpKey := t.Pills.HelpKey.Render("ctrl+space")
+	helpText := t.Pills.HelpText.Render(helpDesc)
+	helpHint := lipgloss.JoinHorizontal(lipgloss.Center, helpKey, " ", helpText)
+	pillsRow = lipgloss.JoinHorizontal(lipgloss.Center, pillsRow, " ", helpHint)
+
+	pillsArea := pillsRow
+	if expandedList != "" {
+		pillsArea = lipgloss.JoinVertical(lipgloss.Left, pillsRow, expandedList)
+	}
+
+	m.pillsView = t.Pills.Area.MaxWidth(width).PaddingLeft(paddingLeft).Render(pillsArea)
+}

internal/ui/model/ui.go πŸ”—

@@ -189,6 +189,16 @@ type UI struct {
 
 	// detailsOpen tracks whether the details panel is open (in compact mode)
 	detailsOpen bool
+
+	// pills state
+	pillsExpanded      bool
+	focusedPillSection pillSection
+	promptQueue        int
+	pillsView          string
+
+	// Todo spinner
+	todoSpinner    spinner.Model
+	todoIsSpinning bool
 }
 
 // New creates a new instance of the [UI] model.
@@ -212,6 +222,11 @@ func New(com *common.Common) *UI {
 		com.Styles.Completions.Match,
 	)
 
+	todoSpinner := spinner.New(
+		spinner.WithSpinner(spinner.MiniDot),
+		spinner.WithStyle(com.Styles.Pills.TodoSpinner),
+	)
+
 	// Attachments component
 	attachments := attachments.New(
 		attachments.NewRenderer(
@@ -237,6 +252,7 @@ func New(com *common.Common) *UI {
 		chat:        ch,
 		completions: comp,
 		attachments: attachments,
+		todoSpinner: todoSpinner,
 	}
 
 	status := NewStatus(com, ui)
@@ -312,6 +328,13 @@ func (m *UI) loadMCPrompts() tea.Cmd {
 // Update handles updates to the UI model.
 func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	var cmds []tea.Cmd
+	if m.hasSession() && m.isAgentBusy() {
+		queueSize := m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID)
+		if queueSize != m.promptQueue {
+			m.promptQueue = queueSize
+			m.updateLayoutAndSize()
+		}
+	}
 	switch msg := msg.(type) {
 	case tea.EnvMsg:
 		// Is this Windows Terminal?
@@ -333,6 +356,14 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		if cmd := m.setSessionMessages(msgs); cmd != nil {
 			cmds = append(cmds, cmd)
 		}
+		if hasInProgressTodo(m.session.Todos) {
+			// only start spinner if there is an in-progress todo
+			if m.isAgentBusy() {
+				m.todoIsSpinning = true
+				cmds = append(cmds, m.todoSpinner.Tick)
+			}
+			m.updateLayoutAndSize()
+		}
 
 	case sendMessageMsg:
 		cmds = append(cmds, m.sendMessage(msg.Content, msg.Attachments...))
@@ -363,6 +394,16 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case closeDialogMsg:
 		m.dialog.CloseFrontDialog()
 
+	case pubsub.Event[session.Session]:
+		if m.session != nil && msg.Payload.ID == m.session.ID {
+			prevHasInProgress := hasInProgressTodo(m.session.Todos)
+			m.session = &msg.Payload
+			if !prevHasInProgress && hasInProgressTodo(m.session.Todos) {
+				m.todoIsSpinning = true
+				cmds = append(cmds, m.todoSpinner.Tick)
+				m.updateLayoutAndSize()
+			}
+		}
 	case pubsub.Event[message.Message]:
 		// Check if this is a child session message for an agent tool.
 		if m.session == nil {
@@ -383,6 +424,17 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		case pubsub.DeletedEvent:
 			m.chat.RemoveMessage(msg.Payload.ID)
 		}
+		// start the spinner if there is a new message
+		if hasInProgressTodo(m.session.Todos) && m.isAgentBusy() && !m.todoIsSpinning {
+			m.todoIsSpinning = true
+			cmds = append(cmds, m.todoSpinner.Tick)
+		}
+		// stop the spinner if the agent is not busy anymore
+		if m.todoIsSpinning && !m.isAgentBusy() {
+			m.todoIsSpinning = false
+		}
+		// there is a number of things that could change the pills here so we want to re-render
+		m.renderPills()
 	case pubsub.Event[history.File]:
 		cmds = append(cmds, m.handleFileEvent(msg.Payload))
 	case pubsub.Event[app.LSPEvent]:
@@ -524,6 +576,14 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				cmds = append(cmds, cmd)
 			}
 		}
+		if m.state == uiChat && m.hasSession() && hasInProgressTodo(m.session.Todos) && m.todoIsSpinning {
+			var cmd tea.Cmd
+			m.todoSpinner, cmd = m.todoSpinner.Update(msg)
+			if cmd != nil {
+				m.renderPills()
+				cmds = append(cmds, cmd)
+			}
+		}
 
 	case tea.KeyPressMsg:
 		if cmd := m.handleKeyPressMsg(msg); cmd != nil {
@@ -573,7 +633,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case uiFocusMain:
 	case uiFocusEditor:
 		// Textarea placeholder logic
-		if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
+		if m.isAgentBusy() {
 			m.textarea.Placeholder = m.workingPlaceholder
 		} else {
 			m.textarea.Placeholder = m.readyPlaceholder
@@ -945,14 +1005,14 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 		m.setEditorPrompt(yolo)
 		m.dialog.CloseDialog(dialog.CommandsID)
 	case dialog.ActionNewSession:
-		if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
+		if m.isAgentBusy() {
 			cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
 			break
 		}
 		m.newSession()
 		m.dialog.CloseDialog(dialog.CommandsID)
 	case dialog.ActionSummarize:
-		if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
+		if m.isAgentBusy() {
 			cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session..."))
 			break
 		}
@@ -968,7 +1028,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 		m.status.ToggleHelp()
 		m.dialog.CloseDialog(dialog.CommandsID)
 	case dialog.ActionExternalEditor:
-		if m.session != nil && m.com.App.AgentCoordinator.IsSessionBusy(m.session.ID) {
+		if m.isAgentBusy() {
 			cmds = append(cmds, uiutil.ReportWarn("Agent is working, please wait..."))
 			break
 		}
@@ -978,7 +1038,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 		cmds = append(cmds, m.toggleCompactMode())
 		m.dialog.CloseDialog(dialog.CommandsID)
 	case dialog.ActionToggleThinking:
-		if m.com.App.AgentCoordinator.IsBusy() {
+		if m.isAgentBusy() {
 			cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
 			break
 		}
@@ -1010,14 +1070,14 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 	case dialog.ActionQuit:
 		cmds = append(cmds, tea.Quit)
 	case dialog.ActionInitializeProject:
-		if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
+		if m.isAgentBusy() {
 			cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session..."))
 			break
 		}
 		cmds = append(cmds, m.initializeProject())
 
 	case dialog.ActionSelectModel:
-		if m.com.App.AgentCoordinator.IsBusy() {
+		if m.isAgentBusy() {
 			cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
 			break
 		}
@@ -1063,7 +1123,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 		m.dialog.CloseDialog(dialog.OAuthID)
 		m.dialog.CloseDialog(dialog.ModelsID)
 	case dialog.ActionSelectReasoningEffort:
-		if m.com.App.AgentCoordinator.IsBusy() {
+		if m.isAgentBusy() {
 			cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
 			break
 		}
@@ -1217,6 +1277,27 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 			m.detailsOpen = !m.detailsOpen
 			m.updateLayoutAndSize()
 			return true
+		case key.Matches(msg, m.keyMap.Chat.TogglePills):
+			if m.state == uiChat && m.hasSession() {
+				if cmd := m.togglePillsExpanded(); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+				return true
+			}
+		case key.Matches(msg, m.keyMap.Chat.PillLeft):
+			if m.state == uiChat && m.hasSession() && m.pillsExpanded {
+				if cmd := m.switchPillSection(-1); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+				return true
+			}
+		case key.Matches(msg, m.keyMap.Chat.PillRight):
+			if m.state == uiChat && m.hasSession() && m.pillsExpanded {
+				if cmd := m.switchPillSection(1); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+				return true
+			}
 		}
 		return false
 	}
@@ -1237,7 +1318,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 
 	// Handle cancel key when agent is busy.
 	if key.Matches(msg, m.keyMap.Chat.Cancel) {
-		if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
+		if m.isAgentBusy() {
 			if cmd := m.cancelAgent(); cmd != nil {
 				cmds = append(cmds, cmd)
 			}
@@ -1309,10 +1390,10 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 
 				return m.sendMessage(value, attachments...)
 			case key.Matches(msg, m.keyMap.Chat.NewSession):
-				if m.session == nil || m.session.ID == "" {
+				if !m.hasSession() {
 					break
 				}
-				if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
+				if m.isAgentBusy() {
 					cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
 					break
 				}
@@ -1323,7 +1404,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 				m.chat.Focus()
 				m.chat.SetSelected(m.chat.Len() - 1)
 			case key.Matches(msg, m.keyMap.Editor.OpenEditor):
-				if m.session != nil && m.com.App.AgentCoordinator.IsSessionBusy(m.session.ID) {
+				if m.isAgentBusy() {
 					cmds = append(cmds, uiutil.ReportWarn("Agent is working, please wait..."))
 					break
 				}
@@ -1518,6 +1599,9 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 		}
 
 		m.chat.Draw(scr, layout.main)
+		if layout.pills.Dy() > 0 && m.pillsView != "" {
+			uv.NewStyledString(m.pillsView).Draw(scr, layout.pills)
+		}
 
 		editorWidth := scr.Bounds().Dx()
 		if !m.isCompact {
@@ -1617,7 +1701,7 @@ func (m *UI) View() tea.View {
 	content = strings.Join(contentLines, "\n")
 
 	v.Content = content
-	if m.sendProgressBar && m.com.App != nil && m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
+	if m.sendProgressBar && m.isAgentBusy() {
 		// HACK: use a random percentage to prevent ghostty from hiding it
 		// after a timeout.
 		v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
@@ -1641,7 +1725,7 @@ func (m *UI) ShortHelp() []key.Binding {
 		binds = append(binds, k.Quit)
 	case uiChat:
 		// Show cancel binding if agent is busy.
-		if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
+		if m.isAgentBusy() {
 			cancelBinding := k.Chat.Cancel
 			if m.isCanceling {
 				cancelBinding.SetHelp("esc", "press again to cancel")
@@ -1676,6 +1760,9 @@ func (m *UI) ShortHelp() []key.Binding {
 				k.Chat.PageDown,
 				k.Chat.Copy,
 			)
+			if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
+				binds = append(binds, k.Chat.PillLeft)
+			}
 		}
 	default:
 		// TODO: other states
@@ -1703,7 +1790,7 @@ func (m *UI) FullHelp() [][]key.Binding {
 	help := k.Help
 	help.SetHelp("ctrl+g", "less")
 	hasAttachments := len(m.attachments.List()) > 0
-	hasSession := m.session != nil && m.session.ID != ""
+	hasSession := m.hasSession()
 	commands := k.Commands
 	if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
 		commands.SetHelp("/ or ctrl+p", "commands")
@@ -1717,7 +1804,7 @@ func (m *UI) FullHelp() [][]key.Binding {
 			})
 	case uiChat:
 		// Show cancel binding if agent is busy.
-		if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
+		if m.isAgentBusy() {
 			cancelBinding := k.Chat.Cancel
 			if m.isCanceling {
 				cancelBinding.SetHelp("esc", "press again to cancel")
@@ -1785,6 +1872,9 @@ func (m *UI) FullHelp() [][]key.Binding {
 					k.Chat.ClearHighlight,
 				},
 			)
+			if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
+				binds = append(binds, []key.Binding{k.Chat.PillLeft})
+			}
 		}
 	default:
 		if m.session == nil {
@@ -1873,6 +1963,7 @@ func (m *UI) updateSize() {
 	m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
 	m.textarea.SetWidth(m.layout.editor.Dx())
 	m.textarea.SetHeight(m.layout.editor.Dy())
+	m.renderPills()
 
 	// Handle different app states
 	switch m.state {
@@ -1984,10 +2075,18 @@ func (m *UI) generateLayout(w, h int) layout {
 			mainRect.Min.Y += 1
 			mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
 			mainRect.Max.X -= 1 // Add padding right
-			// Add bottom margin to main
-			mainRect.Max.Y -= 1
 			layout.header = headerRect
-			layout.main = mainRect
+			pillsHeight := m.pillsAreaHeight()
+			if pillsHeight > 0 {
+				pillsHeight = min(pillsHeight, mainRect.Dy())
+				chatRect, pillsRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-pillsHeight))
+				layout.main = chatRect
+				layout.pills = pillsRect
+			} else {
+				layout.main = mainRect
+			}
+			// Add bottom margin to main
+			layout.main.Max.Y -= 1
 			layout.editor = editorRect
 		} else {
 			// Layout
@@ -2004,10 +2103,18 @@ func (m *UI) generateLayout(w, h int) layout {
 			sideRect.Min.X += 1
 			mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
 			mainRect.Max.X -= 1 // Add padding right
-			// Add bottom margin to main
-			mainRect.Max.Y -= 1
 			layout.sidebar = sideRect
-			layout.main = mainRect
+			pillsHeight := m.pillsAreaHeight()
+			if pillsHeight > 0 {
+				pillsHeight = min(pillsHeight, mainRect.Dy())
+				chatRect, pillsRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-pillsHeight))
+				layout.main = chatRect
+				layout.pills = pillsRect
+			} else {
+				layout.main = mainRect
+			}
+			// Add bottom margin to main
+			layout.main.Max.Y -= 1
 			layout.editor = editorRect
 		}
 	}
@@ -2035,6 +2142,9 @@ type layout struct {
 	// main is the area for the main pane. (e.x chat, configure, landing)
 	main uv.Rectangle
 
+	// pills is the area for the pills panel.
+	pills uv.Rectangle
+
 	// editor is the area for the editor pane.
 	editor uv.Rectangle
 
@@ -2208,6 +2318,19 @@ func isWhitespace(b byte) bool {
 	return b == ' ' || b == '\t' || b == '\n' || b == '\r'
 }
 
+// isAgentBusy returns true if the agent coordinator exists and is currently
+// busy processing a request.
+func (m *UI) isAgentBusy() bool {
+	return m.com.App != nil &&
+		m.com.App.AgentCoordinator != nil &&
+		m.com.App.AgentCoordinator.IsBusy()
+}
+
+// hasSession returns true if there is an active session with a valid ID.
+func (m *UI) hasSession() bool {
+	return m.session != nil && m.session.ID != ""
+}
+
 // mimeOf detects the MIME type of the given content.
 func mimeOf(content []byte) string {
 	mimeBufferSize := min(512, len(content))
@@ -2271,7 +2394,7 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.
 	}
 
 	var cmds []tea.Cmd
-	if m.session == nil || m.session.ID == "" {
+	if !m.hasSession() {
 		newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
 		if err != nil {
 			return uiutil.ReportError(err)
@@ -2319,7 +2442,7 @@ func cancelTimerCmd() tea.Cmd {
 // and starts a timer. The second press (before the timer expires) actually
 // cancels the agent.
 func (m *UI) cancelAgent() tea.Cmd {
-	if m.session == nil || m.session.ID == "" {
+	if !m.hasSession() {
 		return nil
 	}
 
@@ -2332,6 +2455,9 @@ func (m *UI) cancelAgent() tea.Cmd {
 		// Second escape press - actually cancel the agent.
 		m.isCanceling = false
 		coordinator.Cancel(m.session.ID)
+		// Stop the spinning todo indicator.
+		m.todoIsSpinning = false
+		m.renderPills()
 		return nil
 	}
 
@@ -2521,7 +2647,7 @@ func (m *UI) handlePermissionNotification(notification permission.PermissionNoti
 // newSession clears the current session state and prepares for a new session.
 // The actual session creation happens when the user sends their first message.
 func (m *UI) newSession() {
-	if m.session == nil || m.session.ID == "" {
+	if !m.hasSession() {
 		return
 	}
 
@@ -2532,6 +2658,9 @@ func (m *UI) newSession() {
 	m.textarea.Focus()
 	m.chat.Blur()
 	m.chat.ClearMessages()
+	m.pillsExpanded = false
+	m.promptQueue = 0
+	m.pillsView = ""
 }
 
 // handlePasteMsg handles a paste message.

internal/ui/styles/styles.go πŸ”—

@@ -21,7 +21,7 @@ const (
 	WarningIcon string = "⚠"
 	InfoIcon    string = "β“˜"
 	HintIcon    string = "∡"
-	SpinnerIcon string = "..."
+	SpinnerIcon string = "β‹―"
 	LoadingIcon string = "⟳"
 	ModelIcon   string = "β—‡"
 
@@ -394,6 +394,18 @@ type Styles struct {
 		Text     lipgloss.Style
 		Deleting lipgloss.Style
 	}
+
+	// Pills styles for todo/queue pills
+	Pills struct {
+		Base            lipgloss.Style // Base pill style with padding
+		Focused         lipgloss.Style // Focused pill with visible border
+		Blurred         lipgloss.Style // Blurred pill with hidden border
+		QueueItemPrefix lipgloss.Style // Prefix for queue list items
+		HelpKey         lipgloss.Style // Keystroke hint style
+		HelpText        lipgloss.Style // Help action text style
+		Area            lipgloss.Style // Pills area container
+		TodoSpinner     lipgloss.Style // Todo spinner style
+	}
 }
 
 // ChromaTheme converts the current markdown chroma styles to a chroma
@@ -1270,6 +1282,16 @@ func DefaultStyles() Styles {
 	s.Attachments.Normal = base.Padding(0, 1).MarginRight(1).Background(fgMuted).Foreground(fgBase)
 	s.Attachments.Deleting = base.Padding(0, 1).Bold(true).Background(red).Foreground(fgBase)
 
+	// Pills styles
+	s.Pills.Base = base.Padding(0, 1)
+	s.Pills.Focused = base.Padding(0, 1).BorderStyle(lipgloss.RoundedBorder()).BorderForeground(bgOverlay)
+	s.Pills.Blurred = base.Padding(0, 1).BorderStyle(lipgloss.HiddenBorder())
+	s.Pills.QueueItemPrefix = s.Muted.SetString("  β€’")
+	s.Pills.HelpKey = s.Muted
+	s.Pills.HelpText = s.Subtle
+	s.Pills.Area = base
+	s.Pills.TodoSpinner = base.Foreground(greenDark)
+
 	return s
 }