From 3f7de0eaaddd6ada99972a8c84ea74649c6fdb61 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Mon, 15 Dec 2025 18:27:17 -0500 Subject: [PATCH] feat(ui): wip: add commands dialog to show available commands --- internal/ui/dialog/commands.go | 485 ++++++++++++++++++ internal/ui/dialog/commands_item.go | 55 ++ internal/ui/dialog/dialog.go | 10 + internal/ui/dialog/sessions.go | 6 - .../ui/dialog/{items.go => sessions_item.go} | 45 +- internal/ui/model/ui.go | 37 +- internal/ui/styles/styles.go | 6 + 7 files changed, 617 insertions(+), 27 deletions(-) create mode 100644 internal/ui/dialog/commands.go create mode 100644 internal/ui/dialog/commands_item.go rename internal/ui/dialog/{items.go => sessions_item.go} (82%) diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go new file mode 100644 index 0000000000000000000000000000000000000000..447d874dbd00d25c09e98f4a78481a1cc3a490af --- /dev/null +++ b/internal/ui/dialog/commands.go @@ -0,0 +1,485 @@ +package dialog + +import ( + "fmt" + "os" + "slices" + "strings" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/catwalk/pkg/catwalk" + "github.com/charmbracelet/crush/internal/agent" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/csync" + "github.com/charmbracelet/crush/internal/tui/components/chat" + "github.com/charmbracelet/crush/internal/tui/util" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/list" + "github.com/charmbracelet/crush/internal/uicmd" +) + +// CommandsID is the identifier for the commands dialog. +const CommandsID = "commands" + +// Messages for commands +type ( + SwitchSessionsMsg struct{} + NewSessionsMsg struct{} + SwitchModelMsg struct{} + QuitMsg struct{} + OpenFilePickerMsg struct{} + ToggleHelpMsg struct{} + ToggleCompactModeMsg struct{} + ToggleThinkingMsg struct{} + OpenReasoningDialogMsg struct{} + OpenExternalEditorMsg struct{} + ToggleYoloModeMsg struct{} + CompactMsg struct { + SessionID string + } +) + +// Commands represents a dialog that shows available commands. +type Commands struct { + com *common.Common + keyMap struct { + Select, + Next, + Previous, + Tab, + Close key.Binding + } + + sessionID string // can be empty for non-session-specific commands + selected uicmd.CommandType + userCmds []uicmd.Command + mcpPrompts *csync.Slice[uicmd.Command] + + help help.Model + input textinput.Model + list *list.FilterableList + width, height int +} + +var _ Dialog = (*Commands)(nil) + +// NewCommands creates a new commands dialog. +func NewCommands(com *common.Common, sessionID string) (*Commands, error) { + commands, err := uicmd.LoadCustomCommandsFromConfig(com.Config()) + if err != nil { + return nil, err + } + + mcpPrompts := csync.NewSlice[uicmd.Command]() + mcpPrompts.SetSlice(uicmd.LoadMCPPrompts()) + + c := &Commands{ + com: com, + userCmds: commands, + selected: uicmd.SystemCommands, + mcpPrompts: mcpPrompts, + sessionID: sessionID, + } + + help := help.New() + help.Styles = com.Styles.DialogHelpStyles() + + c.help = help + + c.list = list.NewFilterableList() + c.list.Focus() + c.list.SetSelected(0) + + c.input = textinput.New() + c.input.SetVirtualCursor(false) + c.input.Placeholder = "Type to filter" + c.input.SetStyles(com.Styles.TextInput) + c.input.Focus() + + c.keyMap.Select = key.NewBinding( + key.WithKeys("enter", "ctrl+y"), + key.WithHelp("enter", "confirm"), + ) + c.keyMap.Next = key.NewBinding( + key.WithKeys("down", "ctrl+n"), + key.WithHelp("↓", "next item"), + ) + c.keyMap.Previous = key.NewBinding( + key.WithKeys("up", "ctrl+p"), + key.WithHelp("↑", "previous item"), + ) + c.keyMap.Tab = key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "switch selection"), + ) + closeKey := CloseKey + closeKey.SetHelp("esc", "cancel") + c.keyMap.Close = closeKey + + // Set initial commands + c.setCommandType(c.selected) + + return c, nil +} + +// SetSize sets the size of the dialog. +func (c *Commands) SetSize(width, height int) { + c.width = width + c.height = height + innerWidth := width - c.com.Styles.Dialog.View.GetHorizontalFrameSize() + c.input.SetWidth(innerWidth - c.com.Styles.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) + c.list.SetSize(innerWidth, height-6) // (1) title + (3) input + (1) padding + (1) help + c.help.SetWidth(width) +} + +// ID implements Dialog. +func (c *Commands) ID() string { + return CommandsID +} + +// Update implements Dialog. +func (c *Commands) Update(msg tea.Msg) tea.Cmd { + switch msg := msg.(type) { + case tea.KeyPressMsg: + switch { + case key.Matches(msg, c.keyMap.Previous): + c.list.Focus() + c.list.SelectPrev() + c.list.ScrollToSelected() + case key.Matches(msg, c.keyMap.Next): + c.list.Focus() + c.list.SelectNext() + c.list.ScrollToSelected() + case key.Matches(msg, c.keyMap.Select): + if selectedItem := c.list.SelectedItem(); selectedItem != nil { + if item, ok := selectedItem.(*CommandItem); ok && item != nil { + return item.Cmd.Handler(item.Cmd) // Huh?? + } + } + case key.Matches(msg, c.keyMap.Tab): + if len(c.userCmds) > 0 || c.mcpPrompts.Len() > 0 { + c.selected = c.nextCommandType() + c.setCommandType(c.selected) + } + default: + var cmd tea.Cmd + c.input, cmd = c.input.Update(msg) + // Update the list filter + c.list.SetFilter(c.input.Value()) + return cmd + } + } + return nil +} + +// ReloadMCPPrompts reloads the MCP prompts. +func (c *Commands) ReloadMCPPrompts() tea.Cmd { + c.mcpPrompts.SetSlice(uicmd.LoadMCPPrompts()) + // If we're currently viewing MCP prompts, refresh the list + if c.selected == uicmd.MCPPrompts { + c.setCommandType(uicmd.MCPPrompts) + } + return nil +} + +// Cursor returns the cursor position relative to the dialog. +func (c *Commands) Cursor() *tea.Cursor { + return c.input.Cursor() +} + +// View implements [Dialog]. +func (c *Commands) View() string { + t := c.com.Styles + selectedFn := func(t uicmd.CommandType) string { + if t == c.selected { + return "◉ " + t.String() + } + return "○ " + t.String() + } + + parts := []string{ + selectedFn(uicmd.SystemCommands), + } + if len(c.userCmds) > 0 { + parts = append(parts, selectedFn(uicmd.UserCommands)) + } + if c.mcpPrompts.Len() > 0 { + parts = append(parts, selectedFn(uicmd.MCPPrompts)) + } + + radio := strings.Join(parts, " ") + radio = t.Dialog.Commands.CommandTypeSelector.Render(radio) + if len(c.userCmds) > 0 || c.mcpPrompts.Len() > 0 { + radio = " " + radio + } + + titleStyle := t.Dialog.Title + helpStyle := t.Dialog.HelpView + dialogStyle := t.Dialog.View.Width(c.width) + inputStyle := t.Dialog.InputPrompt + helpStyle = helpStyle.Width(c.width - dialogStyle.GetHorizontalFrameSize()) + + headerOffset := lipgloss.Width(radio) + titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize() + header := common.DialogTitle(t, "Commands", c.width-headerOffset) + radio + title := titleStyle.Render(header) + help := helpStyle.Render(c.help.View(c)) + listContent := c.list.Render() + if nlines := lipgloss.Height(listContent); nlines < c.list.Height() { + // pad the list content to avoid jumping when navigating + listContent += strings.Repeat("\n", max(0, c.list.Height()-nlines)) + } + + content := strings.Join([]string{ + title, + "", + inputStyle.Render(c.input.View()), + "", + c.list.Render(), + "", + help, + }, "\n") + + return dialogStyle.Render(content) +} + +// ShortHelp implements [help.KeyMap]. +func (c *Commands) ShortHelp() []key.Binding { + upDown := key.NewBinding( + key.WithKeys("up", "down"), + key.WithHelp("↑/↓", "choose"), + ) + return []key.Binding{ + c.keyMap.Tab, + upDown, + c.keyMap.Select, + c.keyMap.Close, + } +} + +// FullHelp implements [help.KeyMap]. +func (c *Commands) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {c.keyMap.Select, c.keyMap.Next, c.keyMap.Previous, c.keyMap.Tab}, + {c.keyMap.Close}, + } +} + +func (c *Commands) nextCommandType() uicmd.CommandType { + switch c.selected { + case uicmd.SystemCommands: + if len(c.userCmds) > 0 { + return uicmd.UserCommands + } + if c.mcpPrompts.Len() > 0 { + return uicmd.MCPPrompts + } + fallthrough + case uicmd.UserCommands: + if c.mcpPrompts.Len() > 0 { + return uicmd.MCPPrompts + } + fallthrough + case uicmd.MCPPrompts: + return uicmd.SystemCommands + default: + return uicmd.SystemCommands + } +} + +func (c *Commands) setCommandType(commandType uicmd.CommandType) { + c.selected = commandType + + var commands []uicmd.Command + switch c.selected { + case uicmd.SystemCommands: + commands = c.defaultCommands() + case uicmd.UserCommands: + commands = c.userCmds + case uicmd.MCPPrompts: + commands = slices.Collect(c.mcpPrompts.Seq()) + } + + commandItems := []list.FilterableItem{} + for _, cmd := range commands { + commandItems = append(commandItems, NewCommandItem(c.com.Styles, cmd)) + } + + c.list.SetItems(commandItems...) + // Reset selection and filter + c.list.SetSelected(0) + c.input.SetValue("") +} + +// TODO: Rethink this +func (c *Commands) defaultCommands() []uicmd.Command { + commands := []uicmd.Command{ + { + ID: "new_session", + Title: "New Session", + Description: "start a new session", + Shortcut: "ctrl+n", + Handler: func(cmd uicmd.Command) tea.Cmd { + return util.CmdHandler(NewSessionsMsg{}) + }, + }, + { + ID: "switch_session", + Title: "Switch Session", + Description: "Switch to a different session", + Shortcut: "ctrl+s", + Handler: func(cmd uicmd.Command) tea.Cmd { + return util.CmdHandler(SwitchSessionsMsg{}) + }, + }, + { + ID: "switch_model", + Title: "Switch Model", + Description: "Switch to a different model", + Shortcut: "ctrl+l", + Handler: func(cmd uicmd.Command) tea.Cmd { + return util.CmdHandler(SwitchModelMsg{}) + }, + }, + } + + // Only show compact command if there's an active session + if c.sessionID != "" { + commands = append(commands, uicmd.Command{ + ID: "Summarize", + Title: "Summarize Session", + Description: "Summarize the current session and create a new one with the summary", + Handler: func(cmd uicmd.Command) tea.Cmd { + return util.CmdHandler(CompactMsg{ + SessionID: c.sessionID, + }) + }, + }) + } + + // Add reasoning toggle for models that support it + cfg := c.com.Config() + if agentCfg, ok := cfg.Agents[config.AgentCoder]; ok { + providerCfg := cfg.GetProviderForModel(agentCfg.Model) + model := cfg.GetModelByType(agentCfg.Model) + if providerCfg != nil && model != nil && model.CanReason { + selectedModel := cfg.Models[agentCfg.Model] + + // Anthropic models: thinking toggle + if providerCfg.Type == catwalk.TypeAnthropic { + status := "Enable" + if selectedModel.Think { + status = "Disable" + } + commands = append(commands, uicmd.Command{ + ID: "toggle_thinking", + Title: status + " Thinking Mode", + Description: "Toggle model thinking for reasoning-capable models", + Handler: func(cmd uicmd.Command) tea.Cmd { + return util.CmdHandler(ToggleThinkingMsg{}) + }, + }) + } + + // OpenAI models: reasoning effort dialog + if len(model.ReasoningLevels) > 0 { + commands = append(commands, uicmd.Command{ + ID: "select_reasoning_effort", + Title: "Select Reasoning Effort", + Description: "Choose reasoning effort level (low/medium/high)", + Handler: func(cmd uicmd.Command) tea.Cmd { + return util.CmdHandler(OpenReasoningDialogMsg{}) + }, + }) + } + } + } + // Only show toggle compact mode command if window width is larger than compact breakpoint (90) + // TODO: Get. Rid. Of. Magic. Numbers! + if c.width > 120 && c.sessionID != "" { + commands = append(commands, uicmd.Command{ + ID: "toggle_sidebar", + Title: "Toggle Sidebar", + Description: "Toggle between compact and normal layout", + Handler: func(cmd uicmd.Command) tea.Cmd { + return util.CmdHandler(ToggleCompactModeMsg{}) + }, + }) + } + if c.sessionID != "" { + cfg := c.com.Config() + agentCfg := cfg.Agents[config.AgentCoder] + model := cfg.GetModelByType(agentCfg.Model) + if model.SupportsImages { + commands = append(commands, uicmd.Command{ + ID: "file_picker", + Title: "Open File Picker", + Shortcut: "ctrl+f", + Description: "Open file picker", + Handler: func(cmd uicmd.Command) tea.Cmd { + return util.CmdHandler(OpenFilePickerMsg{}) + }, + }) + } + } + + // Add external editor command if $EDITOR is available + // TODO: Use [tea.EnvMsg] to get environment variable instead of os.Getenv + if os.Getenv("EDITOR") != "" { + commands = append(commands, uicmd.Command{ + ID: "open_external_editor", + Title: "Open External Editor", + Shortcut: "ctrl+o", + Description: "Open external editor to compose message", + Handler: func(cmd uicmd.Command) tea.Cmd { + return util.CmdHandler(OpenExternalEditorMsg{}) + }, + }) + } + + return append(commands, []uicmd.Command{ + { + ID: "toggle_yolo", + Title: "Toggle Yolo Mode", + Description: "Toggle yolo mode", + Handler: func(cmd uicmd.Command) tea.Cmd { + return util.CmdHandler(ToggleYoloModeMsg{}) + }, + }, + { + ID: "toggle_help", + Title: "Toggle Help", + Shortcut: "ctrl+g", + Description: "Toggle help", + Handler: func(cmd uicmd.Command) tea.Cmd { + return util.CmdHandler(ToggleHelpMsg{}) + }, + }, + { + ID: "init", + Title: "Initialize Project", + Description: fmt.Sprintf("Create/Update the %s memory file", config.Get().Options.InitializeAs), + Handler: func(cmd uicmd.Command) tea.Cmd { + initPrompt, err := agent.InitializePrompt(*c.com.Config()) + if err != nil { + return util.ReportError(err) + } + return util.CmdHandler(chat.SendMsg{ + Text: initPrompt, + }) + }, + }, + { + ID: "quit", + Title: "Quit", + Description: "Quit", + Shortcut: "ctrl+c", + Handler: func(cmd uicmd.Command) tea.Cmd { + return util.CmdHandler(QuitMsg{}) + }, + }, + }...) +} diff --git a/internal/ui/dialog/commands_item.go b/internal/ui/dialog/commands_item.go new file mode 100644 index 0000000000000000000000000000000000000000..79f0aa047ee22a691d117014c33bc7e551b1a29b --- /dev/null +++ b/internal/ui/dialog/commands_item.go @@ -0,0 +1,55 @@ +package dialog + +import ( + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/crush/internal/uicmd" + "github.com/sahilm/fuzzy" +) + +// CommandItem wraps a uicmd.Command to implement the ListItem interface. +type CommandItem struct { + Cmd uicmd.Command + t *styles.Styles + m fuzzy.Match + cache map[int]string + focused bool +} + +var _ ListItem = &CommandItem{} + +// NewCommandItem creates a new CommandItem. +func NewCommandItem(t *styles.Styles, cmd uicmd.Command) *CommandItem { + return &CommandItem{ + Cmd: cmd, + t: t, + } +} + +// Filter implements ListItem. +func (c *CommandItem) Filter() string { + return c.Cmd.Title +} + +// ID implements ListItem. +func (c *CommandItem) ID() string { + return c.Cmd.ID +} + +// SetFocused implements ListItem. +func (c *CommandItem) SetFocused(focused bool) { + if c.focused != focused { + c.cache = nil + } + c.focused = focused +} + +// SetMatch implements ListItem. +func (c *CommandItem) SetMatch(m fuzzy.Match) { + c.cache = nil + c.m = m +} + +// Render implements ListItem. +func (c *CommandItem) Render(width int) string { + return renderItem(c.t, c.Cmd.Title, 0, c.focused, width, c.cache, &c.m) +} diff --git a/internal/ui/dialog/dialog.go b/internal/ui/dialog/dialog.go index 29175922b44015a31182c27e7411c91c47a7f31f..6dc30a9263cf99be1bed0037fd331135f61826b3 100644 --- a/internal/ui/dialog/dialog.go +++ b/internal/ui/dialog/dialog.go @@ -71,6 +71,16 @@ func (d *Overlay) RemoveDialog(dialogID string) { } } +// Dialog returns the dialog with the specified ID, or nil if not found. +func (d *Overlay) Dialog(dialogID string) Dialog { + for _, dialog := range d.dialogs { + if dialog.ID() == dialogID { + return dialog + } + } + return nil +} + // BringToFront brings the dialog with the specified ID to the front. func (d *Overlay) BringToFront(dialogID string) { for i, dialog := range d.dialogs { diff --git a/internal/ui/dialog/sessions.go b/internal/ui/dialog/sessions.go index ceeeda86dd306f7819ae0b8bc8f7107c6c9c00a0..26fa34d9ace6119da928249ba7753b0cd600ea4f 100644 --- a/internal/ui/dialog/sessions.go +++ b/internal/ui/dialog/sessions.go @@ -88,12 +88,6 @@ func (s *Session) SetSize(width, height int) { s.help.SetWidth(width) } -// SelectedItem returns the currently selected item. It may be nil if no item -// is selected. -func (s *Session) SelectedItem() list.Item { - return s.list.SelectedItem() -} - // ID implements Dialog. func (s *Session) ID() string { return SessionsID diff --git a/internal/ui/dialog/items.go b/internal/ui/dialog/sessions_item.go similarity index 82% rename from internal/ui/dialog/items.go rename to internal/ui/dialog/sessions_item.go index 3cdc010f9f225d2acbe1cb129c010806a9531987..dbfd939c93530dad95e2f25bce543d9a33f39f5e 100644 --- a/internal/ui/dialog/items.go +++ b/internal/ui/dialog/sessions_item.go @@ -53,50 +53,57 @@ func (s *SessionItem) SetMatch(m fuzzy.Match) { // Render returns the string representation of the session item. func (s *SessionItem) Render(width int) string { - if s.cache == nil { - s.cache = make(map[int]string) + return renderItem(s.t, s.Session.Title, s.Session.UpdatedAt, s.focused, width, s.cache, &s.m) +} + +func renderItem(t *styles.Styles, title string, updatedAt int64, focused bool, width int, cache map[int]string, m *fuzzy.Match) string { + if cache == nil { + cache = make(map[int]string) } - cached, ok := s.cache[width] + cached, ok := cache[width] if ok { return cached } - style := s.t.Dialog.NormalItem - if s.focused { - style = s.t.Dialog.SelectedItem + style := t.Dialog.NormalItem + if focused { + style = t.Dialog.SelectedItem } width -= style.GetHorizontalFrameSize() - age := humanize.Time(time.Unix(s.Session.UpdatedAt, 0)) - if s.focused { - age = s.t.Base.Render(age) - } else { - age = s.t.Subtle.Render(age) - } - age = " " + age + var age string + if updatedAt > 0 { + age = humanize.Time(time.Unix(updatedAt, 0)) + if focused { + age = t.Base.Render(age) + } else { + age = t.Subtle.Render(age) + } + + age = " " + age + } ageLen := lipgloss.Width(age) - title := s.Session.Title titleLen := lipgloss.Width(title) title = ansi.Truncate(title, max(0, width-ageLen), "…") right := lipgloss.NewStyle().AlignHorizontal(lipgloss.Right).Width(width - titleLen).Render(age) content := title - if matches := len(s.m.MatchedIndexes); matches > 0 { + if matches := len(m.MatchedIndexes); matches > 0 { var lastPos int parts := make([]string, 0) - ranges := matchedRanges(s.m.MatchedIndexes) + ranges := matchedRanges(m.MatchedIndexes) for _, rng := range ranges { start, stop := bytePosToVisibleCharPos(title, rng) if start > lastPos { parts = append(parts, title[lastPos:start]) } - // NOTE: We're using [ansi.Style] here instead of [lipgloss.Style] + // NOTE: We're using [ansi.Style] here instead of [lipglosStyle] // because we can control the underline start and stop more // precisely via [ansi.AttrUnderline] and [ansi.AttrNoUnderline] // which only affect the underline attribute without interfering - // with other styles. + // with other style parts = append(parts, ansi.NewStyle().Underline(true).String(), title[start:stop+1], @@ -112,7 +119,7 @@ func (s *SessionItem) Render(width int) string { } content = style.Render(content + right) - s.cache[width] = content + cache[width] = content return content } diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 24923955acf057458a64ca48083385926d1ad5a4..99a3ba9b942fe92a038e27d686d3d533779b628b 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -20,6 +20,7 @@ import ( "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/pubsub" "github.com/charmbracelet/crush/internal/session" + "github.com/charmbracelet/crush/internal/tui/util" "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/dialog" "github.com/charmbracelet/crush/internal/ui/logo" @@ -190,6 +191,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case sessionsLoadedMsg: sessions := dialog.NewSessions(m.com, msg.sessions...) + // TODO: Get. Rid. Of. Magic numbers! sessions.SetSize(min(120, m.width-8), 30) m.dialog.AddDialog(sessions) case dialog.SessionSelectedMsg: @@ -236,6 +238,19 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.lspStates = app.GetLSPStates() case pubsub.Event[mcp.Event]: m.mcpStates = mcp.GetStates() + if msg.Type == pubsub.UpdatedEvent && m.dialog.ContainsDialog(dialog.CommandsID) { + dia := m.dialog.Dialog(dialog.CommandsID) + if dia == nil { + break + } + + commands, ok := dia.(*dialog.Commands) + if ok { + if cmd := commands.ReloadMCPPrompts(); cmd != nil { + cmds = append(cmds, cmd) + } + } + } case tea.TerminalVersionMsg: termVersion := strings.ToLower(msg.Name) // Only enable progress bar for the following terminals. @@ -366,7 +381,23 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) { m.updateLayoutAndSize() return true case key.Matches(msg, m.keyMap.Commands): - // TODO: Implement me + if m.dialog.ContainsDialog(dialog.CommandsID) { + // Bring to front + m.dialog.BringToFront(dialog.CommandsID) + } else { + sessionID := "" + if m.session != nil { + sessionID = m.session.ID + } + commands, err := dialog.NewCommands(m.com, sessionID) + if err != nil { + cmds = append(cmds, util.ReportError(err)) + } else { + // TODO: Get. Rid. Of. Magic numbers! + commands.SetSize(min(120, m.width-8), 30) + m.dialog.AddDialog(commands) + } + } case key.Matches(msg, m.keyMap.Models): // TODO: Implement me case key.Matches(msg, m.keyMap.Sessions): @@ -389,7 +420,9 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) { updatedDialog, cmd := m.dialog.Update(msg) m.dialog = updatedDialog - cmds = append(cmds, cmd) + if cmd != nil { + cmds = append(cmds, cmd) + } return cmds } diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 8eb077db163fd3eea80eec5e6a4a625d3c3116d6..8eb3562d0221a281bfe559b22e70266da97b56b4 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -276,6 +276,10 @@ type Styles struct { NormalItem lipgloss.Style SelectedItem lipgloss.Style InputPrompt lipgloss.Style + + Commands struct { + CommandTypeSelector lipgloss.Style + } } } @@ -907,6 +911,8 @@ func DefaultStyles() Styles { s.Dialog.SelectedItem = base.Padding(0, 1).Background(primary).Foreground(fgBase) s.Dialog.InputPrompt = base.Padding(0, 1) + s.Dialog.Commands.CommandTypeSelector = base.Foreground(fgHalfMuted) + return s }