From c3e9196ed126e12d675092f02f3ddd4f42f8001b Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Tue, 20 Jan 2026 15:42:52 +0100 Subject: [PATCH] refactor: pills section (#1916) --- internal/ui/chat/todos.go | 8 +- internal/ui/model/keys.go | 15 ++ internal/ui/model/pills.go | 283 +++++++++++++++++++++++++++++++++++ internal/ui/model/ui.go | 179 ++++++++++++++++++---- internal/ui/styles/styles.go | 24 ++- 5 files changed, 479 insertions(+), 30 deletions(-) create mode 100644 internal/ui/model/pills.go diff --git a/internal/ui/chat/todos.go b/internal/ui/chat/todos.go index f34e10a093b2b66d4b9993237fdbfe94fb53ecfb..5678d0e47f4c3a808c13c1dc6209f9194e9f9482 100644 --- a/internal/ui/chat/todos.go +++ b/internal/ui/chat/todos.go @@ -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 "" } diff --git a/internal/ui/model/keys.go b/internal/ui/model/keys.go index f2f3fc9106c92effe38b48dd6f664cb8617f9443..053c30aaa1b51b1fd04bc8a3e754460519336359 100644 --- a/internal/ui/model/keys.go +++ b/internal/ui/model/keys.go @@ -23,6 +23,9 @@ type KeyMap struct { Cancel key.Binding Tab key.Binding Details key.Binding + TogglePills key.Binding + PillLeft key.Binding + PillRight key.Binding Down key.Binding Up key.Binding UpDown key.Binding @@ -149,6 +152,18 @@ func DefaultKeyMap() KeyMap { key.WithKeys("ctrl+d"), key.WithHelp("ctrl+d", "toggle details"), ) + km.Chat.TogglePills = key.NewBinding( + key.WithKeys("ctrl+space"), + key.WithHelp("ctrl+space", "toggle tasks"), + ) + km.Chat.PillLeft = key.NewBinding( + key.WithKeys("left"), + key.WithHelp("←/→", "switch section"), + ) + km.Chat.PillRight = key.NewBinding( + key.WithKeys("right"), + key.WithHelp("←/→", "switch section"), + ) km.Chat.Down = key.NewBinding( key.WithKeys("down", "ctrl+j", "ctrl+n", "j"), diff --git a/internal/ui/model/pills.go b/internal/ui/model/pills.go new file mode 100644 index 0000000000000000000000000000000000000000..7662b10cc61c19b5333f7487747354341e35aa99 --- /dev/null +++ b/internal/ui/model/pills.go @@ -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) +} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 46f4b658f7509c899dfa7f35f164f34f27a1ea43..b4c173781560aee26755eefe4d5694fb44ec8807 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -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. diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 42cf3c8dbd44f8983f588bc303ef7ae142e71a70..21a2febea006c5366ba8bd7a30a17cfc1e4d0b0e 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -21,7 +21,7 @@ const ( WarningIcon string = "⚠" InfoIcon string = "ⓘ" HintIcon string = "∵" - SpinnerIcon string = "..." + SpinnerIcon string = "⋯" LoadingIcon string = "⟳" ModelIcon string = "◇" @@ -394,6 +394,18 @@ type Styles struct { Text lipgloss.Style Deleting lipgloss.Style } + + // Pills styles for todo/queue pills + Pills struct { + Base lipgloss.Style // Base pill style with padding + Focused lipgloss.Style // Focused pill with visible border + Blurred lipgloss.Style // Blurred pill with hidden border + QueueItemPrefix lipgloss.Style // Prefix for queue list items + HelpKey lipgloss.Style // Keystroke hint style + HelpText lipgloss.Style // Help action text style + Area lipgloss.Style // Pills area container + TodoSpinner lipgloss.Style // Todo spinner style + } } // ChromaTheme converts the current markdown chroma styles to a chroma @@ -1270,6 +1282,16 @@ func DefaultStyles() Styles { s.Attachments.Normal = base.Padding(0, 1).MarginRight(1).Background(fgMuted).Foreground(fgBase) s.Attachments.Deleting = base.Padding(0, 1).Bold(true).Background(red).Foreground(fgBase) + // Pills styles + s.Pills.Base = base.Padding(0, 1) + s.Pills.Focused = base.Padding(0, 1).BorderStyle(lipgloss.RoundedBorder()).BorderForeground(bgOverlay) + s.Pills.Blurred = base.Padding(0, 1).BorderStyle(lipgloss.HiddenBorder()) + s.Pills.QueueItemPrefix = s.Muted.SetString(" •") + s.Pills.HelpKey = s.Muted + s.Pills.HelpText = s.Subtle + s.Pills.Area = base + s.Pills.TodoSpinner = base.Foreground(greenDark) + return s }