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	text := t.Base.Render(fmt.Sprintf("%d Queued", queue))
 70	content := fmt.Sprintf("%s %s", strings.Join(triangles, ""), text)
 71	return pillStyle(focused, panelFocused, t).Render(content)
 72}
 73
 74// todoPill renders the todo progress pill with optional spinner and task name.
 75func todoPill(todos []session.Todo, spinnerView string, focused, panelFocused bool, t *styles.Styles) string {
 76	if !hasIncompleteTodos(todos) {
 77		return ""
 78	}
 79
 80	completed := 0
 81	var currentTodo *session.Todo
 82	for i := range todos {
 83		switch todos[i].Status {
 84		case session.TodoStatusCompleted:
 85			completed++
 86		case session.TodoStatusInProgress:
 87			if currentTodo == nil {
 88				currentTodo = &todos[i]
 89			}
 90		}
 91	}
 92
 93	total := len(todos)
 94
 95	label := t.Base.Render("To-Do")
 96	progress := t.Muted.Render(fmt.Sprintf("%d/%d", completed, total))
 97
 98	var content string
 99	if panelFocused {
100		content = fmt.Sprintf("%s %s", label, progress)
101	} else if currentTodo != nil {
102		taskText := currentTodo.Content
103		if currentTodo.ActiveForm != "" {
104			taskText = currentTodo.ActiveForm
105		}
106		if len(taskText) > maxTaskDisplayLength {
107			taskText = taskText[:maxTaskDisplayLength-1] + "…"
108		}
109		task := t.Subtle.Render(taskText)
110		content = fmt.Sprintf("%s %s %s  %s", spinnerView, label, progress, task)
111	} else {
112		content = fmt.Sprintf("%s %s", label, progress)
113	}
114
115	return pillStyle(focused, panelFocused, t).Render(content)
116}
117
118// todoList renders the expanded todo list.
119func todoList(sessionTodos []session.Todo, spinnerView string, t *styles.Styles, width int) string {
120	return chat.FormatTodosList(t, sessionTodos, spinnerView, width)
121}
122
123// queueList renders the expanded queue items list.
124func queueList(queueItems []string, t *styles.Styles) string {
125	if len(queueItems) == 0 {
126		return ""
127	}
128
129	var lines []string
130	for _, item := range queueItems {
131		text := item
132		if len(text) > maxQueueDisplayLength {
133			text = text[:maxQueueDisplayLength-1] + "…"
134		}
135		prefix := t.Pills.QueueItemPrefix.Render() + " "
136		lines = append(lines, prefix+t.Muted.Render(text))
137	}
138
139	return strings.Join(lines, "\n")
140}
141
142// togglePillsExpanded toggles the pills panel expansion state.
143func (m *UI) togglePillsExpanded() tea.Cmd {
144	if !m.hasSession() {
145		return nil
146	}
147	if m.layout.pills.Dy() > 0 {
148		if cmd := m.chat.ScrollByAndAnimate(0); cmd != nil {
149			return cmd
150		}
151	}
152	hasPills := hasIncompleteTodos(m.session.Todos) || m.promptQueue > 0
153	if !hasPills {
154		return nil
155	}
156	m.pillsExpanded = !m.pillsExpanded
157	if m.pillsExpanded {
158		if hasIncompleteTodos(m.session.Todos) {
159			m.focusedPillSection = pillSectionTodos
160		} else {
161			m.focusedPillSection = pillSectionQueue
162		}
163	}
164	m.updateLayoutAndSize()
165	return nil
166}
167
168// switchPillSection changes focus between todo and queue sections.
169func (m *UI) switchPillSection(dir int) tea.Cmd {
170	if !m.pillsExpanded || !m.hasSession() {
171		return nil
172	}
173	hasIncompleteTodos := hasIncompleteTodos(m.session.Todos)
174	hasQueue := m.promptQueue > 0
175
176	if dir < 0 && m.focusedPillSection == pillSectionQueue && hasIncompleteTodos {
177		m.focusedPillSection = pillSectionTodos
178		m.updateLayoutAndSize()
179		return nil
180	}
181	if dir > 0 && m.focusedPillSection == pillSectionTodos && hasQueue {
182		m.focusedPillSection = pillSectionQueue
183		m.updateLayoutAndSize()
184		return nil
185	}
186	return nil
187}
188
189// pillsAreaHeight calculates the total height needed for the pills area.
190func (m *UI) pillsAreaHeight() int {
191	if !m.hasSession() {
192		return 0
193	}
194	hasIncomplete := hasIncompleteTodos(m.session.Todos)
195	hasQueue := m.promptQueue > 0
196	hasPills := hasIncomplete || hasQueue
197	if !hasPills {
198		return 0
199	}
200
201	pillsAreaHeight := pillHeightWithBorder
202	if m.pillsExpanded {
203		if m.focusedPillSection == pillSectionTodos && hasIncomplete {
204			pillsAreaHeight += len(m.session.Todos)
205		} else if m.focusedPillSection == pillSectionQueue && hasQueue {
206			pillsAreaHeight += m.promptQueue
207		}
208	}
209	return pillsAreaHeight
210}
211
212// renderPills renders the pills panel and stores it in m.pillsView.
213func (m *UI) renderPills() {
214	m.pillsView = ""
215	if !m.hasSession() {
216		return
217	}
218
219	width := m.layout.pills.Dx()
220	if width <= 0 {
221		return
222	}
223
224	paddingLeft := 3
225	contentWidth := max(width-paddingLeft, 0)
226
227	hasIncomplete := hasIncompleteTodos(m.session.Todos)
228	hasQueue := m.promptQueue > 0
229
230	if !hasIncomplete && !hasQueue {
231		return
232	}
233
234	t := m.com.Styles
235	todosFocused := m.pillsExpanded && m.focusedPillSection == pillSectionTodos
236	queueFocused := m.pillsExpanded && m.focusedPillSection == pillSectionQueue
237
238	inProgressIcon := t.Tool.TodoInProgressIcon.Render(styles.SpinnerIcon)
239	if m.todoIsSpinning {
240		inProgressIcon = m.todoSpinner.View()
241	}
242
243	var pills []string
244	if hasIncomplete {
245		pills = append(pills, todoPill(m.session.Todos, inProgressIcon, todosFocused, m.pillsExpanded, t))
246	}
247	if hasQueue {
248		pills = append(pills, queuePill(m.promptQueue, queueFocused, m.pillsExpanded, t))
249	}
250
251	var expandedList string
252	if m.pillsExpanded {
253		if todosFocused && hasIncomplete {
254			expandedList = todoList(m.session.Todos, inProgressIcon, t, contentWidth)
255		} else if queueFocused && hasQueue {
256			if m.com.App != nil && m.com.App.AgentCoordinator != nil {
257				queueItems := m.com.App.AgentCoordinator.QueuedPromptsList(m.session.ID)
258				expandedList = queueList(queueItems, t)
259			}
260		}
261	}
262
263	if len(pills) == 0 {
264		return
265	}
266
267	pillsRow := lipgloss.JoinHorizontal(lipgloss.Top, pills...)
268
269	helpDesc := "open"
270	if m.pillsExpanded {
271		helpDesc = "close"
272	}
273	helpKey := t.Pills.HelpKey.Render("ctrl+space")
274	helpText := t.Pills.HelpText.Render(helpDesc)
275	helpHint := lipgloss.JoinHorizontal(lipgloss.Center, helpKey, " ", helpText)
276	pillsRow = lipgloss.JoinHorizontal(lipgloss.Center, pillsRow, " ", helpHint)
277
278	pillsArea := pillsRow
279	if expandedList != "" {
280		pillsArea = lipgloss.JoinVertical(lipgloss.Left, pillsRow, expandedList)
281	}
282
283	m.pillsView = t.Pills.Area.MaxWidth(width).PaddingLeft(paddingLeft).Render(pillsArea)
284}