on main help sign in
Refactor Custom Command Arguments Dialog (#1869)
f2f63d1Kujtim Hoxha
internal/commands/commands.go+44−20
@@ -1,6 +1,7 @@
1package commands
1package commands
2
2
3import (
3import (
4+ "context"
4 "io/fs"
5 "io/fs"
5 "os"
6 "os"
6 "path/filepath"
7 "path/filepath"
@@ -19,18 +20,22 @@ const (
19 projectCommandPrefix = "project:"
20 projectCommandPrefix = "project:"
20)
21)
21
22
22−// Argument represents a command argument with its name and required status.
23+// Argument represents a command argument with its metadata.
23type Argument struct {
24type Argument struct {
25+ ID string
26+ Title string
24− Name string
27+ Description string
25− Required bool
28+ Required bool
26}
29}
27
30
28−// MCPCustomCommand represents a custom command loaded from an MCP server.
31+// MCPPrompt represents a custom command loaded from an MCP server.
29−type MCPCustomCommand struct {
32+type MCPPrompt struct {
33+ ID string
34+ Title string
30− ID string
35+ Description string
31− Name string
36+ PromptID string
32− Client string
37+ ClientID string
33− Arguments []Argument
38+ Arguments []Argument
34}
39}
35
40
36// CustomCommand represents a user-defined custom command loaded from markdown files.
41// CustomCommand represents a user-defined custom command loaded from markdown files.
@@ -52,22 +57,32 @@ func LoadCustomCommands(cfg *config.Config) ([]CustomCommand, error) {
52 return loadAll(buildCommandSources(cfg))
57 return loadAll(buildCommandSources(cfg))
53}
58}
54
59
55−// LoadMCPCustomCommands loads custom commands from available MCP servers.
60+// LoadMCPPrompts loads custom commands from available MCP servers.
56−func LoadMCPCustomCommands() ([]MCPCustomCommand, error) {
61+func LoadMCPPrompts() ([]MCPPrompt, error) {
57− var commands []MCPCustomCommand
62+ var commands []MCPPrompt
58 for mcpName, prompts := range mcp.Prompts() {
63 for mcpName, prompts := range mcp.Prompts() {
59 for _, prompt := range prompts {
64 for _, prompt := range prompts {
60 key := mcpName + ":" + prompt.Name
65 key := mcpName + ":" + prompt.Name
61 var args []Argument
66 var args []Argument
62 for _, arg := range prompt.Arguments {
67 for _, arg := range prompt.Arguments {
68+ title := arg.Title
69+ if title == "" {
70+ title = arg.Name
71+ }
63− args = append(args, Argument{Name: arg.Name, Required: arg.Required})
72+ args = append(args, Argument{
73+ ID: arg.Name,
74+ Title: title,
75+ Description: arg.Description,
76+ Required: arg.Required,
77+ })
64 }
78 }
65−
66− commands = append(commands, MCPCustomCommand{
79+ commands = append(commands, MCPPrompt{
67− ID: key,
80+ ID: key,
81+ Title: prompt.Title,
82+ Description: prompt.Description,
68− Name: prompt.Name,
83+ PromptID: prompt.Name,
69− Client: mcpName,
84+ ClientID: mcpName,
70− Arguments: args,
85+ Arguments: args,
71 })
86 })
72 }
87 }
73 }
88 }
@@ -168,7 +183,7 @@ func extractArgNames(content string) []Argument {
168 if !seen[arg] {
183 if !seen[arg] {
169 seen[arg] = true
184 seen[arg] = true
170 // for normal custom commands, all args are required
185 // for normal custom commands, all args are required
171− args = append(args, Argument{Name: arg, Required: true})
186+ args = append(args, Argument{ID: arg, Title: arg, Required: true})
172 }
187 }
173 }
188 }
174
189
@@ -211,3 +226,12 @@ func ensureDir(path string) error {
211func isMarkdownFile(name string) bool {
226func isMarkdownFile(name string) bool {
212 return strings.HasSuffix(strings.ToLower(name), ".md")
227 return strings.HasSuffix(strings.ToLower(name), ".md")
213}
228}
229+
230+func GetMCPPrompt(clientID, promptID string, args map[string]string) (string, error) {
231+ // TODO: we should pass the context down
232+ result, err := mcp.GetPromptMessages(context.Background(), clientID, promptID, args)
233+ if err != nil {
234+ return "", err
235+ }
236+ return strings.Join(result, " "), nil
237+}
internal/ui/dialog/actions.go+11−13
@@ -51,20 +51,18 @@ type (
51 }
51 }
52 // ActionRunCustomCommand is a message to run a custom command.
52 // ActionRunCustomCommand is a message to run a custom command.
53 ActionRunCustomCommand struct {
53 ActionRunCustomCommand struct {
54− CommandID string
55− // Used when running a user-defined command
56− Content string
54+ Content string
57− // Used when running a prompt from MCP
58− Client string
59− }
60− // ActionOpenCustomCommandArgumentsDialog is a message to open the custom command arguments dialog.
61− ActionOpenCustomCommandArgumentsDialog struct {
62− CommandID string
63− // Used when running a user-defined command
64− Content string
65− // Used when running a prompt from MCP
66− Client string
67 Arguments []commands.Argument
55 Arguments []commands.Argument
56+ Args map[string]string // Actual argument values
57+ }
58+ // ActionRunMCPPrompt is a message to run a custom command.
59+ ActionRunMCPPrompt struct {
60+ Title string
61+ Description string
62+ PromptID string
63+ ClientID string
64+ Arguments []commands.Argument
65+ Args map[string]string // Actual argument values
68 }
66 }
69)
67)
70
68
internal/ui/dialog/api_key_input.go+4−4
@@ -95,7 +95,7 @@ func (m *APIKeyInput) ID() string {
95 return APIKeyInputID
95 return APIKeyInputID
96}
96}
97
97
98−// Update implements tea.Model.
98+// HandleMsg implements [Dialog].
99func (m *APIKeyInput) HandleMsg(msg tea.Msg) Action {
99func (m *APIKeyInput) HandleMsg(msg tea.Msg) Action {
100 switch msg := msg.(type) {
100 switch msg := msg.(type) {
101 case ActionChangeAPIKeyState:
101 case ActionChangeAPIKeyState:
@@ -149,7 +149,7 @@ func (m *APIKeyInput) HandleMsg(msg tea.Msg) Action {
149 return nil
149 return nil
150}
150}
151
151
152−// View implements tea.Model.
152+// Draw implements [Dialog].
153func (m *APIKeyInput) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
153func (m *APIKeyInput) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
154 t := m.com.Styles
154 t := m.com.Styles
155
155
@@ -239,8 +239,8 @@ func (m *APIKeyInput) inputView() string {
239}
239}
240
240
241// Cursor returns the cursor position relative to the dialog.
241// Cursor returns the cursor position relative to the dialog.
242−func (c *APIKeyInput) Cursor() *tea.Cursor {
242+func (m *APIKeyInput) Cursor() *tea.Cursor {
243− return InputCursor(c.com.Styles, c.input.Cursor())
243+ return InputCursor(m.com.Styles, m.input.Cursor())
244}
244}
245
245
246// FullHelp returns the full help view.
246// FullHelp returns the full help view.
internal/ui/dialog/arguments.gonew file+399
@@ -0,0 +1,399 @@
1+package dialog
2+
3+import (
4+ "strings"
5+
6+ "charm.land/bubbles/v2/help"
7+ "charm.land/bubbles/v2/key"
8+ "charm.land/bubbles/v2/spinner"
9+ "charm.land/bubbles/v2/textinput"
10+ "charm.land/bubbles/v2/viewport"
11+ tea "charm.land/bubbletea/v2"
12+ "charm.land/lipgloss/v2"
13+ "golang.org/x/text/cases"
14+ "golang.org/x/text/language"
15+
16+ "github.com/charmbracelet/crush/internal/commands"
17+ "github.com/charmbracelet/crush/internal/ui/common"
18+ "github.com/charmbracelet/crush/internal/uiutil"
19+ uv "github.com/charmbracelet/ultraviolet"
20+)
21+
22+// ArgumentsID is the identifier for the arguments dialog.
23+const ArgumentsID = "arguments"
24+
25+// Dialog sizing for arguments.
26+const (
27+ maxInputWidth = 120
28+ minInputWidth = 30
29+ maxViewportHeight = 20
30+ argumentsFieldHeight = 3 // label + input + spacing per field
31+)
32+
33+// Arguments represents a dialog for collecting command arguments.
34+type Arguments struct {
35+ com *common.Common
36+ title string
37+ arguments []commands.Argument
38+ inputs []textinput.Model
39+ focused int
40+ spinner spinner.Model
41+ loading bool
42+
43+ description string
44+ resultAction Action
45+
46+ help help.Model
47+ keyMap struct {
48+ Confirm,
49+ Next,
50+ Previous,
51+ ScrollUp,
52+ ScrollDown,
53+ Close key.Binding
54+ }
55+
56+ viewport viewport.Model
57+}
58+
59+var _ Dialog = (*Arguments)(nil)
60+
61+// NewArguments creates a new arguments dialog.
62+func NewArguments(com *common.Common, title, description string, arguments []commands.Argument, resultAction Action) *Arguments {
63+ a := &Arguments{
64+ com: com,
65+ title: title,
66+ description: description,
67+ arguments: arguments,
68+ resultAction: resultAction,
69+ }
70+
71+ a.help = help.New()
72+ a.help.Styles = com.Styles.DialogHelpStyles()
73+
74+ a.keyMap.Confirm = key.NewBinding(
75+ key.WithKeys("enter"),
76+ key.WithHelp("enter", "confirm"),
77+ )
78+ a.keyMap.Next = key.NewBinding(
79+ key.WithKeys("down", "tab"),
80+ key.WithHelp("↓/tab", "next"),
81+ )
82+ a.keyMap.Previous = key.NewBinding(
83+ key.WithKeys("up", "shift+tab"),
84+ key.WithHelp("↑/shift+tab", "previous"),
85+ )
86+ a.keyMap.Close = CloseKey
87+
88+ // Create input fields for each argument.
89+ a.inputs = make([]textinput.Model, len(arguments))
90+ for i, arg := range arguments {
91+ input := textinput.New()
92+ input.SetVirtualCursor(false)
93+ input.SetStyles(com.Styles.TextInput)
94+ input.Prompt = "> "
95+ // Use description as placeholder if available, otherwise title
96+ if arg.Description != "" {
97+ input.Placeholder = arg.Description
98+ } else {
99+ input.Placeholder = arg.Title
100+ }
101+
102+ if i == 0 {
103+ input.Focus()
104+ } else {
105+ input.Blur()
106+ }
107+
108+ a.inputs[i] = input
109+ }
110+ s := spinner.New()
111+ s.Spinner = spinner.Dot
112+ s.Style = com.Styles.Dialog.Spinner
113+ a.spinner = s
114+
115+ return a
116+}
117+
118+// ID implements Dialog.
119+func (a *Arguments) ID() string {
120+ return ArgumentsID
121+}
122+
123+// focusInput changes focus to a new input by index with wrap-around.
124+func (a *Arguments) focusInput(newIndex int) {
125+ a.inputs[a.focused].Blur()
126+
127+ // Wrap around: Go's modulo can return negative, so add len first.
128+ n := len(a.inputs)
129+ a.focused = ((newIndex % n) + n) % n
130+
131+ a.inputs[a.focused].Focus()
132+
133+ // Ensure the newly focused field is visible in the viewport
134+ a.ensureFieldVisible(a.focused)
135+}
136+
137+// isFieldVisible checks if a field at the given index is visible in the viewport.
138+func (a *Arguments) isFieldVisible(fieldIndex int) bool {
139+ fieldStart := fieldIndex * argumentsFieldHeight
140+ fieldEnd := fieldStart + argumentsFieldHeight - 1
141+ viewportTop := a.viewport.YOffset()
142+ viewportBottom := viewportTop + a.viewport.Height() - 1
143+
144+ return fieldStart >= viewportTop && fieldEnd <= viewportBottom
145+}
146+
147+// ensureFieldVisible scrolls the viewport to make the field visible.
148+func (a *Arguments) ensureFieldVisible(fieldIndex int) {
149+ if a.isFieldVisible(fieldIndex) {
150+ return
151+ }
152+
153+ fieldStart := fieldIndex * argumentsFieldHeight
154+ fieldEnd := fieldStart + argumentsFieldHeight - 1
155+ viewportTop := a.viewport.YOffset()
156+ viewportHeight := a.viewport.Height()
157+
158+ // If field is above viewport, scroll up to show it at top
159+ if fieldStart < viewportTop {
160+ a.viewport.SetYOffset(fieldStart)
161+ return
162+ }
163+
164+ // If field is below viewport, scroll down to show it at bottom
165+ if fieldEnd > viewportTop+viewportHeight-1 {
166+ a.viewport.SetYOffset(fieldEnd - viewportHeight + 1)
167+ }
168+}
169+
170+// findVisibleFieldByOffset returns the field index closest to the given viewport offset.
171+func (a *Arguments) findVisibleFieldByOffset(fromTop bool) int {
172+ offset := a.viewport.YOffset()
173+ if !fromTop {
174+ offset += a.viewport.Height() - 1
175+ }
176+
177+ fieldIndex := offset / argumentsFieldHeight
178+ if fieldIndex >= len(a.inputs) {
179+ return len(a.inputs) - 1
180+ }
181+ return fieldIndex
182+}
183+
184+// HandleMsg implements Dialog.
185+func (a *Arguments) HandleMsg(msg tea.Msg) Action {
186+ switch msg := msg.(type) {
187+ case spinner.TickMsg:
188+ if a.loading {
189+ var cmd tea.Cmd
190+ a.spinner, cmd = a.spinner.Update(msg)
191+ return ActionCmd{Cmd: cmd}
192+ }
193+ case tea.KeyPressMsg:
194+ switch {
195+ case key.Matches(msg, a.keyMap.Close):
196+ return ActionClose{}
197+ case key.Matches(msg, a.keyMap.Confirm):
198+ // If we're on the last input or there's only one input, submit.
199+ if a.focused == len(a.inputs)-1 || len(a.inputs) == 1 {
200+ args := make(map[string]string)
201+ var warning tea.Cmd
202+ for i, arg := range a.arguments {
203+ args[arg.ID] = a.inputs[i].Value()
204+ if arg.Required && strings.TrimSpace(a.inputs[i].Value()) == "" {
205+ warning = uiutil.ReportWarn("Required argument '" + arg.Title + "' is missing.")
206+ break
207+ }
208+ }
209+ if warning != nil {
210+ return ActionCmd{Cmd: warning}
211+ }
212+
213+ switch action := a.resultAction.(type) {
214+ case ActionRunCustomCommand:
215+ action.Args = args
216+ return action
217+ case ActionRunMCPPrompt:
218+ action.Args = args
219+ return action
220+ }
221+ }
222+ a.focusInput(a.focused + 1)
223+ case key.Matches(msg, a.keyMap.Next):
224+ a.focusInput(a.focused + 1)
225+ case key.Matches(msg, a.keyMap.Previous):
226+ a.focusInput(a.focused - 1)
227+ default:
228+ var cmd tea.Cmd
229+ a.inputs[a.focused], cmd = a.inputs[a.focused].Update(msg)
230+ return ActionCmd{Cmd: cmd}
231+ }
232+ case tea.MouseWheelMsg:
233+ a.viewport, _ = a.viewport.Update(msg)
234+ // If focused field scrolled out of view, focus the visible field
235+ if !a.isFieldVisible(a.focused) {
236+ a.focusInput(a.findVisibleFieldByOffset(msg.Button == tea.MouseWheelDown))
237+ }
238+ case tea.PasteMsg:
239+ var cmd tea.Cmd
240+ a.inputs[a.focused], cmd = a.inputs[a.focused].Update(msg)
241+ return ActionCmd{Cmd: cmd}
242+ }
243+ return nil
244+}
245+
246+// Cursor returns the cursor position relative to the dialog.
247+// we pass the description height to offset the cursor correctly.
248+func (a *Arguments) Cursor(descriptionHeight int) *tea.Cursor {
249+ cursor := InputCursor(a.com.Styles, a.inputs[a.focused].Cursor())
250+ if cursor == nil {
251+ return nil
252+ }
253+ cursor.Y += descriptionHeight + a.focused*argumentsFieldHeight - a.viewport.YOffset() + 1
254+ return cursor
255+}
256+
257+// Draw implements Dialog.
258+func (a *Arguments) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
259+ s := a.com.Styles
260+
261+ dialogContentStyle := s.Dialog.Arguments.Content
262+ possibleWidth := area.Dx() - s.Dialog.View.GetHorizontalFrameSize() - dialogContentStyle.GetHorizontalFrameSize()
263+ // Build fields with label and input.
264+ caser := cases.Title(language.English)
265+
266+ var fields []string
267+ for i, arg := range a.arguments {
268+ isFocused := i == a.focused
269+
270+ // Try to pretty up the title for the label.
271+ title := strings.ReplaceAll(arg.Title, "_", " ")
272+ title = strings.ReplaceAll(title, "-", " ")
273+ titleParts := strings.Fields(title)
274+ for i, part := range titleParts {
275+ titleParts[i] = caser.String(strings.ToLower(part))
276+ }
277+ labelText := strings.Join(titleParts, " ")
278+
279+ markRequiredStyle := s.Dialog.Arguments.InputRequiredMarkBlurred
280+
281+ labelStyle := s.Dialog.Arguments.InputLabelBlurred
282+ if isFocused {
283+ labelStyle = s.Dialog.Arguments.InputLabelFocused
284+ markRequiredStyle = s.Dialog.Arguments.InputRequiredMarkFocused
285+ }
286+ if arg.Required {
287+ labelText += markRequiredStyle.String()
288+ }
289+ label := labelStyle.Render(labelText)
290+
291+ labelWidth := lipgloss.Width(labelText)
292+ placeholderWidth := lipgloss.Width(a.inputs[i].Placeholder)
293+
294+ inputWidth := max(placeholderWidth, labelWidth, minInputWidth)
295+ inputWidth = min(inputWidth, min(possibleWidth, maxInputWidth))
296+ a.inputs[i].SetWidth(inputWidth)
297+
298+ inputLine := a.inputs[i].View()
299+
300+ field := lipgloss.JoinVertical(lipgloss.Left, label, inputLine, "")
301+ fields = append(fields, field)
302+ }
303+
304+ renderedFields := lipgloss.JoinVertical(lipgloss.Left, fields...)
305+
306+ // Anchor width to the longest field, capped at maxInputWidth.
307+ const scrollbarWidth = 1
308+ width := lipgloss.Width(renderedFields)
309+ height := lipgloss.Height(renderedFields)
310+
311+ // Use standard header
312+ titleStyle := s.Dialog.Title
313+
314+ titleText := a.title
315+ if titleText == "" {
316+ titleText = "Arguments"
317+ }
318+
319+ header := common.DialogTitle(s, titleText, width)
320+
321+ // Add description if available.
322+ var description string
323+ if a.description != "" {
324+ descStyle := s.Dialog.Arguments.Description.Width(width)
325+ description = descStyle.Render(a.description)
326+ }
327+
328+ helpView := s.Dialog.HelpView.Width(width).Render(a.help.View(a))
329+ if a.loading {
330+ helpView = s.Dialog.HelpView.Width(width).Render(a.spinner.View() + " Generating Prompt...")
331+ }
332+
333+ availableHeight := area.Dy() - s.Dialog.View.GetVerticalFrameSize() - dialogContentStyle.GetVerticalFrameSize() - lipgloss.Height(header) - lipgloss.Height(description) - lipgloss.Height(helpView) - 2 // extra spacing
334+ viewportHeight := min(height, maxViewportHeight, availableHeight)
335+
336+ a.viewport.SetWidth(width) // -1 for scrollbar
337+ a.viewport.SetHeight(viewportHeight)
338+ a.viewport.SetContent(renderedFields)
339+
340+ scrollbar := common.Scrollbar(s, viewportHeight, a.viewport.TotalLineCount(), viewportHeight, a.viewport.YOffset())
341+ content := a.viewport.View()
342+ if scrollbar != "" {
343+ content = lipgloss.JoinHorizontal(lipgloss.Top, content, scrollbar)
344+ }
345+ contentParts := []string{}
346+ if description != "" {
347+ contentParts = append(contentParts, description)
348+ }
349+ contentParts = append(contentParts, content)
350+
351+ view := lipgloss.JoinVertical(
352+ lipgloss.Left,
353+ titleStyle.Render(header),
354+ dialogContentStyle.Render(lipgloss.JoinVertical(lipgloss.Left, contentParts...)),
355+ helpView,
356+ )
357+
358+ dialog := s.Dialog.View.Render(view)
359+
360+ descriptionHeight := 0
361+ if a.description != "" {
362+ descriptionHeight = lipgloss.Height(description)
363+ }
364+ cur := a.Cursor(descriptionHeight)
365+
366+ DrawCenterCursor(scr, area, dialog, cur)
367+ return cur
368+}
369+
370+// StartLoading implements [LoadingDialog].
371+func (a *Arguments) StartLoading() tea.Cmd {
372+ if a.loading {
373+ return nil
374+ }
375+ a.loading = true
376+ return a.spinner.Tick
377+}
378+
379+// StopLoading implements [LoadingDialog].
380+func (a *Arguments) StopLoading() {
381+ a.loading = false
382+}
383+
384+// ShortHelp implements help.KeyMap.
385+func (a *Arguments) ShortHelp() []key.Binding {
386+ return []key.Binding{
387+ a.keyMap.Confirm,
388+ a.keyMap.Next,
389+ a.keyMap.Close,
390+ }
391+}
392+
393+// FullHelp implements help.KeyMap.
394+func (a *Arguments) FullHelp() [][]key.Binding {
395+ return [][]key.Binding{
396+ {a.keyMap.Confirm, a.keyMap.Next, a.keyMap.Previous},
397+ {a.keyMap.Close},
398+ }
399+}
internal/ui/dialog/commands.go+62−44
@@ -6,6 +6,7 @@ import (
6
6
7 "charm.land/bubbles/v2/help"
7 "charm.land/bubbles/v2/help"
8 "charm.land/bubbles/v2/key"
8 "charm.land/bubbles/v2/key"
9+ "charm.land/bubbles/v2/spinner"
9 "charm.land/bubbles/v2/textinput"
10 "charm.land/bubbles/v2/textinput"
10 tea "charm.land/bubbletea/v2"
11 tea "charm.land/bubbletea/v2"
11 "charm.land/lipgloss/v2"
12 "charm.land/lipgloss/v2"
@@ -52,26 +53,29 @@ type Commands struct {
52 sessionID string // can be empty for non-session-specific commands
53 sessionID string // can be empty for non-session-specific commands
53 selected CommandType
54 selected CommandType
54
55
56+ spinner spinner.Model
57+ loading bool
58+
55 help help.Model
59 help help.Model
56 input textinput.Model
60 input textinput.Model
57 list *list.FilterableList
61 list *list.FilterableList
58
62
59 windowWidth int
63 windowWidth int
60
64
61− customCommands []commands.CustomCommand
65+ customCommands []commands.CustomCommand
62− mcpCustomCommands []commands.MCPCustomCommand
66+ mcpPrompts []commands.MCPPrompt
63}
67}
64
68
65var _ Dialog = (*Commands)(nil)
69var _ Dialog = (*Commands)(nil)
66
70
67// NewCommands creates a new commands dialog.
71// NewCommands creates a new commands dialog.
68−func NewCommands(com *common.Common, sessionID string, customCommands []commands.CustomCommand, mcpCustomCommands []commands.MCPCustomCommand) (*Commands, error) {
72+func NewCommands(com *common.Common, sessionID string, customCommands []commands.CustomCommand, mcpPrompts []commands.MCPPrompt) (*Commands, error) {
69 c := &Commands{
73 c := &Commands{
70− com: com,
74+ com: com,
71− selected: SystemCommands,
75+ selected: SystemCommands,
72− sessionID: sessionID,
76+ sessionID: sessionID,
73− customCommands: customCommands,
77+ customCommands: customCommands,
74− mcpCustomCommands: mcpCustomCommands,
78+ mcpPrompts: mcpPrompts,
75 }
79 }
76
80
77 help := help.New()
81 help := help.New()
@@ -120,6 +124,11 @@ func NewCommands(com *common.Common, sessionID string, customCommands []commands
120 // Set initial commands
124 // Set initial commands
121 c.setCommandItems(c.selected)
125 c.setCommandItems(c.selected)
122
126
127+ s := spinner.New()
128+ s.Spinner = spinner.Dot
129+ s.Style = com.Styles.Dialog.Spinner
130+ c.spinner = s
131+
123 return c, nil
132 return c, nil
124}
133}
125
134
@@ -128,9 +137,15 @@ func (c *Commands) ID() string {
128 return CommandsID
137 return CommandsID
129}
138}
130
139
131−// HandleMsg implements Dialog.
140+// HandleMsg implements [Dialog].
132func (c *Commands) HandleMsg(msg tea.Msg) Action {
141func (c *Commands) HandleMsg(msg tea.Msg) Action {
133 switch msg := msg.(type) {
142 switch msg := msg.(type) {
143+ case spinner.TickMsg:
144+ if c.loading {
145+ var cmd tea.Cmd
146+ c.spinner, cmd = c.spinner.Update(msg)
147+ return ActionCmd{Cmd: cmd}
148+ }
134 case tea.KeyPressMsg:
149 case tea.KeyPressMsg:
135 switch {
150 switch {
136 case key.Matches(msg, c.keyMap.Close):
151 case key.Matches(msg, c.keyMap.Close):
@@ -160,12 +175,12 @@ func (c *Commands) HandleMsg(msg tea.Msg) Action {
160 }
175 }
161 }
176 }
162 case key.Matches(msg, c.keyMap.Tab):
177 case key.Matches(msg, c.keyMap.Tab):
163− if len(c.customCommands) > 0 || len(c.mcpCustomCommands) > 0 {
178+ if len(c.customCommands) > 0 || len(c.mcpPrompts) > 0 {
164 c.selected = c.nextCommandType()
179 c.selected = c.nextCommandType()
165 c.setCommandItems(c.selected)
180 c.setCommandItems(c.selected)
166 }
181 }
167 case key.Matches(msg, c.keyMap.ShiftTab):
182 case key.Matches(msg, c.keyMap.ShiftTab):
168− if len(c.customCommands) > 0 || len(c.mcpCustomCommands) > 0 {
183+ if len(c.customCommands) > 0 || len(c.mcpPrompts) > 0 {
169 c.selected = c.previousCommandType()
184 c.selected = c.previousCommandType()
170 c.setCommandItems(c.selected)
185 c.setCommandItems(c.selected)
171 }
186 }
@@ -242,12 +257,16 @@ func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
242 c.list.SetSize(innerWidth, height-heightOffset)
257 c.list.SetSize(innerWidth, height-heightOffset)
243 c.help.SetWidth(innerWidth)
258 c.help.SetWidth(innerWidth)
244
259
245− radio := commandsRadioView(t, c.selected, len(c.customCommands) > 0, len(c.mcpCustomCommands) > 0)
260+ radio := commandsRadioView(t, c.selected, len(c.customCommands) > 0, len(c.mcpPrompts) > 0)
246 titleStyle := t.Dialog.Title
261 titleStyle := t.Dialog.Title
247 dialogStyle := t.Dialog.View.Width(width)
262 dialogStyle := t.Dialog.View.Width(width)
248 headerOffset := lipgloss.Width(radio) + titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
263 headerOffset := lipgloss.Width(radio) + titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
249 helpView := ansi.Truncate(c.help.View(c), innerWidth, "")
264 helpView := ansi.Truncate(c.help.View(c), innerWidth, "")
250 header := common.DialogTitle(t, "Commands", width-headerOffset) + radio
265 header := common.DialogTitle(t, "Commands", width-headerOffset) + radio
266+
267+ if c.loading {
268+ helpView = t.Dialog.HelpView.Width(width).Render(c.spinner.View() + " Generating Prompt...")
269+ }
251 view := HeaderInputListHelpView(t, width, c.list.Height(), header,
270 view := HeaderInputListHelpView(t, width, c.list.Height(), header,
252 c.input.View(), c.list.Render(), helpView)
271 c.input.View(), c.list.Render(), helpView)
253
272
@@ -281,12 +300,12 @@ func (c *Commands) nextCommandType() CommandType {
281 if len(c.customCommands) > 0 {
300 if len(c.customCommands) > 0 {
282 return UserCommands
301 return UserCommands
283 }
302 }
284− if len(c.mcpCustomCommands) > 0 {
303+ if len(c.mcpPrompts) > 0 {
285 return MCPPrompts
304 return MCPPrompts
286 }
305 }
287 fallthrough
306 fallthrough
288 case UserCommands:
307 case UserCommands:
289− if len(c.mcpCustomCommands) > 0 {
308+ if len(c.mcpPrompts) > 0 {
290 return MCPPrompts
309 return MCPPrompts
291 }
310 }
292 fallthrough
311 fallthrough
@@ -301,7 +320,7 @@ func (c *Commands) nextCommandType() CommandType {
301func (c *Commands) previousCommandType() CommandType {
320func (c *Commands) previousCommandType() CommandType {
302 switch c.selected {
321 switch c.selected {
303 case SystemCommands:
322 case SystemCommands:
304− if len(c.mcpCustomCommands) > 0 {
323+ if len(c.mcpPrompts) > 0 {
305 return MCPPrompts
324 return MCPPrompts
306 }
325 }
307 if len(c.customCommands) > 0 {
326 if len(c.customCommands) > 0 {
@@ -332,37 +351,22 @@ func (c *Commands) setCommandItems(commandType CommandType) {
332 }
351 }
333 case UserCommands:
352 case UserCommands:
334 for _, cmd := range c.customCommands {
353 for _, cmd := range c.customCommands {
335− var action Action
336− if len(cmd.Arguments) > 0 {
337− action = ActionOpenCustomCommandArgumentsDialog{
338− CommandID: cmd.ID,
339− Content: cmd.Content,
340− Arguments: cmd.Arguments,
341− }
342− } else {
343− action = ActionRunCustomCommand{
354+ action := ActionRunCustomCommand{
344− CommandID: cmd.ID,
345− Content: cmd.Content,
355+ Content: cmd.Content,
346− }
356+ Arguments: cmd.Arguments,
347 }
357 }
348 commandItems = append(commandItems, NewCommandItem(c.com.Styles, "custom_"+cmd.ID, cmd.Name, "", action))
358 commandItems = append(commandItems, NewCommandItem(c.com.Styles, "custom_"+cmd.ID, cmd.Name, "", action))
349 }
359 }
350 case MCPPrompts:
360 case MCPPrompts:
351− for _, cmd := range c.mcpCustomCommands {
361+ for _, cmd := range c.mcpPrompts {
352− var action Action
362+ action := ActionRunMCPPrompt{
353− if len(cmd.Arguments) > 0 {
363+ Title: cmd.Title,
354− action = ActionOpenCustomCommandArgumentsDialog{
364+ Description: cmd.Description,
355− CommandID: cmd.ID,
365+ PromptID: cmd.PromptID,
356− Client: cmd.Client,
366+ ClientID: cmd.ClientID,
357− Arguments: cmd.Arguments,
367+ Arguments: cmd.Arguments,
358− }
359− } else {
360− action = ActionRunCustomCommand{
361− CommandID: cmd.ID,
362− Client: cmd.Client,
363− }
364 }
368 }
365− commandItems = append(commandItems, NewCommandItem(c.com.Styles, "mcp_"+cmd.ID, cmd.Name, "", action))
369+ commandItems = append(commandItems, NewCommandItem(c.com.Styles, "mcp_"+cmd.ID, cmd.PromptID, "", action))
366 }
370 }
367 }
371 }
368
372
@@ -448,10 +452,24 @@ func (c *Commands) SetCustomCommands(customCommands []commands.CustomCommand) {
448 }
452 }
449}
453}
450
454
451−// SetMCPCustomCommands sets the MCP custom commands and refreshes the view if MCP prompts are currently displayed.
455+// SetMCPPrompts sets the MCP prompts and refreshes the view if MCP prompts are currently displayed.
452−func (c *Commands) SetMCPCustomCommands(mcpCustomCommands []commands.MCPCustomCommand) {
456+func (c *Commands) SetMCPPrompts(mcpPrompts []commands.MCPPrompt) {
453− c.mcpCustomCommands = mcpCustomCommands
457+ c.mcpPrompts = mcpPrompts
454 if c.selected == MCPPrompts {
458 if c.selected == MCPPrompts {
455 c.setCommandItems(c.selected)
459 c.setCommandItems(c.selected)
456 }
460 }
457}
461}
462+
463+// StartLoading implements [LoadingDialog].
464+func (a *Commands) StartLoading() tea.Cmd {
465+ if a.loading {
466+ return nil
467+ }
468+ a.loading = true
469+ return a.spinner.Tick
470+}
471+
472+// StopLoading implements [LoadingDialog].
473+func (a *Commands) StopLoading() {
474+ a.loading = false
475+}
internal/ui/dialog/dialog.go+26−1
@@ -27,7 +27,7 @@ var CloseKey = key.NewBinding(
27)
27)
28
28
29// Action represents an action taken in a dialog after handling a message.
29// Action represents an action taken in a dialog after handling a message.
30−type Action interface{}
30+type Action any
31
31
32// Dialog is a component that can be displayed on top of the UI.
32// Dialog is a component that can be displayed on top of the UI.
33type Dialog interface {
33type Dialog interface {
@@ -41,6 +41,12 @@ type Dialog interface {
41 Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor
41 Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor
42}
42}
43
43
44+// LoadingDialog is a dialog that can show a loading state.
45+type LoadingDialog interface {
46+ StartLoading() tea.Cmd
47+ StopLoading()
48+}
49+
44// Overlay manages multiple dialogs as an overlay.
50// Overlay manages multiple dialogs as an overlay.
45type Overlay struct {
51type Overlay struct {
46 dialogs []Dialog
52 dialogs []Dialog
@@ -136,6 +142,25 @@ func (d *Overlay) Update(msg tea.Msg) tea.Msg {
136 return dialog.HandleMsg(msg)
142 return dialog.HandleMsg(msg)
137}
143}
138
144
145+// StartLoading starts the loading state for the front dialog if it
146+// implements [LoadingDialog].
147+func (d *Overlay) StartLoading() tea.Cmd {
148+ dialog := d.DialogLast()
149+ if ld, ok := dialog.(LoadingDialog); ok {
150+ return ld.StartLoading()
151+ }
152+ return nil
153+}
154+
155+// StopLoading stops the loading state for the front dialog if it
156+// implements [LoadingDialog].
157+func (d *Overlay) StopLoading() {
158+ dialog := d.DialogLast()
159+ if ld, ok := dialog.(LoadingDialog); ok {
160+ ld.StopLoading()
161+ }
162+}
163+
139// DrawCenterCursor draws the given string view centered in the screen area and
164// DrawCenterCursor draws the given string view centered in the screen area and
140// adjusts the cursor position accordingly.
165// adjusts the cursor position accordingly.
141func DrawCenterCursor(scr uv.Screen, area uv.Rectangle, view string, cur *tea.Cursor) {
166func DrawCenterCursor(scr uv.Screen, area uv.Rectangle, view string, cur *tea.Cursor) {
internal/ui/model/ui.go+118−15
@@ -18,6 +18,7 @@ import (
18
18
19 "charm.land/bubbles/v2/help"
19 "charm.land/bubbles/v2/help"
20 "charm.land/bubbles/v2/key"
20 "charm.land/bubbles/v2/key"
21+ "charm.land/bubbles/v2/spinner"
21 "charm.land/bubbles/v2/textarea"
22 "charm.land/bubbles/v2/textarea"
22 tea "charm.land/bubbletea/v2"
23 tea "charm.land/bubbletea/v2"
23 "charm.land/lipgloss/v2"
24 "charm.land/lipgloss/v2"
@@ -93,10 +94,19 @@ type (
93 userCommandsLoadedMsg struct {
94 userCommandsLoadedMsg struct {
94 Commands []commands.CustomCommand
95 Commands []commands.CustomCommand
95 }
96 }
96− // mcpCustomCommandsLoadedMsg is sent when mcp prompts are loaded.
97+ // mcpPromptsLoadedMsg is sent when mcp prompts are loaded.
97− mcpCustomCommandsLoadedMsg struct {
98+ mcpPromptsLoadedMsg struct {
98− Prompts []commands.MCPCustomCommand
99+ Prompts []commands.MCPPrompt
99 }
100 }
101+ // sendMessageMsg is sent to send a message.
102+ // currently only used for mcp prompts.
103+ sendMessageMsg struct {
104+ Content string
105+ Attachments []message.Attachment
106+ }
107+
108+ // closeDialogMsg is sent to close the current dialog.
109+ closeDialogMsg struct{}
100)
110)
101
111
102// UI represents the main user interface model.
112// UI represents the main user interface model.
@@ -167,8 +177,8 @@ type UI struct {
167 sidebarLogo string
177 sidebarLogo string
168
178
169 // custom commands & mcp commands
179 // custom commands & mcp commands
170− customCommands []commands.CustomCommand
180+ customCommands []commands.CustomCommand
171− mcpCustomCommands []commands.MCPCustomCommand
181+ mcpPrompts []commands.MCPPrompt
172
182
173 // forceCompactMode tracks whether compact mode is forced by user toggle
183 // forceCompactMode tracks whether compact mode is forced by user toggle
174 forceCompactMode bool
184 forceCompactMode bool
@@ -282,15 +292,15 @@ func (m *UI) loadCustomCommands() tea.Cmd {
282// loadMCPrompts loads the MCP prompts asynchronously.
292// loadMCPrompts loads the MCP prompts asynchronously.
283func (m *UI) loadMCPrompts() tea.Cmd {
293func (m *UI) loadMCPrompts() tea.Cmd {
284 return func() tea.Msg {
294 return func() tea.Msg {
285− prompts, err := commands.LoadMCPCustomCommands()
295+ prompts, err := commands.LoadMCPPrompts()
286 if err != nil {
296 if err != nil {
287 slog.Error("failed to load mcp prompts", "error", err)
297 slog.Error("failed to load mcp prompts", "error", err)
288 }
298 }
289 if prompts == nil {
299 if prompts == nil {
290 // flag them as loaded even if there is none or an error
300 // flag them as loaded even if there is none or an error
291− prompts = []commands.MCPCustomCommand{}
301+ prompts = []commands.MCPPrompt{}
292 }
302 }
293− return mcpCustomCommandsLoadedMsg{Prompts: prompts}
303+ return mcpPromptsLoadedMsg{Prompts: prompts}
294 }
304 }
295}
305}
296
306
@@ -319,6 +329,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
319 cmds = append(cmds, cmd)
329 cmds = append(cmds, cmd)
320 }
330 }
321
331
332+ case sendMessageMsg:
333+ cmds = append(cmds, m.sendMessage(msg.Content, msg.Attachments...))
334+
322 case userCommandsLoadedMsg:
335 case userCommandsLoadedMsg:
323 m.customCommands = msg.Commands
336 m.customCommands = msg.Commands
324 dia := m.dialog.Dialog(dialog.CommandsID)
337 dia := m.dialog.Dialog(dialog.CommandsID)
@@ -330,8 +343,8 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
330 if ok {
343 if ok {
331 commands.SetCustomCommands(m.customCommands)
344 commands.SetCustomCommands(m.customCommands)
332 }
345 }
333− case mcpCustomCommandsLoadedMsg:
346+ case mcpPromptsLoadedMsg:
334− m.mcpCustomCommands = msg.Prompts
347+ m.mcpPrompts = msg.Prompts
335 dia := m.dialog.Dialog(dialog.CommandsID)
348 dia := m.dialog.Dialog(dialog.CommandsID)
336 if dia == nil {
349 if dia == nil {
337 break
350 break
@@ -339,9 +352,12 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
339
352
340 commands, ok := dia.(*dialog.Commands)
353 commands, ok := dia.(*dialog.Commands)
341 if ok {
354 if ok {
342− commands.SetMCPCustomCommands(m.mcpCustomCommands)
355+ commands.SetMCPPrompts(m.mcpPrompts)
343 }
356 }
344
357
358+ case closeDialogMsg:
359+ m.dialog.CloseFrontDialog()
360+
345 case pubsub.Event[message.Message]:
361 case pubsub.Event[message.Message]:
346 // Check if this is a child session message for an agent tool.
362 // Check if this is a child session message for an agent tool.
347 if m.session == nil {
363 if m.session == nil {
@@ -374,7 +390,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
374 break
390 break
375 }
391 }
376 }
392 }
377− if initialized && m.mcpCustomCommands == nil {
393+ if initialized && m.mcpPrompts == nil {
378 cmds = append(cmds, m.loadMCPrompts())
394 cmds = append(cmds, m.loadMCPrompts())
379 }
395 }
380 case pubsub.Event[permission.PermissionRequest]:
396 case pubsub.Event[permission.PermissionRequest]:
@@ -492,6 +508,14 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
492 cmds = append(cmds, cmd)
508 cmds = append(cmds, cmd)
493 }
509 }
494 }
510 }
511+ case spinner.TickMsg:
512+ if m.dialog.HasDialogs() {
513+ // route to dialog
514+ if cmd := m.handleDialogMsg(msg); cmd != nil {
515+ cmds = append(cmds, cmd)
516+ }
517+ }
518+
495 case tea.KeyPressMsg:
519 case tea.KeyPressMsg:
496 if cmd := m.handleKeyPressMsg(msg); cmd != nil {
520 if cmd := m.handleKeyPressMsg(msg); cmd != nil {
497 cmds = append(cmds, cmd)
521 cmds = append(cmds, cmd)
@@ -645,6 +669,11 @@ func (m *UI) loadNestedToolCalls(items []chat.MessageItem) {
645// if the message is a tool result it will update the corresponding tool call message
669// if the message is a tool result it will update the corresponding tool call message
646func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
670func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
647 var cmds []tea.Cmd
671 var cmds []tea.Cmd
672+ existing := m.chat.MessageItem(msg.ID)
673+ if existing != nil {
674+ // message already exists, skip
675+ return nil
676+ }
648 switch msg.Role {
677 switch msg.Role {
649 case message.User, message.Assistant:
678 case message.User, message.Assistant:
650 items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
679 items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
@@ -920,6 +949,44 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
920 case dialog.PermissionDeny:
949 case dialog.PermissionDeny:
921 m.com.App.Permissions.Deny(msg.Permission)
950 m.com.App.Permissions.Deny(msg.Permission)
922 }
951 }
952+
953+ case dialog.ActionRunCustomCommand:
954+ if len(msg.Arguments) > 0 && msg.Args == nil {
955+ m.dialog.CloseFrontDialog()
956+ argsDialog := dialog.NewArguments(
957+ m.com,
958+ "Custom Command Arguments",
959+ "",
960+ msg.Arguments,
961+ msg, // Pass the action as the result
962+ )
963+ m.dialog.OpenDialog(argsDialog)
964+ break
965+ }
966+ content := msg.Content
967+ if msg.Args != nil {
968+ content = substituteArgs(content, msg.Args)
969+ }
970+ cmds = append(cmds, m.sendMessage(content))
971+ m.dialog.CloseFrontDialog()
972+ case dialog.ActionRunMCPPrompt:
973+ if len(msg.Arguments) > 0 && msg.Args == nil {
974+ m.dialog.CloseFrontDialog()
975+ title := msg.Title
976+ if title == "" {
977+ title = "MCP Prompt Arguments"
978+ }
979+ argsDialog := dialog.NewArguments(
980+ m.com,
981+ title,
982+ msg.Description,
983+ msg.Arguments,
984+ msg, // Pass the action as the result
985+ )
986+ m.dialog.OpenDialog(argsDialog)
987+ break
988+ }
989+ cmds = append(cmds, m.runMCPPrompt(msg.ClientID, msg.PromptID, msg.Args))
923 default:
990 default:
924 cmds = append(cmds, uiutil.CmdHandler(msg))
991 cmds = append(cmds, uiutil.CmdHandler(msg))
925 }
992 }
@@ -927,6 +994,15 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
927 return tea.Batch(cmds...)
994 return tea.Batch(cmds...)
928}
995}
929
996
997+// substituteArgs replaces $ARG_NAME placeholders in content with actual values.
998+func substituteArgs(content string, args map[string]string) string {
999+ for name, value := range args {
1000+ placeholder := "$" + name
1001+ content = strings.ReplaceAll(content, placeholder, value)
1002+ }
1003+ return content
1004+}
1005+
930// openAPIKeyInputDialog opens the API key input dialog.
1006// openAPIKeyInputDialog opens the API key input dialog.
931func (m *UI) openAPIKeyInputDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd {
1007func (m *UI) openAPIKeyInputDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd {
932 if m.dialog.ContainsDialog(dialog.APIKeyInputID) {
1008 if m.dialog.ContainsDialog(dialog.APIKeyInputID) {
@@ -1055,7 +1131,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
1055
1131
1056 m.randomizePlaceholders()
1132 m.randomizePlaceholders()
1057
1133
1058− return m.sendMessage(value, attachments)
1134+ return m.sendMessage(value, attachments...)
1059 case key.Matches(msg, m.keyMap.Chat.NewSession):
1135 case key.Matches(msg, m.keyMap.Chat.NewSession):
1060 if m.session == nil || m.session.ID == "" {
1136 if m.session == nil || m.session.ID == "" {
1061 break
1137 break
@@ -2013,7 +2089,7 @@ func (m *UI) renderSidebarLogo(width int) {
2013}
2089}
2014
2090
2015// sendMessage sends a message with the given content and attachments.
2091// sendMessage sends a message with the given content and attachments.
2016−func (m *UI) sendMessage(content string, attachments []message.Attachment) tea.Cmd {
2092+func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
2017 if m.com.App.AgentCoordinator == nil {
2093 if m.com.App.AgentCoordinator == nil {
2018 return uiutil.ReportError(fmt.Errorf("coder agent is not initialized"))
2094 return uiutil.ReportError(fmt.Errorf("coder agent is not initialized"))
2019 }
2095 }
@@ -2165,7 +2241,7 @@ func (m *UI) openCommandsDialog() tea.Cmd {
2165 sessionID = m.session.ID
2241 sessionID = m.session.ID
2166 }
2242 }
2167
2243
2168− commands, err := dialog.NewCommands(m.com, sessionID, m.customCommands, m.mcpCustomCommands)
2244+ commands, err := dialog.NewCommands(m.com, sessionID, m.customCommands, m.mcpPrompts)
2169 if err != nil {
2245 if err != nil {
2170 return uiutil.ReportError(err)
2246 return uiutil.ReportError(err)
2171 }
2247 }
@@ -2393,6 +2469,33 @@ func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
2393 ).Draw(scr, area)
2469 ).Draw(scr, area)
2394}
2470}
2395
2471
2472+func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
2473+ load := func() tea.Msg {
2474+ prompt, err := commands.GetMCPPrompt(clientID, promptID, arguments)
2475+ if err != nil {
2476+ // TODO: make this better
2477+ return uiutil.ReportError(err)()
2478+ }
2479+
2480+ if prompt == "" {
2481+ return nil
2482+ }
2483+ return sendMessageMsg{
2484+ Content: prompt,
2485+ }
2486+ }
2487+
2488+ var cmds []tea.Cmd
2489+ if cmd := m.dialog.StartLoading(); cmd != nil {
2490+ cmds = append(cmds, cmd)
2491+ }
2492+ cmds = append(cmds, load, func() tea.Msg {
2493+ return closeDialogMsg{}
2494+ })
2495+
2496+ return tea.Sequence(cmds...)
2497+}
2498+
2396// renderLogo renders the Crush logo with the given styles and dimensions.
2499// renderLogo renders the Crush logo with the given styles and dimensions.
2397func renderLogo(t *styles.Styles, compact bool, width int) string {
2500func renderLogo(t *styles.Styles, compact bool, width int) string {
2398 return logo.Render(version.Version, compact, logo.Opts{
2501 return logo.Render(version.Version, compact, logo.Opts{
internal/ui/styles/styles.go+20
@@ -333,6 +333,8 @@ type Styles struct {
333
333
334 List lipgloss.Style
334 List lipgloss.Style
335
335
336+ Spinner lipgloss.Style
337+
336 // ContentPanel is used for content blocks with subtle background.
338 // ContentPanel is used for content blocks with subtle background.
337 ContentPanel lipgloss.Style
339 ContentPanel lipgloss.Style
338
340
@@ -340,6 +342,16 @@ type Styles struct {
340 ScrollbarThumb lipgloss.Style
342 ScrollbarThumb lipgloss.Style
341 ScrollbarTrack lipgloss.Style
343 ScrollbarTrack lipgloss.Style
342
344
345+ // Arguments
346+ Arguments struct {
347+ Content lipgloss.Style
348+ Description lipgloss.Style
349+ InputLabelBlurred lipgloss.Style
350+ InputLabelFocused lipgloss.Style
351+ InputRequiredMarkBlurred lipgloss.Style
352+ InputRequiredMarkFocused lipgloss.Style
353+ }
354+
343 Commands struct{}
355 Commands struct{}
344 }
356 }
345
357
@@ -1205,9 +1217,17 @@ func DefaultStyles() Styles {
1205
1217
1206 s.Dialog.List = base.Margin(0, 0, 1, 0)
1218 s.Dialog.List = base.Margin(0, 0, 1, 0)
1207 s.Dialog.ContentPanel = base.Background(bgSubtle).Foreground(fgBase).Padding(1, 2)
1219 s.Dialog.ContentPanel = base.Background(bgSubtle).Foreground(fgBase).Padding(1, 2)
1220+ s.Dialog.Spinner = base.Foreground(secondary)
1208 s.Dialog.ScrollbarThumb = base.Foreground(secondary)
1221 s.Dialog.ScrollbarThumb = base.Foreground(secondary)
1209 s.Dialog.ScrollbarTrack = base.Foreground(border)
1222 s.Dialog.ScrollbarTrack = base.Foreground(border)
1210
1223
1224+ s.Dialog.Arguments.Content = base.Padding(1)
1225+ s.Dialog.Arguments.Description = base.MarginBottom(1).MaxHeight(3)
1226+ s.Dialog.Arguments.InputLabelBlurred = base.Foreground(fgMuted)
1227+ s.Dialog.Arguments.InputLabelFocused = base.Bold(true)
1228+ s.Dialog.Arguments.InputRequiredMarkBlurred = base.Foreground(fgMuted).SetString("*")
1229+ s.Dialog.Arguments.InputRequiredMarkFocused = base.Foreground(primary).Bold(true).SetString("*")
1230+
1211 s.Status.Help = lipgloss.NewStyle().Padding(0, 1)
1231 s.Status.Help = lipgloss.NewStyle().Padding(0, 1)
1212 s.Status.SuccessIndicator = base.Foreground(bgSubtle).Background(green).Padding(0, 1).Bold(true).SetString("OKAY!")
1232 s.Status.SuccessIndicator = base.Foreground(bgSubtle).Background(green).Padding(0, 1).Bold(true).SetString("OKAY!")
1213 s.Status.InfoIndicator = s.Status.SuccessIndicator
1233 s.Status.InfoIndicator = s.Status.SuccessIndicator