Skip to content

feat: implement rich multi-selector #19201

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Aug 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions cli/cliui/parameter.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,16 @@ func RichParameter(inv *serpent.Invocation, templateVersionParameter codersdk.Te
// Move the cursor up a single line for nicer display!
_, _ = fmt.Fprint(inv.Stdout, "\033[1A")

var options []string
err = json.Unmarshal([]byte(templateVersionParameter.DefaultValue), &options)
var defaults []string
err = json.Unmarshal([]byte(templateVersionParameter.DefaultValue), &defaults)
if err != nil {
return "", err
}

values, err := MultiSelect(inv, MultiSelectOptions{
Options: options,
Defaults: options,
values, err := RichMultiSelect(inv, RichMultiSelectOptions{
Options: templateVersionParameter.Options,
Defaults: defaults,
EnableCustomInput: templateVersionParameter.FormType == "tag-select",
})
if err == nil {
v, err := json.Marshal(&values)
Expand Down
68 changes: 68 additions & 0 deletions cli/cliui/select.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"os"
"os/signal"
"slices"
"strings"
"syscall"

Expand Down Expand Up @@ -299,6 +300,73 @@ func (m selectModel) filteredOptions() []string {
return options
}

type RichMultiSelectOptions struct {
Message string
Options []codersdk.TemplateVersionParameterOption
Defaults []string
EnableCustomInput bool
}

func RichMultiSelect(inv *serpent.Invocation, richOptions RichMultiSelectOptions) ([]string, error) {
var opts []string
var defaultOpts []string

asLine := func(option codersdk.TemplateVersionParameterOption) string {
line := option.Name
if len(option.Description) > 0 {
line += ": " + option.Description
}
return line
}

var predefinedOpts []string
for i, option := range richOptions.Options {
opts = append(opts, asLine(option)) // Some options may have description defined.

// Check if option is selected by default
if slices.Contains(richOptions.Defaults, option.Value) {
defaultOpts = append(defaultOpts, opts[i])
predefinedOpts = append(predefinedOpts, option.Value)
}
}

// Check if "defaults" contains extra/custom options, user could select them.
for _, def := range richOptions.Defaults {
if !slices.Contains(predefinedOpts, def) {
opts = append(opts, def)
defaultOpts = append(defaultOpts, def)
}
}

selected, err := MultiSelect(inv, MultiSelectOptions{
Message: richOptions.Message,
Options: opts,
Defaults: defaultOpts,
EnableCustomInput: richOptions.EnableCustomInput,
})
if err != nil {
return nil, err
}

// Check selected option, convert descriptions (line) to values
var results []string
for _, sel := range selected {
custom := true
for i, option := range richOptions.Options {
if asLine(option) == sel {
results = append(results, richOptions.Options[i].Value)
custom = false
break
}
}

if custom {
results = append(results, sel)
}
}
return results, nil
}

type MultiSelectOptions struct {
Message string
Options []string
Expand Down
157 changes: 103 additions & 54 deletions cli/cliui/select_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,15 +52,8 @@ func TestRichSelect(t *testing.T) {
go func() {
resp, err := newRichSelect(ptty, cliui.RichSelectOptions{
Options: []codersdk.TemplateVersionParameterOption{
{
Name: "A-Name",
Value: "A-Value",
Description: "A-Description.",
}, {
Name: "B-Name",
Value: "B-Value",
Description: "B-Description.",
},
{Name: "A-Name", Value: "A-Value", Description: "A-Description."},
{Name: "B-Name", Value: "B-Value", Description: "B-Description."},
},
})
assert.NoError(t, err)
Expand All @@ -86,63 +79,119 @@ func newRichSelect(ptty *ptytest.PTY, opts cliui.RichSelectOptions) (string, err
return value, inv.Run()
}

func TestMultiSelect(t *testing.T) {
func TestRichMultiSelect(t *testing.T) {
t.Parallel()
t.Run("MultiSelect", func(t *testing.T) {
items := []string{"aaa", "bbb", "ccc"}

t.Parallel()
ptty := ptytest.New(t)
msgChan := make(chan []string)
go func() {
resp, err := newMultiSelect(ptty, items)
assert.NoError(t, err)
msgChan <- resp
}()
require.Equal(t, items, <-msgChan)
})
tests := []struct {
name string
options []codersdk.TemplateVersionParameterOption
defaults []string
allowCustom bool
want []string
}{
{
name: "Predefined",
options: []codersdk.TemplateVersionParameterOption{
{Name: "AAA", Description: "This is AAA", Value: "aaa"},
{Name: "BBB", Description: "This is BBB", Value: "bbb"},
{Name: "CCC", Description: "This is CCC", Value: "ccc"},
},
defaults: []string{"bbb", "ccc"},
allowCustom: false,
want: []string{"bbb", "ccc"},
},
{
name: "Custom",
options: []codersdk.TemplateVersionParameterOption{
{Name: "AAA", Description: "This is AAA", Value: "aaa"},
{Name: "BBB", Description: "This is BBB", Value: "bbb"},
{Name: "CCC", Description: "This is CCC", Value: "ccc"},
},
defaults: []string{"aaa", "bbb"},
allowCustom: true,
want: []string{"aaa", "bbb"},
},
}

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)
})
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

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
var selectedItems []string
var err error
cmd := &serpent.Command{
Handler: func(inv *serpent.Invocation) error {
selectedItems, err = cliui.RichMultiSelect(inv, cliui.RichMultiSelectOptions{
Options: tt.options,
Defaults: tt.defaults,
EnableCustomInput: tt.allowCustom,
})
return err
},
}
return err

doneChan := make(chan struct{})
go func() {
defer close(doneChan)
err := cmd.Invoke().Run()
assert.NoError(t, err)
}()
<-doneChan

require.Equal(t, tt.want, selectedItems)
})
}
}

func TestMultiSelect(t *testing.T) {
t.Parallel()

tests := []struct {
name string
items []string
allowCustom bool
want []string
}{
{
name: "MultiSelect",
items: []string{"aaa", "bbb", "ccc"},
allowCustom: false,
want: []string{"aaa", "bbb", "ccc"},
},
{
name: "MultiSelectWithCustomInput",
items: []string{"Code", "Chairs", "Whale", "Diamond", "Carrot"},
allowCustom: true,
want: []string{"Code", "Chairs", "Whale", "Diamond", "Carrot"},
},
}
inv := cmd.Invoke()
ptty.Attach(inv)
return values, inv.Run()

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

ptty := ptytest.New(t)
msgChan := make(chan []string)

go func() {
resp, err := newMultiSelect(ptty, tt.items, tt.allowCustom)
assert.NoError(t, err)
msgChan <- resp
}()

require.Equal(t, tt.want, <-msgChan)
})
}
}

func newMultiSelect(ptty *ptytest.PTY, items []string) ([]string, error) {
func newMultiSelect(pty *ptytest.PTY, items []string, custom bool) ([]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,
Options: items,
Defaults: items,
EnableCustomInput: custom,
})
if err == nil {
values = selectedItems
Expand All @@ -151,6 +200,6 @@ func newMultiSelect(ptty *ptytest.PTY, items []string) ([]string, error) {
},
}
inv := cmd.Invoke()
ptty.Attach(inv)
pty.Attach(inv)
return values, inv.Run()
}
14 changes: 14 additions & 0 deletions cli/exp_prompts.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,20 @@ func (RootCmd) promptExample() *serpent.Command {
_, _ = fmt.Fprintf(inv.Stdout, "%q are nice choices.\n", strings.Join(multiSelectValues, ", "))
return multiSelectError
}, useThingsOption, enableCustomInputOption),
promptCmd("rich-multi-select", func(inv *serpent.Invocation) error {
if len(multiSelectValues) == 0 {
multiSelectValues, multiSelectError = cliui.MultiSelect(inv, cliui.MultiSelectOptions{
Message: "Select some things:",
Options: []string{
"Apples", "Plums", "Grapes", "Oranges", "Bananas",
},
Defaults: []string{"Grapes", "Plums"},
EnableCustomInput: enableCustomInput,
})
}
_, _ = fmt.Fprintf(inv.Stdout, "%q are nice choices.\n", strings.Join(multiSelectValues, ", "))
return multiSelectError
}, useThingsOption, enableCustomInputOption),
promptCmd("rich-parameter", func(inv *serpent.Invocation) error {
value, err := cliui.RichSelect(inv, cliui.RichSelectOptions{
Options: []codersdk.TemplateVersionParameterOption{
Expand Down
Loading