package prompter import ( "fmt" "slices" "strings" "charm.land/huh/v2" "github.com/AlecAivazis/survey/v2" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/pkg/surveyext" ghPrompter "github.com/cli/go-gh/v2/pkg/prompter" ) //go:generate moq -rm -out prompter_mock.go . Prompter type Prompter interface { // generic prompts from go-gh // Select prompts the user to select an option from a list of options. 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 has the signature: func(query string) MultiSelectSearchResult. // In the returned MultiSelectSearchResult, Keys are the values eventually returned by MultiSelectWithSearch and Labels are what is shown to the user in the prompt. MultiSelectWithSearch(prompt, searchPrompt string, defaults []string, persistentOptions []string, searchFunc func(string) MultiSelectSearchResult) ([]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. Password(prompt string) (string, error) // Confirm prompts the user to confirm an action. Confirm(prompt string, defaultValue bool) (bool, error) // gh specific prompts // AuthToken prompts the user to enter an authentication token. AuthToken() (string, error) // ConfirmDeletion prompts the user to confirm deletion of a resource by // typing the requiredValue. ConfirmDeletion(requiredValue string) error // InputHostname prompts the user to enter a hostname. InputHostname() (string, error) // MarkdownEditor prompts the user to edit a markdown document in an editor. // If blankAllowed is true, the user can skip the editor and an empty string // will be returned. MarkdownEditor(prompt string, defaultValue string, blankAllowed bool) (string, error) } func New(editorCmd string, io *iostreams.IOStreams) Prompter { if io.ExperimentalPrompterEnabled() { return &huhPrompter{ stdin: io.In, stdout: io.Out, stderr: io.ErrOut, editorCmd: editorCmd, } } if io.AccessiblePrompterEnabled() { return &accessiblePrompter{ stdin: io.In, stdout: io.Out, stderr: io.ErrOut, editorCmd: editorCmd, } } return &surveyPrompter{ prompter: ghPrompter.New(io.In, io.Out, io.ErrOut), stdin: io.In, stdout: io.Out, stderr: io.ErrOut, editorCmd: editorCmd, } } type accessiblePrompter struct { stdin ghPrompter.FileReader stdout ghPrompter.FileWriter stderr ghPrompter.FileWriter editorCmd string } func (p *accessiblePrompter) newForm(groups ...*huh.Group) *huh.Form { return huh.NewForm(groups...). WithTheme(huh.ThemeFunc(huh.ThemeBase16)). WithAccessible(true). WithInput(p.stdin). WithOutput(p.stdout) } // addDefaultsToPrompt adds default values to the prompt string. func (p *accessiblePrompter) addDefaultsToPrompt(prompt string, defaultValues []string) string { // Removing empty defaults from the slice. defaultValues = slices.DeleteFunc(defaultValues, func(s string) bool { return s == "" }) // Pluralizing the prompt if there are multiple default values. if len(defaultValues) == 1 { prompt = fmt.Sprintf("%s (default: %s)", prompt, defaultValues[0]) } else if len(defaultValues) > 1 { prompt = fmt.Sprintf("%s (defaults: %s)", prompt, strings.Join(defaultValues, ", ")) } // Zero-length defaultValues means return prompt unchanged. return prompt } func (p *accessiblePrompter) Select(prompt, defaultValue string, options []string) (int, error) { var result int // Remove invalid default values from the defaults slice. if !slices.Contains(options, defaultValue) { defaultValue = "" } prompt = p.addDefaultsToPrompt(prompt, []string{defaultValue}) formOptions := []huh.Option[int]{} for i, o := range options { // If this option is the default value, assign its index // to the result variable. huh will treat it as a default selection. if defaultValue == o { result = i } formOptions = append(formOptions, huh.NewOption(o, i)) } form := p.newForm( huh.NewGroup( huh.NewSelect[int](). Title(prompt). Value(&result). Options(formOptions...), ), ) err := form.Run() return result, err } func (p *accessiblePrompter) MultiSelect(prompt string, defaults []string, options []string) ([]int, error) { var result []int // Remove invalid default values from the defaults slice. defaults = slices.DeleteFunc(defaults, func(s string) bool { return !slices.Contains(options, s) }) prompt = p.addDefaultsToPrompt(prompt, defaults) formOptions := make([]huh.Option[int], len(options)) for i, o := range options { // If this option is in the defaults slice, // let's add its index to the result slice and huh // will treat it as a default selection. if slices.Contains(defaults, o) { result = append(result, i) } formOptions[i] = huh.NewOption(o, i) } form := p.newForm( huh.NewGroup( huh.NewMultiSelect[int](). Title(prompt). Value(&result). Limit(len(options)). Options(formOptions...), ), ) if err := form.Run(); err != nil { return nil, err } return result, nil } func (p *accessiblePrompter) Input(prompt, defaultValue string) (string, error) { result := defaultValue prompt = p.addDefaultsToPrompt(prompt, []string{defaultValue}) form := p.newForm( huh.NewGroup( huh.NewInput(). Title(prompt). Value(&result), ), ) err := form.Run() return result, err } func (p *accessiblePrompter) Password(prompt string) (string, error) { var result string // EchoModePassword is not used as password masking is unsupported in huh. // EchoModeNone and EchoModePassword have the same effect of hiding user input. form := p.newForm( huh.NewGroup( huh.NewInput(). EchoMode(huh.EchoModeNone). Title(prompt). Value(&result), ), ) err := form.Run() if err != nil { return "", err } return result, nil } func (p *accessiblePrompter) Confirm(prompt string, defaultValue bool) (bool, error) { result := defaultValue if defaultValue { prompt = p.addDefaultsToPrompt(prompt, []string{"yes"}) } else { prompt = p.addDefaultsToPrompt(prompt, []string{"no"}) } form := p.newForm( huh.NewGroup( huh.NewConfirm(). Title(prompt). Value(&result), ), ) if err := form.Run(); err != nil { return false, err } return result, nil } func (p *accessiblePrompter) AuthToken() (string, error) { var result string // EchoModeNone and EchoModePassword both result in disabling echo mode // as password masking is outside of VT100 spec. form := p.newForm( huh.NewGroup( huh.NewInput(). EchoMode(huh.EchoModeNone). Title("Paste your authentication token:"). // Note: if this validation fails, the prompt loops. Validate(func(input string) error { if input == "" { return fmt.Errorf("token is required") } return nil }). Value(&result), ), ) err := form.Run() return result, err } func (p *accessiblePrompter) ConfirmDeletion(requiredValue string) error { form := p.newForm( huh.NewGroup( huh.NewInput(). Title(fmt.Sprintf("Type %q to confirm deletion", requiredValue)). Validate(func(input string) error { if input != requiredValue { return fmt.Errorf("You entered: %q", input) } return nil }), ), ) return form.Run() } func (p *accessiblePrompter) InputHostname() (string, error) { var result string form := p.newForm( huh.NewGroup( huh.NewInput(). Title("Hostname:"). Validate(ghinstance.HostnameValidator). Value(&result), ), ) err := form.Run() if err != nil { return "", err } return result, nil } func (p *accessiblePrompter) MarkdownEditor(prompt, defaultValue string, blankAllowed bool) (string, error) { var result string skipOption := "skip" launchOption := "launch" options := []huh.Option[string]{ huh.NewOption(fmt.Sprintf("Launch %s", surveyext.EditorName(p.editorCmd)), launchOption), } if blankAllowed { options = append(options, huh.NewOption("Skip", skipOption)) } form := p.newForm( huh.NewGroup( huh.NewSelect[string](). Title(prompt). Options(options...). Value(&result), ), ) if err := form.Run(); err != nil { return "", err } if result == skipOption { return "", nil } // launchOption was selected text, err := surveyext.Edit(p.editorCmd, "*.md", defaultValue, p.stdin, p.stdout, p.stderr) if err != nil { return "", err } return text, nil } func (p *accessiblePrompter) MultiSelectWithSearch(prompt, searchPrompt string, defaultValues, persistentValues []string, searchFunc func(string) MultiSelectSearchResult) ([]string, error) { return multiSelectWithSearch(p, prompt, searchPrompt, defaultValues, persistentValues, searchFunc) } type surveyPrompter struct { prompter *ghPrompter.Prompter stdin ghPrompter.FileReader stdout ghPrompter.FileWriter stderr ghPrompter.FileWriter editorCmd string } func (p *surveyPrompter) Select(prompt, defaultValue string, options []string) (int, error) { return p.prompter.Select(prompt, defaultValue, options) } func (p *surveyPrompter) MultiSelect(prompt string, defaultValues, options []string) ([]int, error) { return p.prompter.MultiSelect(prompt, defaultValues, options) } func (p *surveyPrompter) MultiSelectWithSearch(prompt string, searchPrompt string, defaultValues, persistentValues []string, searchFunc func(string) MultiSelectSearchResult) ([]string, error) { return multiSelectWithSearch(p, prompt, searchPrompt, defaultValues, persistentValues, searchFunc) } type MultiSelectSearchResult struct { Keys []string Labels []string MoreResults int Err error } func multiSelectWithSearch(p Prompter, prompt, searchPrompt string, defaultValues, persistentValues []string, searchFunc func(string) MultiSelectSearchResult) ([]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 } searchResult := searchFunc("") if searchResult.Err != nil { return nil, fmt.Errorf("failed to search: %w", searchResult.Err) } searchResultKeys := searchResult.Keys searchResultLabels := searchResult.Labels moreResults := searchResult.MoreResults 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 } searchResult := searchFunc(query) if searchResult.Err != nil { return nil, searchResult.Err } searchResultKeys = searchResult.Keys searchResultLabels = searchResult.Labels moreResults = searchResult.MoreResults 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) } func (p *surveyPrompter) Password(prompt string) (string, error) { return p.prompter.Password(prompt) } func (p *surveyPrompter) Confirm(prompt string, defaultValue bool) (bool, error) { return p.prompter.Confirm(prompt, defaultValue) } func (p *surveyPrompter) AuthToken() (string, error) { var result string err := p.ask(&survey.Password{ Message: "Paste your authentication token:", }, &result, survey.WithValidator(survey.Required)) return result, err } func (p *surveyPrompter) ConfirmDeletion(requiredValue string) error { var result string return p.ask( &survey.Input{ Message: fmt.Sprintf("Type %s to confirm deletion:", requiredValue), }, &result, survey.WithValidator( func(val interface{}) error { if str := val.(string); !strings.EqualFold(str, requiredValue) { return fmt.Errorf("You entered %s", str) } return nil })) } func (p *surveyPrompter) InputHostname() (string, error) { var result string err := p.ask( &survey.Input{ Message: "Hostname:", }, &result, survey.WithValidator(func(v interface{}) error { return ghinstance.HostnameValidator(v.(string)) })) return result, err } func (p *surveyPrompter) MarkdownEditor(prompt, defaultValue string, blankAllowed bool) (string, error) { var result string err := p.ask(&surveyext.GhEditor{ BlankAllowed: blankAllowed, EditorCommand: p.editorCmd, Editor: &survey.Editor{ Message: prompt, Default: defaultValue, FileName: "*.md", HideDefault: true, AppendDefault: true, }, }, &result) return result, err } func (p *surveyPrompter) ask(q survey.Prompt, response interface{}, opts ...survey.AskOpt) error { opts = append(opts, survey.WithStdio(p.stdin, p.stdout, p.stderr)) err := survey.AskOne(q, response, opts...) if err == nil { return nil } return fmt.Errorf("could not prompt: %w", err) }