@@ -82,7 +82,7 @@ func (t *TodosToolRenderContext) RenderTool(sty *styles.Styles, width int, opts
} else {
headerText = fmt.Sprintf("created %d todos", meta.Total)
}
- body = formatTodosList(sty, meta.Todos, styles.ArrowRightIcon, cappedWidth)
+ body = FormatTodosList(sty, meta.Todos, styles.ArrowRightIcon, cappedWidth)
} else {
// Build header based on what changed.
hasCompleted := len(meta.JustCompleted) > 0
@@ -108,7 +108,7 @@ func (t *TodosToolRenderContext) RenderTool(sty *styles.Styles, width int, opts
// Build body with details.
if allCompleted {
// Show all todos when all are completed, like when created.
- body = formatTodosList(sty, meta.Todos, styles.ArrowRightIcon, cappedWidth)
+ body = FormatTodosList(sty, meta.Todos, styles.ArrowRightIcon, cappedWidth)
} else if meta.JustStarted != "" {
body = sty.Tool.TodoInProgressIcon.Render(styles.ArrowRightIcon+" ") +
sty.Base.Render(meta.JustStarted)
@@ -135,8 +135,8 @@ func (t *TodosToolRenderContext) RenderTool(sty *styles.Styles, width int, opts
return joinToolParts(header, sty.Tool.Body.Render(body))
}
-// formatTodosList formats a list of todos for display.
-func formatTodosList(sty *styles.Styles, todos []session.Todo, inProgressIcon string, width int) string {
+// FormatTodosList formats a list of todos for display.
+func FormatTodosList(sty *styles.Styles, todos []session.Todo, inProgressIcon string, width int) string {
if len(todos) == 0 {
return ""
}
@@ -0,0 +1,283 @@
+package model
+
+import (
+ "fmt"
+ "strings"
+
+ tea "charm.land/bubbletea/v2"
+ "charm.land/lipgloss/v2"
+ "github.com/charmbracelet/crush/internal/session"
+ "github.com/charmbracelet/crush/internal/ui/chat"
+ "github.com/charmbracelet/crush/internal/ui/styles"
+)
+
+// pillStyle returns the appropriate style for a pill based on focus state.
+func pillStyle(focused, panelFocused bool, t *styles.Styles) lipgloss.Style {
+ if !panelFocused || focused {
+ return t.Pills.Focused
+ }
+ return t.Pills.Blurred
+}
+
+const (
+ // pillHeightWithBorder is the height of a pill including its border.
+ pillHeightWithBorder = 3
+ // maxTaskDisplayLength is the maximum length of a task name in the pill.
+ maxTaskDisplayLength = 40
+ // maxQueueDisplayLength is the maximum length of a queue item in the list.
+ maxQueueDisplayLength = 60
+)
+
+// pillSection represents which section of the pills panel is focused.
+type pillSection int
+
+const (
+ pillSectionTodos pillSection = iota
+ pillSectionQueue
+)
+
+// hasIncompleteTodos returns true if there are any non-completed todos.
+func hasIncompleteTodos(todos []session.Todo) bool {
+ for _, todo := range todos {
+ if todo.Status != session.TodoStatusCompleted {
+ return true
+ }
+ }
+ return false
+}
+
+// hasInProgressTodo returns true if there is at least one in-progress todo.
+func hasInProgressTodo(todos []session.Todo) bool {
+ for _, todo := range todos {
+ if todo.Status == session.TodoStatusInProgress {
+ return true
+ }
+ }
+ return false
+}
+
+// queuePill renders the queue count pill with gradient triangles.
+func queuePill(queue int, focused, panelFocused bool, t *styles.Styles) string {
+ if queue <= 0 {
+ return ""
+ }
+ triangles := styles.ForegroundGrad(t, "βΆβΆβΆβΆβΆβΆβΆβΆβΆ", false, t.RedDark, t.Secondary)
+ if queue < len(triangles) {
+ triangles = triangles[:queue]
+ }
+
+ content := fmt.Sprintf("%s %d Queued", strings.Join(triangles, ""), queue)
+ return pillStyle(focused, panelFocused, t).Render(content)
+}
+
+// todoPill renders the todo progress pill with optional spinner and task name.
+func todoPill(todos []session.Todo, spinnerView string, focused, panelFocused bool, t *styles.Styles) string {
+ if !hasIncompleteTodos(todos) {
+ return ""
+ }
+
+ completed := 0
+ var currentTodo *session.Todo
+ for i := range todos {
+ switch todos[i].Status {
+ case session.TodoStatusCompleted:
+ completed++
+ case session.TodoStatusInProgress:
+ if currentTodo == nil {
+ currentTodo = &todos[i]
+ }
+ }
+ }
+
+ total := len(todos)
+
+ label := t.Base.Render("To-Do")
+ progress := t.Muted.Render(fmt.Sprintf("%d/%d", completed, total))
+
+ var content string
+ if panelFocused {
+ content = fmt.Sprintf("%s %s", label, progress)
+ } else if currentTodo != nil {
+ taskText := currentTodo.Content
+ if currentTodo.ActiveForm != "" {
+ taskText = currentTodo.ActiveForm
+ }
+ if len(taskText) > maxTaskDisplayLength {
+ taskText = taskText[:maxTaskDisplayLength-1] + "β¦"
+ }
+ task := t.Subtle.Render(taskText)
+ content = fmt.Sprintf("%s %s %s %s", spinnerView, label, progress, task)
+ } else {
+ content = fmt.Sprintf("%s %s", label, progress)
+ }
+
+ return pillStyle(focused, panelFocused, t).Render(content)
+}
+
+// todoList renders the expanded todo list.
+func todoList(sessionTodos []session.Todo, spinnerView string, t *styles.Styles, width int) string {
+ return chat.FormatTodosList(t, sessionTodos, spinnerView, width)
+}
+
+// queueList renders the expanded queue items list.
+func queueList(queueItems []string, t *styles.Styles) string {
+ if len(queueItems) == 0 {
+ return ""
+ }
+
+ var lines []string
+ for _, item := range queueItems {
+ text := item
+ if len(text) > maxQueueDisplayLength {
+ text = text[:maxQueueDisplayLength-1] + "β¦"
+ }
+ prefix := t.Pills.QueueItemPrefix.Render() + " "
+ lines = append(lines, prefix+t.Muted.Render(text))
+ }
+
+ return strings.Join(lines, "\n")
+}
+
+// togglePillsExpanded toggles the pills panel expansion state.
+func (m *UI) togglePillsExpanded() tea.Cmd {
+ if !m.hasSession() {
+ return nil
+ }
+ if m.layout.pills.Dy() > 0 {
+ if cmd := m.chat.ScrollByAndAnimate(0); cmd != nil {
+ return cmd
+ }
+ }
+ hasPills := hasIncompleteTodos(m.session.Todos) || m.promptQueue > 0
+ if !hasPills {
+ return nil
+ }
+ m.pillsExpanded = !m.pillsExpanded
+ if m.pillsExpanded {
+ if hasIncompleteTodos(m.session.Todos) {
+ m.focusedPillSection = pillSectionTodos
+ } else {
+ m.focusedPillSection = pillSectionQueue
+ }
+ }
+ m.updateLayoutAndSize()
+ return nil
+}
+
+// switchPillSection changes focus between todo and queue sections.
+func (m *UI) switchPillSection(dir int) tea.Cmd {
+ if !m.pillsExpanded || !m.hasSession() {
+ return nil
+ }
+ hasIncompleteTodos := hasIncompleteTodos(m.session.Todos)
+ hasQueue := m.promptQueue > 0
+
+ if dir < 0 && m.focusedPillSection == pillSectionQueue && hasIncompleteTodos {
+ m.focusedPillSection = pillSectionTodos
+ m.updateLayoutAndSize()
+ return nil
+ }
+ if dir > 0 && m.focusedPillSection == pillSectionTodos && hasQueue {
+ m.focusedPillSection = pillSectionQueue
+ m.updateLayoutAndSize()
+ return nil
+ }
+ return nil
+}
+
+// pillsAreaHeight calculates the total height needed for the pills area.
+func (m *UI) pillsAreaHeight() int {
+ if !m.hasSession() {
+ return 0
+ }
+ hasIncomplete := hasIncompleteTodos(m.session.Todos)
+ hasQueue := m.promptQueue > 0
+ hasPills := hasIncomplete || hasQueue
+ if !hasPills {
+ return 0
+ }
+
+ pillsAreaHeight := pillHeightWithBorder
+ if m.pillsExpanded {
+ if m.focusedPillSection == pillSectionTodos && hasIncomplete {
+ pillsAreaHeight += len(m.session.Todos)
+ } else if m.focusedPillSection == pillSectionQueue && hasQueue {
+ pillsAreaHeight += m.promptQueue
+ }
+ }
+ return pillsAreaHeight
+}
+
+// renderPills renders the pills panel and stores it in m.pillsView.
+func (m *UI) renderPills() {
+ m.pillsView = ""
+ if !m.hasSession() {
+ return
+ }
+
+ width := m.layout.pills.Dx()
+ if width <= 0 {
+ return
+ }
+
+ paddingLeft := 3
+ contentWidth := max(width-paddingLeft, 0)
+
+ hasIncomplete := hasIncompleteTodos(m.session.Todos)
+ hasQueue := m.promptQueue > 0
+
+ if !hasIncomplete && !hasQueue {
+ return
+ }
+
+ t := m.com.Styles
+ todosFocused := m.pillsExpanded && m.focusedPillSection == pillSectionTodos
+ queueFocused := m.pillsExpanded && m.focusedPillSection == pillSectionQueue
+
+ inProgressIcon := t.Tool.TodoInProgressIcon.Render(styles.SpinnerIcon)
+ if m.todoIsSpinning {
+ inProgressIcon = m.todoSpinner.View()
+ }
+
+ var pills []string
+ if hasIncomplete {
+ pills = append(pills, todoPill(m.session.Todos, inProgressIcon, todosFocused, m.pillsExpanded, t))
+ }
+ if hasQueue {
+ pills = append(pills, queuePill(m.promptQueue, queueFocused, m.pillsExpanded, t))
+ }
+
+ var expandedList string
+ if m.pillsExpanded {
+ if todosFocused && hasIncomplete {
+ expandedList = todoList(m.session.Todos, inProgressIcon, t, contentWidth)
+ } else if queueFocused && hasQueue {
+ if m.com.App != nil && m.com.App.AgentCoordinator != nil {
+ queueItems := m.com.App.AgentCoordinator.QueuedPromptsList(m.session.ID)
+ expandedList = queueList(queueItems, t)
+ }
+ }
+ }
+
+ if len(pills) == 0 {
+ return
+ }
+
+ pillsRow := lipgloss.JoinHorizontal(lipgloss.Top, pills...)
+
+ helpDesc := "open"
+ if m.pillsExpanded {
+ helpDesc = "close"
+ }
+ helpKey := t.Pills.HelpKey.Render("ctrl+space")
+ helpText := t.Pills.HelpText.Render(helpDesc)
+ helpHint := lipgloss.JoinHorizontal(lipgloss.Center, helpKey, " ", helpText)
+ pillsRow = lipgloss.JoinHorizontal(lipgloss.Center, pillsRow, " ", helpHint)
+
+ pillsArea := pillsRow
+ if expandedList != "" {
+ pillsArea = lipgloss.JoinVertical(lipgloss.Left, pillsRow, expandedList)
+ }
+
+ m.pillsView = t.Pills.Area.MaxWidth(width).PaddingLeft(paddingLeft).Render(pillsArea)
+}
@@ -189,6 +189,16 @@ type UI struct {
// detailsOpen tracks whether the details panel is open (in compact mode)
detailsOpen bool
+
+ // pills state
+ pillsExpanded bool
+ focusedPillSection pillSection
+ promptQueue int
+ pillsView string
+
+ // Todo spinner
+ todoSpinner spinner.Model
+ todoIsSpinning bool
}
// New creates a new instance of the [UI] model.
@@ -212,6 +222,11 @@ func New(com *common.Common) *UI {
com.Styles.Completions.Match,
)
+ todoSpinner := spinner.New(
+ spinner.WithSpinner(spinner.MiniDot),
+ spinner.WithStyle(com.Styles.Pills.TodoSpinner),
+ )
+
// Attachments component
attachments := attachments.New(
attachments.NewRenderer(
@@ -237,6 +252,7 @@ func New(com *common.Common) *UI {
chat: ch,
completions: comp,
attachments: attachments,
+ todoSpinner: todoSpinner,
}
status := NewStatus(com, ui)
@@ -312,6 +328,13 @@ func (m *UI) loadMCPrompts() tea.Cmd {
// Update handles updates to the UI model.
func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
+ if m.hasSession() && m.isAgentBusy() {
+ queueSize := m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID)
+ if queueSize != m.promptQueue {
+ m.promptQueue = queueSize
+ m.updateLayoutAndSize()
+ }
+ }
switch msg := msg.(type) {
case tea.EnvMsg:
// Is this Windows Terminal?
@@ -333,6 +356,14 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if cmd := m.setSessionMessages(msgs); cmd != nil {
cmds = append(cmds, cmd)
}
+ if hasInProgressTodo(m.session.Todos) {
+ // only start spinner if there is an in-progress todo
+ if m.isAgentBusy() {
+ m.todoIsSpinning = true
+ cmds = append(cmds, m.todoSpinner.Tick)
+ }
+ m.updateLayoutAndSize()
+ }
case sendMessageMsg:
cmds = append(cmds, m.sendMessage(msg.Content, msg.Attachments...))
@@ -363,6 +394,16 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case closeDialogMsg:
m.dialog.CloseFrontDialog()
+ case pubsub.Event[session.Session]:
+ if m.session != nil && msg.Payload.ID == m.session.ID {
+ prevHasInProgress := hasInProgressTodo(m.session.Todos)
+ m.session = &msg.Payload
+ if !prevHasInProgress && hasInProgressTodo(m.session.Todos) {
+ m.todoIsSpinning = true
+ cmds = append(cmds, m.todoSpinner.Tick)
+ m.updateLayoutAndSize()
+ }
+ }
case pubsub.Event[message.Message]:
// Check if this is a child session message for an agent tool.
if m.session == nil {
@@ -383,6 +424,17 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case pubsub.DeletedEvent:
m.chat.RemoveMessage(msg.Payload.ID)
}
+ // start the spinner if there is a new message
+ if hasInProgressTodo(m.session.Todos) && m.isAgentBusy() && !m.todoIsSpinning {
+ m.todoIsSpinning = true
+ cmds = append(cmds, m.todoSpinner.Tick)
+ }
+ // stop the spinner if the agent is not busy anymore
+ if m.todoIsSpinning && !m.isAgentBusy() {
+ m.todoIsSpinning = false
+ }
+ // there is a number of things that could change the pills here so we want to re-render
+ m.renderPills()
case pubsub.Event[history.File]:
cmds = append(cmds, m.handleFileEvent(msg.Payload))
case pubsub.Event[app.LSPEvent]:
@@ -524,6 +576,14 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, cmd)
}
}
+ if m.state == uiChat && m.hasSession() && hasInProgressTodo(m.session.Todos) && m.todoIsSpinning {
+ var cmd tea.Cmd
+ m.todoSpinner, cmd = m.todoSpinner.Update(msg)
+ if cmd != nil {
+ m.renderPills()
+ cmds = append(cmds, cmd)
+ }
+ }
case tea.KeyPressMsg:
if cmd := m.handleKeyPressMsg(msg); cmd != nil {
@@ -573,7 +633,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case uiFocusMain:
case uiFocusEditor:
// Textarea placeholder logic
- if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
+ if m.isAgentBusy() {
m.textarea.Placeholder = m.workingPlaceholder
} else {
m.textarea.Placeholder = m.readyPlaceholder
@@ -945,14 +1005,14 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
m.setEditorPrompt(yolo)
m.dialog.CloseDialog(dialog.CommandsID)
case dialog.ActionNewSession:
- if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
+ if m.isAgentBusy() {
cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
break
}
m.newSession()
m.dialog.CloseDialog(dialog.CommandsID)
case dialog.ActionSummarize:
- if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
+ if m.isAgentBusy() {
cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session..."))
break
}
@@ -968,7 +1028,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
m.status.ToggleHelp()
m.dialog.CloseDialog(dialog.CommandsID)
case dialog.ActionExternalEditor:
- if m.session != nil && m.com.App.AgentCoordinator.IsSessionBusy(m.session.ID) {
+ if m.isAgentBusy() {
cmds = append(cmds, uiutil.ReportWarn("Agent is working, please wait..."))
break
}
@@ -978,7 +1038,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
cmds = append(cmds, m.toggleCompactMode())
m.dialog.CloseDialog(dialog.CommandsID)
case dialog.ActionToggleThinking:
- if m.com.App.AgentCoordinator.IsBusy() {
+ if m.isAgentBusy() {
cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
break
}
@@ -1010,14 +1070,14 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
case dialog.ActionQuit:
cmds = append(cmds, tea.Quit)
case dialog.ActionInitializeProject:
- if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
+ if m.isAgentBusy() {
cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session..."))
break
}
cmds = append(cmds, m.initializeProject())
case dialog.ActionSelectModel:
- if m.com.App.AgentCoordinator.IsBusy() {
+ if m.isAgentBusy() {
cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
break
}
@@ -1063,7 +1123,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
m.dialog.CloseDialog(dialog.OAuthID)
m.dialog.CloseDialog(dialog.ModelsID)
case dialog.ActionSelectReasoningEffort:
- if m.com.App.AgentCoordinator.IsBusy() {
+ if m.isAgentBusy() {
cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
break
}
@@ -1217,6 +1277,27 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
m.detailsOpen = !m.detailsOpen
m.updateLayoutAndSize()
return true
+ case key.Matches(msg, m.keyMap.Chat.TogglePills):
+ if m.state == uiChat && m.hasSession() {
+ if cmd := m.togglePillsExpanded(); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ return true
+ }
+ case key.Matches(msg, m.keyMap.Chat.PillLeft):
+ if m.state == uiChat && m.hasSession() && m.pillsExpanded {
+ if cmd := m.switchPillSection(-1); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ return true
+ }
+ case key.Matches(msg, m.keyMap.Chat.PillRight):
+ if m.state == uiChat && m.hasSession() && m.pillsExpanded {
+ if cmd := m.switchPillSection(1); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ return true
+ }
}
return false
}
@@ -1237,7 +1318,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
// Handle cancel key when agent is busy.
if key.Matches(msg, m.keyMap.Chat.Cancel) {
- if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
+ if m.isAgentBusy() {
if cmd := m.cancelAgent(); cmd != nil {
cmds = append(cmds, cmd)
}
@@ -1309,10 +1390,10 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
return m.sendMessage(value, attachments...)
case key.Matches(msg, m.keyMap.Chat.NewSession):
- if m.session == nil || m.session.ID == "" {
+ if !m.hasSession() {
break
}
- if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
+ if m.isAgentBusy() {
cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
break
}
@@ -1323,7 +1404,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
m.chat.Focus()
m.chat.SetSelected(m.chat.Len() - 1)
case key.Matches(msg, m.keyMap.Editor.OpenEditor):
- if m.session != nil && m.com.App.AgentCoordinator.IsSessionBusy(m.session.ID) {
+ if m.isAgentBusy() {
cmds = append(cmds, uiutil.ReportWarn("Agent is working, please wait..."))
break
}
@@ -1518,6 +1599,9 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
}
m.chat.Draw(scr, layout.main)
+ if layout.pills.Dy() > 0 && m.pillsView != "" {
+ uv.NewStyledString(m.pillsView).Draw(scr, layout.pills)
+ }
editorWidth := scr.Bounds().Dx()
if !m.isCompact {
@@ -1617,7 +1701,7 @@ func (m *UI) View() tea.View {
content = strings.Join(contentLines, "\n")
v.Content = content
- if m.sendProgressBar && m.com.App != nil && m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
+ if m.sendProgressBar && m.isAgentBusy() {
// HACK: use a random percentage to prevent ghostty from hiding it
// after a timeout.
v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
@@ -1641,7 +1725,7 @@ func (m *UI) ShortHelp() []key.Binding {
binds = append(binds, k.Quit)
case uiChat:
// Show cancel binding if agent is busy.
- if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
+ if m.isAgentBusy() {
cancelBinding := k.Chat.Cancel
if m.isCanceling {
cancelBinding.SetHelp("esc", "press again to cancel")
@@ -1676,6 +1760,9 @@ func (m *UI) ShortHelp() []key.Binding {
k.Chat.PageDown,
k.Chat.Copy,
)
+ if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
+ binds = append(binds, k.Chat.PillLeft)
+ }
}
default:
// TODO: other states
@@ -1703,7 +1790,7 @@ func (m *UI) FullHelp() [][]key.Binding {
help := k.Help
help.SetHelp("ctrl+g", "less")
hasAttachments := len(m.attachments.List()) > 0
- hasSession := m.session != nil && m.session.ID != ""
+ hasSession := m.hasSession()
commands := k.Commands
if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
commands.SetHelp("/ or ctrl+p", "commands")
@@ -1717,7 +1804,7 @@ func (m *UI) FullHelp() [][]key.Binding {
})
case uiChat:
// Show cancel binding if agent is busy.
- if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
+ if m.isAgentBusy() {
cancelBinding := k.Chat.Cancel
if m.isCanceling {
cancelBinding.SetHelp("esc", "press again to cancel")
@@ -1785,6 +1872,9 @@ func (m *UI) FullHelp() [][]key.Binding {
k.Chat.ClearHighlight,
},
)
+ if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
+ binds = append(binds, []key.Binding{k.Chat.PillLeft})
+ }
}
default:
if m.session == nil {
@@ -1873,6 +1963,7 @@ func (m *UI) updateSize() {
m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
m.textarea.SetWidth(m.layout.editor.Dx())
m.textarea.SetHeight(m.layout.editor.Dy())
+ m.renderPills()
// Handle different app states
switch m.state {
@@ -1984,10 +2075,18 @@ func (m *UI) generateLayout(w, h int) layout {
mainRect.Min.Y += 1
mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
mainRect.Max.X -= 1 // Add padding right
- // Add bottom margin to main
- mainRect.Max.Y -= 1
layout.header = headerRect
- layout.main = mainRect
+ pillsHeight := m.pillsAreaHeight()
+ if pillsHeight > 0 {
+ pillsHeight = min(pillsHeight, mainRect.Dy())
+ chatRect, pillsRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-pillsHeight))
+ layout.main = chatRect
+ layout.pills = pillsRect
+ } else {
+ layout.main = mainRect
+ }
+ // Add bottom margin to main
+ layout.main.Max.Y -= 1
layout.editor = editorRect
} else {
// Layout
@@ -2004,10 +2103,18 @@ func (m *UI) generateLayout(w, h int) layout {
sideRect.Min.X += 1
mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
mainRect.Max.X -= 1 // Add padding right
- // Add bottom margin to main
- mainRect.Max.Y -= 1
layout.sidebar = sideRect
- layout.main = mainRect
+ pillsHeight := m.pillsAreaHeight()
+ if pillsHeight > 0 {
+ pillsHeight = min(pillsHeight, mainRect.Dy())
+ chatRect, pillsRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-pillsHeight))
+ layout.main = chatRect
+ layout.pills = pillsRect
+ } else {
+ layout.main = mainRect
+ }
+ // Add bottom margin to main
+ layout.main.Max.Y -= 1
layout.editor = editorRect
}
}
@@ -2035,6 +2142,9 @@ type layout struct {
// main is the area for the main pane. (e.x chat, configure, landing)
main uv.Rectangle
+ // pills is the area for the pills panel.
+ pills uv.Rectangle
+
// editor is the area for the editor pane.
editor uv.Rectangle
@@ -2208,6 +2318,19 @@ func isWhitespace(b byte) bool {
return b == ' ' || b == '\t' || b == '\n' || b == '\r'
}
+// isAgentBusy returns true if the agent coordinator exists and is currently
+// busy processing a request.
+func (m *UI) isAgentBusy() bool {
+ return m.com.App != nil &&
+ m.com.App.AgentCoordinator != nil &&
+ m.com.App.AgentCoordinator.IsBusy()
+}
+
+// hasSession returns true if there is an active session with a valid ID.
+func (m *UI) hasSession() bool {
+ return m.session != nil && m.session.ID != ""
+}
+
// mimeOf detects the MIME type of the given content.
func mimeOf(content []byte) string {
mimeBufferSize := min(512, len(content))
@@ -2271,7 +2394,7 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.
}
var cmds []tea.Cmd
- if m.session == nil || m.session.ID == "" {
+ if !m.hasSession() {
newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
if err != nil {
return uiutil.ReportError(err)
@@ -2319,7 +2442,7 @@ func cancelTimerCmd() tea.Cmd {
// and starts a timer. The second press (before the timer expires) actually
// cancels the agent.
func (m *UI) cancelAgent() tea.Cmd {
- if m.session == nil || m.session.ID == "" {
+ if !m.hasSession() {
return nil
}
@@ -2332,6 +2455,9 @@ func (m *UI) cancelAgent() tea.Cmd {
// Second escape press - actually cancel the agent.
m.isCanceling = false
coordinator.Cancel(m.session.ID)
+ // Stop the spinning todo indicator.
+ m.todoIsSpinning = false
+ m.renderPills()
return nil
}
@@ -2521,7 +2647,7 @@ func (m *UI) handlePermissionNotification(notification permission.PermissionNoti
// newSession clears the current session state and prepares for a new session.
// The actual session creation happens when the user sends their first message.
func (m *UI) newSession() {
- if m.session == nil || m.session.ID == "" {
+ if !m.hasSession() {
return
}
@@ -2532,6 +2658,9 @@ func (m *UI) newSession() {
m.textarea.Focus()
m.chat.Blur()
m.chat.ClearMessages()
+ m.pillsExpanded = false
+ m.promptQueue = 0
+ m.pillsView = ""
}
// handlePasteMsg handles a paste message.