1package dialog
2
3import (
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.
23const ArgumentsID = "arguments"
24
25// Dialog sizing for arguments.
26const (
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.
34type 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
59var _ Dialog = (*Arguments)(nil)
60
61// NewArguments creates a new arguments dialog.
62func 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.
119func (a *Arguments) ID() string {
120 return ArgumentsID
121}
122
123// focusInput changes focus to a new input by index with wrap-around.
124func (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.
138func (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.
148func (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.
171func (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.
185func (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.
248func (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.
258func (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, s.Primary, s.Secondary)
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].
371func (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].
380func (a *Arguments) StopLoading() {
381 a.loading = false
382}
383
384// ShortHelp implements help.KeyMap.
385func (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.
394func (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}