@@ -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.