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}