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	return session.HasIncompleteTodos(todos)
 42}
 43
 44// hasInProgressTodo returns true if there is at least one in-progress todo.
 45func hasInProgressTodo(todos []session.Todo) bool {
 46	for _, todo := range todos {
 47		if todo.Status == session.TodoStatusInProgress {
 48			return true
 49		}
 50	}
 51	return false
 52}
 53
 54// queuePill renders the queue count pill with gradient triangles.
 55func queuePill(queue int, focused, panelFocused bool, t *styles.Styles) string {
 56	if queue <= 0 {
 57		return ""
 58	}
 59	triangles := styles.ForegroundGrad(t.Pills.QueueIconBase, "▶▶▶▶▶▶▶▶▶", false, t.Pills.QueueGradFromColor, t.Pills.QueueGradToColor)
 60	if queue < len(triangles) {
 61		triangles = triangles[:queue]
 62	}
 63
 64	text := t.Pills.QueueLabel.Render(fmt.Sprintf("%d Queued", queue))
 65	content := fmt.Sprintf("%s %s", strings.Join(triangles, ""), text)
 66	return pillStyle(focused, panelFocused, t).Render(content)
 67}
 68
 69// todoPill renders the todo progress pill with optional spinner and task name.
 70func todoPill(todos []session.Todo, spinnerView string, focused, panelFocused bool, t *styles.Styles) string {
 71	if !hasIncompleteTodos(todos) {
 72		return ""
 73	}
 74
 75	completed := 0
 76	var currentTodo *session.Todo
 77	for i := range todos {
 78		switch todos[i].Status {
 79		case session.TodoStatusCompleted:
 80			completed++
 81		case session.TodoStatusInProgress:
 82			if currentTodo == nil {
 83				currentTodo = &todos[i]
 84			}
 85		}
 86	}
 87
 88	total := len(todos)
 89
 90	label := t.Pills.TodoLabel.Render("To-Do")
 91	progress := t.Pills.TodoProgress.Render(fmt.Sprintf("%d/%d", completed, total))
 92
 93	var content string
 94	if panelFocused {
 95		content = fmt.Sprintf("%s %s", label, progress)
 96	} else if currentTodo != nil {
 97		taskText := currentTodo.Content
 98		if currentTodo.ActiveForm != "" {
 99			taskText = currentTodo.ActiveForm
100		}
101		if len(taskText) > maxTaskDisplayLength {
102			taskText = taskText[:maxTaskDisplayLength-1] + "…"
103		}
104		task := t.Pills.TodoCurrentTask.Render(taskText)
105		content = fmt.Sprintf("%s %s %s  %s", spinnerView, label, progress, task)
106	} else {
107		content = fmt.Sprintf("%s %s", label, progress)
108	}
109
110	return pillStyle(focused, panelFocused, t).Render(content)
111}
112
113// todoList renders the expanded todo list.
114func todoList(sessionTodos []session.Todo, spinnerView string, t *styles.Styles, width int) string {
115	return chat.FormatTodosList(t, sessionTodos, spinnerView, width)
116}
117
118// queueList renders the expanded queue items list.
119func queueList(queueItems []string, t *styles.Styles) string {
120	if len(queueItems) == 0 {
121		return ""
122	}
123
124	var lines []string
125	for _, item := range queueItems {
126		text := item
127		if len(text) > maxQueueDisplayLength {
128			text = text[:maxQueueDisplayLength-1] + "…"
129		}
130		prefix := t.Pills.QueueItemPrefix.Render() + " "
131		lines = append(lines, prefix+t.Pills.QueueItemText.Render(text))
132	}
133
134	return strings.Join(lines, "\n")
135}
136
137// pillsHeightReasonableTerminalHeight is the minimum terminal height at which
138// we auto-expand pills when there are incomplete todos.
139const pillsHeightReasonableTerminalHeight = 40
140
141// autoExpandPillsIfReasonable expands the pills panel if the terminal has
142// enough vertical space to show the expanded list comfortably.
143func (m *UI) autoExpandPillsIfReasonable() tea.Cmd {
144	if !m.hasSession() {
145		return nil
146	}
147	if m.height < pillsHeightReasonableTerminalHeight {
148		return nil
149	}
150	hasPills := hasIncompleteTodos(m.session.Todos) || m.promptQueue > 0
151	if !hasPills {
152		return nil
153	}
154	if m.pillsExpanded {
155		return nil
156	}
157	if m.pillsAutoExpanded {
158		return nil
159	}
160	m.pillsExpanded = true
161	m.pillsAutoExpanded = true
162	if hasIncompleteTodos(m.session.Todos) {
163		m.focusedPillSection = pillSectionTodos
164	} else {
165		m.focusedPillSection = pillSectionQueue
166	}
167	m.updateLayoutAndSize()
168	if m.chat.Follow() {
169		m.chat.ScrollToBottom()
170	}
171	return nil
172}
173
174// togglePillsExpanded toggles the pills panel expansion state.
175func (m *UI) togglePillsExpanded() tea.Cmd {
176	if !m.hasSession() {
177		return nil
178	}
179	hasPills := hasIncompleteTodos(m.session.Todos) || m.promptQueue > 0
180	if !hasPills {
181		return nil
182	}
183	m.pillsExpanded = !m.pillsExpanded
184	if m.pillsExpanded {
185		if hasIncompleteTodos(m.session.Todos) {
186			m.focusedPillSection = pillSectionTodos
187		} else {
188			m.focusedPillSection = pillSectionQueue
189		}
190	}
191	m.updateLayoutAndSize()
192
193	// Make sure to follow scroll if follow is enabled when toggling pills.
194	if m.chat.Follow() {
195		m.chat.ScrollToBottom()
196	}
197
198	return nil
199}
200
201// switchPillSection changes focus between todo and queue sections.
202func (m *UI) switchPillSection(dir int) tea.Cmd {
203	if !m.pillsExpanded || !m.hasSession() {
204		return nil
205	}
206	hasIncompleteTodos := hasIncompleteTodos(m.session.Todos)
207	hasQueue := m.promptQueue > 0
208
209	if dir < 0 && m.focusedPillSection == pillSectionQueue && hasIncompleteTodos {
210		m.focusedPillSection = pillSectionTodos
211		m.updateLayoutAndSize()
212		return nil
213	}
214	if dir > 0 && m.focusedPillSection == pillSectionTodos && hasQueue {
215		m.focusedPillSection = pillSectionQueue
216		m.updateLayoutAndSize()
217		return nil
218	}
219	return nil
220}
221
222// pillsAreaHeight calculates the total height needed for the pills area.
223func (m *UI) pillsAreaHeight() int {
224	if !m.hasSession() {
225		return 0
226	}
227	hasIncomplete := hasIncompleteTodos(m.session.Todos)
228	hasQueue := m.promptQueue > 0
229	hasPills := hasIncomplete || hasQueue
230	if !hasPills {
231		return 0
232	}
233
234	pillsAreaHeight := pillHeightWithBorder
235	if m.pillsExpanded {
236		if m.focusedPillSection == pillSectionTodos && hasIncomplete {
237			pillsAreaHeight += len(m.session.Todos)
238		} else if m.focusedPillSection == pillSectionQueue && hasQueue {
239			pillsAreaHeight += m.promptQueue
240		}
241	}
242	return pillsAreaHeight
243}
244
245// renderPills renders the pills panel and stores it in m.pillsView.
246func (m *UI) renderPills() {
247	m.pillsView = ""
248	if !m.hasSession() {
249		return
250	}
251
252	width := m.layout.pills.Dx()
253	if width <= 0 {
254		return
255	}
256
257	paddingLeft := 3
258	contentWidth := max(width-paddingLeft, 0)
259
260	hasIncomplete := hasIncompleteTodos(m.session.Todos)
261	hasQueue := m.promptQueue > 0
262
263	if !hasIncomplete && !hasQueue {
264		return
265	}
266
267	t := m.com.Styles
268	todosFocused := m.pillsExpanded && m.focusedPillSection == pillSectionTodos
269	queueFocused := m.pillsExpanded && m.focusedPillSection == pillSectionQueue
270
271	inProgressIcon := t.Tool.TodoInProgressIcon.Render(styles.SpinnerIcon)
272	if m.todoIsSpinning {
273		inProgressIcon = m.todoSpinner.View()
274	}
275
276	var pills []string
277	if hasIncomplete {
278		pills = append(pills, todoPill(m.session.Todos, inProgressIcon, todosFocused, m.pillsExpanded, t))
279	}
280	if hasQueue {
281		pills = append(pills, queuePill(m.promptQueue, queueFocused, m.pillsExpanded, t))
282	}
283
284	var expandedList string
285	if m.pillsExpanded {
286		if todosFocused && hasIncomplete {
287			expandedList = todoList(m.session.Todos, inProgressIcon, t, contentWidth)
288		} else if queueFocused && hasQueue {
289			if m.com != nil && m.com.Workspace != nil && m.com.Workspace.AgentIsReady() {
290				queueItems := m.com.Workspace.AgentQueuedPromptsList(m.session.ID)
291				expandedList = queueList(queueItems, t)
292			}
293		}
294	}
295
296	if len(pills) == 0 {
297		return
298	}
299
300	pillsRow := lipgloss.JoinHorizontal(lipgloss.Top, pills...)
301
302	helpDesc := "open"
303	if m.pillsExpanded {
304		helpDesc = "close"
305	}
306	helpKey := t.Pills.HelpKey.Render("ctrl+t")
307	helpText := t.Pills.HelpText.Render(helpDesc)
308	helpHint := lipgloss.JoinHorizontal(lipgloss.Center, helpKey, " ", helpText)
309	pillsRow = lipgloss.JoinHorizontal(lipgloss.Center, pillsRow, " ", helpHint)
310
311	pillsArea := pillsRow
312	if expandedList != "" {
313		pillsArea = lipgloss.JoinVertical(lipgloss.Left, pillsRow, expandedList)
314	}
315
316	m.pillsView = t.Pills.Area.MaxWidth(width).PaddingLeft(paddingLeft).Render(pillsArea)
317}