From ce4b3c2b349daaa98c19ce8e2517b1720117c3b1 Mon Sep 17 00:00:00 2001 From: joobisb Date: Thu, 19 Dec 2024 16:29:09 +0530 Subject: [PATCH 1/4] feat: allow entering non-default values in multi-select --- cli/cliui/select.go | 110 +++++++++++++++++++++++++++++++++------ cli/cliui/select_test.go | 33 ++++++++++++ cli/prompts.go | 5 +- 3 files changed, 130 insertions(+), 18 deletions(-) diff --git a/cli/cliui/select.go b/cli/cliui/select.go index 39e547c0258ea..8d5b2886f4082 100644 --- a/cli/cliui/select.go +++ b/cli/cliui/select.go @@ -195,7 +195,7 @@ func (m selectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.cursor > 0 { m.cursor-- } else { - m.cursor = len(options) - 1 + m.cursor = len(options) } case tea.KeyDown: @@ -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 + isInputMode bool // New field to track if we're adding a custom option + customInput string // New field to store custom input + enableCustomInput bool // New field to 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.isInputMode { + 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.isInputMode = true + return m, nil + } if len(m.options) != 0 { m.selected = true return m, tea.Quit @@ -414,15 +428,17 @@ func (m multiSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyUp: options := m.filteredOptions() + maxIndex := len(options) 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 := len(options) + if m.cursor < maxIndex { m.cursor++ } else { m.cursor = 0 @@ -457,6 +473,52 @@ func (m multiSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } +// 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.options = append(m.options, &multiSelectOption{ + option: m.customInput, + chosen: true, + }) + } + // Reset input state regardless of whether input was empty + m.customInput = "" + m.isInputMode = 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 +531,19 @@ func (m multiSelectModel) View() string { return s.String() } + if m.isInputMode { + _, _ = 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 +566,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..1ee47e7ea1abf 100644 --- a/cli/prompts.go +++ b/cli/prompts.go @@ -156,9 +156,10 @@ 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: true, }) } _, _ = fmt.Fprintf(inv.Stdout, "%q are nice choices.\n", strings.Join(multiSelectValues, ", ")) From cc41fe79798c70437cadadb4bcac37e831f671ea Mon Sep 17 00:00:00 2001 From: joobisb Date: Fri, 20 Dec 2024 11:42:20 +0530 Subject: [PATCH 2/4] fix: duplicates, cursor positioning --- cli/cliui/select.go | 67 +++++++++++++++++++++++++++++++++++---------- cli/prompts.go | 13 +++++++-- 2 files changed, 63 insertions(+), 17 deletions(-) diff --git a/cli/cliui/select.go b/cli/cliui/select.go index 8d5b2886f4082..be8b6ed0abf12 100644 --- a/cli/cliui/select.go +++ b/cli/cliui/select.go @@ -378,7 +378,7 @@ type multiSelectModel struct { message string canceled bool selected bool - isInputMode bool // New field to track if we're adding a custom option + isCustomInputMode bool // New field to track if we're adding a custom option customInput string // New field to store custom input enableCustomInput bool // New field to control whether custom input is allowed } @@ -391,7 +391,7 @@ func (multiSelectModel) Init() tea.Cmd { func (m multiSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd - if m.isInputMode { + if m.isCustomInputMode { return m.handleCustomInputMode(msg) } @@ -409,7 +409,7 @@ func (m multiSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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.isInputMode = true + m.isCustomInputMode = true return m, nil } if len(m.options) != 0 { @@ -427,8 +427,7 @@ func (m multiSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.KeyUp: - options := m.filteredOptions() - maxIndex := len(options) + maxIndex := m.getMaxIndex() if m.cursor > 0 { m.cursor-- } else { @@ -436,8 +435,7 @@ func (m multiSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case tea.KeyDown: - options := m.filteredOptions() - maxIndex := len(options) + maxIndex := m.getMaxIndex() if m.cursor < maxIndex { m.cursor++ } else { @@ -473,6 +471,16 @@ 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) @@ -499,15 +507,44 @@ func (m *multiSelectModel) handleCustomInputMode(msg tea.Msg) (tea.Model, tea.Cm // handleCustomInputSubmission processes the submission of custom input func (m *multiSelectModel) handleCustomInputSubmission() (tea.Model, tea.Cmd) { - if m.customInput != "" { - m.options = append(m.options, &multiSelectOption{ - option: m.customInput, - chosen: true, - }) + 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 strings.EqualFold(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 + } } - // Reset input state regardless of whether input was empty + + // 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.isInputMode = false + m.isCustomInputMode = false return m, nil } @@ -531,7 +568,7 @@ func (m multiSelectModel) View() string { return s.String() } - if m.isInputMode { + if m.isCustomInputMode { _, _ = s.WriteString(fmt.Sprintf("%s\nEnter custom value: %s\n", msg, m.customInput)) return s.String() } diff --git a/cli/prompts.go b/cli/prompts.go index 1ee47e7ea1abf..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", @@ -159,12 +168,12 @@ func (RootCmd) promptExample() *serpent.Command { "Code", "Chairs", "Whale", "Diamond", "Carrot", }, Defaults: []string{"Code"}, - EnableCustomInput: true, + 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{ From 3a3ee034ea7ebab9d883074a664e4031cff15ac7 Mon Sep 17 00:00:00 2001 From: joobisb Date: Fri, 20 Dec 2024 11:49:04 +0530 Subject: [PATCH 3/4] chore: remove unwanted change --- cli/cliui/select.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cli/cliui/select.go b/cli/cliui/select.go index be8b6ed0abf12..212efb346acd5 100644 --- a/cli/cliui/select.go +++ b/cli/cliui/select.go @@ -195,7 +195,7 @@ func (m selectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.cursor > 0 { m.cursor-- } else { - m.cursor = len(options) + m.cursor = len(options) - 1 } case tea.KeyDown: @@ -378,9 +378,9 @@ type multiSelectModel struct { message string canceled bool selected bool - isCustomInputMode bool // New field to track if we're adding a custom option - customInput string // New field to store custom input - enableCustomInput bool // New field to control whether custom input is allowed + 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 { From 37e2dd43460773a598e0ee1865a0ed5563c00648 Mon Sep 17 00:00:00 2001 From: joobisb Date: Fri, 20 Dec 2024 16:11:27 +0530 Subject: [PATCH 4/4] remove case insensitive matching --- cli/cliui/select.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/cliui/select.go b/cli/cliui/select.go index 212efb346acd5..4697dda09d660 100644 --- a/cli/cliui/select.go +++ b/cli/cliui/select.go @@ -517,7 +517,7 @@ func (m *multiSelectModel) handleCustomInputSubmission() (tea.Model, tea.Cmd) { // Check for duplicates for i, opt := range m.options { - if strings.EqualFold(opt.option, m.customInput) { + if opt.option == m.customInput { // If the option exists but isn't chosen, select it if !opt.chosen { opt.chosen = true