1package dialog
2
3import (
4 "cmp"
5 "strings"
6
7 "charm.land/bubbles/v2/help"
8 "charm.land/bubbles/v2/key"
9 "charm.land/bubbles/v2/spinner"
10 "charm.land/bubbles/v2/textinput"
11 "charm.land/bubbles/v2/viewport"
12 tea "charm.land/bubbletea/v2"
13 "charm.land/lipgloss/v2"
14 "golang.org/x/text/cases"
15 "golang.org/x/text/language"
16
17 "github.com/charmbracelet/crush/internal/commands"
18 "github.com/charmbracelet/crush/internal/ui/common"
19 "github.com/charmbracelet/crush/internal/ui/util"
20 uv "github.com/charmbracelet/ultraviolet"
21)
22
23// ArgumentsID is the identifier for the arguments dialog.
24const ArgumentsID = "arguments"
25
26// Dialog sizing for arguments.
27const (
28 maxInputWidth = 120
29 minInputWidth = 30
30 maxViewportHeight = 20
31 argumentsFieldHeight = 3 // label + input + spacing per field
32)
33
34// Arguments represents a dialog for collecting command arguments.
35type Arguments struct {
36 com *common.Common
37 title string
38 arguments []commands.Argument
39 inputs []textinput.Model
40 focused int
41 spinner spinner.Model
42 loading bool
43
44 description string
45 resultAction Action
46
47 help help.Model
48 keyMap struct {
49 Confirm,
50 Next,
51 Previous,
52 ScrollUp,
53 ScrollDown,
54 Close key.Binding
55 }
56
57 viewport viewport.Model
58}
59
60var _ Dialog = (*Arguments)(nil)
61
62// NewArguments creates a new arguments dialog.
63func NewArguments(com *common.Common, title, description string, arguments []commands.Argument, resultAction Action) *Arguments {
64 a := &Arguments{
65 com: com,
66 title: title,
67 description: description,
68 arguments: arguments,
69 resultAction: resultAction,
70 }
71
72 a.help = help.New()
73 a.help.Styles = com.Styles.DialogHelpStyles()
74
75 a.keyMap.Confirm = key.NewBinding(
76 key.WithKeys("enter"),
77 key.WithHelp("enter", "confirm"),
78 )
79 a.keyMap.Next = key.NewBinding(
80 key.WithKeys("down", "tab"),
81 key.WithHelp("↓/tab", "next"),
82 )
83 a.keyMap.Previous = key.NewBinding(
84 key.WithKeys("up", "shift+tab"),
85 key.WithHelp("↑/shift+tab", "previous"),
86 )
87 a.keyMap.Close = CloseKey
88
89 // Create input fields for each argument.
90 a.inputs = make([]textinput.Model, len(arguments))
91 for i, arg := range arguments {
92 input := textinput.New()
93 input.SetVirtualCursor(false)
94 input.SetStyles(com.Styles.TextInput)
95 input.Prompt = "> "
96 // Use description as placeholder if available, otherwise title
97 if arg.Description != "" {
98 input.Placeholder = arg.Description
99 } else {
100 input.Placeholder = arg.Title
101 }
102
103 if i == 0 {
104 input.Focus()
105 } else {
106 input.Blur()
107 }
108
109 a.inputs[i] = input
110 }
111 s := spinner.New()
112 s.Spinner = spinner.Dot
113 s.Style = com.Styles.Dialog.Spinner
114 a.spinner = s
115
116 return a
117}
118
119// ID implements Dialog.
120func (a *Arguments) ID() string {
121 return ArgumentsID
122}
123
124// focusInput changes focus to a new input by index with wrap-around.
125func (a *Arguments) focusInput(newIndex int) {
126 a.inputs[a.focused].Blur()
127
128 // Wrap around: Go's modulo can return negative, so add len first.
129 n := len(a.inputs)
130 a.focused = ((newIndex % n) + n) % n
131
132 a.inputs[a.focused].Focus()
133
134 // Ensure the newly focused field is visible in the viewport
135 a.ensureFieldVisible(a.focused)
136}
137
138// isFieldVisible checks if a field at the given index is visible in the viewport.
139func (a *Arguments) isFieldVisible(fieldIndex int) bool {
140 fieldStart := fieldIndex * argumentsFieldHeight
141 fieldEnd := fieldStart + argumentsFieldHeight - 1
142 viewportTop := a.viewport.YOffset()
143 viewportBottom := viewportTop + a.viewport.Height() - 1
144
145 return fieldStart >= viewportTop && fieldEnd <= viewportBottom
146}
147
148// ensureFieldVisible scrolls the viewport to make the field visible.
149func (a *Arguments) ensureFieldVisible(fieldIndex int) {
150 if a.isFieldVisible(fieldIndex) {
151 return
152 }
153
154 fieldStart := fieldIndex * argumentsFieldHeight
155 fieldEnd := fieldStart + argumentsFieldHeight - 1
156 viewportTop := a.viewport.YOffset()
157 viewportHeight := a.viewport.Height()
158
159 // If field is above viewport, scroll up to show it at top
160 if fieldStart < viewportTop {
161 a.viewport.SetYOffset(fieldStart)
162 return
163 }
164
165 // If field is below viewport, scroll down to show it at bottom
166 if fieldEnd > viewportTop+viewportHeight-1 {
167 a.viewport.SetYOffset(fieldEnd - viewportHeight + 1)
168 }
169}
170
171// findVisibleFieldByOffset returns the field index closest to the given viewport offset.
172func (a *Arguments) findVisibleFieldByOffset(fromTop bool) int {
173 offset := a.viewport.YOffset()
174 if !fromTop {
175 offset += a.viewport.Height() - 1
176 }
177
178 fieldIndex := offset / argumentsFieldHeight
179 if fieldIndex >= len(a.inputs) {
180 return len(a.inputs) - 1
181 }
182 return fieldIndex
183}
184
185// HandleMsg implements Dialog.
186func (a *Arguments) HandleMsg(msg tea.Msg) Action {
187 switch msg := msg.(type) {
188 case spinner.TickMsg:
189 if a.loading {
190 var cmd tea.Cmd
191 a.spinner, cmd = a.spinner.Update(msg)
192 return ActionCmd{Cmd: cmd}
193 }
194 case tea.KeyPressMsg:
195 switch {
196 case key.Matches(msg, a.keyMap.Close):
197 return ActionClose{}
198 case key.Matches(msg, a.keyMap.Confirm):
199 // If we're on the last input or there's only one input, submit.
200 if a.focused == len(a.inputs)-1 || len(a.inputs) == 1 {
201 args := make(map[string]string)
202 var warning tea.Cmd
203 for i, arg := range a.arguments {
204 args[arg.ID] = a.inputs[i].Value()
205 if arg.Required && strings.TrimSpace(a.inputs[i].Value()) == "" {
206 warning = util.ReportWarn("Required argument '" + arg.Title + "' is missing.")
207 break
208 }
209 }
210 if warning != nil {
211 return ActionCmd{Cmd: warning}
212 }
213
214 switch action := a.resultAction.(type) {
215 case ActionRunCustomCommand:
216 action.Args = args
217 return action
218 case ActionRunMCPPrompt:
219 action.Args = args
220 return action
221 }
222 }
223 a.focusInput(a.focused + 1)
224 case key.Matches(msg, a.keyMap.Next):
225 a.focusInput(a.focused + 1)
226 case key.Matches(msg, a.keyMap.Previous):
227 a.focusInput(a.focused - 1)
228 default:
229 var cmd tea.Cmd
230 a.inputs[a.focused], cmd = a.inputs[a.focused].Update(msg)
231 return ActionCmd{Cmd: cmd}
232 }
233 case tea.MouseWheelMsg:
234 a.viewport, _ = a.viewport.Update(msg)
235 // If focused field scrolled out of view, focus the visible field
236 if !a.isFieldVisible(a.focused) {
237 a.focusInput(a.findVisibleFieldByOffset(msg.Button == tea.MouseWheelDown))
238 }
239 case tea.PasteMsg:
240 var cmd tea.Cmd
241 a.inputs[a.focused], cmd = a.inputs[a.focused].Update(msg)
242 return ActionCmd{Cmd: cmd}
243 }
244 return nil
245}
246
247// Cursor returns the cursor position relative to the dialog.
248// we pass the description height to offset the cursor correctly.
249func (a *Arguments) Cursor(descriptionHeight int) *tea.Cursor {
250 cursor := InputCursor(a.com.Styles, a.inputs[a.focused].Cursor())
251 if cursor == nil {
252 return nil
253 }
254 cursor.Y += descriptionHeight + a.focused*argumentsFieldHeight - a.viewport.YOffset() + 1
255 return cursor
256}
257
258// Draw implements Dialog.
259func (a *Arguments) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
260 s := a.com.Styles
261
262 dialogContentStyle := s.Dialog.Arguments.Content
263 possibleWidth := area.Dx() - s.Dialog.View.GetHorizontalFrameSize() - dialogContentStyle.GetHorizontalFrameSize()
264 // Build fields with label and input.
265 caser := cases.Title(language.English)
266
267 var fields []string
268 for i, arg := range a.arguments {
269 isFocused := i == a.focused
270
271 // Try to pretty up the title for the label.
272 title := strings.ReplaceAll(arg.Title, "_", " ")
273 title = strings.ReplaceAll(title, "-", " ")
274 titleParts := strings.Fields(title)
275 for i, part := range titleParts {
276 titleParts[i] = caser.String(strings.ToLower(part))
277 }
278 labelText := strings.Join(titleParts, " ")
279
280 markRequiredStyle := s.Dialog.Arguments.InputRequiredMarkBlurred
281
282 labelStyle := s.Dialog.Arguments.InputLabelBlurred
283 if isFocused {
284 labelStyle = s.Dialog.Arguments.InputLabelFocused
285 markRequiredStyle = s.Dialog.Arguments.InputRequiredMarkFocused
286 }
287 if arg.Required {
288 labelText += markRequiredStyle.String()
289 }
290 label := labelStyle.Render(labelText)
291
292 labelWidth := lipgloss.Width(labelText)
293 placeholderWidth := lipgloss.Width(a.inputs[i].Placeholder)
294
295 inputWidth := max(placeholderWidth, labelWidth, minInputWidth)
296 inputWidth = min(inputWidth, min(possibleWidth, maxInputWidth))
297 a.inputs[i].SetWidth(inputWidth)
298
299 inputLine := a.inputs[i].View()
300
301 field := lipgloss.JoinVertical(lipgloss.Left, label, inputLine, "")
302 fields = append(fields, field)
303 }
304
305 renderedFields := lipgloss.JoinVertical(lipgloss.Left, fields...)
306
307 // Anchor width to the longest field, capped at maxInputWidth.
308 const scrollbarWidth = 1
309 width := lipgloss.Width(renderedFields)
310 height := lipgloss.Height(renderedFields)
311
312 // Use standard header
313 titleStyle := s.Dialog.Title
314
315 titleText := cmp.Or(a.title, "Arguments")
316
317 header := common.DialogTitle(s, titleText, width, s.Primary, s.Secondary)
318
319 // Add description if available.
320 var description string
321 if a.description != "" {
322 descStyle := s.Dialog.Arguments.Description.Width(width)
323 description = descStyle.Render(a.description)
324 }
325
326 helpView := s.Dialog.HelpView.Width(width).Render(a.help.View(a))
327 if a.loading {
328 helpView = s.Dialog.HelpView.Width(width).Render(a.spinner.View() + " Generating Prompt...")
329 }
330
331 availableHeight := area.Dy() - s.Dialog.View.GetVerticalFrameSize() - dialogContentStyle.GetVerticalFrameSize() - lipgloss.Height(header) - lipgloss.Height(description) - lipgloss.Height(helpView) - 2 // extra spacing
332 viewportHeight := min(height, maxViewportHeight, availableHeight)
333
334 a.viewport.SetWidth(width) // -1 for scrollbar
335 a.viewport.SetHeight(viewportHeight)
336 a.viewport.SetContent(renderedFields)
337
338 scrollbar := common.Scrollbar(s, viewportHeight, a.viewport.TotalLineCount(), viewportHeight, a.viewport.YOffset())
339 content := a.viewport.View()
340 if scrollbar != "" {
341 content = lipgloss.JoinHorizontal(lipgloss.Top, content, scrollbar)
342 }
343 var contentParts []string
344 if description != "" {
345 contentParts = append(contentParts, description)
346 }
347 contentParts = append(contentParts, content)
348
349 view := lipgloss.JoinVertical(
350 lipgloss.Left,
351 titleStyle.Render(header),
352 dialogContentStyle.Render(lipgloss.JoinVertical(lipgloss.Left, contentParts...)),
353 helpView,
354 )
355
356 dialog := s.Dialog.View.Render(view)
357
358 descriptionHeight := 0
359 if a.description != "" {
360 descriptionHeight = lipgloss.Height(description)
361 }
362 cur := a.Cursor(descriptionHeight)
363
364 DrawCenterCursor(scr, area, dialog, cur)
365 return cur
366}
367
368// StartLoading implements [LoadingDialog].
369func (a *Arguments) StartLoading() tea.Cmd {
370 if a.loading {
371 return nil
372 }
373 a.loading = true
374 return a.spinner.Tick
375}
376
377// StopLoading implements [LoadingDialog].
378func (a *Arguments) StopLoading() {
379 a.loading = false
380}
381
382// ShortHelp implements help.KeyMap.
383func (a *Arguments) ShortHelp() []key.Binding {
384 return []key.Binding{
385 a.keyMap.Confirm,
386 a.keyMap.Next,
387 a.keyMap.Close,
388 }
389}
390
391// FullHelp implements help.KeyMap.
392func (a *Arguments) FullHelp() [][]key.Binding {
393 return [][]key.Binding{
394 {a.keyMap.Confirm, a.keyMap.Next, a.keyMap.Previous},
395 {a.keyMap.Close},
396 }
397}