diff --git a/cli/cliui/select.go b/cli/cliui/select.go index 39e547c0258ea..4697dda09d660 100644 --- a/cli/cliui/select.go +++ b/cli/cliui/select.go @@ -300,9 +300,10 @@ func (m selectModel) filteredOptions() []string { } type MultiSelectOptions struct { - Message string - Options []string - Defaults []string + Message string + Options []string + Defaults []string + EnableCustomInput bool } func MultiSelect(inv *serpent.Invocation, opts MultiSelectOptions) ([]string, error) { @@ -328,9 +329,10 @@ func MultiSelect(inv *serpent.Invocation, opts MultiSelectOptions) ([]string, er } initialModel := multiSelectModel{ - search: textinput.New(), - options: options, - message: opts.Message, + search: textinput.New(), + options: options, + message: opts.Message, + enableCustomInput: opts.EnableCustomInput, } initialModel.search.Prompt = "" @@ -370,12 +372,15 @@ type multiSelectOption struct { } type multiSelectModel struct { - search textinput.Model - options []*multiSelectOption - cursor int - message string - canceled bool - selected bool + search textinput.Model + options []*multiSelectOption + cursor int + message string + canceled bool + selected bool + isCustomInputMode bool // track if we're adding a custom option + customInput string // store custom input + enableCustomInput bool // control whether custom input is allowed } func (multiSelectModel) Init() tea.Cmd { @@ -386,6 +391,10 @@ func (multiSelectModel) Init() tea.Cmd { func (m multiSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd + if m.isCustomInputMode { + return m.handleCustomInputMode(msg) + } + switch msg := msg.(type) { case terminateMsg: m.canceled = true @@ -398,6 +407,11 @@ func (m multiSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit case tea.KeyEnter: + // Switch to custom input mode if we're on the "+ Add custom value:" option + if m.enableCustomInput && m.cursor == len(m.filteredOptions()) { + m.isCustomInputMode = true + return m, nil + } if len(m.options) != 0 { m.selected = true return m, tea.Quit @@ -413,16 +427,16 @@ func (m multiSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.KeyUp: - options := m.filteredOptions() + maxIndex := m.getMaxIndex() if m.cursor > 0 { m.cursor-- } else { - m.cursor = len(options) - 1 + m.cursor = maxIndex } case tea.KeyDown: - options := m.filteredOptions() - if m.cursor < len(options)-1 { + maxIndex := m.getMaxIndex() + if m.cursor < maxIndex { m.cursor++ } else { m.cursor = 0 @@ -457,6 +471,91 @@ func (m multiSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } +func (m multiSelectModel) getMaxIndex() int { + options := m.filteredOptions() + if m.enableCustomInput { + // Include the "+ Add custom value" entry + return len(options) + } + // Includes only the actual options + return len(options) - 1 +} + +// handleCustomInputMode manages keyboard interactions when in custom input mode +func (m *multiSelectModel) handleCustomInputMode(msg tea.Msg) (tea.Model, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + + switch keyMsg.Type { + case tea.KeyEnter: + return m.handleCustomInputSubmission() + + case tea.KeyCtrlC: + m.canceled = true + return m, tea.Quit + + case tea.KeyBackspace: + return m.handleCustomInputBackspace() + + default: + m.customInput += keyMsg.String() + return m, nil + } +} + +// handleCustomInputSubmission processes the submission of custom input +func (m *multiSelectModel) handleCustomInputSubmission() (tea.Model, tea.Cmd) { + if m.customInput == "" { + m.isCustomInputMode = false + return m, nil + } + + // Clear search to ensure option is visible and cursor points to the new option + m.search.SetValue("") + + // Check for duplicates + for i, opt := range m.options { + if opt.option == m.customInput { + // If the option exists but isn't chosen, select it + if !opt.chosen { + opt.chosen = true + } + + // Point cursor to the new option + m.cursor = i + + // Reset custom input mode to disabled + m.isCustomInputMode = false + m.customInput = "" + return m, nil + } + } + + // Add new unique option + m.options = append(m.options, &multiSelectOption{ + option: m.customInput, + chosen: true, + }) + + // Point cursor to the newly added option + m.cursor = len(m.options) - 1 + + // Reset custom input mode to disabled + m.customInput = "" + m.isCustomInputMode = false + return m, nil +} + +// handleCustomInputBackspace handles backspace in custom input mode +func (m *multiSelectModel) handleCustomInputBackspace() (tea.Model, tea.Cmd) { + if len(m.customInput) > 0 { + m.customInput = m.customInput[:len(m.customInput)-1] + } + return m, nil +} + func (m multiSelectModel) View() string { var s strings.Builder @@ -469,13 +568,19 @@ func (m multiSelectModel) View() string { return s.String() } + if m.isCustomInputMode { + _, _ = s.WriteString(fmt.Sprintf("%s\nEnter custom value: %s\n", msg, m.customInput)) + return s.String() + } + _, _ = s.WriteString(fmt.Sprintf( "%s %s[Use arrows to move, space to select, to all, to none, type to filter]\n", msg, m.search.View(), )) - for i, option := range m.filteredOptions() { + options := m.filteredOptions() + for i, option := range options { cursor := " " chosen := "[ ]" o := option.option @@ -498,6 +603,16 @@ func (m multiSelectModel) View() string { )) } + if m.enableCustomInput { + // Add the "+ Add custom value" option at the bottom + cursor := " " + text := " + Add custom value" + if m.cursor == len(options) { + cursor = pretty.Sprint(DefaultStyles.Keyword, "> ") + text = pretty.Sprint(DefaultStyles.Keyword, text) + } + _, _ = s.WriteString(fmt.Sprintf("%s%s\n", cursor, text)) + } return s.String() } diff --git a/cli/cliui/select_test.go b/cli/cliui/select_test.go index c0da49714fc40..c7630ac4f2460 100644 --- a/cli/cliui/select_test.go +++ b/cli/cliui/select_test.go @@ -101,6 +101,39 @@ func TestMultiSelect(t *testing.T) { }() require.Equal(t, items, <-msgChan) }) + + t.Run("MultiSelectWithCustomInput", func(t *testing.T) { + t.Parallel() + items := []string{"Code", "Chairs", "Whale", "Diamond", "Carrot"} + ptty := ptytest.New(t) + msgChan := make(chan []string) + go func() { + resp, err := newMultiSelectWithCustomInput(ptty, items) + assert.NoError(t, err) + msgChan <- resp + }() + require.Equal(t, items, <-msgChan) + }) +} + +func newMultiSelectWithCustomInput(ptty *ptytest.PTY, items []string) ([]string, error) { + var values []string + cmd := &serpent.Command{ + Handler: func(inv *serpent.Invocation) error { + selectedItems, err := cliui.MultiSelect(inv, cliui.MultiSelectOptions{ + Options: items, + Defaults: items, + EnableCustomInput: true, + }) + if err == nil { + values = selectedItems + } + return err + }, + } + inv := cmd.Invoke() + ptty.Attach(inv) + return values, inv.Run() } func newMultiSelect(ptty *ptytest.PTY, items []string) ([]string, error) { diff --git a/cli/prompts.go b/cli/prompts.go index 9bd7ecaa03204..225685a0c375a 100644 --- a/cli/prompts.go +++ b/cli/prompts.go @@ -41,6 +41,15 @@ func (RootCmd) promptExample() *serpent.Command { Default: "", Value: serpent.StringArrayOf(&multiSelectValues), } + + enableCustomInput bool + enableCustomInputOption = serpent.Option{ + Name: "enable-custom-input", + Description: "Enable custom input option in multi-select.", + Required: false, + Flag: "enable-custom-input", + Value: serpent.BoolOf(&enableCustomInput), + } ) cmd := &serpent.Command{ Use: "prompt-example", @@ -156,14 +165,15 @@ func (RootCmd) promptExample() *serpent.Command { multiSelectValues, multiSelectError = cliui.MultiSelect(inv, cliui.MultiSelectOptions{ Message: "Select some things:", Options: []string{ - "Code", "Chair", "Whale", "Diamond", "Carrot", + "Code", "Chairs", "Whale", "Diamond", "Carrot", }, - Defaults: []string{"Code"}, + Defaults: []string{"Code"}, + EnableCustomInput: enableCustomInput, }) } _, _ = fmt.Fprintf(inv.Stdout, "%q are nice choices.\n", strings.Join(multiSelectValues, ", ")) return multiSelectError - }, useThingsOption), + }, useThingsOption, enableCustomInputOption), promptCmd("rich-parameter", func(inv *serpent.Invocation) error { value, err := cliui.RichSelect(inv, cliui.RichSelectOptions{ Options: []codersdk.TemplateVersionParameterOption{