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}