pills.go

  1package model
  2
  3import (
  4	"fmt"
  5	"strings"
  6
  7	tea "charm.land/bubbletea/v2"
  8	"charm.land/lipgloss/v2"
  9	"github.com/charmbracelet/crush/internal/session"
 10	"github.com/charmbracelet/crush/internal/ui/chat"
 11	"github.com/charmbracelet/crush/internal/ui/styles"
 12)
 13
 14// pillStyle returns the appropriate style for a pill based on focus state.
 15func pillStyle(focused, panelFocused bool, t *styles.Styles) lipgloss.Style {
 16	if !panelFocused || focused {
 17		return t.Pills.Focused
 18	}
 19	return t.Pills.Blurred
 20}
 21
 22const (
 23	// pillHeightWithBorder is the height of a pill including its border.
 24	pillHeightWithBorder = 3
 25	// maxTaskDisplayLength is the maximum length of a task name in the pill.
 26	maxTaskDisplayLength = 40
 27	// maxQueueDisplayLength is the maximum length of a queue item in the list.
 28	maxQueueDisplayLength = 60
 29)
 30
 31// pillSection represents which section of the pills panel is focused.
 32type pillSection int
 33
 34const (
 35	pillSectionTodos pillSection = iota
 36	pillSectionQueue
 37)
 38
 39// hasIncompleteTodos returns true if there are any non-completed todos.
 40func hasIncompleteTodos(todos []session.Todo) bool {
 41	for _, todo := range todos {
 42		if todo.Status != session.TodoStatusCompleted {
 43			return true
 44		}
 45	}
 46	return false
 47}
 48
 49// hasInProgressTodo returns true if there is at least one in-progress todo.
 50func hasInProgressTodo(todos []session.Todo) bool {
 51	for _, todo := range todos {
 52		if todo.Status == session.TodoStatusInProgress {
 53			return true
 54		}
 55	}
 56	return false
 57}
 58
 59// queuePill renders the queue count pill with gradient triangles.
 60func queuePill(queue int, focused, panelFocused bool, t *styles.Styles) string {
 61	if queue <= 0 {
 62		return ""
 63	}
 64	triangles := styles.ForegroundGrad(t, "▶▶▶▶▶▶▶▶▶", false, t.RedDark, t.Secondary)
 65	if queue < len(triangles) {
 66		triangles = triangles[:queue]
 67	}
 68
 69	content := fmt.Sprintf("%s %d Queued", strings.Join(triangles, ""), queue)
 70	return pillStyle(focused, panelFocused, t).Render(content)
 71}
 72
 73// todoPill renders the todo progress pill with optional spinner and task name.
 74func todoPill(todos []session.Todo, spinnerView string, focused, panelFocused bool, t *styles.Styles) string {
 75	if !hasIncompleteTodos(todos) {
 76		return ""
 77	}
 78
 79	completed := 0
 80	var currentTodo *session.Todo
 81	for i := range todos {
 82		switch todos[i].Status {
 83		case session.TodoStatusCompleted:
 84			completed++
 85		case session.TodoStatusInProgress:
 86			if currentTodo == nil {
 87				currentTodo = &todos[i]
 88			}
 89		}
 90	}
 91
 92	total := len(todos)
 93
 94	label := t.Base.Render("To-Do")
 95	progress := t.Muted.Render(fmt.Sprintf("%d/%d", completed, total))
 96
 97	var content string
 98	if panelFocused {
 99		content = fmt.Sprintf("%s %s", label, progress)
100	} else if currentTodo != nil {
101		taskText := currentTodo.Content
102		if currentTodo.ActiveForm != "" {
103			taskText = currentTodo.ActiveForm
104		}
105		if len(taskText) > maxTaskDisplayLength {
106			taskText = taskText[:maxTaskDisplayLength-1] + "…"
107		}
108		task := t.Subtle.Render(taskText)
109		content = fmt.Sprintf("%s %s %s  %s", spinnerView, label, progress, task)
110	} else {
111		content = fmt.Sprintf("%s %s", label, progress)
112	}
113
114	return pillStyle(focused, panelFocused, t).Render(content)
115}
116
117// todoList renders the expanded todo list.
118func todoList(sessionTodos []session.Todo, spinnerView string, t *styles.Styles, width int) string {
119	return chat.FormatTodosList(t, sessionTodos, spinnerView, width)
120}
121
122// queueList renders the expanded queue items list.
123func queueList(queueItems []string, t *styles.Styles) string {
124	if len(queueItems) == 0 {
125		return ""
126	}
127
128	var lines []string
129	for _, item := range queueItems {
130		text := item
131		if len(text) > maxQueueDisplayLength {
132			text = text[:maxQueueDisplayLength-1] + "…"
133		}
134		prefix := t.Pills.QueueItemPrefix.Render() + " "
135		lines = append(lines, prefix+t.Muted.Render(text))
136	}
137
138	return strings.Join(lines, "\n")
139}
140
141// togglePillsExpanded toggles the pills panel expansion state.
142func (m *UI) togglePillsExpanded() tea.Cmd {
143	if !m.hasSession() {
144		return nil
145	}
146	if m.layout.pills.Dy() > 0 {
147		if cmd := m.chat.ScrollByAndAnimate(0); cmd != nil {
148			return cmd
149		}
150	}
151	hasPills := hasIncompleteTodos(m.session.Todos) || m.promptQueue > 0
152	if !hasPills {
153		return nil
154	}
155	m.pillsExpanded = !m.pillsExpanded
156	if m.pillsExpanded {
157		if hasIncompleteTodos(m.session.Todos) {
158			m.focusedPillSection = pillSectionTodos
159		} else {
160			m.focusedPillSection = pillSectionQueue
161		}
162	}
163	m.updateLayoutAndSize()
164	return nil
165}
166
167// switchPillSection changes focus between todo and queue sections.
168func (m *UI) switchPillSection(dir int) tea.Cmd {
169	if !m.pillsExpanded || !m.hasSession() {
170		return nil
171	}
172	hasIncompleteTodos := hasIncompleteTodos(m.session.Todos)
173	hasQueue := m.promptQueue > 0
174
175	if dir < 0 && m.focusedPillSection == pillSectionQueue && hasIncompleteTodos {
176		m.focusedPillSection = pillSectionTodos
177		m.updateLayoutAndSize()
178		return nil
179	}
180	if dir > 0 && m.focusedPillSection == pillSectionTodos && hasQueue {
181		m.focusedPillSection = pillSectionQueue
182		m.updateLayoutAndSize()
183		return nil
184	}
185	return nil
186}
187
188// pillsAreaHeight calculates the total height needed for the pills area.
189func (m *UI) pillsAreaHeight() int {
190	if !m.hasSession() {
191		return 0
192	}
193	hasIncomplete := hasIncompleteTodos(m.session.Todos)
194	hasQueue := m.promptQueue > 0
195	hasPills := hasIncomplete || hasQueue
196	if !hasPills {
197		return 0
198	}
199
200	pillsAreaHeight := pillHeightWithBorder
201	if m.pillsExpanded {
202		if m.focusedPillSection == pillSectionTodos && hasIncomplete {
203			pillsAreaHeight += len(m.session.Todos)
204		} else if m.focusedPillSection == pillSectionQueue && hasQueue {
205			pillsAreaHeight += m.promptQueue
206		}
207	}
208	return pillsAreaHeight
209}
210
211// renderPills renders the pills panel and stores it in m.pillsView.
212func (m *UI) renderPills() {
213	m.pillsView = ""
214	if !m.hasSession() {
215		return
216	}
217
218	width := m.layout.pills.Dx()
219	if width <= 0 {
220		return
221	}
222
223	paddingLeft := 3
224	contentWidth := max(width-paddingLeft, 0)
225
226	hasIncomplete := hasIncompleteTodos(m.session.Todos)
227	hasQueue := m.promptQueue > 0
228
229	if !hasIncomplete && !hasQueue {
230		return
231	}
232
233	t := m.com.Styles
234	todosFocused := m.pillsExpanded && m.focusedPillSection == pillSectionTodos
235	queueFocused := m.pillsExpanded && m.focusedPillSection == pillSectionQueue
236
237	inProgressIcon := t.Tool.TodoInProgressIcon.Render(styles.SpinnerIcon)
238	if m.todoIsSpinning {
239		inProgressIcon = m.todoSpinner.View()
240	}
241
242	var pills []string
243	if hasIncomplete {
244		pills = append(pills, todoPill(m.session.Todos, inProgressIcon, todosFocused, m.pillsExpanded, t))
245	}
246	if hasQueue {
247		pills = append(pills, queuePill(m.promptQueue, queueFocused, m.pillsExpanded, t))
248	}
249
250	var expandedList string
251	if m.pillsExpanded {
252		if todosFocused && hasIncomplete {
253			expandedList = todoList(m.session.Todos, inProgressIcon, t, contentWidth)
254		} else if queueFocused && hasQueue {
255			if m.com.App != nil && m.com.App.AgentCoordinator != nil {
256				queueItems := m.com.App.AgentCoordinator.QueuedPromptsList(m.session.ID)
257				expandedList = queueList(queueItems, t)
258			}
259		}
260	}
261
262	if len(pills) == 0 {
263		return
264	}
265
266	pillsRow := lipgloss.JoinHorizontal(lipgloss.Top, pills...)
267
268	helpDesc := "open"
269	if m.pillsExpanded {
270		helpDesc = "close"
271	}
272	helpKey := t.Pills.HelpKey.Render("ctrl+space")
273	helpText := t.Pills.HelpText.Render(helpDesc)
274	helpHint := lipgloss.JoinHorizontal(lipgloss.Center, helpKey, " ", helpText)
275	pillsRow = lipgloss.JoinHorizontal(lipgloss.Center, pillsRow, " ", helpHint)
276
277	pillsArea := pillsRow
278	if expandedList != "" {
279		pillsArea = lipgloss.JoinVertical(lipgloss.Left, pillsRow, expandedList)
280	}
281
282	m.pillsView = t.Pills.Area.MaxWidth(width).PaddingLeft(paddingLeft).Render(pillsArea)
283}