on main help sign in
refactor: pills section (#1916)
commitc3e9196ed126e12d675092f02f3ddd4f42f8001b authorKujtim Hoxha <kujtimii.h@gmail.com> dateTue 20 Jan 2026, 15:42 (9 months ago) parent0decf78
5 files changed+479−30
internal/ui/chat/todos.go+4−4 internal/ui/model/keys.go+15 internal/ui/model/pills.go+283 internal/ui/model/ui.go+154−25 internal/ui/styles/styles.go+23−1
internal/ui/chat/todos.go+4−4
@@ -82,7 +82,7 @@ func (t *TodosToolRenderContext) RenderTool(sty *styles.Styles, width int, opts
8282 } else {
8383 headerText = fmt.Sprintf("created %d todos", meta.Total)
8484 }
85− body = formatTodosList(sty, meta.Todos, styles.ArrowRightIcon, cappedWidth)
85+ body = FormatTodosList(sty, meta.Todos, styles.ArrowRightIcon, cappedWidth)
8686 } else {
8787 // Build header based on what changed.
8888 hasCompleted := len(meta.JustCompleted) > 0
@@ -108,7 +108,7 @@ func (t *TodosToolRenderContext) RenderTool(sty *styles.Styles, width int, opts
108108 // Build body with details.
109109 if allCompleted {
110110 // Show all todos when all are completed, like when created.
111− body = formatTodosList(sty, meta.Todos, styles.ArrowRightIcon, cappedWidth)
111+ body = FormatTodosList(sty, meta.Todos, styles.ArrowRightIcon, cappedWidth)
112112 } else if meta.JustStarted != "" {
113113 body = sty.Tool.TodoInProgressIcon.Render(styles.ArrowRightIcon+" ") +
114114 sty.Base.Render(meta.JustStarted)
@@ -135,8 +135,8 @@ func (t *TodosToolRenderContext) RenderTool(sty *styles.Styles, width int, opts
135135 return joinToolParts(header, sty.Tool.Body.Render(body))
136136}
137137
138−// formatTodosList formats a list of todos for display.
139−func formatTodosList(sty *styles.Styles, todos []session.Todo, inProgressIcon string, width int) string {
138+// FormatTodosList formats a list of todos for display.
139+func FormatTodosList(sty *styles.Styles, todos []session.Todo, inProgressIcon string, width int) string {
140140 if len(todos) == 0 {
141141 return ""
142142 }
internal/ui/model/keys.go+15
@@ -23,6 +23,9 @@ type KeyMap struct {
2323 Cancel key.Binding
2424 Tab key.Binding
2525 Details key.Binding
26+ TogglePills key.Binding
27+ PillLeft key.Binding
28+ PillRight key.Binding
2629 Down key.Binding
2730 Up key.Binding
2831 UpDown key.Binding
@@ -149,6 +152,18 @@ func DefaultKeyMap() KeyMap {
149152 key.WithKeys("ctrl+d"),
150153 key.WithHelp("ctrl+d", "toggle details"),
151154 )
155+ km.Chat.TogglePills = key.NewBinding(
156+ key.WithKeys("ctrl+space"),
157+ key.WithHelp("ctrl+space", "toggle tasks"),
158+ )
159+ km.Chat.PillLeft = key.NewBinding(
160+ key.WithKeys("left"),
161+ key.WithHelp("←/→", "switch section"),
162+ )
163+ km.Chat.PillRight = key.NewBinding(
164+ key.WithKeys("right"),
165+ key.WithHelp("←/→", "switch section"),
166+ )
152167
153168 km.Chat.Down = key.NewBinding(
154169 key.WithKeys("down", "ctrl+j", "ctrl+n", "j"),
internal/ui/model/pills.gonew file+283
@@ -0,0 +1,283 @@
1+package model
2+
3+import (
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.
15+func 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+
22+const (
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.
32+type pillSection int
33+
34+const (
35+ pillSectionTodos pillSection = iota
36+ pillSectionQueue
37+)
38+
39+// hasIncompleteTodos returns true if there are any non-completed todos.
40+func 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.
50+func 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.
60+func 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.
74+func 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.
118+func 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.
123+func 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.
142+func (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.
168+func (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.
189+func (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.
212+func (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+}
internal/ui/model/ui.go+154−25
@@ -189,6 +189,16 @@ type UI struct {
189189
190190 // detailsOpen tracks whether the details panel is open (in compact mode)
191191 detailsOpen bool
192+
193+ // pills state
194+ pillsExpanded bool
195+ focusedPillSection pillSection
196+ promptQueue int
197+ pillsView string
198+
199+ // Todo spinner
200+ todoSpinner spinner.Model
201+ todoIsSpinning bool
192202}
193203
194204// New creates a new instance of the [UI] model.
@@ -212,6 +222,11 @@ func New(com *common.Common) *UI {
212222 com.Styles.Completions.Match,
213223 )
214224
225+ todoSpinner := spinner.New(
226+ spinner.WithSpinner(spinner.MiniDot),
227+ spinner.WithStyle(com.Styles.Pills.TodoSpinner),
228+ )
229+
215230 // Attachments component
216231 attachments := attachments.New(
217232 attachments.NewRenderer(
@@ -237,6 +252,7 @@ func New(com *common.Common) *UI {
237252 chat: ch,
238253 completions: comp,
239254 attachments: attachments,
255+ todoSpinner: todoSpinner,
240256 }
241257
242258 status := NewStatus(com, ui)
@@ -312,6 +328,13 @@ func (m *UI) loadMCPrompts() tea.Cmd {
312328// Update handles updates to the UI model.
313329func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
314330 var cmds []tea.Cmd
331+ if m.hasSession() && m.isAgentBusy() {
332+ queueSize := m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID)
333+ if queueSize != m.promptQueue {
334+ m.promptQueue = queueSize
335+ m.updateLayoutAndSize()
336+ }
337+ }
315338 switch msg := msg.(type) {
316339 case tea.EnvMsg:
317340 // Is this Windows Terminal?
@@ -333,6 +356,14 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
333356 if cmd := m.setSessionMessages(msgs); cmd != nil {
334357 cmds = append(cmds, cmd)
335358 }
359+ if hasInProgressTodo(m.session.Todos) {
360+ // only start spinner if there is an in-progress todo
361+ if m.isAgentBusy() {
362+ m.todoIsSpinning = true
363+ cmds = append(cmds, m.todoSpinner.Tick)
364+ }
365+ m.updateLayoutAndSize()
366+ }
336367
337368 case sendMessageMsg:
338369 cmds = append(cmds, m.sendMessage(msg.Content, msg.Attachments...))
@@ -363,6 +394,16 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
363394 case closeDialogMsg:
364395 m.dialog.CloseFrontDialog()
365396
397+ case pubsub.Event[session.Session]:
398+ if m.session != nil && msg.Payload.ID == m.session.ID {
399+ prevHasInProgress := hasInProgressTodo(m.session.Todos)
400+ m.session = &msg.Payload
401+ if !prevHasInProgress && hasInProgressTodo(m.session.Todos) {
402+ m.todoIsSpinning = true
403+ cmds = append(cmds, m.todoSpinner.Tick)
404+ m.updateLayoutAndSize()
405+ }
406+ }
366407 case pubsub.Event[message.Message]:
367408 // Check if this is a child session message for an agent tool.
368409 if m.session == nil {
@@ -383,6 +424,17 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
383424 case pubsub.DeletedEvent:
384425 m.chat.RemoveMessage(msg.Payload.ID)
385426 }
427+ // start the spinner if there is a new message
428+ if hasInProgressTodo(m.session.Todos) && m.isAgentBusy() && !m.todoIsSpinning {
429+ m.todoIsSpinning = true
430+ cmds = append(cmds, m.todoSpinner.Tick)
431+ }
432+ // stop the spinner if the agent is not busy anymore
433+ if m.todoIsSpinning && !m.isAgentBusy() {
434+ m.todoIsSpinning = false
435+ }
436+ // there is a number of things that could change the pills here so we want to re-render
437+ m.renderPills()
386438 case pubsub.Event[history.File]:
387439 cmds = append(cmds, m.handleFileEvent(msg.Payload))
388440 case pubsub.Event[app.LSPEvent]:
@@ -524,6 +576,14 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
524576 cmds = append(cmds, cmd)
525577 }
526578 }
579+ if m.state == uiChat && m.hasSession() && hasInProgressTodo(m.session.Todos) && m.todoIsSpinning {
580+ var cmd tea.Cmd
581+ m.todoSpinner, cmd = m.todoSpinner.Update(msg)
582+ if cmd != nil {
583+ m.renderPills()
584+ cmds = append(cmds, cmd)
585+ }
586+ }
527587
528588 case tea.KeyPressMsg:
529589 if cmd := m.handleKeyPressMsg(msg); cmd != nil {
@@ -573,7 +633,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
573633 case uiFocusMain:
574634 case uiFocusEditor:
575635 // Textarea placeholder logic
576− if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
636+ if m.isAgentBusy() {
577637 m.textarea.Placeholder = m.workingPlaceholder
578638 } else {
579639 m.textarea.Placeholder = m.readyPlaceholder
@@ -945,14 +1005,14 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
9451005 m.setEditorPrompt(yolo)
9461006 m.dialog.CloseDialog(dialog.CommandsID)
9471007 case dialog.ActionNewSession:
948− if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
1008+ if m.isAgentBusy() {
9491009 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
9501010 break
9511011 }
9521012 m.newSession()
9531013 m.dialog.CloseDialog(dialog.CommandsID)
9541014 case dialog.ActionSummarize:
955− if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
1015+ if m.isAgentBusy() {
9561016 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session..."))
9571017 break
9581018 }
@@ -968,7 +1028,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
9681028 m.status.ToggleHelp()
9691029 m.dialog.CloseDialog(dialog.CommandsID)
9701030 case dialog.ActionExternalEditor:
971− if m.session != nil && m.com.App.AgentCoordinator.IsSessionBusy(m.session.ID) {
1031+ if m.isAgentBusy() {
9721032 cmds = append(cmds, uiutil.ReportWarn("Agent is working, please wait..."))
9731033 break
9741034 }
@@ -978,7 +1038,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
9781038 cmds = append(cmds, m.toggleCompactMode())
9791039 m.dialog.CloseDialog(dialog.CommandsID)
9801040 case dialog.ActionToggleThinking:
981− if m.com.App.AgentCoordinator.IsBusy() {
1041+ if m.isAgentBusy() {
9821042 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
9831043 break
9841044 }
@@ -1010,14 +1070,14 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
10101070 case dialog.ActionQuit:
10111071 cmds = append(cmds, tea.Quit)
10121072 case dialog.ActionInitializeProject:
1013− if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
1073+ if m.isAgentBusy() {
10141074 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session..."))
10151075 break
10161076 }
10171077 cmds = append(cmds, m.initializeProject())
10181078
10191079 case dialog.ActionSelectModel:
1020− if m.com.App.AgentCoordinator.IsBusy() {
1080+ if m.isAgentBusy() {
10211081 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
10221082 break
10231083 }
@@ -1063,7 +1123,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
10631123 m.dialog.CloseDialog(dialog.OAuthID)
10641124 m.dialog.CloseDialog(dialog.ModelsID)
10651125 case dialog.ActionSelectReasoningEffort:
1066− if m.com.App.AgentCoordinator.IsBusy() {
1126+ if m.isAgentBusy() {
10671127 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
10681128 break
10691129 }
@@ -1217,6 +1277,27 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
12171277 m.detailsOpen = !m.detailsOpen
12181278 m.updateLayoutAndSize()
12191279 return true
1280+ case key.Matches(msg, m.keyMap.Chat.TogglePills):
1281+ if m.state == uiChat && m.hasSession() {
1282+ if cmd := m.togglePillsExpanded(); cmd != nil {
1283+ cmds = append(cmds, cmd)
1284+ }
1285+ return true
1286+ }
1287+ case key.Matches(msg, m.keyMap.Chat.PillLeft):
1288+ if m.state == uiChat && m.hasSession() && m.pillsExpanded {
1289+ if cmd := m.switchPillSection(-1); cmd != nil {
1290+ cmds = append(cmds, cmd)
1291+ }
1292+ return true
1293+ }
1294+ case key.Matches(msg, m.keyMap.Chat.PillRight):
1295+ if m.state == uiChat && m.hasSession() && m.pillsExpanded {
1296+ if cmd := m.switchPillSection(1); cmd != nil {
1297+ cmds = append(cmds, cmd)
1298+ }
1299+ return true
1300+ }
12201301 }
12211302 return false
12221303 }
@@ -1237,7 +1318,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
12371318
12381319 // Handle cancel key when agent is busy.
12391320 if key.Matches(msg, m.keyMap.Chat.Cancel) {
1240− if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
1321+ if m.isAgentBusy() {
12411322 if cmd := m.cancelAgent(); cmd != nil {
12421323 cmds = append(cmds, cmd)
12431324 }
@@ -1309,10 +1390,10 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
13091390
13101391 return m.sendMessage(value, attachments...)
13111392 case key.Matches(msg, m.keyMap.Chat.NewSession):
1312− if m.session == nil || m.session.ID == "" {
1393+ if !m.hasSession() {
13131394 break
13141395 }
1315− if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
1396+ if m.isAgentBusy() {
13161397 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
13171398 break
13181399 }
@@ -1323,7 +1404,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
13231404 m.chat.Focus()
13241405 m.chat.SetSelected(m.chat.Len() - 1)
13251406 case key.Matches(msg, m.keyMap.Editor.OpenEditor):
1326− if m.session != nil && m.com.App.AgentCoordinator.IsSessionBusy(m.session.ID) {
1407+ if m.isAgentBusy() {
13271408 cmds = append(cmds, uiutil.ReportWarn("Agent is working, please wait..."))
13281409 break
13291410 }
@@ -1518,6 +1599,9 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
15181599 }
15191600
15201601 m.chat.Draw(scr, layout.main)
1602+ if layout.pills.Dy() > 0 && m.pillsView != "" {
1603+ uv.NewStyledString(m.pillsView).Draw(scr, layout.pills)
1604+ }
15211605
15221606 editorWidth := scr.Bounds().Dx()
15231607 if !m.isCompact {
@@ -1617,7 +1701,7 @@ func (m *UI) View() tea.View {
16171701 content = strings.Join(contentLines, "\n")
16181702
16191703 v.Content = content
1620− if m.sendProgressBar && m.com.App != nil && m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
1704+ if m.sendProgressBar && m.isAgentBusy() {
16211705 // HACK: use a random percentage to prevent ghostty from hiding it
16221706 // after a timeout.
16231707 v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
@@ -1641,7 +1725,7 @@ func (m *UI) ShortHelp() []key.Binding {
16411725 binds = append(binds, k.Quit)
16421726 case uiChat:
16431727 // Show cancel binding if agent is busy.
1644− if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
1728+ if m.isAgentBusy() {
16451729 cancelBinding := k.Chat.Cancel
16461730 if m.isCanceling {
16471731 cancelBinding.SetHelp("esc", "press again to cancel")
@@ -1676,6 +1760,9 @@ func (m *UI) ShortHelp() []key.Binding {
16761760 k.Chat.PageDown,
16771761 k.Chat.Copy,
16781762 )
1763+ if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
1764+ binds = append(binds, k.Chat.PillLeft)
1765+ }
16791766 }
16801767 default:
16811768 // TODO: other states
@@ -1703,7 +1790,7 @@ func (m *UI) FullHelp() [][]key.Binding {
17031790 help := k.Help
17041791 help.SetHelp("ctrl+g", "less")
17051792 hasAttachments := len(m.attachments.List()) > 0
1706− hasSession := m.session != nil && m.session.ID != ""
1793+ hasSession := m.hasSession()
17071794 commands := k.Commands
17081795 if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
17091796 commands.SetHelp("/ or ctrl+p", "commands")
@@ -1717,7 +1804,7 @@ func (m *UI) FullHelp() [][]key.Binding {
17171804 })
17181805 case uiChat:
17191806 // Show cancel binding if agent is busy.
1720− if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
1807+ if m.isAgentBusy() {
17211808 cancelBinding := k.Chat.Cancel
17221809 if m.isCanceling {
17231810 cancelBinding.SetHelp("esc", "press again to cancel")
@@ -1785,6 +1872,9 @@ func (m *UI) FullHelp() [][]key.Binding {
17851872 k.Chat.ClearHighlight,
17861873 },
17871874 )
1875+ if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
1876+ binds = append(binds, []key.Binding{k.Chat.PillLeft})
1877+ }
17881878 }
17891879 default:
17901880 if m.session == nil {
@@ -1873,6 +1963,7 @@ func (m *UI) updateSize() {
18731963 m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
18741964 m.textarea.SetWidth(m.layout.editor.Dx())
18751965 m.textarea.SetHeight(m.layout.editor.Dy())
1966+ m.renderPills()
18761967
18771968 // Handle different app states
18781969 switch m.state {
@@ -1984,10 +2075,18 @@ func (m *UI) generateLayout(w, h int) layout {
19842075 mainRect.Min.Y += 1
19852076 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
19862077 mainRect.Max.X -= 1 // Add padding right
1987− // Add bottom margin to main
1988− mainRect.Max.Y -= 1
19892078 layout.header = headerRect
1990− layout.main = mainRect
2079+ pillsHeight := m.pillsAreaHeight()
2080+ if pillsHeight > 0 {
2081+ pillsHeight = min(pillsHeight, mainRect.Dy())
2082+ chatRect, pillsRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-pillsHeight))
2083+ layout.main = chatRect
2084+ layout.pills = pillsRect
2085+ } else {
2086+ layout.main = mainRect
2087+ }
2088+ // Add bottom margin to main
2089+ layout.main.Max.Y -= 1
19912090 layout.editor = editorRect
19922091 } else {
19932092 // Layout
@@ -2004,10 +2103,18 @@ func (m *UI) generateLayout(w, h int) layout {
20042103 sideRect.Min.X += 1
20052104 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
20062105 mainRect.Max.X -= 1 // Add padding right
2007− // Add bottom margin to main
2008− mainRect.Max.Y -= 1
20092106 layout.sidebar = sideRect
2010− layout.main = mainRect
2107+ pillsHeight := m.pillsAreaHeight()
2108+ if pillsHeight > 0 {
2109+ pillsHeight = min(pillsHeight, mainRect.Dy())
2110+ chatRect, pillsRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-pillsHeight))
2111+ layout.main = chatRect
2112+ layout.pills = pillsRect
2113+ } else {
2114+ layout.main = mainRect
2115+ }
2116+ // Add bottom margin to main
2117+ layout.main.Max.Y -= 1
20112118 layout.editor = editorRect
20122119 }
20132120 }
@@ -2035,6 +2142,9 @@ type layout struct {
20352142 // main is the area for the main pane. (e.x chat, configure, landing)
20362143 main uv.Rectangle
20372144
2145+ // pills is the area for the pills panel.
2146+ pills uv.Rectangle
2147+
20382148 // editor is the area for the editor pane.
20392149 editor uv.Rectangle
20402150
@@ -2208,6 +2318,19 @@ func isWhitespace(b byte) bool {
22082318 return b == ' ' || b == '\t' || b == '\n' || b == '\r'
22092319}
22102320
2321+// isAgentBusy returns true if the agent coordinator exists and is currently
2322+// busy processing a request.
2323+func (m *UI) isAgentBusy() bool {
2324+ return m.com.App != nil &&
2325+ m.com.App.AgentCoordinator != nil &&
2326+ m.com.App.AgentCoordinator.IsBusy()
2327+}
2328+
2329+// hasSession returns true if there is an active session with a valid ID.
2330+func (m *UI) hasSession() bool {
2331+ return m.session != nil && m.session.ID != ""
2332+}
2333+
22112334// mimeOf detects the MIME type of the given content.
22122335func mimeOf(content []byte) string {
22132336 mimeBufferSize := min(512, len(content))
@@ -2271,7 +2394,7 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.
22712394 }
22722395
22732396 var cmds []tea.Cmd
2274− if m.session == nil || m.session.ID == "" {
2397+ if !m.hasSession() {
22752398 newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
22762399 if err != nil {
22772400 return uiutil.ReportError(err)
@@ -2319,7 +2442,7 @@ func cancelTimerCmd() tea.Cmd {
23192442// and starts a timer. The second press (before the timer expires) actually
23202443// cancels the agent.
23212444func (m *UI) cancelAgent() tea.Cmd {
2322− if m.session == nil || m.session.ID == "" {
2445+ if !m.hasSession() {
23232446 return nil
23242447 }
23252448
@@ -2332,6 +2455,9 @@ func (m *UI) cancelAgent() tea.Cmd {
23322455 // Second escape press - actually cancel the agent.
23332456 m.isCanceling = false
23342457 coordinator.Cancel(m.session.ID)
2458+ // Stop the spinning todo indicator.
2459+ m.todoIsSpinning = false
2460+ m.renderPills()
23352461 return nil
23362462 }
23372463
@@ -2521,7 +2647,7 @@ func (m *UI) handlePermissionNotification(notification permission.PermissionNoti
25212647// newSession clears the current session state and prepares for a new session.
25222648// The actual session creation happens when the user sends their first message.
25232649func (m *UI) newSession() {
2524− if m.session == nil || m.session.ID == "" {
2650+ if !m.hasSession() {
25252651 return
25262652 }
25272653
@@ -2532,6 +2658,9 @@ func (m *UI) newSession() {
25322658 m.textarea.Focus()
25332659 m.chat.Blur()
25342660 m.chat.ClearMessages()
2661+ m.pillsExpanded = false
2662+ m.promptQueue = 0
2663+ m.pillsView = ""
25352664}
25362665
25372666// handlePasteMsg handles a paste message.
internal/ui/styles/styles.go+23−1
@@ -21,7 +21,7 @@ const (
2121 WarningIcon string = "⚠"
2222 InfoIcon string = "ⓘ"
2323 HintIcon string = "∵"
24− SpinnerIcon string = "..."
24+ SpinnerIcon string = "⋯"
2525 LoadingIcon string = "⟳"
2626 ModelIcon string = "◇"
2727
@@ -394,6 +394,18 @@ type Styles struct {
394394 Text lipgloss.Style
395395 Deleting lipgloss.Style
396396 }
397+
398+ // Pills styles for todo/queue pills
399+ Pills struct {
400+ Base lipgloss.Style // Base pill style with padding
401+ Focused lipgloss.Style // Focused pill with visible border
402+ Blurred lipgloss.Style // Blurred pill with hidden border
403+ QueueItemPrefix lipgloss.Style // Prefix for queue list items
404+ HelpKey lipgloss.Style // Keystroke hint style
405+ HelpText lipgloss.Style // Help action text style
406+ Area lipgloss.Style // Pills area container
407+ TodoSpinner lipgloss.Style // Todo spinner style
408+ }
397409}
398410
399411// ChromaTheme converts the current markdown chroma styles to a chroma
@@ -1270,6 +1282,16 @@ func DefaultStyles() Styles {
12701282 s.Attachments.Normal = base.Padding(0, 1).MarginRight(1).Background(fgMuted).Foreground(fgBase)
12711283 s.Attachments.Deleting = base.Padding(0, 1).Bold(true).Background(red).Foreground(fgBase)
12721284
1285+ // Pills styles
1286+ s.Pills.Base = base.Padding(0, 1)
1287+ s.Pills.Focused = base.Padding(0, 1).BorderStyle(lipgloss.RoundedBorder()).BorderForeground(bgOverlay)
1288+ s.Pills.Blurred = base.Padding(0, 1).BorderStyle(lipgloss.HiddenBorder())
1289+ s.Pills.QueueItemPrefix = s.Muted.SetString(" •")
1290+ s.Pills.HelpKey = s.Muted
1291+ s.Pills.HelpText = s.Subtle
1292+ s.Pills.Area = base
1293+ s.Pills.TodoSpinner = base.Foreground(greenDark)
1294+
12731295 return s
12741296}
12751297