on main help sign in
feat(ui): wip: add commands dialog to show available commands
commit3f7de0eaaddd6ada99972a8c84ea74649c6fdb61 authorAyman Bagabas <ayman.bagabas@gmail.com> dateMon 15 Dec 2025, 18:27 (1 year ago) parentb2d6a8a
7 files changed+617−27
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/items.go → internal/ui/dialog/sessions_item.go+26−19 internal/ui/model/ui.go+35−2 internal/ui/styles/styles.go+6
internal/ui/dialog/commands.gonew file+485
@@ -0,0 +1,485 @@
1+package dialog
2+
3+import (
4+ "fmt"
5+ "os"
6+ "slices"
7+ "strings"
8+
9+ "charm.land/bubbles/v2/help"
10+ "charm.land/bubbles/v2/key"
11+ "charm.land/bubbles/v2/textinput"
12+ tea "charm.land/bubbletea/v2"
13+ "charm.land/lipgloss/v2"
14+ "github.com/charmbracelet/catwalk/pkg/catwalk"
15+ "github.com/charmbracelet/crush/internal/agent"
16+ "github.com/charmbracelet/crush/internal/config"
17+ "github.com/charmbracelet/crush/internal/csync"
18+ "github.com/charmbracelet/crush/internal/tui/components/chat"
19+ "github.com/charmbracelet/crush/internal/tui/util"
20+ "github.com/charmbracelet/crush/internal/ui/common"
21+ "github.com/charmbracelet/crush/internal/ui/list"
22+ "github.com/charmbracelet/crush/internal/uicmd"
23+)
24+
25+// CommandsID is the identifier for the commands dialog.
26+const CommandsID = "commands"
27+
28+// Messages for commands
29+type (
30+ SwitchSessionsMsg struct{}
31+ NewSessionsMsg struct{}
32+ SwitchModelMsg struct{}
33+ QuitMsg struct{}
34+ OpenFilePickerMsg struct{}
35+ ToggleHelpMsg struct{}
36+ ToggleCompactModeMsg struct{}
37+ ToggleThinkingMsg struct{}
38+ OpenReasoningDialogMsg struct{}
39+ OpenExternalEditorMsg struct{}
40+ ToggleYoloModeMsg struct{}
41+ CompactMsg struct {
42+ SessionID string
43+ }
44+)
45+
46+// Commands represents a dialog that shows available commands.
47+type Commands struct {
48+ com *common.Common
49+ keyMap struct {
50+ Select,
51+ Next,
52+ Previous,
53+ Tab,
54+ Close key.Binding
55+ }
56+
57+ sessionID string // can be empty for non-session-specific commands
58+ selected uicmd.CommandType
59+ userCmds []uicmd.Command
60+ mcpPrompts *csync.Slice[uicmd.Command]
61+
62+ help help.Model
63+ input textinput.Model
64+ list *list.FilterableList
65+ width, height int
66+}
67+
68+var _ Dialog = (*Commands)(nil)
69+
70+// NewCommands creates a new commands dialog.
71+func NewCommands(com *common.Common, sessionID string) (*Commands, error) {
72+ commands, err := uicmd.LoadCustomCommandsFromConfig(com.Config())
73+ if err != nil {
74+ return nil, err
75+ }
76+
77+ mcpPrompts := csync.NewSlice[uicmd.Command]()
78+ mcpPrompts.SetSlice(uicmd.LoadMCPPrompts())
79+
80+ c := &Commands{
81+ com: com,
82+ userCmds: commands,
83+ selected: uicmd.SystemCommands,
84+ mcpPrompts: mcpPrompts,
85+ sessionID: sessionID,
86+ }
87+
88+ help := help.New()
89+ help.Styles = com.Styles.DialogHelpStyles()
90+
91+ c.help = help
92+
93+ c.list = list.NewFilterableList()
94+ c.list.Focus()
95+ c.list.SetSelected(0)
96+
97+ c.input = textinput.New()
98+ c.input.SetVirtualCursor(false)
99+ c.input.Placeholder = "Type to filter"
100+ c.input.SetStyles(com.Styles.TextInput)
101+ c.input.Focus()
102+
103+ c.keyMap.Select = key.NewBinding(
104+ key.WithKeys("enter", "ctrl+y"),
105+ key.WithHelp("enter", "confirm"),
106+ )
107+ c.keyMap.Next = key.NewBinding(
108+ key.WithKeys("down", "ctrl+n"),
109+ key.WithHelp("↓", "next item"),
110+ )
111+ c.keyMap.Previous = key.NewBinding(
112+ key.WithKeys("up", "ctrl+p"),
113+ key.WithHelp("↑", "previous item"),
114+ )
115+ c.keyMap.Tab = key.NewBinding(
116+ key.WithKeys("tab"),
117+ key.WithHelp("tab", "switch selection"),
118+ )
119+ closeKey := CloseKey
120+ closeKey.SetHelp("esc", "cancel")
121+ c.keyMap.Close = closeKey
122+
123+ // Set initial commands
124+ c.setCommandType(c.selected)
125+
126+ return c, nil
127+}
128+
129+// SetSize sets the size of the dialog.
130+func (c *Commands) SetSize(width, height int) {
131+ c.width = width
132+ c.height = height
133+ innerWidth := width - c.com.Styles.Dialog.View.GetHorizontalFrameSize()
134+ c.input.SetWidth(innerWidth - c.com.Styles.Dialog.InputPrompt.GetHorizontalFrameSize() - 1)
135+ c.list.SetSize(innerWidth, height-6) // (1) title + (3) input + (1) padding + (1) help
136+ c.help.SetWidth(width)
137+}
138+
139+// ID implements Dialog.
140+func (c *Commands) ID() string {
141+ return CommandsID
142+}
143+
144+// Update implements Dialog.
145+func (c *Commands) Update(msg tea.Msg) tea.Cmd {
146+ switch msg := msg.(type) {
147+ case tea.KeyPressMsg:
148+ switch {
149+ case key.Matches(msg, c.keyMap.Previous):
150+ c.list.Focus()
151+ c.list.SelectPrev()
152+ c.list.ScrollToSelected()
153+ case key.Matches(msg, c.keyMap.Next):
154+ c.list.Focus()
155+ c.list.SelectNext()
156+ c.list.ScrollToSelected()
157+ case key.Matches(msg, c.keyMap.Select):
158+ if selectedItem := c.list.SelectedItem(); selectedItem != nil {
159+ if item, ok := selectedItem.(*CommandItem); ok && item != nil {
160+ return item.Cmd.Handler(item.Cmd) // Huh??
161+ }
162+ }
163+ case key.Matches(msg, c.keyMap.Tab):
164+ if len(c.userCmds) > 0 || c.mcpPrompts.Len() > 0 {
165+ c.selected = c.nextCommandType()
166+ c.setCommandType(c.selected)
167+ }
168+ default:
169+ var cmd tea.Cmd
170+ c.input, cmd = c.input.Update(msg)
171+ // Update the list filter
172+ c.list.SetFilter(c.input.Value())
173+ return cmd
174+ }
175+ }
176+ return nil
177+}
178+
179+// ReloadMCPPrompts reloads the MCP prompts.
180+func (c *Commands) ReloadMCPPrompts() tea.Cmd {
181+ c.mcpPrompts.SetSlice(uicmd.LoadMCPPrompts())
182+ // If we're currently viewing MCP prompts, refresh the list
183+ if c.selected == uicmd.MCPPrompts {
184+ c.setCommandType(uicmd.MCPPrompts)
185+ }
186+ return nil
187+}
188+
189+// Cursor returns the cursor position relative to the dialog.
190+func (c *Commands) Cursor() *tea.Cursor {
191+ return c.input.Cursor()
192+}
193+
194+// View implements [Dialog].
195+func (c *Commands) View() string {
196+ t := c.com.Styles
197+ selectedFn := func(t uicmd.CommandType) string {
198+ if t == c.selected {
199+ return "◉ " + t.String()
200+ }
201+ return "○ " + t.String()
202+ }
203+
204+ parts := []string{
205+ selectedFn(uicmd.SystemCommands),
206+ }
207+ if len(c.userCmds) > 0 {
208+ parts = append(parts, selectedFn(uicmd.UserCommands))
209+ }
210+ if c.mcpPrompts.Len() > 0 {
211+ parts = append(parts, selectedFn(uicmd.MCPPrompts))
212+ }
213+
214+ radio := strings.Join(parts, " ")
215+ radio = t.Dialog.Commands.CommandTypeSelector.Render(radio)
216+ if len(c.userCmds) > 0 || c.mcpPrompts.Len() > 0 {
217+ radio = " " + radio
218+ }
219+
220+ titleStyle := t.Dialog.Title
221+ helpStyle := t.Dialog.HelpView
222+ dialogStyle := t.Dialog.View.Width(c.width)
223+ inputStyle := t.Dialog.InputPrompt
224+ helpStyle = helpStyle.Width(c.width - dialogStyle.GetHorizontalFrameSize())
225+
226+ headerOffset := lipgloss.Width(radio) + titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
227+ header := common.DialogTitle(t, "Commands", c.width-headerOffset) + radio
228+ title := titleStyle.Render(header)
229+ help := helpStyle.Render(c.help.View(c))
230+ listContent := c.list.Render()
231+ if nlines := lipgloss.Height(listContent); nlines < c.list.Height() {
232+ // pad the list content to avoid jumping when navigating
233+ listContent += strings.Repeat("\n", max(0, c.list.Height()-nlines))
234+ }
235+
236+ content := strings.Join([]string{
237+ title,
238+ "",
239+ inputStyle.Render(c.input.View()),
240+ "",
241+ c.list.Render(),
242+ "",
243+ help,
244+ }, "\n")
245+
246+ return dialogStyle.Render(content)
247+}
248+
249+// ShortHelp implements [help.KeyMap].
250+func (c *Commands) ShortHelp() []key.Binding {
251+ upDown := key.NewBinding(
252+ key.WithKeys("up", "down"),
253+ key.WithHelp("↑/↓", "choose"),
254+ )
255+ return []key.Binding{
256+ c.keyMap.Tab,
257+ upDown,
258+ c.keyMap.Select,
259+ c.keyMap.Close,
260+ }
261+}
262+
263+// FullHelp implements [help.KeyMap].
264+func (c *Commands) FullHelp() [][]key.Binding {
265+ return [][]key.Binding{
266+ {c.keyMap.Select, c.keyMap.Next, c.keyMap.Previous, c.keyMap.Tab},
267+ {c.keyMap.Close},
268+ }
269+}
270+
271+func (c *Commands) nextCommandType() uicmd.CommandType {
272+ switch c.selected {
273+ case uicmd.SystemCommands:
274+ if len(c.userCmds) > 0 {
275+ return uicmd.UserCommands
276+ }
277+ if c.mcpPrompts.Len() > 0 {
278+ return uicmd.MCPPrompts
279+ }
280+ fallthrough
281+ case uicmd.UserCommands:
282+ if c.mcpPrompts.Len() > 0 {
283+ return uicmd.MCPPrompts
284+ }
285+ fallthrough
286+ case uicmd.MCPPrompts:
287+ return uicmd.SystemCommands
288+ default:
289+ return uicmd.SystemCommands
290+ }
291+}
292+
293+func (c *Commands) setCommandType(commandType uicmd.CommandType) {
294+ c.selected = commandType
295+
296+ var commands []uicmd.Command
297+ switch c.selected {
298+ case uicmd.SystemCommands:
299+ commands = c.defaultCommands()
300+ case uicmd.UserCommands:
301+ commands = c.userCmds
302+ case uicmd.MCPPrompts:
303+ commands = slices.Collect(c.mcpPrompts.Seq())
304+ }
305+
306+ commandItems := []list.FilterableItem{}
307+ for _, cmd := range commands {
308+ commandItems = append(commandItems, NewCommandItem(c.com.Styles, cmd))
309+ }
310+
311+ c.list.SetItems(commandItems...)
312+ // Reset selection and filter
313+ c.list.SetSelected(0)
314+ c.input.SetValue("")
315+}
316+
317+// TODO: Rethink this
318+func (c *Commands) defaultCommands() []uicmd.Command {
319+ commands := []uicmd.Command{
320+ {
321+ ID: "new_session",
322+ Title: "New Session",
323+ Description: "start a new session",
324+ Shortcut: "ctrl+n",
325+ Handler: func(cmd uicmd.Command) tea.Cmd {
326+ return util.CmdHandler(NewSessionsMsg{})
327+ },
328+ },
329+ {
330+ ID: "switch_session",
331+ Title: "Switch Session",
332+ Description: "Switch to a different session",
333+ Shortcut: "ctrl+s",
334+ Handler: func(cmd uicmd.Command) tea.Cmd {
335+ return util.CmdHandler(SwitchSessionsMsg{})
336+ },
337+ },
338+ {
339+ ID: "switch_model",
340+ Title: "Switch Model",
341+ Description: "Switch to a different model",
342+ Shortcut: "ctrl+l",
343+ Handler: func(cmd uicmd.Command) tea.Cmd {
344+ return util.CmdHandler(SwitchModelMsg{})
345+ },
346+ },
347+ }
348+
349+ // Only show compact command if there's an active session
350+ if c.sessionID != "" {
351+ commands = append(commands, uicmd.Command{
352+ ID: "Summarize",
353+ Title: "Summarize Session",
354+ Description: "Summarize the current session and create a new one with the summary",
355+ Handler: func(cmd uicmd.Command) tea.Cmd {
356+ return util.CmdHandler(CompactMsg{
357+ SessionID: c.sessionID,
358+ })
359+ },
360+ })
361+ }
362+
363+ // Add reasoning toggle for models that support it
364+ cfg := c.com.Config()
365+ if agentCfg, ok := cfg.Agents[config.AgentCoder]; ok {
366+ providerCfg := cfg.GetProviderForModel(agentCfg.Model)
367+ model := cfg.GetModelByType(agentCfg.Model)
368+ if providerCfg != nil && model != nil && model.CanReason {
369+ selectedModel := cfg.Models[agentCfg.Model]
370+
371+ // Anthropic models: thinking toggle
372+ if providerCfg.Type == catwalk.TypeAnthropic {
373+ status := "Enable"
374+ if selectedModel.Think {
375+ status = "Disable"
376+ }
377+ commands = append(commands, uicmd.Command{
378+ ID: "toggle_thinking",
379+ Title: status + " Thinking Mode",
380+ Description: "Toggle model thinking for reasoning-capable models",
381+ Handler: func(cmd uicmd.Command) tea.Cmd {
382+ return util.CmdHandler(ToggleThinkingMsg{})
383+ },
384+ })
385+ }
386+
387+ // OpenAI models: reasoning effort dialog
388+ if len(model.ReasoningLevels) > 0 {
389+ commands = append(commands, uicmd.Command{
390+ ID: "select_reasoning_effort",
391+ Title: "Select Reasoning Effort",
392+ Description: "Choose reasoning effort level (low/medium/high)",
393+ Handler: func(cmd uicmd.Command) tea.Cmd {
394+ return util.CmdHandler(OpenReasoningDialogMsg{})
395+ },
396+ })
397+ }
398+ }
399+ }
400+ // Only show toggle compact mode command if window width is larger than compact breakpoint (90)
401+ // TODO: Get. Rid. Of. Magic. Numbers!
402+ if c.width > 120 && c.sessionID != "" {
403+ commands = append(commands, uicmd.Command{
404+ ID: "toggle_sidebar",
405+ Title: "Toggle Sidebar",
406+ Description: "Toggle between compact and normal layout",
407+ Handler: func(cmd uicmd.Command) tea.Cmd {
408+ return util.CmdHandler(ToggleCompactModeMsg{})
409+ },
410+ })
411+ }
412+ if c.sessionID != "" {
413+ cfg := c.com.Config()
414+ agentCfg := cfg.Agents[config.AgentCoder]
415+ model := cfg.GetModelByType(agentCfg.Model)
416+ if model.SupportsImages {
417+ commands = append(commands, uicmd.Command{
418+ ID: "file_picker",
419+ Title: "Open File Picker",
420+ Shortcut: "ctrl+f",
421+ Description: "Open file picker",
422+ Handler: func(cmd uicmd.Command) tea.Cmd {
423+ return util.CmdHandler(OpenFilePickerMsg{})
424+ },
425+ })
426+ }
427+ }
428+
429+ // Add external editor command if $EDITOR is available
430+ // TODO: Use [tea.EnvMsg] to get environment variable instead of os.Getenv
431+ if os.Getenv("EDITOR") != "" {
432+ commands = append(commands, uicmd.Command{
433+ ID: "open_external_editor",
434+ Title: "Open External Editor",
435+ Shortcut: "ctrl+o",
436+ Description: "Open external editor to compose message",
437+ Handler: func(cmd uicmd.Command) tea.Cmd {
438+ return util.CmdHandler(OpenExternalEditorMsg{})
439+ },
440+ })
441+ }
442+
443+ return append(commands, []uicmd.Command{
444+ {
445+ ID: "toggle_yolo",
446+ Title: "Toggle Yolo Mode",
447+ Description: "Toggle yolo mode",
448+ Handler: func(cmd uicmd.Command) tea.Cmd {
449+ return util.CmdHandler(ToggleYoloModeMsg{})
450+ },
451+ },
452+ {
453+ ID: "toggle_help",
454+ Title: "Toggle Help",
455+ Shortcut: "ctrl+g",
456+ Description: "Toggle help",
457+ Handler: func(cmd uicmd.Command) tea.Cmd {
458+ return util.CmdHandler(ToggleHelpMsg{})
459+ },
460+ },
461+ {
462+ ID: "init",
463+ Title: "Initialize Project",
464+ Description: fmt.Sprintf("Create/Update the %s memory file", config.Get().Options.InitializeAs),
465+ Handler: func(cmd uicmd.Command) tea.Cmd {
466+ initPrompt, err := agent.InitializePrompt(*c.com.Config())
467+ if err != nil {
468+ return util.ReportError(err)
469+ }
470+ return util.CmdHandler(chat.SendMsg{
471+ Text: initPrompt,
472+ })
473+ },
474+ },
475+ {
476+ ID: "quit",
477+ Title: "Quit",
478+ Description: "Quit",
479+ Shortcut: "ctrl+c",
480+ Handler: func(cmd uicmd.Command) tea.Cmd {
481+ return util.CmdHandler(QuitMsg{})
482+ },
483+ },
484+ }...)
485+}
internal/ui/dialog/commands_item.gonew file+55
@@ -0,0 +1,55 @@
1+package dialog
2+
3+import (
4+ "github.com/charmbracelet/crush/internal/ui/styles"
5+ "github.com/charmbracelet/crush/internal/uicmd"
6+ "github.com/sahilm/fuzzy"
7+)
8+
9+// CommandItem wraps a uicmd.Command to implement the ListItem interface.
10+type CommandItem struct {
11+ Cmd uicmd.Command
12+ t *styles.Styles
13+ m fuzzy.Match
14+ cache map[int]string
15+ focused bool
16+}
17+
18+var _ ListItem = &CommandItem{}
19+
20+// NewCommandItem creates a new CommandItem.
21+func NewCommandItem(t *styles.Styles, cmd uicmd.Command) *CommandItem {
22+ return &CommandItem{
23+ Cmd: cmd,
24+ t: t,
25+ }
26+}
27+
28+// Filter implements ListItem.
29+func (c *CommandItem) Filter() string {
30+ return c.Cmd.Title
31+}
32+
33+// ID implements ListItem.
34+func (c *CommandItem) ID() string {
35+ return c.Cmd.ID
36+}
37+
38+// SetFocused implements ListItem.
39+func (c *CommandItem) SetFocused(focused bool) {
40+ if c.focused != focused {
41+ c.cache = nil
42+ }
43+ c.focused = focused
44+}
45+
46+// SetMatch implements ListItem.
47+func (c *CommandItem) SetMatch(m fuzzy.Match) {
48+ c.cache = nil
49+ c.m = m
50+}
51+
52+// Render implements ListItem.
53+func (c *CommandItem) Render(width int) string {
54+ return renderItem(c.t, c.Cmd.Title, 0, c.focused, width, c.cache, &c.m)
55+}
internal/ui/dialog/dialog.go+10
@@ -71,6 +71,16 @@ func (d *Overlay) RemoveDialog(dialogID string) {
7171 }
7272}
7373
74+// Dialog returns the dialog with the specified ID, or nil if not found.
75+func (d *Overlay) Dialog(dialogID string) Dialog {
76+ for _, dialog := range d.dialogs {
77+ if dialog.ID() == dialogID {
78+ return dialog
79+ }
80+ }
81+ return nil
82+}
83+
7484// BringToFront brings the dialog with the specified ID to the front.
7585func (d *Overlay) BringToFront(dialogID string) {
7686 for i, dialog := range d.dialogs {
internal/ui/dialog/sessions.go−6
@@ -88,12 +88,6 @@ func (s *Session) SetSize(width, height int) {
8888 s.help.SetWidth(width)
8989}
9090
91−// SelectedItem returns the currently selected item. It may be nil if no item
92−// is selected.
93−func (s *Session) SelectedItem() list.Item {
94− return s.list.SelectedItem()
95−}
96−
9791// ID implements Dialog.
9892func (s *Session) ID() string {
9993 return SessionsID
internal/ui/dialog/items.go → internal/ui/dialog/sessions_item.gorenamed+26−19
@@ -53,50 +53,57 @@ func (s *SessionItem) SetMatch(m fuzzy.Match) {
5353
5454// Render returns the string representation of the session item.
5555func (s *SessionItem) Render(width int) string {
56− if s.cache == nil {
57− s.cache = make(map[int]string)
56+ return renderItem(s.t, s.Session.Title, s.Session.UpdatedAt, s.focused, width, s.cache, &s.m)
57+}
58+
59+func renderItem(t *styles.Styles, title string, updatedAt int64, focused bool, width int, cache map[int]string, m *fuzzy.Match) string {
60+ if cache == nil {
61+ cache = make(map[int]string)
5862 }
5963
60− cached, ok := s.cache[width]
64+ cached, ok := cache[width]
6165 if ok {
6266 return cached
6367 }
6468
65− style := s.t.Dialog.NormalItem
66− if s.focused {
67− style = s.t.Dialog.SelectedItem
69+ style := t.Dialog.NormalItem
70+ if focused {
71+ style = t.Dialog.SelectedItem
6872 }
6973
7074 width -= style.GetHorizontalFrameSize()
71− age := humanize.Time(time.Unix(s.Session.UpdatedAt, 0))
72− if s.focused {
73− age = s.t.Base.Render(age)
74− } else {
75− age = s.t.Subtle.Render(age)
76− }
7775
78− age = " " + age
76+ var age string
77+ if updatedAt > 0 {
78+ age = humanize.Time(time.Unix(updatedAt, 0))
79+ if focused {
80+ age = t.Base.Render(age)
81+ } else {
82+ age = t.Subtle.Render(age)
83+ }
84+
85+ age = " " + age
86+ }
7987 ageLen := lipgloss.Width(age)
80− title := s.Session.Title
8188 titleLen := lipgloss.Width(title)
8289 title = ansi.Truncate(title, max(0, width-ageLen), "…")
8390 right := lipgloss.NewStyle().AlignHorizontal(lipgloss.Right).Width(width - titleLen).Render(age)
8491
8592 content := title
86− if matches := len(s.m.MatchedIndexes); matches > 0 {
93+ if matches := len(m.MatchedIndexes); matches > 0 {
8794 var lastPos int
8895 parts := make([]string, 0)
89− ranges := matchedRanges(s.m.MatchedIndexes)
96+ ranges := matchedRanges(m.MatchedIndexes)
9097 for _, rng := range ranges {
9198 start, stop := bytePosToVisibleCharPos(title, rng)
9299 if start > lastPos {
93100 parts = append(parts, title[lastPos:start])
94101 }
95− // NOTE: We're using [ansi.Style] here instead of [lipgloss.Style]
102+ // NOTE: We're using [ansi.Style] here instead of [lipglosStyle]
96103 // because we can control the underline start and stop more
97104 // precisely via [ansi.AttrUnderline] and [ansi.AttrNoUnderline]
98105 // which only affect the underline attribute without interfering
99− // with other styles.
106+ // with other style
100107 parts = append(parts,
101108 ansi.NewStyle().Underline(true).String(),
102109 title[start:stop+1],
@@ -112,7 +119,7 @@ func (s *SessionItem) Render(width int) string {
112119 }
113120
114121 content = style.Render(content + right)
115− s.cache[width] = content
122+ cache[width] = content
116123 return content
117124}
118125
internal/ui/model/ui.go+35−2
@@ -20,6 +20,7 @@ import (
2020 "github.com/charmbracelet/crush/internal/message"
2121 "github.com/charmbracelet/crush/internal/pubsub"
2222 "github.com/charmbracelet/crush/internal/session"
23+ "github.com/charmbracelet/crush/internal/tui/util"
2324 "github.com/charmbracelet/crush/internal/ui/common"
2425 "github.com/charmbracelet/crush/internal/ui/dialog"
2526 "github.com/charmbracelet/crush/internal/ui/logo"
@@ -190,6 +191,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
190191 }
191192 case sessionsLoadedMsg:
192193 sessions := dialog.NewSessions(m.com, msg.sessions...)
194+ // TODO: Get. Rid. Of. Magic numbers!
193195 sessions.SetSize(min(120, m.width-8), 30)
194196 m.dialog.AddDialog(sessions)
195197 case dialog.SessionSelectedMsg:
@@ -236,6 +238,19 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
236238 m.lspStates = app.GetLSPStates()
237239 case pubsub.Event[mcp.Event]:
238240 m.mcpStates = mcp.GetStates()
241+ if msg.Type == pubsub.UpdatedEvent && m.dialog.ContainsDialog(dialog.CommandsID) {
242+ dia := m.dialog.Dialog(dialog.CommandsID)
243+ if dia == nil {
244+ break
245+ }
246+
247+ commands, ok := dia.(*dialog.Commands)
248+ if ok {
249+ if cmd := commands.ReloadMCPPrompts(); cmd != nil {
250+ cmds = append(cmds, cmd)
251+ }
252+ }
253+ }
239254 case tea.TerminalVersionMsg:
240255 termVersion := strings.ToLower(msg.Name)
241256 // Only enable progress bar for the following terminals.
@@ -366,7 +381,23 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
366381 m.updateLayoutAndSize()
367382 return true
368383 case key.Matches(msg, m.keyMap.Commands):
369− // TODO: Implement me
384+ if m.dialog.ContainsDialog(dialog.CommandsID) {
385+ // Bring to front
386+ m.dialog.BringToFront(dialog.CommandsID)
387+ } else {
388+ sessionID := ""
389+ if m.session != nil {
390+ sessionID = m.session.ID
391+ }
392+ commands, err := dialog.NewCommands(m.com, sessionID)
393+ if err != nil {
394+ cmds = append(cmds, util.ReportError(err))
395+ } else {
396+ // TODO: Get. Rid. Of. Magic numbers!
397+ commands.SetSize(min(120, m.width-8), 30)
398+ m.dialog.AddDialog(commands)
399+ }
400+ }
370401 case key.Matches(msg, m.keyMap.Models):
371402 // TODO: Implement me
372403 case key.Matches(msg, m.keyMap.Sessions):
@@ -389,7 +420,9 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
389420
390421 updatedDialog, cmd := m.dialog.Update(msg)
391422 m.dialog = updatedDialog
392− cmds = append(cmds, cmd)
423+ if cmd != nil {
424+ cmds = append(cmds, cmd)
425+ }
393426 return cmds
394427 }
395428
internal/ui/styles/styles.go+6
@@ -276,6 +276,10 @@ type Styles struct {
276276 NormalItem lipgloss.Style
277277 SelectedItem lipgloss.Style
278278 InputPrompt lipgloss.Style
279+
280+ Commands struct {
281+ CommandTypeSelector lipgloss.Style
282+ }
279283 }
280284}
281285
@@ -907,6 +911,8 @@ func DefaultStyles() Styles {
907911 s.Dialog.SelectedItem = base.Padding(0, 1).Background(primary).Foreground(fgBase)
908912 s.Dialog.InputPrompt = base.Padding(0, 1)
909913
914+ s.Dialog.Commands.CommandTypeSelector = base.Foreground(fgHalfMuted)
915+
910916 return s
911917}
912918