feat(ui): wip: add commands dialog to show available commands

Ayman Bagabas created

Change summary

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 
internal/ui/dialog/sessions_item.go |  45 +-
internal/ui/model/ui.go             |  37 ++
internal/ui/styles/styles.go        |   6 
7 files changed, 617 insertions(+), 27 deletions(-)

Detailed changes

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{})
+			},
+		},
+	}...)
+}

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)
+}

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 {

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

internal/ui/dialog/items.go → 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
 }
 

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
 	}
 

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
 }