From 0beb74bf729349c09fb3e8144226639bb0472fe6 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Sat, 22 Nov 2025 15:01:55 -0700 Subject: [PATCH] MultiSelectWithSearch initial implementation Initial implementation of MultiSelectWithSearch: - Implement by survey and accessible prompters. They use the same internal func under the hood. - Implement in `gh preview prompter` for initial testing and demonstration - Implement interface changes across the codebase and mocks to satisfy compiler. - Implement tests for new MultiSelectWithSearch prompter --- internal/prompter/accessible_prompter_test.go | 162 ++++++++++++++++++ internal/prompter/prompter.go | 154 +++++++++++++++++ internal/prompter/prompter_mock.go | 86 +++++++++- internal/prompter/test.go | 25 ++- pkg/cmd/pr/shared/survey.go | 5 + pkg/cmd/preview/prompter/prompter.go | 77 +++++++-- 6 files changed, 478 insertions(+), 31 deletions(-) diff --git a/internal/prompter/accessible_prompter_test.go b/internal/prompter/accessible_prompter_test.go index 770ff0e76..03baa34b7 100644 --- a/internal/prompter/accessible_prompter_test.go +++ b/internal/prompter/accessible_prompter_test.go @@ -224,6 +224,167 @@ func TestAccessiblePrompter(t *testing.T) { assert.Equal(t, []int{1}, multiSelectValues) }) + t.Run("MultiSelectWithSearch - basic flow", func(t *testing.T) { + console := newTestVirtualTerminal(t) + p := newTestAccessiblePrompter(t, console) + persistentOptions := []string{"persistent-option-1"} + searchFunc := func(input string) ([]string, []string, int, error) { + var searchResultKeys []string + var searchResultLabels []string + + // Initial search with no input + if input == "" { + moreResults := 2 + searchResultKeys = []string{"initial-result-1", "initial-result-2"} + searchResultLabels = []string{"Initial Result Label 1", "Initial Result Label 2"} + return searchResultKeys, searchResultLabels, moreResults, nil + } + + // Subsequent search with input + moreResults := 0 + searchResultKeys = []string{"search-result-1", "search-result-2"} + searchResultLabels = []string{"Search Result Label 1", "Search Result Label 2"} + return searchResultKeys, searchResultLabels, moreResults, nil + } + + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Select an option \r\n") + require.NoError(t, err) + + // Select the search option, which will always be the first option + _, err = console.SendLine("1") + require.NoError(t, err) + + // Submit search + _, err = console.SendLine("0") + require.NoError(t, err) + + // Wait for the search prompt to appear + _, err = console.ExpectString("Search for an option") + require.NoError(t, err) + + // Enter some search text to trigger the search + _, err = console.SendLine("search text") + require.NoError(t, err) + + // Wait for the multiselect prompt to re-appear after search + _, err = console.ExpectString("Select an option \r\n") + require.NoError(t, err) + + // Select the first search result + _, err = console.SendLine("2") + require.NoError(t, err) + + // This confirms selections + _, err = console.SendLine("0") + require.NoError(t, err) + }() + multiSelectValues, err := p.MultiSelectWithSearch("Select an option", "Search for an option", []string{}, persistentOptions, searchFunc) + require.NoError(t, err) + assert.Equal(t, []string{"search-result-1"}, multiSelectValues) + }) + + t.Run("MultiSelectWithSearch - defaults are pre-selected", func(t *testing.T) { + console := newTestVirtualTerminal(t) + p := newTestAccessiblePrompter(t, console) + initialSearchResultKeys := []string{"initial-result-1"} + initialSearchResultLabels := []string{"Initial Result Label 1"} + defaultOptions := initialSearchResultKeys + searchFunc := func(input string) ([]string, []string, int, error) { + // Initial search with no input + if input == "" { + moreResults := 2 + return initialSearchResultKeys, initialSearchResultLabels, moreResults, nil + } + + // No search selected, so this should fail the test. + t.FailNow() + return nil, nil, 0, nil + } + + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Select an option (default: Initial Result Label 1) \r\n") + require.NoError(t, err) + + // This confirms default selections + _, err = console.SendLine("0") + require.NoError(t, err) + }() + multiSelectValues, err := p.MultiSelectWithSearch("Select an option", "Search for an option", defaultOptions, initialSearchResultKeys, searchFunc) + require.NoError(t, err) + assert.Equal(t, defaultOptions, multiSelectValues) + }) + + t.Run("MultiSelectWithSearch - selected options persist between searches", func(t *testing.T) { + console := newTestVirtualTerminal(t) + p := newTestAccessiblePrompter(t, console) + initialSearchResultKeys := []string{"initial-result-1"} + initialSearchResultLabels := []string{"Initial Result Label 1"} + moreResultKeys := []string{"more-result-1"} + moreResultLabels := []string{"More Result Label 1"} + + searchFunc := func(input string) ([]string, []string, int, error) { + // Initial search with no input + if input == "" { + moreResults := 2 + return initialSearchResultKeys, initialSearchResultLabels, moreResults, nil + } + + // Subsequent search with input "more" + if input == "more" { + return moreResultKeys, moreResultLabels, 0, nil + } + + // No other searches expected + t.FailNow() + return nil, nil, 0, nil + } + + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Select an option \r\n") + require.NoError(t, err) + + // Select one of our initial search results + _, err = console.SendLine("2") + require.NoError(t, err) + + // Select to search + _, err = console.SendLine("1") + require.NoError(t, err) + + // Submit the search selection + _, err = console.SendLine("0") + require.NoError(t, err) + + // Wait for the search prompt to appear + _, err = console.ExpectString("Search for an option") + require.NoError(t, err) + + // Enter some search text to trigger the search + _, err = console.SendLine("more") + require.NoError(t, err) + + // Wait for the multiselect prompt to re-appear after search + _, err = console.ExpectString("Select up to") + require.NoError(t, err) + + // Select the new option from the new search results + _, err = console.SendLine("3") + require.NoError(t, err) + + // Submit selections + _, err = console.SendLine("0") + require.NoError(t, err) + }() + multiSelectValues, err := p.MultiSelectWithSearch("Select an option", "Search for an option", []string{}, []string{}, searchFunc) + require.NoError(t, err) + expectedValues := append(initialSearchResultKeys, moreResultKeys...) + assert.Equal(t, expectedValues, multiSelectValues) + }) + t.Run("Input", func(t *testing.T) { console := newTestVirtualTerminal(t) p := newTestAccessiblePrompter(t, console) @@ -642,6 +803,7 @@ func newTestVirtualTerminal(t *testing.T) *expect.Console { failOnExpectError(t), failOnSendError(t), expect.WithDefaultTimeout(time.Second), + // expect.WithLogger(log.New(os.Stdout, "", 0)), } console, err := expect.NewConsole(consoleOpts...) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index c2233fd92..dddb49035 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -21,6 +21,15 @@ type Prompter interface { Select(prompt string, defaultValue string, options []string) (int, error) // MultiSelect prompts the user to select one or more options from a list of options. MultiSelect(prompt string, defaults []string, options []string) ([]int, error) + // MultiSelectWithSearch is MultiSelect with an added search option to the list, + // prompting the user for text input to filter the options via the searchFunc. + // Items selected in the search are persisted in the list after subsequent searches. + // Items passed in persistentOptions are always shown in the list, even when not selected. + // Unlike MultiSelect, MultiselectWithSearch returns the selected option strings, + // not their indices, since the list of options is dynamic. + // The searchFunc args and return values are: func(query) (map[keys]labels, moreResultsCount, searchError) + // Where the selected keys are eventually returned by MultiSelectWithSearch and the labels are what is shown to the user in the prompt. + MultiSelectWithSearch(prompt, searchPrompt string, defaults []string, persistentOptions []string, searchFunc func(string) ([]string, []string, int, error)) ([]string, error) // Input prompts the user to enter a string value. Input(prompt string, defaultValue string) (string, error) // Password prompts the user to enter a password. @@ -320,6 +329,10 @@ func (p *accessiblePrompter) MarkdownEditor(prompt, defaultValue string, blankAl return text, nil } +func (p *accessiblePrompter) MultiSelectWithSearch(prompt, searchPrompt string, defaultValues, persistentValues []string, searchFunc func(string) ([]string, []string, int, error)) ([]string, error) { + return multiSelectWithSearch(p, prompt, searchPrompt, defaultValues, persistentValues, searchFunc) +} + type surveyPrompter struct { prompter *ghPrompter.Prompter stdin ghPrompter.FileReader @@ -336,6 +349,147 @@ func (p *surveyPrompter) MultiSelect(prompt string, defaultValues, options []str return p.prompter.MultiSelect(prompt, defaultValues, options) } +func (p *surveyPrompter) MultiSelectWithSearch(prompt string, searchPrompt string, defaultValues, persistentValues []string, searchFunc func(string) ([]string, []string, int, error)) ([]string, error) { + return multiSelectWithSearch(p, prompt, searchPrompt, defaultValues, persistentValues, searchFunc) +} + +func multiSelectWithSearch(p Prompter, prompt, searchPrompt string, defaultValues, persistentValues []string, searchFunc func(string) ([]string, []string, int, error)) ([]string, error) { + selectedOptions := defaultValues + + // The optionKeyLabels map is used to uniquely identify optionKeyLabels + // and provide optional display labels. + optionKeyLabels := make(map[string]string) + for _, k := range selectedOptions { + optionKeyLabels[k] = k + } + + searchResultKeys, searchResultLabels, moreResults, err := searchFunc("") + if err != nil { + return nil, fmt.Errorf("failed to search: %w", err) + } + + for i, k := range searchResultKeys { + optionKeyLabels[k] = searchResultLabels[i] + } + + for { + // Build dynamic option list -> search sentinel, selections, search results, persistent options. + optionKeys := make([]string, 0, 1+len(selectedOptions)+len(searchResultKeys)+len(persistentValues)) + optionLabels := make([]string, 0, len(optionKeys)) + + // 1. Search sentinel. + optionKeys = append(optionKeys, "") + if moreResults > 0 { + optionLabels = append(optionLabels, fmt.Sprintf("Search (%d more)", moreResults)) + } else { + optionLabels = append(optionLabels, "Search") + } + + // 2. Selections + for _, k := range selectedOptions { + l := optionKeyLabels[k] + + if l == "" { + l = k + } + + optionKeys = append(optionKeys, k) + optionLabels = append(optionLabels, l) + } + + // 3. Search results + for _, k := range searchResultKeys { + // It's already selected or persistent, if we add here we'll have duplicates. + if slices.Contains(selectedOptions, k) || slices.Contains(persistentValues, k) { + continue + } + + l := optionKeyLabels[k] + if l == "" { + l = k + } + optionKeys = append(optionKeys, k) + optionLabels = append(optionLabels, l) + } + + // 4. Persistent options + for _, k := range persistentValues { + if slices.Contains(selectedOptions, k) { + continue + } + + l := optionKeyLabels[k] + if l == "" { + l = k + } + + optionKeys = append(optionKeys, k) + optionLabels = append(optionLabels, l) + } + + selectedOptionLabels := make([]string, len(selectedOptions)) + for i, k := range selectedOptions { + l := optionKeyLabels[k] + if l == "" { + l = k + } + selectedOptionLabels[i] = l + } + + selectedIdxs, err := p.MultiSelect(prompt, selectedOptionLabels, optionLabels) + if err != nil { + return nil, err + } + + pickedSearch := false + var newSelectedOptions []string + for _, idx := range selectedIdxs { + if idx == 0 { // Search sentinel selected + pickedSearch = true + continue + } + + if idx < 0 || idx >= len(optionKeys) { + continue + } + + key := optionKeys[idx] + if key == "" { + continue + } + + newSelectedOptions = append(newSelectedOptions, key) + } + + selectedOptions = newSelectedOptions + for _, k := range selectedOptions { + if _, ok := optionKeyLabels[k]; !ok { + optionKeyLabels[k] = k + } + } + + if pickedSearch { + query, err := p.Input(searchPrompt, "") + if err != nil { + return nil, err + } + + searchResultKeys, searchResultLabels, moreResults, err = searchFunc(query) + if err != nil { + return nil, err + } + + for i, k := range searchResultKeys { + optionKeyLabels[k] = searchResultLabels[i] + } + + continue + } + + return selectedOptions, nil + } +} + func (p *surveyPrompter) Input(prompt, defaultValue string) (string, error) { return p.prompter.Input(prompt, defaultValue) } diff --git a/internal/prompter/prompter_mock.go b/internal/prompter/prompter_mock.go index b15f8bf96..4543004a0 100644 --- a/internal/prompter/prompter_mock.go +++ b/internal/prompter/prompter_mock.go @@ -38,6 +38,9 @@ var _ Prompter = &PrompterMock{} // MultiSelectFunc: func(prompt string, defaults []string, options []string) ([]int, error) { // panic("mock out the MultiSelect method") // }, +// MultiSelectWithSearchFunc: func(prompt string, searchPrompt string, defaults []string, persistentOptions []string, searchFunc func(string) ([]string, []string, int, error)) ([]string, error) { +// panic("mock out the MultiSelectWithSearch method") +// }, // PasswordFunc: func(prompt string) (string, error) { // panic("mock out the Password method") // }, @@ -72,6 +75,9 @@ type PrompterMock struct { // MultiSelectFunc mocks the MultiSelect method. MultiSelectFunc func(prompt string, defaults []string, options []string) ([]int, error) + // MultiSelectWithSearchFunc mocks the MultiSelectWithSearch method. + MultiSelectWithSearchFunc func(prompt string, searchPrompt string, defaults []string, persistentOptions []string, searchFunc func(string) ([]string, []string, int, error)) ([]string, error) + // PasswordFunc mocks the Password method. PasswordFunc func(prompt string) (string, error) @@ -123,6 +129,19 @@ type PrompterMock struct { // Options is the options argument value. Options []string } + // MultiSelectWithSearch holds details about calls to the MultiSelectWithSearch method. + MultiSelectWithSearch []struct { + // Prompt is the prompt argument value. + Prompt string + // SearchPrompt is the searchPrompt argument value. + SearchPrompt string + // Defaults is the defaults argument value. + Defaults []string + // PersistentOptions is the persistentOptions argument value. + PersistentOptions []string + // SearchFunc is the searchFunc argument value. + SearchFunc func(string) ([]string, []string, int, error) + } // Password holds details about calls to the Password method. Password []struct { // Prompt is the prompt argument value. @@ -138,15 +157,16 @@ type PrompterMock struct { Options []string } } - lockAuthToken sync.RWMutex - lockConfirm sync.RWMutex - lockConfirmDeletion sync.RWMutex - lockInput sync.RWMutex - lockInputHostname sync.RWMutex - lockMarkdownEditor sync.RWMutex - lockMultiSelect sync.RWMutex - lockPassword sync.RWMutex - lockSelect sync.RWMutex + lockAuthToken sync.RWMutex + lockConfirm sync.RWMutex + lockConfirmDeletion sync.RWMutex + lockInput sync.RWMutex + lockInputHostname sync.RWMutex + lockMarkdownEditor sync.RWMutex + lockMultiSelect sync.RWMutex + lockMultiSelectWithSearch sync.RWMutex + lockPassword sync.RWMutex + lockSelect sync.RWMutex } // AuthToken calls AuthTokenFunc. @@ -387,6 +407,54 @@ func (mock *PrompterMock) MultiSelectCalls() []struct { return calls } +// MultiSelectWithSearch calls MultiSelectWithSearchFunc. +func (mock *PrompterMock) MultiSelectWithSearch(prompt string, searchPrompt string, defaults []string, persistentOptions []string, searchFunc func(string) ([]string, []string, int, error)) ([]string, error) { + if mock.MultiSelectWithSearchFunc == nil { + panic("PrompterMock.MultiSelectWithSearchFunc: method is nil but Prompter.MultiSelectWithSearch was just called") + } + callInfo := struct { + Prompt string + SearchPrompt string + Defaults []string + PersistentOptions []string + SearchFunc func(string) ([]string, []string, int, error) + }{ + Prompt: prompt, + SearchPrompt: searchPrompt, + Defaults: defaults, + PersistentOptions: persistentOptions, + SearchFunc: searchFunc, + } + mock.lockMultiSelectWithSearch.Lock() + mock.calls.MultiSelectWithSearch = append(mock.calls.MultiSelectWithSearch, callInfo) + mock.lockMultiSelectWithSearch.Unlock() + return mock.MultiSelectWithSearchFunc(prompt, searchPrompt, defaults, persistentOptions, searchFunc) +} + +// MultiSelectWithSearchCalls gets all the calls that were made to MultiSelectWithSearch. +// Check the length with: +// +// len(mockedPrompter.MultiSelectWithSearchCalls()) +func (mock *PrompterMock) MultiSelectWithSearchCalls() []struct { + Prompt string + SearchPrompt string + Defaults []string + PersistentOptions []string + SearchFunc func(string) ([]string, []string, int, error) +} { + var calls []struct { + Prompt string + SearchPrompt string + Defaults []string + PersistentOptions []string + SearchFunc func(string) ([]string, []string, int, error) + } + mock.lockMultiSelectWithSearch.RLock() + calls = mock.calls.MultiSelectWithSearch + mock.lockMultiSelectWithSearch.RUnlock() + return calls +} + // Password calls PasswordFunc. func (mock *PrompterMock) Password(prompt string) (string, error) { if mock.PasswordFunc == nil { diff --git a/internal/prompter/test.go b/internal/prompter/test.go index dfa124fca..adaa0db6d 100644 --- a/internal/prompter/test.go +++ b/internal/prompter/test.go @@ -25,10 +25,11 @@ func NewMockPrompter(t *testing.T) *MockPrompter { type MockPrompter struct { t *testing.T ghPrompter.PrompterMock - authTokenStubs []authTokenStub - confirmDeletionStubs []confirmDeletionStub - inputHostnameStubs []inputHostnameStub - markdownEditorStubs []markdownEditorStub + authTokenStubs []authTokenStub + confirmDeletionStubs []confirmDeletionStub + inputHostnameStubs []inputHostnameStub + markdownEditorStubs []markdownEditorStub + multiSelectWithSearchStubs []multiSelectWithSearchStub } type authTokenStub struct { @@ -49,6 +50,12 @@ type markdownEditorStub struct { fn func(string, string, bool) (string, error) } +type multiSelectWithSearchStub struct { + prompt string + searchPrompt string + fn func(string, string, []string, []string) ([]string, error) +} + func (m *MockPrompter) AuthToken() (string, error) { var s authTokenStub if len(m.authTokenStubs) == 0 { @@ -92,6 +99,16 @@ func (m *MockPrompter) MarkdownEditor(prompt, defaultValue string, blankAllowed return s.fn(prompt, defaultValue, blankAllowed) } +func (m *MockPrompter) MultiSelectWithSearch(prompt, searchPrompt string, defaults []string, persistentOptions []string, searchFunc func(string) ([]string, []string, int, error)) ([]string, error) { + var s multiSelectWithSearchStub + if len(m.multiSelectWithSearchStubs) == 0 { + return nil, NoSuchPromptErr(prompt) + } + s = m.multiSelectWithSearchStubs[0] + m.multiSelectWithSearchStubs = m.multiSelectWithSearchStubs[1:len(m.multiSelectWithSearchStubs)] + return s.fn(prompt, searchPrompt, defaults, persistentOptions) +} + func (m *MockPrompter) RegisterAuthToken(stub func() (string, error)) { m.authTokenStubs = append(m.authTokenStubs, authTokenStub{fn: stub}) } diff --git a/pkg/cmd/pr/shared/survey.go b/pkg/cmd/pr/shared/survey.go index 56885487e..5197d6ace 100644 --- a/pkg/cmd/pr/shared/survey.go +++ b/pkg/cmd/pr/shared/survey.go @@ -40,6 +40,7 @@ type Prompt interface { MarkdownEditor(prompt string, defaultValue string, blankAllowed bool) (string, error) Confirm(prompt string, defaultValue bool) (bool, error) MultiSelect(prompt string, defaults []string, options []string) ([]int, error) + MultiSelectWithSearch(prompt, searchPrompt string, defaults []string, persistentOptions []string, searchFunc func(string) ([]string, []string, int, error)) ([]string, error) } func ConfirmIssueSubmission(p Prompt, allowPreview bool, allowMetadata bool) (Action, error) { @@ -207,6 +208,7 @@ func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface // Populate the list of selectable assignees and their default selections. // This logic maps the default assignees from `state` to the corresponding actors or users // so that the correct display names are preselected in the prompt. + // TODO: KW21 This will need to go away since we're going to dynamically load assignees via search. var assignees []string var assigneesDefault []string if state.ActorAssignees { @@ -264,6 +266,9 @@ func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface fmt.Fprintln(io.ErrOut, "warning: no available reviewers") } } + // TODO: KW21 This will need to change to use MultiSelectWithSearch once it's implemented. + // MultiSelectWithSearch will return the selected strings directly instead of indices, + // so the logic here will need to be updated accordingly. if isChosen("Assignees") { if len(assignees) > 0 { selected, err := p.MultiSelect("Assignees", assigneesDefault, assignees) diff --git a/pkg/cmd/preview/prompter/prompter.go b/pkg/cmd/preview/prompter/prompter.go index 5b44a5cbf..35ff901d4 100644 --- a/pkg/cmd/preview/prompter/prompter.go +++ b/pkg/cmd/preview/prompter/prompter.go @@ -2,6 +2,7 @@ package prompter import ( "fmt" + "strings" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/gh" @@ -25,32 +26,35 @@ func NewCmdPrompter(f *cmdutil.Factory, runF func(*prompterOptions) error) *cobr } const ( - selectPrompt = "select" - multiSelectPrompt = "multi-select" - inputPrompt = "input" - passwordPrompt = "password" - confirmPrompt = "confirm" - authTokenPrompt = "auth-token" - confirmDeletionPrompt = "confirm-deletion" - inputHostnamePrompt = "input-hostname" - markdownEditorPrompt = "markdown-editor" + selectPrompt = "select" + multiSelectPrompt = "multi-select" + multiSelectWithSearchPrompt = "multi-select-with-search" + inputPrompt = "input" + passwordPrompt = "password" + confirmPrompt = "confirm" + authTokenPrompt = "auth-token" + confirmDeletionPrompt = "confirm-deletion" + inputHostnamePrompt = "input-hostname" + markdownEditorPrompt = "markdown-editor" ) prompterTypeFuncMap := map[string]func(prompter.Prompter, *iostreams.IOStreams) error{ - selectPrompt: runSelect, - multiSelectPrompt: runMultiSelect, - inputPrompt: runInput, - passwordPrompt: runPassword, - confirmPrompt: runConfirm, - authTokenPrompt: runAuthToken, - confirmDeletionPrompt: runConfirmDeletion, - inputHostnamePrompt: runInputHostname, - markdownEditorPrompt: runMarkdownEditor, + selectPrompt: runSelect, + multiSelectPrompt: runMultiSelect, + multiSelectWithSearchPrompt: runMultiSelectWithSearch, + inputPrompt: runInput, + passwordPrompt: runPassword, + confirmPrompt: runConfirm, + authTokenPrompt: runAuthToken, + confirmDeletionPrompt: runConfirmDeletion, + inputHostnamePrompt: runInputHostname, + markdownEditorPrompt: runMarkdownEditor, } allPromptsOrder := []string{ selectPrompt, multiSelectPrompt, + multiSelectWithSearchPrompt, inputPrompt, passwordPrompt, confirmPrompt, @@ -70,6 +74,7 @@ func NewCmdPrompter(f *cmdutil.Factory, runF func(*prompterOptions) error) *cobr Available prompt types: - select - multi-select + - multi-select-with-search - input - password - confirm @@ -149,6 +154,42 @@ func runMultiSelect(p prompter.Prompter, io *iostreams.IOStreams) error { return nil } +func runMultiSelectWithSearch(p prompter.Prompter, io *iostreams.IOStreams) error { + fmt.Fprintln(io.Out, "Demonstrating Multi Select With Search") + persistentOptions := []string{"persistent-option-1"} + searchFunc := func(input string) ([]string, []string, int, error) { + var searchResultKeys []string + var searchResultLabels []string + + if input == "" { + moreResults := 2 // Indicate that there are more results available + searchResultKeys = []string{"initial-result-1", "initial-result-2"} + searchResultLabels = []string{"Initial Result Label 1", "Initial Result Label 2"} + return searchResultKeys, searchResultLabels, moreResults, nil + } + + // In a real implementation, this function would perform a search based on the input. + // Here, we return a static set of options for demonstration purposes. + moreResults := 0 + searchResultKeys = []string{"search-result-1", "search-result-2"} + searchResultLabels = []string{"Search Result Label 1", "Search Result Label 2"} + return searchResultKeys, searchResultLabels, moreResults, nil + } + + selections, err := p.MultiSelectWithSearch("Select an option", "Search for an option", []string{}, persistentOptions, searchFunc) + if err != nil { + return err + } + + if len(selections) == 0 { + fmt.Fprintln(io.Out, "No options selected.") + return nil + } + + fmt.Fprintf(io.Out, "Selected options: %s\n", strings.Join(selections, ", ")) + return nil +} + func runInput(p prompter.Prompter, io *iostreams.IOStreams) error { fmt.Fprintln(io.Out, "Demonstrating Text Input") text, err := p.Input("Favorite meal?", "Breakfast")