Skip to content

Commit ce4b3c2

Browse files
committed
feat: allow entering non-default values in multi-select
1 parent dcf5153 commit ce4b3c2

File tree

3 files changed

+130
-18
lines changed

3 files changed

+130
-18
lines changed

cli/cliui/select.go

Lines changed: 94 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ func (m selectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
195195
if m.cursor > 0 {
196196
m.cursor--
197197
} else {
198-
m.cursor = len(options) - 1
198+
m.cursor = len(options)
199199
}
200200

201201
case tea.KeyDown:
@@ -300,9 +300,10 @@ func (m selectModel) filteredOptions() []string {
300300
}
301301

302302
type MultiSelectOptions struct {
303-
Message string
304-
Options []string
305-
Defaults []string
303+
Message string
304+
Options []string
305+
Defaults []string
306+
EnableCustomInput bool
306307
}
307308

308309
func MultiSelect(inv *serpent.Invocation, opts MultiSelectOptions) ([]string, error) {
@@ -328,9 +329,10 @@ func MultiSelect(inv *serpent.Invocation, opts MultiSelectOptions) ([]string, er
328329
}
329330

330331
initialModel := multiSelectModel{
331-
search: textinput.New(),
332-
options: options,
333-
message: opts.Message,
332+
search: textinput.New(),
333+
options: options,
334+
message: opts.Message,
335+
enableCustomInput: opts.EnableCustomInput,
334336
}
335337

336338
initialModel.search.Prompt = ""
@@ -370,12 +372,15 @@ type multiSelectOption struct {
370372
}
371373

372374
type multiSelectModel struct {
373-
search textinput.Model
374-
options []*multiSelectOption
375-
cursor int
376-
message string
377-
canceled bool
378-
selected bool
375+
search textinput.Model
376+
options []*multiSelectOption
377+
cursor int
378+
message string
379+
canceled bool
380+
selected bool
381+
isInputMode bool // New field to track if we're adding a custom option
382+
customInput string // New field to store custom input
383+
enableCustomInput bool // New field to control whether custom input is allowed
379384
}
380385

381386
func (multiSelectModel) Init() tea.Cmd {
@@ -386,6 +391,10 @@ func (multiSelectModel) Init() tea.Cmd {
386391
func (m multiSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
387392
var cmd tea.Cmd
388393

394+
if m.isInputMode {
395+
return m.handleCustomInputMode(msg)
396+
}
397+
389398
switch msg := msg.(type) {
390399
case terminateMsg:
391400
m.canceled = true
@@ -398,6 +407,11 @@ func (m multiSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
398407
return m, tea.Quit
399408

400409
case tea.KeyEnter:
410+
// Switch to custom input mode if we're on the "+ Add custom value:" option
411+
if m.enableCustomInput && m.cursor == len(m.filteredOptions()) {
412+
m.isInputMode = true
413+
return m, nil
414+
}
401415
if len(m.options) != 0 {
402416
m.selected = true
403417
return m, tea.Quit
@@ -414,15 +428,17 @@ func (m multiSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
414428

415429
case tea.KeyUp:
416430
options := m.filteredOptions()
431+
maxIndex := len(options)
417432
if m.cursor > 0 {
418433
m.cursor--
419434
} else {
420-
m.cursor = len(options) - 1
435+
m.cursor = maxIndex
421436
}
422437

423438
case tea.KeyDown:
424439
options := m.filteredOptions()
425-
if m.cursor < len(options)-1 {
440+
maxIndex := len(options)
441+
if m.cursor < maxIndex {
426442
m.cursor++
427443
} else {
428444
m.cursor = 0
@@ -457,6 +473,52 @@ func (m multiSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
457473
return m, cmd
458474
}
459475

476+
// handleCustomInputMode manages keyboard interactions when in custom input mode
477+
func (m *multiSelectModel) handleCustomInputMode(msg tea.Msg) (tea.Model, tea.Cmd) {
478+
keyMsg, ok := msg.(tea.KeyMsg)
479+
if !ok {
480+
return m, nil
481+
}
482+
483+
switch keyMsg.Type {
484+
case tea.KeyEnter:
485+
return m.handleCustomInputSubmission()
486+
487+
case tea.KeyCtrlC:
488+
m.canceled = true
489+
return m, tea.Quit
490+
491+
case tea.KeyBackspace:
492+
return m.handleCustomInputBackspace()
493+
494+
default:
495+
m.customInput += keyMsg.String()
496+
return m, nil
497+
}
498+
}
499+
500+
// handleCustomInputSubmission processes the submission of custom input
501+
func (m *multiSelectModel) handleCustomInputSubmission() (tea.Model, tea.Cmd) {
502+
if m.customInput != "" {
503+
m.options = append(m.options, &multiSelectOption{
504+
option: m.customInput,
505+
chosen: true,
506+
})
507+
}
508+
// Reset input state regardless of whether input was empty
509+
m.customInput = ""
510+
m.isInputMode = false
511+
return m, nil
512+
}
513+
514+
// handleCustomInputBackspace handles backspace in custom input mode
515+
func (m *multiSelectModel) handleCustomInputBackspace() (tea.Model, tea.Cmd) {
516+
if len(m.customInput) > 0 {
517+
m.customInput = m.customInput[:len(m.customInput)-1]
518+
}
519+
return m, nil
520+
}
521+
460522
func (m multiSelectModel) View() string {
461523
var s strings.Builder
462524

@@ -469,13 +531,19 @@ func (m multiSelectModel) View() string {
469531
return s.String()
470532
}
471533

534+
if m.isInputMode {
535+
_, _ = s.WriteString(fmt.Sprintf("%s\nEnter custom value: %s\n", msg, m.customInput))
536+
return s.String()
537+
}
538+
472539
_, _ = s.WriteString(fmt.Sprintf(
473540
"%s %s[Use arrows to move, space to select, <right> to all, <left> to none, type to filter]\n",
474541
msg,
475542
m.search.View(),
476543
))
477544

478-
for i, option := range m.filteredOptions() {
545+
options := m.filteredOptions()
546+
for i, option := range options {
479547
cursor := " "
480548
chosen := "[ ]"
481549
o := option.option
@@ -498,6 +566,16 @@ func (m multiSelectModel) View() string {
498566
))
499567
}
500568

569+
if m.enableCustomInput {
570+
// Add the "+ Add custom value" option at the bottom
571+
cursor := " "
572+
text := " + Add custom value"
573+
if m.cursor == len(options) {
574+
cursor = pretty.Sprint(DefaultStyles.Keyword, "> ")
575+
text = pretty.Sprint(DefaultStyles.Keyword, text)
576+
}
577+
_, _ = s.WriteString(fmt.Sprintf("%s%s\n", cursor, text))
578+
}
501579
return s.String()
502580
}
503581

cli/cliui/select_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,39 @@ func TestMultiSelect(t *testing.T) {
101101
}()
102102
require.Equal(t, items, <-msgChan)
103103
})
104+
105+
t.Run("MultiSelectWithCustomInput", func(t *testing.T) {
106+
t.Parallel()
107+
items := []string{"Code", "Chairs", "Whale", "Diamond", "Carrot"}
108+
ptty := ptytest.New(t)
109+
msgChan := make(chan []string)
110+
go func() {
111+
resp, err := newMultiSelectWithCustomInput(ptty, items)
112+
assert.NoError(t, err)
113+
msgChan <- resp
114+
}()
115+
require.Equal(t, items, <-msgChan)
116+
})
117+
}
118+
119+
func newMultiSelectWithCustomInput(ptty *ptytest.PTY, items []string) ([]string, error) {
120+
var values []string
121+
cmd := &serpent.Command{
122+
Handler: func(inv *serpent.Invocation) error {
123+
selectedItems, err := cliui.MultiSelect(inv, cliui.MultiSelectOptions{
124+
Options: items,
125+
Defaults: items,
126+
EnableCustomInput: true,
127+
})
128+
if err == nil {
129+
values = selectedItems
130+
}
131+
return err
132+
},
133+
}
134+
inv := cmd.Invoke()
135+
ptty.Attach(inv)
136+
return values, inv.Run()
104137
}
105138

106139
func newMultiSelect(ptty *ptytest.PTY, items []string) ([]string, error) {

cli/prompts.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,9 +156,10 @@ func (RootCmd) promptExample() *serpent.Command {
156156
multiSelectValues, multiSelectError = cliui.MultiSelect(inv, cliui.MultiSelectOptions{
157157
Message: "Select some things:",
158158
Options: []string{
159-
"Code", "Chair", "Whale", "Diamond", "Carrot",
159+
"Code", "Chairs", "Whale", "Diamond", "Carrot",
160160
},
161-
Defaults: []string{"Code"},
161+
Defaults: []string{"Code"},
162+
EnableCustomInput: true,
162163
})
163164
}
164165
_, _ = fmt.Fprintf(inv.Stdout, "%q are nice choices.\n", strings.Join(multiSelectValues, ", "))

0 commit comments

Comments
 (0)