Repository: coder/coder (2025-08-06T13:21:34Z)

Issue:
bug: MultiSelect skips options when if default value is not set
Related: https://github.com/coder/coder/issues/15488

There are 2 bugs related to the current `MultiSelect` implementation:
* MultiSelect does not present available options if the default is not set or is an empty array. Probably just a structure [misconfiguration](https://github.com/coder/coder/blob/760dc8b467c4d7facf051aa8072394967e50be48/cli/cliui/parameter.go#L48-L49)?
* MultiSelect ignores descriptions


Discussion:

Patch:
diff --git a/cli/cliui/parameter.go b/cli/cliui/parameter.go
index 2e639f8dfa425..d972e346bf196 100644
--- a/cli/cliui/parameter.go
+++ b/cli/cliui/parameter.go
@@ -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)
diff --git a/cli/cliui/select.go b/cli/cliui/select.go
index 40f63d92e279d..b3222cbbf3a71 100644
--- a/cli/cliui/select.go
+++ b/cli/cliui/select.go
@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"os"
 	"os/signal"
+	"slices"
 	"strings"
 	"syscall"
 
@@ -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
diff --git a/cli/exp_prompts.go b/cli/exp_prompts.go
index 225685a0c375a..ef51a1ce04398 100644
--- a/cli/exp_prompts.go
+++ b/cli/exp_prompts.go
@@ -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{


Test patch:
diff --git a/cli/cliui/select_test.go b/cli/cliui/select_test.go
index c7630ac4f2460..21fc4cb03c398 100644
--- a/cli/cliui/select_test.go
+++ b/cli/cliui/select_test.go
@@ -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)
@@ -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
@@ -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()
 }