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