on main help sign in
refactor: pills section (#1916)
c3e9196Kujtim Hoxha
internal/ui/chat/todos.go+4−4
@@ -82,7 +82,7 @@ func (t *TodosToolRenderContext) RenderTool(sty *styles.Styles, width int, opts
82 } else {
82 } else {
83 headerText = fmt.Sprintf("created %d todos", meta.Total)
83 headerText = fmt.Sprintf("created %d todos", meta.Total)
84 }
84 }
85− body = formatTodosList(sty, meta.Todos, styles.ArrowRightIcon, cappedWidth)
85+ body = FormatTodosList(sty, meta.Todos, styles.ArrowRightIcon, cappedWidth)
86 } else {
86 } else {
87 // Build header based on what changed.
87 // Build header based on what changed.
88 hasCompleted := len(meta.JustCompleted) > 0
88 hasCompleted := len(meta.JustCompleted) > 0
@@ -108,7 +108,7 @@ func (t *TodosToolRenderContext) RenderTool(sty *styles.Styles, width int, opts
108 // Build body with details.
108 // Build body with details.
109 if allCompleted {
109 if allCompleted {
110 // Show all todos when all are completed, like when created.
110 // 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)
112 } else if meta.JustStarted != "" {
112 } else if meta.JustStarted != "" {
113 body = sty.Tool.TodoInProgressIcon.Render(styles.ArrowRightIcon+" ") +
113 body = sty.Tool.TodoInProgressIcon.Render(styles.ArrowRightIcon+" ") +
114 sty.Base.Render(meta.JustStarted)
114 sty.Base.Render(meta.JustStarted)
@@ -135,8 +135,8 @@ func (t *TodosToolRenderContext) RenderTool(sty *styles.Styles, width int, opts
135 return joinToolParts(header, sty.Tool.Body.Render(body))
135 return joinToolParts(header, sty.Tool.Body.Render(body))
136}
136}
137
137
138−// formatTodosList formats a list of todos for display.
138+// FormatTodosList formats a list of todos for display.
139−func formatTodosList(sty *styles.Styles, todos []session.Todo, inProgressIcon string, width int) string {
139+func FormatTodosList(sty *styles.Styles, todos []session.Todo, inProgressIcon string, width int) string {
140 if len(todos) == 0 {
140 if len(todos) == 0 {
141 return ""
141 return ""
142 }
142 }
internal/ui/model/keys.go+15
@@ -23,6 +23,9 @@ type KeyMap struct {
23 Cancel key.Binding
23 Cancel key.Binding
24 Tab key.Binding
24 Tab key.Binding
25 Details key.Binding
25 Details key.Binding
26+ TogglePills key.Binding
27+ PillLeft key.Binding
28+ PillRight key.Binding
26 Down key.Binding
29 Down key.Binding
27 Up key.Binding
30 Up key.Binding
28 UpDown key.Binding
31 UpDown key.Binding
@@ -149,6 +152,18 @@ func DefaultKeyMap() KeyMap {
149 key.WithKeys("ctrl+d"),
152 key.WithKeys("ctrl+d"),
150 key.WithHelp("ctrl+d", "toggle details"),
153 key.WithHelp("ctrl+d", "toggle details"),
151 )
154 )
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+ )
152
167
153 km.Chat.Down = key.NewBinding(
168 km.Chat.Down = key.NewBinding(
154 key.WithKeys("down", "ctrl+j", "ctrl+n", "j"),
169 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 {
189
189
190 // detailsOpen tracks whether the details panel is open (in compact mode)
190 // detailsOpen tracks whether the details panel is open (in compact mode)
191 detailsOpen bool
191 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
192}
202}
193
203
194// New creates a new instance of the [UI] model.
204// New creates a new instance of the [UI] model.
@@ -212,6 +222,11 @@ func New(com *common.Common) *UI {
212 com.Styles.Completions.Match,
222 com.Styles.Completions.Match,
213 )
223 )
214
224
225+ todoSpinner := spinner.New(
226+ spinner.WithSpinner(spinner.MiniDot),
227+ spinner.WithStyle(com.Styles.Pills.TodoSpinner),
228+ )
229+
215 // Attachments component
230 // Attachments component
216 attachments := attachments.New(
231 attachments := attachments.New(
217 attachments.NewRenderer(
232 attachments.NewRenderer(
@@ -237,6 +252,7 @@ func New(com *common.Common) *UI {
237 chat: ch,
252 chat: ch,
238 completions: comp,
253 completions: comp,
239 attachments: attachments,
254 attachments: attachments,
255+ todoSpinner: todoSpinner,
240 }
256 }
241
257
242 status := NewStatus(com, ui)
258 status := NewStatus(com, ui)
@@ -312,6 +328,13 @@ func (m *UI) loadMCPrompts() tea.Cmd {
312// Update handles updates to the UI model.
328// Update handles updates to the UI model.
313func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
329func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
314 var cmds []tea.Cmd
330 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+ }
315 switch msg := msg.(type) {
338 switch msg := msg.(type) {
316 case tea.EnvMsg:
339 case tea.EnvMsg:
317 // Is this Windows Terminal?
340 // Is this Windows Terminal?
@@ -333,6 +356,14 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
333 if cmd := m.setSessionMessages(msgs); cmd != nil {
356 if cmd := m.setSessionMessages(msgs); cmd != nil {
334 cmds = append(cmds, cmd)
357 cmds = append(cmds, cmd)
335 }
358 }
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+ }
336
367
337 case sendMessageMsg:
368 case sendMessageMsg:
338 cmds = append(cmds, m.sendMessage(msg.Content, msg.Attachments...))
369 cmds = append(cmds, m.sendMessage(msg.Content, msg.Attachments...))
@@ -363,6 +394,16 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
363 case closeDialogMsg:
394 case closeDialogMsg:
364 m.dialog.CloseFrontDialog()
395 m.dialog.CloseFrontDialog()
365
396
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+ }
366 case pubsub.Event[message.Message]:
407 case pubsub.Event[message.Message]:
367 // Check if this is a child session message for an agent tool.
408 // Check if this is a child session message for an agent tool.
368 if m.session == nil {
409 if m.session == nil {
@@ -383,6 +424,17 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
383 case pubsub.DeletedEvent:
424 case pubsub.DeletedEvent:
384 m.chat.RemoveMessage(msg.Payload.ID)
425 m.chat.RemoveMessage(msg.Payload.ID)
385 }
426 }
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()
386 case pubsub.Event[history.File]:
438 case pubsub.Event[history.File]:
387 cmds = append(cmds, m.handleFileEvent(msg.Payload))
439 cmds = append(cmds, m.handleFileEvent(msg.Payload))
388 case pubsub.Event[app.LSPEvent]:
440 case pubsub.Event[app.LSPEvent]:
@@ -524,6 +576,14 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
524 cmds = append(cmds, cmd)
576 cmds = append(cmds, cmd)
525 }
577 }
526 }
578 }
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+ }
527
587
528 case tea.KeyPressMsg:
588 case tea.KeyPressMsg:
529 if cmd := m.handleKeyPressMsg(msg); cmd != nil {
589 if cmd := m.handleKeyPressMsg(msg); cmd != nil {
@@ -573,7 +633,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
573 case uiFocusMain:
633 case uiFocusMain:
574 case uiFocusEditor:
634 case uiFocusEditor:
575 // Textarea placeholder logic
635 // Textarea placeholder logic
576− if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
636+ if m.isAgentBusy() {
577 m.textarea.Placeholder = m.workingPlaceholder
637 m.textarea.Placeholder = m.workingPlaceholder
578 } else {
638 } else {
579 m.textarea.Placeholder = m.readyPlaceholder
639 m.textarea.Placeholder = m.readyPlaceholder
@@ -945,14 +1005,14 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
945 m.setEditorPrompt(yolo)
1005 m.setEditorPrompt(yolo)
946 m.dialog.CloseDialog(dialog.CommandsID)
1006 m.dialog.CloseDialog(dialog.CommandsID)
947 case dialog.ActionNewSession:
1007 case dialog.ActionNewSession:
948− if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
1008+ if m.isAgentBusy() {
949 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
1009 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
950 break
1010 break
951 }
1011 }
952 m.newSession()
1012 m.newSession()
953 m.dialog.CloseDialog(dialog.CommandsID)
1013 m.dialog.CloseDialog(dialog.CommandsID)
954 case dialog.ActionSummarize:
1014 case dialog.ActionSummarize:
955− if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
1015+ if m.isAgentBusy() {
956 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session..."))
1016 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session..."))
957 break
1017 break
958 }
1018 }
@@ -968,7 +1028,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
968 m.status.ToggleHelp()
1028 m.status.ToggleHelp()
969 m.dialog.CloseDialog(dialog.CommandsID)
1029 m.dialog.CloseDialog(dialog.CommandsID)
970 case dialog.ActionExternalEditor:
1030 case dialog.ActionExternalEditor:
971− if m.session != nil && m.com.App.AgentCoordinator.IsSessionBusy(m.session.ID) {
1031+ if m.isAgentBusy() {
972 cmds = append(cmds, uiutil.ReportWarn("Agent is working, please wait..."))
1032 cmds = append(cmds, uiutil.ReportWarn("Agent is working, please wait..."))
973 break
1033 break
974 }
1034 }
@@ -978,7 +1038,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
978 cmds = append(cmds, m.toggleCompactMode())
1038 cmds = append(cmds, m.toggleCompactMode())
979 m.dialog.CloseDialog(dialog.CommandsID)
1039 m.dialog.CloseDialog(dialog.CommandsID)
980 case dialog.ActionToggleThinking:
1040 case dialog.ActionToggleThinking:
981− if m.com.App.AgentCoordinator.IsBusy() {
1041+ if m.isAgentBusy() {
982 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
1042 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
983 break
1043 break
984 }
1044 }
@@ -1010,14 +1070,14 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
1010 case dialog.ActionQuit:
1070 case dialog.ActionQuit:
1011 cmds = append(cmds, tea.Quit)
1071 cmds = append(cmds, tea.Quit)
1012 case dialog.ActionInitializeProject:
1072 case dialog.ActionInitializeProject:
1013− if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
1073+ if m.isAgentBusy() {
1014 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session..."))
1074 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session..."))
1015 break
1075 break
1016 }
1076 }
1017 cmds = append(cmds, m.initializeProject())
1077 cmds = append(cmds, m.initializeProject())
1018
1078
1019 case dialog.ActionSelectModel:
1079 case dialog.ActionSelectModel:
1020− if m.com.App.AgentCoordinator.IsBusy() {
1080+ if m.isAgentBusy() {
1021 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
1081 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
1022 break
1082 break
1023 }
1083 }
@@ -1063,7 +1123,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
1063 m.dialog.CloseDialog(dialog.OAuthID)
1123 m.dialog.CloseDialog(dialog.OAuthID)
1064 m.dialog.CloseDialog(dialog.ModelsID)
1124 m.dialog.CloseDialog(dialog.ModelsID)
1065 case dialog.ActionSelectReasoningEffort:
1125 case dialog.ActionSelectReasoningEffort:
1066− if m.com.App.AgentCoordinator.IsBusy() {
1126+ if m.isAgentBusy() {
1067 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
1127 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
1068 break
1128 break
1069 }
1129 }
@@ -1217,6 +1277,27 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
1217 m.detailsOpen = !m.detailsOpen
1277 m.detailsOpen = !m.detailsOpen
1218 m.updateLayoutAndSize()
1278 m.updateLayoutAndSize()
1219 return true
1279 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+ }
1220 }
1301 }
1221 return false
1302 return false
1222 }
1303 }
@@ -1237,7 +1318,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
1237
1318
1238 // Handle cancel key when agent is busy.
1319 // Handle cancel key when agent is busy.
1239 if key.Matches(msg, m.keyMap.Chat.Cancel) {
1320 if key.Matches(msg, m.keyMap.Chat.Cancel) {
1240− if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
1321+ if m.isAgentBusy() {
1241 if cmd := m.cancelAgent(); cmd != nil {
1322 if cmd := m.cancelAgent(); cmd != nil {
1242 cmds = append(cmds, cmd)
1323 cmds = append(cmds, cmd)
1243 }
1324 }
@@ -1309,10 +1390,10 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
1309
1390
1310 return m.sendMessage(value, attachments...)
1391 return m.sendMessage(value, attachments...)
1311 case key.Matches(msg, m.keyMap.Chat.NewSession):
1392 case key.Matches(msg, m.keyMap.Chat.NewSession):
1312− if m.session == nil || m.session.ID == "" {
1393+ if !m.hasSession() {
1313 break
1394 break
1314 }
1395 }
1315− if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
1396+ if m.isAgentBusy() {
1316 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
1397 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
1317 break
1398 break
1318 }
1399 }
@@ -1323,7 +1404,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
1323 m.chat.Focus()
1404 m.chat.Focus()
1324 m.chat.SetSelected(m.chat.Len() - 1)
1405 m.chat.SetSelected(m.chat.Len() - 1)
1325 case key.Matches(msg, m.keyMap.Editor.OpenEditor):
1406 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() {
1327 cmds = append(cmds, uiutil.ReportWarn("Agent is working, please wait..."))
1408 cmds = append(cmds, uiutil.ReportWarn("Agent is working, please wait..."))
1328 break
1409 break
1329 }
1410 }
@@ -1518,6 +1599,9 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
1518 }
1599 }
1519
1600
1520 m.chat.Draw(scr, layout.main)
1601 m.chat.Draw(scr, layout.main)
1602+ if layout.pills.Dy() > 0 && m.pillsView != "" {
1603+ uv.NewStyledString(m.pillsView).Draw(scr, layout.pills)
1604+ }
1521
1605
1522 editorWidth := scr.Bounds().Dx()
1606 editorWidth := scr.Bounds().Dx()
1523 if !m.isCompact {
1607 if !m.isCompact {
@@ -1617,7 +1701,7 @@ func (m *UI) View() tea.View {
1617 content = strings.Join(contentLines, "\n")
1701 content = strings.Join(contentLines, "\n")
1618
1702
1619 v.Content = content
1703 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() {
1621 // HACK: use a random percentage to prevent ghostty from hiding it
1705 // HACK: use a random percentage to prevent ghostty from hiding it
1622 // after a timeout.
1706 // after a timeout.
1623 v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
1707 v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
@@ -1641,7 +1725,7 @@ func (m *UI) ShortHelp() []key.Binding {
1641 binds = append(binds, k.Quit)
1725 binds = append(binds, k.Quit)
1642 case uiChat:
1726 case uiChat:
1643 // Show cancel binding if agent is busy.
1727 // Show cancel binding if agent is busy.
1644− if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
1728+ if m.isAgentBusy() {
1645 cancelBinding := k.Chat.Cancel
1729 cancelBinding := k.Chat.Cancel
1646 if m.isCanceling {
1730 if m.isCanceling {
1647 cancelBinding.SetHelp("esc", "press again to cancel")
1731 cancelBinding.SetHelp("esc", "press again to cancel")
@@ -1676,6 +1760,9 @@ func (m *UI) ShortHelp() []key.Binding {
1676 k.Chat.PageDown,
1760 k.Chat.PageDown,
1677 k.Chat.Copy,
1761 k.Chat.Copy,
1678 )
1762 )
1763+ if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
1764+ binds = append(binds, k.Chat.PillLeft)
1765+ }
1679 }
1766 }
1680 default:
1767 default:
1681 // TODO: other states
1768 // TODO: other states
@@ -1703,7 +1790,7 @@ func (m *UI) FullHelp() [][]key.Binding {
1703 help := k.Help
1790 help := k.Help
1704 help.SetHelp("ctrl+g", "less")
1791 help.SetHelp("ctrl+g", "less")
1705 hasAttachments := len(m.attachments.List()) > 0
1792 hasAttachments := len(m.attachments.List()) > 0
1706− hasSession := m.session != nil && m.session.ID != ""
1793+ hasSession := m.hasSession()
1707 commands := k.Commands
1794 commands := k.Commands
1708 if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
1795 if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
1709 commands.SetHelp("/ or ctrl+p", "commands")
1796 commands.SetHelp("/ or ctrl+p", "commands")
@@ -1717,7 +1804,7 @@ func (m *UI) FullHelp() [][]key.Binding {
1717 })
1804 })
1718 case uiChat:
1805 case uiChat:
1719 // Show cancel binding if agent is busy.
1806 // Show cancel binding if agent is busy.
1720− if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
1807+ if m.isAgentBusy() {
1721 cancelBinding := k.Chat.Cancel
1808 cancelBinding := k.Chat.Cancel
1722 if m.isCanceling {
1809 if m.isCanceling {
1723 cancelBinding.SetHelp("esc", "press again to cancel")
1810 cancelBinding.SetHelp("esc", "press again to cancel")
@@ -1785,6 +1872,9 @@ func (m *UI) FullHelp() [][]key.Binding {
1785 k.Chat.ClearHighlight,
1872 k.Chat.ClearHighlight,
1786 },
1873 },
1787 )
1874 )
1875+ if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
1876+ binds = append(binds, []key.Binding{k.Chat.PillLeft})
1877+ }
1788 }
1878 }
1789 default:
1879 default:
1790 if m.session == nil {
1880 if m.session == nil {
@@ -1873,6 +1963,7 @@ func (m *UI) updateSize() {
1873 m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
1963 m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
1874 m.textarea.SetWidth(m.layout.editor.Dx())
1964 m.textarea.SetWidth(m.layout.editor.Dx())
1875 m.textarea.SetHeight(m.layout.editor.Dy())
1965 m.textarea.SetHeight(m.layout.editor.Dy())
1966+ m.renderPills()
1876
1967
1877 // Handle different app states
1968 // Handle different app states
1878 switch m.state {
1969 switch m.state {
@@ -1984,10 +2075,18 @@ func (m *UI) generateLayout(w, h int) layout {
1984 mainRect.Min.Y += 1
2075 mainRect.Min.Y += 1
1985 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
2076 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
1986 mainRect.Max.X -= 1 // Add padding right
2077 mainRect.Max.X -= 1 // Add padding right
1987− // Add bottom margin to main
1988− mainRect.Max.Y -= 1
1989 layout.header = headerRect
2078 layout.header = headerRect
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))
1990− layout.main = mainRect
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
1991 layout.editor = editorRect
2090 layout.editor = editorRect
1992 } else {
2091 } else {
1993 // Layout
2092 // Layout
@@ -2004,10 +2103,18 @@ func (m *UI) generateLayout(w, h int) layout {
2004 sideRect.Min.X += 1
2103 sideRect.Min.X += 1
2005 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
2104 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
2006 mainRect.Max.X -= 1 // Add padding right
2105 mainRect.Max.X -= 1 // Add padding right
2007− // Add bottom margin to main
2008− mainRect.Max.Y -= 1
2009 layout.sidebar = sideRect
2106 layout.sidebar = sideRect
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))
2010− layout.main = mainRect
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
2011 layout.editor = editorRect
2118 layout.editor = editorRect
2012 }
2119 }
2013 }
2120 }
@@ -2035,6 +2142,9 @@ type layout struct {
2035 // main is the area for the main pane. (e.x chat, configure, landing)
2142 // main is the area for the main pane. (e.x chat, configure, landing)
2036 main uv.Rectangle
2143 main uv.Rectangle
2037
2144
2145+ // pills is the area for the pills panel.
2146+ pills uv.Rectangle
2147+
2038 // editor is the area for the editor pane.
2148 // editor is the area for the editor pane.
2039 editor uv.Rectangle
2149 editor uv.Rectangle
2040
2150
@@ -2208,6 +2318,19 @@ func isWhitespace(b byte) bool {
2208 return b == ' ' || b == '\t' || b == '\n' || b == '\r'
2318 return b == ' ' || b == '\t' || b == '\n' || b == '\r'
2209}
2319}
2210
2320
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+
2211// mimeOf detects the MIME type of the given content.
2334// mimeOf detects the MIME type of the given content.
2212func mimeOf(content []byte) string {
2335func mimeOf(content []byte) string {
2213 mimeBufferSize := min(512, len(content))
2336 mimeBufferSize := min(512, len(content))
@@ -2271,7 +2394,7 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.
2271 }
2394 }
2272
2395
2273 var cmds []tea.Cmd
2396 var cmds []tea.Cmd
2274− if m.session == nil || m.session.ID == "" {
2397+ if !m.hasSession() {
2275 newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
2398 newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
2276 if err != nil {
2399 if err != nil {
2277 return uiutil.ReportError(err)
2400 return uiutil.ReportError(err)
@@ -2319,7 +2442,7 @@ func cancelTimerCmd() tea.Cmd {
2319// and starts a timer. The second press (before the timer expires) actually
2442// and starts a timer. The second press (before the timer expires) actually
2320// cancels the agent.
2443// cancels the agent.
2321func (m *UI) cancelAgent() tea.Cmd {
2444func (m *UI) cancelAgent() tea.Cmd {
2322− if m.session == nil || m.session.ID == "" {
2445+ if !m.hasSession() {
2323 return nil
2446 return nil
2324 }
2447 }
2325
2448
@@ -2332,6 +2455,9 @@ func (m *UI) cancelAgent() tea.Cmd {
2332 // Second escape press - actually cancel the agent.
2455 // Second escape press - actually cancel the agent.
2333 m.isCanceling = false
2456 m.isCanceling = false
2334 coordinator.Cancel(m.session.ID)
2457 coordinator.Cancel(m.session.ID)
2458+ // Stop the spinning todo indicator.
2459+ m.todoIsSpinning = false
2460+ m.renderPills()
2335 return nil
2461 return nil
2336 }
2462 }
2337
2463
@@ -2521,7 +2647,7 @@ func (m *UI) handlePermissionNotification(notification permission.PermissionNoti
2521// newSession clears the current session state and prepares for a new session.
2647// newSession clears the current session state and prepares for a new session.
2522// The actual session creation happens when the user sends their first message.
2648// The actual session creation happens when the user sends their first message.
2523func (m *UI) newSession() {
2649func (m *UI) newSession() {
2524− if m.session == nil || m.session.ID == "" {
2650+ if !m.hasSession() {
2525 return
2651 return
2526 }
2652 }
2527
2653
@@ -2532,6 +2658,9 @@ func (m *UI) newSession() {
2532 m.textarea.Focus()
2658 m.textarea.Focus()
2533 m.chat.Blur()
2659 m.chat.Blur()
2534 m.chat.ClearMessages()
2660 m.chat.ClearMessages()
2661+ m.pillsExpanded = false
2662+ m.promptQueue = 0
2663+ m.pillsView = ""
2535}
2664}
2536
2665
2537// handlePasteMsg handles a paste message.
2666// handlePasteMsg handles a paste message.
internal/ui/styles/styles.go+23−1
@@ -21,7 +21,7 @@ const (
21 WarningIcon string = "⚠"
21 WarningIcon string = "⚠"
22 InfoIcon string = "ⓘ"
22 InfoIcon string = "ⓘ"
23 HintIcon string = "∵"
23 HintIcon string = "∵"
24− SpinnerIcon string = "..."
24+ SpinnerIcon string = "⋯"
25 LoadingIcon string = "⟳"
25 LoadingIcon string = "⟳"
26 ModelIcon string = "◇"
26 ModelIcon string = "◇"
27
27
@@ -394,6 +394,18 @@ type Styles struct {
394 Text lipgloss.Style
394 Text lipgloss.Style
395 Deleting lipgloss.Style
395 Deleting lipgloss.Style
396 }
396 }
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+ }
397}
409}
398
410
399// ChromaTheme converts the current markdown chroma styles to a chroma
411// ChromaTheme converts the current markdown chroma styles to a chroma
@@ -1270,6 +1282,16 @@ func DefaultStyles() Styles {
1270 s.Attachments.Normal = base.Padding(0, 1).MarginRight(1).Background(fgMuted).Foreground(fgBase)
1282 s.Attachments.Normal = base.Padding(0, 1).MarginRight(1).Background(fgMuted).Foreground(fgBase)
1271 s.Attachments.Deleting = base.Padding(0, 1).Bold(true).Background(red).Foreground(fgBase)
1283 s.Attachments.Deleting = base.Padding(0, 1).Bold(true).Background(red).Foreground(fgBase)
1272
1284
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+
1273 return s
1295 return s
1274}
1296}
1275
1297